summaryrefslogtreecommitdiff
path: root/java
diff options
context:
space:
mode:
Diffstat (limited to 'java')
-rw-r--r--java/res/drawable/checkbox.xml10
-rw-r--r--java/res/drawable/chooser_direct_share_label_placeholder.xml37
-rw-r--r--java/res/drawable/ic_file_video.xml3
-rw-r--r--java/res/drawable/ic_play_circle_filled_24px.xml3
-rw-r--r--java/res/drawable/resolver_profile_tab_bg.xml2
-rw-r--r--java/res/layout-h480dp/image_preview_loading_item.xml32
-rw-r--r--java/res/layout-h480dp/image_preview_other_item.xml31
-rw-r--r--java/res/layout/chooser_action_row.xml5
-rw-r--r--java/res/layout/chooser_action_view.xml3
-rw-r--r--java/res/layout/chooser_grid_item.xml70
-rw-r--r--java/res/layout/chooser_grid_preview_file.xml2
-rw-r--r--java/res/layout/chooser_grid_preview_files_text.xml3
-rw-r--r--java/res/layout/chooser_grid_preview_image.xml8
-rw-r--r--java/res/layout/chooser_grid_preview_text.xml3
-rw-r--r--java/res/layout/chooser_grid_scrollable_preview.xml (renamed from java/res/layout/chooser_grid.xml)76
-rw-r--r--java/res/layout/chooser_headline_row.xml23
-rw-r--r--java/res/layout/chooser_list_per_profile_wrap.xml (renamed from java/res/layout/chooser_list_per_profile.xml)13
-rw-r--r--java/res/layout/image_preview_image_item.xml4
-rw-r--r--java/res/layout/image_preview_loading_item.xml33
-rw-r--r--java/res/layout/image_preview_other_item.xml10
-rw-r--r--java/res/layout/resolver_empty_states.xml16
-rw-r--r--java/res/values-af/strings.xml7
-rw-r--r--java/res/values-am/strings.xml7
-rw-r--r--java/res/values-ar/strings.xml7
-rw-r--r--java/res/values-as/strings.xml7
-rw-r--r--java/res/values-az/strings.xml7
-rw-r--r--java/res/values-b+sr+Latn/strings.xml7
-rw-r--r--java/res/values-be/strings.xml7
-rw-r--r--java/res/values-bg/strings.xml7
-rw-r--r--java/res/values-bn/strings.xml7
-rw-r--r--java/res/values-bs/strings.xml9
-rw-r--r--java/res/values-ca/strings.xml7
-rw-r--r--java/res/values-cs/strings.xml7
-rw-r--r--java/res/values-da/strings.xml7
-rw-r--r--java/res/values-de/strings.xml7
-rw-r--r--java/res/values-el/strings.xml7
-rw-r--r--java/res/values-en-rAU/strings.xml7
-rw-r--r--java/res/values-en-rCA/strings.xml7
-rw-r--r--java/res/values-en-rGB/strings.xml7
-rw-r--r--java/res/values-en-rIN/strings.xml7
-rw-r--r--java/res/values-en-rXC/strings.xml7
-rw-r--r--java/res/values-es-rUS/strings.xml7
-rw-r--r--java/res/values-es/strings.xml7
-rw-r--r--java/res/values-et/strings.xml7
-rw-r--r--java/res/values-eu/strings.xml9
-rw-r--r--java/res/values-fa/strings.xml7
-rw-r--r--java/res/values-fi/strings.xml7
-rw-r--r--java/res/values-fr-rCA/strings.xml7
-rw-r--r--java/res/values-fr/strings.xml7
-rw-r--r--java/res/values-gl/strings.xml7
-rw-r--r--java/res/values-gu/strings.xml7
-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.xml7
-rw-r--r--java/res/values-hr/strings.xml7
-rw-r--r--java/res/values-hu/strings.xml7
-rw-r--r--java/res/values-hy/strings.xml7
-rw-r--r--java/res/values-in/strings.xml7
-rw-r--r--java/res/values-is/strings.xml7
-rw-r--r--java/res/values-it/strings.xml7
-rw-r--r--java/res/values-iw/strings.xml7
-rw-r--r--java/res/values-ja/strings.xml9
-rw-r--r--java/res/values-ka/strings.xml7
-rw-r--r--java/res/values-kk/strings.xml7
-rw-r--r--java/res/values-km/strings.xml7
-rw-r--r--java/res/values-kn/strings.xml7
-rw-r--r--java/res/values-ko/strings.xml7
-rw-r--r--java/res/values-ky/strings.xml7
-rw-r--r--java/res/values-lo/strings.xml7
-rw-r--r--java/res/values-lt/strings.xml7
-rw-r--r--java/res/values-lv/strings.xml9
-rw-r--r--java/res/values-mk/strings.xml7
-rw-r--r--java/res/values-ml/strings.xml7
-rw-r--r--java/res/values-mn/strings.xml7
-rw-r--r--java/res/values-mr/strings.xml7
-rw-r--r--java/res/values-ms/strings.xml7
-rw-r--r--java/res/values-my/strings.xml7
-rw-r--r--java/res/values-nb/strings.xml7
-rw-r--r--java/res/values-ne/strings.xml7
-rw-r--r--java/res/values-night/styles.xml22
-rw-r--r--java/res/values-nl/strings.xml7
-rw-r--r--java/res/values-or/strings.xml7
-rw-r--r--java/res/values-pa/strings.xml7
-rw-r--r--java/res/values-pl/strings.xml7
-rw-r--r--java/res/values-port/dimens.xml18
-rw-r--r--java/res/values-pt-rBR/strings.xml7
-rw-r--r--java/res/values-pt-rPT/strings.xml7
-rw-r--r--java/res/values-pt/strings.xml7
-rw-r--r--java/res/values-ro/strings.xml7
-rw-r--r--java/res/values-ru/strings.xml7
-rw-r--r--java/res/values-si/strings.xml7
-rw-r--r--java/res/values-sk/strings.xml7
-rw-r--r--java/res/values-sl/strings.xml7
-rw-r--r--java/res/values-sq/strings.xml7
-rw-r--r--java/res/values-sr/strings.xml7
-rw-r--r--java/res/values-sv/strings.xml7
-rw-r--r--java/res/values-sw/strings.xml7
-rw-r--r--java/res/values-ta/strings.xml7
-rw-r--r--java/res/values-te/strings.xml7
-rw-r--r--java/res/values-th/strings.xml7
-rw-r--r--java/res/values-tl/strings.xml7
-rw-r--r--java/res/values-tr/strings.xml7
-rw-r--r--java/res/values-uk/strings.xml7
-rw-r--r--java/res/values-ur/strings.xml7
-rw-r--r--java/res/values-uz/strings.xml7
-rw-r--r--java/res/values-vi/strings.xml7
-rw-r--r--java/res/values-zh-rCN/strings.xml7
-rw-r--r--java/res/values-zh-rHK/strings.xml7
-rw-r--r--java/res/values-zh-rTW/strings.xml7
-rw-r--r--java/res/values-zu/strings.xml7
-rw-r--r--java/res/values/attrs.xml5
-rw-r--r--java/res/values/dimens.xml6
-rw-r--r--java/res/values/integers.xml2
-rw-r--r--java/res/values/strings.xml34
-rw-r--r--java/res/values/styles.xml3
-rw-r--r--java/src-debug/com/android/intentresolver/flags/DebugFeatureFlagRepository.kt81
-rw-r--r--java/src-release/com/android/intentresolver/flags/ReleaseFeatureFlagRepository.kt31
-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.java161
-rw-r--r--java/src/com/android/intentresolver/ChooserActivity.java2233
-rw-r--r--java/src/com/android/intentresolver/ChooserGridLayoutManager.java2
-rw-r--r--java/src/com/android/intentresolver/ChooserHelper.kt190
-rw-r--r--java/src/com/android/intentresolver/ChooserIntegratedDeviceComponents.java83
-rw-r--r--java/src/com/android/intentresolver/ChooserListAdapter.java342
-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.java43
-rw-r--r--java/src/com/android/intentresolver/ChooserSelector.kt52
-rw-r--r--java/src/com/android/intentresolver/ChooserStackedAppDialogFragment.java4
-rw-r--r--java/src/com/android/intentresolver/ChooserTargetActionsDialogFragment.java6
-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.java14
-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.kt (renamed from java/tests/src/com/android/intentresolver/TestApplication.kt)17
-rw-r--r--java/src/com/android/intentresolver/ProfileAvailability.kt103
-rw-r--r--java/src/com/android/intentresolver/ProfileHelper.kt97
-rw-r--r--java/src/com/android/intentresolver/ResolvedComponentInfo.java4
-rw-r--r--java/src/com/android/intentresolver/ResolverActivity.java1718
-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.java257
-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/SecureSettings.kt4
-rw-r--r--java/src/com/android/intentresolver/ShortcutSelectionLogic.java20
-rw-r--r--java/src/com/android/intentresolver/SimpleIconFactory.java20
-rw-r--r--java/src/com/android/intentresolver/StartsSelectedItem.kt21
-rw-r--r--java/src/com/android/intentresolver/TargetPresentationGetter.java5
-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.java42
-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.java3
-rw-r--r--java/src/com/android/intentresolver/chooser/TargetInfo.java19
-rw-r--r--java/src/com/android/intentresolver/contentpreview/BasePreviewViewModel.kt16
-rw-r--r--java/src/com/android/intentresolver/contentpreview/CachingImagePreviewImageLoader.kt110
-rw-r--r--java/src/com/android/intentresolver/contentpreview/ChooserContentPreviewUi.java121
-rw-r--r--java/src/com/android/intentresolver/contentpreview/ContentPreviewType.java6
-rw-r--r--java/src/com/android/intentresolver/contentpreview/ContentPreviewUi.java79
-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.java94
-rw-r--r--java/src/com/android/intentresolver/contentpreview/HeadlineGenerator.kt6
-rw-r--r--java/src/com/android/intentresolver/contentpreview/HeadlineGeneratorImpl.kt67
-rw-r--r--java/src/com/android/intentresolver/contentpreview/ImageLoader.kt4
-rw-r--r--java/src/com/android/intentresolver/contentpreview/ImageLoaderModule.kt50
-rw-r--r--java/src/com/android/intentresolver/contentpreview/ImagePreviewImageLoader.kt40
-rw-r--r--java/src/com/android/intentresolver/contentpreview/IsHttpUri.kt11
-rw-r--r--java/src/com/android/intentresolver/contentpreview/JavaFlowHelper.kt51
-rw-r--r--java/src/com/android/intentresolver/contentpreview/NoContextPreviewUi.kt6
-rw-r--r--java/src/com/android/intentresolver/contentpreview/PreviewDataProvider.kt274
-rw-r--r--java/src/com/android/intentresolver/contentpreview/PreviewViewModel.kt81
-rw-r--r--java/src/com/android/intentresolver/contentpreview/ShareouselContentPreviewUi.kt106
-rw-r--r--java/src/com/android/intentresolver/contentpreview/TextContentPreviewUi.java64
-rw-r--r--java/src/com/android/intentresolver/contentpreview/ThumbnailLoader.kt40
-rw-r--r--java/src/com/android/intentresolver/contentpreview/UnifiedContentPreviewUi.java83
-rw-r--r--java/src/com/android/intentresolver/contentpreview/UriMetadataHelpers.kt138
-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.kt28
-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.kt80
-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.kt337
-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.kt75
-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.kt42
-rw-r--r--java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/interactor/SelectablePreviewsInteractor.kt40
-rw-r--r--java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/interactor/SelectionInteractor.kt86
-rw-r--r--java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/interactor/SetCursorPreviewsInteractor.kt59
-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.kt63
-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.kt102
-rw-r--r--java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/model/ShareouselUpdate.kt34
-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.kt173
-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/PreviewModel.kt30
-rw-r--r--java/src/com/android/intentresolver/contentpreview/payloadtoggle/shared/model/PreviewsModel.kt35
-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.kt106
-rw-r--r--java/src/com/android/intentresolver/contentpreview/payloadtoggle/ui/composable/ShareouselComposable.kt220
-rw-r--r--java/src/com/android/intentresolver/contentpreview/payloadtoggle/ui/viewmodel/ActionChipViewModel.kt (renamed from java/src-debug/com/android/intentresolver/flags/FeatureFlagRepositoryFactory.kt)25
-rw-r--r--java/src/com/android/intentresolver/contentpreview/payloadtoggle/ui/viewmodel/ShareouselPreviewViewModel.kt34
-rw-r--r--java/src/com/android/intentresolver/contentpreview/payloadtoggle/ui/viewmodel/ShareouselViewModel.kt142
-rw-r--r--java/src/com/android/intentresolver/data/BroadcastSubscriber.kt73
-rw-r--r--java/src/com/android/intentresolver/data/model/ChooserRequest.kt195
-rw-r--r--java/src/com/android/intentresolver/data/repository/ChooserRequestRepository.kt39
-rw-r--r--java/src/com/android/intentresolver/data/repository/DevicePolicyResources.kt153
-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/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.kt34
-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.kt31
-rw-r--r--java/src/com/android/intentresolver/grid/ChooserGridAdapter.java139
-rw-r--r--java/src/com/android/intentresolver/icon/ComposeIcon.kt88
-rw-r--r--java/src/com/android/intentresolver/icons/CachingTargetDataLoader.kt76
-rw-r--r--java/src/com/android/intentresolver/icons/DefaultTargetDataLoader.kt18
-rw-r--r--java/src/com/android/intentresolver/icons/LabelInfo.kt19
-rw-r--r--java/src/com/android/intentresolver/icons/LoadDirectShareIconTask.java2
-rw-r--r--java/src/com/android/intentresolver/icons/LoadIconTask.java4
-rw-r--r--java/src/com/android/intentresolver/icons/LoadLabelTask.java39
-rw-r--r--java/src/com/android/intentresolver/icons/TargetDataLoader.kt14
-rw-r--r--java/src/com/android/intentresolver/icons/TargetDataLoaderModule.kt44
-rw-r--r--java/src/com/android/intentresolver/inject/ActivityModelModule.kt127
-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/FeatureFlagsModule.kt41
-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/logging/EventLog.kt74
-rw-r--r--java/src/com/android/intentresolver/logging/EventLogImpl.java (renamed from java/src/com/android/intentresolver/ChooserActivityLogger.java)178
-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.java64
-rw-r--r--java/src/com/android/intentresolver/model/AppPredictionServiceResolverComparator.java85
-rw-r--r--java/src/com/android/intentresolver/model/ResolveInfoAzInfoComparator.java44
-rw-r--r--java/src/com/android/intentresolver/model/ResolverRankerServiceResolverComparator.java63
-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/PlatformSecureSettings.kt46
-rw-r--r--java/src/com/android/intentresolver/platform/SecureSettings.kt41
-rw-r--r--java/src/com/android/intentresolver/platform/SecureSettingsModule.kt30
-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)93
-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/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.kt32
-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.kt163
-rw-r--r--java/src/com/android/intentresolver/ui/ShortcutPolicyModule.kt94
-rw-r--r--java/src/com/android/intentresolver/ui/model/ActivityModel.kt81
-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.kt198
-rw-r--r--java/src/com/android/intentresolver/ui/viewmodel/ChooserViewModel.kt94
-rw-r--r--java/src/com/android/intentresolver/ui/viewmodel/ResolverRequestReader.kt59
-rw-r--r--java/src/com/android/intentresolver/ui/viewmodel/ResolverViewModel.kt70
-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.kt50
-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/validation/Findings.kt120
-rw-r--r--java/src/com/android/intentresolver/validation/Validation.kt137
-rw-r--r--java/src/com/android/intentresolver/validation/ValidationResult.kt (renamed from java/tests/src/com/android/intentresolver/TestLifecycleOwner.kt)23
-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.kt106
-rw-r--r--java/src/com/android/intentresolver/widget/ImagePreviewView.kt13
-rw-r--r--java/src/com/android/intentresolver/widget/RecyclerViewExtensions.kt8
-rw-r--r--java/src/com/android/intentresolver/widget/ResolverDrawerLayout.java101
-rw-r--r--java/src/com/android/intentresolver/widget/ScrollableImagePreviewView.kt388
-rw-r--r--java/src/com/android/intentresolver/widget/ViewExtensions.kt27
-rw-r--r--java/tests/Android.bp39
-rw-r--r--java/tests/AndroidManifest.xml43
-rw-r--r--java/tests/AndroidTest.xml28
-rw-r--r--java/tests/res/drawable/test320x240.pngbin39533 -> 0 bytes
-rw-r--r--java/tests/src/com/android/intentresolver/AnnotatedUserHandlesTest.kt79
-rw-r--r--java/tests/src/com/android/intentresolver/ChooserActionFactoryTest.kt224
-rw-r--r--java/tests/src/com/android/intentresolver/ChooserActivityLoggerTest.java422
-rw-r--r--java/tests/src/com/android/intentresolver/ChooserActivityOverrideData.java134
-rw-r--r--java/tests/src/com/android/intentresolver/ChooserIntegratedDeviceComponentsTest.kt71
-rw-r--r--java/tests/src/com/android/intentresolver/ChooserListAdapterTest.kt174
-rw-r--r--java/tests/src/com/android/intentresolver/ChooserRefinementManagerTest.kt242
-rw-r--r--java/tests/src/com/android/intentresolver/ChooserRequestParametersTest.kt88
-rw-r--r--java/tests/src/com/android/intentresolver/ChooserWrapperActivity.java293
-rw-r--r--java/tests/src/com/android/intentresolver/EnterTransitionAnimationDelegateTest.kt112
-rw-r--r--java/tests/src/com/android/intentresolver/FeatureFlagRule.kt56
-rw-r--r--java/tests/src/com/android/intentresolver/IChooserWrapper.java46
-rw-r--r--java/tests/src/com/android/intentresolver/MatcherUtils.java51
-rw-r--r--java/tests/src/com/android/intentresolver/MockitoKotlinHelpers.kt149
-rw-r--r--java/tests/src/com/android/intentresolver/ResolverActivityTest.java1100
-rw-r--r--java/tests/src/com/android/intentresolver/ResolverDataProvider.java255
-rw-r--r--java/tests/src/com/android/intentresolver/ResolverWrapperActivity.java294
-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/TestContentProvider.kt55
-rw-r--r--java/tests/src/com/android/intentresolver/TestHelpers.kt71
-rw-r--r--java/tests/src/com/android/intentresolver/TestPreviewImageLoader.kt33
-rw-r--r--java/tests/src/com/android/intentresolver/UnbundledChooserActivityTest.java2971
-rw-r--r--java/tests/src/com/android/intentresolver/UnbundledChooserActivityWorkProfileTest.java473
-rw-r--r--java/tests/src/com/android/intentresolver/chooser/ImmutableTargetInfoTest.kt503
-rw-r--r--java/tests/src/com/android/intentresolver/chooser/TargetInfoTest.kt399
-rw-r--r--java/tests/src/com/android/intentresolver/contentpreview/ChooserContentPreviewUiTest.kt146
-rw-r--r--java/tests/src/com/android/intentresolver/contentpreview/ContentPreviewUiTest.kt41
-rw-r--r--java/tests/src/com/android/intentresolver/contentpreview/HeadlineGeneratorImplTest.kt61
-rw-r--r--java/tests/src/com/android/intentresolver/contentpreview/ImagePreviewImageLoaderTest.kt366
-rw-r--r--java/tests/src/com/android/intentresolver/contentpreview/PreviewDataProviderTest.kt351
-rw-r--r--java/tests/src/com/android/intentresolver/model/AbstractResolverComparatorTest.java141
-rw-r--r--java/tests/src/com/android/intentresolver/shortcuts/ShortcutLoaderTest.kt463
-rw-r--r--java/tests/src/com/android/intentresolver/shortcuts/ShortcutToChooserTargetConverterTest.kt177
-rw-r--r--java/tests/src/com/android/intentresolver/util/UriFiltersTest.kt95
-rw-r--r--java/tests/src/com/android/intentresolver/widget/BatchPreviewLoaderTest.kt215
376 files changed, 15611 insertions, 16115 deletions
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/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/ic_file_video.xml b/java/res/drawable/ic_file_video.xml
index ec6e290b..3156c55a 100644
--- a/java/res/drawable/ic_file_video.xml
+++ b/java/res/drawable/ic_file_video.xml
@@ -23,5 +23,6 @@
<path
android:fillColor="@android:color/white"
- android:pathData="m4,20c-0.55,0 -1.02,-0.19 -1.42,-0.57c-0.39,-0.4 -0.58,-0.88 -0.58,-1.43l0,-12c0,-0.55 0.19,-1.02 0.58,-1.4c0.39,-0.4 0.87,-0.6 1.42,-0.6l12,0c0.55,0 1.02,0.2 1.4,0.6c0.4,0.38 0.6,0.85 0.6,1.4l0,4.5l4,-4l0,11l-4,-4l0,4.5c0,0.55 -0.2,1.03 -0.6,1.43c-0.38,0.38 -0.85,0.57 -1.4,0.57l-12,0zm0,-2l12,0l0,-12l-12,0l0,12zm0,0l0,-12l0,12z"/>
+ android:pathData="M2,12C2,6.48 6.48,2 12,2C17.52,2 22,6.48 22,12C22,17.52 17.52,22 12,22C6.48,22 2,17.52 2,12ZM16,12L10,7.5V16.5L16,12Z"
+ android:fillType="evenOdd"/>
</vector>
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/resolver_profile_tab_bg.xml b/java/res/drawable/resolver_profile_tab_bg.xml
index 8bb23a53..97f3b7e2 100644
--- a/java/res/drawable/resolver_profile_tab_bg.xml
+++ b/java/res/drawable/resolver_profile_tab_bg.xml
@@ -25,7 +25,7 @@
</item>
<item>
- <selector android:enterFadeDuration="100">
+ <selector>
<item android:state_selected="false">
<shape android:shape="rectangle">
<corners android:radius="12dp" />
diff --git a/java/res/layout-h480dp/image_preview_loading_item.xml b/java/res/layout-h480dp/image_preview_loading_item.xml
new file mode 100644
index 00000000..85020e9a
--- /dev/null
+++ b/java/res/layout-h480dp/image_preview_loading_item.xml
@@ -0,0 +1,32 @@
+<!--
+ ~ 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.
+ -->
+
+<FrameLayout
+ xmlns:android="http://schemas.android.com/apk/res/android"
+ xmlns:androidprv="http://schemas.android.com/apk/prv/res/android"
+ android:layout_width="@dimen/chooser_preview_image_width"
+ android:layout_height="@dimen/chooser_preview_image_height_tall">
+
+ <ProgressBar
+ android:id="@+id/loading_indicator"
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:layout_gravity="center"
+ android:indeterminate="true"
+ android:indeterminateTint="?androidprv:attr/materialColorPrimary"
+ android:indeterminateTintMode="src_in" />
+
+</FrameLayout>
diff --git a/java/res/layout-h480dp/image_preview_other_item.xml b/java/res/layout-h480dp/image_preview_other_item.xml
new file mode 100644
index 00000000..470f105a
--- /dev/null
+++ b/java/res/layout-h480dp/image_preview_other_item.xml
@@ -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.
+ -->
+
+<FrameLayout
+ xmlns:android="http://schemas.android.com/apk/res/android"
+ android:layout_width="@dimen/chooser_preview_image_width"
+ android:layout_height="@dimen/chooser_preview_image_height_tall">
+
+ <TextView
+ android:id="@+id/label"
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:layout_gravity="center"
+ android:drawableTop="@drawable/ic_file_copy"
+ android:drawablePadding="8dp"
+ android:textAppearance="@style/TextAppearance.ChooserDefault" />
+
+</FrameLayout> \ No newline at end of file
diff --git a/java/res/layout/chooser_action_row.xml b/java/res/layout/chooser_action_row.xml
index 55d6adf7..7bce113e 100644
--- a/java/res/layout/chooser_action_row.xml
+++ b/java/res/layout/chooser_action_row.xml
@@ -20,10 +20,9 @@
<com.android.intentresolver.widget.ScrollableActionRow
android:id="@androidprv:id/chooser_action_row"
- android:layout_width="match_parent"
+ android:layout_width="wrap_content"
android:layout_height="wrap_content"
- android:layout_gravity="center_horizontal"
- android:gravity="center"/>
+ android:layout_gravity="center_horizontal" />
<View
android:layout_width="match_parent"
diff --git a/java/res/layout/chooser_action_view.xml b/java/res/layout/chooser_action_view.xml
index ba9134cc..d045a7e3 100644
--- a/java/res/layout/chooser_action_view.xml
+++ b/java/res/layout/chooser_action_view.xml
@@ -18,7 +18,7 @@
xmlns:androidprv="http://schemas.android.com/apk/prv/res/android"
style="?android:attr/borderlessButtonStyle"
android:background="@drawable/chooser_action_button_bg"
- android:paddingBottom="8dp"
+ android:paddingVertical="15dp"
android:paddingHorizontal="@dimen/chooser_edge_margin_normal_half"
android:clickable="true"
android:drawablePadding="6dp"
@@ -27,6 +27,5 @@
android:ellipsize="end"
android:gravity="center"
android:maxLines="1"
- android:maxWidth="@dimen/chooser_action_max_width"
android:textColor="?androidprv:attr/materialColorOnSurface"
android:textSize="12sp" />
diff --git a/java/res/layout/chooser_grid_item.xml b/java/res/layout/chooser_grid_item.xml
new file mode 100644
index 00000000..18abc7bc
--- /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:attr/materialColorOnSurface"
+ android:textSize="12sp"
+ 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="12sp"
+ android:textColor="?androidprv:attr/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_preview_file.xml b/java/res/layout/chooser_grid_preview_file.xml
index 3c836b4c..4e8cf7ba 100644
--- a/java/res/layout/chooser_grid_preview_file.xml
+++ b/java/res/layout/chooser_grid_preview_file.xml
@@ -26,8 +26,6 @@
android:orientation="vertical"
android:background="?androidprv:attr/materialColorSurfaceContainer">
- <include layout="@layout/chooser_headline_row"/>
-
<RelativeLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
diff --git a/java/res/layout/chooser_grid_preview_files_text.xml b/java/res/layout/chooser_grid_preview_files_text.xml
index c64d7ddd..65c62f82 100644
--- a/java/res/layout/chooser_grid_preview_files_text.xml
+++ b/java/res/layout/chooser_grid_preview_files_text.xml
@@ -25,8 +25,6 @@
android:orientation="vertical"
android:background="?androidprv:attr/materialColorSurfaceContainer">
- <include layout="@layout/chooser_headline_row" />
-
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
@@ -55,6 +53,7 @@
android:maxLines="@integer/text_preview_lines"
android:ellipsize="end"
android:linksClickable="false"
+ android:textColor="?androidprv:attr/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..4745e04c 100644
--- a/java/res/layout/chooser_grid_preview_image.xml
+++ b/java/res/layout/chooser_grid_preview_image.xml
@@ -26,7 +26,13 @@
android:importantForAccessibility="no"
android:background="?androidprv:attr/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"
diff --git a/java/res/layout/chooser_grid_preview_text.xml b/java/res/layout/chooser_grid_preview_text.xml
index df906cce..ee54c0ae 100644
--- a/java/res/layout/chooser_grid_preview_text.xml
+++ b/java/res/layout/chooser_grid_preview_text.xml
@@ -27,8 +27,6 @@
android:orientation="vertical"
android:background="?androidprv:attr/materialColorSurfaceContainer">
- <include layout="@layout/chooser_headline_row" />
-
<androidx.constraintlayout.widget.ConstraintLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
@@ -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..c1bcf912 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,67 @@
</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:attr/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: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:attr/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..bfce7473 100644
--- a/java/res/layout/chooser_headline_row.xml
+++ b/java/res/layout/chooser_headline_row.xml
@@ -38,6 +38,21 @@
android:textSize="18sp"
/>
+ <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
android:id="@+id/barrier"
android:layout_width="wrap_content"
@@ -50,13 +65,17 @@
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:attr/materialColorOnSurface"
+ android:textSize="12sp"
/>
<!-- This is only relevant for image+text preview, but needs to be in this layout so it can
diff --git a/java/res/layout/chooser_list_per_profile.xml b/java/res/layout/chooser_list_per_profile_wrap.xml
index ef82090c..fc0431d7 100644
--- a/java/res/layout/chooser_list_per_profile.xml
+++ b/java/res/layout/chooser_list_per_profile_wrap.xml
@@ -18,16 +18,23 @@
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"
+ android:descendantFocusability="blocksDescendants">
+ <!-- ^^^ Block descendants from receiving focus to prevent NestedScrollView
+ (ChooserNestedScrollView) scrolling to the focused view when switching tabs. Without it, TabHost
+ view will request focus on the newly activated tab. The RecyclerView from this layout 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. -->
+
<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:scrollbars="none"
- android:elevation="1dp"
android:nestedScrollingEnabled="true" />
<include layout="@layout/resolver_empty_states" />
diff --git a/java/res/layout/image_preview_image_item.xml b/java/res/layout/image_preview_image_item.xml
index 3f534831..442e9345 100644
--- a/java/res/layout/image_preview_image_item.xml
+++ b/java/res/layout/image_preview_image_item.xml
@@ -18,8 +18,8 @@
<androidx.constraintlayout.widget.ConstraintLayout
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
- android:layout_width="46dp"
- android:layout_height="46dp">
+ android:layout_width="@dimen/chooser_preview_image_height_tall"
+ android:layout_height="@dimen/chooser_preview_image_height_tall">
<com.android.intentresolver.widget.RoundedRectImageView
android:id="@+id/image"
diff --git a/java/res/layout/image_preview_loading_item.xml b/java/res/layout/image_preview_loading_item.xml
new file mode 100644
index 00000000..a8a8f264
--- /dev/null
+++ b/java/res/layout/image_preview_loading_item.xml
@@ -0,0 +1,33 @@
+<!--
+ ~ 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.
+ -->
+
+<FrameLayout
+ xmlns:android="http://schemas.android.com/apk/res/android"
+ xmlns:androidprv="http://schemas.android.com/apk/prv/res/android"
+ android:layout_width="wrap_content"
+ android:layout_height="@dimen/chooser_preview_image_height_tall"
+ android:padding="8dp">
+
+ <ProgressBar
+ android:id="@+id/loading_indicator"
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:layout_gravity="center"
+ android:indeterminate="true"
+ android:indeterminateTint="?androidprv:attr/materialColorPrimary"
+ android:indeterminateTintMode="src_in" />
+
+</FrameLayout>
diff --git a/java/res/layout/image_preview_other_item.xml b/java/res/layout/image_preview_other_item.xml
index 07f87e3a..db458656 100644
--- a/java/res/layout/image_preview_other_item.xml
+++ b/java/res/layout/image_preview_other_item.xml
@@ -16,16 +16,18 @@
<FrameLayout
xmlns:android="http://schemas.android.com/apk/res/android"
- android:layout_width="@dimen/chooser_preview_image_width"
- android:layout_height="@dimen/chooser_preview_image_height_tall">
+ android:layout_width="wrap_content"
+ android:layout_height="@dimen/chooser_preview_image_height_tall"
+ android:paddingHorizontal="@dimen/chooser_edge_margin_normal_half">
<TextView
android:id="@+id/label"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center"
- android:drawableTop="@drawable/ic_file_copy"
- android:drawablePadding="8dp"
+ android:drawableStart="@drawable/ic_file_copy"
+ android:drawablePadding="4dp"
+ android:gravity="bottom"
android:textAppearance="@style/TextAppearance.ChooserDefault" />
</FrameLayout>
diff --git a/java/res/layout/resolver_empty_states.xml b/java/res/layout/resolver_empty_states.xml
index d77630ee..0cf6e955 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:attr/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/values-af/strings.xml b/java/res/values-af/strings.xml
index 23dca362..52063f5b 100644
--- a/java/res/values-af/strings.xml
+++ b/java/res/values-af/strings.xml
@@ -66,6 +66,7 @@
<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>
@@ -76,17 +77,22 @@
<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="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 +101,5 @@
<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>
</resources>
diff --git a/java/res/values-am/strings.xml b/java/res/values-am/strings.xml
index d381cfd1..d1d581a2 100644
--- a/java/res/values-am/strings.xml
+++ b/java/res/values-am/strings.xml
@@ -66,6 +66,7 @@
<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 +77,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 +101,5 @@
<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>
</resources>
diff --git a/java/res/values-ar/strings.xml b/java/res/values-ar/strings.xml
index 049f1b49..76e74b83 100644
--- a/java/res/values-ar/strings.xml
+++ b/java/res/values-ar/strings.xml
@@ -66,6 +66,7 @@
<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>
@@ -76,17 +77,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 +101,5 @@
<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>
</resources>
diff --git a/java/res/values-as/strings.xml b/java/res/values-as/strings.xml
index 93d8d10f..c732113a 100644
--- a/java/res/values-as/strings.xml
+++ b/java/res/values-as/strings.xml
@@ -66,6 +66,7 @@
<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 +77,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 +101,5 @@
<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>
</resources>
diff --git a/java/res/values-az/strings.xml b/java/res/values-az/strings.xml
index 8e58d593..d5d6b75f 100644
--- a/java/res/values-az/strings.xml
+++ b/java/res/values-az/strings.xml
@@ -66,6 +66,7 @@
<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 +77,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 +101,5 @@
<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>
</resources>
diff --git a/java/res/values-b+sr+Latn/strings.xml b/java/res/values-b+sr+Latn/strings.xml
index b772afec..99339496 100644
--- a/java/res/values-b+sr+Latn/strings.xml
+++ b/java/res/values-b+sr+Latn/strings.xml
@@ -66,6 +66,7 @@
<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 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>
@@ -76,17 +77,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 +101,5 @@
<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>
</resources>
diff --git a/java/res/values-be/strings.xml b/java/res/values-be/strings.xml
index 7f0a3939..6f9190bc 100644
--- a/java/res/values-be/strings.xml
+++ b/java/res/values-be/strings.xml
@@ -66,6 +66,7 @@
<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 +77,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 +101,5 @@
<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>
</resources>
diff --git a/java/res/values-bg/strings.xml b/java/res/values-bg/strings.xml
index 8ee68f9f..f16e5486 100644
--- a/java/res/values-bg/strings.xml
+++ b/java/res/values-bg/strings.xml
@@ -66,6 +66,7 @@
<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 +77,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 +101,5 @@
<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>
</resources>
diff --git a/java/res/values-bn/strings.xml b/java/res/values-bn/strings.xml
index d940b770..41778ade 100644
--- a/java/res/values-bn/strings.xml
+++ b/java/res/values-bn/strings.xml
@@ -66,6 +66,7 @@
<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 +77,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 +101,5 @@
<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>
</resources>
diff --git a/java/res/values-bs/strings.xml b/java/res/values-bs/strings.xml
index 35425c84..d8cfc346 100644
--- a/java/res/values-bs/strings.xml
+++ b/java/res/values-bs/strings.xml
@@ -57,7 +57,7 @@
<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{Dijeli se slika}one{Dijeli se # slika}few{Dijele se # slike}other{Dijeli se # slika}}"</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_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="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>
@@ -66,6 +66,7 @@
<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 +77,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 +101,5 @@
<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>
</resources>
diff --git a/java/res/values-ca/strings.xml b/java/res/values-ca/strings.xml
index b1460f48..d3af1d2b 100644
--- a/java/res/values-ca/strings.xml
+++ b/java/res/values-ca/strings.xml
@@ -66,6 +66,7 @@
<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 +77,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 +101,5 @@
<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>
</resources>
diff --git a/java/res/values-cs/strings.xml b/java/res/values-cs/strings.xml
index 06336793..8c3d6f88 100644
--- a/java/res/values-cs/strings.xml
+++ b/java/res/values-cs/strings.xml
@@ -66,6 +66,7 @@
<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 +77,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 +101,5 @@
<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>
</resources>
diff --git a/java/res/values-da/strings.xml b/java/res/values-da/strings.xml
index 57f81416..cc726342 100644
--- a/java/res/values-da/strings.xml
+++ b/java/res/values-da/strings.xml
@@ -66,6 +66,7 @@
<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 +77,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 +101,5 @@
<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>
</resources>
diff --git a/java/res/values-de/strings.xml b/java/res/values-de/strings.xml
index f5de3756..32c8265f 100644
--- a/java/res/values-de/strings.xml
+++ b/java/res/values-de/strings.xml
@@ -66,6 +66,7 @@
<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 +77,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_private_tab_accessibility" msgid="2513122834337197252">"Private Ansicht"</string>
<string name="resolver_cross_profile_blocked" msgid="3515194063758605377">"Von deinem 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 +101,5 @@
<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>
</resources>
diff --git a/java/res/values-el/strings.xml b/java/res/values-el/strings.xml
index b0c4abf5..5e02ab9e 100644
--- a/java/res/values-el/strings.xml
+++ b/java/res/values-el/strings.xml
@@ -66,6 +66,7 @@
<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 +77,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 +101,5 @@
<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>
</resources>
diff --git a/java/res/values-en-rAU/strings.xml b/java/res/values-en-rAU/strings.xml
index c42a5531..28067f40 100644
--- a/java/res/values-en-rAU/strings.xml
+++ b/java/res/values-en-rAU/strings.xml
@@ -66,6 +66,7 @@
<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 +77,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 +101,5 @@
<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>
</resources>
diff --git a/java/res/values-en-rCA/strings.xml b/java/res/values-en-rCA/strings.xml
index c42a5531..de4865f0 100644
--- a/java/res/values-en-rCA/strings.xml
+++ b/java/res/values-en-rCA/strings.xml
@@ -66,6 +66,7 @@
<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 +77,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 +101,5 @@
<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>
</resources>
diff --git a/java/res/values-en-rGB/strings.xml b/java/res/values-en-rGB/strings.xml
index c42a5531..28067f40 100644
--- a/java/res/values-en-rGB/strings.xml
+++ b/java/res/values-en-rGB/strings.xml
@@ -66,6 +66,7 @@
<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 +77,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 +101,5 @@
<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>
</resources>
diff --git a/java/res/values-en-rIN/strings.xml b/java/res/values-en-rIN/strings.xml
index c42a5531..28067f40 100644
--- a/java/res/values-en-rIN/strings.xml
+++ b/java/res/values-en-rIN/strings.xml
@@ -66,6 +66,7 @@
<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 +77,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 +101,5 @@
<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>
</resources>
diff --git a/java/res/values-en-rXC/strings.xml b/java/res/values-en-rXC/strings.xml
index 95a8922d..f02e2c0d 100644
--- a/java/res/values-en-rXC/strings.xml
+++ b/java/res/values-en-rXC/strings.xml
@@ -66,6 +66,7 @@
<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 +77,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 +101,5 @@
<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>
</resources>
diff --git a/java/res/values-es-rUS/strings.xml b/java/res/values-es-rUS/strings.xml
index 7bb36dc1..a881ede8 100644
--- a/java/res/values-es-rUS/strings.xml
+++ b/java/res/values-es-rUS/strings.xml
@@ -66,6 +66,7 @@
<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 +77,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">"Privada"</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 +101,5 @@
<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>
</resources>
diff --git a/java/res/values-es/strings.xml b/java/res/values-es/strings.xml
index 9e28d051..62063d99 100644
--- a/java/res/values-es/strings.xml
+++ b/java/res/values-es/strings.xml
@@ -66,6 +66,7 @@
<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 +77,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">"Privada"</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 +101,5 @@
<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>
</resources>
diff --git a/java/res/values-et/strings.xml b/java/res/values-et/strings.xml
index fc1da949..27d14e00 100644
--- a/java/res/values-et/strings.xml
+++ b/java/res/values-et/strings.xml
@@ -66,6 +66,7 @@
<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 +77,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 +101,5 @@
<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>
</resources>
diff --git a/java/res/values-eu/strings.xml b/java/res/values-eu/strings.xml
index e8a85b96..a77d5db2 100644
--- a/java/res/values-eu/strings.xml
+++ b/java/res/values-eu/strings.xml
@@ -57,7 +57,7 @@
<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 partekatuko da}other{# irudi partekatuko dira}}"</string>
+ <string name="sharing_images" msgid="5251443722186962006">"{count,plural, =1{Irudia partekatzen}other{# irudi partekatzen}}"</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="sharing_images_with_text" msgid="9005717434461730242">"{count,plural, =1{Irudi testudun bat partekatuko da}other{# irudi testudun partekatuko dira}}"</string>
@@ -66,6 +66,7 @@
<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 +77,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 +101,5 @@
<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>
</resources>
diff --git a/java/res/values-fa/strings.xml b/java/res/values-fa/strings.xml
index 010e0bc0..55204e51 100644
--- a/java/res/values-fa/strings.xml
+++ b/java/res/values-fa/strings.xml
@@ -66,6 +66,7 @@
<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 +77,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 +101,5 @@
<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>
</resources>
diff --git a/java/res/values-fi/strings.xml b/java/res/values-fi/strings.xml
index 792746b2..1456ddf5 100644
--- a/java/res/values-fi/strings.xml
+++ b/java/res/values-fi/strings.xml
@@ -66,6 +66,7 @@
<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 +77,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 +101,5 @@
<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>
</resources>
diff --git a/java/res/values-fr-rCA/strings.xml b/java/res/values-fr-rCA/strings.xml
index c0ee0ea6..a8916bb0 100644
--- a/java/res/values-fr-rCA/strings.xml
+++ b/java/res/values-fr-rCA/strings.xml
@@ -66,6 +66,7 @@
<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>
@@ -76,17 +77,22 @@
<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="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_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 applications 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_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 +101,5 @@
<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>
</resources>
diff --git a/java/res/values-fr/strings.xml b/java/res/values-fr/strings.xml
index 68faf9bd..8d19248f 100644
--- a/java/res/values-fr/strings.xml
+++ b/java/res/values-fr/strings.xml
@@ -66,6 +66,7 @@
<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 +77,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 +101,5 @@
<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>
</resources>
diff --git a/java/res/values-gl/strings.xml b/java/res/values-gl/strings.xml
index c782666c..81ab1d16 100644
--- a/java/res/values-gl/strings.xml
+++ b/java/res/values-gl/strings.xml
@@ -66,6 +66,7 @@
<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 +77,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">"Privada"</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 +101,5 @@
<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>
</resources>
diff --git a/java/res/values-gu/strings.xml b/java/res/values-gu/strings.xml
index 70a99e84..5f74da1c 100644
--- a/java/res/values-gu/strings.xml
+++ b/java/res/values-gu/strings.xml
@@ -66,6 +66,7 @@
<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 +77,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 +101,5 @@
<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>
</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..291594fc 100644
--- a/java/res/values-hi/strings.xml
+++ b/java/res/values-hi/strings.xml
@@ -66,6 +66,7 @@
<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 +77,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 +101,5 @@
<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>
</resources>
diff --git a/java/res/values-hr/strings.xml b/java/res/values-hr/strings.xml
index b9d8f867..0d969c40 100644
--- a/java/res/values-hr/strings.xml
+++ b/java/res/values-hr/strings.xml
@@ -66,6 +66,7 @@
<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 +77,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 +101,5 @@
<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>
</resources>
diff --git a/java/res/values-hu/strings.xml b/java/res/values-hu/strings.xml
index 4ad60f92..1ecadb77 100644
--- a/java/res/values-hu/strings.xml
+++ b/java/res/values-hu/strings.xml
@@ -66,6 +66,7 @@
<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 +77,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 +101,5 @@
<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>
</resources>
diff --git a/java/res/values-hy/strings.xml b/java/res/values-hy/strings.xml
index 3db5ed02..e4ba3ed4 100644
--- a/java/res/values-hy/strings.xml
+++ b/java/res/values-hy/strings.xml
@@ -66,6 +66,7 @@
<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 +77,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 +101,5 @@
<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>
</resources>
diff --git a/java/res/values-in/strings.xml b/java/res/values-in/strings.xml
index 4fbcb5a7..13c20f36 100644
--- a/java/res/values-in/strings.xml
+++ b/java/res/values-in/strings.xml
@@ -66,6 +66,7 @@
<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 +77,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 +101,5 @@
<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>
</resources>
diff --git a/java/res/values-is/strings.xml b/java/res/values-is/strings.xml
index f5664058..1fc0f98c 100644
--- a/java/res/values-is/strings.xml
+++ b/java/res/values-is/strings.xml
@@ -66,6 +66,7 @@
<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 +77,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 +101,5 @@
<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>
</resources>
diff --git a/java/res/values-it/strings.xml b/java/res/values-it/strings.xml
index 7f861f52..96baefae 100644
--- a/java/res/values-it/strings.xml
+++ b/java/res/values-it/strings.xml
@@ -66,6 +66,7 @@
<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_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 +77,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 +101,5 @@
<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>
</resources>
diff --git a/java/res/values-iw/strings.xml b/java/res/values-iw/strings.xml
index 28b72fda..8cfa7bd6 100644
--- a/java/res/values-iw/strings.xml
+++ b/java/res/values-iw/strings.xml
@@ -66,6 +66,7 @@
<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>
@@ -76,17 +77,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 +101,5 @@
<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>
</resources>
diff --git a/java/res/values-ja/strings.xml b/java/res/values-ja/strings.xml
index e99b73d2..d85b6864 100644
--- a/java/res/values-ja/strings.xml
+++ b/java/res/values-ja/strings.xml
@@ -57,7 +57,7 @@
<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{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="sharing_images_with_text" msgid="9005717434461730242">"{count,plural, =1{テキスト付き画像を共有しています}other{テキスト付き画像を # 件共有しています}}"</string>
@@ -66,6 +66,7 @@
<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 +77,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 +101,5 @@
<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>
</resources>
diff --git a/java/res/values-ka/strings.xml b/java/res/values-ka/strings.xml
index 5a47bbac..91f3b2ac 100644
--- a/java/res/values-ka/strings.xml
+++ b/java/res/values-ka/strings.xml
@@ -66,6 +66,7 @@
<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 +77,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 +101,5 @@
<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>
</resources>
diff --git a/java/res/values-kk/strings.xml b/java/res/values-kk/strings.xml
index add2fd0a..ce4055f6 100644
--- a/java/res/values-kk/strings.xml
+++ b/java/res/values-kk/strings.xml
@@ -66,6 +66,7 @@
<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 +77,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 +101,5 @@
<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>
</resources>
diff --git a/java/res/values-km/strings.xml b/java/res/values-km/strings.xml
index 39a2048b..b6ff7b70 100644
--- a/java/res/values-km/strings.xml
+++ b/java/res/values-km/strings.xml
@@ -66,6 +66,7 @@
<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 +77,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 +101,5 @@
<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>
</resources>
diff --git a/java/res/values-kn/strings.xml b/java/res/values-kn/strings.xml
index cb27a7af..33abd7f4 100644
--- a/java/res/values-kn/strings.xml
+++ b/java/res/values-kn/strings.xml
@@ -66,6 +66,7 @@
<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 +77,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 +101,5 @@
<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>
</resources>
diff --git a/java/res/values-ko/strings.xml b/java/res/values-ko/strings.xml
index de8de12a..9110dc9c 100644
--- a/java/res/values-ko/strings.xml
+++ b/java/res/values-ko/strings.xml
@@ -66,6 +66,7 @@
<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 +77,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 +101,5 @@
<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>
</resources>
diff --git a/java/res/values-ky/strings.xml b/java/res/values-ky/strings.xml
index 96ffb0ce..b6ac7bb1 100644
--- a/java/res/values-ky/strings.xml
+++ b/java/res/values-ky/strings.xml
@@ -66,6 +66,7 @@
<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 +77,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 +101,5 @@
<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>
</resources>
diff --git a/java/res/values-lo/strings.xml b/java/res/values-lo/strings.xml
index 91c39162..1cc677ed 100644
--- a/java/res/values-lo/strings.xml
+++ b/java/res/values-lo/strings.xml
@@ -66,6 +66,7 @@
<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 +77,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 +101,5 @@
<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>
</resources>
diff --git a/java/res/values-lt/strings.xml b/java/res/values-lt/strings.xml
index 1d7fc237..17971c47 100644
--- a/java/res/values-lt/strings.xml
+++ b/java/res/values-lt/strings.xml
@@ -66,6 +66,7 @@
<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 +77,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 +101,5 @@
<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>
</resources>
diff --git a/java/res/values-lv/strings.xml b/java/res/values-lv/strings.xml
index d1506be1..76070171 100644
--- a/java/res/values-lv/strings.xml
+++ b/java/res/values-lv/strings.xml
@@ -66,6 +66,7 @@
<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 +75,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">"Personīgais profils"</string>
+ <string name="resolver_personal_tab" msgid="1381052735324320565">"Privātais 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 +101,5 @@
<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>
</resources>
diff --git a/java/res/values-mk/strings.xml b/java/res/values-mk/strings.xml
index 939ecae9..ceca73ce 100644
--- a/java/res/values-mk/strings.xml
+++ b/java/res/values-mk/strings.xml
@@ -66,6 +66,7 @@
<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 +77,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 +101,5 @@
<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>
</resources>
diff --git a/java/res/values-ml/strings.xml b/java/res/values-ml/strings.xml
index e42ac807..5697294b 100644
--- a/java/res/values-ml/strings.xml
+++ b/java/res/values-ml/strings.xml
@@ -66,6 +66,7 @@
<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 +77,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 +101,5 @@
<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>
</resources>
diff --git a/java/res/values-mn/strings.xml b/java/res/values-mn/strings.xml
index 9d0ea1b8..4ef02012 100644
--- a/java/res/values-mn/strings.xml
+++ b/java/res/values-mn/strings.xml
@@ -66,6 +66,7 @@
<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 +77,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 +101,5 @@
<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>
</resources>
diff --git a/java/res/values-mr/strings.xml b/java/res/values-mr/strings.xml
index 81aac409..9eaec274 100644
--- a/java/res/values-mr/strings.xml
+++ b/java/res/values-mr/strings.xml
@@ -66,6 +66,7 @@
<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 +77,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 +101,5 @@
<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>
</resources>
diff --git a/java/res/values-ms/strings.xml b/java/res/values-ms/strings.xml
index dfdde1b8..de0f20d2 100644
--- a/java/res/values-ms/strings.xml
+++ b/java/res/values-ms/strings.xml
@@ -66,6 +66,7 @@
<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 +77,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 +101,5 @@
<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>
</resources>
diff --git a/java/res/values-my/strings.xml b/java/res/values-my/strings.xml
index af258250..10fb4b9b 100644
--- a/java/res/values-my/strings.xml
+++ b/java/res/values-my/strings.xml
@@ -66,6 +66,7 @@
<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 +77,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 +101,5 @@
<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>
</resources>
diff --git a/java/res/values-nb/strings.xml b/java/res/values-nb/strings.xml
index d6979daa..3aeaf799 100644
--- a/java/res/values-nb/strings.xml
+++ b/java/res/values-nb/strings.xml
@@ -66,6 +66,7 @@
<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 +77,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 +101,5 @@
<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>
</resources>
diff --git a/java/res/values-ne/strings.xml b/java/res/values-ne/strings.xml
index b5533d2e..1c5ee2f7 100644
--- a/java/res/values-ne/strings.xml
+++ b/java/res/values-ne/strings.xml
@@ -66,6 +66,7 @@
<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 +77,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 +101,5 @@
<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>
</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 e5977285..c77ee336 100644
--- a/java/res/values-nl/strings.xml
+++ b/java/res/values-nl/strings.xml
@@ -66,6 +66,7 @@
<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 +77,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_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 +101,5 @@
<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>
</resources>
diff --git a/java/res/values-or/strings.xml b/java/res/values-or/strings.xml
index a4eb4de4..228af287 100644
--- a/java/res/values-or/strings.xml
+++ b/java/res/values-or/strings.xml
@@ -66,6 +66,7 @@
<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 +77,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 +101,5 @@
<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>
</resources>
diff --git a/java/res/values-pa/strings.xml b/java/res/values-pa/strings.xml
index d5477741..48b92189 100644
--- a/java/res/values-pa/strings.xml
+++ b/java/res/values-pa/strings.xml
@@ -66,6 +66,7 @@
<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 +77,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 +101,5 @@
<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>
</resources>
diff --git a/java/res/values-pl/strings.xml b/java/res/values-pl/strings.xml
index 0d218a0f..92877b42 100644
--- a/java/res/values-pl/strings.xml
+++ b/java/res/values-pl/strings.xml
@@ -66,6 +66,7 @@
<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 +77,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 +101,5 @@
<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>
</resources>
diff --git a/java/res/values-port/dimens.xml b/java/res/values-port/dimens.xml
new file mode 100644
index 00000000..100a7e17
--- /dev/null
+++ b/java/res/values-port/dimens.xml
@@ -0,0 +1,18 @@
+<!--
+ ~ 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">
+ <dimen name="chooser_width">-1px</dimen>
+</resources>
diff --git a/java/res/values-pt-rBR/strings.xml b/java/res/values-pt-rBR/strings.xml
index c7c8ecf8..b70ffae6 100644
--- a/java/res/values-pt-rBR/strings.xml
+++ b/java/res/values-pt-rBR/strings.xml
@@ -66,6 +66,7 @@
<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 +77,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 +101,5 @@
<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>
</resources>
diff --git a/java/res/values-pt-rPT/strings.xml b/java/res/values-pt-rPT/strings.xml
index 0ed5cf88..46ad2a78 100644
--- a/java/res/values-pt-rPT/strings.xml
+++ b/java/res/values-pt-rPT/strings.xml
@@ -66,6 +66,7 @@
<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>
@@ -76,17 +77,22 @@
<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">"Privada"</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 +101,5 @@
<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>
</resources>
diff --git a/java/res/values-pt/strings.xml b/java/res/values-pt/strings.xml
index c7c8ecf8..b70ffae6 100644
--- a/java/res/values-pt/strings.xml
+++ b/java/res/values-pt/strings.xml
@@ -66,6 +66,7 @@
<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 +77,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 +101,5 @@
<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>
</resources>
diff --git a/java/res/values-ro/strings.xml b/java/res/values-ro/strings.xml
index ed949a6a..8029c8b2 100644
--- a/java/res/values-ro/strings.xml
+++ b/java/res/values-ro/strings.xml
@@ -66,6 +66,7 @@
<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>
@@ -76,17 +77,22 @@
<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 +101,5 @@
<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>
</resources>
diff --git a/java/res/values-ru/strings.xml b/java/res/values-ru/strings.xml
index 86cd0136..a868eb9a 100644
--- a/java/res/values-ru/strings.xml
+++ b/java/res/values-ru/strings.xml
@@ -66,6 +66,7 @@
<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 +77,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 +101,5 @@
<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>
</resources>
diff --git a/java/res/values-si/strings.xml b/java/res/values-si/strings.xml
index bb8a1911..0ddf7886 100644
--- a/java/res/values-si/strings.xml
+++ b/java/res/values-si/strings.xml
@@ -66,6 +66,7 @@
<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 +77,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 +101,5 @@
<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>
</resources>
diff --git a/java/res/values-sk/strings.xml b/java/res/values-sk/strings.xml
index ed0f74df..b76a5dc2 100644
--- a/java/res/values-sk/strings.xml
+++ b/java/res/values-sk/strings.xml
@@ -66,6 +66,7 @@
<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 +77,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 +101,5 @@
<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>
</resources>
diff --git a/java/res/values-sl/strings.xml b/java/res/values-sl/strings.xml
index dc2442e7..c68eb0a2 100644
--- a/java/res/values-sl/strings.xml
+++ b/java/res/values-sl/strings.xml
@@ -66,6 +66,7 @@
<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 +77,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 +101,5 @@
<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>
</resources>
diff --git a/java/res/values-sq/strings.xml b/java/res/values-sq/strings.xml
index 596efea4..68a28543 100644
--- a/java/res/values-sq/strings.xml
+++ b/java/res/values-sq/strings.xml
@@ -66,6 +66,7 @@
<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>
@@ -76,17 +77,22 @@
<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 +101,5 @@
<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>
</resources>
diff --git a/java/res/values-sr/strings.xml b/java/res/values-sr/strings.xml
index 71d32f23..a74fa350 100644
--- a/java/res/values-sr/strings.xml
+++ b/java/res/values-sr/strings.xml
@@ -66,6 +66,7 @@
<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_files_only" msgid="6603666533766964768">"{count,plural, =1{Само фајл}one{Само фајлови}few{Само фајлови}other{Само фајлови}}"</string>
@@ -76,17 +77,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 +101,5 @@
<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>
</resources>
diff --git a/java/res/values-sv/strings.xml b/java/res/values-sv/strings.xml
index 21a286d4..3f57f368 100644
--- a/java/res/values-sv/strings.xml
+++ b/java/res/values-sv/strings.xml
@@ -66,6 +66,7 @@
<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>
@@ -76,17 +77,22 @@
<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_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 +101,5 @@
<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>
</resources>
diff --git a/java/res/values-sw/strings.xml b/java/res/values-sw/strings.xml
index d182ccd4..b29737c8 100644
--- a/java/res/values-sw/strings.xml
+++ b/java/res/values-sw/strings.xml
@@ -66,6 +66,7 @@
<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>
@@ -76,17 +77,22 @@
<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 +101,5 @@
<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>
</resources>
diff --git a/java/res/values-ta/strings.xml b/java/res/values-ta/strings.xml
index 43525f97..3d0333fd 100644
--- a/java/res/values-ta/strings.xml
+++ b/java/res/values-ta/strings.xml
@@ -66,6 +66,7 @@
<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 +77,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 +101,5 @@
<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>
</resources>
diff --git a/java/res/values-te/strings.xml b/java/res/values-te/strings.xml
index 9a6a6ec2..0ddb058b 100644
--- a/java/res/values-te/strings.xml
+++ b/java/res/values-te/strings.xml
@@ -66,6 +66,7 @@
<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 +77,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 +101,5 @@
<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>
</resources>
diff --git a/java/res/values-th/strings.xml b/java/res/values-th/strings.xml
index 70b7c8f5..1c3e5f86 100644
--- a/java/res/values-th/strings.xml
+++ b/java/res/values-th/strings.xml
@@ -66,6 +66,7 @@
<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 +77,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 +101,5 @@
<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>
</resources>
diff --git a/java/res/values-tl/strings.xml b/java/res/values-tl/strings.xml
index 8e8cc62b..f845ae41 100644
--- a/java/res/values-tl/strings.xml
+++ b/java/res/values-tl/strings.xml
@@ -66,6 +66,7 @@
<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 +77,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 +101,5 @@
<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>
</resources>
diff --git a/java/res/values-tr/strings.xml b/java/res/values-tr/strings.xml
index 83c51cb3..e46c3e43 100644
--- a/java/res/values-tr/strings.xml
+++ b/java/res/values-tr/strings.xml
@@ -66,6 +66,7 @@
<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 +77,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 +101,5 @@
<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>
</resources>
diff --git a/java/res/values-uk/strings.xml b/java/res/values-uk/strings.xml
index 46573598..f9635aea 100644
--- a/java/res/values-uk/strings.xml
+++ b/java/res/values-uk/strings.xml
@@ -66,6 +66,7 @@
<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 +77,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 +101,5 @@
<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>
</resources>
diff --git a/java/res/values-ur/strings.xml b/java/res/values-ur/strings.xml
index daec2511..858ca9c7 100644
--- a/java/res/values-ur/strings.xml
+++ b/java/res/values-ur/strings.xml
@@ -66,6 +66,7 @@
<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 +77,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 +101,5 @@
<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>
</resources>
diff --git a/java/res/values-uz/strings.xml b/java/res/values-uz/strings.xml
index 83fd9333..047c2ee3 100644
--- a/java/res/values-uz/strings.xml
+++ b/java/res/values-uz/strings.xml
@@ -66,6 +66,7 @@
<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 +77,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 +101,5 @@
<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>
</resources>
diff --git a/java/res/values-vi/strings.xml b/java/res/values-vi/strings.xml
index 9eb8af81..89260efe 100644
--- a/java/res/values-vi/strings.xml
+++ b/java/res/values-vi/strings.xml
@@ -66,6 +66,7 @@
<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 +77,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_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 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 +101,5 @@
<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>
</resources>
diff --git a/java/res/values-zh-rCN/strings.xml b/java/res/values-zh-rCN/strings.xml
index f008a9a3..9049644a 100644
--- a/java/res/values-zh-rCN/strings.xml
+++ b/java/res/values-zh-rCN/strings.xml
@@ -66,6 +66,7 @@
<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 +77,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 +101,5 @@
<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>
</resources>
diff --git a/java/res/values-zh-rHK/strings.xml b/java/res/values-zh-rHK/strings.xml
index c48dc430..bab7dc9a 100644
--- a/java/res/values-zh-rHK/strings.xml
+++ b/java/res/values-zh-rHK/strings.xml
@@ -66,6 +66,7 @@
<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 +77,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 +101,5 @@
<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>
</resources>
diff --git a/java/res/values-zh-rTW/strings.xml b/java/res/values-zh-rTW/strings.xml
index 96ff900b..88172c78 100644
--- a/java/res/values-zh-rTW/strings.xml
+++ b/java/res/values-zh-rTW/strings.xml
@@ -66,6 +66,7 @@
<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 +77,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 +101,5 @@
<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>
</resources>
diff --git a/java/res/values-zu/strings.xml b/java/res/values-zu/strings.xml
index a6feae36..c845de43 100644
--- a/java/res/values-zu/strings.xml
+++ b/java/res/values-zu/strings.xml
@@ -66,6 +66,7 @@
<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 +77,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 +101,5 @@
<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>
</resources>
diff --git a/java/res/values/attrs.xml b/java/res/values/attrs.xml
index 67acb3ae..c9f2c300 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">
diff --git a/java/res/values/dimens.xml b/java/res/values/dimens.xml
index 6590d70e..bd868c9f 100644
--- a/java/res/values/dimens.xml
+++ b/java/res/values/dimens.xml
@@ -19,8 +19,7 @@
<!-- 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">412dp</dimen>
+ <dimen name="chooser_width">450dp</dimen>
<dimen name="chooser_corner_radius">28dp</dimen>
<dimen name="chooser_corner_radius_small">14dp</dimen>
<dimen name="chooser_row_text_option_translate">25dp</dimen>
@@ -33,7 +32,6 @@
<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="resolver_icon_size">32dp</dimen>
@@ -60,7 +58,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..c026ee59 100644
--- a/java/res/values/strings.xml
+++ b/java/res/values/strings.xml
@@ -210,6 +210,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 +255,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 +284,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 +301,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 +321,18 @@
<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>
</resources>
diff --git a/java/res/values/styles.xml b/java/res/values/styles.xml
index 9a31a141..143009d0 100644
--- a/java/res/values/styles.xml
+++ b/java/res/values/styles.xml
@@ -23,7 +23,7 @@
<item name="android:taskOpenExitAnimation">@anim/resolver_close_anim</item>
</style>
<style name="Theme.DeviceDefault.ResolverCommon"
- parent="@android:style/Theme.DeviceDefault.DayNight">
+ parent="@android:style/Theme.DeviceDefault.DayNight">
<item name="android:windowAnimationStyle">@style/ResolverAnimation</item>
<item name="android:windowIsTranslucent">true</item>
<item name="android:windowNoTitle">true</item>
@@ -45,6 +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">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-release/com/android/intentresolver/flags/ReleaseFeatureFlagRepository.kt b/java/src-release/com/android/intentresolver/flags/ReleaseFeatureFlagRepository.kt
deleted file mode 100644
index f9fa2c6a..00000000
--- a/java/src-release/com/android/intentresolver/flags/ReleaseFeatureFlagRepository.kt
+++ /dev/null
@@ -1,31 +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
-import javax.annotation.concurrent.ThreadSafe
-
-@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
-}
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 06c7e8d7..79998fbc 100644
--- a/java/src/com/android/intentresolver/ChooserActionFactory.java
+++ b/java/src/com/android/intentresolver/ChooserActionFactory.java
@@ -16,7 +16,6 @@
package com.android.intentresolver;
-import android.annotation.Nullable;
import android.app.Activity;
import android.app.ActivityOptions;
import android.app.PendingIntent;
@@ -34,9 +33,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;
@@ -44,6 +48,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;
@@ -51,8 +56,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
@@ -90,20 +98,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 ChooserActivityLogger 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
@@ -114,54 +119,74 @@ public final class ChooserActionFactory implements ChooserContentPreviewUi.Actio
*/
public ChooserActionFactory(
Context context,
- ChooserRequestParameters chooserRequest,
- ChooserIntegratedDeviceComponents integratedDeviceComponents,
- ChooserActivityLogger 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,
- ChooserActivityLogger 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
@@ -185,11 +210,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);
}
@@ -198,21 +221,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(ChooserActivityLogger.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.
@@ -228,27 +236,25 @@ public final class ChooserActionFactory implements ChooserContentPreviewUi.Actio
@Nullable
private static Runnable makeCopyButtonRunnable(
- Context context,
+ ClipboardManager clipboardManager,
Intent targetIntent,
String referrerPackageName,
Consumer<Integer> finishCallback,
- ChooserActivityLogger 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(ChooserActivityLogger.SELECTION_TYPE_COPY);
+ log.logActionSelected(EventLog.SELECTION_TYPE_COPY);
finishCallback.accept(Activity.RESULT_OK);
};
}
@@ -277,18 +283,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();
@@ -307,7 +313,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;
}
@@ -316,21 +322,22 @@ 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,
- ChooserActivityLogger logger) {
+ EventLog log) {
+ if (editSharingTarget == null) return null;
return () -> {
// Log share completion via edit.
- logger.logActionSelected(ChooserActivityLogger.SELECTION_TYPE_EDIT);
+ log.logActionSelected(EventLog.SELECTION_TYPE_EDIT);
View firstImageView = null;
try {
@@ -347,12 +354,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);
@@ -372,18 +380,25 @@ 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);
+ }
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 63ac6435..9643b9f0 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,26 +16,30 @@
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.ext.CreationExtrasExtKt.addDefaultArgs;
+import static com.android.intentresolver.profiles.MultiProfilePagerAdapter.PROFILE_PERSONAL;
+import static com.android.intentresolver.profiles.MultiProfilePagerAdapter.PROFILE_WORK;
+import static com.android.intentresolver.ui.model.ActivityModel.ACTIVITY_MODEL_KEY;
import static com.android.internal.util.LatencyTracker.ACTION_LOAD_SHARE_SHEET;
-import android.annotation.IntDef;
-import android.annotation.NonNull;
-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;
@@ -52,34 +56,42 @@ import android.database.Cursor;
import android.graphics.Insets;
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.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.animation.AlphaAnimation;
-import android.view.animation.Animation;
-import android.view.animation.LinearInterpolator;
+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;
@@ -87,44 +99,85 @@ 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.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.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.model.ActivityModel;
+import com.android.intentresolver.ui.viewmodel.ChooserViewModel;
+import com.android.intentresolver.widget.ActionRow;
import com.android.intentresolver.widget.ImagePreviewView;
+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.MetricsEvent;
+import com.android.internal.util.LatencyTracker;
+
+import com.google.common.collect.ImmutableList;
+
+import dagger.hilt.android.AndroidEntryPoint;
+
+import kotlin.Pair;
+
+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.Collections;
-import java.util.Comparator;
import java.util.HashMap;
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;
+import javax.inject.Provider;
/**
* 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";
/**
@@ -138,7 +191,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";
@@ -147,6 +199,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_TAB_KEY = "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`.
@@ -155,44 +240,47 @@ 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 FeatureFlags mFeatureFlags;
+ @Inject public android.service.chooser.FeatureFlags mChooserServiceFeatureFlags;
+ @Inject public EventLog mEventLog;
+ @Inject @AppPredictionAvailable public boolean mAppPredictionAvailable;
+ @Inject @ImageEditor public Optional<ComponentName> mImageEditor;
+ @Inject @NearbyShare public Optional<ComponentName> mNearbyShare;
+ protected TargetDataLoader mTargetDataLoader;
+ @Inject public Provider<TargetDataLoader> mTargetDataLoaderProvider;
+ @Inject
+ @Caching
+ public Provider<TargetDataLoader> mCachingTargetDataLoaderProvider;
+ @Inject public DevicePolicyResources mDevicePolicyResources;
+ @Inject public ProfilePagerResources mProfilePagerResources;
+ @Inject public PackageManager mPackageManager;
+ @Inject public ClipboardManager mClipboardManager;
+ @Inject public IntentForwarding mIntentForwarding;
+ @Inject public ShareResultSenderFactory mShareResultSenderFactory;
+
+ 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 ChooserActivityLogger mChooserActivityLogger;
-
private long mChooserShownTime;
protected boolean mIsSuccessfullySelected;
@@ -214,164 +302,1059 @@ 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 HashMap<>();
private boolean mExcludeSharedText = false;
+ /**
+ * When we intend to finish the activity with a shared element transition, we can't immediately
+ * finish() when the transition is invoked, as the receiving end may not be able to start the
+ * animation and the UI breaks if this takes too long. Instead we defer finishing until onStop
+ * in order to wait for the transition to begin.
+ */
+ 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() {
+ return addDefaultArgs(
+ super.getDefaultViewModelCreationExtras(),
+ new Pair<>(ACTIVITY_MODEL_KEY, createActivityModel()));
+ }
@Override
protected void onCreate(Bundle savedInstanceState) {
- Tracer.INSTANCE.markLaunched();
- final long intentReceivedTime = System.currentTimeMillis();
- mLatencyTracker.onActionStart(ACTION_LOAD_SHARE_SHEET);
+ super.onCreate(savedInstanceState);
+ Log.i(TAG, "onCreate");
- getChooserActivityLogger().logSharesheetTriggered();
+ mTargetDataLoader = mChooserServiceFeatureFlags.chooserPayloadToggling()
+ ? mCachingTargetDataLoaderProvider.get()
+ : mTargetDataLoaderProvider.get();
- mFeatureFlagRepository = createFeatureFlagRepository();
- mIntegratedDeviceComponents = getIntegratedDeviceComponents();
+ setTheme(R.style.Theme_DeviceDefault_Chooser);
- try {
- mChooserRequest = new ChooserRequestParameters(
- getIntent(),
- getReferrerPackageName(),
- getReferrer(),
- mFeatureFlagRepository);
- } catch (IllegalArgumentException e) {
- Log.e(TAG, "Caller provided invalid Chooser request parameters", e);
- finish();
- super_onCreate(null);
- return;
+ // Initializer is invoked when this function returns, via Lifecycle.
+ mChooserHelper.setInitializer(this::initialize);
+ if (mChooserServiceFeatureFlags.chooserPayloadToggling()) {
+ mChooserHelper.setOnChooserRequestChanged(this::onChooserRequestChanged);
+ mChooserHelper.setOnPendingSelection(this::onPendingSelection);
}
+ }
- mRefinementManager = new ViewModelProvider(this).get(ChooserRefinementManager.class);
+ @Override
+ protected final void onStart() {
+ super.onStart();
+ this.getWindow().addSystemFlags(SYSTEM_FLAG_HIDE_NON_SYSTEM_OVERLAY_WINDOWS);
+ }
- 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 onResume() {
+ super.onResume();
+ Log.d(TAG, "onResume: " + getComponentName().flattenToShortString());
+ mFinishWhenStopped = false;
+ mRefinementManager.onActivityResume();
+ }
+
+ @Override
+ protected final 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()
+ && !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();
}
- });
+ }
- 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));
+ if (mRefinementManager != null) {
+ mRefinementManager.onActivityStop(isChangingConfigurations());
+ }
+
+ if (mFinishWhenStopped) {
+ mFinishWhenStopped = false;
+ finish();
+ }
+ }
+
+ @Override
+ protected final void onSaveInstanceState(Bundle outState) {
+ super.onSaveInstanceState(outState);
+ if (mViewPager != null) {
+ outState.putInt(LAST_SHOWN_TAB_KEY, mViewPager.getCurrentItem());
+ }
+ }
+
+ @Override
+ protected final void onRestart() {
+ super.onRestart();
+ if (mFeatureFlags.fixPrivateSpaceLockedOnRestart()) {
+ 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();
+ }
+
+ @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);
+ }
+
+ 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,
+ getCoroutineScope(getLifecycle()),
+ mBackgroundDispatcher,
+ mFeatureFlags);
- setAdditionalTargets(mChooserRequest.getAdditionalTargets());
+ mProfileAvailability = new ProfileAvailability(
+ mUserInteractor,
+ getCoroutineScope(getLifecycle()),
+ mBackgroundDispatcher);
- setSafeForwardingMode(true);
+ 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.getTitle(),
- mChooserRequest.getDefaultTitleResource(),
- mChooserRequest.getInitialIntents(),
- /* rList: List<ResolveInfo> = */ null,
- /* supportsAlwaysUseOption = */ false,
- new DefaultTargetDataLoader(this, getLifecycle(), false));
+ this,
+ Objects.toString(mRequest.getSharedText(), null),
+ mRequest.getShareTargetFilter(),
+ mAppPredictionAvailable
+ ),
+ mRequest.getShareTargetFilter()
+ );
- mChooserShownTime = System.currentTimeMillis();
- final long systemCost = mChooserShownTime - intentReceivedTime;
- getChooserActivityLogger().logChooserActivityShown(
- isWorkProfile(), mChooserRequest.getTargetType(), systemCost);
+ mChooserMultiProfilePagerAdapter = createMultiProfilePagerAdapter(
+ /* context = */ this,
+ mProfilePagerResources,
+ mRequest,
+ mProfiles,
+ 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();
+ }
+ });
+ BasePreviewViewModel previewViewModel =
+ new ViewModelProvider(this, createPreviewViewModelFactory())
+ .get(BasePreviewViewModel.class);
+ previewViewModel.init(
+ mRequest.getTargetIntent(),
+ mRequest.getAdditionalContentUri(),
+ mChooserServiceFeatureFlags.chooserPayloadToggling());
+ ChooserContentPreviewUi.ActionFactory actionFactory =
+ decorateActionFactoryWithRefinement(
+ createChooserActionFactory(mRequest.getTargetIntent()));
+ mChooserContentPreviewUi = new ChooserContentPreviewUi(
+ getCoroutineScope(getLifecycle()),
+ previewViewModel.getPreviewDataProvider(),
+ mRequest.getTargetIntent(),
+ previewViewModel.getImageLoader(),
+ actionFactory,
+ createModifyShareActionFactory(),
+ mEnterTransitionAnimationDelegate,
+ new HeadlineGeneratorImpl(this),
+ mRequest.getContentTypeHint(),
+ mRequest.getMetadataText(),
+ mChooserServiceFeatureFlags.chooserPayloadToggling());
+ updateStickyContentPreview();
+ if (shouldShowStickyContentPreview()) {
+ getEventLog().logActionShareWithPreview(
+ mChooserContentPreviewUi.getPreferredContentPreview());
+ }
+ mChooserShownTime = System.currentTimeMillis();
+ final long systemCost = mChooserShownTime - mIntentReceivedTime.get();
+ getEventLog().logChooserActivityShown(
+ isWorkProfile(), mRequest.getTargetType(), systemCost);
if (mResolverDrawerLayout != null) {
mResolverDrawerLayout.addOnLayoutChangeListener(this::handleLayoutChange);
mResolverDrawerLayout.setOnCollapsedChangedListener(
isCollapsed -> {
mChooserMultiProfilePagerAdapter.setIsCollapsed(isCollapsed);
- getChooserActivityLogger().logSharesheetExpansionChanged(isCollapsed);
+ getEventLog().logSharesheetExpansionChanged(isCollapsed);
});
}
-
if (DEBUG) {
Log.d(TAG, "System Time Cost is " + systemCost);
}
-
- getChooserActivityLogger().logShareStarted(
- getReferrerPackageName(),
- mChooserRequest.getTargetType(),
- mChooserRequest.getCallerChooserTargets().size(),
- (mChooserRequest.getInitialIntents() == null)
- ? 0 : mChooserRequest.getInitialIntents().length,
+ getEventLog().logShareStarted(
+ 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();
+ Tracer.INSTANCE.markLaunched();
}
- @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) {
+ // intentional reference comparison
+ 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 onAppTargetsLoaded(ResolverListAdapter listAdapter) {
+ 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();
+
+ // 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);
+ }
+
+ private void recreatePagerAdapter() {
+ if (!mChooserServiceFeatureFlags.chooserPayloadToggling()) {
+ return;
+ }
+ 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.
+ mChooserMultiProfilePagerAdapter = createMultiProfilePagerAdapter(
+ /* context = */ this,
+ mProfilePagerResources,
+ mRequest,
+ mProfiles,
+ 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()));
+ setTabsViewEnabled(false);
+ }
+
+ 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) {
+ mViewPager.setCurrentItem(savedInstanceState.getInt(LAST_SHOWN_TAB_KEY));
+ }
+ 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)) {
+ safelyStartActivity(target);
+ finish();
+ return true;
+ }
+ return false;
}
- protected FeatureFlagRepository createFeatureFlagRepository() {
- return new FeatureFlagRepositoryFactory().create(getApplicationContext());
+ 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);
+ finish();
+ return true;
+ }
+
+ /**
+ * @return {@code true} if a resolved target is autolaunched, otherwise {@code false}
+ */
+ private boolean maybeAutolaunchActivity() {
+ 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())) {
+ maybeSendShareResult(cti);
+ 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;
+ }
+
+ // @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) {
+ if (!mChooserMultiProfilePagerAdapter.onHandlePackagesChanged(
+ (ChooserListAdapter) listAdapter,
+ mProfileAvailability.getWaitingToEnableProfile())) {
+ // We no longer have any items... just finish the activity.
+ finish();
+ }
+ }
+
+ 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);
+ 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();
+ UserHandle mainUserHandle = mProfiles.getPersonalHandle();
ProfileRecord record = createProfileRecord(mainUserHandle, targetIntentFilter, factory);
if (record.shortcutLoader == null) {
Tracer.INSTANCE.endLaunchToShortcutTrace();
}
- UserHandle workUserHandle = getWorkProfileUserHandle();
+ UserHandle workUserHandle = mProfiles.getWorkHandle();
if (workUserHandle != null) {
createProfileRecord(workUserHandle, targetIntentFilter, factory);
}
+
+ UserHandle privateUserHandle = mProfiles.getPrivateHandle();
+ if (privateUserHandle != null && mProfileAvailability.isAvailable(
+ requireNonNull(mProfiles.getPrivateProfile()))) {
+ createProfileRecord(privateUserHandle, targetIntentFilter, factory);
+ }
}
private ProfileRecord createProfileRecord(
@@ -380,7 +1363,7 @@ public class ChooserActivity extends ResolverActivity implements
ShortcutLoader shortcutLoader = ActivityManager.isLowRamDeviceStatic()
? null
: createShortcutLoader(
- getApplicationContext(),
+ this,
appPredictor,
userHandle,
targetIntentFilter,
@@ -392,7 +1375,7 @@ public class ChooserActivity extends ResolverActivity implements
@Nullable
private ProfileRecord getProfileRecord(UserHandle userHandle) {
- return mProfileRecords.get(userHandle.getIdentifier(), null);
+ return mProfileRecords.get(userHandle.getIdentifier());
}
@VisibleForTesting
@@ -404,7 +1387,7 @@ public class ChooserActivity extends ResolverActivity implements
Consumer<ShortcutLoader.Result> callback) {
return new ShortcutLoader(
context,
- getLifecycle(),
+ getCoroutineScope(getLifecycle()),
appPredictor,
userHandle,
targetIntentFilter,
@@ -412,147 +1395,73 @@ 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,
+ 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 (Profile profile : profileHelper.getProfiles()) {
+ if (profile.getType() == Profile.Type.PRIVATE
+ && !profileAvailability.isAvailable(profile)) {
+ continue;
+ }
+ 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) {
- getChooserActivityLogger().logActionShareWithPreview(
- mChooserContentPreviewUi.getPreferredContentPreview());
- }
- return postRebuildListInternal(rebuildCompleted);
+ return mProfiles.getLaunchedAsProfileType().ordinal();
}
/**
@@ -560,12 +1469,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
@@ -578,6 +1486,7 @@ public class ChooserActivity extends ResolverActivity implements
/**
* Update UI to reflect changes in data.
*/
+ @Override
public void handlePackagesChanged() {
handlePackagesChanged(/* listAdapter */ null);
}
@@ -591,40 +1500,23 @@ public class ChooserActivity extends ResolverActivity implements
// Refresh pinned items
mPinnedSharedPrefs = getPinnedSharedPrefs(this);
if (listAdapter == null) {
- handlePackageChangePerProfile(mChooserMultiProfilePagerAdapter.getActiveListAdapter());
- if (mChooserMultiProfilePagerAdapter.getCount() > 1) {
- handlePackageChangePerProfile(
- mChooserMultiProfilePagerAdapter.getInactiveListAdapter());
- }
+ mChooserMultiProfilePagerAdapter.refreshPackagesInAllTabs();
} else {
- handlePackageChangePerProfile(listAdapter);
+ listAdapter.handlePackagesChanged();
}
- updateProfileViewButton();
- }
-
- private void handlePackageChangePerProfile(ResolverListAdapter adapter) {
- ProfileRecord record = getProfileRecord(adapter.getUserHandle());
- if (record != null && record.shortcutLoader != null) {
- record.shortcutLoader.reset();
- }
- adapter.handlePackagesChanged();
- }
-
- @Override
- protected void onResume() {
- super.onResume();
- Log.d(TAG, "onResume: " + getComponentName().flattenToShortString());
- maybeCancelFinishAnimation();
-
- 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) {
+ mResolverDrawerLayout.setPadding(mSystemWindowInsets.left, mSystemWindowInsets.top,
+ mSystemWindowInsets.right, 0);
+ }
+ if (mViewPager.isLayoutRtl()) {
+ mChooserMultiProfilePagerAdapter.setupViewPager(mViewPager);
}
mShouldDisplayLandscape = shouldDisplayLandscape(newConfig.orientation);
@@ -654,7 +1546,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
@@ -685,7 +1577,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);
@@ -711,46 +1604,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 (maybeCancelFinishAnimation()) {
- 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);
@@ -769,33 +1633,22 @@ public class ChooserActivity extends ResolverActivity implements
return result;
}
- @Override
- public void onActivityStarted(TargetInfo cti) {
- if (mChooserRequest.getChosenComponentSender() != null) {
+ private void maybeSendShareResult(TargetInfo cti) {
+ 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);
- }
+ mShareResultSender.onComponentSelected(target, cti.isChooserTargetInfo());
}
}
}
private void addCallerChooserTargets() {
- if (!mChooserRequest.getCallerChooserTargets().isEmpty()) {
+ if (!mRequest.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) {
+ if (mChooserMultiProfilePagerAdapter.getActiveProfile() == findSelectedProfile()) {
mChooserMultiProfilePagerAdapter.getActiveListAdapter().addServiceResults(
/* origTarget */ null,
- new ArrayList<>(mChooserRequest.getCallerChooserTargets()),
+ new ArrayList<>(mRequest.getCallerChooserTargets()),
TARGET_TYPE_DEFAULT,
/* directShareShortcutInfoCache */ Collections.emptyMap(),
/* directShareAppTargetCache */ Collections.emptyMap());
@@ -803,26 +1656,19 @@ public class ChooserActivity extends ResolverActivity implements
}
}
- @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) {
@@ -837,8 +1683,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();
@@ -848,31 +1695,32 @@ public class ChooserActivity extends ResolverActivity implements
targetList,
// Adding userHandle from ResolveInfo allows the app icon in Dialog Box to be
// resolved correctly within the same tab.
- getResolveInfoUserHandle(
- targetInfo.getResolveInfo(),
- mChooserMultiProfilePagerAdapter.getCurrentUserHandle()),
+ targetInfo.getResolveInfo().userHandle,
shortcutIdKey,
shortcutTitle,
isShortcutPinned,
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
@@ -883,7 +1731,7 @@ public class ChooserActivity extends ResolverActivity implements
final long selectionCost = System.currentTimeMillis() - mChooserShownTime;
- if (targetInfo.isMultiDisplayResolveInfo()) {
+ if ((targetInfo != null) && targetInfo.isMultiDisplayResolveInfo()) {
MultiDisplayResolveInfo mti = (MultiDisplayResolveInfo) targetInfo;
if (!mti.hasSelected()) {
// Add userHandle based badge to the stackedAppDialogBox.
@@ -891,24 +1739,47 @@ public class ChooserActivity extends ResolverActivity implements
getSupportFragmentManager(),
mti,
which,
- getResolveInfoUserHandle(
- targetInfo.getResolveInfo(),
- mChooserMultiProfilePagerAdapter.getCurrentUserHandle()));
+ targetInfo.getResolveInfo().userHandle);
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);
+ finish();
+ }
+ }
- if (currentListAdapter.getCount() > 0) {
+ // 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
+ // like targetInfo (from `ChooserListAdapter.targetInfoForPosition()`) is *probably*
+ // expected to be null only at out-of-bounds indexes where `getPositionTargetType()`
+ // returns TARGET_BAD; then the switch falls through to a default no-op, and we don't
+ // need to null-check targetInfo. We only need the null check if it's possible that
+ // the ChooserListAdapter contains null elements "in the middle" of its list data,
+ // such that they're classified as belonging to one of the real target types. That
+ // should probably never happen. But why would this method ever be invoked with a
+ // null target at all? Even an out-of-bounds index should never be "selected"...
+ if ((currentListAdapter.getCount() > 0) && (targetInfo != null)) {
switch (currentListAdapter.getPositionTargetType(which)) {
case ChooserListAdapter.TARGET_SERVICE:
- getChooserActivityLogger().logShareTargetSelected(
- ChooserActivityLogger.SELECTION_TYPE_SERVICE,
+ getEventLog().logShareTargetSelected(
+ EventLog.SELECTION_TYPE_SERVICE,
targetInfo.getResolveInfo().activityInfo.processName,
which,
/* directTargetAlsoRanked= */ getRankedPosition(targetInfo),
- mChooserRequest.getCallerChooserTargets().size(),
+ mRequest.getCallerChooserTargets().size(),
targetInfo.getHashedTargetIdForMetrics(this),
targetInfo.isPinned(),
mIsSuccessfullySelected,
@@ -917,8 +1788,8 @@ public class ChooserActivity extends ResolverActivity implements
return;
case ChooserListAdapter.TARGET_CALLER:
case ChooserListAdapter.TARGET_STANDARD:
- getChooserActivityLogger().logShareTargetSelected(
- ChooserActivityLogger.SELECTION_TYPE_APP,
+ getEventLog().logShareTargetSelected(
+ EventLog.SELECTION_TYPE_APP,
targetInfo.getResolveInfo().activityInfo.processName,
(which - currentListAdapter.getSurfacedTargetInfo().size()),
/* directTargetAlsoRanked= */ -1,
@@ -934,8 +1805,8 @@ public class ChooserActivity extends ResolverActivity implements
// they are from the alphabetical pool.
// TODO: why do we log a different selection type if the -1 value already
// designates the same condition?
- getChooserActivityLogger().logShareTargetSelected(
- ChooserActivityLogger.SELECTION_TYPE_STANDARD,
+ getEventLog().logShareTargetSelected(
+ EventLog.SELECTION_TYPE_STANDARD,
targetInfo.getResolveInfo().activityInfo.processName,
/* value= */ -1,
/* directTargetAlsoRanked= */ -1,
@@ -945,7 +1816,6 @@ public class ChooserActivity extends ResolverActivity implements
mIsSuccessfullySelected,
selectionCost
);
- return;
}
}
}
@@ -967,19 +1837,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) {
@@ -987,7 +1846,7 @@ public class ChooserActivity extends ResolverActivity implements
if (profileRecord == null) {
return;
}
- getChooserActivityLogger().logDirectShareTargetReceived(
+ getEventLog().logDirectShareTargetReceived(
MetricsEvent.ACTION_DIRECT_SHARE_TARGETS_LOADED_SHORTCUT_MANAGER,
(int) (SystemClock.elapsedRealtime() - profileRecord.loadingStartTime));
}
@@ -999,7 +1858,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();
@@ -1022,12 +1881,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) {
@@ -1097,107 +1956,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;
+ return ((record == null) || (mProfiles.getCloneUserPresent()))
+ ? 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(displayResolveInfo ->
- getResolveInfoUserHandle(
- displayResolveInfo.getResolveInfo(),
- // TODO: User resolveInfo.userHandle, once its available.
- UserHandle.SYSTEM).getIdentifier());
- }
-
- @Override
- public int compare(
- DisplayResolveInfo lhsp, DisplayResolveInfo rhsp) {
- return mComparator.compare(lhsp, rhsp);
- }
- }
-
- protected ChooserActivityLogger getChooserActivityLogger() {
- if (mChooserActivityLogger == null) {
- mChooserActivityLogger = new ChooserActivityLogger();
- }
- return mChooserActivityLogger;
- }
-
- 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);
- }
+ protected EventLog getEventLog() {
+ return mEventLog;
}
- @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);
}
@@ -1215,17 +2003,11 @@ 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(),
- mMaxTargetsPerRow);
+ mMaxTargetsPerRow,
+ mFeatureFlags);
}
@VisibleForTesting
@@ -1238,12 +2020,9 @@ 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,
@@ -1253,54 +2032,72 @@ public class ChooserActivity extends ResolverActivity implements
createListController(userHandle),
userHandle,
targetIntent,
+ referrerFillInIntent,
this,
- context.getPackageManager(),
- getChooserActivityLogger(),
- chooserRequest,
+ mPackageManager,
+ getEventLog(),
maxTargetsPerRow,
initialIntentsUserSpace,
- targetDataLoader);
+ mTargetDataLoader,
+ () -> {
+ ProfileRecord record = getProfileRecord(userHandle);
+ if (record != null && record.shortcutLoader != null) {
+ record.shortcutLoader.reset();
+ }
+ },
+ mFeatureFlags);
}
- @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, getChooserActivityLogger(),
- 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,
- getChooserActivityLogger(),
+ 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
@@ -1308,18 +2105,80 @@ public class ChooserActivity extends ResolverActivity implements
return PreviewViewModel.Companion.getFactory();
}
- private ChooserActionFactory createChooserActionFactory() {
+ private ChooserContentPreviewUi.ActionFactory decorateActionFactoryWithRefinement(
+ ChooserContentPreviewUi.ActionFactory originalFactory) {
+ if (!mFeatureFlags.refineSystemActions()) {
+ return originalFactory;
+ }
+
+ return new ChooserContentPreviewUi.ActionFactory() {
+ @Override
+ @Nullable
+ public Runnable getEditButtonRunnable() {
+ return () -> {
+ if (!mRefinementManager.maybeHandleSelection(
+ RefinementType.EDIT_ACTION,
+ List.of(mRequest.getTargetIntent()),
+ null,
+ mRequest.getRefinementIntentSender(),
+ getApplication(),
+ getMainThreadHandler())) {
+ originalFactory.getEditButtonRunnable().run();
+ }
+ };
+ }
+
+ @Override
+ @Nullable
+ public Runnable getCopyButtonRunnable() {
+ 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(Intent targetIntent) {
return new ChooserActionFactory(
this,
- mChooserRequest,
- mIntegratedDeviceComponents,
- getChooserActivityLogger(),
+ 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()
+ );
finish();
}
@@ -1329,16 +2188,33 @@ public class ChooserActivity extends ResolverActivity implements
ActivityOptions options = ActivityOptions.makeSceneTransitionAnimation(
ChooserActivity.this, sharedElement, sharedElementName);
safelyStartActivityAsUser(
- targetInfo, getPersonalProfileUserHandle(), options.toBundle());
- startFinishAnimation();
+ 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);
+ }
+ finish();
}
/*
@@ -1348,7 +2224,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();
@@ -1383,8 +2259,7 @@ public class ChooserActivity extends ResolverActivity implements
updateTabPadding();
}
- UserHandle currentUserHandle = mChooserMultiProfilePagerAdapter.getCurrentUserHandle();
- int currentProfile = getProfileForUser(currentUserHandle);
+ int currentProfile = mChooserMultiProfilePagerAdapter.getActiveProfile();
int initialProfile = findSelectedProfile();
if (currentProfile != initialProfile) {
return;
@@ -1410,9 +2285,7 @@ public class ChooserActivity extends ResolverActivity implements
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
@@ -1434,7 +2307,7 @@ public class ChooserActivity extends ResolverActivity implements
offset += stickyContentPreview.getHeight();
}
- if (shouldShowTabs()) {
+ if (mProfiles.getWorkProfilePresent()) {
offset += findViewById(com.android.internal.R.id.tabs).getHeight();
}
@@ -1457,7 +2330,8 @@ public class ChooserActivity extends ResolverActivity implements
rowsToShow--;
}
} else {
- ViewGroup currentEmptyStateView = getActiveEmptyStateView();
+ ViewGroup currentEmptyStateView =
+ mChooserMultiProfilePagerAdapter.getActiveEmptyStateView();
if (currentEmptyStateView.getVisibility() == View.VISIBLE) {
offset += currentEmptyStateView.getHeight();
}
@@ -1466,82 +2340,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) {
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) {
+ if (rebuildComplete && mChooserServiceFeatureFlags.chooserPayloadToggling()) {
+ onAppTargetsLoaded(listAdapter);
+ }
chooserListAdapter.notifyDataSetChanged();
} else {
- chooserListAdapter.updateAlphabeticalList();
+ if (mChooserServiceFeatureFlags.chooserPayloadToggling()) {
+ chooserListAdapter.updateAlphabeticalList(
+ () -> onAppTargetsLoaded(listAdapter));
+ } else {
+ chooserListAdapter.updateAlphabeticalList();
+ }
}
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();
- getChooserActivityLogger().logSharesheetAppLoadComplete();
- maybeQueryAdditionalPostProcessingTargets(chooserListAdapter);
+ getEventLog().logSharesheetAppLoadComplete();
+ 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
@@ -1567,7 +2432,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");
@@ -1575,20 +2440,22 @@ public class ChooserActivity extends ResolverActivity implements
}
logDirectShareTargetReceived(userHandle);
sendVoiceChoicesIfNeeded();
- getChooserActivityLogger().logSharesheetDirectLoadComplete();
+ getEventLog().logSharesheetDirectLoadComplete();
}
private void setupScrollListener() {
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) {
@@ -1603,6 +2470,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);
@@ -1618,7 +2486,7 @@ public class ChooserActivity extends ResolverActivity implements
}
private void maybeSetupGlobalLayoutListener() {
- if (shouldShowTabs()) {
+ if (mProfiles.getWorkProfilePresent()) {
return;
}
final View recyclerView = mChooserMultiProfilePagerAdapter.getActiveAdapterView();
@@ -1649,11 +2517,13 @@ public class ChooserActivity extends ResolverActivity implements
}
private boolean shouldShowStickyContentPreviewNoOrientationCheck() {
- return shouldShowTabs()
- && (mMultiProfilePagerAdapter.getListAdapterForUserHandle(
- UserHandle.of(UserHandle.myUserId())).getCount() > 0
- || shouldShowContentPreviewWhenEmpty())
- && shouldShowContentPreview();
+ if (!shouldShowContentPreview()) {
+ return false;
+ }
+ ResolverListAdapter adapter = mChooserMultiProfilePagerAdapter.getListAdapterForUserHandle(
+ UserHandle.of(UserHandle.myUserId()));
+ boolean isEmpty = adapter == null || adapter.getCount() == 0;
+ return !isEmpty || shouldShowContentPreviewWhenEmpty();
}
/**
@@ -1671,7 +2541,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() {
@@ -1715,53 +2585,22 @@ public class ChooserActivity extends ResolverActivity implements
contentPreviewContainer.setVisibility(View.GONE);
}
- private void startFinishAnimation() {
- View rootView = findRootView();
- if (rootView != null) {
- rootView.startAnimation(new FinishAnimation(this, rootView));
- }
- }
-
- private boolean maybeCancelFinishAnimation() {
- View rootView = findRootView();
- Animation animation = (rootView == null) ? null : rootView.getAnimation();
- if (animation instanceof FinishAnimation) {
- boolean hasEnded = animation.hasEnded();
- animation.cancel();
- rootView.clearAnimation();
- return !hasEnded;
- }
- return false;
- }
-
- 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
@@ -1771,25 +2610,28 @@ public class ChooserActivity extends ResolverActivity implements
}
}
- @Override
protected WindowInsets onApplyWindowInsets(View v, WindowInsets insets) {
- if (shouldShowTabs()) {
+ mSystemWindowInsets = insets.getInsets(WindowInsets.Type.systemBars());
+ if (mFeatureFlags.fixEmptyStatePaddingBug() || mProfiles.getWorkProfilePresent()) {
mChooserMultiProfilePagerAdapter
- .setEmptyStateBottomOffset(insets.getSystemWindowInsetBottom());
- mChooserMultiProfilePagerAdapter.setupContainerPadding(
- getActiveEmptyStateView().findViewById(com.android.internal.R.id.resolver_empty_state_container));
+ .setEmptyStateBottomOffset(mSystemWindowInsets.bottom);
}
- WindowInsets result = super.onApplyWindowInsets(v, insets);
+ mResolverDrawerLayout.setPadding(mSystemWindowInsets.left, mSystemWindowInsets.top,
+ mSystemWindowInsets.right, 0);
+
+ // Need extra padding so the list can fully scroll up
+ // To accommodate for window insets
+ applyFooterView(mSystemWindowInsets.bottom);
+
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) {
@@ -1799,7 +2641,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) {
@@ -1814,74 +2655,8 @@ public class ChooserActivity extends ResolverActivity implements
}
}
- /**
- * Used in combination with the scene transition when launching the image editor
- */
- private static class FinishAnimation extends AlphaAnimation implements
- Animation.AnimationListener {
- @Nullable
- private Activity mActivity;
- @Nullable
- private View mRootView;
- private final float mFromAlpha;
-
- FinishAnimation(@NonNull Activity activity, @NonNull View rootView) {
- super(rootView.getAlpha(), 0.0f);
- mActivity = activity;
- mRootView = rootView;
- mFromAlpha = rootView.getAlpha();
- setInterpolator(new LinearInterpolator());
- long duration = activity.getWindow().getTransitionBackgroundFadeDuration();
- setDuration(duration);
- // The scene transition animation looks better when it's not overlapped with this
- // fade-out animation thus the delay.
- // It is most likely that the image editor will cause this activity to stop and this
- // animation will be cancelled in the background without running (i.e. we'll animate
- // only when this activity remains partially visible after the image editor launch).
- setStartOffset(duration);
- super.setAnimationListener(this);
- }
-
- @Override
- public void setAnimationListener(AnimationListener listener) {
- throw new UnsupportedOperationException();
- }
-
- @Override
- public void cancel() {
- if (mRootView != null) {
- mRootView.setAlpha(mFromAlpha);
- }
- cleanup();
- super.cancel();
- }
-
- @Override
- public void onAnimationStart(Animation animation) {
- }
-
- @Override
- public void onAnimationEnd(Animation animation) {
- Activity activity = mActivity;
- cleanup();
- if (activity != null) {
- activity.finish();
- }
- }
-
- @Override
- public void onAnimationRepeat(Animation animation) {
- }
-
- private void cleanup() {
- mActivity = null;
- mRootView = null;
- }
- }
-
- @Override
protected void maybeLogProfileChange() {
- getChooserActivityLogger().logSharesheetProfileChanged();
+ getEventLog().logSharesheetProfileChanged();
}
private static class ProfileRecord {
diff --git a/java/src/com/android/intentresolver/ChooserGridLayoutManager.java b/java/src/com/android/intentresolver/ChooserGridLayoutManager.java
index 5f373525..aaa7554c 100644
--- a/java/src/com/android/intentresolver/ChooserGridLayoutManager.java
+++ b/java/src/com/android/intentresolver/ChooserGridLayoutManager.java
@@ -70,7 +70,7 @@ public class ChooserGridLayoutManager extends GridLayoutManager {
return super.getRowCountForAccessibility(recycler, state) - 1;
}
- void setVerticalScrollEnabled(boolean verticalScrollEnabled) {
+ public void setVerticalScrollEnabled(boolean verticalScrollEnabled) {
mVerticalScrollEnabled = verticalScrollEnabled;
}
diff --git a/java/src/com/android/intentresolver/ChooserHelper.kt b/java/src/com/android/intentresolver/ChooserHelper.kt
new file mode 100644
index 00000000..6317ee1d
--- /dev/null
+++ b/java/src/com/android/intentresolver/ChooserHelper.kt
@@ -0,0 +1,190 @@
+/*
+ * 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 androidx.lifecycle.lifecycleScope
+import androidx.lifecycle.repeatOnLifecycle
+import com.android.intentresolver.annotation.JavaInterop
+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.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.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.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,
+) : 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 {}
+
+ 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
+ }
+
+ 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 hasPendingCallbackFlow =
+ pendingSelectionCallbackRepo.pendingTargetIntent
+ .map { it != null }
+ .distinctUntilChanged()
+ .onEach { hasPendingCallback ->
+ if (hasPendingCallback) {
+ onPendingSelection.run()
+ }
+ }
+ activity.lifecycle.repeatOnLifecycle(Lifecycle.State.STARTED) {
+ viewModel.request
+ .combine(hasPendingCallbackFlow) { request, hasPendingCallback ->
+ request to hasPendingCallback
+ }
+ // 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) }
+ }
+ }
+ }
+
+ 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 b1fa16b0..8b848e55 100644
--- a/java/src/com/android/intentresolver/ChooserListAdapter.java
+++ b/java/src/com/android/intentresolver/ChooserListAdapter.java
@@ -19,7 +19,6 @@ 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 android.annotation.Nullable;
import android.app.ActivityManager;
import android.app.prediction.AppTarget;
import android.content.ComponentName;
@@ -38,17 +37,25 @@ 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;
@@ -56,10 +63,23 @@ 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;
@@ -77,23 +97,28 @@ 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 ChooserActivityLogger mChooserActivityLogger;
+ 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;
+ private final boolean mUseBadgeTextViewForLabels;
private final List<TargetInfo> mServiceTargets = new ArrayList<>();
private final List<DisplayResolveInfo> mCallerTargets = new ArrayList<>();
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();
@@ -128,6 +153,49 @@ public class ChooserListAdapter extends ResolverListAdapter {
}
};
+ private boolean mAnimateItems = 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,
+ FeatureFlags featureFlags) {
+ this(
+ context,
+ payloadIntents,
+ initialIntents,
+ rList,
+ filterLastUsed,
+ resolverListController,
+ userHandle,
+ targetIntent,
+ referrerFillInIntent,
+ resolverListCommunicator,
+ packageManager,
+ eventLog,
+ maxRankedTargets,
+ initialIntentsUserSpace,
+ targetDataLoader,
+ packageChangeCallback,
+ AsyncTask.SERIAL_EXECUTOR,
+ context.getMainExecutor(),
+ featureFlags);
+ }
+
+ @VisibleForTesting
public ChooserListAdapter(
Context context,
List<Intent> payloadIntents,
@@ -137,13 +205,17 @@ public class ChooserListAdapter extends ResolverListAdapter {
ResolverListController resolverListController,
UserHandle userHandle,
Intent targetIntent,
+ Intent referrerFillInIntent,
ResolverListCommunicator resolverListCommunicator,
PackageManager packageManager,
- ChooserActivityLogger chooserActivityLogger,
- ChooserRequestParameters chooserRequest,
+ EventLog eventLog,
int maxRankedTargets,
UserHandle initialIntentsUserSpace,
- TargetDataLoader targetDataLoader) {
+ TargetDataLoader targetDataLoader,
+ @Nullable PackageChangeCallback packageChangeCallback,
+ Executor bgExecutor,
+ Executor mainExecutor,
+ FeatureFlags featureFlags) {
// Don't send the initial intents through the shared ResolverActivity path,
// we want to separate them into a different section.
super(
@@ -157,15 +229,19 @@ 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;
+ mUseBadgeTextViewForLabels = featureFlags.bespokeLabelView();
createPlaceHolders();
- mChooserActivityLogger = chooserActivityLogger;
+ mEventLog = eventLog;
mShortcutSelectionLogic = new ShortcutSelectionLogic(
context.getResources().getInteger(R.integer.config_maxShortcutTargetsPerApp),
DeviceConfig.getBoolean(
@@ -226,17 +302,23 @@ 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;
}
}
}
+ 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");
}
@@ -246,7 +328,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);
@@ -263,86 +345,138 @@ public class ChooserListAdapter extends ResolverListAdapter {
@Override
View onCreateView(ViewGroup parent) {
- return mInflater.inflate(R.layout.resolve_grid_item, parent, false);
+ return mInflater.inflate(
+ mUseBadgeTextViewForLabels
+ ? R.layout.chooser_grid_item
+ : R.layout.resolve_grid_item,
+ parent,
+ false);
+ }
+
+ @Override
+ public void onDestroy() {
+ super.onDestroy();
+ notifyDataSetChanged();
}
@VisibleForTesting
@Override
public void onBindView(View view, TargetInfo info, int position) {
+ view.setEnabled(!isDestroyed());
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()) {
// 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);
+ 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);
+ }
+ }
+
+ private void resetViewHolder(ViewHolder holder) {
+ holder.reset();
+ holder.itemView.setBackground(holder.defaultItemViewBackground);
+
+ if (mUseBadgeTextViewForLabels) {
+ ((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) {
+ if (mUseBadgeTextViewForLabels) {
+ ((BadgeTextView) holder.text).setBadgeDrawable(indicator);
} else {
- holder.text.setBackground(null);
- holder.text.setPaddingRelative(0, 0, 0, 0);
+ holder.text.setPaddingRelative(0, 0, /*end = */indicator.getIntrinsicWidth(), 0);
+ holder.text.setBackground(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(
@@ -359,9 +493,23 @@ public class ChooserListAdapter extends ResolverListAdapter {
}
}
- 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() {
+ updateAlphabeticalList(() -> {});
+ }
+
+ /**
+ * Group application targets
+ */
+ public void updateAlphabeticalList(Runnable onCompleted) {
+ final DisplayResolveInfoAzInfoComparator
+ comparator = new DisplayResolveInfoAzInfoComparator(mContext);
+ final List<DisplayResolveInfo> allTargets = new ArrayList<>();
+ allTargets.addAll(getTargetsInCurrentDisplayList());
+ allTargets.addAll(mCallerTargets);
+
new AsyncTask<Void, Void, List<DisplayResolveInfo>>() {
@Override
protected List<DisplayResolveInfo> doInBackground(Void... voids) {
@@ -374,32 +522,39 @@ 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()
.collect(Collectors.groupingBy(target ->
target.getResolvedComponentName().getPackageName()
- + "#" + target.getDisplayLabel()
- + '#' + ResolverActivity.getResolveInfoUserHandle(
- target.getResolveInfo(), getUserHandle()).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 +593,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 +714,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,7 +738,7 @@ 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.
@@ -594,8 +755,8 @@ public class ChooserListAdapter extends ResolverListAdapter {
directShareToShortcutInfos,
directShareToAppTargets,
mContext.createContextAsUser(getUserHandle(), 0),
- mChooserRequest.getTargetIntent(),
- mChooserRequest.getReferrerFillInIntent(),
+ getTargetIntent(),
+ mReferrerFillInIntent,
mMaxRankedTargets,
mServiceTargets);
if (isUpdated) {
@@ -614,7 +775,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;
}
@@ -634,7 +795,7 @@ public class ChooserListAdapter extends ResolverListAdapter {
mServiceTargets.removeIf(o -> o.isPlaceHolderTargetInfo());
if (mServiceTargets.isEmpty()) {
mServiceTargets.add(NotSelectableTargetInfo.newEmptyTargetInfo());
- mChooserActivityLogger.logSharesheetEmptyDirectShareRow();
+ mEventLog.logSharesheetEmptyDirectShareRow();
}
notifyDataSetChanged();
}
@@ -644,29 +805,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
index 5157986b..06f56e3b 100644
--- a/java/src/com/android/intentresolver/ChooserRequestParameters.java
+++ b/java/src/com/android/intentresolver/ChooserRequestParameters.java
@@ -16,8 +16,7 @@
package com.android.intentresolver;
-import android.annotation.NonNull;
-import android.annotation.Nullable;
+
import android.content.ComponentName;
import android.content.Intent;
import android.content.IntentFilter;
@@ -32,7 +31,9 @@ import android.text.TextUtils;
import android.util.Log;
import android.util.Pair;
-import com.android.intentresolver.flags.FeatureFlagRepository;
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+
import com.android.intentresolver.util.UriFilters;
import com.google.common.collect.ImmutableList;
@@ -41,6 +42,7 @@ import java.net.URISyntaxException;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
+import java.util.Optional;
import java.util.stream.Collector;
import java.util.stream.Collectors;
import java.util.stream.Stream;
@@ -101,11 +103,13 @@ public class ChooserRequestParameters {
@Nullable
private final IntentFilter mTargetIntentFilter;
+ @Nullable
+ private final CharSequence mMetadataText;
+
public ChooserRequestParameters(
final Intent clientIntent,
String referrerPackageName,
- final Uri referrer,
- FeatureFlagRepository featureFlags) {
+ final Uri referrer) {
final Intent requestedTarget = parseTargetIntentExtra(
clientIntent.getParcelableExtra(Intent.EXTRA_INTENT));
mTarget = intentWithModifiedLaunchFlags(requestedTarget);
@@ -126,8 +130,14 @@ public class ChooserRequestParameters {
mReferrerFillInIntent = new Intent().putExtra(Intent.EXTRA_REFERRER, referrer);
- mChosenComponentSender = clientIntent.getParcelableExtra(
- Intent.EXTRA_CHOSEN_COMPONENT_INTENT_SENDER);
+ mChosenComponentSender =
+ Optional.ofNullable(
+ clientIntent.getParcelableExtra(Intent.EXTRA_CHOSEN_COMPONENT_INTENT_SENDER,
+ IntentSender.class))
+ .orElse(clientIntent.getParcelableExtra(
+ Intent.EXTRA_CHOOSER_RESULT_INTENT_SENDER,
+ IntentSender.class));
+
mRefinementIntentSender = clientIntent.getParcelableExtra(
Intent.EXTRA_CHOOSER_REFINEMENT_INTENT_SENDER);
@@ -148,6 +158,12 @@ public class ChooserRequestParameters {
mChooserActions = getChooserActions(clientIntent);
mModifyShareAction = getModifyShareAction(clientIntent);
+
+ if (android.service.chooser.Flags.enableSharesheetMetadataExtra()) {
+ mMetadataText = clientIntent.getCharSequenceExtra(Intent.EXTRA_METADATA_TEXT);
+ } else {
+ mMetadataText = null;
+ }
}
public Intent getTargetIntent() {
@@ -212,7 +228,7 @@ public class ChooserRequestParameters {
/**
* TODO: this returns a nullable array for convenience, but if the legacy APIs can be
- * refactored, returning {@link mAdditionalTargets} directly is simpler and safer.
+ * refactored, returning {@link #mAdditionalTargets} directly is simpler and safer.
*/
@Nullable
public Intent[] getAdditionalTargets() {
@@ -226,7 +242,7 @@ public class ChooserRequestParameters {
/**
* TODO: this returns a nullable array for convenience, but if the legacy APIs can be
- * refactored, returning {@link mInitialIntents} directly is simpler and safer.
+ * refactored, returning {@link #mInitialIntents} directly is simpler and safer.
*/
@Nullable
public Intent[] getInitialIntents() {
@@ -253,6 +269,11 @@ public class ChooserRequestParameters {
return mTargetIntentFilter;
}
+ @Nullable
+ public CharSequence getMetadataText() {
+ return mMetadataText;
+ }
+
private static boolean isSendAction(@Nullable String action) {
return (Intent.ACTION_SEND.equals(action) || Intent.ACTION_SEND_MULTIPLE.equals(action));
}
@@ -288,7 +309,7 @@ public class ChooserRequestParameters {
* 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
+ * 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(
@@ -371,7 +392,7 @@ public class ChooserRequestParameters {
* 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).
+ * present in the intent (or if it had the wrong type, but <em>warnOnTypeError</em> 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.
*/
diff --git a/java/src/com/android/intentresolver/ChooserSelector.kt b/java/src/com/android/intentresolver/ChooserSelector.kt
new file mode 100644
index 00000000..c1174e95
--- /dev/null
+++ b/java/src/com/android/intentresolver/ChooserSelector.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.v2
+
+import android.content.BroadcastReceiver
+import android.content.ComponentName
+import android.content.Context
+import android.content.Intent
+import android.content.pm.PackageManager
+import com.android.intentresolver.FeatureFlags
+import dagger.hilt.android.AndroidEntryPoint
+import javax.inject.Inject
+
+@AndroidEntryPoint(BroadcastReceiver::class)
+class ChooserSelector : Hilt_ChooserSelector() {
+
+ @Inject lateinit var featureFlags: FeatureFlags
+
+ override fun onReceive(context: Context, intent: Intent) {
+ super.onReceive(context, intent)
+ if (intent.action == Intent.ACTION_BOOT_COMPLETED) {
+ context.packageManager.setComponentEnabledSetting(
+ ComponentName(CHOOSER_PACKAGE, CHOOSER_PACKAGE + CHOOSER_CLASS),
+ if (featureFlags.modularFramework()) {
+ PackageManager.COMPONENT_ENABLED_STATE_ENABLED
+ } else {
+ PackageManager.COMPONENT_ENABLED_STATE_DEFAULT
+ },
+ /* flags = */ 0,
+ )
+ }
+ }
+
+ companion object {
+ private const val CHOOSER_PACKAGE = "com.android.intentresolver"
+ private const val CHOOSER_CLASS = ".v2.ChooserActivity"
+ }
+}
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..ae80fad4 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;
@@ -46,6 +44,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;
@@ -205,7 +205,7 @@ public class ChooserTargetActionsDialogFragment extends DialogFragment
} else {
pinComponent(mTargetInfos.get(which).getResolvedComponentName());
}
- ((ChooserActivity) getActivity()).handlePackagesChanged();
+ ((PackagesChangedListener) getActivity()).handlePackagesChanged();
dismiss();
}
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 5e8945f1..db94c918 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;
}
@@ -309,7 +311,7 @@ public class IntentForwarderActivity extends Activity {
* Check whether the intent can be forwarded to target user. Return the intent used for
* forwarding if it can be forwarded, {@code null} otherwise.
*/
- static Intent canForward(Intent incomingIntent, int sourceUserId, int targetUserId,
+ public static Intent canForward(Intent incomingIntent, int sourceUserId, int targetUserId,
IPackageManager packageManager, ContentResolver contentResolver) {
Intent forwardIntent = new Intent(incomingIntent);
forwardIntent.addFlags(
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/tests/src/com/android/intentresolver/TestApplication.kt b/java/src/com/android/intentresolver/PackagesChangedListener.kt
index 849cfbab..10f0bf51 100644
--- a/java/tests/src/com/android/intentresolver/TestApplication.kt
+++ b/java/src/com/android/intentresolver/PackagesChangedListener.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.
@@ -13,15 +13,10 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
-
package com.android.intentresolver
-import android.app.Application
-import android.content.Context
-import android.os.UserHandle
-
-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
+/** 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..53a873a3
--- /dev/null
+++ b/java/src/com/android/intentresolver/ProfileHelper.kt
@@ -0,0 +1,97 @@
+/*
+ * 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.inject.IntentResolverFlags
+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.CoroutineScope
+import kotlinx.coroutines.flow.first
+import kotlinx.coroutines.runBlocking
+
+@JavaInterop
+@MainThread
+class ProfileHelper
+@Inject
+constructor(
+ interactor: UserInteractor,
+ private val scope: CoroutineScope,
+ private val background: CoroutineDispatcher,
+ private val flags: IntentResolverFlags,
+) {
+ 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 57871532..a402fc72 100644
--- a/java/src/com/android/intentresolver/ResolverActivity.java
+++ b/java/src/com/android/intentresolver/ResolverActivity.java
@@ -16,41 +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_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.addDefaultArgs;
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;
@@ -58,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;
@@ -69,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;
@@ -91,31 +73,55 @@ 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.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.Profile;
+import com.android.intentresolver.ui.ActionTitle;
+import com.android.intentresolver.ui.ProfilePagerResources;
+import com.android.intentresolver.ui.model.ActivityModel;
+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 kotlin.Pair;
+
+import kotlinx.coroutines.CoroutineDispatcher;
import java.util.ArrayList;
import java.util.Arrays;
@@ -123,7 +129,8 @@ 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
@@ -131,40 +138,34 @@ 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;
- }
+ @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 FeatureFlags mFeatureFlags;
+
+ private ResolverViewModel mViewModel;
+ private ResolverRequest mRequest;
+ private ProfileHelper mProfiles;
+ private ProfileAvailability mProfileAvailability;
+ protected TargetDataLoader mTargetDataLoader;
+ private boolean mResolvingHome;
- private boolean mSafeForwardingMode;
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;
@@ -175,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
@@ -319,99 +213,169 @@ public class ResolverActivity extends FragmentActivity implements
};
}
+ protected ActivityModel createActivityModel() {
+ return ActivityModel.createFrom(this);
+ }
+
+ @NonNull
@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;
- }
+ public CreationExtras getDefaultViewModelCreationExtras() {
+ return addDefaultArgs(
+ super.getDefaultViewModelCreationExtras(),
+ new Pair<>(ActivityModel.ACTIVITY_MODEL_KEY, createActivityModel()));
+ }
- setSafeForwardingMode(true);
+ @Override
+ protected void onCreate(Bundle savedInstanceState) {
+ super.onCreate(savedInstanceState);
+ Log.i(TAG, "onCreate");
+ setTheme(R.style.Theme_DeviceDefault_Resolver);
+ mResolverHelper.setInitializer(this::initialize);
+ }
- onCreate(savedInstanceState, intent, null, 0, null, null, true, createIconLoader());
+ @Override
+ protected final void onStart() {
+ super.onStart();
+ this.getWindow().addSystemFlags(SYSTEM_FLAG_HIDE_NON_SYSTEM_OVERLAY_WINDOWS);
}
- /**
- * 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> rList, boolean supportsAlwaysUseOption) {
- onCreate(
- savedInstanceState,
- intent,
- title,
- 0,
- initialIntents,
- rList,
- supportsAlwaysUseOption,
- createIconLoader());
+ @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) {
+ // 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();
+ }
+ }
}
- protected void onCreate(
- Bundle savedInstanceState,
- Intent intent,
- CharSequence title,
- int defaultTitleRes,
- Intent[] initialIntents,
- List<ResolveInfo> rList,
- boolean supportsAlwaysUseOption,
- TargetDataLoader targetDataLoader) {
- setTheme(appliedThemeResId());
- super.onCreate(savedInstanceState);
+ @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());
+ }
+ }
- // 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 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();
+ }
- // 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 onDestroy() {
+ super.onDestroy();
+ if (!isChangingConfigurations() && mPickOptionRequest != null) {
+ mPickOptionRequest.cancel();
+ }
+ if (mMultiProfilePagerAdapter != null
+ && mMultiProfilePagerAdapter.getActiveListAdapter() != null) {
+ mMultiProfilePagerAdapter.getActiveListAdapter().onDestroy();
+ }
+ }
- mWorkProfileAvailability = createWorkProfileAvailabilityManager();
+ private void initialize() {
+ mViewModel = new ViewModelProvider(this).get(ResolverViewModel.class);
+ mRequest = mViewModel.getRequest().getValue();
- mPm = getPackageManager();
+ mProfiles = new ProfileHelper(
+ mUserInteractor,
+ getCoroutineScope(getLifecycle()),
+ mBackgroundDispatcher,
+ mFeatureFlags);
- mReferrerPackage = getReferrerPackageName();
+ mProfileAvailability = new ProfileAvailability(
+ mUserInteractor,
+ getCoroutineScope(getLifecycle()),
+ mBackgroundDispatcher);
- // Add our initial intent as the first item, regardless of what else has already been added.
- mIntents.add(0, new Intent(intent));
- mTitle = title;
- mDefaultTitleResId = defaultTitleRes;
+ mProfileAvailability.setOnProfileStatusChange(this::onWorkProfileStatusUpdated);
- mSupportsAlwaysUseOption = supportsAlwaysUseOption;
+ mResolvingHome = mRequest.isResolvingHome();
+ mTargetDataLoader = new DefaultTargetDataLoader(
+ this,
+ getLifecycle(),
+ 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, rList, 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;
@@ -425,7 +389,7 @@ public class ResolverActivity extends FragmentActivity implements
}
});
- boolean hasTouchScreen = getPackageManager()
+ boolean hasTouchScreen = mPackageManager
.hasSystemFeature(PackageManager.FEATURE_TOUCHSCREEN);
if (isVoiceInteraction() || !hasTouchScreen) {
@@ -438,13 +402,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
@@ -453,61 +411,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> rList,
- boolean filterLastUsed,
- TargetDataLoader targetDataLoader) {
- AbstractMultiProfilePagerAdapter resolverMultiProfilePagerAdapter = null;
- if (shouldShowTabs()) {
+ List<ResolveInfo> resolutionList,
+ boolean filterLastUsed) {
+ ResolverMultiProfilePagerAdapter resolverMultiProfilePagerAdapter = null;
+ if (mProfiles.getWorkProfilePresent()) {
resolverMultiProfilePagerAdapter =
createResolverMultiProfilePagerAdapterForTwoProfiles(
- initialIntents, rList, filterLastUsed, targetDataLoader);
+ initialIntents, resolutionList, filterLastUsed);
} else {
resolverMultiProfilePagerAdapter = createResolverMultiProfilePagerAdapterForOneProfile(
- initialIntents, rList, 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);
}
/**
@@ -519,9 +463,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) {
@@ -529,12 +471,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) {
@@ -563,7 +505,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();
}
@@ -578,52 +520,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();
@@ -642,9 +539,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;
}
@@ -655,15 +552,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()
@@ -673,9 +567,6 @@ public class ResolverActivity extends FragmentActivity implements
}
}
- /**
- * Replace me in subclasses!
- */
@Override // ResolverListCommunicator
public Intent getReplacementIntent(ActivityInfo aInfo, Intent defIntent) {
return defIntent;
@@ -684,7 +575,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()
@@ -699,9 +590,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;
@@ -743,7 +634,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
@@ -801,7 +692,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,
@@ -819,7 +710,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());
@@ -828,7 +719,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 {
@@ -842,21 +734,11 @@ public class ResolverActivity extends FragmentActivity implements
}
}
- if (target != null) {
- safelyStartActivity(target);
+ safelyStartActivity(target);
- // Rely on the ActivityManager to pop up a dialog regarding app suspension
- // and return false
- if (target.isSuspended()) {
- return false;
- }
- }
-
- return true;
- }
-
- 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
@@ -868,58 +750,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) {
@@ -929,7 +818,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);
@@ -937,9 +826,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");
@@ -981,56 +867,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();
@@ -1043,48 +897,22 @@ public class ResolverActivity extends FragmentActivity implements
Context context,
List<Intent> payloadIntents,
Intent[] initialIntents,
- List<ResolveInfo> rList,
+ 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,
initialIntents,
- rList,
+ resolutionList,
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(
@@ -1092,8 +920,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) {
@@ -1102,12 +932,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)
@@ -1118,70 +947,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);
- 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> rList,
- 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,
- rList,
+ 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> rList,
- boolean filterLastUsed,
- TargetDataLoader targetDataLoader) {
+ List<ResolveInfo> resolutionList,
+ 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 {
@@ -1195,119 +1006,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,
- rList,
+ 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,
- rList,
+ 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;
+ }
+ switch (selected) {
+ case PERSONAL: return PROFILE_PERSONAL;
+ case WORK: return PROFILE_WORK;
+ default: 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;
- }
-
- // 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() {
@@ -1326,12 +1086,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();
@@ -1362,91 +1125,7 @@ public class ResolverActivity extends FragmentActivity implements
}
final Option optionForChooserTarget(TargetInfo target, int index) {
- return new Option(target.getDisplayLabel(), index);
- }
-
- protected final void setAdditionalTargets(Intent[] intents) {
- if (intents != null) {
- for (Intent intent : intents) {
- mIntents.add(intent);
- }
- }
- }
-
- 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));
- }
-
- /**
- * Turn on 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.
- *
- * <p>This mode is set to true by default if the activity is initialized through
- * {@link #onCreate(android.os.Bundle)}. If a subclass calls one of the other onCreate
- * methods, it is set to false by default. You must set it before calling one of the
- * more detailed onCreate methods, so that it will be set correctly in the case where
- * there is only one intent to resolve and it is thus started immediately.</p>
- */
- public final void setSafeForwardingMode(boolean safeForwarding) {
- mSafeForwardingMode = safeForwarding;
+ return new Option(getOrLoadDisplayLabel(target), index);
}
protected final CharSequence getTitleForAction(Intent intent, int defaultTitleRes) {
@@ -1462,73 +1141,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) {
@@ -1550,7 +1171,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) {
@@ -1568,7 +1189,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;
}
@@ -1594,41 +1216,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 {
@@ -1649,10 +1258,9 @@ public class ResolverActivity extends FragmentActivity implements
/** 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 adaptor's userHandle. resolveInfo.userHandle
+ // space, which will not be same as the adapter's userHandle. resolveInfo.userHandle
// identifies the correct user space in such cases.
- UserHandle activityUserHandle = getResolveInfoUserHandle(
- cti.getResolveInfo(), mMultiProfilePagerAdapter.getCurrentUserHandle());
+ UserHandle activityUserHandle = cti.getResolveInfo().userHandle;
safelyStartActivityAsUser(cti, activityUserHandle, null);
}
@@ -1678,45 +1286,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))
@@ -1736,13 +1305,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);
@@ -1756,7 +1321,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;
@@ -1772,18 +1338,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) -> {
@@ -1795,9 +1369,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);
@@ -1815,6 +1390,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).
@@ -1822,17 +1460,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");
@@ -1857,53 +1497,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) {
@@ -1926,42 +1519,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);
@@ -1969,140 +1577,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;
@@ -2111,41 +1645,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() {
@@ -2173,10 +1675,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);
}
/**
@@ -2187,17 +1686,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);
@@ -2242,43 +1741,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(
- getResolveInfoUserHandle(lhs,
- mMultiProfilePagerAdapter.getActiveListAdapter().getUserHandle()),
- getResolveInfoUserHandle(rhs,
- mMultiProfilePagerAdapter.getActiveListAdapter().getUserHandle()));
- }
-
- private boolean inactiveListAdapterHasItems() {
- if (!shouldShowTabs()) {
- return false;
- }
- return mMultiProfilePagerAdapter.getInactiveListAdapter().getCount() > 0;
+ return mMultiProfilePagerAdapter.getListAdapterForUserHandle(
+ mProfiles.getTabOwnerUserHandleForLaunch()
+ ).hasFilteredItem();
}
final class ItemClickListener implements AdapterView.OnItemClickListener,
@@ -2335,11 +1800,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 {
@@ -2383,7 +1874,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);
}
/**
@@ -2403,19 +1894,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;
}
- /**
- * This function is temporary in nature, and its usages will be replaced with just
- * resolveInfo.userHandle, once it is available, once sharesheet is stable.
- */
- public static UserHandle getResolveInfoUserHandle(ResolveInfo resolveInfo,
- UserHandle predictedHandle) {
- return resolveInfo.userHandle;
+ 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..5fd37d43 100644
--- a/java/src/com/android/intentresolver/ResolverListAdapter.java
+++ b/java/src/com/android/intentresolver/ResolverListAdapter.java
@@ -16,8 +16,6 @@
package com.android.intentresolver;
-import android.annotation.NonNull;
-import android.annotation.Nullable;
import android.content.Context;
import android.content.Intent;
import android.content.pm.ActivityInfo;
@@ -42,8 +40,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,6 +57,8 @@ 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";
@@ -63,7 +69,7 @@ public class ResolverListAdapter extends BaseAdapter {
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 +81,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 +95,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 +111,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 +156,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 +234,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 +257,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,8 +402,8 @@ public class ResolverListAdapter extends BaseAdapter {
otherProfileInfo,
mPm,
mTargetIntent,
- mResolverListCommunicator,
- mTargetDataLoader);
+ mResolverListCommunicator
+ );
} else {
mOtherProfile = null;
try {
@@ -402,35 +447,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 +523,7 @@ public class ResolverListAdapter extends BaseAdapter {
ri,
ri.loadLabel(mPm),
null,
- ii,
- mTargetDataLoader.createPresentationGetter(ri)));
+ ii));
}
}
@@ -494,23 +545,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 +575,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 +622,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 +650,7 @@ public class ResolverListAdapter extends BaseAdapter {
return null;
}
+ @Override
public int getCount() {
int totalSize = mDisplayList == null || mDisplayList.isEmpty() ? mPlaceholderCount :
mDisplayList.size();
@@ -613,6 +664,7 @@ public class ResolverListAdapter extends BaseAdapter {
return mDisplayList.size();
}
+ @Override
@Nullable
public TargetInfo getItem(int position) {
if (mFilterLastUsed && mLastChosenPosition >= 0 && position >= mLastChosenPosition) {
@@ -625,6 +677,7 @@ public class ResolverListAdapter extends BaseAdapter {
}
}
+ @Override
public long getItemId(int position) {
return position;
}
@@ -642,6 +695,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 +739,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,6 +793,10 @@ public class ResolverListAdapter extends BaseAdapter {
mRequestedLabels.clear();
}
+ public final boolean isDestroyed() {
+ return mDestroyed.get();
+ }
+
private static ColorMatrixColorFilter getSuspendedColorMatrix() {
if (sSuspendedMatrixColorFilter == null) {
@@ -765,10 +824,10 @@ public class ResolverListAdapter extends BaseAdapter {
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 +836,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 +845,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 +888,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 +897,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 +932,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 +946,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,10 +996,6 @@ public class ResolverListAdapter extends BaseAdapter {
itemView.setContentDescription(null);
}
- public void updateContentDescription(String description) {
- itemView.setContentDescription(description);
- }
-
/**
* Bind view holder to a TargetInfo.
*/
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/SecureSettings.kt b/java/src/com/android/intentresolver/SecureSettings.kt
index a4853fd8..1e938895 100644
--- a/java/src/com/android/intentresolver/SecureSettings.kt
+++ b/java/src/com/android/intentresolver/SecureSettings.kt
@@ -19,9 +19,7 @@ package com.android.intentresolver
import android.content.ContentResolver
import android.provider.Settings
-/**
- * A proxy class for secure settings, for easier testing.
- */
+/** 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)
diff --git a/java/src/com/android/intentresolver/ShortcutSelectionLogic.java b/java/src/com/android/intentresolver/ShortcutSelectionLogic.java
index 645b9391..2d5ec451 100644
--- a/java/src/com/android/intentresolver/ShortcutSelectionLogic.java
+++ b/java/src/com/android/intentresolver/ShortcutSelectionLogic.java
@@ -16,7 +16,6 @@
package com.android.intentresolver;
-import android.annotation.Nullable;
import android.app.prediction.AppTarget;
import android.content.Context;
import android.content.Intent;
@@ -26,16 +25,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 +55,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 +85,7 @@ class ShortcutSelectionLogic {
+ targets.size()
+ " targets");
}
- if (targets.size() == 0) {
+ if (targets.isEmpty()) {
return false;
}
Collections.sort(targets, mBaseTargetComparator);
diff --git a/java/src/com/android/intentresolver/SimpleIconFactory.java b/java/src/com/android/intentresolver/SimpleIconFactory.java
index ec5179ac..f4871e36 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,7 +58,6 @@ 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
@@ -719,10 +719,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..910c65c9 100644
--- a/java/src/com/android/intentresolver/TargetPresentationGetter.java
+++ b/java/src/com/android/intentresolver/TargetPresentationGetter.java
@@ -16,7 +16,6 @@
package com.android.intentresolver;
-import android.annotation.Nullable;
import android.content.Context;
import android.content.pm.ActivityInfo;
import android.content.pm.ApplicationInfo;
@@ -30,6 +29,8 @@ import android.os.UserHandle;
import android.text.TextUtils;
import android.util.Log;
+import androidx.annotation.Nullable;
+
/**
* 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
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..5e44c53e 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);
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..c4aa9021 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;
diff --git a/java/src/com/android/intentresolver/chooser/TargetInfo.java b/java/src/com/android/intentresolver/chooser/TargetInfo.java
index 9d793994..ba6c3c05 100644
--- a/java/src/com/android/intentresolver/chooser/TargetInfo.java
+++ b/java/src/com/android/intentresolver/chooser/TargetInfo.java
@@ -17,14 +17,15 @@
package com.android.intentresolver.chooser;
-import android.annotation.Nullable;
import android.app.Activity;
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.UserHandle;
@@ -32,6 +33,12 @@ 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;
@@ -187,9 +194,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 +287,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 +429,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).
diff --git a/java/src/com/android/intentresolver/contentpreview/BasePreviewViewModel.kt b/java/src/com/android/intentresolver/contentpreview/BasePreviewViewModel.kt
index 103e8bf4..dc36e584 100644
--- a/java/src/com/android/intentresolver/contentpreview/BasePreviewViewModel.kt
+++ b/java/src/com/android/intentresolver/contentpreview/BasePreviewViewModel.kt
@@ -16,16 +16,20 @@
package com.android.intentresolver.contentpreview
+import android.content.Intent
+import android.net.Uri
import androidx.annotation.MainThread
import androidx.lifecycle.ViewModel
-import com.android.intentresolver.ChooserRequestParameters
/** A contract for the preview view model. Added for testing. */
abstract class BasePreviewViewModel : ViewModel() {
- @MainThread
- abstract fun createOrReuseProvider(
- chooserRequest: ChooserRequestParameters
- ): PreviewDataProvider
+ @get:MainThread abstract val previewDataProvider: PreviewDataProvider
+ @get:MainThread abstract val imageLoader: ImageLoader
- @MainThread abstract fun createOrReuseImageLoader(): ImageLoader
+ @MainThread
+ abstract fun init(
+ targetIntent: Intent,
+ additionalContentUri: Uri?,
+ isPayloadTogglingEnabled: Boolean,
+ )
}
diff --git a/java/src/com/android/intentresolver/contentpreview/CachingImagePreviewImageLoader.kt b/java/src/com/android/intentresolver/contentpreview/CachingImagePreviewImageLoader.kt
new file mode 100644
index 00000000..ce064cdf
--- /dev/null
+++ b/java/src/com/android/intentresolver/contentpreview/CachingImagePreviewImageLoader.kt
@@ -0,0 +1,110 @@
+/*
+ * 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.Log
+import androidx.core.util.lruCache
+import com.android.intentresolver.inject.Background
+import com.android.intentresolver.inject.ViewModelOwned
+import java.util.function.Consumer
+import javax.inject.Inject
+import javax.inject.Qualifier
+import kotlinx.coroutines.CoroutineDispatcher
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Deferred
+import kotlinx.coroutines.async
+import kotlinx.coroutines.ensureActive
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.sync.Semaphore
+import kotlinx.coroutines.sync.withPermit
+import kotlinx.coroutines.withContext
+
+@Qualifier
+@MustBeDocumented
+@Retention(AnnotationRetention.BINARY)
+annotation class PreviewMaxConcurrency
+
+/**
+ * Implementation of [ImageLoader].
+ *
+ * Allows for cached or uncached loading of images and limits the number of concurrent requests.
+ * Requests are automatically cancelled when they are evicted from the cache. If image loading fails
+ * or the request is cancelled (e.g. by eviction), the returned [Bitmap] will be null.
+ */
+class CachingImagePreviewImageLoader
+@Inject
+constructor(
+ @ViewModelOwned private val scope: CoroutineScope,
+ @Background private val bgDispatcher: CoroutineDispatcher,
+ private val thumbnailLoader: ThumbnailLoader,
+ @PreviewCacheSize cacheSize: Int,
+ @PreviewMaxConcurrency maxConcurrency: Int,
+) : ImageLoader {
+
+ private val semaphore = Semaphore(maxConcurrency)
+
+ private val cache =
+ lruCache(
+ maxSize = cacheSize,
+ create = { uri: Uri -> scope.async { loadUncachedImage(uri) } },
+ onEntryRemoved = { evicted: Boolean, _, oldValue: Deferred<Bitmap?>, _ ->
+ // If removed due to eviction, cancel the coroutine, otherwise it is the
+ // responsibility
+ // of the caller of [cache.remove] to cancel the removed entry when done with it.
+ if (evicted) {
+ oldValue.cancel()
+ }
+ }
+ )
+
+ override fun loadImage(callerScope: CoroutineScope, uri: Uri, callback: Consumer<Bitmap?>) {
+ callerScope.launch { callback.accept(loadCachedImage(uri)) }
+ }
+
+ override fun prePopulate(uris: List<Uri>) {
+ uris.take(cache.maxSize()).map { cache[it] }
+ }
+
+ override suspend fun invoke(uri: Uri, caching: Boolean): Bitmap? {
+ return if (caching) {
+ loadCachedImage(uri)
+ } else {
+ loadUncachedImage(uri)
+ }
+ }
+
+ private suspend fun loadUncachedImage(uri: Uri): Bitmap? =
+ withContext(bgDispatcher) {
+ runCatching { semaphore.withPermit { thumbnailLoader.invoke(uri) } }
+ .onFailure {
+ ensureActive()
+ Log.d(TAG, "Failed to load preview for $uri", it)
+ }
+ .getOrNull()
+ }
+
+ private suspend fun loadCachedImage(uri: Uri): Bitmap? =
+ // [Deferred#await] is called in a [runCatching] block to catch
+ // [CancellationExceptions]s so that they don't cancel the calling coroutine/scope.
+ runCatching { cache[uri].await() }.getOrNull()
+
+ companion object {
+ private const val TAG = "CachingImgPrevLoader"
+ }
+}
diff --git a/java/src/com/android/intentresolver/contentpreview/ChooserContentPreviewUi.java b/java/src/com/android/intentresolver/contentpreview/ChooserContentPreviewUi.java
index e8367c4e..4b955c49 100644
--- a/java/src/com/android/intentresolver/contentpreview/ChooserContentPreviewUi.java
+++ b/java/src/com/android/intentresolver/contentpreview/ChooserContentPreviewUi.java
@@ -18,6 +18,7 @@ package com.android.intentresolver.contentpreview;
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;
@@ -26,17 +27,21 @@ 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.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
@@ -45,7 +50,8 @@ import java.util.function.Consumer;
*/
public final class ChooserContentPreviewUi {
- private final Lifecycle mLifecycle;
+ private final CoroutineScope mScope;
+ private final boolean mIsPayloadTogglingEnabled;
/**
* Delegate to build the default system action buttons to display in the preview layout, if/when
@@ -72,7 +78,9 @@ public final class ChooserContentPreviewUi {
* Provides a share modification action, if any.
*/
@Nullable
- ActionRow.Action getModifyShareAction();
+ default ActionRow.Action getModifyShareAction() {
+ return null;
+ }
/**
* <p>
@@ -88,16 +96,25 @@ 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,
ImageLoader imageLoader,
ActionFactory actionFactory,
+ Supplier</*@Nullable*/ActionRow.Action> modifyShareActionFactory,
TransitionElementStatusCallback transitionElementStatusCallback,
- HeadlineGenerator headlineGenerator) {
- mLifecycle = lifecycle;
+ HeadlineGenerator headlineGenerator,
+ ContentTypeHint contentTypeHint,
+ @Nullable CharSequence metadata,
+ // TODO: replace with the FeatureFlag ref when v1 is gone
+ boolean isPayloadTogglingEnabled) {
+ mScope = scope;
+ mIsPayloadTogglingEnabled = isPayloadTogglingEnabled;
+ mModifyShareActionFactory = modifyShareActionFactory;
mContentPreviewUi = createContentPreview(
previewData,
targetIntent,
@@ -105,7 +122,10 @@ public final class ChooserContentPreviewUi {
imageLoader,
actionFactory,
transitionElementStatusCallback,
- headlineGenerator);
+ headlineGenerator,
+ contentTypeHint,
+ metadata
+ );
if (mContentPreviewUi.getType() != CONTENT_PREVIEW_IMAGE) {
transitionElementStatusCallback.onAllTransitionElementsReady();
}
@@ -118,58 +138,79 @@ public final class ChooserContentPreviewUi {
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,
+ mScope,
targetIntent,
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 && mIsPayloadTogglingEnabled) {
+ transitionElementStatusCallback.onAllTransitionElementsReady(); // TODO
+ return new ShareouselContentPreviewUi();
+ }
+
boolean isSingleImageShare = previewData.getUriCount() == 1
- && typeClassifier.isImageType(previewData.getFirstFileInfo().getMimeType());
+ && typeClassifier.isImageType(previewData.getFirstFileInfo().getMimeType());
CharSequence text = targetIntent.getCharSequenceExtra(Intent.EXTRA_TEXT);
if (!TextUtils.isEmpty(text)) {
FilesPlusTextContentPreviewUi previewUi =
new FilesPlusTextContentPreviewUi(
- mLifecycle,
+ mScope,
isSingleImageShare,
previewData.getUriCount(),
targetIntent.getCharSequenceExtra(Intent.EXTRA_TEXT),
+ targetIntent.getType(),
actionFactory,
imageLoader,
typeClassifier,
- headlineGenerator);
+ headlineGenerator,
+ metadata
+ );
if (previewData.getUriCount() > 0) {
- previewData.getFileMetadataForImagePreview(
- mLifecycle, previewUi::updatePreviewMetadata);
+ JavaFlowHelper.collectToList(
+ mScope,
+ previewData.getImagePreviewFileInfoFlow(),
+ previewUi::updatePreviewMetadata);
}
return previewUi;
}
- UnifiedContentPreviewUi unifiedContentPreviewUi = new UnifiedContentPreviewUi(
+ return new UnifiedContentPreviewUi(
+ mScope,
isSingleImageShare,
+ targetIntent.getType(),
actionFactory,
imageLoader,
typeClassifier,
transitionElementStatusCallback,
- headlineGenerator);
- previewData.getFileMetadataForImagePreview(mLifecycle, unifiedContentPreviewUi::setFiles);
- return unifiedContentPreviewUi;
+ previewData.getImagePreviewFileInfoFlow(),
+ previewData.getUriCount(),
+ headlineGenerator,
+ metadata
+ );
}
public int getPreferredContentPreview() {
@@ -181,19 +222,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) {
- return mContentPreviewUi.display(resources, layoutInflater, parent);
+ ViewGroup layout =
+ mContentPreviewUi.display(resources, layoutInflater, parent, headlineViewParent);
+ mHeadlineParent = headlineViewParent;
+ ContentPreviewUi.displayModifyShareAction(mHeadlineParent, mModifyShareActionFactory.get());
+ return layout;
+ }
+
+ /**
+ * Update Modify Share Action, if it is inflated.
+ */
+ public void updateModifyShareAction() {
+ ContentPreviewUi.displayModifyShareAction(mHeadlineParent, mModifyShareActionFactory.get());
}
private static TextContentPreviewUi createTextPreview(
- Lifecycle lifecycle,
+ CoroutineScope scope,
Intent targetIntent,
ChooserContentPreviewUi.ActionFactory actionFactory,
ImageLoader imageLoader,
- HeadlineGenerator headlineGenerator) {
+ HeadlineGenerator headlineGenerator,
+ ContentTypeHint contentTypeHint,
+ @Nullable CharSequence metadata
+ ) {
CharSequence sharingText = targetIntent.getCharSequenceExtra(Intent.EXTRA_TEXT);
- String previewTitle = targetIntent.getStringExtra(Intent.EXTRA_TITLE);
+ CharSequence previewTitle = targetIntent.getCharSequenceExtra(Intent.EXTRA_TITLE);
ClipData previewData = targetIntent.getClipData();
Uri previewThumbnail = null;
if (previewData != null) {
@@ -202,13 +260,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 07071236..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,35 +65,56 @@ 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);
}
}
- protected static ScrollableImagePreviewView.PreviewType getPreviewType(
+ static ScrollableImagePreviewView.PreviewType getPreviewType(
MimeTypeClassifier typeClassifier, String mimeType) {
if (mimeType == null) {
return ScrollableImagePreviewView.PreviewType.File;
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 35990990..b50f5bc8 100644
--- a/java/src/com/android/intentresolver/contentpreview/FilesPlusTextContentPreviewUi.java
+++ b/java/src/com/android/intentresolver/contentpreview/FilesPlusTextContentPreviewUi.java
@@ -31,12 +31,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,15 +49,20 @@ 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;
private final ChooserContentPreviewUi.ActionFactory mActionFactory;
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 ViewGroup mContentPreviewView;
+ private View mHeadliveView;
private boolean mIsMetadataUpdated = false;
@Nullable
private Uri mFirstFilePreviewUri;
@@ -66,19 +72,22 @@ class FilesPlusTextContentPreviewUi extends ContentPreviewUi {
private static final boolean SHOW_TOGGLE_CHECKMARK = false;
FilesPlusTextContentPreviewUi(
- Lifecycle lifecycle,
+ CoroutineScope scope,
boolean isSingleImage,
int fileCount,
CharSequence text,
+ @Nullable String intentMimeType,
ChooserContentPreviewUi.ActionFactory actionFactory,
ImageLoader imageLoader,
MimeTypeClassifier typeClassifier,
- HeadlineGenerator headlineGenerator) {
- mLifecycle = lifecycle;
+ HeadlineGenerator headlineGenerator,
+ @Nullable CharSequence metadata) {
if (isSingleImage && fileCount != 1) {
throw new IllegalArgumentException(
"fileCount = " + fileCount + " and isSingleImage = true");
}
+ mScope = scope;
+ mIntentMimeType = intentMimeType;
mFileCount = fileCount;
mIsSingleImage = isSingleImage;
mText = text;
@@ -86,6 +95,7 @@ class FilesPlusTextContentPreviewUi extends ContentPreviewUi {
mImageLoader = imageLoader;
mTypeClassifier = typeClassifier;
mHeadlineGenerator = headlineGenerator;
+ mMetadata = metadata;
}
@Override
@@ -94,10 +104,12 @@ 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) {
+ return displayInternal(layoutInflater, parent, headlineViewParent);
}
public void updatePreviewMetadata(List<FileInfo> files) {
@@ -114,36 +126,49 @@ 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);
List<ActionRow.Action> actions = mActionFactory.createCustomActions();
actionRow.setActions(actions);
- if (mIsMetadataUpdated) {
- updateUiWithMetadata(mContentPreviewView);
- } else if (!mIsSingleImage) {
+ if (!mIsSingleImage) {
mContentPreviewView.requireViewById(R.id.image_view).setVisibility(View.GONE);
}
+ prepareTextPreview(mContentPreviewView, mHeadliveView, mActionFactory);
+ if (mIsMetadataUpdated) {
+ updateUiWithMetadata(mContentPreviewView, mHeadliveView);
+ } else {
+ updateHeadline(
+ mHeadliveView,
+ mFileCount,
+ mTypeClassifier.isImageType(mIntentMimeType),
+ mTypeClassifier.isVideoType(mIntentMimeType));
+ }
return mContentPreviewView;
}
- private void updateUiWithMetadata(ViewGroup contentPreviewView) {
- prepareTextPreview(contentPreviewView, mActionFactory);
- updateHeadline(contentPreviewView);
+ 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,
bitmap -> {
if (bitmap == null) {
@@ -157,35 +182,38 @@ class FilesPlusTextContentPreviewUi extends ContentPreviewUi {
}
}
- private void updateHeadline(ViewGroup contentPreview) {
- CheckBox includeText = contentPreview.requireViewById(R.id.include_text_action);
+ private void updateHeadline(
+ 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 (mAllImages) {
- headline = mHeadlineGenerator.getImagesWithTextHeadline(mText, mFileCount);
- } else if (mAllVideos) {
- headline = mHeadlineGenerator.getVideosWithTextHeadline(mText, mFileCount);
+ if (allImages) {
+ headline = mHeadlineGenerator.getImagesWithTextHeadline(mText, fileCount);
+ } else if (allVideos) {
+ headline = mHeadlineGenerator.getVideosWithTextHeadline(mText, fileCount);
} else {
- headline = mHeadlineGenerator.getFilesWithTextHeadline(mText, mFileCount);
+ headline = mHeadlineGenerator.getFilesWithTextHeadline(mText, fileCount);
}
} else {
- if (mAllImages) {
- headline = mHeadlineGenerator.getImagesHeadline(mFileCount);
- } else if (mAllVideos) {
- headline = mHeadlineGenerator.getVideosHeadline(mFileCount);
+ if (allImages) {
+ headline = mHeadlineGenerator.getImagesHeadline(fileCount);
+ } else if (allVideos) {
+ headline = mHeadlineGenerator.getVideosHeadline(fileCount);
} else {
- headline = mHeadlineGenerator.getFilesHeadline(mFileCount);
+ headline = mHeadlineGenerator.getFilesHeadline(fileCount);
}
}
- 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);
@@ -201,7 +229,7 @@ class FilesPlusTextContentPreviewUi extends ContentPreviewUi {
textView.setText(getNoTextString(contentPreview.getResources()));
}
shareTextAction.accept(!isChecked);
- updateHeadline(contentPreview);
+ updateHeadline(headlineView, mFileCount, mAllImages, mAllVideos);
});
if (SHOW_TOGGLE_CHECKMARK) {
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..21308341 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
diff --git a/java/src/com/android/intentresolver/contentpreview/HeadlineGeneratorImpl.kt b/java/src/com/android/intentresolver/contentpreview/HeadlineGeneratorImpl.kt
index 1aace8c3..e92d9bc6 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 {
@@ -70,8 +103,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..629651a3 100644
--- a/java/src/com/android/intentresolver/contentpreview/ImageLoader.kt
+++ b/java/src/com/android/intentresolver/contentpreview/ImageLoader.kt
@@ -18,8 +18,8 @@ package com.android.intentresolver.contentpreview
import android.graphics.Bitmap
import android.net.Uri
-import androidx.lifecycle.Lifecycle
import java.util.function.Consumer
+import kotlinx.coroutines.CoroutineScope
/** A content preview image loader. */
interface ImageLoader : suspend (Uri) -> Bitmap?, suspend (Uri, Boolean) -> Bitmap? {
@@ -30,7 +30,7 @@ interface ImageLoader : suspend (Uri) -> Bitmap?, suspend (Uri, Boolean) -> Bitm
* @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, callback: Consumer<Bitmap?>)
/** Prepopulate the image loader cache. */
fun prePopulate(uris: List<Uri>)
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..7035f765
--- /dev/null
+++ b/java/src/com/android/intentresolver/contentpreview/ImageLoaderModule.kt
@@ -0,0 +1,50 @@
+/*
+ * 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.ActivityRetainedComponent
+import dagger.hilt.android.scopes.ActivityRetainedScoped
+
+@Module
+@InstallIn(ActivityRetainedComponent::class)
+interface ImageLoaderModule {
+ @Binds
+ @ActivityRetainedScoped
+ fun imageLoader(previewImageLoader: ImagePreviewImageLoader): ImageLoader
+
+ @Binds
+ @ActivityRetainedScoped
+ fun thumbnailLoader(thumbnailLoader: ThumbnailLoaderImpl): ThumbnailLoader
+
+ 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
index 22dd1125..fab7203e 100644
--- a/java/src/com/android/intentresolver/contentpreview/ImagePreviewImageLoader.kt
+++ b/java/src/com/android/intentresolver/contentpreview/ImagePreviewImageLoader.kt
@@ -24,19 +24,31 @@ 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 com.android.intentresolver.inject.Background
import java.util.function.Consumer
+import javax.inject.Inject
+import javax.inject.Qualifier
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CompletableDeferred
+import kotlinx.coroutines.CoroutineDispatcher
+import kotlinx.coroutines.CoroutineExceptionHandler
+import kotlinx.coroutines.CoroutineName
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Deferred
+import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Semaphore
private const val TAG = "ImagePreviewImageLoader"
+@Qualifier @MustBeDocumented @Retention(AnnotationRetention.BINARY) annotation class ThumbnailSize
+
+@Qualifier
+@MustBeDocumented
+@Retention(AnnotationRetention.BINARY)
+annotation class PreviewCacheSize
+
/**
* Implements preview image loading for the content preview UI. Provides requests deduplication,
* image caching, and a limit on the number of parallel loadings.
@@ -54,6 +66,26 @@ constructor(
private val contentResolverSemaphore: Semaphore,
) : ImageLoader {
+ @Inject
+ constructor(
+ @Background dispatcher: CoroutineDispatcher,
+ @ThumbnailSize thumbnailSize: Int,
+ contentResolver: ContentResolver,
+ @PreviewCacheSize cacheSize: Int,
+ ) : this(
+ CoroutineScope(
+ SupervisorJob() +
+ dispatcher +
+ CoroutineExceptionHandler { _, exception ->
+ Log.w(TAG, "Uncaught exception in ImageLoader", exception)
+ } +
+ CoroutineName("ImageLoader")
+ ),
+ thumbnailSize,
+ contentResolver,
+ cacheSize,
+ )
+
constructor(
scope: CoroutineScope,
thumbnailSize: Int,
@@ -70,8 +102,8 @@ constructor(
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 {
+ override fun loadImage(callerScope: CoroutineScope, uri: Uri, callback: Consumer<Bitmap?>) {
+ callerScope.launch {
val image = loadImageAsync(uri, caching = true)
if (isActive) {
callback.accept(image)
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/JavaFlowHelper.kt b/java/src/com/android/intentresolver/contentpreview/JavaFlowHelper.kt
new file mode 100644
index 00000000..b29c5774
--- /dev/null
+++ b/java/src/com/android/intentresolver/contentpreview/JavaFlowHelper.kt
@@ -0,0 +1,51 @@
+/*
+ * 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("JavaFlowHelper")
+
+package com.android.intentresolver.contentpreview
+
+import com.android.intentresolver.widget.ScrollableImagePreviewView.Preview
+import java.util.function.Consumer
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.filter
+import kotlinx.coroutines.flow.map
+import kotlinx.coroutines.flow.toList
+import kotlinx.coroutines.launch
+
+internal fun mapFileIntoToPreview(
+ flow: Flow<FileInfo>,
+ typeClassifier: MimeTypeClassifier,
+ editAction: Runnable?
+): Flow<Preview> =
+ flow
+ .filter { it.previewUri != null }
+ .map { fileInfo ->
+ Preview(
+ ContentPreviewUi.getPreviewType(typeClassifier, fileInfo.mimeType),
+ requireNotNull(fileInfo.previewUri),
+ editAction
+ )
+ }
+
+internal fun <T> collectToList(
+ clientScope: CoroutineScope,
+ flow: Flow<T>,
+ callback: Consumer<List<T>>
+) {
+ clientScope.launch { callback.accept(flow.toList()) }
+}
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 8ab3a272..96bb8258 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,23 +28,26 @@ 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.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
import java.util.concurrent.atomic.AtomicInteger
import java.util.function.Consumer
+import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CompletableDeferred
-import kotlinx.coroutines.CoroutineDispatcher
-import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.async
import kotlinx.coroutines.coroutineScope
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.MutableSharedFlow
+import kotlinx.coroutines.flow.SharedFlow
+import kotlinx.coroutines.flow.take
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
-import kotlinx.coroutines.withContext
import kotlinx.coroutines.withTimeoutOrNull
/**
@@ -68,30 +70,51 @@ private const val TIMEOUT_MS = 1_000L
*/
@OpenForTesting
open class PreviewDataProvider
-@VisibleForTesting
+@JvmOverloads
constructor(
+ private val scope: CoroutineScope,
private val targetIntent: Intent,
+ private val additionalContentUri: Uri?,
private val contentResolver: ContentInterface,
- private val typeClassifier: MimeTypeClassifier,
- private val dispatcher: CoroutineDispatcher,
+ // TODO: replace with the ChooserServiceFlags ref when PreviewViewModel dependencies are sorted
+ // out
+ private val isPayloadTogglingEnabled: Boolean,
+ private val typeClassifier: MimeTypeClassifier = DefaultMimeTypeClassifier,
) {
- constructor(
- targetIntent: Intent,
- contentResolver: ContentInterface,
- ) : this(
- targetIntent,
- contentResolver,
- DefaultMimeTypeClassifier,
- Dispatchers.IO,
- )
private val records = targetIntent.contentUris.map { UriRecord(it) }
+ private val fileInfoSharedFlow: SharedFlow<FileInfo> by lazy {
+ // Alternatively, we could just use [shareIn()] on a [flow] -- and it would be, arguably,
+ // cleaner -- but we'd lost the ability to trace the traverse as [runTracing] does not
+ // generally work over suspend function invocations.
+ MutableSharedFlow<FileInfo>(replay = records.size).apply {
+ scope.launch {
+ runTracing("image-preview-metadata") {
+ for (record in records) {
+ tryEmit(FileInfo.Builder(record.uri).readFromRecord(record).build())
+ }
+ }
+ }
+ }
+ }
+
/** returns number of shared URIs, see [Intent.EXTRA_STREAM] */
@get:OpenForTesting
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).
+ */
+ @get:OpenForTesting
+ open val imagePreviewFileInfoFlow: Flow<FileInfo>
+ get() = fileInfoSharedFlow.take(records.size)
+
/**
* Preview type to use. The type is determined asynchronously with a timeout; the fall-back
* values is [ContentPreviewType.CONTENT_PREVIEW_FILE]
@@ -106,16 +129,43 @@ constructor(
* IMAGE, FILE, TEXT. */
if (!targetIntent.isSend || records.isEmpty()) {
CONTENT_PREVIEW_TEXT
+ } else if (isPayloadTogglingEnabled && shouldShowPayloadSelection()) {
+ // TODO: replace with the proper flags injection
+ CONTENT_PREVIEW_PAYLOAD_SELECTION
} else {
- runBlocking(dispatcher) {
- withTimeoutOrNull(TIMEOUT_MS) {
- loadPreviewType()
- } ?: CONTENT_PREVIEW_FILE
+ try {
+ runBlocking(scope.coroutineContext) {
+ withTimeoutOrNull(TIMEOUT_MS) { scope.async { loadPreviewType() }.await() }
+ ?: CONTENT_PREVIEW_FILE
+ }
+ } catch (e: CancellationException) {
+ Log.w(
+ ContentPreviewUi.TAG,
+ "An attempt to read preview type from a cancelled scope",
+ e
+ )
+ CONTENT_PREVIEW_FILE
}
}
}
}
+ 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.
@@ -123,46 +173,24 @@ constructor(
open val firstFileInfo: FileInfo? by lazy {
runTracing("first-uri-metadata") {
records.firstOrNull()?.let { record ->
- runBlocking(dispatcher) {
- val builder = FileInfo.Builder(record.uri)
- withTimeoutOrNull(TIMEOUT_MS) {
- builder.readFromRecord(record)
+ val builder = FileInfo.Builder(record.uri)
+ try {
+ runBlocking(scope.coroutineContext) {
+ withTimeoutOrNull(TIMEOUT_MS) {
+ scope.async { builder.readFromRecord(record) }.await()
+ }
}
- builder.build()
- }
- }
- }
- }
-
- /**
- * Returns a collection of [FileInfo], for each shared URI in order, with [FileInfo.mimeType]
- * and [FileInfo.previewUri] set (a data projection tailored for the image preview UI).
- */
- @OpenForTesting
- open fun getFileMetadataForImagePreview(
- callerLifecycle: Lifecycle,
- callback: Consumer<List<FileInfo>>,
- ) {
- callerLifecycle.coroutineScope.launch {
- val result = withContext(dispatcher) {
- getFileMetadataForImagePreview()
- }
- callback.accept(result)
- }
- }
-
- private fun getFileMetadataForImagePreview(): List<FileInfo> =
- runTracing("image-preview-metadata") {
- ArrayList<FileInfo>(records.size).also { result ->
- for (record in records) {
- result.add(
- FileInfo.Builder(record.uri)
- .readFromRecord(record)
- .build()
+ } catch (e: CancellationException) {
+ Log.w(
+ ContentPreviewUi.TAG,
+ "An attempt to read first file info from a cancelled scope",
+ e
)
}
+ builder.build()
}
}
+ }
private fun FileInfo.Builder.readFromRecord(record: UriRecord): FileInfo.Builder {
withMimeType(record.mimeType)
@@ -181,14 +209,12 @@ 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 = withContext(dispatcher) {
- getFirstFileName()
- }
+ callerScope.launch {
+ val result = scope.async { getFirstFileName() }.await()
callback.accept(result)
}
}
@@ -237,8 +263,7 @@ constructor(
}
resultDeferred.complete(CONTENT_PREVIEW_FILE)
}
- resultDeferred.await()
- .also { job.cancel() }
+ resultDeferred.await().also { job.cancel() }
}
}
@@ -251,8 +276,7 @@ constructor(
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
@@ -263,45 +287,47 @@ constructor(
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 fun readQueryResult(): QueryResult =
+ // TODO: rewrite using methods from UiMetadataHelpers.kt
+ contentResolver.querySafe(uri, METADATA_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
+ }
}
- }
-
- val supportsThumbnail =
- flagColIdx >= 0 && ((cursor.getInt(flagColIdx) and FLAG_SUPPORTS_THUMBNAIL) != 0)
- 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 class QueryResult(
@@ -344,51 +370,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/PreviewViewModel.kt b/java/src/com/android/intentresolver/contentpreview/PreviewViewModel.kt
index 331b0cb6..6a729945 100644
--- a/java/src/com/android/intentresolver/contentpreview/PreviewViewModel.kt
+++ b/java/src/com/android/intentresolver/contentpreview/PreviewViewModel.kt
@@ -17,44 +17,64 @@
package com.android.intentresolver.contentpreview
import android.app.Application
+import android.content.ContentResolver
+import android.content.Intent
+import android.net.Uri
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 com.android.intentresolver.inject.Background
+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) : BasePreviewViewModel() {
- private var previewDataProvider: PreviewDataProvider? = null
- private var imageLoader: ImagePreviewImageLoader? = null
+/** A view model for the preview logic */
+class PreviewViewModel(
+ private val contentResolver: ContentResolver,
+ // TODO: inject ImageLoader instead
+ private val thumbnailSize: Int,
+ @Background private val dispatcher: CoroutineDispatcher = Dispatchers.IO,
+) : BasePreviewViewModel() {
+ private var targetIntent: Intent? = null
+ private var additionalContentUri: Uri? = null
+ private var isPayloadTogglingEnabled = false
- @MainThread
- override fun createOrReuseProvider(
- chooserRequest: ChooserRequestParameters
- ): PreviewDataProvider =
- previewDataProvider
- ?: PreviewDataProvider(chooserRequest.targetIntent, application.contentResolver).also {
- previewDataProvider = it
- }
+ override val previewDataProvider by lazy {
+ val targetIntent = requireNotNull(this.targetIntent) { "Not initialized" }
+ PreviewDataProvider(
+ viewModelScope + dispatcher,
+ targetIntent,
+ additionalContentUri,
+ contentResolver,
+ isPayloadTogglingEnabled,
+ )
+ }
+
+ override val imageLoader by lazy {
+ ImagePreviewImageLoader(
+ viewModelScope + dispatcher,
+ thumbnailSize,
+ contentResolver,
+ cacheSize = 16
+ )
+ }
+ // TODO: make the view model injectable and inject these dependencies instead
@MainThread
- override fun createOrReuseImageLoader(): ImageLoader =
- imageLoader
- ?: ImagePreviewImageLoader(
- viewModelScope + Dispatchers.IO,
- thumbnailSize =
- application.resources.getDimensionPixelSize(
- R.dimen.chooser_preview_image_max_dimen
- ),
- application.contentResolver,
- cacheSize = 16
- )
- .also { imageLoader = it }
+ override fun init(
+ targetIntent: Intent,
+ additionalContentUri: Uri?,
+ isPayloadTogglingEnabled: Boolean,
+ ) {
+ if (this.targetIntent != null) return
+ this.targetIntent = targetIntent
+ this.additionalContentUri = additionalContentUri
+ this.isPayloadTogglingEnabled = isPayloadTogglingEnabled
+ }
companion object {
val Factory: ViewModelProvider.Factory =
@@ -63,7 +83,16 @@ class PreviewViewModel(private val application: Application) : BasePreviewViewMo
override fun <T : ViewModel> create(
modelClass: Class<T>,
extras: CreationExtras
- ): T = PreviewViewModel(checkNotNull(extras[APPLICATION_KEY])) as T
+ ): T {
+ val application: Application = checkNotNull(extras[APPLICATION_KEY])
+ return PreviewViewModel(
+ application.contentResolver,
+ application.resources.getDimensionPixelSize(
+ R.dimen.chooser_preview_image_max_dimen
+ )
+ )
+ 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..57a51239
--- /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_IMAGE
+
+ 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..ae7ddcd9 100644
--- a/java/src/com/android/intentresolver/contentpreview/TextContentPreviewUi.java
+++ b/java/src/com/android/intentresolver/contentpreview/TextContentPreviewUi.java
@@ -20,6 +20,7 @@ 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.view.LayoutInflater;
import android.view.View;
@@ -28,38 +29,47 @@ import android.widget.ImageView;
import android.widget.TextView;
import androidx.annotation.Nullable;
-import androidx.lifecycle.Lifecycle;
+import com.android.intentresolver.ContentTypeHint;
import com.android.intentresolver.R;
import com.android.intentresolver.widget.ActionRow;
+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;
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 +78,21 @@ 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) {
+ 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 +107,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);
@@ -115,7 +125,7 @@ class TextContentPreviewUi extends ContentPreviewUi {
previewThumbnailView.setVisibility(View.GONE);
} else {
mImageLoader.loadImage(
- mLifecycle,
+ mScope,
mPreviewThumbnail,
(bitmap) -> updateViewWithImage(
contentPreviewLayout.findViewById(
@@ -131,8 +141,26 @@ class TextContentPreviewUi extends ContentPreviewUi {
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..9f1d50da
--- /dev/null
+++ b/java/src/com/android/intentresolver/contentpreview/ThumbnailLoader.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.contentpreview
+
+import android.content.ContentResolver
+import android.graphics.Bitmap
+import android.net.Uri
+import android.util.Size
+import javax.inject.Inject
+
+/** Interface for objects that can attempt load a [Bitmap] from a [Uri]. */
+interface ThumbnailLoader : suspend (Uri) -> 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 invoke(uri: Uri): Bitmap =
+ contentResolver.loadThumbnail(uri, size, /* signal = */ null)
+}
diff --git a/java/src/com/android/intentresolver/contentpreview/UnifiedContentPreviewUi.java b/java/src/com/android/intentresolver/contentpreview/UnifiedContentPreviewUi.java
index 6385f2b6..88311016 100644
--- a/java/src/com/android/intentresolver/contentpreview/UnifiedContentPreviewUi.java
+++ b/java/src/com/android/intentresolver/contentpreview/UnifiedContentPreviewUi.java
@@ -31,35 +31,55 @@ import com.android.intentresolver.widget.ActionRow;
import com.android.intentresolver.widget.ImagePreviewView.TransitionElementStatusCallback;
import com.android.intentresolver.widget.ScrollableImagePreviewView;
-import java.util.ArrayList;
+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
+ private final String mIntentMimeType;
private final ChooserContentPreviewUi.ActionFactory mActionFactory;
private final ImageLoader mImageLoader;
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;
UnifiedContentPreviewUi(
+ CoroutineScope scope,
boolean isSingleImage,
+ @Nullable String intentMimeType,
ChooserContentPreviewUi.ActionFactory actionFactory,
ImageLoader imageLoader,
MimeTypeClassifier typeClassifier,
TransitionElementStatusCallback transitionElementStatusCallback,
- HeadlineGenerator headlineGenerator) {
+ Flow<FileInfo> fileInfoFlow,
+ int itemCount,
+ HeadlineGenerator headlineGenerator,
+ @Nullable CharSequence metadata) {
mShowEditAction = isSingleImage;
+ mIntentMimeType = intentMimeType;
mActionFactory = actionFactory;
mImageLoader = imageLoader;
mTypeClassifier = typeClassifier;
mTransitionElementStatusCallback = transitionElementStatusCallback;
+ mFileInfoFlow = fileInfoFlow;
+ mItemCount = itemCount;
mHeadlineGenerator = headlineGenerator;
+ mMetadata = metadata;
+
+ JavaFlowHelper.collectToList(scope, fileInfoFlow, this::setFiles);
}
@Override
@@ -68,26 +88,31 @@ 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) {
+ return displayInternal(layoutInflater, parent, headlineViewParent);
}
- public void setFiles(List<FileInfo> files) {
+ private void setFiles(List<FileInfo> files) {
mImageLoader.prePopulate(files.stream()
.map(FileInfo::getPreviewUri)
.filter(Objects::nonNull)
.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);
@@ -96,17 +121,32 @@ class UnifiedContentPreviewUi extends ContentPreviewUi {
ScrollableImagePreviewView imagePreview =
mContentPreviewView.requireViewById(R.id.scrollable_image_preview);
+ imagePreview.setImageLoader(mImageLoader);
imagePreview.setOnNoPreviewCallback(() -> imagePreview.setVisibility(View.GONE));
imagePreview.setTransitionElementStatusCallback(mTransitionElementStatusCallback);
+ imagePreview.setPreviews(
+ JavaFlowHelper.mapFileIntoToPreview(
+ mFileInfoFlow,
+ mTypeClassifier,
+ mShowEditAction ? mActionFactory.getEditButtonRunnable() : null),
+ mItemCount);
if (mFiles != null) {
- updatePreviewWithFiles(mContentPreviewView, mFiles);
+ updatePreviewWithFiles(mContentPreviewView, mHeadlineView, mFiles);
+ } else {
+ displayHeadline(
+ mHeadlineView,
+ mItemCount,
+ mTypeClassifier.isImageType(mIntentMimeType),
+ mTypeClassifier.isVideoType(mIntentMimeType));
+ imagePreview.setLoading(mItemCount);
}
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);
@@ -120,7 +160,6 @@ class UnifiedContentPreviewUi extends ContentPreviewUi {
return;
}
- List<ScrollableImagePreviewView.Preview> previews = new ArrayList<>();
boolean allImages = true;
boolean allVideos = true;
for (FileInfo fileInfo : files) {
@@ -128,24 +167,20 @@ class UnifiedContentPreviewUi extends ContentPreviewUi {
getPreviewType(mTypeClassifier, fileInfo.getMimeType());
allImages = allImages && previewType == ScrollableImagePreviewView.PreviewType.Image;
allVideos = allVideos && previewType == ScrollableImagePreviewView.PreviewType.Video;
-
- if (fileInfo.getPreviewUri() != null) {
- Runnable editAction =
- mShowEditAction ? mActionFactory.getEditButtonRunnable() : null;
- previews.add(
- new ScrollableImagePreviewView.Preview(
- previewType, fileInfo.getPreviewUri(), editAction));
- }
}
- imagePreview.setPreviews(previews, count - previews.size(), mImageLoader);
+ displayHeadline(headlineView, count, allImages, allVideos);
+ }
+ private void displayHeadline(
+ View layout, int count, boolean allImages, boolean allVideos) {
if (allImages) {
- displayHeadline(contentPreviewView, mHeadlineGenerator.getImagesHeadline(count));
+ displayHeadline(layout, mHeadlineGenerator.getImagesHeadline(count));
} else if (allVideos) {
- displayHeadline(contentPreviewView, mHeadlineGenerator.getVideosHeadline(count));
+ displayHeadline(layout, mHeadlineGenerator.getVideosHeadline(count));
} else {
- displayHeadline(contentPreviewView, mHeadlineGenerator.getFilesHeadline(count));
+ 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..c532b9a5
--- /dev/null
+++ b/java/src/com/android/intentresolver/contentpreview/UriMetadataHelpers.kt
@@ -0,0 +1,138 @@
+/*
+ * 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.Downloads
+import android.provider.MediaStore.MediaColumns.HEIGHT
+import android.provider.MediaStore.MediaColumns.WIDTH
+import android.provider.OpenableColumns
+import android.text.TextUtils
+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 {
+ columnNames
+ .indexOf(MediaMetadata.METADATA_KEY_DISPLAY_ICON_URI)
+ .takeIf { it >= 0 }
+ ?.let { getString(it)?.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.readTitle(): String =
+ runCatching {
+ var nameColIndex = -1
+ var titleColIndex = -1
+ // TODO: double-check why Cursor#getColumnInded didn't work
+ columnNames.forEachIndexed { i, columnName ->
+ when (columnName) {
+ OpenableColumns.DISPLAY_NAME -> nameColIndex = i
+ Downloads.Impl.COLUMN_TITLE -> titleColIndex = i
+ }
+ }
+
+ var title = ""
+ if (nameColIndex >= 0) {
+ title = getString(nameColIndex) ?: ""
+ }
+ if (TextUtils.isEmpty(title) && titleColIndex >= 0) {
+ title = getString(titleColIndex) ?: ""
+ }
+ title
+ }
+ .getOrDefault("")
+
+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..48c06192
--- /dev/null
+++ b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/data/repository/PreviewSelectionsRepository.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 com.android.intentresolver.contentpreview.payloadtoggle.shared.model.PreviewModel
+import dagger.hilt.android.scopes.ViewModelScoped
+import javax.inject.Inject
+import kotlinx.coroutines.flow.MutableStateFlow
+
+/** Stores set of selected previews. */
+@ViewModelScoped
+class PreviewSelectionsRepository @Inject constructor() {
+ val selections = MutableStateFlow(emptyList<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..d9612696
--- /dev/null
+++ b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/cursor/PayloadToggleCursorResolver.kt
@@ -0,0 +1,80 @@
+/*
+ * 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.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,
+ // TODO: uncomment to start using that data
+ arrayOf(URI /*, WIDTH, HEIGHT*/),
+ bundleOf(Intent.EXTRA_INTENT to chooserIntent),
+ signal,
+ )
+ }
+ .getOrNull()
+ ?.viewBy { readUri()?.let { uri -> CursorRow(uri, readSize()) } }
+ }
+
+ 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..a0fc11c3
--- /dev/null
+++ b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/interactor/CursorPreviewsInteractor.kt
@@ -0,0 +1,337 @@
+/*
+ * 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 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.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 kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.filterNotNull
+import kotlinx.coroutines.flow.first
+import kotlinx.coroutines.flow.mapLatest
+
+/** Queries data from a remote cursor, and caches it locally for presentation in Shareousel. */
+class CursorPreviewsInteractor
+@Inject
+constructor(
+ private val interactor: SetCursorPreviewsInteractor,
+ @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(
+ initialState = readInitialState(pagedCursor, startPosition, unclaimedRecords),
+ pagedCursor = pagedCursor,
+ unclaimedRecords = unclaimedRecords,
+ )
+ processLoadRequests(state, pagedCursor, unclaimedRecords)
+ }
+
+ private suspend fun loadToMaxPages(
+ initialState: CursorWindow,
+ pagedCursor: PagedCursor<CursorRow?>,
+ unclaimedRecords: MutableUnclaimedMap,
+ ): CursorWindow {
+ var state = initialState
+ val startPageNum = state.firstLoadedPageNum
+ while ((state.hasMoreLeft || state.hasMoreRight) && state.numLoadedPages < maxLoadedPages) {
+ interactor.setPreviews(
+ previews = state.merged.values.toList(),
+ startIndex = startPageNum,
+ hasMoreLeft = state.hasMoreLeft,
+ hasMoreRight = state.hasMoreRight,
+ )
+ val loadedLeft = startPageNum - state.firstLoadedPageNum
+ val loadedRight = state.lastLoadedPageNum - startPageNum
+ state =
+ when {
+ state.hasMoreLeft && loadedLeft < loadedRight ->
+ state.loadMoreLeft(pagedCursor, unclaimedRecords)
+ state.hasMoreRight -> state.loadMoreRight(pagedCursor, unclaimedRecords)
+ else -> state.loadMoreLeft(pagedCursor, unclaimedRecords)
+ }
+ }
+ return state
+ }
+
+ /** Loop forever, processing any loading requests from the UI and updating local cache. */
+ private suspend fun processLoadRequests(
+ initialState: CursorWindow,
+ pagedCursor: PagedCursor<CursorRow?>,
+ unclaimedRecords: MutableUnclaimedMap,
+ ) {
+ var state = initialState
+ while (true) {
+ // 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 = 0, // TODO: actually track this as the window changes?
+ hasMoreLeft = state.hasMoreLeft,
+ hasMoreRight = state.hasMoreRight,
+ )
+ state = loadingState.handleOneLoadRequest(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(
+ state: CursorWindow,
+ pagedCursor: PagedCursor<CursorRow?>,
+ unclaimedRecords: MutableUnclaimedMap,
+ ): CursorWindow =
+ mapLatest { loadDirection ->
+ loadDirection?.let {
+ when (loadDirection) {
+ LoadDirection.Left -> state.loadMoreLeft(pagedCursor, unclaimedRecords)
+ LoadDirection.Right -> state.loadMoreRight(pagedCursor, unclaimedRecords)
+ }
+ }
+ }
+ .filterNotNull()
+ .first()
+
+ /**
+ * Returns the initial [CursorWindow], with a single page loaded that contains the given
+ * [startPosition].
+ */
+ private suspend fun readInitialState(
+ cursor: PagedCursor<CursorRow?>,
+ startPosition: Int,
+ 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(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(this, unclaimedRecords)
+ }
+ // Finally, add the remainder of the unclaimed Uris.
+ if (!hasMoreRight) {
+ putAllUnclaimedRight(unclaimedRecords)
+ }
+ }
+ return CursorWindow(
+ firstLoadedPageNum = startPageIdx,
+ lastLoadedPageNum = startPageIdx,
+ pages = listOf(page.keys),
+ merged = page,
+ hasMoreLeft = hasMoreLeft,
+ hasMoreRight = hasMoreRight,
+ )
+ }
+
+ private suspend fun CursorWindow.loadMoreRight(
+ cursor: PagedCursor<CursorRow?>,
+ unclaimedRecords: MutableUnclaimedMap,
+ ): CursorWindow {
+ val pageNum = lastLoadedPageNum + 1
+ val hasMoreRight = pageNum < cursor.count - 1
+ val newPage: PreviewMap = buildMap {
+ readAndPutPage(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(
+ 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(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(this@loadMoreLeft, cursor, pageNum, unclaimedRecords)
+ }
+ }
+ return if (numLoadedPages < maxLoadedPages) {
+ expandWindowLeft(newPage, hasMoreLeft)
+ } else {
+ shiftWindowLeft(newPage, hasMoreLeft)
+ }
+ }
+
+ private suspend fun readPage(
+ state: CursorWindow,
+ pagedCursor: PagedCursor<CursorRow?>,
+ pageNum: Int,
+ unclaimedRecords: MutableUnclaimedMap,
+ ): PreviewMap =
+ mutableMapOf<Uri, PreviewModel>()
+ .readAndPutPage(state, pagedCursor, pageNum, unclaimedRecords)
+
+ private suspend fun <M : MutablePreviewMap> M.readAndPutPage(
+ state: CursorWindow,
+ pagedCursor: PagedCursor<CursorRow?>,
+ pageNum: Int,
+ unclaimedRecords: MutableUnclaimedMap,
+ ): M =
+ pagedCursor
+ .getPageRows(pageNum) // TODO: what do we do if the load fails?
+ ?.filter { it.uri !in state.merged }
+ ?.toPage(this, unclaimedRecords)
+ ?: this
+
+ private suspend fun <M : MutablePreviewMap> Sequence<CursorRow>.toPage(
+ 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(row, unclaimedRecords) }
+ .associateByTo(destination) { it.uri }
+
+ private fun createPreviewModel(
+ row: CursorRow,
+ unclaimedRecords: MutableUnclaimedMap,
+ ): PreviewModel =
+ unclaimedRecords.remove(row.uri)?.second
+ ?: uriMetadataReader.getMetadata(row.uri).let { metadata ->
+ val size =
+ row.previewSize
+ ?: metadata.previewUri?.let { uriMetadataReader.readPreviewSize(it) }
+ PreviewModel(
+ uri = row.uri,
+ previewUri = metadata.previewUri,
+ mimeType = metadata.mimeType,
+ aspectRatio = size.aspectRatioOrDefault(1f),
+ )
+ }
+
+ 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<Uri, 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 MutablePreviewMap = MutableMap<Uri, PreviewModel>
+
+private typealias PreviewMap = Map<Uri, PreviewModel>
+
+private fun <M : MutablePreviewMap> M.putAllUnclaimedWhere(
+ unclaimedRecords: UnclaimedMap,
+ predicate: (Int) -> Boolean,
+): M =
+ unclaimedRecords
+ .asSequence()
+ .filter { predicate(it.value.first) }
+ .map { it.key to it.value.second }
+ .toMap(this)
+
+private fun PagedCursor<CursorRow?>.getPageRows(pageNum: Int): Sequence<CursorRow>? =
+ get(pageNum)?.filterNotNull()
+
+@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..388cbc7e
--- /dev/null
+++ b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/interactor/FetchPreviewsInteractor.kt
@@ -0,0 +1,75 @@
+/*
+ * 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.PreviewModel
+import com.android.intentresolver.inject.ContentUris
+import com.android.intentresolver.inject.FocusedItemIndex
+import com.android.intentresolver.util.mapParallel
+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
+ setCursorPreviews.setPreviews(
+ previews = initialPreviewMap,
+ startIndex = focusedItemIdx,
+ hasMoreLeft = false,
+ hasMoreRight = false,
+ )
+ 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.
+ .mapParallel(parallelism = 4) { uri ->
+ val metadata = uriMetadataReader.getMetadata(uri)
+ PreviewModel(
+ uri = uri,
+ previewUri = metadata.previewUri,
+ mimeType = metadata.mimeType,
+ aspectRatio =
+ metadata.previewUri?.let {
+ uriMetadataReader.readPreviewSize(it).aspectRatioOrDefault(1f)
+ }
+ ?: 1f,
+ )
+ }
+}
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..55a995f5
--- /dev/null
+++ b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/interactor/SelectablePreviewInteractor.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 android.net.Uri
+import com.android.intentresolver.contentpreview.payloadtoggle.shared.model.PreviewModel
+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,
+) {
+ val uri: Uri = key.uri
+
+ /** Whether or not this preview is selected by the user. */
+ val isSelected: Flow<Boolean> = selectionInteractor.selections.map { key in it }
+
+ /** Sets whether this preview is selected by the user. */
+ fun setSelected(isSelected: Boolean) {
+ 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..a578d0e2
--- /dev/null
+++ b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/interactor/SelectablePreviewsInteractor.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.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 javax.inject.Inject
+import kotlinx.coroutines.flow.Flow
+
+class SelectablePreviewsInteractor
+@Inject
+constructor(
+ private val previewsRepo: CursorPreviewsRepository,
+ private val selectionInteractor: SelectionInteractor,
+) {
+ /** 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)
+}
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..13af92cb
--- /dev/null
+++ b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/interactor/SelectionInteractor.kt
@@ -0,0 +1,86 @@
+/*
+ * 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.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.StateFlow
+import kotlinx.coroutines.flow.map
+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: StateFlow<List<PreviewModel>>
+ get() = selectionsRepo.selections
+
+ /** Amount of selected previews. */
+ val amountSelected: Flow<Int> = selectionsRepo.selections.map { it.size }
+
+ val aggregateContentType: Flow<ContentType> = selections.map { aggregateContentType(it) }
+
+ fun select(model: PreviewModel) {
+ updateChooserRequest(selectionsRepo.selections.updateAndGet { it + model })
+ }
+
+ fun unselect(model: PreviewModel) {
+ if (selectionsRepo.selections.value.size > 1) {
+ updateChooserRequest(selectionsRepo.selections.updateAndGet { it - model })
+ }
+ }
+
+ private fun updateChooserRequest(selections: List<PreviewModel>) {
+ val intent = targetIntentModifier.intentFromSelection(selections)
+ updateTargetIntentInteractor.updateTargetIntent(intent)
+ }
+
+ private fun aggregateContentType(
+ items: List<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..437bc942
--- /dev/null
+++ b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/interactor/SetCursorPreviewsInteractor.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.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,
+ ): 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
+ },
+ )
+ 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..dd16f0c1
--- /dev/null
+++ b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/interactor/UpdateChooserRequestInteractor.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 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.getOrDefault
+import com.android.intentresolver.contentpreview.payloadtoggle.domain.model.onValue
+import com.android.intentresolver.data.repository.ChooserRequestRepository
+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 { current ->
+ current.copy(
+ targetIntent = targetIntent,
+ callerChooserTargets =
+ update.callerTargets.getOrDefault(current.callerChooserTargets),
+ modifyShareAction =
+ update.modifyShareAction.getOrDefault(current.modifyShareAction),
+ additionalTargets = update.alternateIntents.getOrDefault(current.additionalTargets),
+ chosenComponentSender =
+ update.resultIntentSender.getOrDefault(current.chosenComponentSender),
+ refinementIntentSender =
+ update.refinementIntentSender.getOrDefault(current.refinementIntentSender),
+ metadataText = update.metadataText.getOrDefault(current.metadataText),
+ chooserActions = update.customActions.getOrDefault(current.chooserActions),
+ )
+ }
+ 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..f1d856ac
--- /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?)
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..e2e69852
--- /dev/null
+++ b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/model/LoadedWindow.kt
@@ -0,0 +1,102 @@
+/*
+ * 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>(
+ /** 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(
+ 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(
+ 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(
+ 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(
+ 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..821e88a5
--- /dev/null
+++ b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/model/ShareouselUpdate.kt
@@ -0,0 +1,34 @@
+/*
+ * 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.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,
+)
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..1d34dc75
--- /dev/null
+++ b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/update/SelectionChangeCallback.kt
@@ -0,0 +1,173 @@
+/*
+ * 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.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_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.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.inject.ChooserServiceFlags
+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,
+ private val flags: ChooserServiceFlags,
+) : 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, flags)) {
+ is Valid -> {
+ result.warnings.forEach { it.log(TAG) }
+ result.value
+ }
+ is Invalid -> {
+ result.errors.forEach { it.log(TAG) }
+ null
+ }
+ }
+ }
+}
+
+private fun readCallbackResponse(
+ bundle: Bundle,
+ flags: ChooserServiceFlags
+): 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 =
+ if (flags.enableSharesheetMetadataExtra()) {
+ bundle.readValueUpdate(EXTRA_METADATA_TEXT) { key ->
+ optional(value<CharSequence>(key))
+ }
+ } else {
+ ValueUpdate.Absent
+ }
+
+ ShareouselUpdate(
+ customActions,
+ modifyShareAction,
+ alternateIntents,
+ callerTargets,
+ refinementIntentSender,
+ resultIntentSender,
+ metadataText,
+ )
+ }
+}
+
+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/PreviewModel.kt b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/shared/model/PreviewModel.kt
new file mode 100644
index 00000000..85c70004
--- /dev/null
+++ b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/shared/model/PreviewModel.kt
@@ -0,0 +1,30 @@
+/*
+ * 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(
+ /** 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,
+)
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..1d3eb4b4
--- /dev/null
+++ b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/shared/model/PreviewsModel.kt
@@ -0,0 +1,35 @@
+/*
+ * 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)?,
+)
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..a0be1a9b
--- /dev/null
+++ b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/ui/composable/ShareouselCardComposable.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.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))
+ if (contentType == ContentType.Video) {
+ AnimationIcon(modifier = Modifier.align(Alignment.TopEnd))
+ }
+ }
+ }
+}
+
+@Composable
+private fun AnimationIcon(modifier: Modifier = Modifier) {
+ Icon(
+ painterResource(id = R.drawable.ic_play_circle_filled_24px),
+ contentDescription = null, // Video 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..02d997ae
--- /dev/null
+++ b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/ui/composable/ShareouselComposable.kt
@@ -0,0 +1,220 @@
+/*
+ * 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.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.Spacer
+import androidx.compose.foundation.layout.aspectRatio
+import androidx.compose.foundation.layout.fillMaxHeight
+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.LazyRow
+import androidx.compose.foundation.lazy.itemsIndexed
+import androidx.compose.foundation.lazy.rememberLazyListState
+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.getValue
+import androidx.compose.runtime.rememberCoroutineScope
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.graphics.ColorFilter
+import androidx.compose.ui.graphics.asImageBitmap
+import androidx.compose.ui.layout.ContentScale
+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.lifecycle.compose.collectAsStateWithLifecycle
+import com.android.intentresolver.R
+import com.android.intentresolver.contentpreview.payloadtoggle.shared.ContentType
+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 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)
+ }
+}
+
+@Composable
+private fun PreviewCarousel(
+ previews: PreviewsModel,
+ viewModel: ShareouselViewModel,
+) {
+ val centerIdx = previews.startIdx
+ val carouselState = rememberLazyListState(initialFirstVisibleItemIndex = centerIdx)
+ // TODO: start item needs to be centered, check out ScalingLazyColumn impl or see if
+ // HorizontalPager works for our use-case
+ LazyRow(
+ state = carouselState,
+ horizontalArrangement = Arrangement.spacedBy(4.dp),
+ modifier =
+ Modifier.fillMaxWidth()
+ .height(dimensionResource(R.dimen.chooser_preview_image_height_tall))
+ .systemGestureExclusion()
+ ) {
+ itemsIndexed(previews.previewModels, key = { _, model -> model.uri }) { index, model ->
+ ShareouselCard(viewModel.preview(index, model))
+ }
+ }
+}
+
+@Composable
+private fun ShareouselCard(viewModel: ShareouselPreviewViewModel) {
+ val bitmap by viewModel.bitmap.collectAsStateWithLifecycle(initialValue = null)
+ 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)
+ }
+ ShareouselCard(
+ image = {
+ // TODO: max ratio is actually equal to the viewport ratio
+ val aspectRatio = viewModel.aspectRatio.coerceIn(MIN_ASPECT_RATIO, MAX_ASPECT_RATIO)
+ bitmap?.let { bitmap ->
+ Image(
+ bitmap = bitmap.asImageBitmap(),
+ contentDescription = null,
+ contentScale = ContentScale.Crop,
+ modifier = Modifier.aspectRatio(aspectRatio),
+ )
+ }
+ ?: run {
+ // TODO: look at ScrollableImagePreviewView.setLoading()
+ Box(modifier = Modifier.fillMaxHeight().aspectRatio(aspectRatio))
+ }
+ },
+ contentType = viewModel.contentType,
+ selected = selected,
+ modifier =
+ Modifier.thenIf(selected) {
+ Modifier.border(
+ width = 4.dp,
+ color = borderColor,
+ shape = RoundedCornerShape(size = 12.dp),
+ )
+ }
+ .semantics { this.contentDescription = contentDescription }
+ .clip(RoundedCornerShape(size = 12.dp))
+ .toggleable(
+ value = selected,
+ onValueChange = { scope.launch { viewModel.setSelected(it) } },
+ )
+ )
+}
+
+@Composable
+private fun ActionCarousel(viewModel: ShareouselViewModel) {
+ val actions by viewModel.actions.collectAsStateWithLifecycle(initialValue = emptyList())
+ if (actions.isNotEmpty()) {
+ Spacer(Modifier.height(16.dp))
+ LazyRow(
+ horizontalArrangement = Arrangement.spacedBy(4.dp),
+ modifier = Modifier.height(32.dp),
+ ) {
+ 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)))
+ }
+ }
+ }
+ }
+}
+
+@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,
+ )
+}
+
+inline fun Modifier.thenIf(condition: Boolean, crossinline factory: () -> Modifier): Modifier =
+ if (condition) this.then(factory()) else this
+
+private const val MIN_ASPECT_RATIO = 0.4f
+private const val MAX_ASPECT_RATIO = 2.5f
diff --git a/java/src-debug/com/android/intentresolver/flags/FeatureFlagRepositoryFactory.kt b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/ui/viewmodel/ActionChipViewModel.kt
index 4ddb0447..728c573b 100644
--- a/java/src-debug/com/android/intentresolver/flags/FeatureFlagRepositoryFactory.kt
+++ b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/ui/viewmodel/ActionChipViewModel.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.contentpreview.payloadtoggle.ui.viewmodel
-import android.content.Context
-import android.os.Handler
-import android.os.Looper
-import com.android.systemui.flags.FlagManager
+import com.android.intentresolver.icon.ComposeIcon
-class FeatureFlagRepositoryFactory {
- fun create(context: Context): FeatureFlagRepository =
- DebugFeatureFlagRepository(
- FlagManager(context, Handler(Looper.getMainLooper())),
- DeviceConfigProxy(),
- )
-}
+/** 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..540229c9
--- /dev/null
+++ b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/ui/viewmodel/ShareouselPreviewViewModel.kt
@@ -0,0 +1,34 @@
+/*
+ * 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.shared.ContentType
+import kotlinx.coroutines.flow.Flow
+
+/** An individual preview within Shareousel. */
+data class ShareouselPreviewViewModel(
+ /** Image to be shared. */
+ val bitmap: Flow<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..9d53b92a
--- /dev/null
+++ b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/ui/viewmodel/ShareouselViewModel.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.contentpreview.payloadtoggle.ui.viewmodel
+
+import com.android.intentresolver.contentpreview.CachingImagePreviewImageLoader
+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.cursor.PayloadToggle
+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.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.Binds
+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.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>>,
+ /** Creates a [ShareouselPreviewViewModel] for a [PreviewModel] present in [previews]. */
+ val preview: (index: Int, key: PreviewModel) -> ShareouselPreviewViewModel,
+)
+
+@Module
+@InstallIn(ViewModelComponent::class)
+interface ShareouselViewModelModule {
+
+ @Binds @PayloadToggle fun imageLoader(imageLoader: CachingImagePreviewImageLoader): ImageLoader
+
+ companion object {
+ @Provides
+ fun create(
+ interactor: SelectablePreviewsInteractor,
+ @PayloadToggle 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 ->
+ 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) },
+ )
+ }
+ }
+ },
+ preview = { index, key ->
+ 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
+ }
+ ShareouselPreviewViewModel(
+ bitmap = flow { emit(key.previewUri?.let { imageLoader(it) }) },
+ contentType = contentType,
+ isSelected = previewInteractor.isSelected,
+ setSelected = previewInteractor::setSelected,
+ aspectRatio = key.aspectRatio,
+ )
+ },
+ )
+ }
+ }
+}
+
+private fun PreviewsModel.maybeLoad(index: Int) {
+ when (index) {
+ previewModels.indices.firstOrNull() -> loadMoreLeft?.invoke()
+ previewModels.indices.lastOrNull() -> 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..045a17f6
--- /dev/null
+++ b/java/src/com/android/intentresolver/data/model/ChooserRequest.kt
@@ -0,0 +1,195 @@
+/*
+ * 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.ext.hasAction
+
+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,
+
+ /**
+ * 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 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
+}
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..14177b1b
--- /dev/null
+++ b/java/src/com/android/intentresolver/data/repository/ChooserRequestRepository.kt
@@ -0,0 +1,39 @@
+/*
+ * 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(
+ 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..eb35a358
--- /dev/null
+++ b/java/src/com/android/intentresolver/data/repository/DevicePolicyResources.kt
@@ -0,0 +1,153 @@
+/*
+ * 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_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 if (share) {
+ resources.getString(R.string.resolver_cant_share_with_personal_apps_explanation)
+ } else {
+ resources.getString(R.string.resolver_cant_access_personal_apps_explanation)
+ }
+ }
+
+ open fun toWorkBlockedByPolicyMessage(share: Boolean): String {
+ return if (share) {
+ resources.getString(R.string.resolver_cant_share_with_work_apps_explanation)
+ } else {
+ 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/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..2ba08c90
--- /dev/null
+++ b/java/src/com/android/intentresolver/ext/CreationExtrasExt.kt
@@ -0,0 +1,34 @@
+/*
+ * 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) }
+}
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 b303dd1a..00000000
--- a/java/src/com/android/intentresolver/flags/Flags.kt
+++ /dev/null
@@ -1,31 +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(id: Int, name: String) =
- ReleasedFlag(id, name, "systemui")
-
- private fun unreleasedFlag(id: Int, name: String, teamfood: Boolean = false) =
- UnreleasedFlag(id, name, "systemui", teamfood)
-}
diff --git a/java/src/com/android/intentresolver/grid/ChooserGridAdapter.java b/java/src/com/android/intentresolver/grid/ChooserGridAdapter.java
index 77ae20f5..7cf9d2e9 100644
--- a/java/src/com/android/intentresolver/grid/ChooserGridAdapter.java
+++ b/java/src/com/android/intentresolver/grid/ChooserGridAdapter.java
@@ -32,12 +32,14 @@ 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.FeatureFlags;
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 +49,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 +66,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 +74,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;
@@ -107,6 +90,9 @@ public final class ChooserGridAdapter extends RecyclerView.Adapter<RecyclerView.
private final boolean mShouldShowContentPreview;
private final int mChooserWidthPixels;
private final int mChooserRowTextOptionTranslatePixelSize;
+ private final FeatureFlags mFeatureFlags;
+ @Nullable
+ private RecyclerView mRecyclerView;
private int mChooserTargetWidth = 0;
@@ -119,7 +105,8 @@ public final class ChooserGridAdapter extends RecyclerView.Adapter<RecyclerView.
ChooserActivityDelegate chooserActivityDelegate,
ChooserListAdapter wrappedAdapter,
boolean shouldShowContentPreview,
- int maxTargetsPerRow) {
+ int maxTargetsPerRow,
+ FeatureFlags featureFlags) {
super();
mChooserActivityDelegate = chooserActivityDelegate;
@@ -133,6 +120,7 @@ public final class ChooserGridAdapter extends RecyclerView.Adapter<RecyclerView.
mChooserWidthPixels = context.getResources().getDimensionPixelSize(R.dimen.chooser_width);
mChooserRowTextOptionTranslatePixelSize = context.getResources().getDimensionPixelSize(
R.dimen.chooser_row_text_option_translate);
+ mFeatureFlags = featureFlags;
wrappedAdapter.registerDataSetObserver(new DataSetObserver() {
@Override
@@ -149,8 +137,25 @@ 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;
+ if (mFeatureFlags.fixTargetListFooter()) {
+ // we always have at least one view, the footer, see getItemCount() and
+ // getFooterRowCount()
+ notifyItemChanged(getItemCount() - 1);
+ }
+ }
}
/**
@@ -164,8 +169,10 @@ public final class ChooserGridAdapter extends RecyclerView.Adapter<RecyclerView.
return false;
}
- // Limit width to the maximum width of the chooser activity
- width = Math.min(mChooserWidthPixels, width);
+ // 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) {
@@ -178,9 +185,7 @@ public final class ChooserGridAdapter extends RecyclerView.Adapter<RecyclerView.
public int getRowCount() {
return (int) (
- getSystemRowCount()
- + getProfileRowCount()
- + getServiceTargetRowCount()
+ getServiceTargetRowCount()
+ getCallerAndRankedTargetRowCount()
+ getAzLabelRowCount()
+ Math.ceil(
@@ -189,35 +194,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;
}
@@ -248,38 +224,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),
@@ -302,7 +263,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");
}
}
@@ -316,6 +277,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);
}
}
@@ -341,13 +311,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;
@@ -367,12 +332,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);
}
@@ -550,8 +509,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/CachingTargetDataLoader.kt b/java/src/com/android/intentresolver/icons/CachingTargetDataLoader.kt
new file mode 100644
index 00000000..b3054231
--- /dev/null
+++ b/java/src/com/android/intentresolver/icons/CachingTargetDataLoader.kt
@@ -0,0 +1,76 @@
+/*
+ * 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.graphics.drawable.Drawable
+import android.os.UserHandle
+import androidx.collection.LruCache
+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<ComponentName, Drawable>
+
+class CachingTargetDataLoader(
+ 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)
+ ?: targetDataLoader.getOrLoadAppTargetIcon(info, userHandle) { drawable ->
+ getProfileIconCache(userHandle).put(cacheKey, drawable)
+ callback.accept(drawable)
+ }
+ }
+
+ override fun loadDirectShareIcon(
+ info: SelectableTargetInfo,
+ userHandle: UserHandle,
+ callback: Consumer<Drawable>
+ ) = targetDataLoader.loadDirectShareIcon(info, userHandle, callback)
+
+ override fun loadLabel(info: DisplayResolveInfo, callback: Consumer<LabelInfo>) =
+ targetDataLoader.loadLabel(info, callback)
+
+ override fun getOrLoadLabel(info: DisplayResolveInfo) = targetDataLoader.getOrLoadLabel(info)
+
+ private fun getCachedAppIcon(component: ComponentName, userHandle: UserHandle): Drawable? =
+ 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,
+ )
+}
diff --git a/java/src/com/android/intentresolver/icons/DefaultTargetDataLoader.kt b/java/src/com/android/intentresolver/icons/DefaultTargetDataLoader.kt
index 0e4d0209..1a724d73 100644
--- a/java/src/com/android/intentresolver/icons/DefaultTargetDataLoader.kt
+++ b/java/src/com/android/intentresolver/icons/DefaultTargetDataLoader.kt
@@ -18,7 +18,6 @@ package com.android.intentresolver.icons
import android.app.ActivityManager
import android.content.Context
-import android.content.pm.ResolveInfo
import android.graphics.drawable.Drawable
import android.os.AsyncTask
import android.os.UserHandle
@@ -63,11 +62,11 @@ 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 ->
removeTask(taskId)
@@ -75,6 +74,7 @@ class DefaultTargetDataLoader(
}
.also { addTask(taskId, it) }
.executeOnExecutor(executor)
+ return null
}
override fun loadDirectShareIcon(
@@ -95,7 +95,7 @@ class DefaultTargetDataLoader(
.executeOnExecutor(executor)
}
- 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 +105,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) }
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..0f135d63 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;
@@ -30,6 +29,7 @@ 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;
diff --git a/java/src/com/android/intentresolver/icons/LoadIconTask.java b/java/src/com/android/intentresolver/icons/LoadIconTask.java
index 37ce4093..75132208 100644
--- a/java/src/com/android/intentresolver/icons/LoadIconTask.java
+++ b/java/src/com/android/intentresolver/icons/LoadIconTask.java
@@ -24,7 +24,6 @@ import android.os.Trace;
import android.os.UserHandle;
import android.util.Log;
-import com.android.intentresolver.ResolverActivity;
import com.android.intentresolver.TargetPresentationGetter;
import com.android.intentresolver.chooser.DisplayResolveInfo;
@@ -64,8 +63,7 @@ class LoadIconTask extends BaseLoadIconTask {
protected final Drawable 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(ResolverActivity.getResolveInfoUserHandle(ri, mUserHandle));
+ return mPresentationFactory.makePresentationGetter(ri).getIcon(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..7789df44 100644
--- a/java/src/com/android/intentresolver/icons/TargetDataLoader.kt
+++ b/java/src/com/android/intentresolver/icons/TargetDataLoader.kt
@@ -16,10 +16,8 @@
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
@@ -27,11 +25,11 @@ import java.util.function.Consumer
/** A target data loader contract. Added to support testing. */
abstract class TargetDataLoader {
/** Load an app target icon */
- abstract fun loadAppTargetIcon(
+ abstract fun getOrLoadAppTargetIcon(
info: DisplayResolveInfo,
userHandle: UserHandle,
callback: Consumer<Drawable>,
- )
+ ): Drawable?
/** Load a shortcut icon */
abstract fun loadDirectShareIcon(
@@ -41,10 +39,8 @@ abstract class TargetDataLoader {
)
/** Load target label */
- abstract fun loadLabel(info: DisplayResolveInfo, callback: Consumer<Array<CharSequence?>>)
+ abstract 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 */
+ abstract 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..9c0acb11
--- /dev/null
+++ b/java/src/com/android/intentresolver/icons/TargetDataLoaderModule.kt
@@ -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 com.android.intentresolver.icons
+
+import android.content.Context
+import androidx.lifecycle.Lifecycle
+import com.android.intentresolver.inject.ActivityOwned
+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
+
+@Module
+@InstallIn(ActivityComponent::class)
+object TargetDataLoaderModule {
+ @Provides
+ @ActivityScoped
+ fun targetDataLoader(
+ @ActivityContext context: Context,
+ @ActivityOwned lifecycle: Lifecycle,
+ ): TargetDataLoader = DefaultTargetDataLoader(context, lifecycle, isAudioCaptureDevice = false)
+
+ @Provides
+ @ActivityScoped
+ @Caching
+ fun cachingTargetDataLoader(targetDataLoader: TargetDataLoader): TargetDataLoader =
+ CachingTargetDataLoader(targetDataLoader)
+}
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..bbd25eb7
--- /dev/null
+++ b/java/src/com/android/intentresolver/inject/ActivityModelModule.kt
@@ -0,0 +1,127 @@
+/*
+ * 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.service.chooser.ChooserAction
+import androidx.lifecycle.SavedStateHandle
+import com.android.intentresolver.data.model.ChooserRequest
+import com.android.intentresolver.ui.model.ActivityModel
+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
+ fun provideActivityModel(savedStateHandle: SavedStateHandle): ActivityModel =
+ requireNotNull(savedStateHandle[ActivityModel.ACTIVITY_MODEL_KEY]) {
+ "ActivityModel missing in SavedStateHandle! (${ActivityModel.ACTIVITY_MODEL_KEY})"
+ }
+
+ @Provides
+ @ChooserIntent
+ fun chooserIntent(activityModel: ActivityModel): Intent = activityModel.intent
+
+ @Provides
+ @ViewModelScoped
+ fun provideInitialRequest(
+ activityModel: ActivityModel,
+ flags: ChooserServiceFlags,
+ ): ValidationResult<ChooserRequest> = readChooserRequest(activityModel, flags)
+
+ @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)
+ }
+ }
+ }
+ }
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/FeatureFlagsModule.kt b/java/src/com/android/intentresolver/inject/FeatureFlagsModule.kt
new file mode 100644
index 00000000..d7be67db
--- /dev/null
+++ b/java/src/com/android/intentresolver/inject/FeatureFlagsModule.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.inject
+
+import android.service.chooser.FeatureFlagsImpl as ChooserServiceFlagsImpl
+import com.android.intentresolver.FeatureFlagsImpl as IntentResolverFlagsImpl
+import dagger.Module
+import dagger.Provides
+import dagger.hilt.InstallIn
+import dagger.hilt.components.SingletonComponent
+
+typealias IntentResolverFlags = com.android.intentresolver.FeatureFlags
+
+typealias FakeIntentResolverFlags = com.android.intentresolver.FakeFeatureFlagsImpl
+
+typealias ChooserServiceFlags = android.service.chooser.FeatureFlags
+
+typealias FakeChooserServiceFlags = android.service.chooser.FakeFeatureFlagsImpl
+
+@Module
+@InstallIn(SingletonComponent::class)
+object FeatureFlagsModule {
+
+ @Provides fun intentResolverFlags(): IntentResolverFlags = IntentResolverFlagsImpl()
+
+ @Provides fun chooserServiceFlags(): ChooserServiceFlags = ChooserServiceFlagsImpl()
+}
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/logging/EventLog.kt b/java/src/com/android/intentresolver/logging/EventLog.kt
new file mode 100644
index 00000000..476bd4bf
--- /dev/null
+++ b/java/src/com/android/intentresolver/logging/EventLog.kt
@@ -0,0 +1,74 @@
+/*
+ * 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()
+}
diff --git a/java/src/com/android/intentresolver/ChooserActivityLogger.java b/java/src/com/android/intentresolver/logging/EventLogImpl.java
index 1f606f26..39d23865 100644
--- a/java/src/com/android/intentresolver/ChooserActivityLogger.java
+++ b/java/src/com/android/intentresolver/logging/EventLogImpl.java
@@ -1,5 +1,5 @@
/*
- * Copyright (C) 2020 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.
@@ -14,9 +14,8 @@
* limitations under the License.
*/
-package com.android.intentresolver;
+package com.android.intentresolver.logging;
-import android.annotation.Nullable;
import android.content.Intent;
import android.metrics.LogMaker;
import android.net.Uri;
@@ -24,6 +23,9 @@ 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;
import com.android.internal.logging.InstanceId;
@@ -31,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 ChooserActivityLogger {
+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 ChooserActivityLogger() {
- this(new UiEventLoggerImpl(), new DefaultFrameworkStatsLogger(), new MetricsLogger());
+ public static InstanceIdSequence newIdSequence() {
+ return new InstanceIdSequence(SHARESHEET_INSTANCE_ID_MAX);
}
- @VisibleForTesting
- ChooserActivityLogger(
- 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)
@@ -119,6 +79,7 @@ public class ChooserActivityLogger {
}
/** Logs a UiEventReported event for the system sharesheet completing initial start-up. */
+ @Override
public void logShareStarted(
String packageName,
String mimeType,
@@ -132,7 +93,7 @@ public class ChooserActivityLogger {
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,
@@ -148,12 +109,13 @@ public class ChooserActivityLogger {
*
* @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);
}
@@ -163,6 +125,7 @@ public class ChooserActivityLogger {
* 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,
@@ -176,7 +139,7 @@ public class ChooserActivityLogger {
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);
@@ -208,6 +171,7 @@ public class ChooserActivityLogger {
}
/** Log when direct share targets were received. */
+ @Override
public void logDirectShareTargetReceived(int category, int latency) {
mMetricsLogger.write(new LogMaker(category).setSubtype(latency));
}
@@ -216,12 +180,14 @@ public class ChooserActivityLogger {
* 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(
@@ -231,12 +197,13 @@ public class ChooserActivityLogger {
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 "
@@ -247,55 +214,63 @@ public class ChooserActivityLogger {
}
/** 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);
}
/**
@@ -312,19 +287,6 @@ public class ChooserActivityLogger {
}
/**
- * @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 {
@@ -417,7 +379,9 @@ public class ChooserActivityLogger {
@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) {
@@ -487,52 +451,4 @@ public class ChooserActivityLogger {
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 bc54e01e..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.ChooserActivityLogger;
+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;
@@ -72,9 +74,10 @@ public abstract class AbstractResolverComparator implements Comparator<ResolvedC
private static final int WATCHDOG_TIMEOUT_MILLIS = 500;
private final Comparator<ResolveInfo> mAzComparator;
- private ChooserActivityLogger mChooserActivityLogger;
+ private EventLog mEventLog;
protected final Handler mHandler = new Handler(Looper.getMainLooper()) {
+ @Override
public void handleMessage(Message msg) {
switch (msg.what) {
case RANKER_SERVICE_RESULT:
@@ -94,8 +97,8 @@ public abstract class AbstractResolverComparator implements Comparator<ResolvedC
}
mHandler.removeMessages(RANKER_SERVICE_RESULT);
afterCompute();
- if (mChooserActivityLogger != null) {
- mChooserActivityLogger.logSharesheetAppShareRankingTimeout();
+ if (mEventLog != null) {
+ mEventLog.logSharesheetAppShareRankingTimeout();
}
break;
@@ -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;
}
@@ -161,12 +164,12 @@ public abstract class AbstractResolverComparator implements Comparator<ResolvedC
mAfterCompute = afterCompute;
}
- void setChooserActivityLogger(ChooserActivityLogger chooserActivityLogger) {
- mChooserActivityLogger = chooserActivityLogger;
+ void setEventLog(EventLog eventLog) {
+ mEventLog = eventLog;
}
- ChooserActivityLogger getChooserActivityLogger() {
- return mChooserActivityLogger;
+ EventLog getEventLog() {
+ return mEventLog;
}
protected final void afterCompute() {
@@ -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 ba054731..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.ChooserActivityLogger;
+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;
@@ -72,7 +74,7 @@ public class AppPredictionServiceResolverComparator extends AbstractResolverComp
String referrerPackage,
AppPredictor appPredictor,
UserHandle user,
- ChooserActivityLogger chooserActivityLogger,
+ EventLog eventLog,
@Nullable ComponentName promoteToFirst) {
super(context, intent, Lists.newArrayList(user), promoteToFirst);
mContext = context;
@@ -80,17 +82,17 @@ public class AppPredictionServiceResolverComparator extends AbstractResolverComp
mAppPredictor = appPredictor;
mUser = user;
mReferrerPackage = referrerPackage;
- setChooserActivityLogger(chooserActivityLogger);
+ setEventLog(eventLog);
mComparatorModel = buildUpdatedModel();
}
@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),
- getChooserActivityLogger(),
- 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 ebaffc36..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.ChooserActivityLogger;
+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,10 +104,10 @@ public class ResolverRankerServiceResolverComparator extends AbstractResolverCom
* the userSpace provided by context.
*/
public ResolverRankerServiceResolverComparator(Context launchedFromContext, Intent intent,
- String referrerPackage, Runnable afterCompute,
- ChooserActivityLogger chooserActivityLogger, UserHandle targetUserSpace,
- ComponentName promoteToFirst) {
- this(launchedFromContext, intent, referrerPackage, afterCompute, chooserActivityLogger,
+ 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,
- ChooserActivityLogger chooserActivityLogger, 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);
@@ -139,7 +141,7 @@ public class ResolverRankerServiceResolverComparator extends AbstractResolverCom
mAction = intent.getAction();
mRankerServiceName = new ComponentName(mContext, this.getClass());
setCallBack(afterCompute);
- setChooserActivityLogger(chooserActivityLogger);
+ setEventLog(eventLog);
mComparatorModel = buildUpdatedModel();
}
@@ -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..6cb30b41
--- /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.getString(NEARBY_SHARING_COMPONENT)?.ifEmpty { null }
+ ?: resources.getString(R.string.config_defaultNearbySharingComponent),
+ )
+ )
+}
diff --git a/java/src/com/android/intentresolver/platform/PlatformSecureSettings.kt b/java/src/com/android/intentresolver/platform/PlatformSecureSettings.kt
new file mode 100644
index 00000000..0c802c97
--- /dev/null
+++ b/java/src/com/android/intentresolver/platform/PlatformSecureSettings.kt
@@ -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.platform
+
+import android.content.ContentResolver
+import android.provider.Settings
+import javax.inject.Inject
+
+/**
+ * Implements [SecureSettings] backed by Settings.Secure and a ContentResolver.
+ *
+ * These methods make Binder calls and may block, so use on the Main thread should be avoided.
+ */
+class PlatformSecureSettings @Inject constructor(private val resolver: ContentResolver) :
+ SecureSettings {
+
+ override fun getString(name: String): String? {
+ return Settings.Secure.getString(resolver, name)
+ }
+
+ override fun getInt(name: String): Int? {
+ return runCatching { Settings.Secure.getInt(resolver, name) }.getOrNull()
+ }
+
+ override fun getLong(name: String): Long? {
+ return runCatching { Settings.Secure.getLong(resolver, name) }.getOrNull()
+ }
+
+ override fun getFloat(name: String): Float? {
+ return runCatching { Settings.Secure.getFloat(resolver, name) }.getOrNull()
+ }
+}
diff --git a/java/src/com/android/intentresolver/platform/SecureSettings.kt b/java/src/com/android/intentresolver/platform/SecureSettings.kt
new file mode 100644
index 00000000..8a1dc531
--- /dev/null
+++ b/java/src/com/android/intentresolver/platform/SecureSettings.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.platform
+
+import android.provider.Settings.SettingNotFoundException
+
+/**
+ * A component which provides access to values from [android.provider.Settings.Secure].
+ *
+ * All methods return nullable types instead of throwing [SettingNotFoundException] which yields
+ * cleaner, more idiomatic Kotlin code:
+ *
+ * // apply a default: val foo = settings.getInt(FOO) ?: DEFAULT_FOO
+ *
+ * // assert if missing: val required = settings.getInt(REQUIRED_VALUE) ?: error("required value
+ * missing")
+ */
+interface SecureSettings {
+
+ fun getString(name: String): String?
+
+ fun getInt(name: String): Int?
+
+ fun getLong(name: String): Long?
+
+ fun getFloat(name: String): Float?
+}
diff --git a/java/src/com/android/intentresolver/platform/SecureSettingsModule.kt b/java/src/com/android/intentresolver/platform/SecureSettingsModule.kt
new file mode 100644
index 00000000..fa3ee4fe
--- /dev/null
+++ b/java/src/com/android/intentresolver/platform/SecureSettingsModule.kt
@@ -0,0 +1,30 @@
+/*
+ * 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 SecureSettingsModule {
+
+ @Binds @Reusable fun secureSettings(settings: PlatformSecureSettings): SecureSettings
+}
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..8aee0da1 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,7 @@
* limitations under the License.
*/
-package com.android.intentresolver;
+package com.android.intentresolver.profiles;
import android.content.Context;
import android.os.UserHandle;
@@ -25,9 +25,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 +40,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(
+ public ChooserMultiProfilePagerAdapter(
Context context,
- ChooserGridAdapter adapter,
+ ImmutableList<TabConfig<ChooserGridAdapter>> tabs,
EmptyStateProvider emptyStateProvider,
Supplier<Boolean> workProfileQuietModeChecker,
+ @ProfileType int defaultProfile,
UserHandle workProfileUserHandle,
UserHandle cloneProfileUserHandle,
int maxTargetsPerRow) {
this(
context,
new ChooserProfileAdapterBinder(maxTargetsPerRow),
- ImmutableList.of(adapter),
- emptyStateProvider,
- workProfileQuietModeChecker,
- /* defaultProfile= */ 0,
- workProfileUserHandle,
- cloneProfileUserHandle,
- new BottomPaddingOverrideSupplier(context));
- }
-
- ChooserMultiProfilePagerAdapter(
- Context context,
- ChooserGridAdapter personalAdapter,
- ChooserGridAdapter workAdapter,
- EmptyStateProvider emptyStateProvider,
- Supplier<Boolean> workProfileQuietModeChecker,
- @Profile int defaultProfile,
- UserHandle workProfileUserHandle,
- UserHandle cloneProfileUserHandle,
- int maxTargetsPerRow) {
- this(
- context,
- new ChooserProfileAdapterBinder(maxTargetsPerRow),
- ImmutableList.of(personalAdapter, workAdapter),
+ tabs,
emptyStateProvider,
workProfileQuietModeChecker,
defaultProfile,
@@ -90,24 +71,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 +99,7 @@ public class ChooserMultiProfilePagerAdapter extends GenericMultiProfilePagerAda
public void setEmptyStateBottomOffset(int bottomOffset) {
mBottomPaddingOverrideSupplier.setEmptyStateBottomOffset(bottomOffset);
+ setupContainerPadding();
}
/**
@@ -127,14 +108,14 @@ 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);
}
}
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);
RecyclerView recyclerView = rootView.findViewById(com.android.internal.R.id.resolver_list);
recyclerView.setAccessibilityDelegateCompat(
new ChooserRecyclerViewAccessibilityDelegate(recyclerView));
@@ -142,19 +123,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 +167,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/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 3ffbe039..08230d90 100644
--- a/java/src/com/android/intentresolver/shortcuts/ShortcutLoader.kt
+++ b/java/src/com/android/intentresolver/shortcuts/ShortcutLoader.kt
@@ -35,14 +35,13 @@ 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.chooser.DisplayResolveInfo
import com.android.intentresolver.measurements.Tracer
import com.android.intentresolver.measurements.runTracing
import java.util.concurrent.Executor
import java.util.function.Consumer
import kotlinx.coroutines.CoroutineDispatcher
+import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.asExecutor
import kotlinx.coroutines.channels.BufferOverflow
@@ -50,6 +49,7 @@ 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,14 +58,14 @@ 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,
+ private val scope: CoroutineScope,
private val appPredictor: AppPredictorProxy?,
private val userHandle: UserHandle,
private val isPersonalProfile: Boolean,
@@ -75,7 +75,9 @@ constructor(
) {
private val shortcutToChooserTargetConverter = ShortcutToChooserTargetConverter()
private val userManager = context.getSystemService(Context.USER_SERVICE) as UserManager
- private val appPredictorCallback = AppPredictor.Callback { onAppPredictorCallback(it) }
+ private val appPredictorCallback =
+ ScopedAppTargetListCallback(scope) { onAppPredictorCallback(it) }.toAppPredictorCallback()
+
private val appTargetSource =
MutableSharedFlow<Array<DisplayResolveInfo>?>(
replay = 1,
@@ -84,19 +86,19 @@ constructor(
private val shortcutSource =
MutableSharedFlow<ShortcutData?>(replay = 1, onBufferOverflow = BufferOverflow.DROP_OLDEST)
private val isDestroyed
- get() = !lifecycle.currentState.isAtLeast(Lifecycle.State.CREATED)
+ get() = !scope.isActive
@MainThread
constructor(
context: Context,
- lifecycle: Lifecycle,
+ scope: CoroutineScope,
appPredictor: AppPredictor?,
userHandle: UserHandle,
targetIntentFilter: IntentFilter?,
callback: Consumer<Result>
) : this(
context,
- lifecycle,
+ scope,
appPredictor?.let { AppPredictorProxy(it) },
userHandle,
userHandle == UserHandle.of(ActivityManager.getCurrentUser()),
@@ -107,7 +109,7 @@ constructor(
init {
appPredictor?.registerPredictionUpdates(dispatcher.asExecutor(), appPredictorCallback)
- lifecycle.coroutineScope
+ scope
.launch {
appTargetSource
.combine(shortcutSource) { appTargets, shortcutData ->
@@ -135,12 +137,13 @@ constructor(
reset()
}
- /** Clear application targets (see [updateAppTargets] and initiate shrtcuts loading. */
- fun reset() {
+ /** Clear application targets (see [updateAppTargets] and initiate shortcuts loading. */
+ @OpenForTesting
+ open fun reset() {
Log.d(TAG, "reset shortcut loader for user $userHandle")
appTargetSource.tryEmit(null)
shortcutSource.tryEmit(null)
- lifecycle.coroutineScope.launch(dispatcher) { loadShortcuts() }
+ scope.launch(dispatcher) { loadShortcuts() }
}
/**
@@ -183,6 +186,11 @@ constructor(
// Default to just querying ShortcutManager if AppPredictor not present.
if (targetIntentFilter == null) {
Log.d(TAG, "skip querying ShortcutManager for $userHandle")
+ sendShareShortcutInfoList(
+ emptyList(),
+ isFromAppPredictor = false,
+ appPredictorTargets = null
+ )
return
}
Log.d(TAG, "query ShortcutManager for user $userHandle")
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..7be2076e
--- /dev/null
+++ b/java/src/com/android/intentresolver/ui/ShareResultSender.kt
@@ -0,0 +1,163 @@
+/*
+ * 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.ChooserServiceFlags
+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)
+
+ /** 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(
+ private val flags: ChooserServiceFlags,
+ @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,
+ flags: ChooserServiceFlags,
+ @Main scope: CoroutineScope,
+ @Background backgroundDispatcher: CoroutineDispatcher,
+ @Assisted callerUid: Int,
+ @Assisted chosenComponentSender: IntentSender,
+ ) : this(
+ flags,
+ scope,
+ backgroundDispatcher,
+ callerUid,
+ chosenComponentSender,
+ IntentSenderDispatcher { sender, intent -> sender.dispatchIntent(context, intent) }
+ )
+
+ override fun onComponentSelected(component: ComponentName, directShare: Boolean) {
+ Log.i(TAG, "onComponentSelected: $component directShare=$directShare")
+ scope.launch {
+ val intent = createChosenComponentIntent(component, directShare)
+ intentDispatcher.dispatchIntent(resultSender, intent)
+ }
+ }
+
+ override fun onActionSelected(action: ShareAction) {
+ Log.i(TAG, "onActionSelected: $action")
+ scope.launch {
+ if (flags.enableChooserResult() && 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,
+ ): Intent {
+ // Add extra with component name for backwards compatibility.
+ val intent: Intent = Intent().putExtra(Intent.EXTRA_CHOSEN_COMPONENT, component)
+
+ // Add ChooserResult value for Android V+
+ if (flags.enableChooserResult() && chooserResultSupported(callerUid)) {
+ intent.putExtra(
+ Intent.EXTRA_CHOOSER_RESULT,
+ ChooserResult(CHOOSER_RESULT_SELECTED_COMPONENT, component, direct)
+ )
+ } else {
+ 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/ActivityModel.kt b/java/src/com/android/intentresolver/ui/model/ActivityModel.kt
new file mode 100644
index 00000000..4bcdd69b
--- /dev/null
+++ b/java/src/com/android/intentresolver/ui/model/ActivityModel.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.ui.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?
+) : Parcelable {
+ constructor(
+ source: Parcel
+ ) : this(
+ intent = source.requireParcelable(),
+ launchedFromUid = source.readInt(),
+ launchedFromPackage = requireNotNull(source.readString()),
+ referrer = source.readParcelable()
+ )
+
+ /** 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)
+ }
+
+ companion object {
+ const val ACTIVITY_MODEL_KEY = "com.android.intentresolver.ACTIVITY_MODEL"
+
+ @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
+ )
+ }
+ }
+}
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..a9b6de7e
--- /dev/null
+++ b/java/src/com/android/intentresolver/ui/viewmodel/ChooserRequestReader.kt
@@ -0,0 +1,198 @@
+/*
+ * 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_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_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.IntentFilter
+import android.content.IntentSender
+import android.net.Uri
+import android.os.Bundle
+import android.service.chooser.ChooserAction
+import android.service.chooser.ChooserTarget
+import com.android.intentresolver.ChooserActivity
+import com.android.intentresolver.ContentTypeHint
+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.inject.ChooserServiceFlags
+import com.android.intentresolver.ui.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
+
+internal fun Intent.maybeAddSendActionFlags() =
+ ifMatch(Intent::hasSendAction) {
+ addFlags(FLAG_ACTIVITY_NEW_DOCUMENT)
+ addFlags(FLAG_ACTIVITY_MULTIPLE_TASK)
+ }
+
+fun readChooserRequest(
+ model: ActivityModel,
+ flags: ChooserServiceFlags
+): ValidationResult<ChooserRequest> {
+ val extras = model.intent.extras ?: Bundle()
+ @Suppress("DEPRECATION")
+ return validateFrom(extras::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 sharedText = optional(value<CharSequence>(EXTRA_TEXT))
+
+ val chooserActions = readChooserActions() ?: emptyList()
+
+ val modifyShareAction = optional(value<ChooserAction>(EXTRA_CHOOSER_MODIFY_SHARE_ACTION))
+
+ val additionalContentUri: Uri?
+ val focusedItemPos: Int
+ if (isSendAction && flags.chooserPayloadToggling()) {
+ additionalContentUri = optional(value<Uri>(Intent.EXTRA_CHOOSER_ADDITIONAL_CONTENT_URI))
+ focusedItemPos = optional(value<Int>(Intent.EXTRA_CHOOSER_FOCUSED_ITEM_POSITION)) ?: 0
+ } else {
+ additionalContentUri = null
+ focusedItemPos = 0
+ }
+
+ val contentTypeHint =
+ if (flags.chooserAlbumText()) {
+ when (optional(value<Int>(Intent.EXTRA_CHOOSER_CONTENT_TYPE_HINT))) {
+ Intent.CHOOSER_CONTENT_TYPE_ALBUM -> ContentTypeHint.ALBUM
+ else -> ContentTypeHint.NONE
+ }
+ } else {
+ ContentTypeHint.NONE
+ }
+
+ val metadataText =
+ if (flags.enableSharesheetMetadataExtra()) {
+ optional(value<CharSequence>(EXTRA_METADATA_TEXT))
+ } else {
+ null
+ }
+
+ ChooserRequest(
+ targetIntent = targetIntent,
+ targetAction = targetIntent.action,
+ isSendActionTarget = isSendAction,
+ targetType = targetIntent.type,
+ launchedFromPackage =
+ requireNotNull(model.launchedFromPackage) {
+ "launch.fromPackage was null, See Activity.getLaunchedFromPackage()"
+ },
+ title = customTitle,
+ defaultTitleResource = defaultTitleResource,
+ referrer = model.referrer,
+ filteredComponentNames = filteredComponents,
+ callerChooserTargets = callerChooserTargets,
+ chooserActions = chooserActions,
+ modifyShareAction = modifyShareAction,
+ shouldRetainInOnStop = retainInOnStop,
+ additionalTargets = additionalTargets,
+ replacementExtras = replacementExtras,
+ initialIntents = initialIntents,
+ chosenComponentSender = chosenComponentSender,
+ refinementIntentSender = refinementIntentSender,
+ sharedText = sharedText,
+ shareTargetFilter = targetIntent.toShareTargetFilter(),
+ additionalContentUri = additionalContentUri,
+ focusedItemPosition = focusedItemPos,
+ contentTypeHint = contentTypeHint,
+ metadataText = metadataText,
+ )
+ }
+}
+
+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)
+
+private fun Intent.toShareTargetFilter(): IntentFilter? {
+ return type?.let {
+ IntentFilter().apply {
+ action?.also { addAction(it) }
+ addDataType(it)
+ }
+ }
+}
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..c9cae3db
--- /dev/null
+++ b/java/src/com/android/intentresolver/ui/viewmodel/ChooserViewModel.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.viewmodel
+
+import android.util.Log
+import androidx.lifecycle.SavedStateHandle
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.viewModelScope
+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.ChooserRequestRepository
+import com.android.intentresolver.inject.Background
+import com.android.intentresolver.inject.ChooserServiceFlags
+import com.android.intentresolver.ui.model.ActivityModel
+import com.android.intentresolver.ui.model.ActivityModel.Companion.ACTIVITY_MODEL_KEY
+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
+
+private const val TAG = "ChooserViewModel"
+
+@HiltViewModel
+class ChooserViewModel
+@Inject
+constructor(
+ args: SavedStateHandle,
+ private val shareouselViewModelProvider: Lazy<ShareouselViewModel>,
+ private val processUpdatesInteractor: Lazy<ProcessTargetIntentUpdatesInteractor>,
+ private val fetchPreviewsInteractor: Lazy<FetchPreviewsInteractor>,
+ @Background private val bgDispatcher: CoroutineDispatcher,
+ private val flags: ChooserServiceFlags,
+ /**
+ * 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>,
+) : ViewModel() {
+
+ /** Parcelable-only references provided from the creating Activity */
+ val activityModel: ActivityModel =
+ requireNotNull(args[ACTIVITY_MODEL_KEY]) {
+ "ActivityModel missing in SavedStateHandle! ($ACTIVITY_MODEL_KEY)"
+ }
+
+ 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
+ assert(flags.chooserPayloadToggling()) {
+ "An attempt to use payload selection preview with the disabled flag"
+ }
+
+ 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()
+
+ init {
+ if (initialRequest is Invalid) {
+ Log.w(TAG, "initialRequest is Invalid, initialization failed")
+ }
+ }
+}
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..856d9fdd
--- /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.Profile
+import com.android.intentresolver.ui.model.ActivityModel
+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..a3dc58a6
--- /dev/null
+++ b/java/src/com/android/intentresolver/ui/viewmodel/ResolverViewModel.kt
@@ -0,0 +1,70 @@
+/*
+ * 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.SavedStateHandle
+import androidx.lifecycle.ViewModel
+import com.android.intentresolver.ui.model.ActivityModel
+import com.android.intentresolver.ui.model.ActivityModel.Companion.ACTIVITY_MODEL_KEY
+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(args: SavedStateHandle) : ViewModel() {
+
+ /** Parcelable-only references provided from the creating Activity */
+ val activityModel: ActivityModel =
+ requireNotNull(args[ACTIVITY_MODEL_KEY]) {
+ "ActivityModel missing in SavedStateHandle! ($ACTIVITY_MODEL_KEY)"
+ }
+
+ /**
+ * 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..70c46c47
--- /dev/null
+++ b/java/src/com/android/intentresolver/util/ParallelIteration.kt
@@ -0,0 +1,50 @@
+/*
+ * 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()
+ }
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/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/tests/src/com/android/intentresolver/TestLifecycleOwner.kt b/java/src/com/android/intentresolver/validation/ValidationResult.kt
index 7e588f98..9685c70d 100644
--- a/java/tests/src/com/android/intentresolver/TestLifecycleOwner.kt
+++ b/java/src/com/android/intentresolver/validation/ValidationResult.kt
@@ -13,21 +13,14 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
+package com.android.intentresolver.validation
-package com.android.intentresolver
+sealed interface ValidationResult<T>
-import androidx.lifecycle.Lifecycle
-import androidx.lifecycle.LifecycleOwner
-import androidx.lifecycle.LifecycleRegistry
+data class Valid<T>(val value: T, val warnings: List<Finding> = emptyList()) : ValidationResult<T> {
+ constructor(value: T, warning: Finding) : this(value, listOf(warning))
+}
-internal class TestLifecycleOwner : LifecycleOwner {
- private val lifecycleRegistry = LifecycleRegistry.createUnsafe(this)
-
- override val lifecycle: Lifecycle get() = lifecycleRegistry
-
- var state: Lifecycle.State
- get() = lifecycle.currentState
- set(value) {
- lifecycleRegistry.currentState = value
- }
-} \ No newline at end of file
+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..e86de888
--- /dev/null
+++ b/java/src/com/android/intentresolver/widget/ChooserNestedScrollView.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.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 androidx.core.widget.NestedScrollView
+
+/**
+ * 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)
+
+ 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
+ }
+ }
+}
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/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..2c8140d9 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;
@@ -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(
@@ -832,6 +844,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 +856,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 +903,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 +999,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 +1324,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/ScrollableImagePreviewView.kt b/java/src/com/android/intentresolver/widget/ScrollableImagePreviewView.kt
index 583a2887..7fe16091 100644
--- a/java/src/com/android/intentresolver/widget/ScrollableImagePreviewView.kt
+++ b/java/src/com/android/intentresolver/widget/ScrollableImagePreviewView.kt
@@ -26,11 +26,16 @@ 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
@@ -39,14 +44,13 @@ import com.android.intentresolver.widget.ImagePreviewView.TransitionElementStatu
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
-import kotlinx.coroutines.MainScope
import kotlinx.coroutines.cancel
+import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableSharedFlow
-import kotlinx.coroutines.flow.onCompletion
import kotlinx.coroutines.flow.takeWhile
import kotlinx.coroutines.joinAll
import kotlinx.coroutines.launch
-import kotlinx.coroutines.plus
+import kotlinx.coroutines.suspendCancellableCoroutine
private const val TRANSITION_NAME = "screenshot_preview_image"
private const val PLURALS_COUNT = "count"
@@ -67,7 +71,6 @@ class ScrollableImagePreviewView : RecyclerView, ImagePreviewView {
defStyleAttr: Int
) : super(context, attrs, defStyleAttr) {
layoutManager = LinearLayoutManager(context, LinearLayoutManager.HORIZONTAL, false)
- adapter = Adapter(context)
context
.obtainStyledAttributes(attrs, R.styleable.ScrollableImagePreviewView, defStyleAttr, 0)
@@ -100,11 +103,14 @@ class ScrollableImagePreviewView : RecyclerView, ImagePreviewView {
)
.toInt()
}
- addItemDecoration(SpacingDecoration(innerSpacing, outerSpacing))
+ super.addItemDecoration(SpacingDecoration(innerSpacing, outerSpacing))
maxWidthHint =
a.getDimensionPixelSize(R.styleable.ScrollableImagePreviewView_maxWidthHint, -1)
}
+ val itemAnimator = ItemAnimator()
+ super.setItemAnimator(itemAnimator)
+ super.setAdapter(Adapter(context, itemAnimator.getAddDuration()))
}
private var batchLoader: BatchPreviewLoader? = null
@@ -127,7 +133,7 @@ class ScrollableImagePreviewView : RecyclerView, ImagePreviewView {
isMeasured = true
updateMaxWidthHint(widthSpec)
updateMaxAspectRatio()
- batchLoader?.loadAspectRatios(getMaxWidth(), this::updatePreviewSize)
+ maybeLoadAspectRatios()
}
}
@@ -145,6 +151,17 @@ class ScrollableImagePreviewView : RecyclerView, ImagePreviewView {
)
}
+ override fun onAttachedToWindow() {
+ super.onAttachedToWindow()
+ batchLoader?.totalItemCount?.let(previewAdapter::reset)
+ maybeLoadAspectRatios()
+ }
+
+ override fun onDetachedFromWindow() {
+ batchLoader?.cancel()
+ super.onDetachedFromWindow()
+ }
+
override fun setTransitionElementStatusCallback(callback: TransitionElementStatusCallback?) {
previewAdapter.transitionStatusElementCallback = callback
}
@@ -158,32 +175,46 @@ class ScrollableImagePreviewView : RecyclerView, ImagePreviewView {
return null
}
- fun setPreviews(previews: List<Preview>, otherItemCount: Int, imageLoader: CachingImageLoader) {
- previewAdapter.reset(0, imageLoader)
+ 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
+ }
+
+ fun setLoading(totalItemCount: Int) {
+ previewAdapter.reset(totalItemCount)
+ }
+
+ fun setPreviews(previews: Flow<Preview>, totalItemCount: Int) {
+ previewAdapter.reset(totalItemCount)
batchLoader?.cancel()
batchLoader =
BatchPreviewLoader(
- imageLoader,
- previews,
- otherItemCount,
- onReset = { totalItemCount ->
- previewAdapter.reset(totalItemCount, imageLoader)
- },
- onUpdate = previewAdapter::addPreviews,
- onCompletion = {
- if (!previewAdapter.hasPreviews) {
- onNoPreviewCallback?.run()
- }
- }
- )
- .apply {
- if (isMeasured) {
- loadAspectRatios(
- getMaxWidth(),
- this@ScrollableImagePreviewView::updatePreviewSize
- )
+ previewAdapter.imageLoader ?: error("Image loader is not set"),
+ previews,
+ totalItemCount,
+ onUpdate = previewAdapter::addPreviews,
+ onCompletion = {
+ batchLoader = null
+ if (!previewAdapter.hasPreviews) {
+ onNoPreviewCallback?.run()
}
+ previewAdapter.markLoaded()
}
+ )
+ maybeLoadAspectRatios()
+ }
+
+ private fun maybeLoadAspectRatios() {
+ if (isMeasured && isAttachedToWindow()) {
+ batchLoader?.let { it.loadAspectRatios(getMaxWidth(), this::updatePreviewSize) }
+ }
}
var onNoPreviewCallback: Runnable? = null
@@ -254,7 +285,10 @@ class ScrollableImagePreviewView : RecyclerView, ImagePreviewView {
File
}
- private class Adapter(private val context: Context) : RecyclerView.Adapter<ViewHolder>() {
+ private class Adapter(
+ private val context: Context,
+ private val fadeInDurationMs: Long,
+ ) : RecyclerView.Adapter<ViewHolder>() {
private val previews = ArrayList<Preview>()
private val imagePreviewDescription =
context.resources.getString(R.string.image_preview_a11y_description)
@@ -262,10 +296,11 @@ class ScrollableImagePreviewView : RecyclerView, ImagePreviewView {
context.resources.getString(R.string.video_preview_a11y_description)
private val filePreviewDescription =
context.resources.getString(R.string.file_preview_a11y_description)
- private var imageLoader: CachingImageLoader? = null
+ var imageLoader: CachingImageLoader? = null
private var firstImagePos = -1
private var totalItemCount: Int = 0
+ private var isLoading = false
private val hasOtherItem
get() = previews.size < totalItemCount
val hasPreviews: Boolean
@@ -273,65 +308,86 @@ class ScrollableImagePreviewView : RecyclerView, ImagePreviewView {
var transitionStatusElementCallback: TransitionElementStatusCallback? = null
- fun reset(totalItemCount: Int, imageLoader: CachingImageLoader) {
- this.imageLoader = imageLoader
+ fun reset(totalItemCount: Int) {
firstImagePos = -1
previews.clear()
this.totalItemCount = maxOf(0, totalItemCount)
+ isLoading = this.totalItemCount > 0
notifyDataSetChanged()
}
+ fun markLoaded() {
+ if (!isLoading) return
+ isLoading = false
+ if (hasOtherItem) {
+ notifyItemChanged(previews.size)
+ } else {
+ notifyItemRemoved(previews.size)
+ }
+ }
+
fun addPreviews(newPreviews: Collection<Preview>) {
if (newPreviews.isEmpty()) return
val insertPos = previews.size
val hadOtherItem = hasOtherItem
+ val oldItemCount = getItemCount()
previews.addAll(newPreviews)
if (firstImagePos < 0) {
val pos = newPreviews.indexOfFirst { it.type == PreviewType.Image }
if (pos >= 0) firstImagePos = insertPos + pos
}
- notifyItemRangeInserted(insertPos, newPreviews.size)
- when {
- hadOtherItem && previews.size >= totalItemCount -> {
- notifyItemRemoved(previews.size)
+ if (insertPos == 0) {
+ if (oldItemCount > 0) {
+ notifyItemRangeRemoved(0, oldItemCount)
}
- !hadOtherItem && previews.size < totalItemCount -> {
- notifyItemInserted(previews.size)
+ notifyItemRangeInserted(insertPos, getItemCount())
+ } else {
+ notifyItemRangeInserted(insertPos, newPreviews.size)
+ when {
+ hadOtherItem && !hasOtherItem -> {
+ notifyItemRemoved(previews.size)
+ }
+ !hadOtherItem && hasOtherItem -> {
+ notifyItemInserted(previews.size)
+ }
+ else -> notifyItemChanged(previews.size)
}
}
}
override fun onCreateViewHolder(parent: ViewGroup, itemType: Int): ViewHolder {
val view = LayoutInflater.from(context).inflate(itemType, parent, false)
- return if (itemType == R.layout.image_preview_other_item) {
- OtherItemViewHolder(view)
- } else {
- PreviewViewHolder(
- view,
- imagePreviewDescription,
- videoPreviewDescription,
- filePreviewDescription,
- )
+ return when (itemType) {
+ R.layout.image_preview_other_item -> OtherItemViewHolder(view)
+ R.layout.image_preview_loading_item -> LoadingItemViewHolder(view)
+ else ->
+ PreviewViewHolder(
+ view,
+ imagePreviewDescription,
+ videoPreviewDescription,
+ filePreviewDescription,
+ )
}
}
- override fun getItemCount(): Int = previews.size + if (hasOtherItem) 1 else 0
+ override fun getItemCount(): Int = previews.size + if (isLoading || hasOtherItem) 1 else 0
- override fun getItemViewType(position: Int): Int {
- return if (position == previews.size) {
- R.layout.image_preview_other_item
- } else {
- R.layout.image_preview_image_item
+ override fun getItemViewType(position: Int): Int =
+ when {
+ position == previews.size && isLoading -> R.layout.image_preview_loading_item
+ position == previews.size -> R.layout.image_preview_other_item
+ else -> R.layout.image_preview_image_item
}
- }
override fun onBindViewHolder(vh: ViewHolder, position: Int) {
when (vh) {
is OtherItemViewHolder -> vh.bind(totalItemCount - previews.size)
+ is LoadingItemViewHolder -> vh.bind()
is PreviewViewHolder ->
vh.bind(
previews[position],
imageLoader ?: error("ImageLoader is missing"),
+ fadeInDurationMs,
isSharedTransitionElement = position == firstImagePos,
previewReadyCallback =
if (
@@ -382,10 +438,13 @@ class ScrollableImagePreviewView : RecyclerView, ImagePreviewView {
fun bind(
preview: Preview,
imageLoader: CachingImageLoader,
+ fadeInDurationMs: Long,
isSharedTransitionElement: Boolean,
previewReadyCallback: ((String) -> Unit)?
) {
image.setImageDrawable(null)
+ image.alpha = 1f
+ image.clearAnimation()
(image.layoutParams as? ConstraintLayout.LayoutParams)?.let { params ->
params.dimensionRatio = preview.aspectRatioString
}
@@ -419,11 +478,11 @@ class ScrollableImagePreviewView : RecyclerView, ImagePreviewView {
}
resetScope().launch {
loadImage(preview, imageLoader)
- if (preview.type == PreviewType.Image) {
- previewReadyCallback?.let { callback ->
- image.waitForPreDraw()
- callback(TRANSITION_NAME)
- }
+ if (preview.type == PreviewType.Image && previewReadyCallback != null) {
+ image.waitForPreDraw()
+ previewReadyCallback(TRANSITION_NAME)
+ } else if (image.isAttachedToWindow()) {
+ fadeInPreview(fadeInDurationMs)
}
}
}
@@ -439,8 +498,32 @@ class ScrollableImagePreviewView : RecyclerView, ImagePreviewView {
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 =
- (MainScope() + Dispatchers.Main.immediate).also {
+ CoroutineScope(Dispatchers.Main.immediate).also {
scope?.cancel()
scope = it
}
@@ -466,6 +549,11 @@ class ScrollableImagePreviewView : RecyclerView, ImagePreviewView {
override fun unbind() = Unit
}
+ private class LoadingItemViewHolder(view: View) : ViewHolder(view) {
+ fun bind() = Unit
+ override fun unbind() = Unit
+ }
+
private class SpacingDecoration(private val innerSpacing: Int, private val outerSpacing: Int) :
ItemDecoration() {
override fun getItemOffsets(outRect: Rect, view: View, parent: RecyclerView, state: State) {
@@ -482,30 +570,89 @@ 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,
- previews: List<Preview>,
- otherItemCount: Int,
- private val onReset: (Int) -> Unit,
+ private val previews: Flow<Preview>,
+ val totalItemCount: Int,
private val onUpdate: (List<Preview>) -> Unit,
private val onCompletion: () -> Unit,
) {
- private val previews: List<Preview> =
- if (previews is RandomAccess) previews else ArrayList(previews)
- private val totalItemCount = previews.size + otherItemCount
- private var scope: CoroutineScope? = MainScope() + Dispatchers.Main.immediate
+ private var scope: CoroutineScope = createScope()
+
+ private fun createScope() = CoroutineScope(Dispatchers.Main.immediate)
fun cancel() {
- scope?.cancel()
- scope = null
+ scope.cancel()
+ scope = createScope()
}
fun loadAspectRatios(maxWidth: Int, previewSizeUpdater: (Preview, Int, Int) -> Int) {
- val scope = this.scope ?: return
- // -1 encodes that the preview has not been processed,
- // 0 means failed, > 0 is a preview width
- val previewWidths = IntArray(previews.size) { -1 }
+ val previewInfos = ArrayList<PreviewWidthInfo>(totalItemCount)
var blockStart = 0 // inclusive
var blockEnd = 0 // exclusive
@@ -514,26 +661,16 @@ class ScrollableImagePreviewView : RecyclerView, ImagePreviewView {
val updateEvent = Any()
val completedEvent = Any()
- // throttle adapter updates using flow; the flow first emits when enough preview
- // elements is loaded to fill the viewport and then each time a subsequent block of
- // previews is loaded
+ // collects updates from [reportFlow] throttling adapter updates;
scope.launch(Dispatchers.Main) {
reportFlow
.takeWhile { it !== completedEvent }
.throttle(ADAPTER_UPDATE_INTERVAL_MS)
- .onCompletion { cause ->
- if (cause == null) {
- onCompletion()
- }
- }
.collect {
- if (blockStart == 0) {
- onReset(totalItemCount)
- }
val updates = ArrayList<Preview>(blockEnd - blockStart)
while (blockStart < blockEnd) {
- if (previewWidths[blockStart] > 0) {
- updates.add(previews[blockStart])
+ if (previewInfos[blockStart].width > 0) {
+ updates.add(previewInfos[blockStart].preview)
}
blockStart++
}
@@ -541,57 +678,64 @@ class ScrollableImagePreviewView : RecyclerView, ImagePreviewView {
onUpdate(updates)
}
}
+ onCompletion()
}
+ // Collects [previews] flow and loads aspect ratios, emits updates into [reportFlow]
+ // when a next sequential block of preview aspect ratios is loaded: initially emits when
+ // enough preview elements is loaded to fill the viewport.
scope.launch {
var blockWidth = 0
var isFirstBlock = true
- var nextIdx = 0
- List<Job>(4) {
- launch {
- while (true) {
- val i = nextIdx++
- if (i >= previews.size) break
- val preview = previews[i]
-
- previewWidths[i] =
- runCatching {
- // TODO: decide on adding a timeout
- imageLoader(preview.uri, isFirstBlock)?.let { bitmap ->
- previewSizeUpdater(
- preview,
- bitmap.width,
- bitmap.height
- )
- }
- ?: 0
- }
- .getOrDefault(0)
-
- if (blockEnd != i) continue
- while (
- blockEnd < previewWidths.size && previewWidths[blockEnd] >= 0
- ) {
- blockWidth += previewWidths[blockEnd]
- blockEnd++
- }
- if (isFirstBlock) {
- if (blockWidth >= maxWidth) {
- isFirstBlock = false
- // notify that the preview now can be displayed
- reportFlow.emit(updateEvent)
+
+ val jobs = ArrayList<Job>()
+ previews.collect { preview ->
+ val i = previewInfos.size
+ val pair = PreviewWidthInfo(preview)
+ previewInfos.add(pair)
+
+ val job = launch {
+ pair.width =
+ runCatching {
+ // TODO: decide on adding a timeout. The worst case I can
+ // 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 ->
+ previewSizeUpdater(preview, bitmap.width, bitmap.height)
}
- } else {
- reportFlow.emit(updateEvent)
+ ?: 0
}
+ .getOrDefault(0)
+
+ if (i == blockEnd) {
+ while (
+ blockEnd < previewInfos.size && previewInfos[blockEnd].width >= 0
+ ) {
+ blockWidth += previewInfos[blockEnd].width
+ blockEnd++
+ }
+ if (isFirstBlock && blockWidth >= maxWidth) {
+ isFirstBlock = false
+ }
+ if (!isFirstBlock) {
+ reportFlow.emit(updateEvent)
}
}
}
- .joinAll()
+ jobs.add(job)
+ }
+ jobs.joinAll()
// in case all previews have failed to load
reportFlow.emit(updateEvent)
reportFlow.emit(completedEvent)
}
}
}
+
+ private class PreviewWidthInfo(val preview: Preview) {
+ // -1 encodes that the preview has not been processed,
+ // 0 means failed, > 0 is a preview width
+ var width: Int = -1
+ }
}
diff --git a/java/src/com/android/intentresolver/widget/ViewExtensions.kt b/java/src/com/android/intentresolver/widget/ViewExtensions.kt
index 11b7c146..d19933f5 100644
--- a/java/src/com/android/intentresolver/widget/ViewExtensions.kt
+++ b/java/src/com/android/intentresolver/widget/ViewExtensions.kt
@@ -19,21 +19,26 @@ package com.android.intentresolver.widget
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() }
}
diff --git a/java/tests/Android.bp b/java/tests/Android.bp
deleted file mode 100644
index c381d0a8..00000000
--- a/java/tests/Android.bp
+++ /dev/null
@@ -1,39 +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.rules",
- "androidx.test.ext.junit",
- "androidx.test.espresso.contrib",
- "mockito-target-minus-junit4",
- "androidx.test.espresso.core",
- "androidx.lifecycle_lifecycle-common-java8",
- "androidx.lifecycle_lifecycle-extensions",
- "androidx.lifecycle_lifecycle-runtime-ktx",
- "truth-prebuilt",
- "testables",
- "kotlinx_coroutines_test",
- ],
- test_suites: ["general-tests"],
- sdk_version: "core_platform",
- compile_multilib: "both",
-
- dont_merge_manifests: true,
-}
diff --git a/java/tests/AndroidManifest.xml b/java/tests/AndroidManifest.xml
deleted file mode 100644
index 05830c4c..00000000
--- a/java/tests/AndroidManifest.xml
+++ /dev/null
@@ -1,43 +0,0 @@
-<?xml version="1.0" encoding="utf-8"?>
-<!-- Copyright (C) 2021 The Android Open Source Project
-
- Licensed under the Apache License, Version 2.0 (the "License");
- you may not use this file except in compliance with the License.
- You may obtain a copy of the License at
-
- http://www.apache.org/licenses/LICENSE-2.0
-
- Unless required by applicable law or agreed to in writing, software
- distributed under the License is distributed on an "AS IS" BASIS,
- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- See the License for the specific language governing permissions and
- limitations under the License.
--->
-
-<manifest xmlns:android="http://schemas.android.com/apk/res/android"
- package="com.android.intentresolver.tests">
-
- <uses-sdk android:minSdkVersion="21" android:targetSdkVersion="30" />
-
- <uses-permission android:name="android.permission.INTERACT_ACROSS_USERS_FULL"/>
- <uses-permission android:name="android.permission.QUERY_USERS"/>
- <uses-permission android:name="android.permission.READ_CLIPBOARD_IN_BACKGROUND"/>
- <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">
- <uses-library android:name="android.test.runner" />
- <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>
-
-</manifest>
diff --git a/java/tests/AndroidTest.xml b/java/tests/AndroidTest.xml
deleted file mode 100644
index d1d77c10..00000000
--- a/java/tests/AndroidTest.xml
+++ /dev/null
@@ -1,28 +0,0 @@
-<?xml version="1.0" encoding="utf-8"?>
-<!-- Copyright (C) 2021 The Android Open Source Project
-
- Licensed under the Apache License, Version 2.0 (the "License");
- you may not use this file except in compliance with the License.
- You may obtain a copy of the License at
-
- http://www.apache.org/licenses/LICENSE-2.0
-
- Unless required by applicable law or agreed to in writing, software
- distributed under the License is distributed on an "AS IS" BASIS,
- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- See the License for the specific language governing permissions and
- limitations under the License.
--->
-<configuration description="Run IntentResolver Tests.">
- <target_preparer class="com.android.tradefed.targetprep.TestAppInstallSetup">
- <option name="test-file-name" value="IntentResolverUnitTests.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="hidden-api-checks" value="false"/>
- </test>
-</configuration>
diff --git a/java/tests/res/drawable/test320x240.png b/java/tests/res/drawable/test320x240.png
deleted file mode 100644
index 9b5800da..00000000
--- a/java/tests/res/drawable/test320x240.png
+++ /dev/null
Binary files differ
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 cd2fbc7a..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/ChooserActionFactoryTest.kt b/java/tests/src/com/android/intentresolver/ChooserActionFactoryTest.kt
deleted file mode 100644
index 8d994f08..00000000
--- a/java/tests/src/com/android/intentresolver/ChooserActionFactoryTest.kt
+++ /dev/null
@@ -1,224 +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.Activity
-import android.app.PendingIntent
-import android.content.BroadcastReceiver
-import android.content.Context
-import android.content.Intent
-import android.content.IntentFilter
-import android.content.res.Resources
-import android.graphics.drawable.Icon
-import android.service.chooser.ChooserAction
-import androidx.test.ext.junit.runners.AndroidJUnit4
-import androidx.test.platform.app.InstrumentationRegistry
-import com.google.common.collect.ImmutableList
-import com.google.common.truth.Truth.assertThat
-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.Before
-import org.junit.Test
-import org.junit.runner.RunWith
-import org.mockito.Mockito
-
-@RunWith(AndroidJUnit4::class)
-class ChooserActionFactoryTest {
- private val context = InstrumentationRegistry.getInstrumentation().getContext()
-
- private val logger = mock<ChooserActivityLogger>()
- 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 =
- object : BroadcastReceiver() {
- override fun onReceive(context: Context, intent: Intent) {
- // Just doing at most a single countdown per test.
- countdown.countDown()
- }
- }
- private val resultConsumer =
- object : Consumer<Int> {
- var latestReturn = Integer.MIN_VALUE
-
- override fun accept(resultCode: Int) {
- latestReturn = resultCode
- }
- }
-
- @Before
- fun setup() {
- context.registerReceiver(testReceiver, IntentFilter(testAction))
- }
-
- @After
- fun teardown() {
- context.unregisterReceiver(testReceiver)
- }
-
- @Test
- fun testCreateCustomActions() {
- val factory = createFactory()
-
- val customActions = factory.createCustomActions()
-
- assertThat(customActions.size).isEqualTo(1)
- assertThat(customActions[0].label).isEqualTo(actionLabel)
-
- // click it
- customActions[0].onClicked.run()
-
- Mockito.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(ChooserActivityLogger.SELECTION_TYPE_MODIFY_SHARE))
- assertEquals(Activity.RESULT_OK, resultConsumer.latestReturn)
- // Verify the pending intent has been called
- countdown.await(500, TimeUnit.MILLISECONDS)
- }
-
- @Test
- fun nonSendAction_noCopyRunnable() {
- val targetIntent =
- Intent(Intent.ACTION_SEND_MULTIPLE).apply {
- 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(),
- {},
- )
- assertThat(testSubject.copyButtonRunnable).isNull()
- }
-
- @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(),
- {},
- )
- assertThat(testSubject.copyButtonRunnable).isNull()
- }
-
- @Test
- fun sendActionWithText_nonNullCopyRunnable() {
- 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 testSubject =
- ChooserActionFactory(
- context,
- chooserRequest,
- mock(),
- logger,
- {},
- { null },
- mock(),
- {},
- )
- assertThat(testSubject.copyButtonRunnable).isNotNull()
- }
-
- private fun createFactory(includeModifyShare: Boolean = false): ChooserActionFactory {
- val testPendingIntent = PendingIntent.getActivity(context, 0, Intent(testAction), 0)
- val targetIntent = Intent()
- val action =
- ChooserAction.Builder(
- Icon.createWithResource("", Resources.ID_NULL),
- actionLabel,
- 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
- )
- }
-}
diff --git a/java/tests/src/com/android/intentresolver/ChooserActivityLoggerTest.java b/java/tests/src/com/android/intentresolver/ChooserActivityLoggerTest.java
deleted file mode 100644
index aa42c24c..00000000
--- a/java/tests/src/com/android/intentresolver/ChooserActivityLoggerTest.java
+++ /dev/null
@@ -1,422 +0,0 @@
-/*
- * Copyright (C) 2020 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-package com.android.intentresolver;
-
-import static com.google.common.truth.Truth.assertThat;
-
-import static org.mockito.AdditionalMatchers.gt;
-import static org.mockito.ArgumentMatchers.any;
-import static org.mockito.ArgumentMatchers.anyBoolean;
-import static org.mockito.ArgumentMatchers.anyInt;
-import static org.mockito.ArgumentMatchers.anyString;
-import static org.mockito.ArgumentMatchers.eq;
-import static org.mockito.ArgumentMatchers.isNull;
-import static org.mockito.Mockito.times;
-import static org.mockito.Mockito.verify;
-import static org.mockito.Mockito.verifyNoMoreInteractions;
-
-import android.content.Intent;
-import android.metrics.LogMaker;
-
-import com.android.intentresolver.ChooserActivityLogger.FrameworkStatsLogger;
-import com.android.intentresolver.ChooserActivityLogger.SharesheetStandardEvent;
-import com.android.intentresolver.ChooserActivityLogger.SharesheetStartedEvent;
-import com.android.intentresolver.ChooserActivityLogger.SharesheetTargetSelectedEvent;
-import com.android.intentresolver.contentpreview.ContentPreviewType;
-import com.android.internal.logging.InstanceId;
-import com.android.internal.logging.MetricsLogger;
-import com.android.internal.logging.UiEventLogger;
-import com.android.internal.logging.UiEventLogger.UiEventEnum;
-import com.android.internal.logging.nano.MetricsProto.MetricsEvent;
-import com.android.internal.util.FrameworkStatsLog;
-
-import org.junit.After;
-import org.junit.Before;
-import org.junit.Test;
-import org.junit.runner.RunWith;
-import org.mockito.ArgumentCaptor;
-import org.mockito.Mock;
-import org.mockito.junit.MockitoJUnitRunner;
-
-@RunWith(MockitoJUnitRunner.class)
-public final class ChooserActivityLoggerTest {
- @Mock private UiEventLogger mUiEventLog;
- @Mock private FrameworkStatsLogger mFrameworkLog;
- @Mock private MetricsLogger mMetricsLogger;
-
- private ChooserActivityLogger mChooserLogger;
-
- @Before
- public void setUp() {
- //Mockito.reset(mUiEventLog, mFrameworkLog, mMetricsLogger);
- mChooserLogger = new ChooserActivityLogger(mUiEventLog, mFrameworkLog, mMetricsLogger);
- }
-
- @After
- public void tearDown() {
- verifyNoMoreInteractions(mUiEventLog);
- verifyNoMoreInteractions(mFrameworkLog);
- verifyNoMoreInteractions(mMetricsLogger);
- }
-
- @Test
- public void testLogChooserActivityShown_personalProfile() {
- final boolean isWorkProfile = false;
- final String mimeType = "application/TestType";
- final long systemCost = 456;
-
- mChooserLogger.logChooserActivityShown(isWorkProfile, mimeType, systemCost);
-
- ArgumentCaptor<LogMaker> eventCaptor = ArgumentCaptor.forClass(LogMaker.class);
- verify(mMetricsLogger).write(eventCaptor.capture());
- LogMaker event = eventCaptor.getValue();
-
- assertThat(event.getCategory()).isEqualTo(MetricsEvent.ACTION_ACTIVITY_CHOOSER_SHOWN);
- assertThat(event.getSubtype()).isEqualTo(MetricsEvent.PARENT_PROFILE);
- assertThat(event.getTaggedData(MetricsEvent.FIELD_SHARESHEET_MIMETYPE)).isEqualTo(mimeType);
- assertThat(event.getTaggedData(MetricsEvent.FIELD_TIME_TO_APP_TARGETS))
- .isEqualTo(systemCost);
- }
-
- @Test
- public void testLogChooserActivityShown_workProfile() {
- final boolean isWorkProfile = true;
- final String mimeType = "application/TestType";
- final long systemCost = 456;
-
- mChooserLogger.logChooserActivityShown(isWorkProfile, mimeType, systemCost);
-
- ArgumentCaptor<LogMaker> eventCaptor = ArgumentCaptor.forClass(LogMaker.class);
- verify(mMetricsLogger).write(eventCaptor.capture());
- LogMaker event = eventCaptor.getValue();
-
- assertThat(event.getCategory()).isEqualTo(MetricsEvent.ACTION_ACTIVITY_CHOOSER_SHOWN);
- assertThat(event.getSubtype()).isEqualTo(MetricsEvent.MANAGED_PROFILE);
- assertThat(event.getTaggedData(MetricsEvent.FIELD_SHARESHEET_MIMETYPE)).isEqualTo(mimeType);
- assertThat(event.getTaggedData(MetricsEvent.FIELD_TIME_TO_APP_TARGETS))
- .isEqualTo(systemCost);
- }
-
- @Test
- public void testLogShareStarted() {
- 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_FILE;
- 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_FILE),
- 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 = ChooserActivityLogger.SELECTION_TYPE_SERVICE;
- final String packageName = "com.test.foo";
- final int positionPicked = 123;
- final int directTargetAlsoRanked = -1;
- final int callerTargetCount = 0;
- final boolean isPinned = true;
- final boolean isSuccessfullySelected = true;
- final long selectionCost = 456;
-
- mChooserLogger.logShareTargetSelected(
- targetType,
- packageName,
- positionPicked,
- directTargetAlsoRanked,
- callerTargetCount,
- /* directTargetHashed= */ null,
- isPinned,
- isSuccessfullySelected,
- selectionCost);
-
- verify(mFrameworkLog).write(
- eq(FrameworkStatsLog.RANKING_SELECTED),
- eq(SharesheetTargetSelectedEvent.SHARESHEET_SERVICE_TARGET_SELECTED.getId()),
- eq(packageName),
- /* instanceId=*/ gt(0),
- eq(positionPicked),
- eq(isPinned));
-
- ArgumentCaptor<LogMaker> eventCaptor = ArgumentCaptor.forClass(LogMaker.class);
- verify(mMetricsLogger).write(eventCaptor.capture());
- LogMaker event = eventCaptor.getValue();
- assertThat(event.getCategory()).isEqualTo(
- MetricsEvent.ACTION_ACTIVITY_CHOOSER_PICKED_SERVICE_TARGET);
- assertThat(event.getSubtype()).isEqualTo(positionPicked);
- }
-
- @Test
- public void testLogActionSelected() {
- mChooserLogger.logActionSelected(ChooserActivityLogger.SELECTION_TYPE_COPY);
-
- verify(mFrameworkLog).write(
- eq(FrameworkStatsLog.RANKING_SELECTED),
- eq(SharesheetTargetSelectedEvent.SHARESHEET_COPY_TARGET_SELECTED.getId()),
- eq(""),
- /* instanceId=*/ gt(0),
- eq(-1),
- eq(false));
-
- ArgumentCaptor<LogMaker> eventCaptor = ArgumentCaptor.forClass(LogMaker.class);
- verify(mMetricsLogger).write(eventCaptor.capture());
- LogMaker event = eventCaptor.getValue();
- assertThat(event.getCategory()).isEqualTo(
- MetricsEvent.ACTION_ACTIVITY_CHOOSER_PICKED_SYSTEM_TARGET);
- assertThat(event.getSubtype()).isEqualTo(1);
- }
-
- @Test
- public void testLogCustomActionSelected() {
- final int position = 4;
- mChooserLogger.logCustomActionSelected(position);
-
- verify(mFrameworkLog).write(
- eq(FrameworkStatsLog.RANKING_SELECTED),
- eq(SharesheetTargetSelectedEvent.SHARESHEET_CUSTOM_ACTION_SELECTED.getId()),
- any(), anyInt(), eq(position), eq(false));
- }
-
- @Test
- public void testLogDirectShareTargetReceived() {
- final int category = MetricsEvent.ACTION_DIRECT_SHARE_TARGETS_LOADED_SHORTCUT_MANAGER;
- final int latency = 123;
-
- mChooserLogger.logDirectShareTargetReceived(category, latency);
-
- ArgumentCaptor<LogMaker> eventCaptor = ArgumentCaptor.forClass(LogMaker.class);
- verify(mMetricsLogger).write(eventCaptor.capture());
- LogMaker event = eventCaptor.getValue();
- assertThat(event.getCategory()).isEqualTo(category);
- assertThat(event.getSubtype()).isEqualTo(latency);
- }
-
- @Test
- public void testLogActionShareWithPreview() {
- final int previewType = ContentPreviewType.CONTENT_PREVIEW_TEXT;
-
- mChooserLogger.logActionShareWithPreview(previewType);
-
- ArgumentCaptor<LogMaker> eventCaptor = ArgumentCaptor.forClass(LogMaker.class);
- verify(mMetricsLogger).write(eventCaptor.capture());
- LogMaker event = eventCaptor.getValue();
- assertThat(event.getCategory()).isEqualTo(MetricsEvent.ACTION_SHARE_WITH_PREVIEW);
- assertThat(event.getSubtype()).isEqualTo(previewType);
- }
-
- @Test
- public void testLogSharesheetTriggered() {
- mChooserLogger.logSharesheetTriggered();
- verify(mUiEventLog).logWithInstanceId(
- eq(SharesheetStandardEvent.SHARESHEET_TRIGGERED), eq(0), isNull(), any());
- }
-
- @Test
- public void testLogSharesheetAppLoadComplete() {
- mChooserLogger.logSharesheetAppLoadComplete();
- verify(mUiEventLog).logWithInstanceId(
- eq(SharesheetStandardEvent.SHARESHEET_APP_LOAD_COMPLETE), eq(0), isNull(), any());
- }
-
- @Test
- public void testLogSharesheetDirectLoadComplete() {
- mChooserLogger.logSharesheetDirectLoadComplete();
- verify(mUiEventLog).logWithInstanceId(
- eq(SharesheetStandardEvent.SHARESHEET_DIRECT_LOAD_COMPLETE),
- eq(0),
- isNull(),
- any());
- }
-
- @Test
- public void testLogSharesheetDirectLoadTimeout() {
- mChooserLogger.logSharesheetDirectLoadTimeout();
- verify(mUiEventLog).logWithInstanceId(
- eq(SharesheetStandardEvent.SHARESHEET_DIRECT_LOAD_TIMEOUT), eq(0), isNull(), any());
- }
-
- @Test
- public void testLogSharesheetProfileChanged() {
- mChooserLogger.logSharesheetProfileChanged();
- verify(mUiEventLog).logWithInstanceId(
- eq(SharesheetStandardEvent.SHARESHEET_PROFILE_CHANGED), eq(0), isNull(), any());
- }
-
- @Test
- public void testLogSharesheetExpansionChanged_collapsed() {
- mChooserLogger.logSharesheetExpansionChanged(/* isCollapsed=*/ true);
- verify(mUiEventLog).logWithInstanceId(
- eq(SharesheetStandardEvent.SHARESHEET_COLLAPSED), eq(0), isNull(), any());
- }
-
- @Test
- public void testLogSharesheetExpansionChanged_expanded() {
- mChooserLogger.logSharesheetExpansionChanged(/* isCollapsed=*/ false);
- verify(mUiEventLog).logWithInstanceId(
- eq(SharesheetStandardEvent.SHARESHEET_EXPANDED), eq(0), isNull(), any());
- }
-
- @Test
- public void testLogSharesheetAppShareRankingTimeout() {
- mChooserLogger.logSharesheetAppShareRankingTimeout();
- verify(mUiEventLog).logWithInstanceId(
- eq(SharesheetStandardEvent.SHARESHEET_APP_SHARE_RANKING_TIMEOUT),
- eq(0),
- isNull(),
- any());
- }
-
- @Test
- public void testLogSharesheetEmptyDirectShareRow() {
- mChooserLogger.logSharesheetEmptyDirectShareRow();
- verify(mUiEventLog).logWithInstanceId(
- eq(SharesheetStandardEvent.SHARESHEET_EMPTY_DIRECT_SHARE_ROW),
- eq(0),
- isNull(),
- any());
- }
-
- @Test
- public void testDifferentLoggerInstancesUseDifferentInstanceIds() {
- ArgumentCaptor<Integer> idIntCaptor = ArgumentCaptor.forClass(Integer.class);
- ChooserActivityLogger chooserLogger2 =
- new ChooserActivityLogger(mUiEventLog, mFrameworkLog, mMetricsLogger);
-
- final int targetType = ChooserActivityLogger.SELECTION_TYPE_COPY;
- final String packageName = "com.test.foo";
- final int positionPicked = 123;
- final int directTargetAlsoRanked = -1;
- final int callerTargetCount = 0;
- final boolean isPinned = true;
- final boolean isSuccessfullySelected = true;
- final long selectionCost = 456;
-
- mChooserLogger.logShareTargetSelected(
- targetType,
- packageName,
- positionPicked,
- directTargetAlsoRanked,
- callerTargetCount,
- /* directTargetHashed= */ null,
- isPinned,
- isSuccessfullySelected,
- selectionCost);
-
- chooserLogger2.logShareTargetSelected(
- targetType,
- packageName,
- positionPicked,
- directTargetAlsoRanked,
- callerTargetCount,
- /* directTargetHashed= */ null,
- isPinned,
- isSuccessfullySelected,
- selectionCost);
-
- verify(mFrameworkLog, times(2)).write(
- anyInt(), anyInt(), anyString(), idIntCaptor.capture(), anyInt(), anyBoolean());
-
- int id1 = idIntCaptor.getAllValues().get(0);
- int id2 = idIntCaptor.getAllValues().get(1);
-
- assertThat(id1).isGreaterThan(0);
- assertThat(id2).isGreaterThan(0);
- assertThat(id1).isNotEqualTo(id2);
- }
-
- @Test
- public void testUiAndFrameworkEventsUseSameInstanceIdForSameLoggerInstance() {
- ArgumentCaptor<Integer> idIntCaptor = ArgumentCaptor.forClass(Integer.class);
- ArgumentCaptor<InstanceId> idObjectCaptor = ArgumentCaptor.forClass(InstanceId.class);
-
- final int targetType = ChooserActivityLogger.SELECTION_TYPE_COPY;
- final String packageName = "com.test.foo";
- final int positionPicked = 123;
- final int directTargetAlsoRanked = -1;
- final int callerTargetCount = 0;
- final boolean isPinned = true;
- final boolean isSuccessfullySelected = true;
- final long selectionCost = 456;
-
- mChooserLogger.logShareTargetSelected(
- targetType,
- packageName,
- positionPicked,
- directTargetAlsoRanked,
- callerTargetCount,
- /* directTargetHashed= */ null,
- isPinned,
- isSuccessfullySelected,
- selectionCost);
-
- verify(mFrameworkLog).write(
- anyInt(), anyInt(), anyString(), idIntCaptor.capture(), anyInt(), anyBoolean());
-
- mChooserLogger.logSharesheetTriggered();
- verify(mUiEventLog).logWithInstanceId(
- any(UiEventEnum.class), anyInt(), any(), idObjectCaptor.capture());
-
- assertThat(idIntCaptor.getValue()).isGreaterThan(0);
- assertThat(idObjectCaptor.getValue().getId()).isEqualTo(idIntCaptor.getValue());
- }
-
- @Test
- public void testTargetSelectionCategories() {
- assertThat(ChooserActivityLogger.getTargetSelectionCategory(
- ChooserActivityLogger.SELECTION_TYPE_SERVICE))
- .isEqualTo(MetricsEvent.ACTION_ACTIVITY_CHOOSER_PICKED_SERVICE_TARGET);
- assertThat(ChooserActivityLogger.getTargetSelectionCategory(
- ChooserActivityLogger.SELECTION_TYPE_APP))
- .isEqualTo(MetricsEvent.ACTION_ACTIVITY_CHOOSER_PICKED_APP_TARGET);
- assertThat(ChooserActivityLogger.getTargetSelectionCategory(
- ChooserActivityLogger.SELECTION_TYPE_STANDARD))
- .isEqualTo(MetricsEvent.ACTION_ACTIVITY_CHOOSER_PICKED_STANDARD_TARGET);
- assertThat(ChooserActivityLogger.getTargetSelectionCategory(
- ChooserActivityLogger.SELECTION_TYPE_COPY)).isEqualTo(0);
- assertThat(ChooserActivityLogger.getTargetSelectionCategory(
- ChooserActivityLogger.SELECTION_TYPE_NEARBY)).isEqualTo(0);
- assertThat(ChooserActivityLogger.getTargetSelectionCategory(
- ChooserActivityLogger.SELECTION_TYPE_EDIT)).isEqualTo(0);
- }
-}
diff --git a/java/tests/src/com/android/intentresolver/ChooserActivityOverrideData.java b/java/tests/src/com/android/intentresolver/ChooserActivityOverrideData.java
deleted file mode 100644
index ce96ef63..00000000
--- a/java/tests/src/com/android/intentresolver/ChooserActivityOverrideData.java
+++ /dev/null
@@ -1,134 +0,0 @@
-/*
- * Copyright (C) 2021 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-package com.android.intentresolver;
-
-import static org.mockito.ArgumentMatchers.any;
-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.shortcuts.ShortcutLoader;
-
-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
- * this singleton to modify behavior.
- */
-public class ChooserActivityOverrideData {
- private static ChooserActivityOverrideData sInstance = null;
-
- public static ChooserActivityOverrideData getInstance() {
- if (sInstance == null) {
- sInstance = new 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 Boolean isVoiceInteraction;
- public Cursor resolverCursor;
- public boolean resolverForceException;
- public ImageLoader imageLoader;
- public ChooserActivityLogger chooserActivityLogger;
- 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);
- chooserActivityLogger = mock(ChooserActivityLogger.class);
- alternateProfileSetting = 0;
- 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/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/ChooserListAdapterTest.kt b/java/tests/src/com/android/intentresolver/ChooserListAdapterTest.kt
deleted file mode 100644
index 4612b430..00000000
--- a/java/tests/src/com/android/intentresolver/ChooserListAdapterTest.kt
+++ /dev/null
@@ -1,174 +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.Intent
-import android.content.pm.PackageManager
-import android.content.pm.PackageManager.ResolveInfoFlags
-import android.os.UserHandle
-import android.view.View
-import android.widget.FrameLayout
-import android.widget.ImageView
-import android.widget.TextView
-import androidx.test.ext.junit.runners.AndroidJUnit4
-import androidx.test.platform.app.InstrumentationRegistry
-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.internal.R
-import org.junit.Before
-import org.junit.Test
-import org.junit.runner.RunWith
-import org.mockito.Mockito.times
-import org.mockito.Mockito.verify
-
-@RunWith(AndroidJUnit4::class)
-class ChooserListAdapterTest {
- private val userHandle: UserHandle =
- InstrumentationRegistry.getInstrumentation().targetContext.user
-
- private val packageManager =
- mock<PackageManager> {
- whenever(resolveActivity(any(), any<ResolveInfoFlags>())).thenReturn(mock())
- }
- private val context = InstrumentationRegistry.getInstrumentation().context
- private val resolverListController = mock<ResolverListController>()
- private val chooserActivityLogger = mock<ChooserActivityLogger>()
- private val mTargetDataLoader = mock<TargetDataLoader>()
-
- private val testSubject by lazy {
- ChooserListAdapter(
- context,
- emptyList(),
- emptyArray(),
- emptyList(),
- false,
- resolverListController,
- userHandle,
- Intent(),
- mock(),
- packageManager,
- chooserActivityLogger,
- mock(),
- 0,
- null,
- mTargetDataLoader
- )
- }
-
- @Before
- fun setup() {
- // ChooserListAdapter reads DeviceConfig and needs a permission for that.
- InstrumentationRegistry.getInstrumentation()
- .uiAutomation
- .adoptShellPermissionIdentity("android.permission.READ_DEVICE_CONFIG")
- }
-
- @Test
- fun testDirectShareTargetLoadingIconIsStarted() {
- val view = createView()
- val viewHolder = ResolverListAdapter.ViewHolder(view)
- view.tag = viewHolder
- val targetInfo = createSelectableTargetInfo()
- testSubject.onBindView(view, targetInfo, 0)
-
- verify(mTargetDataLoader, times(1)).loadDirectShareIcon(any(), any(), any())
- }
-
- @Test
- fun onBindView_DirectShareTargetIconAndLabelLoadedOnlyOnce() {
- val view = createView()
- val viewHolderOne = ResolverListAdapter.ViewHolder(view)
- view.tag = viewHolderOne
- val targetInfo = createSelectableTargetInfo()
- testSubject.onBindView(view, targetInfo, 0)
-
- val viewHolderTwo = ResolverListAdapter.ViewHolder(view)
- view.tag = viewHolderTwo
-
- testSubject.onBindView(view, targetInfo, 0)
-
- verify(mTargetDataLoader, times(1)).loadDirectShareIcon(any(), any(), any())
- }
-
- @Test
- fun onBindView_AppTargetIconAndLabelLoadedOnlyOnce() {
- val view = createView()
- val viewHolderOne = ResolverListAdapter.ViewHolder(view)
- view.tag = viewHolderOne
- val targetInfo =
- DisplayResolveInfo.newDisplayResolveInfo(
- Intent(),
- ResolverDataProvider.createResolveInfo(2, 0, userHandle),
- null,
- "extended info",
- Intent(),
- /* resolveInfoPresentationGetter= */ null
- )
- testSubject.onBindView(view, targetInfo, 0)
-
- val viewHolderTwo = ResolverListAdapter.ViewHolder(view)
- view.tag = viewHolderTwo
-
- testSubject.onBindView(view, targetInfo, 0)
-
- verify(mTargetDataLoader, times(1)).loadAppTargetIcon(any(), any(), any())
- }
-
- private fun createSelectableTargetInfo(): TargetInfo =
- SelectableTargetInfo.newSelectableTargetInfo(
- /* sourceInfo = */ DisplayResolveInfo.newDisplayResolveInfo(
- Intent(),
- ResolverDataProvider.createResolveInfo(2, 0, userHandle),
- "label",
- "extended info",
- Intent(),
- /* resolveInfoPresentationGetter= */ null
- ),
- /* backupResolveInfo = */ mock(),
- /* resolvedIntent = */ Intent(),
- /* chooserTarget = */ createChooserTarget(
- "Target",
- 0.5f,
- ComponentName("pkg", "Class"),
- "id-1"
- ),
- /* modifiedScore = */ 1f,
- /* shortcutInfo = */ createShortcutInfo("id-1", ComponentName("pkg", "Class"), 1),
- /* appTarget */ null,
- /* referrerFillInIntent = */ Intent()
- )
-
- private fun createView(): View {
- val view = FrameLayout(context)
- TextView(context).apply {
- id = R.id.text1
- view.addView(this)
- }
- TextView(context).apply {
- id = R.id.text2
- view.addView(this)
- }
- ImageView(context).apply {
- id = R.id.icon
- view.addView(this)
- }
- return view
- }
-}
diff --git a/java/tests/src/com/android/intentresolver/ChooserRefinementManagerTest.kt b/java/tests/src/com/android/intentresolver/ChooserRefinementManagerTest.kt
deleted file mode 100644
index 61ac0c21..00000000
--- a/java/tests/src/com/android/intentresolver/ChooserRefinementManagerTest.kt
+++ /dev/null
@@ -1,242 +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.Activity
-import android.app.Application
-import android.content.Intent
-import android.content.IntentSender
-import android.os.Bundle
-import android.os.Handler
-import android.os.Looper
-import android.os.Message
-import android.os.ResultReceiver
-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.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
-
-@RunWith(AndroidJUnit4::class)
-@UiThreadTest
-class ChooserRefinementManagerTest {
- private val refinementManager = ChooserRefinementManager()
- private val intentSender = mock<IntentSender>()
- private val application = mock<Application>()
- private val exampleSourceIntents =
- listOf(Intent(Intent.ACTION_VIEW), Intent(Intent.ACTION_EDIT))
- private val exampleTargetInfo =
- ImmutableTargetInfo.newBuilder().setAllSourceIntents(exampleSourceIntents).build()
-
- private val completionObserver =
- object : Observer<RefinementCompletion> {
- val failureCountDown = CountDownLatch(1)
- val successCountDown = CountDownLatch(1)
- var latestTargetInfo: TargetInfo? = null
-
- override fun onChanged(completion: RefinementCompletion) {
- if (completion.consume()) {
- val targetInfo = completion.targetInfo
- if (targetInfo == null) {
- failureCountDown.countDown()
- } else {
- latestTargetInfo = targetInfo
- successCountDown.countDown()
- }
- }
- }
- }
-
- /** Synchronously executes post() calls. */
- private class FakeHandler(looper: Looper) : Handler(looper) {
- override fun sendMessageAtTime(msg: Message, uptimeMillis: Long): Boolean {
- dispatchMessage(msg)
- return true
- }
- }
-
- @Before
- fun setup() {
- refinementManager.refinementCompletion.observeForever(completionObserver)
- }
-
- @Test
- fun testTypicalRefinementFlow() {
- assertThat(
- refinementManager.maybeHandleSelection(
- exampleTargetInfo,
- intentSender,
- application,
- 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 intent = intentCaptor.value
- assertThat(intent?.getParcelableExtra(Intent.EXTRA_INTENT, Intent::class.java))
- .isEqualTo(exampleSourceIntents[0])
-
- val alternates =
- 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)
- 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)
- }
-
- @Test
- fun testRefinementCancelled() {
- assertThat(
- refinementManager.maybeHandleSelection(
- exampleTargetInfo,
- intentSender,
- application,
- 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 intent = intentCaptor.value
-
- // Complete the refinement
- val receiver =
- intent?.getParcelableExtra(Intent.EXTRA_RESULT_RECEIVER, ResultReceiver::class.java)
- val bundle = Bundle().apply { putParcelable(Intent.EXTRA_INTENT, exampleSourceIntents[0]) }
- receiver?.send(Activity.RESULT_CANCELED, bundle)
-
- assertThat(completionObserver.failureCountDown.await(1000, TimeUnit.MILLISECONDS)).isTrue()
- }
-
- @Test
- fun testMaybeHandleSelection_noSourceIntents() {
- assertThat(
- refinementManager.maybeHandleSelection(
- ImmutableTargetInfo.newBuilder().build(),
- intentSender,
- application,
- FakeHandler(checkNotNull(Looper.myLooper()))
- )
- )
- .isFalse()
- }
-
- @Test
- fun testMaybeHandleSelection_suspended() {
- val targetInfo =
- ImmutableTargetInfo.newBuilder()
- .setAllSourceIntents(exampleSourceIntents)
- .setIsSuspended(true)
- .build()
-
- assertThat(
- refinementManager.maybeHandleSelection(
- targetInfo,
- intentSender,
- application,
- FakeHandler(checkNotNull(Looper.myLooper()))
- )
- )
- .isFalse()
- }
-
- @Test
- fun testMaybeHandleSelection_noIntentSender() {
- assertThat(
- refinementManager.maybeHandleSelection(
- exampleTargetInfo,
- /* IntentSender */ null,
- application,
- FakeHandler(checkNotNull(Looper.myLooper()))
- )
- )
- .isFalse()
- }
-
- @Test
- fun testConfigurationChangeDuringRefinement() {
- assertThat(
- refinementManager.maybeHandleSelection(
- exampleTargetInfo,
- intentSender,
- application,
- FakeHandler(checkNotNull(Looper.myLooper()))
- )
- )
- .isTrue()
-
- refinementManager.onActivityStop(/* config changing = */ true)
- refinementManager.onActivityResume()
-
- assertThat(completionObserver.failureCountDown.count).isEqualTo(1)
- }
-
- @Test
- fun testResumeDuringRefinement() {
- assertThat(
- refinementManager.maybeHandleSelection(
- exampleTargetInfo,
- intentSender,
- application,
- FakeHandler(checkNotNull(Looper.myLooper())!!)
- )
- )
- .isTrue()
-
- refinementManager.onActivityStop(/* config changing = */ false)
- // Resume during refinement but not during a config change, so finish the activity.
- refinementManager.onActivityResume()
-
- // Call should be synchronous, don't need to await for this one.
- assertThat(completionObserver.failureCountDown.count).isEqualTo(0)
- }
-
- @Test
- fun testRefinementCompletion() {
- val refinementCompletion = RefinementCompletion(exampleTargetInfo)
- assertThat(refinementCompletion.targetInfo).isEqualTo(exampleTargetInfo)
- assertThat(refinementCompletion.consume()).isTrue()
- assertThat(refinementCompletion.targetInfo).isEqualTo(exampleTargetInfo)
-
- // can only consume once.
- assertThat(refinementCompletion.consume()).isFalse()
- }
-}
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/ChooserWrapperActivity.java b/java/tests/src/com/android/intentresolver/ChooserWrapperActivity.java
deleted file mode 100644
index 6ac6b6d3..00000000
--- a/java/tests/src/com/android/intentresolver/ChooserWrapperActivity.java
+++ /dev/null
@@ -1,293 +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.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;
-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.shortcuts.ShortcutLoader;
-import com.android.internal.logging.nano.MetricsProto.MetricsEvent;
-
-import java.util.List;
-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 {
- 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(
- Context context,
- List<Intent> payloadIntents,
- Intent[] initialIntents,
- List<ResolveInfo> rList,
- boolean filterLastUsed,
- ResolverListController resolverListController,
- UserHandle userHandle,
- Intent targetIntent,
- ChooserRequestParameters chooserRequest,
- int maxTargetsPerRow,
- TargetDataLoader targetDataLoader) {
- PackageManager packageManager =
- sOverrides.packageManager == null ? context.getPackageManager()
- : sOverrides.packageManager;
- return new ChooserListAdapter(
- context,
- payloadIntents,
- initialIntents,
- rList,
- filterLastUsed,
- createListController(userHandle),
- userHandle,
- targetIntent,
- this,
- packageManager,
- getChooserActivityLogger(),
- chooserRequest,
- maxTargetsPerRow,
- userHandle,
- targetDataLoader);
- }
-
- @Override
- public ChooserListAdapter getAdapter() {
- return mChooserMultiProfilePagerAdapter.getActiveListAdapter();
- }
-
- @Override
- public ChooserListAdapter getPersonalListAdapter() {
- return ((ChooserGridAdapter) mMultiProfilePagerAdapter.getAdapterForIndex(0))
- .getListAdapter();
- }
-
- @Override
- public ChooserListAdapter getWorkListAdapter() {
- if (mMultiProfilePagerAdapter.getInactiveListAdapter() == null) {
- return null;
- }
- return ((ChooserGridAdapter) mMultiProfilePagerAdapter.getAdapterForIndex(1))
- .getListAdapter();
- }
-
- @Override
- public boolean getIsSelected() {
- return mIsSuccessfullySelected;
- }
-
- @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);
- }
- return mUsm;
- }
-
- @Override
- public boolean isVoiceInteraction() {
- if (sOverrides.isVoiceInteraction != null) {
- return sOverrides.isVoiceInteraction;
- }
- return super.isVoiceInteraction();
- }
-
- @Override
- protected CrossProfileIntentsChecker createCrossProfileIntentsChecker() {
- if (sOverrides.mCrossProfileIntentsChecker != null) {
- return sOverrides.mCrossProfileIntentsChecker;
- }
- return super.createCrossProfileIntentsChecker();
- }
-
- @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
- && sOverrides.onSafelyStartInternalCallback.apply(cti)) {
- return;
- }
- super.safelyStartActivityInternal(cti, user, options);
- }
-
- @Override
- protected ChooserListController createListController(UserHandle userHandle) {
- if (userHandle == UserHandle.SYSTEM) {
- return sOverrides.resolverListController;
- }
- return sOverrides.workResolverListController;
- }
-
- @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;
- }
- return super.getResources();
- }
-
- @Override
- protected ViewModelProvider.Factory createPreviewViewModelFactory() {
- return TestContentPreviewViewModel.Companion.wrap(
- super.createPreviewViewModelFactory(),
- sOverrides.imageLoader);
- }
-
- @Override
- public ChooserActivityLogger getChooserActivityLogger() {
- return sOverrides.chooserActivityLogger;
- }
-
- @Override
- public Cursor queryResolver(ContentResolver resolver, Uri uri) {
- if (sOverrides.resolverCursor != null) {
- return sOverrides.resolverCursor;
- }
-
- if (sOverrides.resolverForceException) {
- throw new SecurityException("Test exception handling");
- }
-
- return super.queryResolver(resolver, uri);
- }
-
- @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) {
- return DisplayResolveInfo.newDisplayResolveInfo(
- originalIntent,
- pri,
- pLabel,
- pInfo,
- replacementIntent,
- resolveInfoPresentationGetter);
- }
-
- @Override
- protected UserHandle getWorkProfileUserHandle() {
- return sOverrides.workProfileUserHandle;
- }
-
- @Override
- public UserHandle getCurrentUserHandle() {
- return mMultiProfilePagerAdapter.getCurrentUserHandle();
- }
-
- @Override
- protected UserHandle getTabOwnerUserHandleForLaunch() {
- if (sOverrides.tabOwnerUserHandleForLaunch == null) {
- return super.getTabOwnerUserHandleForLaunch();
- }
- return sOverrides.tabOwnerUserHandleForLaunch;
- }
-
- @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();
- }
-
- @Override
- protected ShortcutLoader createShortcutLoader(
- Context context,
- AppPredictor appPredictor,
- UserHandle userHandle,
- IntentFilter targetIntentFilter,
- Consumer<ShortcutLoader.Result> callback) {
- ShortcutLoader shortcutLoader =
- sOverrides.shortcutLoaderFactory.invoke(userHandle, callback);
- if (shortcutLoader != null) {
- return shortcutLoader;
- }
- 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/EnterTransitionAnimationDelegateTest.kt b/java/tests/src/com/android/intentresolver/EnterTransitionAnimationDelegateTest.kt
deleted file mode 100644
index 9ea9dfa7..00000000
--- a/java/tests/src/com/android/intentresolver/EnterTransitionAnimationDelegateTest.kt
+++ /dev/null
@@ -1,112 +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.res.Resources
-import android.view.View
-import android.view.Window
-import androidx.activity.ComponentActivity
-import androidx.lifecycle.Lifecycle
-import kotlinx.coroutines.Dispatchers
-import kotlinx.coroutines.ExperimentalCoroutinesApi
-import kotlinx.coroutines.test.StandardTestDispatcher
-import kotlinx.coroutines.test.TestCoroutineScheduler
-import kotlinx.coroutines.test.resetMain
-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
-
-private const val TIMEOUT_MS = 200
-
-@OptIn(ExperimentalCoroutinesApi::class)
-class EnterTransitionAnimationDelegateTest {
- private val elementName = "shared-element"
- private val scheduler = TestCoroutineScheduler()
- private val dispatcher = StandardTestDispatcher(scheduler)
- private val lifecycleOwner = TestLifecycleOwner()
-
- private val transitionTargetView = mock<View> {
- // avoid the request-layout path in the delegate
- whenever(isInLayout).thenReturn(true)
- }
-
- private val windowMock = mock<Window>()
- private val resourcesMock = mock<Resources> {
- whenever(getInteger(anyInt())).thenReturn(TIMEOUT_MS)
- }
- private val activity = mock<ComponentActivity> {
- whenever(lifecycle).thenReturn(lifecycleOwner.lifecycle)
- whenever(resources).thenReturn(resourcesMock)
- whenever(isActivityTransitionRunning).thenReturn(true)
- whenever(window).thenReturn(windowMock)
- }
-
- private val testSubject = EnterTransitionAnimationDelegate(activity) {
- transitionTargetView
- }
-
- @Before
- fun setup() {
- Dispatchers.setMain(dispatcher)
- lifecycleOwner.state = Lifecycle.State.CREATED
- }
-
- @After
- fun cleanup() {
- lifecycleOwner.state = Lifecycle.State.DESTROYED
- Dispatchers.resetMain()
- }
-
- @Test
- fun test_postponeTransition_timeout() {
- testSubject.postponeTransition()
- testSubject.markOffsetCalculated()
-
- scheduler.advanceTimeBy(TIMEOUT_MS + 1L)
- verify(activity, times(1)).startPostponedEnterTransition()
- verify(windowMock, never()).setWindowAnimations(anyInt())
- }
-
- @Test
- fun test_postponeTransition_animation_resumes_only_once() {
- testSubject.postponeTransition()
- testSubject.markOffsetCalculated()
- testSubject.onTransitionElementReady(elementName)
- testSubject.markOffsetCalculated()
- testSubject.onTransitionElementReady(elementName)
-
- scheduler.advanceTimeBy(TIMEOUT_MS + 1L)
- verify(activity, times(1)).startPostponedEnterTransition()
- }
-
- @Test
- fun test_postponeTransition_resume_animation_conditions() {
- testSubject.postponeTransition()
- verify(activity, never()).startPostponedEnterTransition()
-
- testSubject.markOffsetCalculated()
- verify(activity, never()).startPostponedEnterTransition()
-
- testSubject.onAllTransitionElementsReady()
- verify(activity, times(1)).startPostponedEnterTransition()
- }
-}
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/IChooserWrapper.java b/java/tests/src/com/android/intentresolver/IChooserWrapper.java
deleted file mode 100644
index af897a47..00000000
--- a/java/tests/src/com/android/intentresolver/IChooserWrapper.java
+++ /dev/null
@@ -1,46 +0,0 @@
-/*
- * Copyright (C) 2021 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-package com.android.intentresolver;
-
-import android.annotation.Nullable;
-import android.app.usage.UsageStatsManager;
-import android.content.Intent;
-import android.content.pm.ResolveInfo;
-import android.os.UserHandle;
-
-import com.android.intentresolver.chooser.DisplayResolveInfo;
-
-import java.util.concurrent.Executor;
-
-/**
- * Test-only extended API capabilities that an instrumented ChooserActivity subclass provides in
- * order to expose the internals for override/inspection. Implementations should apply the overrides
- * specified by the {@code ChooserActivityOverrideData} singleton.
- */
-public interface IChooserWrapper {
- ChooserListAdapter getAdapter();
- ChooserListAdapter getPersonalListAdapter();
- ChooserListAdapter getWorkListAdapter();
- boolean getIsSelected();
- UsageStatsManager getUsageStatsManager();
- DisplayResolveInfo createTestDisplayResolveInfo(Intent originalIntent, ResolveInfo pri,
- CharSequence pLabel, CharSequence pInfo, Intent replacementIntent,
- @Nullable TargetPresentationGetter resolveInfoPresentationGetter);
- UserHandle getCurrentUserHandle();
- ChooserActivityLogger getChooserActivityLogger();
- Executor getMainExecutor();
-}
diff --git a/java/tests/src/com/android/intentresolver/MatcherUtils.java b/java/tests/src/com/android/intentresolver/MatcherUtils.java
deleted file mode 100644
index 6168968b..00000000
--- a/java/tests/src/com/android/intentresolver/MatcherUtils.java
+++ /dev/null
@@ -1,51 +0,0 @@
-/*
- * Copyright (C) 2020 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-package com.android.intentresolver;
-
-import org.hamcrest.BaseMatcher;
-import org.hamcrest.Description;
-import org.hamcrest.Matcher;
-
-/**
- * Utils for helping with more customized matching options, for example matching the first
- * occurrence of a set criteria.
- */
-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) {
- return new BaseMatcher<T>() {
- boolean isFirstMatch = true;
-
- @Override
- public boolean matches(final Object item) {
- if (isFirstMatch && matcher.matches(item)) {
- isFirstMatch = false;
- return true;
- }
- return false;
- }
-
- @Override
- public void describeTo(final Description description) {
- description.appendText("Returns the first matching item");
- }
- };
- }
-}
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/ResolverActivityTest.java b/java/tests/src/com/android/intentresolver/ResolverActivityTest.java
deleted file mode 100644
index 7233fd3d..00000000
--- a/java/tests/src/com/android/intentresolver/ResolverActivityTest.java
+++ /dev/null
@@ -1,1100 +0,0 @@
-/*
- * Copyright (C) 2016 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS 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 androidx.test.espresso.Espresso.onView;
-import static androidx.test.espresso.action.ViewActions.click;
-import static androidx.test.espresso.action.ViewActions.swipeUp;
-import static androidx.test.espresso.assertion.ViewAssertions.matches;
-import static androidx.test.espresso.matcher.ViewMatchers.isCompletelyDisplayed;
-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 com.android.intentresolver.MatcherUtils.first;
-import static com.android.intentresolver.ResolverWrapperActivity.sOverrides;
-
-import static org.hamcrest.CoreMatchers.allOf;
-import static org.hamcrest.CoreMatchers.is;
-import static org.hamcrest.CoreMatchers.not;
-import static org.hamcrest.MatcherAssert.assertThat;
-import static org.junit.Assert.assertFalse;
-import static org.junit.Assert.assertNull;
-import static org.junit.Assert.fail;
-import static org.mockito.ArgumentMatchers.eq;
-import static org.mockito.Mockito.when;
-
-import android.content.Intent;
-import android.content.pm.ResolveInfo;
-import android.net.Uri;
-import android.os.RemoteException;
-import android.os.UserHandle;
-import android.text.TextUtils;
-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.rule.ActivityTestRule;
-import androidx.test.runner.AndroidJUnit4;
-
-import com.android.intentresolver.widget.ResolverDrawerLayout;
-
-import com.google.android.collect.Lists;
-
-import org.junit.Before;
-import org.junit.Ignore;
-import org.junit.Rule;
-import org.junit.Test;
-import org.junit.runner.RunWith;
-import org.mockito.Mockito;
-
-import java.util.ArrayList;
-import java.util.List;
-
-/**
- * Resolver activity instrumentation tests
- */
-@RunWith(AndroidJUnit4.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;
- }
-
- @Rule
- public ActivityTestRule<ResolverWrapperActivity> mActivityRule =
- new ActivityTestRule<>(ResolverWrapperActivity.class, false, false);
-
- @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).
- androidx.test.platform.app.InstrumentationRegistry
- .getInstrumentation()
- .getUiAutomation()
- .adoptShellPermissionIdentity();
-
- sOverrides.reset();
- }
-
- @Test
- public void twoOptionsAndUserSelectsOne() throws InterruptedException {
- Intent sendIntent = createSendImageIntent();
- List<ResolvedComponentInfo> resolvedComponentInfos = createResolvedComponentsForTest(2,
- PERSONAL_USER_HANDLE);
-
- setupResolverControllers(resolvedComponentInfos);
-
- final ResolverWrapperActivity activity = mActivityRule.launchActivity(sendIntent);
- Espresso.registerIdlingResources(activity.getLabelIdlingResource());
- waitForIdle();
-
- assertThat(activity.getAdapter().getCount(), is(2));
-
- ResolveInfo[] chosen = new ResolveInfo[1];
- sOverrides.onSafelyStartInternalCallback = result -> {
- chosen[0] = result.first.getResolveInfo();
- return true;
- };
-
- ResolveInfo toChoose = resolvedComponentInfos.get(0).getResolveInfoAt(0);
- onView(withText(toChoose.activityInfo.name))
- .perform(click());
- onView(withId(com.android.internal.R.id.button_once))
- .perform(click());
- waitForIdle();
- assertThat(chosen[0], is(toChoose));
- }
-
- @Ignore // Failing - b/144929805
- @Test
- public void setMaxHeight() throws Exception {
- Intent sendIntent = createSendImageIntent();
- List<ResolvedComponentInfo> resolvedComponentInfos = createResolvedComponentsForTest(2,
- PERSONAL_USER_HANDLE);
-
- setupResolverControllers(resolvedComponentInfos);
- waitForIdle();
-
- final ResolverWrapperActivity activity = mActivityRule.launchActivity(sendIntent);
- final View viewPager = activity.findViewById(com.android.internal.R.id.profile_pager);
- final int initialResolverHeight = viewPager.getHeight();
-
- activity.runOnUiThread(() -> {
- ResolverDrawerLayout layout = (ResolverDrawerLayout)
- activity.findViewById(
- com.android.internal.R.id.contentPanel);
- ((ResolverDrawerLayout.LayoutParams) viewPager.getLayoutParams()).maxHeight
- = initialResolverHeight - 1;
- // Force a relayout
- layout.invalidate();
- layout.requestLayout();
- });
- waitForIdle();
- assertThat("Drawer should be capped at maxHeight",
- viewPager.getHeight() == (initialResolverHeight - 1));
-
- activity.runOnUiThread(() -> {
- ResolverDrawerLayout layout = (ResolverDrawerLayout)
- activity.findViewById(
- com.android.internal.R.id.contentPanel);
- ((ResolverDrawerLayout.LayoutParams) viewPager.getLayoutParams()).maxHeight
- = initialResolverHeight + 1;
- // Force a relayout
- layout.invalidate();
- layout.requestLayout();
- });
- waitForIdle();
- assertThat("Drawer should not change height if its height is less than maxHeight",
- viewPager.getHeight() == initialResolverHeight);
- }
-
- @Ignore // Failing - b/144929805
- @Test
- public void setShowAtTopToTrue() throws Exception {
- Intent sendIntent = createSendImageIntent();
- List<ResolvedComponentInfo> resolvedComponentInfos = createResolvedComponentsForTest(2,
- PERSONAL_USER_HANDLE);
-
- setupResolverControllers(resolvedComponentInfos);
- waitForIdle();
-
- final ResolverWrapperActivity activity = mActivityRule.launchActivity(sendIntent);
- final View viewPager = activity.findViewById(com.android.internal.R.id.profile_pager);
- final View divider = activity.findViewById(com.android.internal.R.id.divider);
- final RelativeLayout profileView =
- (RelativeLayout) activity.findViewById(com.android.internal.R.id.profile_button)
- .getParent();
- assertThat("Drawer should show at bottom by default",
- profileView.getBottom() + divider.getHeight() == viewPager.getTop()
- && profileView.getTop() > 0);
-
- activity.runOnUiThread(() -> {
- ResolverDrawerLayout layout = (ResolverDrawerLayout)
- activity.findViewById(
- com.android.internal.R.id.contentPanel);
- layout.setShowAtTop(true);
- });
- waitForIdle();
- assertThat("Drawer should show at top with new attribute",
- profileView.getBottom() + divider.getHeight() == viewPager.getTop()
- && profileView.getTop() == 0);
- }
-
- @Test
- public void hasLastChosenActivity() throws Exception {
- Intent sendIntent = createSendImageIntent();
- List<ResolvedComponentInfo> resolvedComponentInfos = createResolvedComponentsForTest(2,
- PERSONAL_USER_HANDLE);
- ResolveInfo toChoose = resolvedComponentInfos.get(0).getResolveInfoAt(0);
-
- setupResolverControllers(resolvedComponentInfos);
- when(sOverrides.resolverListController.getLastChosen())
- .thenReturn(resolvedComponentInfos.get(0).getResolveInfoAt(0));
-
- final ResolverWrapperActivity activity = mActivityRule.launchActivity(sendIntent);
- waitForIdle();
-
- // The other entry is filtered to the last used slot
- assertThat(activity.getAdapter().getCount(), is(1));
- assertThat(activity.getAdapter().getPlaceholderCount(), is(1));
-
- ResolveInfo[] chosen = new ResolveInfo[1];
- sOverrides.onSafelyStartInternalCallback = result -> {
- chosen[0] = result.first.getResolveInfo();
- return true;
- };
-
- onView(withId(com.android.internal.R.id.button_once)).perform(click());
- waitForIdle();
- assertThat(chosen[0], is(toChoose));
- }
-
- @Test
- public void hasOtherProfileOneOption() throws Exception {
- List<ResolvedComponentInfo> personalResolvedComponentInfos =
- createResolvedComponentsForTestWithOtherProfile(2, /* userId */ 10,
- PERSONAL_USER_HANDLE);
- markWorkProfileUserAvailable();
- List<ResolvedComponentInfo> workResolvedComponentInfos = createResolvedComponentsForTest(4,
- sOverrides.workProfileUserHandle);
- setupResolverControllers(personalResolvedComponentInfos, workResolvedComponentInfos);
-
- ResolveInfo toChoose = personalResolvedComponentInfos.get(1).getResolveInfoAt(0);
- Intent sendIntent = createSendImageIntent();
- final ResolverWrapperActivity activity = mActivityRule.launchActivity(sendIntent);
- Espresso.registerIdlingResources(activity.getLabelIdlingResource());
- waitForIdle();
-
- // The other entry is filtered to the last used slot
- assertThat(activity.getAdapter().getCount(), is(1));
-
- ResolveInfo[] chosen = new ResolveInfo[1];
- sOverrides.onSafelyStartInternalCallback = result -> {
- chosen[0] = result.first.getResolveInfo();
- return true;
- };
- // Make a stable copy of the components as the original list may be modified
- List<ResolvedComponentInfo> stableCopy =
- createResolvedComponentsForTestWithOtherProfile(2, /* userId= */ 10,
- PERSONAL_USER_HANDLE);
- // We pick the first one as there is another one in the work profile side
- onView(first(withText(stableCopy.get(1).getResolveInfoAt(0).activityInfo.name)))
- .perform(click());
- onView(withId(com.android.internal.R.id.button_once))
- .perform(click());
- waitForIdle();
- assertThat(chosen[0], is(toChoose));
- }
-
- @Test
- public void hasOtherProfileTwoOptionsAndUserSelectsOne() throws Exception {
- Intent sendIntent = createSendImageIntent();
- List<ResolvedComponentInfo> resolvedComponentInfos =
- createResolvedComponentsForTestWithOtherProfile(3, PERSONAL_USER_HANDLE);
- ResolveInfo toChoose = resolvedComponentInfos.get(1).getResolveInfoAt(0);
-
- setupResolverControllers(resolvedComponentInfos);
-
- final ResolverWrapperActivity activity = mActivityRule.launchActivity(sendIntent);
- Espresso.registerIdlingResources(activity.getLabelIdlingResource());
- waitForIdle();
-
- // The other entry is filtered to the other profile slot
- assertThat(activity.getAdapter().getCount(), is(2));
-
- ResolveInfo[] chosen = new ResolveInfo[1];
- sOverrides.onSafelyStartInternalCallback = result -> {
- chosen[0] = result.first.getResolveInfo();
- return true;
- };
-
- // Confirm that the button bar is disabled by default
- onView(withId(com.android.internal.R.id.button_once)).check(matches(not(isEnabled())));
-
- // Make a stable copy of the components as the original list may be modified
- List<ResolvedComponentInfo> stableCopy =
- createResolvedComponentsForTestWithOtherProfile(2, PERSONAL_USER_HANDLE);
-
- onView(withText(stableCopy.get(1).getResolveInfoAt(0).activityInfo.name))
- .perform(click());
- onView(withId(com.android.internal.R.id.button_once)).perform(click());
- waitForIdle();
- assertThat(chosen[0], is(toChoose));
- }
-
-
- @Test
- public void hasLastChosenActivityAndOtherProfile() throws Exception {
- // In this case we prefer the other profile and don't display anything about the last
- // chosen activity.
- Intent sendIntent = createSendImageIntent();
- List<ResolvedComponentInfo> resolvedComponentInfos =
- createResolvedComponentsForTestWithOtherProfile(3, PERSONAL_USER_HANDLE);
- ResolveInfo toChoose = resolvedComponentInfos.get(1).getResolveInfoAt(0);
-
- setupResolverControllers(resolvedComponentInfos);
- when(sOverrides.resolverListController.getLastChosen())
- .thenReturn(resolvedComponentInfos.get(1).getResolveInfoAt(0));
-
- final ResolverWrapperActivity activity = mActivityRule.launchActivity(sendIntent);
- Espresso.registerIdlingResources(activity.getLabelIdlingResource());
- waitForIdle();
-
- // The other entry is filtered to the other profile slot
- assertThat(activity.getAdapter().getCount(), is(2));
-
- ResolveInfo[] chosen = new ResolveInfo[1];
- sOverrides.onSafelyStartInternalCallback = result -> {
- chosen[0] = result.first.getResolveInfo();
- return true;
- };
-
- // Confirm that the button bar is disabled by default
- onView(withId(com.android.internal.R.id.button_once)).check(matches(not(isEnabled())));
-
- // Make a stable copy of the components as the original list may be modified
- List<ResolvedComponentInfo> stableCopy =
- createResolvedComponentsForTestWithOtherProfile(2, PERSONAL_USER_HANDLE);
-
- onView(withText(stableCopy.get(1).getResolveInfoAt(0).activityInfo.name))
- .perform(click());
- onView(withId(com.android.internal.R.id.button_once)).perform(click());
- waitForIdle();
- assertThat(chosen[0], is(toChoose));
- }
-
- @Test
- public void testWorkTab_displayedWhenWorkProfileUserAvailable() {
- Intent sendIntent = createSendImageIntent();
- markWorkProfileUserAvailable();
-
- mActivityRule.launchActivity(sendIntent);
- waitForIdle();
-
- onView(withId(com.android.internal.R.id.tabs)).check(matches(isDisplayed()));
- }
-
- @Test
- public void testWorkTab_hiddenWhenWorkProfileUserNotAvailable() {
- Intent sendIntent = createSendImageIntent();
-
- mActivityRule.launchActivity(sendIntent);
- waitForIdle();
-
- onView(withId(com.android.internal.R.id.tabs)).check(matches(not(isDisplayed())));
- }
-
- @Test
- public void testWorkTab_workTabListPopulatedBeforeGoingToTab() throws InterruptedException {
- List<ResolvedComponentInfo> personalResolvedComponentInfos =
- createResolvedComponentsForTestWithOtherProfile(3, /* userId = */ 10,
- PERSONAL_USER_HANDLE);
- markWorkProfileUserAvailable();
- List<ResolvedComponentInfo> workResolvedComponentInfos = createResolvedComponentsForTest(4,
- sOverrides.workProfileUserHandle);
- setupResolverControllers(personalResolvedComponentInfos,
- new ArrayList<>(workResolvedComponentInfos));
- Intent sendIntent = createSendImageIntent();
-
- final ResolverWrapperActivity activity = mActivityRule.launchActivity(sendIntent);
- waitForIdle();
-
- assertThat(activity.getCurrentUserHandle().getIdentifier(), is(0));
- // The work list adapter must be populated in advance before tapping the other tab
- assertThat(activity.getWorkListAdapter().getCount(), is(4));
- }
-
- @Test
- public void testWorkTab_workTabUsesExpectedAdapter() {
- List<ResolvedComponentInfo> personalResolvedComponentInfos =
- createResolvedComponentsForTestWithOtherProfile(3, /* userId */ 10,
- PERSONAL_USER_HANDLE);
- markWorkProfileUserAvailable();
- List<ResolvedComponentInfo> workResolvedComponentInfos = createResolvedComponentsForTest(4,
- sOverrides.workProfileUserHandle);
- setupResolverControllers(personalResolvedComponentInfos, workResolvedComponentInfos);
- Intent sendIntent = createSendImageIntent();
- markWorkProfileUserAvailable();
-
- final ResolverWrapperActivity activity = mActivityRule.launchActivity(sendIntent);
- waitForIdle();
- onView(withText(R.string.resolver_work_tab)).perform(click());
-
- assertThat(activity.getCurrentUserHandle().getIdentifier(), is(10));
- assertThat(activity.getWorkListAdapter().getCount(), is(4));
- }
-
- @Test
- public void testWorkTab_personalTabUsesExpectedAdapter() {
- List<ResolvedComponentInfo> personalResolvedComponentInfos =
- createResolvedComponentsForTestWithOtherProfile(3, PERSONAL_USER_HANDLE);
- markWorkProfileUserAvailable();
- List<ResolvedComponentInfo> workResolvedComponentInfos = createResolvedComponentsForTest(4,
- sOverrides.workProfileUserHandle);
- setupResolverControllers(personalResolvedComponentInfos, workResolvedComponentInfos);
- Intent sendIntent = createSendImageIntent();
-
- final ResolverWrapperActivity activity = mActivityRule.launchActivity(sendIntent);
- waitForIdle();
- onView(withText(R.string.resolver_work_tab)).perform(click());
-
- assertThat(activity.getCurrentUserHandle().getIdentifier(), is(10));
- assertThat(activity.getPersonalListAdapter().getCount(), is(2));
- }
-
- @Test
- public void testWorkTab_workProfileHasExpectedNumberOfTargets() throws InterruptedException {
- markWorkProfileUserAvailable();
- List<ResolvedComponentInfo> personalResolvedComponentInfos =
- createResolvedComponentsForTestWithOtherProfile(3, /* userId */ 10,
- PERSONAL_USER_HANDLE);
- List<ResolvedComponentInfo> workResolvedComponentInfos = createResolvedComponentsForTest(4,
- sOverrides.workProfileUserHandle);
- setupResolverControllers(personalResolvedComponentInfos, workResolvedComponentInfos);
- Intent sendIntent = createSendImageIntent();
-
- final ResolverWrapperActivity activity = mActivityRule.launchActivity(sendIntent);
- waitForIdle();
-
- onView(withText(R.string.resolver_work_tab))
- .perform(click());
- waitForIdle();
- assertThat(activity.getWorkListAdapter().getCount(), is(4));
- }
-
- @Test
- public void testWorkTab_selectingWorkTabAppOpensAppInWorkProfile() throws InterruptedException {
- markWorkProfileUserAvailable();
- List<ResolvedComponentInfo> personalResolvedComponentInfos =
- createResolvedComponentsForTestWithOtherProfile(3, /* userId */ 10,
- PERSONAL_USER_HANDLE);
- List<ResolvedComponentInfo> workResolvedComponentInfos = createResolvedComponentsForTest(4,
- sOverrides.workProfileUserHandle);
- setupResolverControllers(personalResolvedComponentInfos, workResolvedComponentInfos);
- Intent sendIntent = createSendImageIntent();
- ResolveInfo[] chosen = new ResolveInfo[1];
- sOverrides.onSafelyStartInternalCallback = result -> {
- chosen[0] = result.first.getResolveInfo();
- return true;
- };
-
- mActivityRule.launchActivity(sendIntent);
- waitForIdle();
- onView(withText(R.string.resolver_work_tab))
- .perform(click());
- waitForIdle();
- onView(first(allOf(withText(workResolvedComponentInfos.get(0)
- .getResolveInfoAt(0).activityInfo.applicationInfo.name), isCompletelyDisplayed())))
- .perform(click());
- onView(withId(com.android.internal.R.id.button_once))
- .perform(click());
-
- waitForIdle();
- assertThat(chosen[0], is(workResolvedComponentInfos.get(0).getResolveInfoAt(0)));
- }
-
- @Test
- public void testWorkTab_noPersonalApps_workTabHasExpectedNumberOfTargets()
- throws InterruptedException {
- markWorkProfileUserAvailable();
- List<ResolvedComponentInfo> personalResolvedComponentInfos =
- createResolvedComponentsForTestWithOtherProfile(1, PERSONAL_USER_HANDLE);
- List<ResolvedComponentInfo> workResolvedComponentInfos = createResolvedComponentsForTest(4,
- sOverrides.workProfileUserHandle);
- setupResolverControllers(personalResolvedComponentInfos, workResolvedComponentInfos);
- Intent sendIntent = createSendImageIntent();
-
- final ResolverWrapperActivity activity = mActivityRule.launchActivity(sendIntent);
- waitForIdle();
- onView(withText(R.string.resolver_work_tab))
- .perform(click());
-
- waitForIdle();
- assertThat(activity.getWorkListAdapter().getCount(), is(4));
- }
-
- @Test
- public void testWorkTab_headerIsVisibleInPersonalTab() {
- markWorkProfileUserAvailable();
- List<ResolvedComponentInfo> personalResolvedComponentInfos =
- createResolvedComponentsForTestWithOtherProfile(1, PERSONAL_USER_HANDLE);
- List<ResolvedComponentInfo> workResolvedComponentInfos = createResolvedComponentsForTest(4,
- sOverrides.workProfileUserHandle);
- setupResolverControllers(personalResolvedComponentInfos, workResolvedComponentInfos);
- Intent sendIntent = createOpenWebsiteIntent();
-
- final ResolverWrapperActivity activity = mActivityRule.launchActivity(sendIntent);
- waitForIdle();
- TextView headerText = activity.findViewById(com.android.internal.R.id.title);
- String initialText = headerText.getText().toString();
- assertFalse("Header text is empty.", initialText.isEmpty());
- assertThat(headerText.getVisibility(), is(View.VISIBLE));
- }
-
- @Test
- public void testWorkTab_switchTabs_headerStaysSame() {
- markWorkProfileUserAvailable();
- List<ResolvedComponentInfo> personalResolvedComponentInfos =
- createResolvedComponentsForTestWithOtherProfile(1, PERSONAL_USER_HANDLE);
- List<ResolvedComponentInfo> workResolvedComponentInfos = createResolvedComponentsForTest(4,
- sOverrides.workProfileUserHandle);
- setupResolverControllers(personalResolvedComponentInfos, workResolvedComponentInfos);
- Intent sendIntent = createOpenWebsiteIntent();
-
- final ResolverWrapperActivity activity = mActivityRule.launchActivity(sendIntent);
- waitForIdle();
- TextView headerText = activity.findViewById(com.android.internal.R.id.title);
- String initialText = headerText.getText().toString();
- onView(withText(R.string.resolver_work_tab))
- .perform(click());
-
- waitForIdle();
- String currentText = headerText.getText().toString();
- assertThat(headerText.getVisibility(), is(View.VISIBLE));
- assertThat(String.format("Header text is not the same when switching tabs, personal profile"
- + " header was %s but work profile header is %s", initialText, currentText),
- TextUtils.equals(initialText, currentText));
- }
-
- @Test
- public void testWorkTab_noPersonalApps_canStartWorkApps()
- throws InterruptedException {
- markWorkProfileUserAvailable();
- List<ResolvedComponentInfo> personalResolvedComponentInfos =
- createResolvedComponentsForTestWithOtherProfile(3, /* userId= */ 10,
- PERSONAL_USER_HANDLE);
- List<ResolvedComponentInfo> workResolvedComponentInfos = createResolvedComponentsForTest(4,
- sOverrides.workProfileUserHandle);
- setupResolverControllers(personalResolvedComponentInfos, workResolvedComponentInfos);
- Intent sendIntent = createSendImageIntent();
- ResolveInfo[] chosen = new ResolveInfo[1];
- sOverrides.onSafelyStartInternalCallback = result -> {
- chosen[0] = result.first.getResolveInfo();
- return true;
- };
-
- mActivityRule.launchActivity(sendIntent);
- waitForIdle();
- onView(withText(R.string.resolver_work_tab))
- .perform(click());
- waitForIdle();
- onView(first(allOf(
- withText(workResolvedComponentInfos.get(0)
- .getResolveInfoAt(0).activityInfo.applicationInfo.name),
- isDisplayed())))
- .perform(click());
- onView(withId(com.android.internal.R.id.button_once))
- .perform(click());
- waitForIdle();
-
- assertThat(chosen[0], is(workResolvedComponentInfos.get(0).getResolveInfoAt(0)));
- }
-
- @Test
- public void testWorkTab_crossProfileIntentsDisabled_personalToWork_emptyStateShown() {
- markWorkProfileUserAvailable();
- int workProfileTargets = 4;
- List<ResolvedComponentInfo> personalResolvedComponentInfos =
- createResolvedComponentsForTestWithOtherProfile(3, /* userId */ 10,
- PERSONAL_USER_HANDLE);
- List<ResolvedComponentInfo> workResolvedComponentInfos =
- createResolvedComponentsForTest(workProfileTargets,
- sOverrides.workProfileUserHandle);
- sOverrides.hasCrossProfileIntents = false;
- setupResolverControllers(personalResolvedComponentInfos, workResolvedComponentInfos);
- Intent sendIntent = createSendImageIntent();
- sendIntent.setType("TestType");
-
- mActivityRule.launchActivity(sendIntent);
- waitForIdle();
- onView(withText(R.string.resolver_work_tab)).perform(click());
- waitForIdle();
- onView(withId(com.android.internal.R.id.contentPanel))
- .perform(swipeUp());
-
- onView(withText(R.string.resolver_cross_profile_blocked))
- .check(matches(isDisplayed()));
- }
-
- @Test
- public void testWorkTab_workProfileDisabled_emptyStateShown() {
- markWorkProfileUserAvailable();
- int workProfileTargets = 4;
- List<ResolvedComponentInfo> personalResolvedComponentInfos =
- createResolvedComponentsForTestWithOtherProfile(3, /* userId */ 10,
- PERSONAL_USER_HANDLE);
- List<ResolvedComponentInfo> workResolvedComponentInfos =
- createResolvedComponentsForTest(workProfileTargets,
- sOverrides.workProfileUserHandle);
- sOverrides.isQuietModeEnabled = true;
- setupResolverControllers(personalResolvedComponentInfos, workResolvedComponentInfos);
- Intent sendIntent = createSendImageIntent();
- sendIntent.setType("TestType");
-
- mActivityRule.launchActivity(sendIntent);
- waitForIdle();
- onView(withId(com.android.internal.R.id.contentPanel))
- .perform(swipeUp());
- onView(withText(R.string.resolver_work_tab)).perform(click());
- waitForIdle();
-
- onView(withText(R.string.resolver_turn_on_work_apps))
- .check(matches(isDisplayed()));
- }
-
- @Test
- public void testWorkTab_noWorkAppsAvailable_emptyStateShown() {
- markWorkProfileUserAvailable();
- List<ResolvedComponentInfo> personalResolvedComponentInfos =
- createResolvedComponentsForTest(3, PERSONAL_USER_HANDLE);
- List<ResolvedComponentInfo> workResolvedComponentInfos =
- createResolvedComponentsForTest(0, sOverrides.workProfileUserHandle);
- setupResolverControllers(personalResolvedComponentInfos, workResolvedComponentInfos);
- Intent sendIntent = createSendImageIntent();
- sendIntent.setType("TestType");
-
- mActivityRule.launchActivity(sendIntent);
- waitForIdle();
- onView(withId(com.android.internal.R.id.contentPanel))
- .perform(swipeUp());
- onView(withText(R.string.resolver_work_tab)).perform(click());
- waitForIdle();
-
- onView(withText(R.string.resolver_no_work_apps_available))
- .check(matches(isDisplayed()));
- }
-
- @Test
- public void testWorkTab_xProfileOff_noAppsAvailable_workOff_xProfileOffEmptyStateShown() {
- markWorkProfileUserAvailable();
- List<ResolvedComponentInfo> personalResolvedComponentInfos =
- createResolvedComponentsForTest(3, PERSONAL_USER_HANDLE);
- List<ResolvedComponentInfo> workResolvedComponentInfos =
- createResolvedComponentsForTest(0, sOverrides.workProfileUserHandle);
- setupResolverControllers(personalResolvedComponentInfos, workResolvedComponentInfos);
- Intent sendIntent = createSendImageIntent();
- sendIntent.setType("TestType");
- sOverrides.isQuietModeEnabled = true;
- sOverrides.hasCrossProfileIntents = false;
-
- mActivityRule.launchActivity(sendIntent);
- waitForIdle();
- onView(withId(com.android.internal.R.id.contentPanel))
- .perform(swipeUp());
- onView(withText(R.string.resolver_work_tab)).perform(click());
- waitForIdle();
-
- onView(withText(R.string.resolver_cross_profile_blocked))
- .check(matches(isDisplayed()));
- }
-
- @Test
- public void testMiniResolver() {
- markWorkProfileUserAvailable();
- List<ResolvedComponentInfo> personalResolvedComponentInfos =
- createResolvedComponentsForTest(1, PERSONAL_USER_HANDLE);
- List<ResolvedComponentInfo> workResolvedComponentInfos =
- createResolvedComponentsForTest(1, sOverrides.workProfileUserHandle);
- // Personal profile only has a browser
- personalResolvedComponentInfos.get(0).getResolveInfoAt(0).handleAllWebDataURI = true;
- setupResolverControllers(personalResolvedComponentInfos, workResolvedComponentInfos);
- Intent sendIntent = createSendImageIntent();
- sendIntent.setType("TestType");
-
- mActivityRule.launchActivity(sendIntent);
- waitForIdle();
- onView(withId(com.android.internal.R.id.open_cross_profile)).check(matches(isDisplayed()));
- }
-
- @Test
- public void testMiniResolver_noCurrentProfileTarget() {
- markWorkProfileUserAvailable();
- List<ResolvedComponentInfo> personalResolvedComponentInfos =
- createResolvedComponentsForTest(0, PERSONAL_USER_HANDLE);
- List<ResolvedComponentInfo> workResolvedComponentInfos =
- createResolvedComponentsForTest(1, sOverrides.workProfileUserHandle);
- setupResolverControllers(personalResolvedComponentInfos, workResolvedComponentInfos);
- Intent sendIntent = createSendImageIntent();
- sendIntent.setType("TestType");
-
- mActivityRule.launchActivity(sendIntent);
- waitForIdle();
-
- // Need to ensure mini resolver doesn't trigger here.
- assertNotMiniResolver();
- }
-
- private void assertNotMiniResolver() {
- try {
- onView(withId(com.android.internal.R.id.open_cross_profile))
- .check(matches(isDisplayed()));
- } catch (NoMatchingViewException e) {
- return;
- }
- fail("Mini resolver present but shouldn't be");
- }
-
- @Test
- public void testWorkTab_noAppsAvailable_workOff_noAppsAvailableEmptyStateShown() {
- markWorkProfileUserAvailable();
- List<ResolvedComponentInfo> personalResolvedComponentInfos =
- createResolvedComponentsForTest(3, PERSONAL_USER_HANDLE);
- List<ResolvedComponentInfo> workResolvedComponentInfos =
- createResolvedComponentsForTest(0, sOverrides.workProfileUserHandle);
- setupResolverControllers(personalResolvedComponentInfos, workResolvedComponentInfos);
- Intent sendIntent = createSendImageIntent();
- sendIntent.setType("TestType");
- sOverrides.isQuietModeEnabled = true;
-
- mActivityRule.launchActivity(sendIntent);
- waitForIdle();
- onView(withId(com.android.internal.R.id.contentPanel))
- .perform(swipeUp());
- onView(withText(R.string.resolver_work_tab)).perform(click());
- waitForIdle();
-
- onView(withText(R.string.resolver_no_work_apps_available))
- .check(matches(isDisplayed()));
- }
-
- @Test
- public void testWorkTab_onePersonalTarget_emptyStateOnWorkTarget_doesNotAutoLaunch() {
- markWorkProfileUserAvailable();
- int workProfileTargets = 4;
- List<ResolvedComponentInfo> personalResolvedComponentInfos =
- createResolvedComponentsForTestWithOtherProfile(2, /* userId */ 10,
- PERSONAL_USER_HANDLE);
- List<ResolvedComponentInfo> workResolvedComponentInfos =
- createResolvedComponentsForTest(workProfileTargets,
- sOverrides.workProfileUserHandle);
- sOverrides.hasCrossProfileIntents = false;
- setupResolverControllers(personalResolvedComponentInfos, workResolvedComponentInfos);
- Intent sendIntent = createSendImageIntent();
- sendIntent.setType("TestType");
- ResolveInfo[] chosen = new ResolveInfo[1];
- sOverrides.onSafelyStartInternalCallback = result -> {
- chosen[0] = result.first.getResolveInfo();
- return true;
- };
-
- mActivityRule.launchActivity(sendIntent);
- waitForIdle();
-
- assertNull(chosen[0]);
- }
-
- @Test
- public void testLayoutWithDefault_withWorkTab_neverShown() throws RemoteException {
- markWorkProfileUserAvailable();
-
- // In this case we prefer the other profile and don't display anything about the last
- // chosen activity.
- Intent sendIntent = createSendImageIntent();
- List<ResolvedComponentInfo> resolvedComponentInfos =
- createResolvedComponentsForTest(2, PERSONAL_USER_HANDLE);
-
- setupResolverControllers(resolvedComponentInfos);
- when(sOverrides.resolverListController.getLastChosen())
- .thenReturn(resolvedComponentInfos.get(1).getResolveInfoAt(0));
-
- final ResolverWrapperActivity activity = mActivityRule.launchActivity(sendIntent);
- Espresso.registerIdlingResources(activity.getLabelIdlingResource());
- waitForIdle();
-
- // The other entry is filtered to the last used slot
- assertThat(activity.getAdapter().hasFilteredItem(), is(false));
- assertThat(activity.getAdapter().getCount(), is(2));
- assertThat(activity.getAdapter().getPlaceholderCount(), is(2));
- }
-
- @Test
- public void testClonedProfilePresent_personalAdapterIsSetWithPersonalProfile() {
- // enable cloneProfile
- markCloneProfileUserAvailable();
- List<ResolvedComponentInfo> resolvedComponentInfos =
- createResolvedComponentsWithCloneProfileForTest(
- 3,
- PERSONAL_USER_HANDLE,
- sOverrides.cloneProfileUserHandle);
- setupResolverControllers(resolvedComponentInfos);
- Intent sendIntent = createSendImageIntent();
-
- final ResolverWrapperActivity activity = mActivityRule.launchActivity(sendIntent);
- waitForIdle();
-
- assertThat(activity.getCurrentUserHandle(), is(activity.getPersonalProfileUserHandle()));
- assertThat(activity.getAdapter().getCount(), is(3));
- }
-
- @Test
- public void testClonedProfilePresent_personalTabUsesExpectedAdapter() {
- markWorkProfileUserAvailable();
- // enable cloneProfile
- markCloneProfileUserAvailable();
- List<ResolvedComponentInfo> personalResolvedComponentInfos =
- createResolvedComponentsWithCloneProfileForTest(
- 3,
- PERSONAL_USER_HANDLE,
- sOverrides.cloneProfileUserHandle);
- List<ResolvedComponentInfo> workResolvedComponentInfos = createResolvedComponentsForTest(4,
- sOverrides.workProfileUserHandle);
- setupResolverControllers(personalResolvedComponentInfos, workResolvedComponentInfos);
- Intent sendIntent = createSendImageIntent();
-
- final ResolverWrapperActivity activity = mActivityRule.launchActivity(sendIntent);
- waitForIdle();
-
- assertThat(activity.getCurrentUserHandle(), is(activity.getPersonalProfileUserHandle()));
- assertThat(activity.getAdapter().getCount(), is(3));
- }
-
- @Test
- public void testClonedProfilePresent_layoutWithDefault_neverShown() throws Exception {
- // enable cloneProfile
- markCloneProfileUserAvailable();
- Intent sendIntent = createSendImageIntent();
- List<ResolvedComponentInfo> resolvedComponentInfos =
- createResolvedComponentsWithCloneProfileForTest(
- 2,
- PERSONAL_USER_HANDLE,
- sOverrides.cloneProfileUserHandle);
-
- setupResolverControllers(resolvedComponentInfos);
- when(sOverrides.resolverListController.getLastChosen())
- .thenReturn(resolvedComponentInfos.get(0).getResolveInfoAt(0));
-
- final ResolverWrapperActivity activity = mActivityRule.launchActivity(sendIntent);
- Espresso.registerIdlingResources(activity.getLabelIdlingResource());
- waitForIdle();
-
- assertThat(activity.getAdapter().hasFilteredItem(), is(false));
- assertThat(activity.getAdapter().getCount(), is(2));
- assertThat(activity.getAdapter().getPlaceholderCount(), is(2));
- }
-
- @Test
- public void testClonedProfilePresent_alwaysButtonDisabled() throws Exception {
- // enable cloneProfile
- markCloneProfileUserAvailable();
- Intent sendIntent = createSendImageIntent();
- List<ResolvedComponentInfo> resolvedComponentInfos =
- createResolvedComponentsWithCloneProfileForTest(
- 3,
- PERSONAL_USER_HANDLE,
- sOverrides.cloneProfileUserHandle);
-
- setupResolverControllers(resolvedComponentInfos);
- when(sOverrides.resolverListController.getLastChosen())
- .thenReturn(resolvedComponentInfos.get(0).getResolveInfoAt(0));
-
- final ResolverWrapperActivity activity = mActivityRule.launchActivity(sendIntent);
- waitForIdle();
-
- // Confirm that the button bar is disabled by default
- onView(withId(com.android.internal.R.id.button_once)).check(matches(not(isEnabled())));
- onView(withId(com.android.internal.R.id.button_always)).check(matches(not(isEnabled())));
-
- // Make a stable copy of the components as the original list may be modified
- List<ResolvedComponentInfo> stableCopy =
- createResolvedComponentsForTestWithOtherProfile(2, PERSONAL_USER_HANDLE);
-
- onView(withText(stableCopy.get(1).getResolveInfoAt(0).activityInfo.name))
- .perform(click());
-
- onView(withId(com.android.internal.R.id.button_once)).check(matches(isEnabled()));
- onView(withId(com.android.internal.R.id.button_always)).check(matches(not(isEnabled())));
- }
-
- @Test
- public void testClonedProfilePresent_personalProfileActivityIsStartedInCorrectUser()
- throws Exception {
- markWorkProfileUserAvailable();
- // enable cloneProfile
- markCloneProfileUserAvailable();
-
- List<ResolvedComponentInfo> personalResolvedComponentInfos =
- createResolvedComponentsWithCloneProfileForTest(
- 3,
- PERSONAL_USER_HANDLE,
- sOverrides.cloneProfileUserHandle);
- List<ResolvedComponentInfo> workResolvedComponentInfos =
- createResolvedComponentsForTest(3, sOverrides.workProfileUserHandle);
- sOverrides.hasCrossProfileIntents = false;
- setupResolverControllers(personalResolvedComponentInfos, workResolvedComponentInfos);
- Intent sendIntent = createSendImageIntent();
- sendIntent.setType("TestType");
- final UserHandle[] selectedActivityUserHandle = new UserHandle[1];
- sOverrides.onSafelyStartInternalCallback = result -> {
- selectedActivityUserHandle[0] = result.second;
- return true;
- };
-
- final ResolverWrapperActivity activity = mActivityRule.launchActivity(sendIntent);
- waitForIdle();
- onView(first(allOf(withText(personalResolvedComponentInfos.get(0)
- .getResolveInfoAt(0).activityInfo.applicationInfo.name), isCompletelyDisplayed())))
- .perform(click());
- onView(withId(com.android.internal.R.id.button_once))
- .perform(click());
- waitForIdle();
-
- assertThat(selectedActivityUserHandle[0], is(activity.getAdapter().getUserHandle()));
- }
-
- @Test
- public void testClonedProfilePresent_workProfileActivityIsStartedInCorrectUser()
- throws Exception {
- markWorkProfileUserAvailable();
- // enable cloneProfile
- markCloneProfileUserAvailable();
-
- List<ResolvedComponentInfo> personalResolvedComponentInfos =
- createResolvedComponentsWithCloneProfileForTest(
- 3,
- PERSONAL_USER_HANDLE,
- sOverrides.cloneProfileUserHandle);
- List<ResolvedComponentInfo> workResolvedComponentInfos =
- createResolvedComponentsForTest(3, sOverrides.workProfileUserHandle);
- setupResolverControllers(personalResolvedComponentInfos, workResolvedComponentInfos);
- Intent sendIntent = createSendImageIntent();
- sendIntent.setType("TestType");
- final UserHandle[] selectedActivityUserHandle = new UserHandle[1];
- sOverrides.onSafelyStartInternalCallback = result -> {
- selectedActivityUserHandle[0] = result.second;
- return true;
- };
-
- final ResolverWrapperActivity activity = mActivityRule.launchActivity(sendIntent);
- waitForIdle();
- onView(withText(R.string.resolver_work_tab))
- .perform(click());
- waitForIdle();
- onView(first(allOf(withText(workResolvedComponentInfos.get(0)
- .getResolveInfoAt(0).activityInfo.applicationInfo.name), isCompletelyDisplayed())))
- .perform(click());
- onView(withId(com.android.internal.R.id.button_once))
- .perform(click());
- waitForIdle();
-
- assertThat(selectedActivityUserHandle[0], is(activity.getAdapter().getUserHandle()));
- }
-
- @Test
- public void testClonedProfilePresent_personalProfileResolverComparatorHasCorrectUsers()
- throws Exception {
- // enable cloneProfile
- markCloneProfileUserAvailable();
- List<ResolvedComponentInfo> resolvedComponentInfos =
- createResolvedComponentsWithCloneProfileForTest(
- 3,
- PERSONAL_USER_HANDLE,
- sOverrides.cloneProfileUserHandle);
- setupResolverControllers(resolvedComponentInfos);
- Intent sendIntent = createSendImageIntent();
-
- final ResolverWrapperActivity activity = mActivityRule.launchActivity(sendIntent);
- waitForIdle();
- List<UserHandle> result = activity
- .getResolverRankerServiceUserHandleList(PERSONAL_USER_HANDLE);
-
- assertThat(result.containsAll(Lists.newArrayList(PERSONAL_USER_HANDLE,
- sOverrides.cloneProfileUserHandle)), is(true));
- }
-
- private Intent createSendImageIntent() {
- Intent sendIntent = new Intent();
- sendIntent.setAction(Intent.ACTION_SEND);
- sendIntent.putExtra(Intent.EXTRA_TEXT, "testing intent sending");
- sendIntent.setType("image/jpeg");
- return sendIntent;
- }
-
- private Intent createOpenWebsiteIntent() {
- Intent sendIntent = new Intent();
- sendIntent.setAction(Intent.ACTION_VIEW);
- sendIntent.setData(Uri.parse("https://google.com"));
- return sendIntent;
- }
-
- private List<ResolvedComponentInfo> createResolvedComponentsForTest(int numberOfResults,
- UserHandle resolvedForUser) {
- List<ResolvedComponentInfo> infoList = new ArrayList<>(numberOfResults);
- for (int i = 0; i < numberOfResults; i++) {
- infoList.add(ResolverDataProvider.createResolvedComponentInfo(i, resolvedForUser));
- }
- return infoList;
- }
-
- private List<ResolvedComponentInfo> createResolvedComponentsWithCloneProfileForTest(
- int numberOfResults,
- UserHandle resolvedForPersonalUser,
- UserHandle resolvedForClonedUser) {
- List<ResolvedComponentInfo> infoList = new ArrayList<>(numberOfResults);
- for (int i = 0; i < 1; i++) {
- infoList.add(ResolverDataProvider.createResolvedComponentInfo(i,
- resolvedForPersonalUser));
- }
- for (int i = 1; i < numberOfResults; i++) {
- infoList.add(ResolverDataProvider.createResolvedComponentInfo(i,
- resolvedForClonedUser));
- }
- return infoList;
- }
-
- private List<ResolvedComponentInfo> createResolvedComponentsForTestWithOtherProfile(
- int numberOfResults,
- UserHandle resolvedForUser) {
- List<ResolvedComponentInfo> infoList = new ArrayList<>(numberOfResults);
- for (int i = 0; i < numberOfResults; i++) {
- if (i == 0) {
- infoList.add(ResolverDataProvider.createResolvedComponentInfoWithOtherId(i,
- resolvedForUser));
- } else {
- infoList.add(ResolverDataProvider.createResolvedComponentInfo(i, resolvedForUser));
- }
- }
- return infoList;
- }
-
- private List<ResolvedComponentInfo> createResolvedComponentsForTestWithOtherProfile(
- int numberOfResults, int userId, UserHandle resolvedForUser) {
- List<ResolvedComponentInfo> infoList = new ArrayList<>(numberOfResults);
- for (int i = 0; i < numberOfResults; i++) {
- if (i == 0) {
- infoList.add(
- ResolverDataProvider.createResolvedComponentInfoWithOtherId(i, userId,
- resolvedForUser));
- } else {
- infoList.add(ResolverDataProvider.createResolvedComponentInfo(i, resolvedForUser));
- }
- }
- return infoList;
- }
-
- private void waitForIdle() {
- InstrumentationRegistry.getInstrumentation().waitForIdleSync();
- }
-
- private void markWorkProfileUserAvailable() {
- ResolverWrapperActivity.sOverrides.workProfileUserHandle = UserHandle.of(10);
- }
-
- private void setupResolverControllers(
- List<ResolvedComponentInfo> personalResolvedComponentInfos) {
- setupResolverControllers(personalResolvedComponentInfos, new ArrayList<>());
- }
-
- private void markCloneProfileUserAvailable() {
- ResolverWrapperActivity.sOverrides.cloneProfileUserHandle = UserHandle.of(11);
- }
-
- private void setupResolverControllers(
- List<ResolvedComponentInfo> personalResolvedComponentInfos,
- List<ResolvedComponentInfo> workResolvedComponentInfos) {
- when(sOverrides.resolverListController.getResolversForIntentAsUser(
- Mockito.anyBoolean(),
- 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)))
- .thenReturn(new ArrayList<>(personalResolvedComponentInfos));
- when(sOverrides.workResolverListController.getResolversForIntentAsUser(
- Mockito.anyBoolean(),
- Mockito.anyBoolean(),
- Mockito.anyBoolean(),
- Mockito.isA(List.class),
- eq(UserHandle.of(10))))
- .thenReturn(new ArrayList<>(workResolvedComponentInfos));
- }
-}
diff --git a/java/tests/src/com/android/intentresolver/ResolverDataProvider.java b/java/tests/src/com/android/intentresolver/ResolverDataProvider.java
deleted file mode 100644
index 688dd867..00000000
--- a/java/tests/src/com/android/intentresolver/ResolverDataProvider.java
+++ /dev/null
@@ -1,255 +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.content.ComponentName;
-import android.content.Context;
-import android.content.Intent;
-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.os.UserHandle;
-import android.test.mock.MockContext;
-import android.test.mock.MockPackageManager;
-import android.test.mock.MockResources;
-
-/**
- * Utility class used by resolver tests to create mock data
- */
-public class ResolverDataProvider {
-
- static private int USER_SOMEONE_ELSE = 10;
-
- static ResolvedComponentInfo createResolvedComponentInfo(int i) {
- return new ResolvedComponentInfo(
- createComponentName(i),
- createResolverIntent(i),
- createResolveInfo(i, UserHandle.USER_CURRENT));
- }
-
- static ResolvedComponentInfo createResolvedComponentInfo(int i,
- UserHandle resolvedForUser) {
- return new ResolvedComponentInfo(
- createComponentName(i),
- createResolverIntent(i),
- createResolveInfo(i, UserHandle.USER_CURRENT, resolvedForUser));
- }
-
- static ResolvedComponentInfo createResolvedComponentInfo(
- ComponentName componentName, Intent intent) {
- return new ResolvedComponentInfo(
- componentName,
- intent,
- createResolveInfo(componentName, UserHandle.USER_CURRENT));
- }
-
- static ResolvedComponentInfo createResolvedComponentInfo(
- ComponentName componentName, Intent intent, UserHandle resolvedForUser) {
- return new ResolvedComponentInfo(
- componentName,
- intent,
- createResolveInfo(componentName, UserHandle.USER_CURRENT, resolvedForUser));
- }
-
- static ResolvedComponentInfo createResolvedComponentInfoWithOtherId(int i) {
- return new ResolvedComponentInfo(
- createComponentName(i),
- createResolverIntent(i),
- createResolveInfo(i, USER_SOMEONE_ELSE));
- }
-
- static ResolvedComponentInfo createResolvedComponentInfoWithOtherId(int i,
- UserHandle resolvedForUser) {
- return new ResolvedComponentInfo(
- createComponentName(i),
- createResolverIntent(i),
- createResolveInfo(i, USER_SOMEONE_ELSE, resolvedForUser));
- }
-
- static ResolvedComponentInfo createResolvedComponentInfoWithOtherId(int i, int userId) {
- return new ResolvedComponentInfo(
- createComponentName(i),
- createResolverIntent(i),
- createResolveInfo(i, userId));
- }
-
- static ResolvedComponentInfo createResolvedComponentInfoWithOtherId(int i,
- int userId, UserHandle resolvedForUser) {
- return new ResolvedComponentInfo(
- createComponentName(i),
- createResolverIntent(i),
- createResolveInfo(i, userId, resolvedForUser));
- }
-
- public static ComponentName createComponentName(int i) {
- final String name = "component" + i;
- return new ComponentName("foo.bar." + name, name);
- }
-
- public static ResolveInfo createResolveInfo(int i, int userId) {
- final ResolveInfo resolveInfo = new ResolveInfo();
- resolveInfo.activityInfo = createActivityInfo(i);
- resolveInfo.targetUserId = userId;
- return resolveInfo;
- }
- public static ResolveInfo createResolveInfo(int i, int userId, UserHandle resolvedForUser) {
- final ResolveInfo resolveInfo = new ResolveInfo();
- resolveInfo.activityInfo = createActivityInfo(i);
- resolveInfo.targetUserId = userId;
- resolveInfo.userHandle = resolvedForUser;
- return resolveInfo;
- }
-
- public static ResolveInfo createResolveInfo(ComponentName componentName, int userId) {
- final ResolveInfo resolveInfo = new ResolveInfo();
- resolveInfo.activityInfo = createActivityInfo(componentName);
- resolveInfo.targetUserId = userId;
- return resolveInfo;
- }
-
- public static ResolveInfo createResolveInfo(ComponentName componentName, int userId,
- UserHandle resolvedForUser) {
- final ResolveInfo resolveInfo = new ResolveInfo();
- resolveInfo.activityInfo = createActivityInfo(componentName);
- resolveInfo.targetUserId = userId;
- resolveInfo.userHandle = resolvedForUser;
- return resolveInfo;
- }
-
- static ActivityInfo createActivityInfo(int i) {
- ActivityInfo ai = new ActivityInfo();
- ai.name = "activity_name" + i;
- ai.packageName = "foo_bar" + i;
- ai.enabled = true;
- ai.exported = true;
- ai.permission = null;
- ai.applicationInfo = createApplicationInfo();
- return ai;
- }
-
- static ActivityInfo createActivityInfo(ComponentName componentName) {
- ActivityInfo ai = new ActivityInfo();
- ai.name = componentName.getClassName();
- ai.packageName = componentName.getPackageName();
- ai.enabled = true;
- ai.exported = true;
- ai.permission = null;
- ai.applicationInfo = createApplicationInfo();
- ai.applicationInfo.packageName = componentName.getPackageName();
- return ai;
- }
-
- static ApplicationInfo createApplicationInfo() {
- ApplicationInfo ai = new ApplicationInfo();
- ai.name = "app_name";
- ai.packageName = "foo.bar";
- ai.enabled = true;
- return ai;
- }
-
- static class PackageManagerMockedInfo {
- public Context ctx;
- public ApplicationInfo appInfo;
- public ActivityInfo activityInfo;
- public ResolveInfo resolveInfo;
- public String setAppLabel;
- public String setActivityLabel;
- public String setResolveInfoLabel;
- }
-
- /** Create a {@link PackageManagerMockedInfo} with all distinct labels. */
- static PackageManagerMockedInfo createPackageManagerMockedInfo(boolean hasOverridePermission) {
- return createPackageManagerMockedInfo(
- hasOverridePermission, "app_label", "activity_label", "resolve_info_label");
- }
-
- static PackageManagerMockedInfo createPackageManagerMockedInfo(
- boolean hasOverridePermission,
- String appLabel,
- String activityLabel,
- String resolveInfoLabel) {
- MockContext ctx = new MockContext() {
- @Override
- public PackageManager getPackageManager() {
- return new MockPackageManager() {
- @Override
- public int checkPermission(String permName, String pkgName) {
- if (hasOverridePermission) return PERMISSION_GRANTED;
- return PERMISSION_DENIED;
- }
- };
- }
-
- @Override
- public Resources getResources() {
- return new MockResources() {
- @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;
- }
- };
- }
- };
-
- ApplicationInfo appInfo = new ApplicationInfo() {
- @Override
- public CharSequence loadLabel(PackageManager pm) {
- return appLabel;
- }
- };
- appInfo.labelRes = 1;
-
- ActivityInfo activityInfo = new ActivityInfo() {
- @Override
- public CharSequence loadLabel(PackageManager pm) {
- return activityLabel;
- }
- };
- activityInfo.labelRes = 2;
- activityInfo.applicationInfo = appInfo;
-
- ResolveInfo resolveInfo = new ResolveInfo() {
- @Override
- public CharSequence loadLabel(PackageManager pm) {
- return resolveInfoLabel;
- }
- };
- resolveInfo.activityInfo = activityInfo;
- resolveInfo.resolvePackageName = "super.fake.packagename";
- resolveInfo.labelRes = 3;
-
- PackageManagerMockedInfo mockedInfo = new PackageManagerMockedInfo();
- mockedInfo.activityInfo = activityInfo;
- mockedInfo.appInfo = appInfo;
- mockedInfo.ctx = ctx;
- mockedInfo.resolveInfo = resolveInfo;
- mockedInfo.setAppLabel = appLabel;
- mockedInfo.setActivityLabel = activityLabel;
- mockedInfo.setResolveInfoLabel = resolveInfoLabel;
-
- return mockedInfo;
- }
-
- static Intent createResolverIntent(int i) {
- return new Intent("intentAction" + i);
- }
-}
diff --git a/java/tests/src/com/android/intentresolver/ResolverWrapperActivity.java b/java/tests/src/com/android/intentresolver/ResolverWrapperActivity.java
deleted file mode 100644
index 401ede26..00000000
--- a/java/tests/src/com/android/intentresolver/ResolverWrapperActivity.java
+++ /dev/null
@@ -1,294 +0,0 @@
-/*
- * Copyright (C) 2017 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS 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 org.mockito.ArgumentMatchers.any;
-import static org.mockito.ArgumentMatchers.anyInt;
-import static org.mockito.Mockito.mock;
-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;
-import android.os.UserHandle;
-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.icons.TargetDataLoader;
-
-import java.util.List;
-import java.util.function.Consumer;
-import java.util.function.Function;
-
-/*
- * Simple wrapper around chooser activity to be able to initiate it under test
- */
-public class ResolverWrapperActivity extends ResolverActivity {
- static final OverrideData sOverrides = new OverrideData();
-
- 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;
- }
-
- @Override
- public ResolverListAdapter createResolverListAdapter(
- Context context,
- List<Intent> payloadIntents,
- Intent[] initialIntents,
- List<ResolveInfo> rList,
- boolean filterLastUsed,
- UserHandle userHandle,
- TargetDataLoader targetDataLoader) {
- return new ResolverListAdapter(
- context,
- payloadIntents,
- initialIntents,
- rList,
- filterLastUsed,
- createListController(userHandle),
- userHandle,
- payloadIntents.get(0), // TODO: extract upstream
- this,
- userHandle,
- new TargetDataLoaderWrapper(targetDataLoader, mLabelIdlingResource));
- }
-
- @Override
- protected CrossProfileIntentsChecker createCrossProfileIntentsChecker() {
- if (sOverrides.mCrossProfileIntentsChecker != null) {
- return sOverrides.mCrossProfileIntentsChecker;
- }
- 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));
- }
-
- ResolverListAdapter getWorkListAdapter() {
- if (mMultiProfilePagerAdapter.getInactiveListAdapter() == null) {
- return null;
- }
- return ((ResolverListAdapter) mMultiProfilePagerAdapter.getAdapterForIndex(1));
- }
-
- @Override
- public boolean isVoiceInteraction() {
- if (sOverrides.isVoiceInteraction != null) {
- return sOverrides.isVoiceInteraction;
- }
- return super.isVoiceInteraction();
- }
-
- @Override
- public void safelyStartActivityInternal(TargetInfo cti, UserHandle user,
- @Nullable Bundle options) {
- if (sOverrides.onSafelyStartInternalCallback != null
- && sOverrides.onSafelyStartInternalCallback.apply(new Pair<>(cti, user))) {
- return;
- }
- super.safelyStartActivityInternal(cti, user, options);
- }
-
- @Override
- protected ResolverListController createListController(UserHandle userHandle) {
- if (userHandle == UserHandle.SYSTEM) {
- return sOverrides.resolverListController;
- }
- 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 {
- @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 final TargetDataLoader mTargetDataLoader;
- private final CountingIdlingResource mLabelIdlingResource;
-
- private TargetDataLoaderWrapper(
- TargetDataLoader targetDataLoader, CountingIdlingResource labelIdlingResource) {
- mTargetDataLoader = targetDataLoader;
- mLabelIdlingResource = labelIdlingResource;
- }
-
- @Override
- public void loadAppTargetIcon(
- @NonNull DisplayResolveInfo info,
- @NonNull UserHandle userHandle,
- @NonNull Consumer<Drawable> callback) {
- mTargetDataLoader.loadAppTargetIcon(info, userHandle, callback);
- }
-
- @Override
- public void loadDirectShareIcon(
- @NonNull SelectableTargetInfo info,
- @NonNull UserHandle userHandle,
- @NonNull Consumer<Drawable> callback) {
- mTargetDataLoader.loadDirectShareIcon(info, userHandle, callback);
- }
-
- @Override
- public void loadLabel(
- @NonNull DisplayResolveInfo info,
- @NonNull Consumer<CharSequence[]> callback) {
- mLabelIdlingResource.increment();
- mTargetDataLoader.loadLabel(
- info,
- (result) -> {
- mLabelIdlingResource.decrement();
- callback.accept(result);
- });
- }
-
- @NonNull
- @Override
- public TargetPresentationGetter createPresentationGetter(@NonNull ResolveInfo info) {
- return mTargetDataLoader.createPresentationGetter(info);
- }
- }
-}
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/TestContentProvider.kt b/java/tests/src/com/android/intentresolver/TestContentProvider.kt
deleted file mode 100644
index b3b53baa..00000000
--- a/java/tests/src/com/android/intentresolver/TestContentProvider.kt
+++ /dev/null
@@ -1,55 +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.ContentProvider
-import android.content.ContentValues
-import android.database.Cursor
-import android.net.Uri
-
-class TestContentProvider : ContentProvider() {
- override fun query(
- uri: Uri,
- projection: Array<out String>?,
- selection: String?,
- selectionArgs: Array<out String>?,
- sortOrder: String?
- ): Cursor? = null
-
- override fun getType(uri: Uri): String?
- = runCatching {
- uri.getQueryParameter("mimeType")
- }.getOrNull()
-
- override fun getStreamTypes(uri: Uri, mimeTypeFilter: String): Array<String>?
- = runCatching {
- uri.getQueryParameter("streamType")?.let { arrayOf(it) }
- }.getOrNull()
-
- override fun insert(uri: Uri, values: ContentValues?): Uri? = null
-
- override fun delete(uri: Uri, selection: String?, selectionArgs: Array<out String>?): Int = 0
-
- override fun update(
- uri: Uri,
- values: ContentValues?,
- selection: String?,
- selectionArgs: Array<out String>?
- ): Int = 0
-
- override fun onCreate(): Boolean = true
-} \ No newline at end of file
diff --git a/java/tests/src/com/android/intentresolver/TestHelpers.kt b/java/tests/src/com/android/intentresolver/TestHelpers.kt
deleted file mode 100644
index 5b583fef..00000000
--- a/java/tests/src/com/android/intentresolver/TestHelpers.kt
+++ /dev/null
@@ -1,71 +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.app.prediction.AppTarget
-import android.app.prediction.AppTargetId
-import android.content.ComponentName
-import android.content.Context
-import android.content.Intent
-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
-
-internal fun createShareShortcutInfo(
- id: String,
- componentName: ComponentName,
- rank: Int
-): 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)
- return ShortcutInfo.Builder(context, id)
- .setShortLabel("Short Label $id")
- .setLongLabel("Long Label $id")
- .setActivity(componentName)
- .setRank(rank)
- .build()
-}
-
-internal fun createAppTarget(shortcutInfo: ShortcutInfo) =
- AppTarget(
- AppTargetId(shortcutInfo.id),
- shortcutInfo,
- shortcutInfo.activity?.className ?: error("missing activity info")
- )
-
-fun createChooserTarget(
- title: String, score: Float, componentName: ComponentName, shortcutId: String
-): ChooserTarget =
- ChooserTarget(
- title,
- null,
- score,
- componentName,
- Bundle().apply { putString(Intent.EXTRA_SHORTCUT_ID, shortcutId) }
- )
diff --git a/java/tests/src/com/android/intentresolver/TestPreviewImageLoader.kt b/java/tests/src/com/android/intentresolver/TestPreviewImageLoader.kt
deleted file mode 100644
index bf87ed8a..00000000
--- a/java/tests/src/com/android/intentresolver/TestPreviewImageLoader.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
-
-import android.graphics.Bitmap
-import android.net.Uri
-import androidx.lifecycle.Lifecycle
-import com.android.intentresolver.contentpreview.ImageLoader
-import java.util.function.Consumer
-
-internal class TestPreviewImageLoader(private val bitmaps: Map<Uri, Bitmap>) : ImageLoader {
- override fun loadImage(callerLifecycle: Lifecycle, uri: Uri, callback: Consumer<Bitmap?>) {
- callback.accept(bitmaps[uri])
- }
-
- override suspend fun invoke(uri: Uri, caching: Boolean): Bitmap? = bitmaps[uri]
-
- override fun prePopulate(uris: List<Uri>) = Unit
-}
diff --git a/java/tests/src/com/android/intentresolver/UnbundledChooserActivityTest.java b/java/tests/src/com/android/intentresolver/UnbundledChooserActivityTest.java
deleted file mode 100644
index 3ddd4394..00000000
--- a/java/tests/src/com/android/intentresolver/UnbundledChooserActivityTest.java
+++ /dev/null
@@ -1,2971 +0,0 @@
-/*
- * Copyright (C) 2016 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS 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.Activity.RESULT_OK;
-
-import static androidx.test.espresso.Espresso.onView;
-import static androidx.test.espresso.action.ViewActions.click;
-import static androidx.test.espresso.action.ViewActions.longClick;
-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.isDisplayed;
-import static androidx.test.espresso.matcher.ViewMatchers.withEffectiveVisibility;
-import static androidx.test.espresso.matcher.ViewMatchers.withId;
-import static androidx.test.espresso.matcher.ViewMatchers.withText;
-
-import static com.android.intentresolver.ChooserActivity.TARGET_TYPE_CHOOSER_TARGET;
-import static com.android.intentresolver.ChooserActivity.TARGET_TYPE_DEFAULT;
-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.ChooserListAdapter.CALLER_TARGET_SCORE_BOOST;
-import static com.android.intentresolver.ChooserListAdapter.SHORTCUT_TARGET_SCORE_BOOST;
-import static com.android.intentresolver.MatcherUtils.first;
-
-import static com.google.common.truth.Truth.assertThat;
-
-import static junit.framework.Assert.assertNull;
-
-import static org.hamcrest.CoreMatchers.allOf;
-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.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.mock;
-import static org.mockito.Mockito.times;
-import static org.mockito.Mockito.verify;
-import static org.mockito.Mockito.when;
-
-import android.app.PendingIntent;
-import android.app.usage.UsageStatsManager;
-import android.content.BroadcastReceiver;
-import android.content.ClipData;
-import android.content.ClipDescription;
-import android.content.ClipboardManager;
-import android.content.ComponentName;
-import android.content.Context;
-import android.content.Intent;
-import android.content.IntentFilter;
-import android.content.pm.ActivityInfo;
-import android.content.pm.ApplicationInfo;
-import android.content.pm.PackageManager;
-import android.content.pm.ResolveInfo;
-import android.content.pm.ShortcutInfo;
-import android.content.pm.ShortcutManager.ShareShortcutInfo;
-import android.content.res.Configuration;
-import android.content.res.Resources;
-import android.database.Cursor;
-import android.graphics.Bitmap;
-import android.graphics.Canvas;
-import android.graphics.Color;
-import android.graphics.Paint;
-import android.graphics.Rect;
-import android.graphics.drawable.Icon;
-import android.net.Uri;
-import android.os.Bundle;
-import android.os.UserHandle;
-import android.provider.DeviceConfig;
-import android.service.chooser.ChooserAction;
-import android.service.chooser.ChooserTarget;
-import android.util.HashedStringCache;
-import android.util.Pair;
-import android.util.SparseArray;
-import android.view.View;
-import android.view.WindowManager;
-
-import androidx.annotation.CallSuper;
-import androidx.annotation.NonNull;
-import androidx.annotation.Nullable;
-import androidx.recyclerview.widget.GridLayoutManager;
-import androidx.recyclerview.widget.RecyclerView;
-import androidx.test.espresso.contrib.RecyclerViewActions;
-import androidx.test.espresso.matcher.BoundedDiagnosingMatcher;
-import androidx.test.espresso.matcher.ViewMatchers;
-import androidx.test.platform.app.InstrumentationRegistry;
-import androidx.test.rule.ActivityTestRule;
-
-import com.android.intentresolver.chooser.DisplayResolveInfo;
-import com.android.intentresolver.contentpreview.ImageLoader;
-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 org.hamcrest.Description;
-import org.hamcrest.Matcher;
-import org.hamcrest.Matchers;
-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.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.concurrent.CountDownLatch;
-import java.util.concurrent.atomic.AtomicReference;
-import java.util.function.Consumer;
-import java.util.function.Function;
-
-/**
- * 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).
- */
-@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.
- * --------
- */
-
- 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;
- }
-
- /* --------
- * 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();
-
- cleanOverrideData();
- ChooserActivityOverrideData.getInstance().featureFlagRepository =
- new TestFeatureFlagRepository(mFlags);
- }
-
- /**
- * 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;
- }
-
- /* --------
- * The code in this section is unorthodox and can be simplified/reverted when we no longer need
- * to support the parallel chooser implementations.
- * --------
- */
-
- @Rule
- public final TestRule mRule;
-
- // 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));
- }
- };
-
- @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();
- }
-
- /* --------
- * 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;
-
-
- public UnbundledChooserActivityTest(
- Function<PackageManager, PackageManager> packageManagerOverride,
- Map<BooleanFlag, Boolean> flags) {
- mPackageManagerOverride = packageManagerOverride;
- mFlags = flags;
-
- mRule = RuleChain
- .outerRule(new FeatureFlagRule(flags))
- .around(mActivityRule);
- }
-
- private void setDeviceConfigProperty(
- @NonNull String propertyName,
- @NonNull String value) {
- // TODO: consider running with {@link #runWithShellPermissionIdentity()} to more narrowly
- // request WRITE_DEVICE_CONFIG permissions if we get rid of the broad grant we currently
- // configure in {@link #setup()}.
- // TODO: is it really appropriate that this is always set with makeDefault=true?
- boolean valueWasSet = DeviceConfig.setProperty(
- DeviceConfig.NAMESPACE_SYSTEMUI,
- propertyName,
- value,
- true /* makeDefault */);
- if (!valueWasSet) {
- throw new IllegalStateException(
- "Could not set " + propertyName + " to " + value);
- }
- }
-
- public void cleanOverrideData() {
- ChooserActivityOverrideData.getInstance().reset();
- ChooserActivityOverrideData.getInstance().createPackageManager = mPackageManagerOverride;
-
- setDeviceConfigProperty(
- SystemUiDeviceConfigFlags.APPLY_SHARING_APP_LIMITS_IN_SYSUI,
- Boolean.toString(true));
- }
-
- @Test
- public void customTitle() throws InterruptedException {
- Intent viewIntent = createViewTextIntent();
- List<ResolvedComponentInfo> resolvedComponentInfos = createResolvedComponentsForTest(2);
-
- setupResolverControllers(resolvedComponentInfos);
- final IChooserWrapper activity = (IChooserWrapper) mActivityRule.launchActivity(
- Intent.createChooser(viewIntent, "chooser test"));
-
- waitForIdle();
- assertThat(activity.getAdapter().getCount(), is(2));
- assertThat(activity.getAdapter().getServiceTargetCount(), is(0));
- onView(withId(android.R.id.title)).check(matches(withText("chooser test")));
- }
-
- @Test
- public void customTitleIgnoredForSendIntents() throws InterruptedException {
- Intent sendIntent = createSendTextIntent();
- List<ResolvedComponentInfo> resolvedComponentInfos = createResolvedComponentsForTest(2);
-
- setupResolverControllers(resolvedComponentInfos);
- mActivityRule.launchActivity(Intent.createChooser(sendIntent, "chooser test"));
- waitForIdle();
- onView(withId(android.R.id.title))
- .check(matches(withText(R.string.whichSendApplication)));
- }
-
- @Test
- public void emptyTitle() throws InterruptedException {
- Intent sendIntent = createSendTextIntent();
- List<ResolvedComponentInfo> resolvedComponentInfos = createResolvedComponentsForTest(2);
-
- setupResolverControllers(resolvedComponentInfos);
- mActivityRule.launchActivity(Intent.createChooser(sendIntent, null));
- waitForIdle();
- onView(withId(android.R.id.title))
- .check(matches(withText(R.string.whichSendApplication)));
- }
-
- @Test
- public void emptyPreviewTitleAndThumbnail() throws InterruptedException {
- Intent sendIntent = createSendTextIntentWithPreview(null, null);
- 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(matches(not(isDisplayed())));
- onView(withId(com.android.internal.R.id.content_preview_thumbnail))
- .check(matches(not(isDisplayed())));
- }
-
- @Test
- public void visiblePreviewTitleWithoutThumbnail() throws InterruptedException {
- String previewTitle = "My Content Preview Title";
- Intent sendIntent = createSendTextIntentWithPreview(previewTitle, null);
- 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(matches(isDisplayed()));
- onView(withId(com.android.internal.R.id.content_preview_title))
- .check(matches(withText(previewTitle)));
- onView(withId(com.android.internal.R.id.content_preview_thumbnail))
- .check(matches(not(isDisplayed())));
- }
-
- @Test
- public void visiblePreviewTitleWithInvalidThumbnail() throws InterruptedException {
- String previewTitle = "My Content Preview Title";
- Intent sendIntent = createSendTextIntentWithPreview(previewTitle,
- Uri.parse("tel:(+49)12345789"));
- 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(matches(isDisplayed()));
- onView(withId(com.android.internal.R.id.content_preview_thumbnail))
- .check(matches(not(isDisplayed())));
- }
-
- @Test
- public void visiblePreviewTitleAndThumbnail() throws InterruptedException {
- String previewTitle = "My Content Preview Title";
- Uri uri = Uri.parse(
- "android.resource://com.android.frameworks.coretests/"
- + R.drawable.test320x240);
- Intent sendIntent = createSendTextIntentWithPreview(previewTitle, uri);
- ChooserActivityOverrideData.getInstance().imageLoader =
- createImageLoader(uri, createBitmap());
- 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(matches(isDisplayed()));
- onView(withId(com.android.internal.R.id.content_preview_thumbnail))
- .check(matches(isDisplayed()));
- }
-
- @Test @Ignore
- public void twoOptionsAndUserSelectsOne() throws InterruptedException {
- Intent sendIntent = createSendTextIntent();
- List<ResolvedComponentInfo> resolvedComponentInfos = createResolvedComponentsForTest(2);
-
- setupResolverControllers(resolvedComponentInfos);
-
- final IChooserWrapper activity = (IChooserWrapper)
- mActivityRule.launchActivity(Intent.createChooser(sendIntent, null));
- waitForIdle();
-
- assertThat(activity.getAdapter().getCount(), is(2));
- onView(withId(com.android.internal.R.id.profile_button)).check(doesNotExist());
-
- ResolveInfo[] chosen = new ResolveInfo[1];
- ChooserActivityOverrideData.getInstance().onSafelyStartInternalCallback = targetInfo -> {
- chosen[0] = targetInfo.getResolveInfo();
- return true;
- };
-
- ResolveInfo toChoose = resolvedComponentInfos.get(0).getResolveInfoAt(0);
- onView(withText(toChoose.activityInfo.name))
- .perform(click());
- waitForIdle();
- assertThat(chosen[0], is(toChoose));
- }
-
- @Test @Ignore
- public void fourOptionsStackedIntoOneTarget() throws InterruptedException {
- Intent sendIntent = createSendTextIntent();
-
- // create just enough targets to ensure the a-z list should be shown
- List<ResolvedComponentInfo> resolvedComponentInfos = createResolvedComponentsForTest(1);
-
- // next create 4 targets in a single app that should be stacked into a single target
- String packageName = "xxx.yyy";
- String appName = "aaa";
- ComponentName cn = new ComponentName(packageName, appName);
- Intent intent = new Intent("fakeIntent");
- List<ResolvedComponentInfo> infosToStack = new ArrayList<>();
- for (int i = 0; i < 4; i++) {
- ResolveInfo resolveInfo = ResolverDataProvider.createResolveInfo(i,
- UserHandle.USER_CURRENT, PERSONAL_USER_HANDLE);
- resolveInfo.activityInfo.applicationInfo.name = appName;
- resolveInfo.activityInfo.applicationInfo.packageName = packageName;
- resolveInfo.activityInfo.packageName = packageName;
- resolveInfo.activityInfo.name = "ccc" + i;
- infosToStack.add(new ResolvedComponentInfo(cn, intent, resolveInfo));
- }
- resolvedComponentInfos.addAll(infosToStack);
-
- setupResolverControllers(resolvedComponentInfos);
-
- final IChooserWrapper activity = (IChooserWrapper)
- mActivityRule.launchActivity(Intent.createChooser(sendIntent, null));
- waitForIdle();
-
- // expect 1 unique targets + 1 group + 4 ranked app targets
- assertThat(activity.getAdapter().getCount(), is(6));
-
- ResolveInfo[] chosen = new ResolveInfo[1];
- ChooserActivityOverrideData.getInstance().onSafelyStartInternalCallback = targetInfo -> {
- chosen[0] = targetInfo.getResolveInfo();
- return true;
- };
-
- onView(allOf(withText(appName), hasSibling(withText("")))).perform(click());
- waitForIdle();
-
- // clicking will launch a dialog to choose the activity within the app
- onView(withText(appName)).check(matches(isDisplayed()));
- int i = 0;
- for (ResolvedComponentInfo rci: infosToStack) {
- onView(withText("ccc" + i)).check(matches(isDisplayed()));
- ++i;
- }
- }
-
- @Test @Ignore
- public void updateChooserCountsAndModelAfterUserSelection() throws InterruptedException {
- Intent sendIntent = createSendTextIntent();
- List<ResolvedComponentInfo> resolvedComponentInfos = createResolvedComponentsForTest(2);
-
- setupResolverControllers(resolvedComponentInfos);
-
- final IChooserWrapper activity = (IChooserWrapper)
- mActivityRule.launchActivity(Intent.createChooser(sendIntent, null));
- waitForIdle();
- UsageStatsManager usm = activity.getUsageStatsManager();
- verify(ChooserActivityOverrideData.getInstance().resolverListController, times(1))
- .topK(any(List.class), anyInt());
- assertThat(activity.getIsSelected(), is(false));
- ChooserActivityOverrideData.getInstance().onSafelyStartInternalCallback = targetInfo -> {
- return true;
- };
- ResolveInfo toChoose = resolvedComponentInfos.get(0).getResolveInfoAt(0);
- DisplayResolveInfo testDri =
- activity.createTestDisplayResolveInfo(sendIntent, toChoose, "testLabel", "testInfo",
- sendIntent, /* resolveInfoPresentationGetter */ null);
- onView(withText(toChoose.activityInfo.name))
- .perform(click());
- waitForIdle();
- verify(ChooserActivityOverrideData.getInstance().resolverListController, times(1))
- .updateChooserCounts(Mockito.anyString(), any(UserHandle.class),
- Mockito.anyString());
- verify(ChooserActivityOverrideData.getInstance().resolverListController, times(1))
- .updateModel(testDri);
- assertThat(activity.getIsSelected(), is(true));
- }
-
- @Ignore // b/148158199
- @Test
- public void noResultsFromPackageManager() {
- setupResolverControllers(null);
- Intent sendIntent = createSendTextIntent();
- final ChooserActivity activity =
- mActivityRule.launchActivity(Intent.createChooser(sendIntent, null));
- final IChooserWrapper wrapper = (IChooserWrapper) activity;
-
- waitForIdle();
- assertThat(activity.isFinishing(), is(false));
-
- onView(withId(android.R.id.empty)).check(matches(isDisplayed()));
- onView(withId(com.android.internal.R.id.profile_pager)).check(matches(not(isDisplayed())));
- InstrumentationRegistry.getInstrumentation().runOnMainSync(
- () -> wrapper.getAdapter().handlePackagesChanged()
- );
- // backward compatibility. looks like we finish when data is empty after package change
- assertThat(activity.isFinishing(), is(true));
- }
-
- @Test
- public void autoLaunchSingleResult() throws InterruptedException {
- ResolveInfo[] chosen = new ResolveInfo[1];
- ChooserActivityOverrideData.getInstance().onSafelyStartInternalCallback = targetInfo -> {
- chosen[0] = targetInfo.getResolveInfo();
- return true;
- };
-
- List<ResolvedComponentInfo> resolvedComponentInfos = createResolvedComponentsForTest(1);
- setupResolverControllers(resolvedComponentInfos);
-
- Intent sendIntent = createSendTextIntent();
- final ChooserActivity activity =
- mActivityRule.launchActivity(Intent.createChooser(sendIntent, null));
- waitForIdle();
-
- assertThat(chosen[0], is(resolvedComponentInfos.get(0).getResolveInfoAt(0)));
- assertThat(activity.isFinishing(), is(true));
- }
-
- @Test @Ignore
- public void hasOtherProfileOneOption() {
- List<ResolvedComponentInfo> personalResolvedComponentInfos =
- createResolvedComponentsForTestWithOtherProfile(2, /* userId */ 10);
- List<ResolvedComponentInfo> workResolvedComponentInfos = createResolvedComponentsForTest(4);
- setupResolverControllers(personalResolvedComponentInfos, workResolvedComponentInfos);
- markWorkProfileUserAvailable();
-
- ResolveInfo toChoose = personalResolvedComponentInfos.get(1).getResolveInfoAt(0);
- Intent sendIntent = createSendTextIntent();
- final IChooserWrapper activity = (IChooserWrapper)
- mActivityRule.launchActivity(Intent.createChooser(sendIntent, null));
- waitForIdle();
-
- // The other entry is filtered to the other profile slot
- assertThat(activity.getAdapter().getCount(), is(1));
-
- ResolveInfo[] chosen = new ResolveInfo[1];
- ChooserActivityOverrideData.getInstance().onSafelyStartInternalCallback = targetInfo -> {
- chosen[0] = targetInfo.getResolveInfo();
- return true;
- };
-
- // Make a stable copy of the components as the original list may be modified
- List<ResolvedComponentInfo> stableCopy =
- createResolvedComponentsForTestWithOtherProfile(2, /* userId= */ 10);
- waitForIdle();
-
- onView(first(withText(stableCopy.get(1).getResolveInfoAt(0).activityInfo.name)))
- .perform(click());
- waitForIdle();
- assertThat(chosen[0], is(toChoose));
- }
-
- @Test @Ignore
- public void hasOtherProfileTwoOptionsAndUserSelectsOne() throws Exception {
- Intent sendIntent = createSendTextIntent();
- List<ResolvedComponentInfo> resolvedComponentInfos =
- createResolvedComponentsForTestWithOtherProfile(3);
- ResolveInfo toChoose = resolvedComponentInfos.get(1).getResolveInfoAt(0);
-
- setupResolverControllers(resolvedComponentInfos);
- when(ChooserActivityOverrideData.getInstance().resolverListController.getLastChosen())
- .thenReturn(resolvedComponentInfos.get(0).getResolveInfoAt(0));
-
- final IChooserWrapper activity = (IChooserWrapper)
- mActivityRule.launchActivity(Intent.createChooser(sendIntent, null));
- waitForIdle();
-
- // The other entry is filtered to the other profile slot
- assertThat(activity.getAdapter().getCount(), is(2));
-
- ResolveInfo[] chosen = new ResolveInfo[1];
- ChooserActivityOverrideData.getInstance().onSafelyStartInternalCallback = targetInfo -> {
- chosen[0] = targetInfo.getResolveInfo();
- return true;
- };
-
- // Make a stable copy of the components as the original list may be modified
- List<ResolvedComponentInfo> stableCopy =
- createResolvedComponentsForTestWithOtherProfile(3);
- onView(withText(stableCopy.get(1).getResolveInfoAt(0).activityInfo.name))
- .perform(click());
- waitForIdle();
- assertThat(chosen[0], is(toChoose));
- }
-
- @Test @Ignore
- public void hasLastChosenActivityAndOtherProfile() throws Exception {
- Intent sendIntent = createSendTextIntent();
- List<ResolvedComponentInfo> resolvedComponentInfos =
- createResolvedComponentsForTestWithOtherProfile(3);
- ResolveInfo toChoose = resolvedComponentInfos.get(1).getResolveInfoAt(0);
-
- setupResolverControllers(resolvedComponentInfos);
-
- final IChooserWrapper activity = (IChooserWrapper)
- mActivityRule.launchActivity(Intent.createChooser(sendIntent, null));
- waitForIdle();
-
- // The other entry is filtered to the last used slot
- assertThat(activity.getAdapter().getCount(), is(2));
-
- ResolveInfo[] chosen = new ResolveInfo[1];
- ChooserActivityOverrideData.getInstance().onSafelyStartInternalCallback = targetInfo -> {
- chosen[0] = targetInfo.getResolveInfo();
- return true;
- };
-
- // Make a stable copy of the components as the original list may be modified
- List<ResolvedComponentInfo> stableCopy =
- createResolvedComponentsForTestWithOtherProfile(3);
- onView(withText(stableCopy.get(1).getResolveInfoAt(0).activityInfo.name))
- .perform(click());
- waitForIdle();
- assertThat(chosen[0], is(toChoose));
- }
-
- @Test
- @Ignore("b/285309527")
- public void testFilePlusTextSharing_ExcludeText() {
- Uri uri = createTestContentProviderUri(null, "image/png");
- Intent sendIntent = createSendImageIntent(uri);
- ChooserActivityOverrideData.getInstance().imageLoader =
- createImageLoader(uri, createBitmap());
- sendIntent.putExtra(Intent.EXTRA_TEXT, "https://google.com/search?q=google");
-
- List<ResolvedComponentInfo> resolvedComponentInfos = Arrays.asList(
- ResolverDataProvider.createResolvedComponentInfo(
- new ComponentName("org.imageviewer", "ImageTarget"),
- sendIntent, PERSONAL_USER_HANDLE),
- ResolverDataProvider.createResolvedComponentInfo(
- new ComponentName("org.textviewer", "UriTarget"),
- new Intent("VIEW_TEXT"), PERSONAL_USER_HANDLE)
- );
-
- setupResolverControllers(resolvedComponentInfos);
-
- mActivityRule.launchActivity(Intent.createChooser(sendIntent, null));
- waitForIdle();
-
- onView(withId(R.id.include_text_action))
- .check(matches(isDisplayed()))
- .perform(click());
- waitForIdle();
-
- onView(withId(R.id.content_preview_text)).check(matches(withText("File only")));
-
- AtomicReference<Intent> launchedIntentRef = new AtomicReference<>();
- ChooserActivityOverrideData.getInstance().onSafelyStartInternalCallback = targetInfo -> {
- launchedIntentRef.set(targetInfo.getTargetIntent());
- return true;
- };
-
- onView(withText(resolvedComponentInfos.get(0).getResolveInfoAt(0).activityInfo.name))
- .perform(click());
- waitForIdle();
- assertThat(launchedIntentRef.get().hasExtra(Intent.EXTRA_TEXT)).isFalse();
- }
-
- @Test
- @Ignore("b/285309527")
- public void testFilePlusTextSharing_RemoveAndAddBackText() {
- Uri uri = createTestContentProviderUri("application/pdf", "image/png");
- Intent sendIntent = createSendImageIntent(uri);
- ChooserActivityOverrideData.getInstance().imageLoader =
- createImageLoader(uri, createBitmap());
- final String text = "https://google.com/search?q=google";
- sendIntent.putExtra(Intent.EXTRA_TEXT, text);
-
- List<ResolvedComponentInfo> resolvedComponentInfos = Arrays.asList(
- ResolverDataProvider.createResolvedComponentInfo(
- new ComponentName("org.imageviewer", "ImageTarget"),
- sendIntent, PERSONAL_USER_HANDLE),
- ResolverDataProvider.createResolvedComponentInfo(
- new ComponentName("org.textviewer", "UriTarget"),
- new Intent("VIEW_TEXT"), PERSONAL_USER_HANDLE)
- );
-
- setupResolverControllers(resolvedComponentInfos);
-
- mActivityRule.launchActivity(Intent.createChooser(sendIntent, null));
- waitForIdle();
-
- onView(withId(R.id.include_text_action))
- .check(matches(isDisplayed()))
- .perform(click());
- waitForIdle();
- onView(withId(R.id.content_preview_text)).check(matches(withText("File only")));
-
- onView(withId(R.id.include_text_action))
- .perform(click());
- waitForIdle();
-
- onView(withId(R.id.content_preview_text)).check(matches(withText(text)));
-
- AtomicReference<Intent> launchedIntentRef = new AtomicReference<>();
- ChooserActivityOverrideData.getInstance().onSafelyStartInternalCallback = targetInfo -> {
- launchedIntentRef.set(targetInfo.getTargetIntent());
- return true;
- };
-
- onView(withText(resolvedComponentInfos.get(0).getResolveInfoAt(0).activityInfo.name))
- .perform(click());
- waitForIdle();
- assertThat(launchedIntentRef.get().getStringExtra(Intent.EXTRA_TEXT)).isEqualTo(text);
- }
-
- @Test
- @Ignore("b/285309527")
- public void testFilePlusTextSharing_TextExclusionDoesNotAffectAlternativeIntent() {
- Uri uri = createTestContentProviderUri("image/png", null);
- Intent sendIntent = createSendImageIntent(uri);
- ChooserActivityOverrideData.getInstance().imageLoader =
- createImageLoader(uri, createBitmap());
- sendIntent.putExtra(Intent.EXTRA_TEXT, "https://google.com/search?q=google");
-
- Intent alternativeIntent = createSendTextIntent();
- final String text = "alternative intent";
- alternativeIntent.putExtra(Intent.EXTRA_TEXT, text);
-
- List<ResolvedComponentInfo> resolvedComponentInfos = Arrays.asList(
- ResolverDataProvider.createResolvedComponentInfo(
- new ComponentName("org.imageviewer", "ImageTarget"),
- sendIntent, PERSONAL_USER_HANDLE),
- ResolverDataProvider.createResolvedComponentInfo(
- new ComponentName("org.textviewer", "UriTarget"),
- alternativeIntent, PERSONAL_USER_HANDLE)
- );
-
- setupResolverControllers(resolvedComponentInfos);
-
- mActivityRule.launchActivity(Intent.createChooser(sendIntent, null));
- waitForIdle();
-
- onView(withId(R.id.include_text_action))
- .check(matches(isDisplayed()))
- .perform(click());
- waitForIdle();
-
- AtomicReference<Intent> launchedIntentRef = new AtomicReference<>();
- ChooserActivityOverrideData.getInstance().onSafelyStartInternalCallback = targetInfo -> {
- launchedIntentRef.set(targetInfo.getTargetIntent());
- return true;
- };
-
- onView(withText(resolvedComponentInfos.get(1).getResolveInfoAt(0).activityInfo.name))
- .perform(click());
- waitForIdle();
- assertThat(launchedIntentRef.get().getStringExtra(Intent.EXTRA_TEXT)).isEqualTo(text);
- }
-
- @Test
- @Ignore("b/285309527")
- 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(
- ResolverDataProvider.createResolvedComponentInfo(
- new ComponentName("org.imageviewer", "ImageTarget"),
- sendIntent, PERSONAL_USER_HANDLE),
- ResolverDataProvider.createResolvedComponentInfo(
- new ComponentName("org.textviewer", "UriTarget"),
- new Intent("VIEW_TEXT"), PERSONAL_USER_HANDLE)
- );
-
- setupResolverControllers(resolvedComponentInfos);
-
- mActivityRule.launchActivity(Intent.createChooser(sendIntent, null));
- waitForIdle();
-
- onView(withId(R.id.include_text_action))
- .check(matches(isDisplayed()))
- .perform(click());
- waitForIdle();
-
- onView(withId(R.id.image_view))
- .check(matches(withEffectiveVisibility(ViewMatchers.Visibility.GONE)));
- onView(withId(R.id.content_preview_text))
- .check(matches(allOf(isDisplayed(), withText("Image only"))));
- }
-
- @Test
- public void copyTextToClipboard() throws Exception {
- Intent sendIntent = createSendTextIntent();
- List<ResolvedComponentInfo> resolvedComponentInfos = createResolvedComponentsForTest(2);
-
- setupResolverControllers(resolvedComponentInfos);
-
- final ChooserActivity activity =
- mActivityRule.launchActivity(Intent.createChooser(sendIntent, null));
- waitForIdle();
-
- onView(withId(R.id.copy)).check(matches(isDisplayed()));
- onView(withId(R.id.copy)).perform(click());
- ClipboardManager clipboard = (ClipboardManager) activity.getSystemService(
- Context.CLIPBOARD_SERVICE);
- ClipData clipData = clipboard.getPrimaryClip();
- assertThat("testing intent sending", is(clipData.getItemAt(0).getText()));
-
- ClipDescription clipDescription = clipData.getDescription();
- assertThat("text/plain", is(clipDescription.getMimeType(0)));
-
- assertEquals(mActivityRule.getActivityResult().getResultCode(), RESULT_OK);
- }
-
- @Test
- public void copyTextToClipboardLogging() {
- Intent sendIntent = createSendTextIntent();
- List<ResolvedComponentInfo> resolvedComponentInfos = createResolvedComponentsForTest(2);
-
- setupResolverControllers(resolvedComponentInfos);
-
- final IChooserWrapper activity = (IChooserWrapper)
- mActivityRule.launchActivity(Intent.createChooser(sendIntent, null));
- waitForIdle();
-
- onView(withId(R.id.copy)).check(matches(isDisplayed()));
- onView(withId(R.id.copy)).perform(click());
-
- ChooserActivityLogger logger = activity.getChooserActivityLogger();
- verify(logger, times(1)).logActionSelected(eq(ChooserActivityLogger.SELECTION_TYPE_COPY));
- }
-
- @Test
- @Ignore
- public void testNearbyShareLogging() throws Exception {
- Intent sendIntent = createSendTextIntent();
- List<ResolvedComponentInfo> resolvedComponentInfos = createResolvedComponentsForTest(2);
-
- setupResolverControllers(resolvedComponentInfos);
-
- final IChooserWrapper activity = (IChooserWrapper)
- mActivityRule.launchActivity(Intent.createChooser(sendIntent, null));
- waitForIdle();
-
- onView(withId(com.android.internal.R.id.chooser_nearby_button))
- .check(matches(isDisplayed()));
- onView(withId(com.android.internal.R.id.chooser_nearby_button)).perform(click());
-
- // 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());
-
- List<ResolvedComponentInfo> resolvedComponentInfos = createResolvedComponentsForTest(2);
-
- setupResolverControllers(resolvedComponentInfos);
-
- final IChooserWrapper activity = (IChooserWrapper)
- mActivityRule.launchActivity(Intent.createChooser(sendIntent, null));
- waitForIdle();
-
- onView(withId(com.android.internal.R.id.chooser_edit_button)).check(matches(isDisplayed()));
- onView(withId(com.android.internal.R.id.chooser_edit_button)).perform(click());
-
- // TODO(b/211669337): Determine the expected SHARESHEET_DIRECT_LOAD_COMPLETE events.
- }
-
-
- @Test
- public void oneVisibleImagePreview() {
- Uri uri = createTestContentProviderUri("image/png", null);
-
- ArrayList<Uri> uris = new ArrayList<>();
- uris.add(uri);
-
- Intent sendIntent = createSendUriIntentWithPreview(uris);
- ChooserActivityOverrideData.getInstance().imageLoader =
- createImageLoader(uri, createWideBitmap());
-
- List<ResolvedComponentInfo> resolvedComponentInfos = createResolvedComponentsForTest(2);
-
- setupResolverControllers(resolvedComponentInfos);
- mActivityRule.launchActivity(Intent.createChooser(sendIntent, null));
- waitForIdle();
- onView(withId(R.id.scrollable_image_preview))
- .check((view, exception) -> {
- if (exception != null) {
- throw exception;
- }
- RecyclerView recyclerView = (RecyclerView) view;
- assertThat(recyclerView.getAdapter().getItemCount(), is(1));
- assertThat(recyclerView.getChildCount(), is(1));
- View imageView = recyclerView.getChildAt(0);
- Rect rect = new Rect();
- boolean isPartiallyVisible = imageView.getGlobalVisibleRect(rect);
- assertThat(
- "image preview view is not fully visible",
- isPartiallyVisible
- && rect.width() == imageView.getWidth()
- && rect.height() == imageView.getHeight());
- });
- }
-
- @Test
- public void allThumbnailsFailedToLoad_hidePreview() {
- Uri uri = createTestContentProviderUri("image/jpg", null);
-
- ArrayList<Uri> uris = new ArrayList<>();
- uris.add(uri);
- uris.add(uri);
-
- Intent sendIntent = createSendUriIntentWithPreview(uris);
- ChooserActivityOverrideData.getInstance().imageLoader =
- new TestPreviewImageLoader(Collections.emptyMap());
-
- List<ResolvedComponentInfo> resolvedComponentInfos = createResolvedComponentsForTest(2);
-
- setupResolverControllers(resolvedComponentInfos);
- mActivityRule.launchActivity(Intent.createChooser(sendIntent, null));
- waitForIdle();
- onView(withId(R.id.scrollable_image_preview))
- .check(matches(withEffectiveVisibility(ViewMatchers.Visibility.GONE)));
- }
-
- @Test
- public void testManyVisibleImagePreview_ScrollableImagePreview() {
- Uri uri = createTestContentProviderUri("image/png", null);
-
- ArrayList<Uri> uris = new ArrayList<>();
- uris.add(uri);
- uris.add(uri);
- uris.add(uri);
- uris.add(uri);
- uris.add(uri);
- uris.add(uri);
- uris.add(uri);
- uris.add(uri);
- uris.add(uri);
- uris.add(uri);
-
- Intent sendIntent = createSendUriIntentWithPreview(uris);
- ChooserActivityOverrideData.getInstance().imageLoader =
- createImageLoader(uri, createBitmap());
-
- List<ResolvedComponentInfo> resolvedComponentInfos = createResolvedComponentsForTest(2);
-
- setupResolverControllers(resolvedComponentInfos);
- mActivityRule.launchActivity(Intent.createChooser(sendIntent, null));
- waitForIdle();
- onView(withId(R.id.scrollable_image_preview))
- .perform(RecyclerViewActions.scrollToLastPosition())
- .check((view, exception) -> {
- if (exception != null) {
- throw exception;
- }
- RecyclerView recyclerView = (RecyclerView) view;
- assertThat(recyclerView.getAdapter().getItemCount(), is(uris.size()));
- });
- }
-
- @Test
- public void testImageAndTextPreview() {
- final Uri uri = createTestContentProviderUri("image/png", null);
- final String sharedText = "text-" + System.currentTimeMillis();
-
- ArrayList<Uri> uris = new ArrayList<>();
- uris.add(uri);
-
- Intent sendIntent = createSendUriIntentWithPreview(uris);
- sendIntent.putExtra(Intent.EXTRA_TEXT, sharedText);
- ChooserActivityOverrideData.getInstance().imageLoader =
- createImageLoader(uri, createBitmap());
-
- List<ResolvedComponentInfo> resolvedComponentInfos = createResolvedComponentsForTest(2);
-
- setupResolverControllers(resolvedComponentInfos);
- mActivityRule.launchActivity(Intent.createChooser(sendIntent, null));
- waitForIdle();
- onView(withText(sharedText))
- .check(matches(isDisplayed()));
- }
-
- @Test
- public void testTextPreviewWhenTextIsSharedWithMultipleImages() {
- final Uri uri = createTestContentProviderUri("image/png", null);
- final String sharedText = "text-" + System.currentTimeMillis();
-
- ArrayList<Uri> uris = new ArrayList<>();
- uris.add(uri);
- uris.add(uri);
-
- Intent sendIntent = createSendUriIntentWithPreview(uris);
- sendIntent.putExtra(Intent.EXTRA_TEXT, sharedText);
- ChooserActivityOverrideData.getInstance().imageLoader =
- createImageLoader(uri, createBitmap());
-
- List<ResolvedComponentInfo> resolvedComponentInfos = createResolvedComponentsForTest(2);
-
- when(
- ChooserActivityOverrideData
- .getInstance()
- .resolverListController
- .getResolversForIntentAsUser(
- Mockito.anyBoolean(),
- Mockito.anyBoolean(),
- Mockito.anyBoolean(),
- Mockito.isA(List.class),
- Mockito.any(UserHandle.class)))
- .thenReturn(resolvedComponentInfos);
- mActivityRule.launchActivity(Intent.createChooser(sendIntent, null));
- waitForIdle();
- onView(withText(sharedText)).check(matches(isDisplayed()));
- }
-
- @Test
- public void testOnCreateLogging() {
- Intent sendIntent = createSendTextIntent();
- sendIntent.setType(TEST_MIME_TYPE);
-
- final IChooserWrapper activity = (IChooserWrapper)
- mActivityRule.launchActivity(Intent.createChooser(sendIntent, "logger test"));
- ChooserActivityLogger logger = activity.getChooserActivityLogger();
- waitForIdle();
-
- verify(logger).logChooserActivityShown(eq(false), eq(TEST_MIME_TYPE), anyLong());
- }
-
- @Test
- public void testOnCreateLoggingFromWorkProfile() {
- Intent sendIntent = createSendTextIntent();
- sendIntent.setType(TEST_MIME_TYPE);
- ChooserActivityOverrideData.getInstance().alternateProfileSetting =
- MetricsEvent.MANAGED_PROFILE;
-
- final IChooserWrapper activity = (IChooserWrapper)
- mActivityRule.launchActivity(Intent.createChooser(sendIntent, "logger test"));
- ChooserActivityLogger logger = activity.getChooserActivityLogger();
- waitForIdle();
-
- verify(logger).logChooserActivityShown(eq(true), eq(TEST_MIME_TYPE), anyLong());
- }
-
- @Test
- public void testEmptyPreviewLogging() {
- Intent sendIntent = createSendTextIntentWithPreview(null, null);
-
- final IChooserWrapper activity = (IChooserWrapper)
- mActivityRule.launchActivity(
- Intent.createChooser(sendIntent, "empty preview logger test"));
- ChooserActivityLogger logger = activity.getChooserActivityLogger();
- waitForIdle();
-
- verify(logger).logChooserActivityShown(eq(false), eq(null), anyLong());
- }
-
- @Test
- public void testTitlePreviewLogging() {
- Intent sendIntent = createSendTextIntentWithPreview("TestTitle", null);
-
- List<ResolvedComponentInfo> resolvedComponentInfos = createResolvedComponentsForTest(2);
-
- setupResolverControllers(resolvedComponentInfos);
-
- final IChooserWrapper activity = (IChooserWrapper)
- mActivityRule.launchActivity(Intent.createChooser(sendIntent, null));
- waitForIdle();
-
- // Second invocation is from onCreate
- ChooserActivityLogger logger = activity.getChooserActivityLogger();
- Mockito.verify(logger, times(1)).logActionShareWithPreview(eq(CONTENT_PREVIEW_TEXT));
- }
-
- @Test
- public void testImagePreviewLogging() {
- Uri uri = createTestContentProviderUri("image/png", null);
-
- ArrayList<Uri> uris = new ArrayList<>();
- uris.add(uri);
-
- Intent sendIntent = createSendUriIntentWithPreview(uris);
- ChooserActivityOverrideData.getInstance().imageLoader =
- createImageLoader(uri, createBitmap());
-
- List<ResolvedComponentInfo> resolvedComponentInfos = createResolvedComponentsForTest(2);
-
- setupResolverControllers(resolvedComponentInfos);
-
- final IChooserWrapper activity = (IChooserWrapper)
- mActivityRule.launchActivity(Intent.createChooser(sendIntent, null));
- waitForIdle();
- ChooserActivityLogger logger = activity.getChooserActivityLogger();
- Mockito.verify(logger, times(1)).logActionShareWithPreview(eq(CONTENT_PREVIEW_IMAGE));
- }
-
- @Test
- public void oneVisibleFilePreview() throws InterruptedException {
- Uri uri = Uri.parse("content://com.android.frameworks.coretests/app.pdf");
-
- ArrayList<Uri> uris = new ArrayList<>();
- uris.add(uri);
-
- Intent sendIntent = createSendUriIntentWithPreview(uris);
-
- List<ResolvedComponentInfo> resolvedComponentInfos = createResolvedComponentsForTest(2);
-
- setupResolverControllers(resolvedComponentInfos);
- mActivityRule.launchActivity(Intent.createChooser(sendIntent, null));
- waitForIdle();
- onView(withId(R.id.content_preview_filename)).check(matches(isDisplayed()));
- onView(withId(R.id.content_preview_filename)).check(matches(withText("app.pdf")));
- onView(withId(R.id.content_preview_file_icon)).check(matches(isDisplayed()));
- }
-
-
- @Test
- public void moreThanOneVisibleFilePreview() throws InterruptedException {
- Uri uri = Uri.parse("content://com.android.frameworks.coretests/app.pdf");
-
- ArrayList<Uri> uris = new ArrayList<>();
- uris.add(uri);
- uris.add(uri);
- uris.add(uri);
-
- Intent sendIntent = createSendUriIntentWithPreview(uris);
-
- List<ResolvedComponentInfo> resolvedComponentInfos = createResolvedComponentsForTest(2);
-
- setupResolverControllers(resolvedComponentInfos);
- mActivityRule.launchActivity(Intent.createChooser(sendIntent, null));
- waitForIdle();
- onView(withId(R.id.content_preview_filename)).check(matches(isDisplayed()));
- onView(withId(R.id.content_preview_filename)).check(matches(withText("app.pdf")));
- onView(withId(R.id.content_preview_more_files)).check(matches(isDisplayed()));
- onView(withId(R.id.content_preview_more_files)).check(matches(withText("+ 2 more files")));
- onView(withId(R.id.content_preview_file_icon)).check(matches(isDisplayed()));
- }
-
- @Test
- public void contentProviderThrowSecurityException() throws InterruptedException {
- Uri uri = Uri.parse("content://com.android.frameworks.coretests/app.pdf");
-
- ArrayList<Uri> uris = new ArrayList<>();
- uris.add(uri);
-
- Intent sendIntent = createSendUriIntentWithPreview(uris);
-
- List<ResolvedComponentInfo> resolvedComponentInfos = createResolvedComponentsForTest(2);
- setupResolverControllers(resolvedComponentInfos);
-
- ChooserActivityOverrideData.getInstance().resolverForceException = true;
-
- mActivityRule.launchActivity(Intent.createChooser(sendIntent, null));
- waitForIdle();
- onView(withId(R.id.content_preview_filename)).check(matches(isDisplayed()));
- onView(withId(R.id.content_preview_filename)).check(matches(withText("app.pdf")));
- onView(withId(R.id.content_preview_file_icon)).check(matches(isDisplayed()));
- }
-
- @Test
- public void contentProviderReturnsNoColumns() throws InterruptedException {
- Uri uri = Uri.parse("content://com.android.frameworks.coretests/app.pdf");
-
- ArrayList<Uri> uris = new ArrayList<>();
- uris.add(uri);
- uris.add(uri);
-
- Intent sendIntent = createSendUriIntentWithPreview(uris);
-
- List<ResolvedComponentInfo> resolvedComponentInfos = createResolvedComponentsForTest(2);
- setupResolverControllers(resolvedComponentInfos);
-
- Cursor cursor = mock(Cursor.class);
- when(cursor.getCount()).thenReturn(1);
- Mockito.doNothing().when(cursor).close();
- when(cursor.moveToFirst()).thenReturn(true);
- when(cursor.getColumnIndex(Mockito.anyString())).thenReturn(-1);
-
- ChooserActivityOverrideData.getInstance().resolverCursor = cursor;
-
- mActivityRule.launchActivity(Intent.createChooser(sendIntent, null));
- waitForIdle();
- onView(withId(R.id.content_preview_filename)).check(matches(isDisplayed()));
- onView(withId(R.id.content_preview_filename)).check(matches(withText("app.pdf")));
- onView(withId(R.id.content_preview_more_files)).check(matches(isDisplayed()));
- onView(withId(R.id.content_preview_more_files)).check(matches(withText("+ 1 more file")));
- onView(withId(R.id.content_preview_file_icon)).check(matches(isDisplayed()));
- }
-
- @Test
- public void testGetBaseScore() {
- final float testBaseScore = 0.89f;
-
- Intent sendIntent = createSendTextIntent();
- List<ResolvedComponentInfo> resolvedComponentInfos = createResolvedComponentsForTest(2);
-
- setupResolverControllers(resolvedComponentInfos);
-
- when(
- ChooserActivityOverrideData
- .getInstance()
- .resolverListController
- .getScore(Mockito.isA(DisplayResolveInfo.class)))
- .thenReturn(testBaseScore);
-
- final IChooserWrapper activity = (IChooserWrapper)
- mActivityRule.launchActivity(Intent.createChooser(sendIntent, null));
- waitForIdle();
-
- final DisplayResolveInfo testDri =
- activity.createTestDisplayResolveInfo(
- sendIntent,
- ResolverDataProvider.createResolveInfo(3, 0, PERSONAL_USER_HANDLE),
- "testLabel",
- "testInfo",
- sendIntent,
- /* resolveInfoPresentationGetter */ null);
- final ChooserListAdapter adapter = activity.getAdapter();
-
- assertThat(adapter.getBaseScore(null, 0), is(CALLER_TARGET_SCORE_BOOST));
- assertThat(adapter.getBaseScore(testDri, TARGET_TYPE_DEFAULT), is(testBaseScore));
- assertThat(adapter.getBaseScore(testDri, TARGET_TYPE_CHOOSER_TARGET), is(testBaseScore));
- assertThat(adapter.getBaseScore(testDri, TARGET_TYPE_SHORTCUTS_FROM_PREDICTION_SERVICE),
- is(testBaseScore * SHORTCUT_TARGET_SCORE_BOOST));
- assertThat(adapter.getBaseScore(testDri, TARGET_TYPE_SHORTCUTS_FROM_SHORTCUT_MANAGER),
- is(testBaseScore * SHORTCUT_TARGET_SCORE_BOOST));
- }
-
- // This test is too long and too slow and should not be taken as an example for future tests.
- @Test
- public void testDirectTargetSelectionLogging() {
- Intent sendIntent = createSendTextIntent();
- // We need app targets for direct targets to get displayed
- List<ResolvedComponentInfo> resolvedComponentInfos = createResolvedComponentsForTest(2);
- setupResolverControllers(resolvedComponentInfos);
-
- // create test shortcut loader factory, remember loaders and their callbacks
- SparseArray<Pair<ShortcutLoader, Consumer<ShortcutLoader.Result>>> shortcutLoaders =
- createShortcutLoaderFactory();
-
- // Start activity
- final IChooserWrapper activity = (IChooserWrapper)
- mActivityRule.launchActivity(Intent.createChooser(sendIntent, null));
- waitForIdle();
-
- // verify that ShortcutLoader was queried
- ArgumentCaptor<DisplayResolveInfo[]> appTargets =
- ArgumentCaptor.forClass(DisplayResolveInfo[].class);
- verify(shortcutLoaders.get(0).first, times(1)).updateAppTargets(appTargets.capture());
-
- // send shortcuts
- assertThat(
- "Wrong number of app targets",
- appTargets.getValue().length,
- is(resolvedComponentInfos.size()));
- List<ChooserTarget> serviceTargets = createDirectShareTargets(1, "");
- ShortcutLoader.Result result = new ShortcutLoader.Result(
- true,
- appTargets.getValue(),
- new ShortcutLoader.ShortcutResultInfo[] {
- new ShortcutLoader.ShortcutResultInfo(
- appTargets.getValue()[0],
- serviceTargets
- )
- },
- new HashMap<>(),
- new HashMap<>()
- );
- activity.getMainExecutor().execute(() -> shortcutLoaders.get(0).second.accept(result));
- waitForIdle();
-
- final ChooserListAdapter activeAdapter = activity.getAdapter();
- assertThat(
- "Chooser should have 3 targets (2 apps, 1 direct)",
- activeAdapter.getCount(),
- is(3));
- assertThat(
- "Chooser should have exactly one selectable direct target",
- activeAdapter.getSelectableServiceTargetCount(),
- is(1));
- assertThat(
- "The resolver info must match the resolver info used to create the target",
- activeAdapter.getItem(0).getResolveInfo(),
- is(resolvedComponentInfos.get(0).getResolveInfoAt(0)));
-
- // Click on the direct target
- String name = serviceTargets.get(0).getTitle().toString();
- onView(withText(name))
- .perform(click());
- waitForIdle();
-
- ArgumentCaptor<HashedStringCache.HashResult> hashCaptor =
- ArgumentCaptor.forClass(HashedStringCache.HashResult.class);
- verify(activity.getChooserActivityLogger(), times(1)).logShareTargetSelected(
- eq(ChooserActivityLogger.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)));
- }
-
- // This test is too long and too slow and should not be taken as an example for future tests.
- @Test
- public void testDirectTargetLoggingWithRankedAppTarget() {
- Intent sendIntent = createSendTextIntent();
- // We need app targets for direct targets to get displayed
- List<ResolvedComponentInfo> resolvedComponentInfos = createResolvedComponentsForTest(2);
- setupResolverControllers(resolvedComponentInfos);
-
- // create test shortcut loader factory, remember loaders and their callbacks
- SparseArray<Pair<ShortcutLoader, Consumer<ShortcutLoader.Result>>> shortcutLoaders =
- createShortcutLoaderFactory();
-
- // Start activity
- final IChooserWrapper activity = (IChooserWrapper)
- mActivityRule.launchActivity(Intent.createChooser(sendIntent, null));
- waitForIdle();
-
- // verify that ShortcutLoader was queried
- ArgumentCaptor<DisplayResolveInfo[]> appTargets =
- ArgumentCaptor.forClass(DisplayResolveInfo[].class);
- verify(shortcutLoaders.get(0).first, times(1)).updateAppTargets(appTargets.capture());
-
- // send shortcuts
- assertThat(
- "Wrong number of app targets",
- appTargets.getValue().length,
- is(resolvedComponentInfos.size()));
- List<ChooserTarget> serviceTargets = createDirectShareTargets(
- 1,
- resolvedComponentInfos.get(0).getResolveInfoAt(0).activityInfo.packageName);
- ShortcutLoader.Result result = new ShortcutLoader.Result(
- true,
- appTargets.getValue(),
- new ShortcutLoader.ShortcutResultInfo[] {
- new ShortcutLoader.ShortcutResultInfo(
- appTargets.getValue()[0],
- serviceTargets
- )
- },
- new HashMap<>(),
- new HashMap<>()
- );
- activity.getMainExecutor().execute(() -> shortcutLoaders.get(0).second.accept(result));
- waitForIdle();
-
- final ChooserListAdapter activeAdapter = activity.getAdapter();
- assertThat(
- "Chooser should have 3 targets (2 apps, 1 direct)",
- activeAdapter.getCount(),
- is(3));
- assertThat(
- "Chooser should have exactly one selectable direct target",
- activeAdapter.getSelectableServiceTargetCount(),
- is(1));
- assertThat(
- "The resolver info must match the resolver info used to create the target",
- activeAdapter.getItem(0).getResolveInfo(),
- is(resolvedComponentInfos.get(0).getResolveInfoAt(0)));
-
- // Click on the direct target
- String name = serviceTargets.get(0).getTitle().toString();
- onView(withText(name))
- .perform(click());
- waitForIdle();
-
- verify(activity.getChooserActivityLogger(), times(1)).logShareTargetSelected(
- eq(ChooserActivityLogger.SELECTION_TYPE_SERVICE),
- /* packageName= */ any(),
- /* positionPicked= */ anyInt(),
- /* directTargetAlsoRanked= */ eq(0),
- /* numCallerProvided= */ anyInt(),
- /* directTargetHashed= */ any(),
- /* isPinned= */ anyBoolean(),
- /* successfullySelected= */ anyBoolean(),
- /* selectionCost= */ anyLong());
- }
-
- @Test
- public void testShortcutTargetWithApplyAppLimits() {
- // Set up resources
- ChooserActivityOverrideData.getInstance().resources = Mockito.spy(
- InstrumentationRegistry.getInstrumentation().getContext().getResources());
- when(
- ChooserActivityOverrideData
- .getInstance()
- .resources
- .getInteger(R.integer.config_maxShortcutTargetsPerApp))
- .thenReturn(1);
- Intent sendIntent = createSendTextIntent();
- // We need app targets for direct targets to get displayed
- List<ResolvedComponentInfo> resolvedComponentInfos = createResolvedComponentsForTest(2);
- setupResolverControllers(resolvedComponentInfos);
-
- // create test shortcut loader factory, remember loaders and their callbacks
- SparseArray<Pair<ShortcutLoader, Consumer<ShortcutLoader.Result>>> shortcutLoaders =
- createShortcutLoaderFactory();
-
- // Start activity
- final IChooserWrapper activity = (IChooserWrapper) mActivityRule
- .launchActivity(Intent.createChooser(sendIntent, null));
- waitForIdle();
-
- // verify that ShortcutLoader was queried
- ArgumentCaptor<DisplayResolveInfo[]> appTargets =
- ArgumentCaptor.forClass(DisplayResolveInfo[].class);
- verify(shortcutLoaders.get(0).first, times(1)).updateAppTargets(appTargets.capture());
-
- // send shortcuts
- assertThat(
- "Wrong number of app targets",
- appTargets.getValue().length,
- is(resolvedComponentInfos.size()));
- List<ChooserTarget> serviceTargets = createDirectShareTargets(
- 2,
- resolvedComponentInfos.get(0).getResolveInfoAt(0).activityInfo.packageName);
- ShortcutLoader.Result result = new ShortcutLoader.Result(
- true,
- appTargets.getValue(),
- new ShortcutLoader.ShortcutResultInfo[] {
- new ShortcutLoader.ShortcutResultInfo(
- appTargets.getValue()[0],
- serviceTargets
- )
- },
- new HashMap<>(),
- new HashMap<>()
- );
- activity.getMainExecutor().execute(() -> shortcutLoaders.get(0).second.accept(result));
- waitForIdle();
-
- final ChooserListAdapter activeAdapter = activity.getAdapter();
- assertThat(
- "Chooser should have 3 targets (2 apps, 1 direct)",
- activeAdapter.getCount(),
- is(3));
- assertThat(
- "Chooser should have exactly one selectable direct target",
- activeAdapter.getSelectableServiceTargetCount(),
- is(1));
- assertThat(
- "The resolver info must match the resolver info used to create the target",
- activeAdapter.getItem(0).getResolveInfo(),
- is(resolvedComponentInfos.get(0).getResolveInfoAt(0)));
- assertThat(
- "The display label must match",
- activeAdapter.getItem(0).getDisplayLabel(),
- is("testTitle0"));
- }
-
- @Test
- public void testShortcutTargetWithoutApplyAppLimits() {
- setDeviceConfigProperty(
- SystemUiDeviceConfigFlags.APPLY_SHARING_APP_LIMITS_IN_SYSUI,
- Boolean.toString(false));
- // Set up resources
- ChooserActivityOverrideData.getInstance().resources = Mockito.spy(
- InstrumentationRegistry.getInstrumentation().getContext().getResources());
- when(
- ChooserActivityOverrideData
- .getInstance()
- .resources
- .getInteger(R.integer.config_maxShortcutTargetsPerApp))
- .thenReturn(1);
- Intent sendIntent = createSendTextIntent();
- // We need app targets for direct targets to get displayed
- List<ResolvedComponentInfo> resolvedComponentInfos = createResolvedComponentsForTest(2);
- setupResolverControllers(resolvedComponentInfos);
-
- // create test shortcut loader factory, remember loaders and their callbacks
- SparseArray<Pair<ShortcutLoader, Consumer<ShortcutLoader.Result>>> shortcutLoaders =
- createShortcutLoaderFactory();
-
- // Start activity
- final IChooserWrapper activity = (IChooserWrapper)
- mActivityRule.launchActivity(Intent.createChooser(sendIntent, null));
- waitForIdle();
-
- // verify that ShortcutLoader was queried
- ArgumentCaptor<DisplayResolveInfo[]> appTargets =
- ArgumentCaptor.forClass(DisplayResolveInfo[].class);
- verify(shortcutLoaders.get(0).first, times(1)).updateAppTargets(appTargets.capture());
-
- // send shortcuts
- assertThat(
- "Wrong number of app targets",
- appTargets.getValue().length,
- is(resolvedComponentInfos.size()));
- List<ChooserTarget> serviceTargets = createDirectShareTargets(
- 2,
- resolvedComponentInfos.get(0).getResolveInfoAt(0).activityInfo.packageName);
- ShortcutLoader.Result result = new ShortcutLoader.Result(
- true,
- appTargets.getValue(),
- new ShortcutLoader.ShortcutResultInfo[] {
- new ShortcutLoader.ShortcutResultInfo(
- appTargets.getValue()[0],
- serviceTargets
- )
- },
- new HashMap<>(),
- new HashMap<>()
- );
- activity.getMainExecutor().execute(() -> shortcutLoaders.get(0).second.accept(result));
- waitForIdle();
-
- final ChooserListAdapter activeAdapter = activity.getAdapter();
- assertThat(
- "Chooser should have 4 targets (2 apps, 2 direct)",
- activeAdapter.getCount(),
- is(4));
- assertThat(
- "Chooser should have exactly two selectable direct target",
- activeAdapter.getSelectableServiceTargetCount(),
- is(2));
- assertThat(
- "The resolver info must match the resolver info used to create the target",
- activeAdapter.getItem(0).getResolveInfo(),
- is(resolvedComponentInfos.get(0).getResolveInfoAt(0)));
- assertThat(
- "The display label must match",
- activeAdapter.getItem(0).getDisplayLabel(),
- is("testTitle0"));
- assertThat(
- "The display label must match",
- activeAdapter.getItem(1).getDisplayLabel(),
- is("testTitle1"));
- }
-
- @Test
- public void testLaunchWithCallerProvidedTarget() {
- setDeviceConfigProperty(
- SystemUiDeviceConfigFlags.APPLY_SHARING_APP_LIMITS_IN_SYSUI,
- Boolean.toString(false));
- // Set up resources
- ChooserActivityOverrideData.getInstance().resources = Mockito.spy(
- InstrumentationRegistry.getInstrumentation().getContext().getResources());
- when(
- ChooserActivityOverrideData
- .getInstance()
- .resources
- .getInteger(R.integer.config_maxShortcutTargetsPerApp))
- .thenReturn(1);
-
- // We need app targets for direct targets to get displayed
- List<ResolvedComponentInfo> resolvedComponentInfos = createResolvedComponentsForTest(2);
- setupResolverControllers(resolvedComponentInfos, resolvedComponentInfos);
- markWorkProfileUserAvailable();
-
- // set caller-provided target
- Intent chooserIntent = Intent.createChooser(createSendTextIntent(), null);
- String callerTargetLabel = "Caller Target";
- ChooserTarget[] targets = new ChooserTarget[] {
- new ChooserTarget(
- callerTargetLabel,
- Icon.createWithBitmap(createBitmap()),
- 0.1f,
- resolvedComponentInfos.get(0).name,
- new Bundle())
- };
- chooserIntent.putExtra(Intent.EXTRA_CHOOSER_TARGETS, targets);
-
- // create test shortcut loader factory, remember loaders and their callbacks
- SparseArray<Pair<ShortcutLoader, Consumer<ShortcutLoader.Result>>> shortcutLoaders =
- createShortcutLoaderFactory();
-
- // Start activity
- final IChooserWrapper activity = (IChooserWrapper)
- mActivityRule.launchActivity(chooserIntent);
- waitForIdle();
-
- // verify that ShortcutLoader was queried
- ArgumentCaptor<DisplayResolveInfo[]> appTargets =
- ArgumentCaptor.forClass(DisplayResolveInfo[].class);
- verify(shortcutLoaders.get(0).first, times(1)).updateAppTargets(appTargets.capture());
-
- // send shortcuts
- assertThat(
- "Wrong number of app targets",
- appTargets.getValue().length,
- is(resolvedComponentInfos.size()));
- ShortcutLoader.Result result = new ShortcutLoader.Result(
- true,
- appTargets.getValue(),
- new ShortcutLoader.ShortcutResultInfo[0],
- new HashMap<>(),
- new HashMap<>());
- activity.getMainExecutor().execute(() -> shortcutLoaders.get(0).second.accept(result));
- waitForIdle();
-
- final ChooserListAdapter activeAdapter = activity.getAdapter();
- assertThat(
- "Chooser should have 3 targets (2 apps, 1 direct)",
- activeAdapter.getCount(),
- is(3));
- assertThat(
- "Chooser should have exactly two selectable direct target",
- activeAdapter.getSelectableServiceTargetCount(),
- is(1));
- assertThat(
- "The display label must match",
- activeAdapter.getItem(0).getDisplayLabel(),
- is(callerTargetLabel));
-
- // Switch to work profile and ensure that the target *doesn't* show up there.
- onView(withText(R.string.resolver_work_tab)).perform(click());
- waitForIdle();
-
- for (int i = 0; i < activity.getWorkListAdapter().getCount(); i++) {
- assertThat(
- "Chooser target should not show up in opposite profile",
- activity.getWorkListAdapter().getItem(i).getDisplayLabel(),
- not(callerTargetLabel));
- }
- }
-
- @Test
- public void testLaunchWithCustomAction() throws InterruptedException {
- List<ResolvedComponentInfo> resolvedComponentInfos = createResolvedComponentsForTest(2);
- setupResolverControllers(resolvedComponentInfos);
-
- Context testContext = InstrumentationRegistry.getInstrumentation().getContext();
- final String customActionLabel = "Custom Action";
- final String testAction = "test-broadcast-receiver-action";
- Intent chooserIntent = Intent.createChooser(createSendTextIntent(), null);
- chooserIntent.putExtra(
- Intent.EXTRA_CHOOSER_CUSTOM_ACTIONS,
- new ChooserAction[] {
- new ChooserAction.Builder(
- Icon.createWithResource("", Resources.ID_NULL),
- customActionLabel,
- PendingIntent.getBroadcast(
- testContext,
- 123,
- new Intent(testAction),
- PendingIntent.FLAG_IMMUTABLE | PendingIntent.FLAG_ONE_SHOT))
- .build()
- });
- // Start activity
- mActivityRule.launchActivity(chooserIntent);
- waitForIdle();
-
- final CountDownLatch broadcastInvoked = new CountDownLatch(1);
- BroadcastReceiver testReceiver = new BroadcastReceiver() {
- @Override
- public void onReceive(Context context, Intent intent) {
- broadcastInvoked.countDown();
- }
- };
- testContext.registerReceiver(testReceiver, new IntentFilter(testAction));
-
- try {
- onView(withText(customActionLabel)).perform(click());
- broadcastInvoked.await();
- } finally {
- testContext.unregisterReceiver(testReceiver);
- }
- }
-
- @Test
- public void testLaunchWithShareModification() throws InterruptedException {
- List<ResolvedComponentInfo> resolvedComponentInfos = createResolvedComponentsForTest(2);
- setupResolverControllers(resolvedComponentInfos);
-
- Context testContext = InstrumentationRegistry.getInstrumentation().getContext();
- final String modifyShareAction = "test-broadcast-receiver-action";
- Intent chooserIntent = Intent.createChooser(createSendTextIntent(), null);
- String label = "modify share";
- PendingIntent pendingIntent = PendingIntent.getBroadcast(
- testContext,
- 123,
- new Intent(modifyShareAction),
- PendingIntent.FLAG_IMMUTABLE | PendingIntent.FLAG_ONE_SHOT);
- ChooserAction action = new ChooserAction.Builder(Icon.createWithBitmap(
- createBitmap()), label, pendingIntent).build();
- chooserIntent.putExtra(
- Intent.EXTRA_CHOOSER_MODIFY_SHARE_ACTION,
- action);
- // Start activity
- mActivityRule.launchActivity(chooserIntent);
- waitForIdle();
-
- final CountDownLatch broadcastInvoked = new CountDownLatch(1);
- BroadcastReceiver testReceiver = new BroadcastReceiver() {
- @Override
- public void onReceive(Context context, Intent intent) {
- broadcastInvoked.countDown();
- }
- };
- testContext.registerReceiver(testReceiver, new IntentFilter(modifyShareAction));
-
- try {
- onView(withText(label)).perform(click());
- broadcastInvoked.await();
- } finally {
- testContext.unregisterReceiver(testReceiver);
- }
- }
-
- @Test
- public void testUpdateMaxTargetsPerRow_columnCountIsUpdated() throws InterruptedException {
- updateMaxTargetsPerRowResource(/* targetsPerRow= */ 4);
- givenAppTargets(/* appCount= */ 16);
- Intent sendIntent = createSendTextIntent();
- final ChooserActivity activity =
- mActivityRule.launchActivity(Intent.createChooser(sendIntent, null));
-
- updateMaxTargetsPerRowResource(/* targetsPerRow= */ 6);
- InstrumentationRegistry.getInstrumentation()
- .runOnMainSync(() -> activity.onConfigurationChanged(
- InstrumentationRegistry.getInstrumentation()
- .getContext().getResources().getConfiguration()));
-
- waitForIdle();
- onView(withId(com.android.internal.R.id.resolver_list))
- .check(matches(withGridColumnCount(6)));
- }
-
- // This test is too long and too slow and should not be taken as an example for future tests.
- @Test @Ignore
- public void testDirectTargetLoggingWithAppTargetNotRankedPortrait()
- throws InterruptedException {
- testDirectTargetLoggingWithAppTargetNotRanked(Configuration.ORIENTATION_PORTRAIT, 4);
- }
-
- @Test @Ignore
- public void testDirectTargetLoggingWithAppTargetNotRankedLandscape()
- throws InterruptedException {
- testDirectTargetLoggingWithAppTargetNotRanked(Configuration.ORIENTATION_LANDSCAPE, 8);
- }
-
- private void testDirectTargetLoggingWithAppTargetNotRanked(
- int orientation, int appTargetsExpected) {
- Configuration configuration =
- new Configuration(InstrumentationRegistry.getInstrumentation().getContext()
- .getResources().getConfiguration());
- configuration.orientation = orientation;
-
- ChooserActivityOverrideData.getInstance().resources = Mockito.spy(
- InstrumentationRegistry.getInstrumentation().getContext().getResources());
- when(
- ChooserActivityOverrideData
- .getInstance()
- .resources
- .getConfiguration())
- .thenReturn(configuration);
-
- Intent sendIntent = createSendTextIntent();
- // We need app targets for direct targets to get displayed
- List<ResolvedComponentInfo> resolvedComponentInfos = createResolvedComponentsForTest(15);
- setupResolverControllers(resolvedComponentInfos);
-
- // Create direct share target
- List<ChooserTarget> serviceTargets = createDirectShareTargets(1,
- resolvedComponentInfos.get(14).getResolveInfoAt(0).activityInfo.packageName);
- ResolveInfo ri = ResolverDataProvider.createResolveInfo(16, 0, PERSONAL_USER_HANDLE);
-
- // Start activity
- final IChooserWrapper wrapper = (IChooserWrapper)
- 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,
- ri,
- "testLabel",
- "testInfo",
- sendIntent,
- /* resolveInfoPresentationGetter */ null),
- serviceTargets,
- TARGET_TYPE_CHOOSER_TARGET,
- directShareToShortcutInfos,
- /* directShareToAppTargets */ null)
- );
-
- assertThat(
- String.format("Chooser should have %d targets (%d apps, 1 direct, 15 A-Z)",
- appTargetsExpected + 16, appTargetsExpected),
- wrapper.getAdapter().getCount(), is(appTargetsExpected + 16));
- assertThat("Chooser should have exactly one selectable direct target",
- wrapper.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));
-
- // Click on the direct target
- String name = serviceTargets.get(0).getTitle().toString();
- onView(withText(name))
- .perform(click());
- waitForIdle();
-
- ChooserActivityLogger logger = wrapper.getChooserActivityLogger();
- verify(logger, times(1)).logShareTargetSelected(
- eq(ChooserActivityLogger.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());
- }
-
- @Test
- public void testWorkTab_displayedWhenWorkProfileUserAvailable() {
- Intent sendIntent = createSendTextIntent();
- sendIntent.setType(TEST_MIME_TYPE);
- markWorkProfileUserAvailable();
-
- mActivityRule.launchActivity(Intent.createChooser(sendIntent, "work tab test"));
- waitForIdle();
-
- onView(withId(android.R.id.tabs)).check(matches(isDisplayed()));
- }
-
- @Test
- public void testWorkTab_hiddenWhenWorkProfileUserNotAvailable() {
- Intent sendIntent = createSendTextIntent();
- sendIntent.setType(TEST_MIME_TYPE);
-
- mActivityRule.launchActivity(Intent.createChooser(sendIntent, "work tab test"));
- waitForIdle();
-
- onView(withId(android.R.id.tabs)).check(matches(not(isDisplayed())));
- }
-
- @Test
- public void testWorkTab_eachTabUsesExpectedAdapter() {
- int personalProfileTargets = 3;
- int otherProfileTargets = 1;
- List<ResolvedComponentInfo> personalResolvedComponentInfos =
- createResolvedComponentsForTestWithOtherProfile(
- personalProfileTargets + otherProfileTargets, /* userID */ 10);
- int workProfileTargets = 4;
- List<ResolvedComponentInfo> workResolvedComponentInfos = createResolvedComponentsForTest(
- workProfileTargets);
- setupResolverControllers(personalResolvedComponentInfos, workResolvedComponentInfos);
- Intent sendIntent = createSendTextIntent();
- sendIntent.setType(TEST_MIME_TYPE);
- markWorkProfileUserAvailable();
-
- final IChooserWrapper activity = (IChooserWrapper)
- mActivityRule.launchActivity(Intent.createChooser(sendIntent, "work tab test"));
- waitForIdle();
-
- assertThat(activity.getCurrentUserHandle().getIdentifier(), is(0));
- onView(withText(R.string.resolver_work_tab)).perform(click());
- assertThat(activity.getCurrentUserHandle().getIdentifier(), is(10));
- assertThat(activity.getPersonalListAdapter().getCount(), is(personalProfileTargets));
- assertThat(activity.getWorkListAdapter().getCount(), is(workProfileTargets));
- }
-
- @Test
- public void testWorkTab_workProfileHasExpectedNumberOfTargets() throws InterruptedException {
- markWorkProfileUserAvailable();
- int workProfileTargets = 4;
- List<ResolvedComponentInfo> personalResolvedComponentInfos =
- createResolvedComponentsForTestWithOtherProfile(3, /* userId */ 10);
- List<ResolvedComponentInfo> workResolvedComponentInfos =
- createResolvedComponentsForTest(workProfileTargets);
- setupResolverControllers(personalResolvedComponentInfos, workResolvedComponentInfos);
- Intent sendIntent = createSendTextIntent();
- sendIntent.setType(TEST_MIME_TYPE);
-
- final IChooserWrapper activity = (IChooserWrapper)
- mActivityRule.launchActivity(Intent.createChooser(sendIntent, "work tab test"));
- waitForIdle();
- onView(withText(R.string.resolver_work_tab)).perform(click());
- waitForIdle();
-
- assertThat(activity.getWorkListAdapter().getCount(), is(workProfileTargets));
- }
-
- @Test @Ignore
- public void testWorkTab_selectingWorkTabAppOpensAppInWorkProfile() {
- markWorkProfileUserAvailable();
- List<ResolvedComponentInfo> personalResolvedComponentInfos =
- createResolvedComponentsForTestWithOtherProfile(3, /* userId */ 10);
- int workProfileTargets = 4;
- List<ResolvedComponentInfo> workResolvedComponentInfos =
- createResolvedComponentsForTest(workProfileTargets);
- setupResolverControllers(personalResolvedComponentInfos, workResolvedComponentInfos);
- Intent sendIntent = createSendTextIntent();
- sendIntent.setType(TEST_MIME_TYPE);
- ResolveInfo[] chosen = new ResolveInfo[1];
- ChooserActivityOverrideData.getInstance().onSafelyStartInternalCallback = targetInfo -> {
- chosen[0] = targetInfo.getResolveInfo();
- return true;
- };
-
- mActivityRule.launchActivity(Intent.createChooser(sendIntent, "work tab test"));
- waitForIdle();
- onView(withText(R.string.resolver_work_tab)).perform(click());
- waitForIdle();
-
- onView(first(allOf(
- withText(workResolvedComponentInfos.get(0)
- .getResolveInfoAt(0).activityInfo.applicationInfo.name),
- isDisplayed())))
- .perform(click());
- waitForIdle();
- assertThat(chosen[0], is(workResolvedComponentInfos.get(0).getResolveInfoAt(0)));
- }
-
- @Test
- public void testWorkTab_crossProfileIntentsDisabled_personalToWork_emptyStateShown() {
- markWorkProfileUserAvailable();
- int workProfileTargets = 4;
- List<ResolvedComponentInfo> personalResolvedComponentInfos =
- createResolvedComponentsForTestWithOtherProfile(3, /* userId */ 10);
- List<ResolvedComponentInfo> workResolvedComponentInfos =
- createResolvedComponentsForTest(workProfileTargets);
- ChooserActivityOverrideData.getInstance().hasCrossProfileIntents = false;
- setupResolverControllers(personalResolvedComponentInfos, workResolvedComponentInfos);
- Intent sendIntent = createSendTextIntent();
- sendIntent.setType(TEST_MIME_TYPE);
-
- mActivityRule.launchActivity(Intent.createChooser(sendIntent, "work tab test"));
- waitForIdle();
- onView(withText(R.string.resolver_work_tab)).perform(click());
- waitForIdle();
- onView(withId(com.android.internal.R.id.contentPanel))
- .perform(swipeUp());
-
- onView(withText(R.string.resolver_cross_profile_blocked))
- .check(matches(isDisplayed()));
- }
-
- @Test
- public void testWorkTab_workProfileDisabled_emptyStateShown() {
- markWorkProfileUserAvailable();
- int workProfileTargets = 4;
- List<ResolvedComponentInfo> personalResolvedComponentInfos =
- createResolvedComponentsForTestWithOtherProfile(3, /* userId */ 10);
- List<ResolvedComponentInfo> workResolvedComponentInfos =
- createResolvedComponentsForTest(workProfileTargets);
- ChooserActivityOverrideData.getInstance().isQuietModeEnabled = true;
- setupResolverControllers(personalResolvedComponentInfos, workResolvedComponentInfos);
- Intent sendIntent = createSendTextIntent();
- sendIntent.setType(TEST_MIME_TYPE);
-
- mActivityRule.launchActivity(Intent.createChooser(sendIntent, "work tab test"));
- waitForIdle();
- onView(withId(com.android.internal.R.id.contentPanel))
- .perform(swipeUp());
- onView(withText(R.string.resolver_work_tab)).perform(click());
- waitForIdle();
-
- onView(withText(R.string.resolver_turn_on_work_apps))
- .check(matches(isDisplayed()));
- }
-
- @Test
- public void testWorkTab_noWorkAppsAvailable_emptyStateShown() {
- markWorkProfileUserAvailable();
- List<ResolvedComponentInfo> personalResolvedComponentInfos =
- createResolvedComponentsForTest(3);
- List<ResolvedComponentInfo> workResolvedComponentInfos =
- createResolvedComponentsForTest(0);
- setupResolverControllers(personalResolvedComponentInfos, workResolvedComponentInfos);
- Intent sendIntent = createSendTextIntent();
- sendIntent.setType(TEST_MIME_TYPE);
-
- mActivityRule.launchActivity(Intent.createChooser(sendIntent, "work tab test"));
- waitForIdle();
- onView(withId(com.android.internal.R.id.contentPanel))
- .perform(swipeUp());
- onView(withText(R.string.resolver_work_tab)).perform(click());
- waitForIdle();
-
- onView(withText(R.string.resolver_no_work_apps_available))
- .check(matches(isDisplayed()));
- }
-
- @Ignore // b/220067877
- @Test
- public void testWorkTab_xProfileOff_noAppsAvailable_workOff_xProfileOffEmptyStateShown() {
- markWorkProfileUserAvailable();
- List<ResolvedComponentInfo> personalResolvedComponentInfos =
- createResolvedComponentsForTest(3);
- List<ResolvedComponentInfo> workResolvedComponentInfos =
- createResolvedComponentsForTest(0);
- setupResolverControllers(personalResolvedComponentInfos, workResolvedComponentInfos);
- ChooserActivityOverrideData.getInstance().isQuietModeEnabled = true;
- ChooserActivityOverrideData.getInstance().hasCrossProfileIntents = false;
- Intent sendIntent = createSendTextIntent();
- sendIntent.setType(TEST_MIME_TYPE);
-
- mActivityRule.launchActivity(Intent.createChooser(sendIntent, "work tab test"));
- waitForIdle();
- onView(withId(com.android.internal.R.id.contentPanel))
- .perform(swipeUp());
- onView(withText(R.string.resolver_work_tab)).perform(click());
- waitForIdle();
-
- onView(withText(R.string.resolver_cross_profile_blocked))
- .check(matches(isDisplayed()));
- }
-
- @Test
- public void testWorkTab_noAppsAvailable_workOff_noAppsAvailableEmptyStateShown() {
- markWorkProfileUserAvailable();
- List<ResolvedComponentInfo> personalResolvedComponentInfos =
- createResolvedComponentsForTest(3);
- List<ResolvedComponentInfo> workResolvedComponentInfos =
- createResolvedComponentsForTest(0);
- setupResolverControllers(personalResolvedComponentInfos, workResolvedComponentInfos);
- ChooserActivityOverrideData.getInstance().isQuietModeEnabled = true;
- Intent sendIntent = createSendTextIntent();
- sendIntent.setType(TEST_MIME_TYPE);
-
- mActivityRule.launchActivity(Intent.createChooser(sendIntent, "work tab test"));
- waitForIdle();
- onView(withId(com.android.internal.R.id.contentPanel))
- .perform(swipeUp());
- onView(withText(R.string.resolver_work_tab)).perform(click());
- waitForIdle();
-
- onView(withText(R.string.resolver_no_work_apps_available))
- .check(matches(isDisplayed()));
- }
-
- @Test @Ignore("b/222124533")
- public void testAppTargetLogging() throws InterruptedException {
- Intent sendIntent = createSendTextIntent();
- List<ResolvedComponentInfo> resolvedComponentInfos = createResolvedComponentsForTest(2);
-
- setupResolverControllers(resolvedComponentInfos);
-
- final IChooserWrapper activity = (IChooserWrapper)
- mActivityRule.launchActivity(Intent.createChooser(sendIntent, null));
- waitForIdle();
-
- // TODO(b/222124533): other test cases use a timeout to make sure that the UI is fully
- // populated; without one, this test flakes. Ideally we should address the need for a
- // timeout everywhere instead of introducing one to fix this particular test.
-
- assertThat(activity.getAdapter().getCount(), is(2));
- onView(withId(com.android.internal.R.id.profile_button)).check(doesNotExist());
-
- ResolveInfo[] chosen = new ResolveInfo[1];
- ChooserActivityOverrideData.getInstance().onSafelyStartInternalCallback = targetInfo -> {
- chosen[0] = targetInfo.getResolveInfo();
- return true;
- };
-
- ResolveInfo toChoose = resolvedComponentInfos.get(0).getResolveInfoAt(0);
- onView(withText(toChoose.activityInfo.name))
- .perform(click());
- waitForIdle();
-
- // TODO(b/211669337): Determine the expected SHARESHEET_DIRECT_LOAD_COMPLETE events.
- }
-
- @Test
- public void testDirectTargetLogging() {
- Intent sendIntent = createSendTextIntent();
- // We need app targets for direct targets to get displayed
- List<ResolvedComponentInfo> resolvedComponentInfos = createResolvedComponentsForTest(2);
- setupResolverControllers(resolvedComponentInfos);
-
- // create test shortcut loader factory, remember loaders and their callbacks
- SparseArray<Pair<ShortcutLoader, Consumer<ShortcutLoader.Result>>> shortcutLoaders =
- new SparseArray<>();
- ChooserActivityOverrideData.getInstance().shortcutLoaderFactory =
- (userHandle, callback) -> {
- Pair<ShortcutLoader, Consumer<ShortcutLoader.Result>> pair =
- new Pair<>(mock(ShortcutLoader.class), callback);
- shortcutLoaders.put(userHandle.getIdentifier(), pair);
- return pair.first;
- };
-
- // Start activity
- final IChooserWrapper activity = (IChooserWrapper)
- mActivityRule.launchActivity(Intent.createChooser(sendIntent, null));
- waitForIdle();
-
- // verify that ShortcutLoader was queried
- ArgumentCaptor<DisplayResolveInfo[]> appTargets =
- ArgumentCaptor.forClass(DisplayResolveInfo[].class);
- verify(shortcutLoaders.get(0).first, times(1))
- .updateAppTargets(appTargets.capture());
-
- // send shortcuts
- assertThat(
- "Wrong number of app targets",
- appTargets.getValue().length,
- is(resolvedComponentInfos.size()));
- List<ChooserTarget> serviceTargets = createDirectShareTargets(1,
- resolvedComponentInfos.get(0).getResolveInfoAt(0).activityInfo.packageName);
- ShortcutLoader.Result result = new ShortcutLoader.Result(
- // TODO: test another value as well
- false,
- appTargets.getValue(),
- new ShortcutLoader.ShortcutResultInfo[] {
- new ShortcutLoader.ShortcutResultInfo(
- appTargets.getValue()[0],
- serviceTargets
- )
- },
- new HashMap<>(),
- new HashMap<>()
- );
- activity.getMainExecutor().execute(() -> shortcutLoaders.get(0).second.accept(result));
- waitForIdle();
-
- assertThat("Chooser should have 3 targets (2 apps, 1 direct)",
- activity.getAdapter().getCount(), is(3));
- assertThat("Chooser should have exactly one selectable direct target",
- activity.getAdapter().getSelectableServiceTargetCount(), is(1));
- assertThat(
- "The resolver info must match the resolver info used to create the target",
- activity.getAdapter().getItem(0).getResolveInfo(),
- is(resolvedComponentInfos.get(0).getResolveInfoAt(0)));
-
- // Click on the direct target
- String name = serviceTargets.get(0).getTitle().toString();
- onView(withText(name))
- .perform(click());
- waitForIdle();
-
- ChooserActivityLogger logger = activity.getChooserActivityLogger();
- ArgumentCaptor<Integer> typeCaptor = ArgumentCaptor.forClass(Integer.class);
- verify(logger, times(1)).logShareTargetSelected(
- eq(ChooserActivityLogger.SELECTION_TYPE_SERVICE),
- /* packageName= */ any(),
- /* positionPicked= */ anyInt(),
- /* directTargetAlsoRanked= */ anyInt(),
- /* numCallerProvided= */ anyInt(),
- /* directTargetHashed= */ any(),
- /* isPinned= */ anyBoolean(),
- /* successfullySelected= */ anyBoolean(),
- /* selectionCost= */ anyLong());
- }
-
- @Test
- public void testDirectTargetPinningDialog() {
- Intent sendIntent = createSendTextIntent();
- // We need app targets for direct targets to get displayed
- List<ResolvedComponentInfo> resolvedComponentInfos = createResolvedComponentsForTest(2);
- setupResolverControllers(resolvedComponentInfos);
-
- // create test shortcut loader factory, remember loaders and their callbacks
- SparseArray<Pair<ShortcutLoader, Consumer<ShortcutLoader.Result>>> shortcutLoaders =
- new SparseArray<>();
- ChooserActivityOverrideData.getInstance().shortcutLoaderFactory =
- (userHandle, callback) -> {
- Pair<ShortcutLoader, Consumer<ShortcutLoader.Result>> pair =
- new Pair<>(mock(ShortcutLoader.class), callback);
- shortcutLoaders.put(userHandle.getIdentifier(), pair);
- return pair.first;
- };
-
- // Start activity
- final IChooserWrapper activity = (IChooserWrapper)
- mActivityRule.launchActivity(Intent.createChooser(sendIntent, null));
- waitForIdle();
-
- // verify that ShortcutLoader was queried
- ArgumentCaptor<DisplayResolveInfo[]> appTargets =
- ArgumentCaptor.forClass(DisplayResolveInfo[].class);
- verify(shortcutLoaders.get(0).first, times(1))
- .updateAppTargets(appTargets.capture());
-
- // send shortcuts
- List<ChooserTarget> serviceTargets = createDirectShareTargets(
- 1,
- resolvedComponentInfos.get(0).getResolveInfoAt(0).activityInfo.packageName);
- ShortcutLoader.Result result = new ShortcutLoader.Result(
- // TODO: test another value as well
- false,
- appTargets.getValue(),
- new ShortcutLoader.ShortcutResultInfo[] {
- new ShortcutLoader.ShortcutResultInfo(
- appTargets.getValue()[0],
- serviceTargets
- )
- },
- new HashMap<>(),
- new HashMap<>()
- );
- activity.getMainExecutor().execute(() -> shortcutLoaders.get(0).second.accept(result));
- waitForIdle();
-
- // Long-click on the direct target
- String name = serviceTargets.get(0).getTitle().toString();
- onView(withText(name)).perform(longClick());
- waitForIdle();
-
- onView(withId(R.id.chooser_dialog_content)).check(matches(isDisplayed()));
- }
-
- @Test @Ignore
- public void testEmptyDirectRowLogging() throws InterruptedException {
- Intent sendIntent = createSendTextIntent();
- // We need app targets for direct targets to get displayed
- List<ResolvedComponentInfo> resolvedComponentInfos = createResolvedComponentsForTest(2);
- setupResolverControllers(resolvedComponentInfos);
-
- // Start activity
- final IChooserWrapper activity = (IChooserWrapper)
- mActivityRule.launchActivity(Intent.createChooser(sendIntent, null));
-
- // Thread.sleep shouldn't be a thing in an integration test but it's
- // necessary here because of the way the code is structured
- Thread.sleep(3000);
-
- assertThat("Chooser should have 2 app targets",
- activity.getAdapter().getCount(), is(2));
- assertThat("Chooser should have no direct targets",
- activity.getAdapter().getSelectableServiceTargetCount(), is(0));
-
- // TODO(b/211669337): Determine the expected SHARESHEET_DIRECT_LOAD_COMPLETE events.
- }
-
- @Ignore // b/220067877
- @Test
- public void testCopyTextToClipboardLogging() throws Exception {
- Intent sendIntent = createSendTextIntent();
- List<ResolvedComponentInfo> resolvedComponentInfos = createResolvedComponentsForTest(2);
-
- setupResolverControllers(resolvedComponentInfos);
-
- final IChooserWrapper activity = (IChooserWrapper)
- mActivityRule.launchActivity(Intent.createChooser(sendIntent, null));
- waitForIdle();
-
- onView(withId(com.android.internal.R.id.chooser_copy_button)).check(matches(isDisplayed()));
- onView(withId(com.android.internal.R.id.chooser_copy_button)).perform(click());
-
- // TODO(b/211669337): Determine the expected SHARESHEET_DIRECT_LOAD_COMPLETE events.
- }
-
- @Test @Ignore("b/222124533")
- public void testSwitchProfileLogging() throws InterruptedException {
- markWorkProfileUserAvailable();
- int workProfileTargets = 4;
- List<ResolvedComponentInfo> personalResolvedComponentInfos =
- createResolvedComponentsForTestWithOtherProfile(3, /* userId */ 10);
- List<ResolvedComponentInfo> workResolvedComponentInfos =
- createResolvedComponentsForTest(workProfileTargets);
- setupResolverControllers(personalResolvedComponentInfos, workResolvedComponentInfos);
- Intent sendIntent = createSendTextIntent();
- sendIntent.setType(TEST_MIME_TYPE);
-
- final IChooserWrapper activity = (IChooserWrapper)
- mActivityRule.launchActivity(Intent.createChooser(sendIntent, "work tab test"));
- waitForIdle();
- onView(withText(R.string.resolver_work_tab)).perform(click());
- waitForIdle();
- onView(withText(R.string.resolver_personal_tab)).perform(click());
- waitForIdle();
-
- // TODO(b/211669337): Determine the expected SHARESHEET_DIRECT_LOAD_COMPLETE events.
- }
-
- @Test
- public void testWorkTab_onePersonalTarget_emptyStateOnWorkTarget_doesNotAutoLaunch() {
- markWorkProfileUserAvailable();
- int workProfileTargets = 4;
- List<ResolvedComponentInfo> personalResolvedComponentInfos =
- createResolvedComponentsForTestWithOtherProfile(2, /* userId */ 10);
- List<ResolvedComponentInfo> workResolvedComponentInfos =
- createResolvedComponentsForTest(workProfileTargets);
- ChooserActivityOverrideData.getInstance().hasCrossProfileIntents = false;
- setupResolverControllers(personalResolvedComponentInfos, workResolvedComponentInfos);
- Intent sendIntent = createSendTextIntent();
- ResolveInfo[] chosen = new ResolveInfo[1];
- ChooserActivityOverrideData.getInstance().onSafelyStartInternalCallback = targetInfo -> {
- chosen[0] = targetInfo.getResolveInfo();
- return true;
- };
-
- mActivityRule.launchActivity(Intent.createChooser(sendIntent, "Test"));
- waitForIdle();
-
- assertNull(chosen[0]);
- }
-
- @Test
- public void testOneInitialIntent_noAutolaunch() {
- List<ResolvedComponentInfo> personalResolvedComponentInfos =
- createResolvedComponentsForTest(1);
- setupResolverControllers(personalResolvedComponentInfos);
- Intent chooserIntent = createChooserIntent(createSendTextIntent(),
- new Intent[] {new Intent("action.fake")});
- ResolveInfo[] chosen = new ResolveInfo[1];
- ChooserActivityOverrideData.getInstance().onSafelyStartInternalCallback = targetInfo -> {
- 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);
- waitForIdle();
-
- IChooserWrapper activity = (IChooserWrapper) mActivityRule.launchActivity(chooserIntent);
- waitForIdle();
-
- assertNull(chosen[0]);
- assertThat(activity
- .getPersonalListAdapter().getCallerTargetCount(), is(1));
- }
-
- @Test
- public void testWorkTab_withInitialIntents_workTabDoesNotIncludePersonalInitialIntents() {
- markWorkProfileUserAvailable();
- int workProfileTargets = 1;
- List<ResolvedComponentInfo> personalResolvedComponentInfos =
- createResolvedComponentsForTestWithOtherProfile(2, /* userId */ 10);
- List<ResolvedComponentInfo> workResolvedComponentInfos =
- createResolvedComponentsForTest(workProfileTargets);
- setupResolverControllers(personalResolvedComponentInfos, workResolvedComponentInfos);
- Intent[] initialIntents = {
- new Intent("action.fake1"),
- 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());
- waitForIdle();
-
- IChooserWrapper activity = (IChooserWrapper) mActivityRule.launchActivity(chooserIntent);
- waitForIdle();
-
- assertThat(activity.getPersonalListAdapter().getCallerTargetCount(), is(2));
- assertThat(activity.getWorkListAdapter().getCallerTargetCount(), is(0));
- }
-
- @Test
- public void testWorkTab_xProfileIntentsDisabled_personalToWork_nonSendIntent_emptyStateShown() {
- markWorkProfileUserAvailable();
- int workProfileTargets = 4;
- List<ResolvedComponentInfo> personalResolvedComponentInfos =
- createResolvedComponentsForTestWithOtherProfile(3, /* userId */ 10);
- List<ResolvedComponentInfo> workResolvedComponentInfos =
- createResolvedComponentsForTest(workProfileTargets);
- ChooserActivityOverrideData.getInstance().hasCrossProfileIntents = false;
- setupResolverControllers(personalResolvedComponentInfos, workResolvedComponentInfos);
- Intent[] initialIntents = {
- new Intent("action.fake1"),
- 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());
-
- mActivityRule.launchActivity(chooserIntent);
- waitForIdle();
- onView(withText(R.string.resolver_work_tab)).perform(click());
- waitForIdle();
- onView(withId(com.android.internal.R.id.contentPanel))
- .perform(swipeUp());
-
- onView(withText(R.string.resolver_cross_profile_blocked))
- .check(matches(isDisplayed()));
- }
-
- @Test
- public void testWorkTab_noWorkAppsAvailable_nonSendIntent_emptyStateShown() {
- markWorkProfileUserAvailable();
- List<ResolvedComponentInfo> personalResolvedComponentInfos =
- createResolvedComponentsForTest(3);
- List<ResolvedComponentInfo> workResolvedComponentInfos =
- createResolvedComponentsForTest(0);
- setupResolverControllers(personalResolvedComponentInfos, workResolvedComponentInfos);
- Intent[] initialIntents = {
- new Intent("action.fake1"),
- 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());
-
- mActivityRule.launchActivity(chooserIntent);
- waitForIdle();
- onView(withId(com.android.internal.R.id.contentPanel))
- .perform(swipeUp());
- onView(withText(R.string.resolver_work_tab)).perform(click());
- waitForIdle();
-
- onView(withText(R.string.resolver_no_work_apps_available))
- .check(matches(isDisplayed()));
- }
-
- @Test
- public void testDeduplicateCallerTargetRankedTarget() {
- // Create 4 ranked app targets.
- List<ResolvedComponentInfo> personalResolvedComponentInfos =
- createResolvedComponentsForTest(4);
- setupResolverControllers(personalResolvedComponentInfos);
- // 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);
- waitForIdle();
-
- IChooserWrapper activity = (IChooserWrapper) mActivityRule.launchActivity(chooserIntent);
- waitForIdle();
-
- // Total 4 targets (1 caller target, 3 ranked targets)
- assertThat(activity.getAdapter().getCount(), is(4));
- assertThat(activity.getAdapter().getCallerTargetCount(), is(1));
- assertThat(activity.getAdapter().getRankedTargetCount(), is(3));
- }
-
- @Test
- public void test_query_shortcut_loader_for_the_selected_tab() {
- markWorkProfileUserAvailable();
- List<ResolvedComponentInfo> personalResolvedComponentInfos =
- createResolvedComponentsForTestWithOtherProfile(3, /* userId */ 10);
- List<ResolvedComponentInfo> workResolvedComponentInfos =
- createResolvedComponentsForTest(3);
- setupResolverControllers(personalResolvedComponentInfos, workResolvedComponentInfos);
- ShortcutLoader personalProfileShortcutLoader = mock(ShortcutLoader.class);
- ShortcutLoader workProfileShortcutLoader = mock(ShortcutLoader.class);
- final SparseArray<ShortcutLoader> shortcutLoaders = new SparseArray<>();
- shortcutLoaders.put(0, personalProfileShortcutLoader);
- shortcutLoaders.put(10, workProfileShortcutLoader);
- ChooserActivityOverrideData.getInstance().shortcutLoaderFactory =
- (userHandle, callback) -> shortcutLoaders.get(userHandle.getIdentifier(), null);
- Intent sendIntent = createSendTextIntent();
- sendIntent.setType(TEST_MIME_TYPE);
-
- mActivityRule.launchActivity(Intent.createChooser(sendIntent, "work tab test"));
- waitForIdle();
- onView(withId(com.android.internal.R.id.contentPanel))
- .perform(swipeUp());
- waitForIdle();
-
- verify(personalProfileShortcutLoader, times(1)).updateAppTargets(any());
-
- onView(withText(R.string.resolver_work_tab)).perform(click());
- waitForIdle();
-
- verify(workProfileShortcutLoader, times(1)).updateAppTargets(any());
- }
-
- @Test
- public void testClonedProfilePresent_personalAdapterIsSetWithPersonalProfile() {
- // enable cloneProfile
- markCloneProfileUserAvailable();
- List<ResolvedComponentInfo> resolvedComponentInfos =
- createResolvedComponentsWithCloneProfileForTest(
- 3,
- PERSONAL_USER_HANDLE,
- ChooserActivityOverrideData.getInstance().cloneProfileUserHandle);
- setupResolverControllers(resolvedComponentInfos);
- Intent sendIntent = createSendTextIntent();
-
- final IChooserWrapper activity = (IChooserWrapper) mActivityRule
- .launchActivity(Intent.createChooser(sendIntent, "personalProfileTest"));
- waitForIdle();
-
- assertThat(activity.getPersonalListAdapter().getUserHandle(), is(PERSONAL_USER_HANDLE));
- assertThat(activity.getAdapter().getCount(), is(3));
- }
-
- @Test
- public void testClonedProfilePresent_personalTabUsesExpectedAdapter() {
- markWorkProfileUserAvailable();
- markCloneProfileUserAvailable();
- List<ResolvedComponentInfo> personalResolvedComponentInfos =
- createResolvedComponentsForTest(3);
- List<ResolvedComponentInfo> workResolvedComponentInfos = createResolvedComponentsForTest(
- 4);
- setupResolverControllers(personalResolvedComponentInfos, workResolvedComponentInfos);
- Intent sendIntent = createSendTextIntent();
- sendIntent.setType(TEST_MIME_TYPE);
-
-
- final IChooserWrapper activity = (IChooserWrapper)
- mActivityRule.launchActivity(Intent.createChooser(sendIntent, "multi tab test"));
- waitForIdle();
-
- assertThat(activity.getCurrentUserHandle(), is(PERSONAL_USER_HANDLE));
- }
-
- private Intent createChooserIntent(Intent intent, Intent[] initialIntents) {
- Intent chooserIntent = new Intent();
- chooserIntent.setAction(Intent.ACTION_CHOOSER);
- chooserIntent.putExtra(Intent.EXTRA_TEXT, "testing intent sending");
- chooserIntent.putExtra(Intent.EXTRA_TITLE, "some title");
- chooserIntent.putExtra(Intent.EXTRA_INTENT, intent);
- chooserIntent.setType("text/plain");
- if (initialIntents != null) {
- chooserIntent.putExtra(Intent.EXTRA_INITIAL_INTENTS, initialIntents);
- }
- return chooserIntent;
- }
-
- /* This is a "test of a test" to make sure that our inherited test class
- * is successfully configured to operate on the unbundled-equivalent
- * ChooserWrapperActivity.
- *
- * TODO: remove after unbundling is complete.
- */
- @Test
- public void testWrapperActivityHasExpectedConcreteType() {
- final ChooserActivity activity = mActivityRule.launchActivity(
- Intent.createChooser(new Intent("ACTION_FOO"), "foo"));
- waitForIdle();
- assertThat(activity).isInstanceOf(com.android.intentresolver.ChooserWrapperActivity.class);
- }
-
- private ResolveInfo createFakeResolveInfo() {
- ResolveInfo ri = new ResolveInfo();
- ri.activityInfo = new ActivityInfo();
- ri.activityInfo.name = "FakeActivityName";
- ri.activityInfo.packageName = "fake.package.name";
- ri.activityInfo.applicationInfo = new ApplicationInfo();
- ri.activityInfo.applicationInfo.packageName = "fake.package.name";
- ri.userHandle = UserHandle.CURRENT;
- return ri;
- }
-
- private Intent createSendTextIntent() {
- Intent sendIntent = new Intent();
- sendIntent.setAction(Intent.ACTION_SEND);
- sendIntent.putExtra(Intent.EXTRA_TEXT, "testing intent sending");
- sendIntent.setType("text/plain");
- return sendIntent;
- }
-
- private Intent createSendImageIntent(Uri imageThumbnail) {
- Intent sendIntent = new Intent();
- sendIntent.setAction(Intent.ACTION_SEND);
- sendIntent.putExtra(Intent.EXTRA_STREAM, imageThumbnail);
- sendIntent.setType("image/png");
- if (imageThumbnail != null) {
- ClipData.Item clipItem = new ClipData.Item(imageThumbnail);
- sendIntent.setClipData(new ClipData("Clip Label", new String[]{"image/png"}, clipItem));
- }
-
- return sendIntent;
- }
-
- private Uri createTestContentProviderUri(
- @Nullable String mimeType, @Nullable String streamType) {
- String packageName =
- InstrumentationRegistry.getInstrumentation().getContext().getPackageName();
- Uri.Builder builder = Uri.parse("content://" + packageName + "/image.png")
- .buildUpon();
- if (mimeType != null) {
- builder.appendQueryParameter("mimeType", mimeType);
- }
- if (streamType != null) {
- builder.appendQueryParameter("streamType", streamType);
- }
- return builder.build();
- }
-
- private Intent createSendTextIntentWithPreview(String title, Uri imageThumbnail) {
- Intent sendIntent = new Intent();
- sendIntent.setAction(Intent.ACTION_SEND);
- sendIntent.putExtra(Intent.EXTRA_TEXT, "testing intent sending");
- sendIntent.putExtra(Intent.EXTRA_TITLE, title);
- if (imageThumbnail != null) {
- ClipData.Item clipItem = new ClipData.Item(imageThumbnail);
- sendIntent.setClipData(new ClipData("Clip Label", new String[]{"image/png"}, clipItem));
- }
-
- return sendIntent;
- }
-
- private Intent createSendUriIntentWithPreview(ArrayList<Uri> uris) {
- Intent sendIntent = new Intent();
-
- if (uris.size() > 1) {
- sendIntent.setAction(Intent.ACTION_SEND_MULTIPLE);
- sendIntent.putExtra(Intent.EXTRA_STREAM, uris);
- } else {
- sendIntent.setAction(Intent.ACTION_SEND);
- sendIntent.putExtra(Intent.EXTRA_STREAM, uris.get(0));
- }
-
- return sendIntent;
- }
-
- private Intent createViewTextIntent() {
- Intent viewIntent = new Intent();
- viewIntent.setAction(Intent.ACTION_VIEW);
- viewIntent.putExtra(Intent.EXTRA_TEXT, "testing intent viewing");
- return viewIntent;
- }
-
- private List<ResolvedComponentInfo> createResolvedComponentsForTest(int numberOfResults) {
- List<ResolvedComponentInfo> infoList = new ArrayList<>(numberOfResults);
- for (int i = 0; i < numberOfResults; i++) {
- infoList.add(ResolverDataProvider.createResolvedComponentInfo(i, PERSONAL_USER_HANDLE));
- }
- return infoList;
- }
-
- private List<ResolvedComponentInfo> createResolvedComponentsWithCloneProfileForTest(
- int numberOfResults,
- UserHandle resolvedForPersonalUser,
- UserHandle resolvedForClonedUser) {
- List<ResolvedComponentInfo> infoList = new ArrayList<>(numberOfResults);
- for (int i = 0; i < 1; i++) {
- infoList.add(ResolverDataProvider.createResolvedComponentInfo(i,
- resolvedForPersonalUser));
- }
- for (int i = 1; i < numberOfResults; i++) {
- infoList.add(ResolverDataProvider.createResolvedComponentInfo(i,
- resolvedForClonedUser));
- }
- return infoList;
- }
-
- private List<ResolvedComponentInfo> createResolvedComponentsForTestWithOtherProfile(
- int numberOfResults) {
- List<ResolvedComponentInfo> infoList = new ArrayList<>(numberOfResults);
- for (int i = 0; i < numberOfResults; i++) {
- if (i == 0) {
- infoList.add(ResolverDataProvider.createResolvedComponentInfoWithOtherId(i,
- PERSONAL_USER_HANDLE));
- } else {
- infoList.add(ResolverDataProvider.createResolvedComponentInfo(i,
- PERSONAL_USER_HANDLE));
- }
- }
- return infoList;
- }
-
- private List<ResolvedComponentInfo> createResolvedComponentsForTestWithOtherProfile(
- int numberOfResults, int userId) {
- List<ResolvedComponentInfo> infoList = new ArrayList<>(numberOfResults);
- for (int i = 0; i < numberOfResults; i++) {
- if (i == 0) {
- infoList.add(
- ResolverDataProvider.createResolvedComponentInfoWithOtherId(i, userId,
- PERSONAL_USER_HANDLE));
- } else {
- infoList.add(ResolverDataProvider.createResolvedComponentInfo(i,
- PERSONAL_USER_HANDLE));
- }
- }
- return infoList;
- }
-
- private List<ResolvedComponentInfo> createResolvedComponentsForTestWithUserId(
- int numberOfResults, int userId) {
- List<ResolvedComponentInfo> infoList = new ArrayList<>(numberOfResults);
- for (int i = 0; i < numberOfResults; i++) {
- infoList.add(ResolverDataProvider.createResolvedComponentInfoWithOtherId(i, userId,
- PERSONAL_USER_HANDLE));
- }
- return infoList;
- }
-
- private List<ChooserTarget> createDirectShareTargets(int numberOfResults, String packageName) {
- Icon icon = Icon.createWithBitmap(createBitmap());
- String testTitle = "testTitle";
- List<ChooserTarget> targets = new ArrayList<>();
- for (int i = 0; i < numberOfResults; i++) {
- ComponentName componentName;
- if (packageName.isEmpty()) {
- componentName = ResolverDataProvider.createComponentName(i);
- } else {
- componentName = new ComponentName(packageName, packageName + ".class");
- }
- ChooserTarget tempTarget = new ChooserTarget(
- testTitle + i,
- icon,
- (float) (1 - ((i + 1) / 10.0)),
- componentName,
- null);
- targets.add(tempTarget);
- }
- return targets;
- }
-
- private void waitForIdle() {
- InstrumentationRegistry.getInstrumentation().waitForIdleSync();
- }
-
- private Bitmap createBitmap() {
- return createBitmap(200, 200);
- }
-
- private Bitmap createWideBitmap() {
- WindowManager windowManager = InstrumentationRegistry.getInstrumentation()
- .getTargetContext()
- .getSystemService(WindowManager.class);
- int width = 3000;
- if (windowManager != null) {
- Rect bounds = windowManager.getMaximumWindowMetrics().getBounds();
- width = bounds.width() + 200;
- }
- return createBitmap(width, 100);
- }
-
- private Bitmap createBitmap(int width, int height) {
- Bitmap bitmap = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888);
- Canvas canvas = new Canvas(bitmap);
-
- Paint paint = new Paint();
- paint.setColor(Color.RED);
- paint.setStyle(Paint.Style.FILL);
- canvas.drawPaint(paint);
-
- paint.setColor(Color.WHITE);
- paint.setAntiAlias(true);
- paint.setTextSize(14.f);
- paint.setTextAlign(Paint.Align.CENTER);
- canvas.drawText("Hi!", (width / 2.f), (height / 2.f), paint);
-
- return bitmap;
- }
-
- private List<ShareShortcutInfo> createShortcuts(Context context) {
- Intent testIntent = new Intent("TestIntent");
-
- List<ShareShortcutInfo> shortcuts = new ArrayList<>();
- shortcuts.add(new ShareShortcutInfo(
- new ShortcutInfo.Builder(context, "shortcut1")
- .setIntent(testIntent).setShortLabel("label1").setRank(3).build(), // 0 2
- new ComponentName("package1", "class1")));
- shortcuts.add(new ShareShortcutInfo(
- new ShortcutInfo.Builder(context, "shortcut2")
- .setIntent(testIntent).setShortLabel("label2").setRank(7).build(), // 1 3
- new ComponentName("package2", "class2")));
- shortcuts.add(new ShareShortcutInfo(
- new ShortcutInfo.Builder(context, "shortcut3")
- .setIntent(testIntent).setShortLabel("label3").setRank(1).build(), // 2 0
- new ComponentName("package3", "class3")));
- shortcuts.add(new ShareShortcutInfo(
- new ShortcutInfo.Builder(context, "shortcut4")
- .setIntent(testIntent).setShortLabel("label4").setRank(3).build(), // 3 2
- new ComponentName("package4", "class4")));
-
- return shortcuts;
- }
-
- private void markWorkProfileUserAvailable() {
- ChooserActivityOverrideData.getInstance().workProfileUserHandle = UserHandle.of(10);
- }
-
- private void markCloneProfileUserAvailable() {
- ChooserActivityOverrideData.getInstance().cloneProfileUserHandle = UserHandle.of(11);
- }
-
- private void setupResolverControllers(
- List<ResolvedComponentInfo> personalResolvedComponentInfos) {
- setupResolverControllers(personalResolvedComponentInfos, new ArrayList<>());
- }
-
- private void setupResolverControllers(
- List<ResolvedComponentInfo> personalResolvedComponentInfos,
- List<ResolvedComponentInfo> workResolvedComponentInfos) {
- when(
- ChooserActivityOverrideData
- .getInstance()
- .resolverListController
- .getResolversForIntentAsUser(
- Mockito.anyBoolean(),
- 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));
- when(
- ChooserActivityOverrideData
- .getInstance()
- .workResolverListController
- .getResolversForIntentAsUser(
- Mockito.anyBoolean(),
- Mockito.anyBoolean(),
- Mockito.anyBoolean(),
- Mockito.isA(List.class),
- eq(UserHandle.of(10))))
- .thenReturn(new ArrayList<>(workResolvedComponentInfos));
- }
-
- private static GridRecyclerSpanCountMatcher withGridColumnCount(int columnCount) {
- return new GridRecyclerSpanCountMatcher(Matchers.is(columnCount));
- }
-
- private static class GridRecyclerSpanCountMatcher extends
- BoundedDiagnosingMatcher<View, RecyclerView> {
-
- private final Matcher<Integer> mIntegerMatcher;
-
- private GridRecyclerSpanCountMatcher(Matcher<Integer> integerMatcher) {
- super(RecyclerView.class);
- this.mIntegerMatcher = integerMatcher;
- }
-
- @Override
- protected void describeMoreTo(Description description) {
- description.appendText("RecyclerView grid layout span count to match: ");
- this.mIntegerMatcher.describeTo(description);
- }
-
- @Override
- protected boolean matchesSafely(RecyclerView view, Description mismatchDescription) {
- int spanCount = ((GridLayoutManager) view.getLayoutManager()).getSpanCount();
- if (this.mIntegerMatcher.matches(spanCount)) {
- return true;
- } else {
- mismatchDescription.appendText("RecyclerView grid layout span count was ")
- .appendValue(spanCount);
- return false;
- }
- }
- }
-
- private void givenAppTargets(int appCount) {
- List<ResolvedComponentInfo> resolvedComponentInfos =
- createResolvedComponentsForTest(appCount);
- setupResolverControllers(resolvedComponentInfos);
- }
-
- private void updateMaxTargetsPerRowResource(int targetsPerRow) {
- ChooserActivityOverrideData.getInstance().resources = Mockito.spy(
- InstrumentationRegistry.getInstrumentation().getContext().getResources());
- when(
- ChooserActivityOverrideData
- .getInstance()
- .resources
- .getInteger(R.integer.config_chooser_max_targets_per_row))
- .thenReturn(targetsPerRow);
- }
-
- private SparseArray<Pair<ShortcutLoader, Consumer<ShortcutLoader.Result>>>
- createShortcutLoaderFactory() {
- SparseArray<Pair<ShortcutLoader, Consumer<ShortcutLoader.Result>>> shortcutLoaders =
- new SparseArray<>();
- ChooserActivityOverrideData.getInstance().shortcutLoaderFactory =
- (userHandle, callback) -> {
- Pair<ShortcutLoader, Consumer<ShortcutLoader.Result>> pair =
- new Pair<>(mock(ShortcutLoader.class), callback);
- shortcutLoaders.put(userHandle.getIdentifier(), pair);
- return pair.first;
- };
- 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/java/tests/src/com/android/intentresolver/UnbundledChooserActivityWorkProfileTest.java
deleted file mode 100644
index 92bccb7d..00000000
--- a/java/tests/src/com/android/intentresolver/UnbundledChooserActivityWorkProfileTest.java
+++ /dev/null
@@ -1,473 +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.testing.PollingCheck.waitFor;
-
-import static androidx.test.espresso.Espresso.onView;
-import static androidx.test.espresso.action.ViewActions.click;
-import static androidx.test.espresso.action.ViewActions.swipeUp;
-import static androidx.test.espresso.assertion.ViewAssertions.matches;
-import static androidx.test.espresso.matcher.ViewMatchers.isDisplayed;
-import static androidx.test.espresso.matcher.ViewMatchers.isSelected;
-import static androidx.test.espresso.matcher.ViewMatchers.withId;
-import static androidx.test.espresso.matcher.ViewMatchers.withText;
-
-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;
-import static org.mockito.Mockito.when;
-
-import android.companion.DeviceFilter;
-import android.content.Intent;
-import android.os.UserHandle;
-
-import androidx.test.InstrumentationRegistry;
-import androidx.test.espresso.NoMatchingViewException;
-import androidx.test.rule.ActivityTestRule;
-
-import com.android.intentresolver.UnbundledChooserActivityWorkProfileTest.TestCase.Tab;
-
-import junit.framework.AssertionFailedError;
-
-import org.junit.Before;
-import org.junit.Rule;
-import org.junit.Test;
-import org.junit.runner.RunWith;
-import org.junit.runners.Parameterized;
-import org.mockito.Mockito;
-
-import java.util.ArrayList;
-import java.util.Arrays;
-import java.util.Collection;
-import java.util.List;
-
-@DeviceFilter.MediumType
-@RunWith(Parameterized.class)
-public class UnbundledChooserActivityWorkProfileTest {
-
- private static final UserHandle PERSONAL_USER_HANDLE = InstrumentationRegistry
- .getInstrumentation().getTargetContext().getUser();
- private static final UserHandle WORK_USER_HANDLE = UserHandle.of(10);
-
- @Rule
- public ActivityTestRule<ChooserWrapperActivity> mActivityRule =
- new ActivityTestRule<>(ChooserWrapperActivity.class, false,
- false);
- private final TestCase mTestCase;
-
- public UnbundledChooserActivityWorkProfileTest(TestCase testCase) {
- mTestCase = testCase;
- }
-
- @Before
- public void cleanOverrideData() {
- // 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();
-
- sOverrides.reset();
- }
-
- @Test
- public void testBlocker() {
- setUpPersonalAndWorkComponentInfos();
- sOverrides.hasCrossProfileIntents = mTestCase.hasCrossProfileIntents();
- sOverrides.tabOwnerUserHandleForLaunch = mTestCase.getMyUserHandle();
-
- launchActivity(mTestCase.getIsSendAction());
- switchToTab(mTestCase.getTab());
-
- switch (mTestCase.getExpectedBlocker()) {
- case NO_BLOCKER:
- assertNoBlockerDisplayed();
- break;
- case PERSONAL_PROFILE_SHARE_BLOCKER:
- assertCantSharePersonalAppsBlockerDisplayed();
- break;
- case WORK_PROFILE_SHARE_BLOCKER:
- assertCantShareWorkAppsBlockerDisplayed();
- break;
- case PERSONAL_PROFILE_ACCESS_BLOCKER:
- assertCantAccessPersonalAppsBlockerDisplayed();
- break;
- case WORK_PROFILE_ACCESS_BLOCKER:
- assertCantAccessWorkAppsBlockerDisplayed();
- break;
- }
- }
-
- @Parameterized.Parameters(name = "{0}")
- public static Collection tests() {
- return Arrays.asList(
- new TestCase(
- /* isSendAction= */ true,
- /* hasCrossProfileIntents= */ true,
- /* myUserHandle= */ WORK_USER_HANDLE,
- /* tab= */ WORK,
- /* expectedBlocker= */ NO_BLOCKER
- ),
- new TestCase(
- /* isSendAction= */ true,
- /* hasCrossProfileIntents= */ false,
- /* myUserHandle= */ WORK_USER_HANDLE,
- /* tab= */ WORK,
- /* expectedBlocker= */ NO_BLOCKER
- ),
- new TestCase(
- /* isSendAction= */ true,
- /* hasCrossProfileIntents= */ true,
- /* myUserHandle= */ PERSONAL_USER_HANDLE,
- /* tab= */ WORK,
- /* expectedBlocker= */ NO_BLOCKER
- ),
- new TestCase(
- /* isSendAction= */ true,
- /* hasCrossProfileIntents= */ false,
- /* myUserHandle= */ PERSONAL_USER_HANDLE,
- /* tab= */ WORK,
- /* expectedBlocker= */ WORK_PROFILE_SHARE_BLOCKER
- ),
- new TestCase(
- /* isSendAction= */ true,
- /* hasCrossProfileIntents= */ true,
- /* myUserHandle= */ WORK_USER_HANDLE,
- /* tab= */ PERSONAL,
- /* expectedBlocker= */ NO_BLOCKER
- ),
- new TestCase(
- /* isSendAction= */ true,
- /* hasCrossProfileIntents= */ false,
- /* myUserHandle= */ WORK_USER_HANDLE,
- /* tab= */ PERSONAL,
- /* expectedBlocker= */ PERSONAL_PROFILE_SHARE_BLOCKER
- ),
- new TestCase(
- /* isSendAction= */ true,
- /* hasCrossProfileIntents= */ true,
- /* myUserHandle= */ PERSONAL_USER_HANDLE,
- /* tab= */ PERSONAL,
- /* expectedBlocker= */ NO_BLOCKER
- ),
- new TestCase(
- /* isSendAction= */ true,
- /* hasCrossProfileIntents= */ false,
- /* myUserHandle= */ PERSONAL_USER_HANDLE,
- /* tab= */ PERSONAL,
- /* expectedBlocker= */ NO_BLOCKER
- ),
- new TestCase(
- /* isSendAction= */ false,
- /* hasCrossProfileIntents= */ true,
- /* myUserHandle= */ WORK_USER_HANDLE,
- /* tab= */ WORK,
- /* expectedBlocker= */ NO_BLOCKER
- ),
- new TestCase(
- /* isSendAction= */ false,
- /* hasCrossProfileIntents= */ false,
- /* myUserHandle= */ WORK_USER_HANDLE,
- /* tab= */ WORK,
- /* expectedBlocker= */ NO_BLOCKER
- ),
- new TestCase(
- /* isSendAction= */ false,
- /* hasCrossProfileIntents= */ true,
- /* myUserHandle= */ PERSONAL_USER_HANDLE,
- /* tab= */ WORK,
- /* expectedBlocker= */ NO_BLOCKER
- ),
- new TestCase(
- /* isSendAction= */ false,
- /* hasCrossProfileIntents= */ false,
- /* myUserHandle= */ PERSONAL_USER_HANDLE,
- /* tab= */ WORK,
- /* expectedBlocker= */ WORK_PROFILE_ACCESS_BLOCKER
- ),
- new TestCase(
- /* isSendAction= */ false,
- /* hasCrossProfileIntents= */ true,
- /* myUserHandle= */ WORK_USER_HANDLE,
- /* tab= */ PERSONAL,
- /* expectedBlocker= */ NO_BLOCKER
- ),
- new TestCase(
- /* isSendAction= */ false,
- /* hasCrossProfileIntents= */ false,
- /* myUserHandle= */ WORK_USER_HANDLE,
- /* tab= */ PERSONAL,
- /* expectedBlocker= */ PERSONAL_PROFILE_ACCESS_BLOCKER
- ),
- new TestCase(
- /* isSendAction= */ false,
- /* hasCrossProfileIntents= */ true,
- /* myUserHandle= */ PERSONAL_USER_HANDLE,
- /* tab= */ PERSONAL,
- /* expectedBlocker= */ NO_BLOCKER
- ),
- new TestCase(
- /* isSendAction= */ false,
- /* hasCrossProfileIntents= */ false,
- /* myUserHandle= */ PERSONAL_USER_HANDLE,
- /* tab= */ PERSONAL,
- /* expectedBlocker= */ NO_BLOCKER
- )
- );
- }
-
- private List<ResolvedComponentInfo> createResolvedComponentsForTestWithOtherProfile(
- int numberOfResults, int userId, UserHandle resolvedForUser) {
- List<ResolvedComponentInfo> infoList = new ArrayList<>(numberOfResults);
- for (int i = 0; i < numberOfResults; i++) {
- infoList.add(
- ResolverDataProvider
- .createResolvedComponentInfoWithOtherId(i, userId, resolvedForUser));
- }
- return infoList;
- }
-
- private List<ResolvedComponentInfo> createResolvedComponentsForTest(int numberOfResults,
- UserHandle resolvedForUser) {
- List<ResolvedComponentInfo> infoList = new ArrayList<>(numberOfResults);
- for (int i = 0; i < numberOfResults; i++) {
- infoList.add(ResolverDataProvider.createResolvedComponentInfo(i, resolvedForUser));
- }
- return infoList;
- }
-
- private void setUpPersonalAndWorkComponentInfos() {
- markWorkProfileUserAvailable();
- int workProfileTargets = 4;
- List<ResolvedComponentInfo> personalResolvedComponentInfos =
- createResolvedComponentsForTestWithOtherProfile(3,
- /* userId */ WORK_USER_HANDLE.getIdentifier(), PERSONAL_USER_HANDLE);
- List<ResolvedComponentInfo> workResolvedComponentInfos =
- createResolvedComponentsForTest(workProfileTargets, WORK_USER_HANDLE);
- setupResolverControllers(personalResolvedComponentInfos, workResolvedComponentInfos);
- }
-
- private void setupResolverControllers(
- List<ResolvedComponentInfo> personalResolvedComponentInfos,
- List<ResolvedComponentInfo> workResolvedComponentInfos) {
- when(sOverrides.resolverListController.getResolversForIntentAsUser(
- Mockito.anyBoolean(),
- 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)))
- .thenReturn(new ArrayList<>(personalResolvedComponentInfos));
- when(sOverrides.workResolverListController.getResolversForIntentAsUser(
- Mockito.anyBoolean(),
- Mockito.anyBoolean(),
- Mockito.anyBoolean(),
- Mockito.isA(List.class),
- eq(WORK_USER_HANDLE)))
- .thenReturn(new ArrayList<>(workResolvedComponentInfos));
- }
-
- private void waitForIdle() {
- 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()));
- onView(withText(R.string.resolver_cant_access_work_apps_explanation))
- .check(matches(isDisplayed()));
- }
-
- private void assertCantAccessPersonalAppsBlockerDisplayed() {
- onView(withText(R.string.resolver_cross_profile_blocked))
- .check(matches(isDisplayed()));
- onView(withText(R.string.resolver_cant_access_personal_apps_explanation))
- .check(matches(isDisplayed()));
- }
-
- private void assertCantShareWorkAppsBlockerDisplayed() {
- onView(withText(R.string.resolver_cross_profile_blocked))
- .check(matches(isDisplayed()));
- onView(withText(R.string.resolver_cant_share_with_work_apps_explanation))
- .check(matches(isDisplayed()));
- }
-
- private void assertCantSharePersonalAppsBlockerDisplayed() {
- onView(withText(R.string.resolver_cross_profile_blocked))
- .check(matches(isDisplayed()));
- onView(withText(R.string.resolver_cant_share_with_personal_apps_explanation))
- .check(matches(isDisplayed()));
- }
-
- private void assertNoBlockerDisplayed() {
- try {
- onView(withText(R.string.resolver_cross_profile_blocked))
- .check(matches(not(isDisplayed())));
- } catch (NoMatchingViewException ignored) {
- }
- }
-
- private void switchToTab(Tab tab) {
- final int stringId = tab == Tab.WORK ? R.string.resolver_work_tab
- : R.string.resolver_personal_tab;
-
- waitFor(() -> {
- onView(withText(stringId)).perform(click());
- waitForIdle();
-
- try {
- onView(withText(stringId)).check(matches(isSelected()));
- return true;
- } catch (AssertionFailedError e) {
- return false;
- }
- });
-
- onView(withId(com.android.internal.R.id.contentPanel))
- .perform(swipeUp());
- waitForIdle();
- }
-
- private Intent createTextIntent(boolean isSendAction) {
- Intent sendIntent = new Intent();
- if (isSendAction) {
- sendIntent.setAction(Intent.ACTION_SEND);
- }
- sendIntent.putExtra(Intent.EXTRA_TEXT, "testing intent sending");
- sendIntent.setType("text/plain");
- return sendIntent;
- }
-
- private void launchActivity(boolean isSendAction) {
- Intent sendIntent = createTextIntent(isSendAction);
- mActivityRule.launchActivity(Intent.createChooser(sendIntent, "Test"));
- waitForIdle();
- }
-
- public static class TestCase {
- private final boolean mIsSendAction;
- private final boolean mHasCrossProfileIntents;
- private final UserHandle mMyUserHandle;
- private final Tab mTab;
- private final ExpectedBlocker mExpectedBlocker;
-
- public enum ExpectedBlocker {
- NO_BLOCKER,
- PERSONAL_PROFILE_SHARE_BLOCKER,
- WORK_PROFILE_SHARE_BLOCKER,
- PERSONAL_PROFILE_ACCESS_BLOCKER,
- WORK_PROFILE_ACCESS_BLOCKER
- }
-
- public enum Tab {
- WORK,
- PERSONAL
- }
-
- public TestCase(boolean isSendAction, boolean hasCrossProfileIntents,
- UserHandle myUserHandle, Tab tab, ExpectedBlocker expectedBlocker) {
- mIsSendAction = isSendAction;
- mHasCrossProfileIntents = hasCrossProfileIntents;
- mMyUserHandle = myUserHandle;
- mTab = tab;
- mExpectedBlocker = expectedBlocker;
- }
-
- public boolean getIsSendAction() {
- return mIsSendAction;
- }
-
- public boolean hasCrossProfileIntents() {
- return mHasCrossProfileIntents;
- }
-
- public UserHandle getMyUserHandle() {
- return mMyUserHandle;
- }
-
- public Tab getTab() {
- return mTab;
- }
-
- public ExpectedBlocker getExpectedBlocker() {
- return mExpectedBlocker;
- }
-
- @Override
- public String toString() {
- StringBuilder result = new StringBuilder("test");
-
- if (mTab == WORK) {
- result.append("WorkTab_");
- } else {
- result.append("PersonalTab_");
- }
-
- if (mIsSendAction) {
- result.append("sendAction_");
- } else {
- result.append("notSendAction_");
- }
-
- if (mHasCrossProfileIntents) {
- result.append("hasCrossProfileIntents_");
- } else {
- result.append("doesNotHaveCrossProfileIntents_");
- }
-
- if (mMyUserHandle.equals(PERSONAL_USER_HANDLE)) {
- result.append("myUserIsPersonal_");
- } else {
- result.append("myUserIsWork_");
- }
-
- if (mExpectedBlocker == ExpectedBlocker.NO_BLOCKER) {
- result.append("thenNoBlocker");
- } else if (mExpectedBlocker == PERSONAL_PROFILE_ACCESS_BLOCKER) {
- result.append("thenAccessBlockerOnPersonalProfile");
- } else if (mExpectedBlocker == PERSONAL_PROFILE_SHARE_BLOCKER) {
- result.append("thenShareBlockerOnPersonalProfile");
- } else if (mExpectedBlocker == WORK_PROFILE_ACCESS_BLOCKER) {
- result.append("thenAccessBlockerOnWorkProfile");
- } else if (mExpectedBlocker == WORK_PROFILE_SHARE_BLOCKER) {
- result.append("thenShareBlockerOnWorkProfile");
- }
-
- return result.toString();
- }
- }
-}
diff --git a/java/tests/src/com/android/intentresolver/chooser/ImmutableTargetInfoTest.kt b/java/tests/src/com/android/intentresolver/chooser/ImmutableTargetInfoTest.kt
deleted file mode 100644
index f3ca76a9..00000000
--- a/java/tests/src/com/android/intentresolver/chooser/ImmutableTargetInfoTest.kt
+++ /dev/null
@@ -1,503 +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
- *3
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS 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.Activity
-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 com.android.intentresolver.ResolverActivity
-import com.android.intentresolver.ResolverDataProvider
-import com.google.common.truth.Truth.assertThat
-import org.junit.Test
-import androidx.test.platform.app.InstrumentationRegistry
-
-class ImmutableTargetInfoTest {
- private val PERSONAL_USER_HANDLE: UserHandle = InstrumentationRegistry
- .getInstrumentation().getTargetContext().getUser()
-
- private val resolvedIntent = Intent("resolved")
- private val targetIntent = Intent("target")
- private val referrerFillInIntent = Intent("referrer_fillin")
- private val resolvedComponentName = ComponentName("resolved", "component")
- private val chooserTargetComponentName = ComponentName("chooser", "target")
- private val resolveInfo = ResolverDataProvider.createResolveInfo(1, 0, PERSONAL_USER_HANDLE)
- private val displayLabel: CharSequence = "Display Label"
- private val extendedInfo: CharSequence = "Extended Info"
- 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 hashProvider: ImmutableTargetInfo.TargetHashProvider = mock()
-
- @Test
- 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()
-
- assertThat(info.resolvedIntent).isEqualTo(resolvedIntent)
- assertThat(info.targetIntent).isEqualTo(targetIntent)
- assertThat(info.referrerFillInIntent).isEqualTo(referrerFillInIntent)
- assertThat(info.resolvedComponentName).isEqualTo(resolvedComponentName)
- assertThat(info.chooserTargetComponentName).isEqualTo(chooserTargetComponentName)
- assertThat(info.resolveInfo).isEqualTo(resolveInfo)
- assertThat(info.displayLabel).isEqualTo(displayLabel)
- assertThat(info.extendedInfo).isEqualTo(extendedInfo)
- assertThat(info.displayIconHolder).isEqualTo(displayIconHolder)
- assertThat(info.allSourceIntents).containsExactly(
- resolvedIntent, sourceIntent1, sourceIntent2)
- assertThat(info.allDisplayTargets).containsExactly(displayTarget1, displayTarget2)
- assertThat(info.isSuspended).isTrue()
- assertThat(info.isPinned).isTrue()
- assertThat(info.modifiedScore).isEqualTo(42.0f)
- assertThat(info.directShareShortcutInfo).isEqualTo(directShareShortcutInfo)
- assertThat(info.directShareAppTarget).isEqualTo(directShareAppTarget)
- assertThat(info.displayResolveInfo).isEqualTo(displayResolveInfo)
- assertThat(info.isEmptyTargetInfo).isFalse()
- assertThat(info.isPlaceHolderTargetInfo).isFalse()
- assertThat(info.isNotSelectableTargetInfo).isFalse()
- assertThat(info.isSelectableTargetInfo).isFalse()
- assertThat(info.isChooserTargetInfo).isFalse()
- assertThat(info.isMultiDisplayResolveInfo).isFalse()
- assertThat(info.isDisplayResolveInfo).isFalse()
- assertThat(info.hashProvider).isEqualTo(hashProvider)
- }
-
- @Test
- 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 info = infoToCopyFrom.toBuilder().build()
-
- assertThat(info.resolvedIntent).isEqualTo(resolvedIntent)
- assertThat(info.targetIntent).isEqualTo(targetIntent)
- assertThat(info.referrerFillInIntent).isEqualTo(referrerFillInIntent)
- assertThat(info.resolvedComponentName).isEqualTo(resolvedComponentName)
- assertThat(info.chooserTargetComponentName).isEqualTo(chooserTargetComponentName)
- assertThat(info.resolveInfo).isEqualTo(resolveInfo)
- assertThat(info.displayLabel).isEqualTo(displayLabel)
- assertThat(info.extendedInfo).isEqualTo(extendedInfo)
- assertThat(info.displayIconHolder).isEqualTo(displayIconHolder)
- assertThat(info.allSourceIntents).containsExactly(
- resolvedIntent, sourceIntent1, sourceIntent2)
- assertThat(info.allDisplayTargets).containsExactly(displayTarget1, displayTarget2)
- assertThat(info.isSuspended).isTrue()
- assertThat(info.isPinned).isTrue()
- assertThat(info.modifiedScore).isEqualTo(42.0f)
- assertThat(info.directShareShortcutInfo).isEqualTo(directShareShortcutInfo)
- assertThat(info.directShareAppTarget).isEqualTo(directShareAppTarget)
- assertThat(info.displayResolveInfo).isEqualTo(displayResolveInfo)
- assertThat(info.isEmptyTargetInfo).isFalse()
- assertThat(info.isPlaceHolderTargetInfo).isFalse()
- assertThat(info.isNotSelectableTargetInfo).isFalse()
- assertThat(info.isSelectableTargetInfo).isFalse()
- assertThat(info.isChooserTargetInfo).isFalse()
- assertThat(info.isMultiDisplayResolveInfo).isFalse()
- assertThat(info.isDisplayResolveInfo).isFalse()
- assertThat(info.hashProvider).isEqualTo(hashProvider)
- }
-
- @Test
- fun testBaseIntentToSend_defaultsToResolvedIntent() {
- val info = ImmutableTargetInfo.newBuilder().setResolvedIntent(resolvedIntent).build()
- assertThat(info.baseIntentToSend.filterEquals(resolvedIntent)).isTrue()
- }
-
- @Test
- fun testBaseIntentToSend_fillsInFromReferrerIntent() {
- val originalIntent = Intent()
- originalIntent.setPackage("original")
-
- val referrerFillInIntent = Intent("REFERRER_FILL_IN")
- referrerFillInIntent.setPackage("referrer")
-
- val info = ImmutableTargetInfo.newBuilder()
- .setResolvedIntent(originalIntent)
- .setReferrerFillInIntent(referrerFillInIntent)
- .build()
-
- assertThat(info.baseIntentToSend.getPackage()).isEqualTo("original") // Only fill if empty.
- assertThat(info.baseIntentToSend.action).isEqualTo("REFERRER_FILL_IN")
- }
-
- @Test
- fun testBaseIntentToSend_fillsInFromRefinementIntent() {
- val originalIntent = Intent()
- originalIntent.putExtra("ORIGINAL", true)
-
- val refinementIntent = Intent()
- refinementIntent.putExtra("REFINEMENT", true)
-
- 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()
- }
-
- @Test
- fun testBaseIntentToSend_twoFillInSourcesFavorsRefinementRequest() {
- val originalIntent = Intent("REFINE_ME")
- originalIntent.setPackage("original")
-
- val referrerFillInIntent = Intent("REFERRER_FILL_IN")
- referrerFillInIntent.setPackage("referrer_pkg")
- referrerFillInIntent.setType("test/referrer")
-
- val infoWithReferrerFillIn = ImmutableTargetInfo.newBuilder()
- .setResolvedIntent(originalIntent)
- .setReferrerFillInIntent(referrerFillInIntent)
- .build()
-
- val refinementIntent = Intent("REFINE_ME")
- refinementIntent.setPackage("original") // Has to match for refinement.
-
- 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.
- }
-
- @Test
- fun testBaseIntentToSend_doubleRefinementPreservesReferrerFillInButNotOriginalRefinement() {
- val originalIntent = Intent("REFINE_ME")
- val referrerFillInIntent = Intent("REFERRER_FILL_IN")
- referrerFillInIntent.putExtra("TEST", "REFERRER")
- val refinementIntent1 = Intent("REFINE_ME")
- refinementIntent1.putExtra("TEST1", "1")
- val refinementIntent2 = Intent("REFINE_ME")
- refinementIntent2.putExtra("TEST2", "2")
-
- val originalInfo = ImmutableTargetInfo.newBuilder()
- .setResolvedIntent(originalIntent)
- .setReferrerFillInIntent(referrerFillInIntent)
- .build()
-
- 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")
- // 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")
- // 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()
- }
-
- @Test
- fun testBaseIntentToSend_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 = ImmutableTargetInfo.newBuilder()
- .setResolvedIntent(originalIntent)
- .setAllSourceIntents(listOf(
- originalIntent, mismatchedAlternate, targetAlternate, extraMatch))
- .build()
-
- val refinement = Intent("REFINE_ME") // First match is `targetAlternate`
- refinement.putExtra("refinement", true)
-
- 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))
- .isFalse()
- assertThat(refinedResult?.baseIntentToSend?.getBooleanExtra("mismatchedAlternate", false))
- .isFalse()
- assertThat(refinedResult?.baseIntentToSend?.getBooleanExtra("extraMatch", false)).isFalse()
- }
-
- @Test
- fun testBaseIntentToSend_noSourceIntentMatchingProposedRefinement() {
- val originalIntent = Intent("DONT_REFINE_ME")
- originalIntent.putExtra("originalIntent", true)
- val mismatchedAlternate = Intent("DOESNT_MATCH")
- mismatchedAlternate.putExtra("mismatchedAlternate", true)
-
- val originalInfo = ImmutableTargetInfo.newBuilder()
- .setResolvedIntent(originalIntent)
- .setAllSourceIntents(listOf(originalIntent, mismatchedAlternate))
- .build()
-
- val refinement = Intent("PROPOSED_REFINEMENT")
- assertThat(originalInfo.tryToCloneWithAppliedRefinement(refinement)).isNull()
- }
-
- @Test
- fun testLegacySubclassRelationships_empty() {
- val info = ImmutableTargetInfo.newBuilder()
- .setLegacyType(ImmutableTargetInfo.LegacyTargetType.EMPTY_TARGET_INFO)
- .build()
-
- assertThat(info.isEmptyTargetInfo).isTrue()
- assertThat(info.isPlaceHolderTargetInfo).isFalse()
- assertThat(info.isNotSelectableTargetInfo).isTrue()
- assertThat(info.isSelectableTargetInfo).isFalse()
- assertThat(info.isChooserTargetInfo).isTrue()
- assertThat(info.isMultiDisplayResolveInfo).isFalse()
- assertThat(info.isDisplayResolveInfo).isFalse()
- }
-
- @Test
- fun testLegacySubclassRelationships_placeholder() {
- val info = ImmutableTargetInfo.newBuilder()
- .setLegacyType(ImmutableTargetInfo.LegacyTargetType.PLACEHOLDER_TARGET_INFO)
- .build()
-
- assertThat(info.isEmptyTargetInfo).isFalse()
- assertThat(info.isPlaceHolderTargetInfo).isTrue()
- assertThat(info.isNotSelectableTargetInfo).isTrue()
- assertThat(info.isSelectableTargetInfo).isFalse()
- assertThat(info.isChooserTargetInfo).isTrue()
- assertThat(info.isMultiDisplayResolveInfo).isFalse()
- assertThat(info.isDisplayResolveInfo).isFalse()
- }
-
- @Test
- fun testLegacySubclassRelationships_selectable() {
- val info = ImmutableTargetInfo.newBuilder()
- .setLegacyType(ImmutableTargetInfo.LegacyTargetType.SELECTABLE_TARGET_INFO)
- .build()
-
- assertThat(info.isEmptyTargetInfo).isFalse()
- assertThat(info.isPlaceHolderTargetInfo).isFalse()
- assertThat(info.isNotSelectableTargetInfo).isFalse()
- assertThat(info.isSelectableTargetInfo).isTrue()
- assertThat(info.isChooserTargetInfo).isTrue()
- assertThat(info.isMultiDisplayResolveInfo).isFalse()
- assertThat(info.isDisplayResolveInfo).isFalse()
- }
-
- @Test
- fun testLegacySubclassRelationships_displayResolveInfo() {
- val info = ImmutableTargetInfo.newBuilder()
- .setLegacyType(ImmutableTargetInfo.LegacyTargetType.DISPLAY_RESOLVE_INFO)
- .build()
-
- assertThat(info.isEmptyTargetInfo).isFalse()
- assertThat(info.isPlaceHolderTargetInfo).isFalse()
- assertThat(info.isNotSelectableTargetInfo).isFalse()
- assertThat(info.isSelectableTargetInfo).isFalse()
- assertThat(info.isChooserTargetInfo).isFalse()
- assertThat(info.isMultiDisplayResolveInfo).isFalse()
- assertThat(info.isDisplayResolveInfo).isTrue()
- }
-
- @Test
- fun testLegacySubclassRelationships_multiDisplayResolveInfo() {
- val info = ImmutableTargetInfo.newBuilder()
- .setLegacyType(ImmutableTargetInfo.LegacyTargetType.MULTI_DISPLAY_RESOLVE_INFO)
- .build()
-
- assertThat(info.isEmptyTargetInfo).isFalse()
- assertThat(info.isPlaceHolderTargetInfo).isFalse()
- assertThat(info.isNotSelectableTargetInfo).isFalse()
- assertThat(info.isSelectableTargetInfo).isFalse()
- assertThat(info.isChooserTargetInfo).isFalse()
- assertThat(info.isMultiDisplayResolveInfo).isTrue()
- assertThat(info.isDisplayResolveInfo).isTrue()
- }
-
- @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 info = ImmutableTargetInfo.newBuilder().setActivityStarter(activityStarter).build()
- val activity: ResolverActivity = mock()
- val options = Bundle()
- options.putInt("TEST_KEY", 1)
-
- info.startAsCaller(activity, options, 42)
-
- assertThat(activityStarter.totalInvocations).isEqualTo(1)
- assertThat(activityStarter.lastInvocationTargetInfo).isEqualTo(info)
- assertThat(activityStarter.lastInvocationActivity).isEqualTo(activity)
- assertThat(activityStarter.lastInvocationOptions).isEqualTo(options)
- assertThat(activityStarter.lastInvocationUserId).isEqualTo(42)
- assertThat(activityStarter.lastInvocationAsCaller).isTrue()
- }
-
- @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 info = ImmutableTargetInfo.newBuilder().setActivityStarter(activityStarter).build()
- val activity: Activity = mock()
- val options = Bundle()
- options.putInt("TEST_KEY", 1)
-
- info.startAsUser(activity, options, UserHandle.of(42))
-
- assertThat(activityStarter.totalInvocations).isEqualTo(1)
- assertThat(activityStarter.lastInvocationTargetInfo).isEqualTo(info)
- assertThat(activityStarter.lastInvocationActivity).isEqualTo(activity)
- assertThat(activityStarter.lastInvocationOptions).isEqualTo(options)
- assertThat(activityStarter.lastInvocationUserId).isEqualTo(42)
- assertThat(activityStarter.lastInvocationAsCaller).isFalse()
- }
-
- @Test
- fun testActivityStarter_invokedWithRespectiveTargetInfoAfterCopy() {
- val activityStarter = TestActivityStarter()
- val info1 = ImmutableTargetInfo.newBuilder().setActivityStarter(activityStarter).build()
- val info2 = info1.toBuilder().build()
-
- info1.startAsCaller(mock(), Bundle(), 42)
- assertThat(activityStarter.lastInvocationTargetInfo).isEqualTo(info1)
- info2.startAsCaller(mock(), Bundle(), 42)
- assertThat(activityStarter.lastInvocationTargetInfo).isEqualTo(info2)
- info2.startAsUser(mock(), Bundle(), UserHandle.of(42))
- assertThat(activityStarter.lastInvocationTargetInfo).isEqualTo(info2)
-
- assertThat(activityStarter.totalInvocations).isEqualTo(3) // Instance is still shared.
- }
-}
-
-private open class TestActivityStarter : ImmutableTargetInfo.TargetActivityStarter {
- var totalInvocations = 0
- var lastInvocationTargetInfo: TargetInfo? = null
- var lastInvocationActivity: Activity? = null
- var lastInvocationOptions: Bundle? = null
- var lastInvocationUserId: Integer? = null
- var lastInvocationAsCaller = false
-
- override fun startAsCaller(
- target: TargetInfo, activity: Activity, options: Bundle, userId: Int): Boolean {
- ++totalInvocations
- lastInvocationTargetInfo = target
- lastInvocationActivity = activity
- lastInvocationOptions = options
- lastInvocationUserId = Integer(userId)
- lastInvocationAsCaller = true
- return true
- }
-
- override fun startAsUser(
- target: TargetInfo, activity: Activity, options: Bundle, user: UserHandle): Boolean {
- ++totalInvocations
- lastInvocationTargetInfo = target
- lastInvocationActivity = activity
- lastInvocationOptions = options
- lastInvocationUserId = Integer(user.identifier)
- lastInvocationAsCaller = false
- return true
- }
-}
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 78e0c3ee..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 = 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,
- 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/ChooserContentPreviewUiTest.kt b/java/tests/src/com/android/intentresolver/contentpreview/ChooserContentPreviewUiTest.kt
deleted file mode 100644
index 9bfd2052..00000000
--- a/java/tests/src/com/android/intentresolver/contentpreview/ChooserContentPreviewUiTest.kt
+++ /dev/null
@@ -1,146 +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.Intent
-import android.graphics.Bitmap
-import android.net.Uri
-import androidx.lifecycle.Lifecycle
-import com.android.intentresolver.any
-import com.android.intentresolver.contentpreview.ChooserContentPreviewUi.ActionFactory
-import com.android.intentresolver.mock
-import com.android.intentresolver.whenever
-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 org.junit.Test
-import org.mockito.Mockito.never
-import org.mockito.Mockito.times
-import org.mockito.Mockito.verify
-
-class ChooserContentPreviewUiTest {
- private val lifecycle = mock<Lifecycle>()
- 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 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>()
-
- @Test
- fun test_textPreviewType_useTextPreviewUi() {
- whenever(previewData.previewType).thenReturn(ContentPreviewType.CONTENT_PREVIEW_TEXT)
- val testSubject =
- ChooserContentPreviewUi(
- lifecycle,
- previewData,
- Intent(Intent.ACTION_VIEW),
- imageLoader,
- actionFactory,
- transitionCallback,
- headlineGenerator,
- )
- assertThat(testSubject.preferredContentPreview)
- .isEqualTo(ContentPreviewType.CONTENT_PREVIEW_TEXT)
- assertThat(testSubject.mContentPreviewUi).isInstanceOf(TextContentPreviewUi::class.java)
- verify(transitionCallback, times(1)).onAllTransitionElementsReady()
- }
-
- @Test
- fun test_filePreviewType_useFilePreviewUi() {
- whenever(previewData.previewType).thenReturn(ContentPreviewType.CONTENT_PREVIEW_FILE)
- val testSubject =
- ChooserContentPreviewUi(
- lifecycle,
- previewData,
- Intent(Intent.ACTION_SEND),
- imageLoader,
- actionFactory,
- transitionCallback,
- headlineGenerator,
- )
- assertThat(testSubject.preferredContentPreview)
- .isEqualTo(ContentPreviewType.CONTENT_PREVIEW_FILE)
- assertThat(testSubject.mContentPreviewUi).isInstanceOf(FileContentPreviewUi::class.java)
- verify(transitionCallback, times(1)).onAllTransitionElementsReady()
- }
-
- @Test
- fun test_imagePreviewTypeWithText_useFilePlusTextPreviewUi() {
- val uri = Uri.parse("content://org.pkg.app/img.png")
- whenever(previewData.previewType).thenReturn(ContentPreviewType.CONTENT_PREVIEW_IMAGE)
- whenever(previewData.uriCount).thenReturn(2)
- whenever(previewData.firstFileInfo)
- .thenReturn(FileInfo.Builder(uri).withPreviewUri(uri).withMimeType("image/png").build())
- val testSubject =
- ChooserContentPreviewUi(
- lifecycle,
- previewData,
- Intent(Intent.ACTION_SEND).apply { putExtra(Intent.EXTRA_TEXT, "Shared text") },
- imageLoader,
- actionFactory,
- transitionCallback,
- headlineGenerator,
- )
- assertThat(testSubject.mContentPreviewUi)
- .isInstanceOf(FilesPlusTextContentPreviewUi::class.java)
- verify(previewData, times(1)).getFileMetadataForImagePreview(any(), any())
- verify(transitionCallback, times(1)).onAllTransitionElementsReady()
- }
-
- @Test
- fun test_imagePreviewTypeWithoutText_useImagePreviewUi() {
- val uri = Uri.parse("content://org.pkg.app/img.png")
- whenever(previewData.previewType).thenReturn(ContentPreviewType.CONTENT_PREVIEW_IMAGE)
- whenever(previewData.uriCount).thenReturn(2)
- whenever(previewData.firstFileInfo)
- .thenReturn(FileInfo.Builder(uri).withPreviewUri(uri).withMimeType("image/png").build())
- val testSubject =
- ChooserContentPreviewUi(
- lifecycle,
- previewData,
- Intent(Intent.ACTION_SEND),
- imageLoader,
- actionFactory,
- transitionCallback,
- headlineGenerator,
- )
- assertThat(testSubject.preferredContentPreview)
- .isEqualTo(ContentPreviewType.CONTENT_PREVIEW_IMAGE)
- assertThat(testSubject.mContentPreviewUi).isInstanceOf(UnifiedContentPreviewUi::class.java)
- verify(previewData, times(1)).getFileMetadataForImagePreview(any(), any())
- verify(transitionCallback, never()).onAllTransitionElementsReady()
- }
-}
diff --git a/java/tests/src/com/android/intentresolver/contentpreview/ContentPreviewUiTest.kt b/java/tests/src/com/android/intentresolver/contentpreview/ContentPreviewUiTest.kt
deleted file mode 100644
index 6db53a9e..00000000
--- a/java/tests/src/com/android/intentresolver/contentpreview/ContentPreviewUiTest.kt
+++ /dev/null
@@ -1,41 +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 com.android.intentresolver.widget.ScrollableImagePreviewView.PreviewType
-import com.google.common.truth.Truth.assertThat
-import org.junit.Test
-
-class ContentPreviewUiTest {
- @Test
- fun testPreviewTypes() {
- val typeClassifier =
- object : MimeTypeClassifier {
- override fun isImageType(type: String?) = (type == "image")
- override fun isVideoType(type: String?) = (type == "video")
- }
-
- assertThat(ContentPreviewUi.getPreviewType(typeClassifier, "image"))
- .isEqualTo(PreviewType.Image)
- assertThat(ContentPreviewUi.getPreviewType(typeClassifier, "video"))
- .isEqualTo(PreviewType.Video)
- assertThat(ContentPreviewUi.getPreviewType(typeClassifier, "other"))
- .isEqualTo(PreviewType.File)
- assertThat(ContentPreviewUi.getPreviewType(typeClassifier, null))
- .isEqualTo(PreviewType.File)
- }
-}
diff --git a/java/tests/src/com/android/intentresolver/contentpreview/HeadlineGeneratorImplTest.kt b/java/tests/src/com/android/intentresolver/contentpreview/HeadlineGeneratorImplTest.kt
deleted file mode 100644
index a65280e5..00000000
--- a/java/tests/src/com/android/intentresolver/contentpreview/HeadlineGeneratorImplTest.kt
+++ /dev/null
@@ -1,61 +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 androidx.test.ext.junit.runners.AndroidJUnit4
-import androidx.test.platform.app.InstrumentationRegistry
-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"
-
- assertThat(generator.getTextHeadline(str)).isEqualTo("Sharing text")
- assertThat(generator.getTextHeadline(url)).isEqualTo("Sharing link")
-
- 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.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.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.getImagesHeadline(1)).isEqualTo("Sharing image")
- assertThat(generator.getImagesHeadline(4)).isEqualTo("Sharing 4 images")
-
- assertThat(generator.getVideosHeadline(1)).isEqualTo("Sharing video")
- assertThat(generator.getVideosHeadline(4)).isEqualTo("Sharing 4 videos")
-
- assertThat(generator.getFilesHeadline(1)).isEqualTo("Sharing 1 file")
- assertThat(generator.getFilesHeadline(4)).isEqualTo("Sharing 4 files")
- }
-}
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 6e57c289..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 com.android.intentresolver.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.state = Lifecycle.State.CREATED
- // 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.state = Lifecycle.State.DESTROYED
- 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.state = Lifecycle.State.DESTROYED
- 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.state = Lifecycle.State.DESTROYED
- 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 145b89ad..00000000
--- a/java/tests/src/com/android/intentresolver/contentpreview/PreviewDataProviderTest.kt
+++ /dev/null
@@ -1,351 +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 androidx.lifecycle.Lifecycle
-import com.android.intentresolver.TestLifecycleOwner
-import com.android.intentresolver.mock
-import com.android.intentresolver.whenever
-import com.google.common.truth.Truth.assertThat
-import kotlinx.coroutines.Dispatchers
-import kotlinx.coroutines.ExperimentalCoroutinesApi
-import kotlinx.coroutines.test.UnconfinedTestDispatcher
-import kotlinx.coroutines.test.resetMain
-import kotlinx.coroutines.test.setMain
-import org.junit.After
-import org.junit.Before
-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 lifecycleOwner = TestLifecycleOwner()
- private val dispatcher = UnconfinedTestDispatcher()
-
- @Before
- fun setup() {
- Dispatchers.setMain(dispatcher)
- lifecycleOwner.state = Lifecycle.State.CREATED
- }
-
- @After
- fun cleanup() {
- lifecycleOwner.state = Lifecycle.State.DESTROYED
- Dispatchers.resetMain()
- }
-
- @Test
- fun test_nonSendIntentAction_resolvesToTextPreviewUiSynchronously() {
- val targetIntent = Intent(Intent.ACTION_VIEW)
- val testSubject =
- PreviewDataProvider(targetIntent, contentResolver, mimeTypeClassifier, dispatcher)
-
- 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(targetIntent, contentResolver, mimeTypeClassifier, dispatcher)
-
- 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(targetIntent, contentResolver, mimeTypeClassifier, dispatcher)
-
- 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(targetIntent, contentResolver, mimeTypeClassifier, dispatcher)
-
- 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(targetIntent, contentResolver, mimeTypeClassifier, dispatcher)
-
- 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(targetIntent, contentResolver, mimeTypeClassifier, dispatcher)
-
- 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(targetIntent, contentResolver, mimeTypeClassifier, dispatcher)
-
- 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(targetIntent, contentResolver, mimeTypeClassifier, dispatcher)
-
- 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(targetIntent, contentResolver, mimeTypeClassifier, dispatcher)
-
- 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(targetIntent, contentResolver, mimeTypeClassifier, dispatcher)
-
- 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(targetIntent, contentResolver, mimeTypeClassifier, dispatcher)
-
- 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(targetIntent, contentResolver, mimeTypeClassifier, dispatcher)
-
- 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(targetIntent, contentResolver, mimeTypeClassifier, dispatcher)
-
- 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())
- }
-}
diff --git a/java/tests/src/com/android/intentresolver/model/AbstractResolverComparatorTest.java b/java/tests/src/com/android/intentresolver/model/AbstractResolverComparatorTest.java
deleted file mode 100644
index 5f0ead7b..00000000
--- a/java/tests/src/com/android/intentresolver/model/AbstractResolverComparatorTest.java
+++ /dev/null
@@ -1,141 +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.model;
-
-import static junit.framework.Assert.assertEquals;
-
-import android.content.ComponentName;
-import android.content.Context;
-import android.content.Intent;
-import android.content.pm.ActivityInfo;
-import android.content.pm.ResolveInfo;
-import android.os.Message;
-
-import androidx.test.InstrumentationRegistry;
-
-import com.android.intentresolver.ResolvedComponentInfo;
-import com.android.intentresolver.chooser.TargetInfo;
-
-import com.google.android.collect.Lists;
-
-import org.junit.Test;
-
-import java.util.List;
-
-public class AbstractResolverComparatorTest {
-
- @Test
- public void testPinned() {
- ResolvedComponentInfo r1 = createResolvedComponentInfo(
- new ComponentName("package", "class"));
- r1.setPinned(true);
-
- ResolvedComponentInfo r2 = createResolvedComponentInfo(
- new ComponentName("zackage", "zlass"));
-
- Context context = InstrumentationRegistry.getTargetContext();
- AbstractResolverComparator comparator = getTestComparator(context, null);
-
- assertEquals("Pinned ranks over unpinned", -1, comparator.compare(r1, r2));
- assertEquals("Unpinned ranks under pinned", 1, comparator.compare(r2, r1));
- }
-
- @Test
- public void testBothPinned() {
- ResolvedComponentInfo r1 = createResolvedComponentInfo(
- new ComponentName("package", "class"));
- r1.setPinned(true);
-
- ResolvedComponentInfo r2 = createResolvedComponentInfo(
- new ComponentName("zackage", "zlass"));
- r2.setPinned(true);
-
- Context context = InstrumentationRegistry.getTargetContext();
- AbstractResolverComparator comparator = getTestComparator(context, null);
-
- assertEquals("Both pinned should rank alphabetically", -1, comparator.compare(r1, r2));
- }
-
- @Test
- public void testPromoteToFirst() {
- ComponentName promoteToFirst = new ComponentName("promoted-package", "class");
- ResolvedComponentInfo r1 = createResolvedComponentInfo(promoteToFirst);
-
- ResolvedComponentInfo r2 = createResolvedComponentInfo(
- new ComponentName("package", "class"));
-
- Context context = InstrumentationRegistry.getTargetContext();
- AbstractResolverComparator comparator = getTestComparator(context, promoteToFirst);
-
- assertEquals("PromoteToFirst ranks over non-cemented", -1, comparator.compare(r1, r2));
- assertEquals("Non-cemented ranks under PromoteToFirst", 1, comparator.compare(r2, r1));
- }
-
- @Test
- public void testPromoteToFirstOverPinned() {
- ComponentName cementedComponent = new ComponentName("promoted-package", "class");
- ResolvedComponentInfo r1 = createResolvedComponentInfo(cementedComponent);
-
- ResolvedComponentInfo r2 = createResolvedComponentInfo(
- new ComponentName("package", "class"));
- r2.setPinned(true);
-
- Context context = InstrumentationRegistry.getTargetContext();
- AbstractResolverComparator comparator = getTestComparator(context, cementedComponent);
-
- assertEquals("PromoteToFirst ranks over pinned", -1, comparator.compare(r1, r2));
- assertEquals("Pinned ranks under PromoteToFirst", 1, comparator.compare(r2, r1));
- }
-
- private ResolvedComponentInfo createResolvedComponentInfo(ComponentName component) {
- ResolveInfo info = new ResolveInfo();
- info.activityInfo = new ActivityInfo();
- info.activityInfo.packageName = component.getPackageName();
- info.activityInfo.name = component.getClassName();
- return new ResolvedComponentInfo(component, new Intent(), info);
- }
-
- private AbstractResolverComparator getTestComparator(
- Context context, ComponentName promoteToFirst) {
- Intent intent = new Intent();
-
- AbstractResolverComparator testComparator =
- new AbstractResolverComparator(context, intent,
- Lists.newArrayList(context.getUser()), promoteToFirst) {
-
- @Override
- 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) {}
-
- @Override
- public float getScore(TargetInfo targetInfo) {
- return 0;
- }
-
- @Override
- void handleResultMessage(Message message) {}
- };
- return testComparator;
- }
-
-}
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 742aac71..00000000
--- a/java/tests/src/com/android/intentresolver/shortcuts/ShortcutLoaderTest.kt
+++ /dev/null
@@ -1,463 +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.test.filters.SmallTest
-import com.android.intentresolver.TestLifecycleOwner
-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 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
-import java.util.function.Consumer
-
-@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.state = Lifecycle.State.CREATED
- }
-
- @After
- fun cleanup() {
- lifecycleOwner.state = Lifecycle.State.DESTROYED
- 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.state = Lifecycle.State.DESTROYED
-
- 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/java/tests/src/com/android/intentresolver/shortcuts/ShortcutToChooserTargetConverterTest.kt b/java/tests/src/com/android/intentresolver/shortcuts/ShortcutToChooserTargetConverterTest.kt
deleted file mode 100644
index e0de005d..00000000
--- a/java/tests/src/com/android/intentresolver/shortcuts/ShortcutToChooserTargetConverterTest.kt
+++ /dev/null
@@ -1,177 +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.AppTarget
-import android.content.ComponentName
-import android.content.Intent
-import android.content.pm.ShortcutInfo
-import android.content.pm.ShortcutManager.ShareShortcutInfo
-import android.service.chooser.ChooserTarget
-import com.android.intentresolver.createAppTarget
-import com.android.intentresolver.createShareShortcutInfo
-import org.junit.Assert.assertEquals
-import org.junit.Assert.assertNotNull
-import org.junit.Test
-
-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 ->
- val id = i + 1
- acc.add(
- createShareShortcutInfo(
- id = "id-$i",
- componentName = ComponentName(PACKAGE, "Class$id"),
- rank,
- )
- )
- acc
- }
-
- @Test
- fun testConvertToChooserTarget_predictionService() {
- val appTargets = shortcuts.map { createAppTarget(it.shortcutInfo) }
- val expectedOrderAllShortcuts = intArrayOf(0, 1, 2, 3)
- val expectedScoreAllShortcuts = floatArrayOf(1.0f, 0.99f, 0.98f, 0.97f)
- val appTargetCache = HashMap<ChooserTarget, AppTarget>()
- val shortcutInfoCache = HashMap<ChooserTarget, ShortcutInfo>()
-
- var chooserTargets = testSubject.convertToChooserTarget(
- shortcuts,
- shortcuts,
- appTargets,
- appTargetCache,
- shortcutInfoCache,
- )
-
- assertCorrectShortcutToChooserTargetConversion(
- shortcuts,
- chooserTargets,
- expectedOrderAllShortcuts,
- expectedScoreAllShortcuts,
- )
- assertAppTargetCache(chooserTargets, appTargetCache)
- assertShortcutInfoCache(chooserTargets, shortcutInfoCache)
-
- val subset = shortcuts.subList(1, shortcuts.size)
- val expectedOrderSubset = intArrayOf(1, 2, 3)
- val expectedScoreSubset = floatArrayOf(0.99f, 0.98f, 0.97f)
- appTargetCache.clear()
- shortcutInfoCache.clear()
-
- chooserTargets = testSubject.convertToChooserTarget(
- subset,
- shortcuts,
- appTargets,
- appTargetCache,
- shortcutInfoCache,
- )
-
- assertCorrectShortcutToChooserTargetConversion(
- shortcuts,
- chooserTargets,
- expectedOrderSubset,
- expectedScoreSubset,
- )
- assertAppTargetCache(chooserTargets, appTargetCache)
- assertShortcutInfoCache(chooserTargets, shortcutInfoCache)
- }
-
- @Test
- fun testConvertToChooserTarget_shortcutManager() {
- val testSubject = ShortcutToChooserTargetConverter()
- val expectedOrderAllShortcuts = intArrayOf(2, 0, 3, 1)
- 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,
- )
-
- assertCorrectShortcutToChooserTargetConversion(
- shortcuts, chooserTargets,
- expectedOrderAllShortcuts, expectedScoreAllShortcuts
- )
- assertShortcutInfoCache(chooserTargets, shortcutInfoCache)
-
- val subset: MutableList<ShareShortcutInfo> = java.util.ArrayList()
- subset.add(shortcuts[1])
- subset.add(shortcuts[2])
- subset.add(shortcuts[3])
- val expectedOrderSubset = intArrayOf(2, 3, 1)
- val expectedScoreSubset = floatArrayOf(1.0f, 0.99f, 0.98f)
- shortcutInfoCache.clear()
-
- chooserTargets = testSubject.convertToChooserTarget(
- subset,
- shortcuts,
- null,
- null,
- shortcutInfoCache,
- )
-
- assertCorrectShortcutToChooserTargetConversion(
- shortcuts, chooserTargets,
- expectedOrderSubset, expectedScoreSubset
- )
- assertShortcutInfoCache(chooserTargets, shortcutInfoCache)
- }
-
- private fun assertCorrectShortcutToChooserTargetConversion(
- shortcuts: List<ShareShortcutInfo>,
- chooserTargets: List<ChooserTarget>,
- expectedOrder: IntArray,
- expectedScores: FloatArray,
- ) {
- assertEquals("Unexpected ChooserTarget count", expectedOrder.size, chooserTargets.size)
- for (i in chooserTargets.indices) {
- val ct = chooserTargets[i]
- val si = shortcuts[expectedOrder[i]].shortcutInfo
- val cn = shortcuts[expectedOrder[i]].targetComponent
- assertEquals(si.id, ct.intentExtras.getString(Intent.EXTRA_SHORTCUT_ID))
- assertEquals(si.label, ct.title)
- assertEquals(expectedScores[i], ct.score)
- assertEquals(cn, ct.componentName)
- }
- }
-
- private fun assertAppTargetCache(
- chooserTargets: List<ChooserTarget>, cache: Map<ChooserTarget, AppTarget>
- ) {
- for (ct in chooserTargets) {
- val target = cache[ct]
- assertNotNull("AppTarget is missing", target)
- }
- }
-
- private fun assertShortcutInfoCache(
- chooserTargets: List<ChooserTarget>, cache: Map<ChooserTarget, ShortcutInfo>
- ) {
- for (ct in chooserTargets) {
- val si = cache[ct]
- assertNotNull("AppTarget is missing", si)
- }
- }
-}
diff --git a/java/tests/src/com/android/intentresolver/util/UriFiltersTest.kt b/java/tests/src/com/android/intentresolver/util/UriFiltersTest.kt
deleted file mode 100644
index 18218064..00000000
--- a/java/tests/src/com/android/intentresolver/util/UriFiltersTest.kt
+++ /dev/null
@@ -1,95 +0,0 @@
-package com.android.intentresolver.util
-
-import android.app.PendingIntent
-import android.content.IIntentReceiver
-import android.content.IIntentSender
-import android.content.Intent
-import android.graphics.Bitmap
-import android.graphics.drawable.Icon
-import android.net.Uri
-import android.os.Binder
-import android.os.Bundle
-import android.os.IBinder
-import android.os.UserHandle
-import android.service.chooser.ChooserAction
-import androidx.test.ext.junit.runners.AndroidJUnit4
-import org.junit.Assert.assertFalse
-import org.junit.Assert.assertTrue
-import org.junit.Test
-import org.junit.runner.RunWith
-
-@RunWith(AndroidJUnit4::class)
-class UriFiltersTest {
-
- @Test
- fun uri_ownedByCurrentUser_noUserId() {
- val uri = Uri.parse("content://media/images/12345")
- assertTrue("Uri without userId should always return true", uri.ownedByCurrentUser)
- }
-
- @Test
- fun uri_ownedByCurrentUser_selfUserId() {
- val uri = Uri.parse("content://${UserHandle.myUserId()}@media/images/12345")
- assertTrue("Uri with own userId should return true", uri.ownedByCurrentUser)
- }
-
- @Test
- fun uri_ownedByCurrentUser_otherUserId() {
- val otherUserId = UserHandle.myUserId() + 10
- val uri = Uri.parse("content://${otherUserId}@media/images/12345")
- assertFalse("Uri with other userId should return false", uri.ownedByCurrentUser)
- }
-
- @Test
- fun chooserAction_hasValidIcon_bitmap() =
- smallBitmap().use {
- val icon = Icon.createWithBitmap(it)
- val action = actionWithIcon(icon)
- assertTrue("No uri, assumed valid", hasValidIcon(action))
- }
-
- @Test
- fun chooserAction_hasValidIcon_uri() {
- val icon = Icon.createWithContentUri("content://provider/content/12345")
- assertTrue("No userId in uri, uri is valid", hasValidIcon(actionWithIcon(icon)))
- }
- @Test
- fun chooserAction_hasValidIcon_uri_unowned() {
- val userId = UserHandle.myUserId() + 10
- val icon = Icon.createWithContentUri("content://${userId}@provider/content/12345")
- assertFalse("uri userId references a different user", hasValidIcon(actionWithIcon(icon)))
- }
-
- private fun smallBitmap() = Bitmap.createBitmap(100, 100, Bitmap.Config.ARGB_8888)
-
- private fun mockAction(): PendingIntent {
- return PendingIntent(
- object : IIntentSender {
- override fun asBinder(): IBinder = Binder()
- override fun send(
- code: Int,
- intent: Intent?,
- resolvedType: String?,
- whitelistToken: IBinder?,
- finishedReceiver: IIntentReceiver?,
- requiredPermission: String?,
- options: Bundle?
- ) {
- /* empty */
- }
- }
- )
- }
-
- private fun actionWithIcon(icon: Icon): ChooserAction {
- return ChooserAction.Builder(icon, "", mockAction()).build()
- }
-
- /** Unconditionally recycles the [Bitmap] after running the given block */
- private fun Bitmap.use(block: (Bitmap) -> Unit) =
- try {
- block(this)
- } finally {
- recycle()
- }
-}
diff --git a/java/tests/src/com/android/intentresolver/widget/BatchPreviewLoaderTest.kt b/java/tests/src/com/android/intentresolver/widget/BatchPreviewLoaderTest.kt
deleted file mode 100644
index e65cba5f..00000000
--- a/java/tests/src/com/android/intentresolver/widget/BatchPreviewLoaderTest.kt
+++ /dev/null
@@ -1,215 +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.widget
-
-import android.graphics.Bitmap
-import android.net.Uri
-import com.android.intentresolver.captureMany
-import com.android.intentresolver.mock
-import com.android.intentresolver.widget.ScrollableImagePreviewView.BatchPreviewLoader
-import com.android.intentresolver.widget.ScrollableImagePreviewView.Preview
-import com.android.intentresolver.widget.ScrollableImagePreviewView.PreviewType
-import com.android.intentresolver.withArgCaptor
-import com.google.common.truth.Truth.assertThat
-import kotlinx.coroutines.CompletableDeferred
-import kotlinx.coroutines.CoroutineScope
-import kotlinx.coroutines.Dispatchers
-import kotlinx.coroutines.ExperimentalCoroutinesApi
-import kotlinx.coroutines.cancel
-import kotlinx.coroutines.flow.MutableSharedFlow
-import kotlinx.coroutines.launch
-import kotlinx.coroutines.test.UnconfinedTestDispatcher
-import kotlinx.coroutines.test.resetMain
-import kotlinx.coroutines.test.setMain
-import org.junit.After
-import org.junit.Before
-import org.junit.Test
-import org.mockito.Mockito.atLeast
-import org.mockito.Mockito.times
-import org.mockito.Mockito.verify
-
-@OptIn(ExperimentalCoroutinesApi::class)
-class BatchPreviewLoaderTest {
- private val dispatcher = UnconfinedTestDispatcher()
- private val testScope = CoroutineScope(dispatcher)
- private val onCompletion = mock<() -> Unit>()
- private val onReset = mock<(Int) -> Unit>()
- private val onUpdate = mock<(List<Preview>) -> Unit>()
-
- @Before
- fun setup() {
- Dispatchers.setMain(dispatcher)
- }
-
- @After
- fun cleanup() {
- testScope.cancel()
- Dispatchers.resetMain()
- }
-
- @Test
- fun test_allImagesWithinViewPort_oneUpdate() {
- val imageLoader = TestImageLoader(testScope)
- val uriOne = createUri(1)
- val uriTwo = createUri(2)
- imageLoader.setUriLoadingOrder(succeed(uriTwo), succeed(uriOne))
- val testSubject =
- BatchPreviewLoader(
- imageLoader,
- previews(uriOne, uriTwo),
- 0,
- onReset,
- onUpdate,
- onCompletion
- )
- testSubject.loadAspectRatios(200) { _, _, _ -> 100 }
- dispatcher.scheduler.advanceUntilIdle()
-
- verify(onCompletion, times(1)).invoke()
- verify(onReset, times(1)).invoke(2)
- val list = withArgCaptor { verify(onUpdate, times(1)).invoke(capture()) }.map { it.uri }
- assertThat(list).containsExactly(uriOne, uriTwo).inOrder()
- }
-
- @Test
- fun test_allImagesWithinViewPortOneFailed_failedPreviewIsNotUpdated() {
- val imageLoader = TestImageLoader(testScope)
- val uriOne = createUri(1)
- val uriTwo = createUri(2)
- val uriThree = createUri(3)
- imageLoader.setUriLoadingOrder(succeed(uriThree), fail(uriTwo), succeed(uriOne))
- val testSubject =
- BatchPreviewLoader(
- imageLoader,
- previews(uriOne, uriTwo, uriThree),
- 0,
- onReset,
- onUpdate,
- onCompletion
- )
- testSubject.loadAspectRatios(200) { _, _, _ -> 100 }
- dispatcher.scheduler.advanceUntilIdle()
-
- verify(onCompletion, times(1)).invoke()
- verify(onReset, times(1)).invoke(3)
- val list = withArgCaptor { verify(onUpdate, times(1)).invoke(capture()) }.map { it.uri }
- assertThat(list).containsExactly(uriOne, uriThree).inOrder()
- }
-
- @Test
- fun test_imagesLoadedNotInOrder_updatedInOrder() {
- val imageLoader = TestImageLoader(testScope)
- val uris = Array(10) { createUri(it) }
- val loadingOrder =
- Array(uris.size) { i ->
- val uriIdx =
- when {
- i % 2 == 1 -> i - 1
- i % 2 == 0 && i < uris.size - 1 -> i + 1
- else -> i
- }
- succeed(uris[uriIdx])
- }
- imageLoader.setUriLoadingOrder(*loadingOrder)
- val testSubject =
- BatchPreviewLoader(imageLoader, previews(*uris), 0, onReset, onUpdate, onCompletion)
- testSubject.loadAspectRatios(200) { _, _, _ -> 100 }
- dispatcher.scheduler.advanceUntilIdle()
-
- verify(onCompletion, times(1)).invoke()
- verify(onReset, times(1)).invoke(uris.size)
- val list =
- captureMany { verify(onUpdate, atLeast(1)).invoke(capture()) }
- .fold(ArrayList<Preview>()) { acc, update -> acc.apply { addAll(update) } }
- .map { it.uri }
- assertThat(list).containsExactly(*uris).inOrder()
- }
-
- @Test
- fun test_imagesLoadedNotInOrderSomeFailed_updatedInOrder() {
- val imageLoader = TestImageLoader(testScope)
- val uris = Array(10) { createUri(it) }
- val loadingOrder =
- Array(uris.size) { i ->
- val uriIdx =
- when {
- i % 2 == 1 -> i - 1
- i % 2 == 0 && i < uris.size - 1 -> i + 1
- else -> i
- }
- if (uriIdx % 2 == 0) fail(uris[uriIdx]) else succeed(uris[uriIdx])
- }
- val expectedUris = Array(uris.size / 2) { createUri(it * 2 + 1) }
- imageLoader.setUriLoadingOrder(*loadingOrder)
- val testSubject =
- BatchPreviewLoader(imageLoader, previews(*uris), 0, onReset, onUpdate, onCompletion)
- testSubject.loadAspectRatios(200) { _, _, _ -> 100 }
- dispatcher.scheduler.advanceUntilIdle()
-
- verify(onCompletion, times(1)).invoke()
- verify(onReset, times(1)).invoke(uris.size)
- val list =
- captureMany { verify(onUpdate, atLeast(1)).invoke(capture()) }
- .fold(ArrayList<Preview>()) { acc, update -> acc.apply { addAll(update) } }
- .map { it.uri }
- assertThat(list).containsExactly(*expectedUris).inOrder()
- }
-
- 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 ->
- acc.apply { add(Preview(PreviewType.Image, uri, editAction = null)) }
- }
-}
-
-private class TestImageLoader(scope: CoroutineScope) : suspend (Uri, Boolean) -> Bitmap? {
- private val loadingOrder = ArrayDeque<Pair<Uri, Boolean>>()
- private val pendingRequests = LinkedHashMap<Uri, CompletableDeferred<Bitmap?>>()
- private val flow = MutableSharedFlow<Unit>(replay = 1)
- private val bitmap by lazy { Bitmap.createBitmap(100, 100, Bitmap.Config.ARGB_8888) }
-
- init {
- scope.launch {
- flow.collect {
- while (true) {
- val (nextUri, isLoaded) = loadingOrder.firstOrNull() ?: break
- val deferred = pendingRequests.remove(nextUri) ?: break
- loadingOrder.removeFirst()
- deferred.complete(if (isLoaded) bitmap else null)
- }
- if (loadingOrder.isEmpty()) {
- pendingRequests.forEach { (uri, deferred) -> deferred.complete(bitmap) }
- pendingRequests.clear()
- }
- }
- }
- }
-
- fun setUriLoadingOrder(vararg uris: Pair<Uri, Boolean>) {
- loadingOrder.clear()
- loadingOrder.addAll(uris)
- }
-
- override suspend fun invoke(uri: Uri, cache: Boolean): Bitmap? {
- val deferred = pendingRequests.getOrPut(uri) { CompletableDeferred() }
- flow.tryEmit(Unit)
- return deferred.await()
- }
-}