summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--Android.bp5
-rw-r--r--aconfig/FeatureFlags.aconfig2
-rw-r--r--java/res/values/styles.xml2
-rw-r--r--java/src/com/android/intentresolver/ChooserActivity.java6
-rw-r--r--java/src/com/android/intentresolver/EnterTransitionAnimationDelegate.kt35
-rw-r--r--java/src/com/android/intentresolver/ItemRevealAnimationTracker.kt4
-rw-r--r--java/src/com/android/intentresolver/SecureSettings.kt4
-rw-r--r--java/src/com/android/intentresolver/ShortcutSelectionLogic.java17
-rw-r--r--java/src/com/android/intentresolver/contentpreview/BasePreviewViewModel.kt3
-rw-r--r--java/src/com/android/intentresolver/contentpreview/ChooserContentPreviewUi.java31
-rw-r--r--java/src/com/android/intentresolver/contentpreview/ContentPreviewUi.java23
-rw-r--r--java/src/com/android/intentresolver/contentpreview/CursorUriReader.kt147
-rw-r--r--java/src/com/android/intentresolver/contentpreview/FileContentPreviewUi.java5
-rw-r--r--java/src/com/android/intentresolver/contentpreview/FilesPlusTextContentPreviewUi.java5
-rw-r--r--java/src/com/android/intentresolver/contentpreview/HeadlineGeneratorImpl.kt18
-rw-r--r--java/src/com/android/intentresolver/contentpreview/ImageLoaderModule.kt44
-rw-r--r--java/src/com/android/intentresolver/contentpreview/ImagePreviewImageLoader.kt34
-rw-r--r--java/src/com/android/intentresolver/contentpreview/IsHttpUri.kt11
-rw-r--r--java/src/com/android/intentresolver/contentpreview/PayloadToggleInteractor.kt382
-rw-r--r--java/src/com/android/intentresolver/contentpreview/PreviewViewModel.kt44
-rw-r--r--java/src/com/android/intentresolver/contentpreview/SelectionChangeCallback.kt97
-rw-r--r--java/src/com/android/intentresolver/contentpreview/SelectionTracker.kt175
-rw-r--r--java/src/com/android/intentresolver/contentpreview/ShareouselContentPreviewUi.kt113
-rw-r--r--java/src/com/android/intentresolver/contentpreview/TextContentPreviewUi.java5
-rw-r--r--java/src/com/android/intentresolver/contentpreview/UnifiedContentPreviewUi.java5
-rw-r--r--java/src/com/android/intentresolver/contentpreview/UriMetadataReader.kt31
-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.kt24
-rw-r--r--java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/cursor/PayloadToggleCursorResolver.kt69
-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.kt (renamed from java/src/com/android/intentresolver/contentpreview/TargetIntentModifier.kt)51
-rw-r--r--java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/interactor/ChooserRequestInteractor.kt38
-rw-r--r--java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/interactor/CursorPreviewsInteractor.kt294
-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.kt65
-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.kt54
-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/UpdateChooserRequestInteractor.kt62
-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/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/model/PreviewModel.kt29
-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.kt (renamed from java/src/com/android/intentresolver/contentpreview/shareousel/ui/composable/ComposeIconComposable.kt)12
-rw-r--r--java/src/com/android/intentresolver/contentpreview/payloadtoggle/ui/composable/ShareouselCardComposable.kt (renamed from java/src/com/android/intentresolver/contentpreview/shareousel/ui/composable/ShareouselCardComposable.kt)8
-rw-r--r--java/src/com/android/intentresolver/contentpreview/payloadtoggle/ui/composable/ShareouselComposable.kt (renamed from java/src/com/android/intentresolver/contentpreview/shareousel/ui/composable/ShareouselComposable.kt)133
-rw-r--r--java/src/com/android/intentresolver/contentpreview/payloadtoggle/ui/viewmodel/ActionChipViewModel.kt29
-rw-r--r--java/src/com/android/intentresolver/contentpreview/payloadtoggle/ui/viewmodel/ShareouselPreviewViewModel.kt39
-rw-r--r--java/src/com/android/intentresolver/contentpreview/payloadtoggle/ui/viewmodel/ShareouselViewModel.kt141
-rw-r--r--java/src/com/android/intentresolver/contentpreview/shareousel/ui/viewmodel/ShareouselViewModel.kt90
-rw-r--r--java/src/com/android/intentresolver/grid/ChooserGridAdapter.java2
-rw-r--r--java/src/com/android/intentresolver/inject/ActivityModelModule.kt127
-rw-r--r--java/src/com/android/intentresolver/inject/Qualifiers.kt5
-rw-r--r--java/src/com/android/intentresolver/inject/SystemServices.kt44
-rw-r--r--java/src/com/android/intentresolver/inject/ViewModelCoroutineScopeModule.kt42
-rw-r--r--java/src/com/android/intentresolver/logging/EventLogImpl.java4
-rw-r--r--java/src/com/android/intentresolver/logging/EventLogModule.kt8
-rw-r--r--java/src/com/android/intentresolver/model/ResolverRankerServiceResolverComparator.java43
-rw-r--r--java/src/com/android/intentresolver/shortcuts/AppPredictorFactory.kt34
-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/v2/ChooserActionFactory.java41
-rw-r--r--java/src/com/android/intentresolver/v2/ChooserActivity.java774
-rw-r--r--java/src/com/android/intentresolver/v2/ChooserActivityLogic.kt23
-rw-r--r--java/src/com/android/intentresolver/v2/ChooserHelper.kt161
-rw-r--r--java/src/com/android/intentresolver/v2/ChooserMutableActionFactory.kt54
-rw-r--r--java/src/com/android/intentresolver/v2/JavaFlowHelper.kt2
-rw-r--r--java/src/com/android/intentresolver/v2/ProfileAvailability.kt29
-rw-r--r--java/src/com/android/intentresolver/v2/ProfileHelper.kt70
-rw-r--r--java/src/com/android/intentresolver/v2/ResolverActivity.java6
-rw-r--r--java/src/com/android/intentresolver/v2/annotation/JavaInterop.kt6
-rw-r--r--java/src/com/android/intentresolver/v2/data/model/ChooserRequest.kt (renamed from java/src/com/android/intentresolver/v2/ui/model/ChooserRequest.kt)2
-rw-r--r--java/src/com/android/intentresolver/v2/data/repository/ChooserRequestRepository.kt39
-rw-r--r--java/src/com/android/intentresolver/v2/data/repository/UserInfoExt.kt16
-rw-r--r--java/src/com/android/intentresolver/v2/data/repository/UserRepository.kt151
-rw-r--r--java/src/com/android/intentresolver/v2/data/repository/UserRepositoryModule.kt16
-rw-r--r--java/src/com/android/intentresolver/v2/data/repository/UserScopedService.kt71
-rw-r--r--java/src/com/android/intentresolver/v2/domain/interactor/UserInteractor.kt4
-rw-r--r--java/src/com/android/intentresolver/v2/emptystate/NoCrossProfileEmptyStateProvider.java59
-rw-r--r--java/src/com/android/intentresolver/v2/emptystate/ResolverNoCrossProfileEmptyStateProvider.java138
-rw-r--r--java/src/com/android/intentresolver/v2/emptystate/WorkProfilePausedEmptyStateProvider.java41
-rw-r--r--java/src/com/android/intentresolver/v2/platform/AppPredictionModule.kt7
-rw-r--r--java/src/com/android/intentresolver/v2/profiles/ChooserMultiProfilePagerAdapter.java10
-rw-r--r--java/src/com/android/intentresolver/v2/profiles/MultiProfilePagerAdapter.java10
-rw-r--r--java/src/com/android/intentresolver/v2/shared/model/User.kt23
-rw-r--r--java/src/com/android/intentresolver/v2/ui/ProfilePagerResources.kt4
-rw-r--r--java/src/com/android/intentresolver/v2/ui/ShortcutPolicyModule.kt94
-rw-r--r--java/src/com/android/intentresolver/v2/ui/model/ActivityModel.kt1
-rw-r--r--java/src/com/android/intentresolver/v2/ui/model/ResolverRequest.kt2
-rw-r--r--java/src/com/android/intentresolver/v2/ui/viewmodel/ChooserRequestReader.kt10
-rw-r--r--java/src/com/android/intentresolver/v2/ui/viewmodel/ChooserViewModel.kt61
-rw-r--r--java/src/com/android/intentresolver/v2/validation/types/IntentOrUri.kt10
-rw-r--r--java/src/com/android/intentresolver/v2/validation/types/ParceledArray.kt11
-rw-r--r--java/src/com/android/intentresolver/v2/validation/types/SimpleValue.kt25
-rw-r--r--java/src/com/android/intentresolver/widget/ActionRow.kt4
-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/ViewExtensions.kt27
-rw-r--r--tests/activity/Android.bp2
-rw-r--r--tests/activity/src/com/android/intentresolver/ResolverActivityTest.java2
-rw-r--r--tests/activity/src/com/android/intentresolver/UnbundledChooserActivityTest.java11
-rw-r--r--tests/activity/src/com/android/intentresolver/UnbundledChooserActivityWorkProfileTest.java2
-rw-r--r--tests/activity/src/com/android/intentresolver/ext/RecyclerViewExt.kt (renamed from java/src/com/android/intentresolver/contentpreview/MutableActionFactory.kt)17
-rw-r--r--tests/activity/src/com/android/intentresolver/logging/TestEventLogModule.kt8
-rw-r--r--tests/activity/src/com/android/intentresolver/v2/ChooserActivityOverrideData.java36
-rw-r--r--tests/activity/src/com/android/intentresolver/v2/ChooserWrapperActivity.java23
-rw-r--r--tests/activity/src/com/android/intentresolver/v2/ResolverActivityTest.java2
-rw-r--r--tests/activity/src/com/android/intentresolver/v2/TestChooserActivityLogic.kt25
-rw-r--r--tests/activity/src/com/android/intentresolver/v2/UnbundledChooserActivityTest.java154
-rw-r--r--tests/activity/src/com/android/intentresolver/v2/UnbundledChooserActivityWorkProfileTest.java48
-rw-r--r--tests/integration/Android.bp3
-rw-r--r--tests/shared/Android.bp3
-rw-r--r--tests/shared/src/com/android/intentresolver/FakeImageLoader.kt (renamed from tests/shared/src/com/android/intentresolver/TestPreviewImageLoader.kt)8
-rw-r--r--tests/shared/src/com/android/intentresolver/MockitoKotlinHelpers.kt66
-rw-r--r--tests/shared/src/com/android/intentresolver/TestContentPreviewViewModel.kt14
-rw-r--r--tests/shared/src/com/android/intentresolver/v2/platform/FakeUserManager.kt31
-rw-r--r--tests/unit/Android.bp2
-rw-r--r--tests/unit/src/com/android/intentresolver/contentpreview/ChooserContentPreviewUiTest.kt5
-rw-r--r--tests/unit/src/com/android/intentresolver/contentpreview/CursorUriReaderTest.kt125
-rw-r--r--tests/unit/src/com/android/intentresolver/contentpreview/ImagePreviewImageLoaderTest.kt476
-rw-r--r--tests/unit/src/com/android/intentresolver/contentpreview/PayloadToggleInteractorTest.kt162
-rw-r--r--tests/unit/src/com/android/intentresolver/contentpreview/PreviewViewModelTest.kt81
-rw-r--r--tests/unit/src/com/android/intentresolver/contentpreview/SelectionTrackerTest.kt330
-rw-r--r--tests/unit/src/com/android/intentresolver/contentpreview/UriMetadataReaderTest.kt8
-rw-r--r--tests/unit/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/intent/TargetIntentModifierImplTest.kt (renamed from tests/unit/src/com/android/intentresolver/contentpreview/TargetIntentModifierTest.kt)23
-rw-r--r--tests/unit/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/interactor/CursorPreviewsInteractorTest.kt271
-rw-r--r--tests/unit/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/interactor/CustomActionsInteractorTest.kt166
-rw-r--r--tests/unit/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/interactor/FetchPreviewsInteractorTest.kt335
-rw-r--r--tests/unit/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/interactor/SelectablePreviewInteractorTest.kt148
-rw-r--r--tests/unit/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/interactor/SelectablePreviewsInteractorTest.kt193
-rw-r--r--tests/unit/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/interactor/SetCursorPreviewsInteractorTest.kt108
-rw-r--r--tests/unit/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/interactor/UpdateChooserRequestInteractorTest.kt74
-rw-r--r--tests/unit/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/update/SelectionChangeCallbackImplTest.kt (renamed from tests/unit/src/com/android/intentresolver/contentpreview/SelectionChangeCallbackTest.kt)205
-rw-r--r--tests/unit/src/com/android/intentresolver/contentpreview/payloadtoggle/ui/viewmodel/ShareouselViewModelTest.kt305
-rw-r--r--tests/unit/src/com/android/intentresolver/model/AbstractResolverComparatorTest.java10
-rw-r--r--tests/unit/src/com/android/intentresolver/util/TruthUtils.kt26
-rw-r--r--tests/unit/src/com/android/intentresolver/v2/ChooserActionFactoryTest.kt39
-rw-r--r--tests/unit/src/com/android/intentresolver/v2/ChooserMutableActionFactoryTest.kt139
-rw-r--r--tests/unit/src/com/android/intentresolver/v2/ProfileAvailabilityTest.kt12
-rw-r--r--tests/unit/src/com/android/intentresolver/v2/ProfileHelperTest.kt10
-rw-r--r--tests/unit/src/com/android/intentresolver/v2/data/model/FakeChooserRequest.kt26
-rw-r--r--tests/unit/src/com/android/intentresolver/v2/data/repository/UserRepositoryImplTest.kt46
-rw-r--r--tests/unit/src/com/android/intentresolver/v2/ui/ShareResultSenderImplTest.kt2
-rw-r--r--tests/unit/src/com/android/intentresolver/v2/ui/viewmodel/ChooserRequestTest.kt16
-rw-r--r--tests/unit/src/com/android/intentresolver/v2/ui/viewmodel/ResolverRequestTest.kt1
163 files changed, 6382 insertions, 3480 deletions
diff --git a/Android.bp b/Android.bp
index 4b411efa..d476321c 100644
--- a/Android.bp
+++ b/Android.bp
@@ -69,6 +69,11 @@ android_library {
"androidx.lifecycle_lifecycle-viewmodel-compose",
"androidx.lifecycle_lifecycle-runtime-compose",
],
+ javacflags: [
+ "-Adagger.fastInit=enabled",
+ "-Adagger.explicitBindingConflictsWithInject=ERROR",
+ "-Adagger.strictMultibindingValidation=enabled",
+ ],
}
java_defaults {
diff --git a/aconfig/FeatureFlags.aconfig b/aconfig/FeatureFlags.aconfig
index 04883baf..583d8502 100644
--- a/aconfig/FeatureFlags.aconfig
+++ b/aconfig/FeatureFlags.aconfig
@@ -47,5 +47,5 @@ flag {
name: "enable_private_profile"
namespace: "intentresolver"
description: "Enable private profile support"
- bug: "311348033"
+ bug: "328029692"
}
diff --git a/java/res/values/styles.xml b/java/res/values/styles.xml
index 0ccab4c0..143009d0 100644
--- a/java/res/values/styles.xml
+++ b/java/res/values/styles.xml
@@ -45,7 +45,7 @@
<style name="Theme.DeviceDefault.Chooser" parent="Theme.DeviceDefault.Resolver">
<item name="*android:iconfactoryIconSize">@dimen/chooser_icon_size</item>
<item name="*android:iconfactoryBadgeSize">@dimen/chooser_badge_size</item>
- <item name="android:windowLayoutInDisplayCutoutMode">shortEdges</item>
+ <item name="android:windowLayoutInDisplayCutoutMode">always</item>
</style>
<style name="TextAppearance.ChooserDefault"
diff --git a/java/src/com/android/intentresolver/ChooserActivity.java b/java/src/com/android/intentresolver/ChooserActivity.java
index 039fad56..9557b25b 100644
--- a/java/src/com/android/intentresolver/ChooserActivity.java
+++ b/java/src/com/android/intentresolver/ChooserActivity.java
@@ -305,16 +305,16 @@ public class ChooserActivity extends Hilt_ChooserActivity implements
.get(BasePreviewViewModel.class);
previewViewModel.init(
mChooserRequest.getTargetIntent(),
- getIntent(),
/*additionalContentUri = */ null,
- /*focusedItemIdx = */ 0,
/*isPayloadTogglingEnabled = */ false);
+ final ChooserActionFactory chooserActionFactory = createChooserActionFactory();
mChooserContentPreviewUi = new ChooserContentPreviewUi(
getCoroutineScope(getLifecycle()),
previewViewModel.getPreviewDataProvider(),
mChooserRequest.getTargetIntent(),
previewViewModel.getImageLoader(),
- createChooserActionFactory(),
+ chooserActionFactory,
+ chooserActionFactory::getModifyShareAction,
mEnterTransitionAnimationDelegate,
new HeadlineGeneratorImpl(this),
ContentTypeHint.NONE,
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/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/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 efaaf894..12465184 100644
--- a/java/src/com/android/intentresolver/ShortcutSelectionLogic.java
+++ b/java/src/com/android/intentresolver/ShortcutSelectionLogic.java
@@ -30,13 +30,19 @@ 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.v2.ui.AppShortcutLimit;
+import com.android.intentresolver.v2.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;
@@ -49,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;
}
@@ -78,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/contentpreview/BasePreviewViewModel.kt b/java/src/com/android/intentresolver/contentpreview/BasePreviewViewModel.kt
index 21c909ea..dc36e584 100644
--- a/java/src/com/android/intentresolver/contentpreview/BasePreviewViewModel.kt
+++ b/java/src/com/android/intentresolver/contentpreview/BasePreviewViewModel.kt
@@ -25,14 +25,11 @@ import androidx.lifecycle.ViewModel
abstract class BasePreviewViewModel : ViewModel() {
@get:MainThread abstract val previewDataProvider: PreviewDataProvider
@get:MainThread abstract val imageLoader: ImageLoader
- abstract val payloadToggleInteractor: PayloadToggleInteractor?
@MainThread
abstract fun init(
targetIntent: Intent,
- chooserIntent: Intent,
additionalContentUri: Uri?,
- focusedItemIdx: Int,
isPayloadTogglingEnabled: Boolean,
)
}
diff --git a/java/src/com/android/intentresolver/contentpreview/ChooserContentPreviewUi.java b/java/src/com/android/intentresolver/contentpreview/ChooserContentPreviewUi.java
index 6f201ad5..67458697 100644
--- a/java/src/com/android/intentresolver/contentpreview/ChooserContentPreviewUi.java
+++ b/java/src/com/android/intentresolver/contentpreview/ChooserContentPreviewUi.java
@@ -39,6 +39,7 @@ import com.android.intentresolver.widget.ImagePreviewView.TransitionElementStatu
import java.util.List;
import java.util.function.Consumer;
+import java.util.function.Supplier;
import kotlinx.coroutines.CoroutineScope;
@@ -77,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>
@@ -93,6 +96,9 @@ public final class ChooserContentPreviewUi {
@VisibleForTesting
final ContentPreviewUi mContentPreviewUi;
+ private final Supplier</*@Nullable*/ActionRow.Action> mModifyShareActionFactory;
+ @Nullable
+ private View mHeadlineParent;
public ChooserContentPreviewUi(
CoroutineScope scope,
@@ -100,6 +106,7 @@ public final class ChooserContentPreviewUi {
Intent targetIntent,
ImageLoader imageLoader,
ActionFactory actionFactory,
+ Supplier</*@Nullable*/ActionRow.Action> modifyShareActionFactory,
TransitionElementStatusCallback transitionElementStatusCallback,
HeadlineGenerator headlineGenerator,
ContentTypeHint contentTypeHint,
@@ -108,6 +115,7 @@ public final class ChooserContentPreviewUi {
boolean isPayloadTogglingEnabled) {
mScope = scope;
mIsPayloadTogglingEnabled = isPayloadTogglingEnabled;
+ mModifyShareActionFactory = modifyShareActionFactory;
mContentPreviewUi = createContentPreview(
previewData,
targetIntent,
@@ -162,7 +170,7 @@ public final class ChooserContentPreviewUi {
if (previewType == CONTENT_PREVIEW_PAYLOAD_SELECTION && mIsPayloadTogglingEnabled) {
transitionElementStatusCallback.onAllTransitionElementsReady(); // TODO
- return new ShareouselContentPreviewUi(actionFactory);
+ return new ShareouselContentPreviewUi();
}
boolean isSingleImageShare = previewData.getUriCount() == 1
@@ -220,7 +228,24 @@ public final class ChooserContentPreviewUi {
ViewGroup parent,
@Nullable View headlineViewParent) {
- return mContentPreviewUi.display(resources, layoutInflater, parent, headlineViewParent);
+ ViewGroup layout =
+ mContentPreviewUi.display(resources, layoutInflater, parent, headlineViewParent);
+ mHeadlineParent = headlineViewParent == null ? layout : headlineViewParent;
+ if (mHeadlineParent != null) {
+ ContentPreviewUi.displayModifyShareAction(
+ mHeadlineParent, mModifyShareActionFactory.get());
+ }
+ return layout;
+ }
+
+ /**
+ * Update Modify Share Action, if it is inflated.
+ */
+ public void updateModifyShareAction() {
+ if (mHeadlineParent != null) {
+ ContentPreviewUi.displayModifyShareAction(
+ mHeadlineParent, mModifyShareActionFactory.get());
+ }
}
private static TextContentPreviewUi createTextPreview(
diff --git a/java/src/com/android/intentresolver/contentpreview/ContentPreviewUi.java b/java/src/com/android/intentresolver/contentpreview/ContentPreviewUi.java
index b0fb278e..71d5fc0b 100644
--- a/java/src/com/android/intentresolver/contentpreview/ContentPreviewUi.java
+++ b/java/src/com/android/intentresolver/contentpreview/ContentPreviewUi.java
@@ -98,16 +98,19 @@ public abstract class ContentPreviewUi {
}
}
- protected static void displayModifyShareAction(
- View 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());
- }
+ static void displayModifyShareAction(
+ View layout, @Nullable ActionRow.Action modifyShareAction) {
+ TextView modifyShareView =
+ layout == null ? null : layout.findViewById(R.id.reselection_action);
+ if (modifyShareView == null) {
+ return;
+ }
+ if (modifyShareAction != null) {
+ modifyShareView.setText(modifyShareAction.getLabel());
+ modifyShareView.setVisibility(View.VISIBLE);
+ modifyShareView.setOnClickListener(view -> modifyShareAction.getOnClicked().run());
+ } else {
+ modifyShareView.setVisibility(View.GONE);
}
}
diff --git a/java/src/com/android/intentresolver/contentpreview/CursorUriReader.kt b/java/src/com/android/intentresolver/contentpreview/CursorUriReader.kt
deleted file mode 100644
index 6a12f56c..00000000
--- a/java/src/com/android/intentresolver/contentpreview/CursorUriReader.kt
+++ /dev/null
@@ -1,147 +0,0 @@
-/*
- * 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.content.Intent
-import android.database.Cursor
-import android.database.MatrixCursor
-import android.net.Uri
-import android.os.Bundle
-import android.os.CancellationSignal
-import android.service.chooser.AdditionalContentContract.Columns
-import android.service.chooser.AdditionalContentContract.CursorExtraKeys
-import android.util.Log
-import android.util.SparseArray
-import kotlinx.coroutines.CancellationException
-import kotlinx.coroutines.coroutineScope
-
-private const val TAG = ContentPreviewUi.TAG
-
-/**
- * A bi-directional cursor reader. Reads URI from the [cursor] starting from the given [startPos],
- * filters items by [predicate].
- */
-class CursorUriReader(
- private val cursor: Cursor,
- startPos: Int,
- private val pageSize: Int,
- private val predicate: (Uri) -> Boolean,
-) : PayloadToggleInteractor.CursorReader {
- override val count = cursor.count
- // Unread ranges are:
- // - left: [0, leftPos);
- // - right: [rightPos, count)
- // i.e. read range is: [leftPos, rightPos)
- private var rightPos = startPos.coerceIn(0, count)
- private var leftPos = rightPos
-
- override val hasMoreBefore
- get() = leftPos > 0
-
- override val hasMoreAfter
- get() = rightPos < count
-
- override fun readPageAfter(): SparseArray<Uri> {
- if (!hasMoreAfter) return SparseArray()
- if (!cursor.moveToPosition(rightPos)) {
- rightPos = count
- Log.w(TAG, "Failed to move the cursor to position $rightPos, stop reading the cursor")
- return SparseArray()
- }
- val result = SparseArray<Uri>(pageSize)
- do {
- cursor
- .getString(0)
- ?.let(Uri::parse)
- ?.takeIf { predicate(it) }
- ?.let { uri -> result.append(rightPos, uri) }
- rightPos++
- } while (result.size() < pageSize && cursor.moveToNext())
- maybeCloseCursor()
- return result
- }
-
- override fun readPageBefore(): SparseArray<Uri> {
- if (!hasMoreBefore) return SparseArray()
- val startPos = maxOf(0, leftPos - pageSize)
- if (!cursor.moveToPosition(startPos)) {
- leftPos = 0
- Log.w(TAG, "Failed to move the cursor to position $startPos, stop reading cursor")
- return SparseArray()
- }
- val result = SparseArray<Uri>(leftPos - startPos)
- for (pos in startPos until leftPos) {
- cursor
- .getString(0)
- ?.let(Uri::parse)
- ?.takeIf { predicate(it) }
- ?.let { uri -> result.append(pos, uri) }
- if (!cursor.moveToNext()) break
- }
- leftPos = startPos
- maybeCloseCursor()
- return result
- }
-
- private fun maybeCloseCursor() {
- if (!hasMoreBefore && !hasMoreAfter) {
- close()
- }
- }
-
- override fun close() {
- cursor.close()
- }
-
- companion object {
- suspend fun createCursorReader(
- contentResolver: ContentInterface,
- uri: Uri,
- chooserIntent: Intent
- ): CursorUriReader {
- val cancellationSignal = CancellationSignal()
- val cursor =
- try {
- coroutineScope {
- runCatching {
- contentResolver.query(
- uri,
- arrayOf(Columns.URI),
- Bundle().apply {
- putParcelable(Intent.EXTRA_INTENT, chooserIntent)
- },
- cancellationSignal
- )
- }
- .getOrNull()
- ?: MatrixCursor(arrayOf(Columns.URI))
- }
- } catch (e: CancellationException) {
- cancellationSignal.cancel()
- throw e
- }
- return CursorUriReader(
- cursor,
- cursor.extras?.getInt(CursorExtraKeys.POSITION, 0) ?: 0,
- 128,
- ) {
- it.authority != uri.authority
- }
- }
- }
-}
diff --git a/java/src/com/android/intentresolver/contentpreview/FileContentPreviewUi.java b/java/src/com/android/intentresolver/contentpreview/FileContentPreviewUi.java
index d4eea8b9..d127d929 100644
--- a/java/src/com/android/intentresolver/contentpreview/FileContentPreviewUi.java
+++ b/java/src/com/android/intentresolver/contentpreview/FileContentPreviewUi.java
@@ -77,10 +77,7 @@ class FileContentPreviewUi extends ContentPreviewUi {
LayoutInflater layoutInflater,
ViewGroup parent,
@Nullable View headlineViewParent) {
- ViewGroup layout = displayInternal(resources, layoutInflater, parent, headlineViewParent);
- displayModifyShareAction(
- headlineViewParent == null ? layout : headlineViewParent, mActionFactory);
- return layout;
+ return displayInternal(resources, layoutInflater, parent, headlineViewParent);
}
private ViewGroup displayInternal(
diff --git a/java/src/com/android/intentresolver/contentpreview/FilesPlusTextContentPreviewUi.java b/java/src/com/android/intentresolver/contentpreview/FilesPlusTextContentPreviewUi.java
index 6832c5c4..4758534d 100644
--- a/java/src/com/android/intentresolver/contentpreview/FilesPlusTextContentPreviewUi.java
+++ b/java/src/com/android/intentresolver/contentpreview/FilesPlusTextContentPreviewUi.java
@@ -109,10 +109,7 @@ class FilesPlusTextContentPreviewUi extends ContentPreviewUi {
LayoutInflater layoutInflater,
ViewGroup parent,
@Nullable View headlineViewParent) {
- ViewGroup layout = displayInternal(layoutInflater, parent, headlineViewParent);
- displayModifyShareAction(
- headlineViewParent == null ? layout : headlineViewParent, mActionFactory);
- return layout;
+ return displayInternal(layoutInflater, parent, headlineViewParent);
}
public void updatePreviewMetadata(List<FileInfo> files) {
diff --git a/java/src/com/android/intentresolver/contentpreview/HeadlineGeneratorImpl.kt b/java/src/com/android/intentresolver/contentpreview/HeadlineGeneratorImpl.kt
index 6e126822..e92d9bc6 100644
--- a/java/src/com/android/intentresolver/contentpreview/HeadlineGeneratorImpl.kt
+++ b/java/src/com/android/intentresolver/contentpreview/HeadlineGeneratorImpl.kt
@@ -20,6 +20,12 @@ import android.content.Context
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"
@@ -27,7 +33,11 @@ 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.
*/
-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)
@@ -100,3 +110,9 @@ class HeadlineGeneratorImpl(private val context: Context) : HeadlineGenerator {
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/ImageLoaderModule.kt b/java/src/com/android/intentresolver/contentpreview/ImageLoaderModule.kt
new file mode 100644
index 00000000..b861a24a
--- /dev/null
+++ b/java/src/com/android/intentresolver/contentpreview/ImageLoaderModule.kt
@@ -0,0 +1,44 @@
+/*
+ * 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
+
+ companion object {
+ @Provides
+ @ThumbnailSize
+ fun thumbnailSize(@ApplicationOwned resources: Resources): Int =
+ resources.getDimensionPixelSize(R.dimen.chooser_preview_image_max_dimen)
+
+ @Provides @PreviewCacheSize fun cacheSize() = 16
+ }
+}
diff --git a/java/src/com/android/intentresolver/contentpreview/ImagePreviewImageLoader.kt b/java/src/com/android/intentresolver/contentpreview/ImagePreviewImageLoader.kt
index 572ccf0b..fab7203e 100644
--- a/java/src/com/android/intentresolver/contentpreview/ImagePreviewImageLoader.kt
+++ b/java/src/com/android/intentresolver/contentpreview/ImagePreviewImageLoader.kt
@@ -24,17 +24,31 @@ import android.util.Size
import androidx.annotation.GuardedBy
import androidx.annotation.VisibleForTesting
import androidx.collection.LruCache
+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.
@@ -52,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,
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/PayloadToggleInteractor.kt b/java/src/com/android/intentresolver/contentpreview/PayloadToggleInteractor.kt
deleted file mode 100644
index eda5c4ca..00000000
--- a/java/src/com/android/intentresolver/contentpreview/PayloadToggleInteractor.kt
+++ /dev/null
@@ -1,382 +0,0 @@
-/*
- * 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.Intent
-import android.content.IntentSender
-import android.net.Uri
-import android.service.chooser.ChooserAction
-import android.service.chooser.ChooserTarget
-import android.util.Log
-import android.util.SparseArray
-import java.io.Closeable
-import java.util.LinkedList
-import java.util.concurrent.atomic.AtomicBoolean
-import java.util.concurrent.atomic.AtomicReference
-import kotlinx.coroutines.CompletableDeferred
-import kotlinx.coroutines.CoroutineScope
-import kotlinx.coroutines.ExperimentalCoroutinesApi
-import kotlinx.coroutines.Job
-import kotlinx.coroutines.awaitCancellation
-import kotlinx.coroutines.channels.BufferOverflow.DROP_LATEST
-import kotlinx.coroutines.flow.Flow
-import kotlinx.coroutines.flow.MutableSharedFlow
-import kotlinx.coroutines.flow.MutableStateFlow
-import kotlinx.coroutines.flow.filter
-import kotlinx.coroutines.flow.flow
-import kotlinx.coroutines.flow.map
-import kotlinx.coroutines.launch
-
-private const val TAG = "PayloadToggleInteractor"
-
-@OptIn(ExperimentalCoroutinesApi::class)
-class PayloadToggleInteractor(
- // TODO: a single-thread dispatcher is currently expected. iterate on the synchronization logic.
- private val scope: CoroutineScope,
- private val initiallySharedUris: List<Uri>,
- private val focusedUriIdx: Int,
- private val mimeTypeClassifier: MimeTypeClassifier,
- private val cursorReaderProvider: suspend () -> CursorReader,
- private val uriMetadataReader: (Uri) -> FileInfo,
- private val targetIntentModifier: (List<Item>) -> Intent,
- private val selectionCallback: (Intent) -> ShareouselUpdate?,
-) {
- private var cursorDataRef = CompletableDeferred<CursorData?>()
- private val records = LinkedList<Record>()
- private val prevPageLoadingGate = AtomicBoolean(true)
- private val nextPageLoadingGate = AtomicBoolean(true)
- private val notifySelectionJobRef = AtomicReference<Job?>()
- private val emptyState =
- State(
- emptyList(),
- hasMoreItemsBefore = false,
- hasMoreItemsAfter = false,
- allowSelectionChange = false
- )
-
- private val stateFlowSource = MutableStateFlow(emptyState)
-
- val customActions =
- MutableSharedFlow<List<ChooserAction>>(replay = 1, onBufferOverflow = DROP_LATEST)
-
- val stateFlow: Flow<State>
- get() = stateFlowSource.filter { it !== emptyState }
-
- val targetPosition: Flow<Int> = stateFlow.map { it.targetPos }
- val previewKeys: Flow<List<Item>> = stateFlow.map { it.items }
-
- fun getKey(item: Any): Int = (item as Item).key
-
- fun selected(key: Item): Flow<Boolean> = (key as Record).isSelected
-
- fun previewUri(key: Item): Flow<Uri?> = flow { emit(key.previewUri) }
-
- fun previewInteractor(key: Any): PayloadTogglePreviewInteractor {
- val state = stateFlowSource.value
- if (state === emptyState) {
- Log.wtf(TAG, "Requesting item preview before any item has been published")
- } else {
- if (state.hasMoreItemsBefore && key === state.items.firstOrNull()) {
- loadMorePreviousItems()
- }
- if (state.hasMoreItemsAfter && key == state.items.lastOrNull()) {
- loadMoreNextItems()
- }
- }
- return PayloadTogglePreviewInteractor(key as Item, this)
- }
-
- init {
- scope
- .launch { awaitCancellation() }
- .invokeOnCompletion {
- cursorDataRef.cancel()
- runCatching {
- if (cursorDataRef.isCompleted && !cursorDataRef.isCancelled) {
- cursorDataRef.getCompleted()
- } else {
- null
- }
- }
- .getOrNull()
- ?.reader
- ?.close()
- }
- }
-
- fun start() {
- scope.launch {
- val cursorReader = cursorReaderProvider()
- val selectedItems =
- initiallySharedUris.map { uri ->
- val fileInfo = uriMetadataReader(uri)
- Record(
- 0, // artificial key for the pending record, it should not be used anywhere
- uri,
- fileInfo.previewUri,
- fileInfo.mimeType,
- )
- }
- val cursorData =
- CursorData(
- cursorReader,
- SelectionTracker(selectedItems, focusedUriIdx, cursorReader.count) { uri },
- )
- if (cursorDataRef.complete(cursorData)) {
- doLoadMorePreviousItems()
- val startPos = records.size
- doLoadMoreNextItems()
- prevPageLoadingGate.set(false)
- nextPageLoadingGate.set(false)
- publishSnapshot(startPos)
- } else {
- cursorReader.close()
- }
- }
- }
-
- fun loadMorePreviousItems() {
- invokeAsyncIfNotRunning(prevPageLoadingGate) {
- doLoadMorePreviousItems()
- publishSnapshot()
- }
- }
-
- fun loadMoreNextItems() {
- invokeAsyncIfNotRunning(nextPageLoadingGate) {
- doLoadMoreNextItems()
- publishSnapshot()
- }
- }
-
- fun setSelected(item: Item, isSelected: Boolean) {
- val record = item as Record
- scope.launch {
- val (_, selectionTracker) = waitForCursorData() ?: return@launch
- if (selectionTracker.setItemSelection(record.key, record, isSelected)) {
- val targetIntent = targetIntentModifier(selectionTracker.getSelection())
- val newJob = scope.launch { notifySelectionChanged(targetIntent) }
- notifySelectionJobRef.getAndSet(newJob)?.cancel()
- record.isSelected.value = selectionTracker.isItemSelected(record.key)
- }
- }
- }
-
- private fun invokeAsyncIfNotRunning(guardingFlag: AtomicBoolean, block: suspend () -> Unit) {
- if (guardingFlag.compareAndSet(false, true)) {
- scope.launch { block() }.invokeOnCompletion { guardingFlag.set(false) }
- }
- }
-
- private suspend fun doLoadMorePreviousItems() {
- val (reader, selectionTracker) = waitForCursorData() ?: return
- if (!reader.hasMoreBefore) return
-
- val newItems = reader.readPageBefore().toItems()
- selectionTracker.onStartItemsAdded(newItems)
- for (i in newItems.size() - 1 downTo 0) {
- records.add(
- 0,
- (newItems.valueAt(i) as Record).apply {
- isSelected.value = selectionTracker.isItemSelected(key)
- }
- )
- }
- if (!reader.hasMoreBefore && !reader.hasMoreAfter) {
- val pendingItems = selectionTracker.getPendingItems()
- val newRecords =
- pendingItems.foldIndexed(SparseArray<Item>()) { idx, acc, item ->
- assert(item is Record) { "Unexpected pending item type: ${item.javaClass}" }
- val rec = item as Record
- val key = idx - pendingItems.size
- acc.append(
- key,
- Record(
- key,
- rec.uri,
- rec.previewUri,
- rec.mimeType,
- rec.mimeType?.mimeTypeToItemType() ?: ItemType.File
- )
- )
- acc
- }
-
- selectionTracker.onStartItemsAdded(newRecords)
- for (i in (newRecords.size() - 1) downTo 0) {
- records.add(0, (newRecords.valueAt(i) as Record).apply { isSelected.value = true })
- }
- }
- }
-
- private suspend fun doLoadMoreNextItems() {
- val (reader, selectionTracker) = waitForCursorData() ?: return
- if (!reader.hasMoreAfter) return
-
- val newItems = reader.readPageAfter().toItems()
- selectionTracker.onEndItemsAdded(newItems)
- for (i in 0 until newItems.size()) {
- val key = newItems.keyAt(i)
- records.add(
- (newItems.valueAt(i) as Record).apply {
- isSelected.value = selectionTracker.isItemSelected(key)
- }
- )
- }
- if (!reader.hasMoreBefore && !reader.hasMoreAfter) {
- val items =
- selectionTracker.getPendingItems().let { items ->
- items.foldIndexed(SparseArray<Item>(items.size)) { i, acc, item ->
- val key = reader.count + i
- val record = item as Record
- acc.append(
- key,
- Record(key, record.uri, record.previewUri, record.mimeType, record.type)
- )
- acc
- }
- }
- selectionTracker.onEndItemsAdded(items)
- for (i in 0 until items.size()) {
- records.add((items.valueAt(i) as Record).apply { isSelected.value = true })
- }
- }
- }
-
- private fun SparseArray<Uri>.toItems(): SparseArray<Item> {
- val items = SparseArray<Item>(size())
- for (i in 0 until size()) {
- val key = keyAt(i)
- val uri = valueAt(i)
- val fileInfo = uriMetadataReader(uri)
- items.append(
- key,
- Record(
- key,
- uri,
- fileInfo.previewUri,
- fileInfo.mimeType,
- fileInfo.mimeType?.mimeTypeToItemType() ?: ItemType.File
- )
- )
- }
- return items
- }
-
- private suspend fun waitForCursorData() = cursorDataRef.await()
-
- private fun notifySelectionChanged(targetIntent: Intent) {
- selectionCallback(targetIntent)?.customActions?.let { customActions.tryEmit(it) }
- }
-
- private suspend fun publishSnapshot(startPos: Int = -1) {
- val (reader, _) = waitForCursorData() ?: return
- // TODO: publish a view into the list as it can only grow on each side thus a view won't be
- // invalidated
- val items = ArrayList<Item>(records)
- stateFlowSource.emit(
- State(
- items,
- reader.hasMoreBefore,
- reader.hasMoreAfter,
- allowSelectionChange = true,
- targetPos = startPos,
- )
- )
- }
-
- private fun String.mimeTypeToItemType(): ItemType =
- when {
- mimeTypeClassifier.isImageType(this) -> ItemType.Image
- mimeTypeClassifier.isVideoType(this) -> ItemType.Video
- else -> ItemType.File
- }
-
- class State(
- val items: List<Item>,
- val hasMoreItemsBefore: Boolean,
- val hasMoreItemsAfter: Boolean,
- val allowSelectionChange: Boolean,
- val targetPos: Int = -1,
- )
-
- sealed interface Item {
- val key: Int
- val uri: Uri
- val previewUri: Uri?
- val mimeType: String?
- val type: ItemType
- }
-
- enum class ItemType {
- Image,
- Video,
- File,
- }
-
- private class Record(
- override val key: Int,
- override val uri: Uri,
- override val previewUri: Uri? = uri,
- override val mimeType: String?,
- override val type: ItemType = ItemType.Image,
- ) : Item {
- val isSelected = MutableStateFlow(false)
- }
-
- data class ShareouselUpdate(
- // for all properties, null value means no change
- val customActions: List<ChooserAction>? = null,
- val modifyShareAction: ChooserAction? = null,
- val alternateIntents: List<Intent>? = null,
- val callerTargets: List<ChooserTarget>? = null,
- val refinementIntentSender: IntentSender? = null,
- )
-
- private data class CursorData(
- val reader: CursorReader,
- val selectionTracker: SelectionTracker<Item>,
- )
-
- interface CursorReader : Closeable {
- val count: Int
- val hasMoreBefore: Boolean
- val hasMoreAfter: Boolean
-
- fun readPageAfter(): SparseArray<Uri>
-
- fun readPageBefore(): SparseArray<Uri>
- }
-}
-
-class PayloadTogglePreviewInteractor(
- private val item: PayloadToggleInteractor.Item,
- private val interactor: PayloadToggleInteractor,
-) {
- fun setSelected(selected: Boolean) {
- interactor.setSelected(item, selected)
- }
-
- val previewUri: Flow<Uri?>
- get() = interactor.previewUri(item)
-
- val selected: Flow<Boolean>
- get() = interactor.selected(item)
-
- val key
- get() = item.key
-}
diff --git a/java/src/com/android/intentresolver/contentpreview/PreviewViewModel.kt b/java/src/com/android/intentresolver/contentpreview/PreviewViewModel.kt
index d694c6ff..6a729945 100644
--- a/java/src/com/android/intentresolver/contentpreview/PreviewViewModel.kt
+++ b/java/src/com/android/intentresolver/contentpreview/PreviewViewModel.kt
@@ -28,10 +28,8 @@ import androidx.lifecycle.viewModelScope
import androidx.lifecycle.viewmodel.CreationExtras
import com.android.intentresolver.R
import com.android.intentresolver.inject.Background
-import java.util.concurrent.Executors
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.Dispatchers
-import kotlinx.coroutines.asCoroutineDispatcher
import kotlinx.coroutines.plus
/** A view model for the preview logic */
@@ -42,9 +40,7 @@ class PreviewViewModel(
@Background private val dispatcher: CoroutineDispatcher = Dispatchers.IO,
) : BasePreviewViewModel() {
private var targetIntent: Intent? = null
- private var chooserIntent: Intent? = null
private var additionalContentUri: Uri? = null
- private var focusedItemIdx: Int = 0
private var isPayloadTogglingEnabled = false
override val previewDataProvider by lazy {
@@ -67,59 +63,19 @@ class PreviewViewModel(
)
}
- override val payloadToggleInteractor: PayloadToggleInteractor? by lazy {
- val targetIntent = requireNotNull(targetIntent) { "Not initialized" }
- // TODO: replace with flags injection
- if (!isPayloadTogglingEnabled) return@lazy null
- createPayloadToggleInteractor(
- additionalContentUri ?: return@lazy null,
- targetIntent,
- chooserIntent ?: return@lazy null,
- )
- .apply { start() }
- }
-
// TODO: make the view model injectable and inject these dependencies instead
@MainThread
override fun init(
targetIntent: Intent,
- chooserIntent: Intent,
additionalContentUri: Uri?,
- focusedItemIdx: Int,
isPayloadTogglingEnabled: Boolean,
) {
if (this.targetIntent != null) return
this.targetIntent = targetIntent
- this.chooserIntent = chooserIntent
this.additionalContentUri = additionalContentUri
- this.focusedItemIdx = focusedItemIdx
this.isPayloadTogglingEnabled = isPayloadTogglingEnabled
}
- private fun createPayloadToggleInteractor(
- contentProviderUri: Uri,
- targetIntent: Intent,
- chooserIntent: Intent,
- ): PayloadToggleInteractor {
- return PayloadToggleInteractor(
- // TODO: update PayloadToggleInteractor to support multiple threads
- viewModelScope + Executors.newSingleThreadScheduledExecutor().asCoroutineDispatcher(),
- previewDataProvider.uris,
- maxOf(0, minOf(focusedItemIdx, previewDataProvider.uriCount - 1)),
- DefaultMimeTypeClassifier,
- {
- CursorUriReader.createCursorReader(
- contentResolver,
- contentProviderUri,
- chooserIntent
- )
- },
- UriMetadataReader(contentResolver, DefaultMimeTypeClassifier),
- TargetIntentModifier(targetIntent, getUri = { uri }, getMimeType = { mimeType }),
- SelectionChangeCallback(contentProviderUri, chooserIntent, contentResolver)
- )
- }
-
companion object {
val Factory: ViewModelProvider.Factory =
object : ViewModelProvider.Factory {
diff --git a/java/src/com/android/intentresolver/contentpreview/SelectionChangeCallback.kt b/java/src/com/android/intentresolver/contentpreview/SelectionChangeCallback.kt
deleted file mode 100644
index 6b33e1cd..00000000
--- a/java/src/com/android/intentresolver/contentpreview/SelectionChangeCallback.kt
+++ /dev/null
@@ -1,97 +0,0 @@
-/*
- * 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.content.Intent
-import android.content.Intent.EXTRA_CHOOSER_MODIFY_SHARE_ACTION
-import android.content.Intent.EXTRA_CHOOSER_REFINEMENT_INTENT_SENDER
-import android.content.Intent.EXTRA_CHOOSER_TARGETS
-import android.content.Intent.EXTRA_INTENT
-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.PayloadToggleInteractor.ShareouselUpdate
-import com.android.intentresolver.v2.ui.viewmodel.readAlternateIntents
-import com.android.intentresolver.v2.ui.viewmodel.readChooserActions
-import com.android.intentresolver.v2.validation.Invalid
-import com.android.intentresolver.v2.validation.Valid
-import com.android.intentresolver.v2.validation.ValidationResult
-import com.android.intentresolver.v2.validation.log
-import com.android.intentresolver.v2.validation.types.array
-import com.android.intentresolver.v2.validation.types.value
-import com.android.intentresolver.v2.validation.validateFrom
-
-private const val TAG = "SelectionChangeCallback"
-
-/**
- * Encapsulates payload change callback invocation to the sharing app; handles callback arguments
- * and result format mapping.
- */
-class SelectionChangeCallback(
- private val uri: Uri,
- private val chooserIntent: Intent,
- private val contentResolver: ContentInterface,
-) : (Intent) -> ShareouselUpdate? {
- fun onSelectionChanged(targetIntent: Intent): ShareouselUpdate? =
- contentResolver
- .call(
- requireNotNull(uri.authority) { "URI authority can not be null" },
- ON_SELECTION_CHANGED,
- uri.toString(),
- Bundle().apply {
- putParcelable(
- EXTRA_INTENT,
- Intent(chooserIntent).apply { putExtra(EXTRA_INTENT, targetIntent) }
- )
- }
- )
- ?.let { bundle ->
- return when (val result = readCallbackResponse(bundle)) {
- is Valid -> result.value
- is Invalid -> {
- result.errors.forEach { it.log(TAG) }
- null
- }
- }
- }
-
- override fun invoke(targetIntent: Intent) = onSelectionChanged(targetIntent)
-
- private fun readCallbackResponse(bundle: Bundle): ValidationResult<ShareouselUpdate> {
- return validateFrom(bundle::get) {
- val customActions = readChooserActions()
- val modifyShareAction =
- optional(value<ChooserAction>(EXTRA_CHOOSER_MODIFY_SHARE_ACTION))
- val alternateIntents = readAlternateIntents()
- val callerTargets = optional(array<ChooserTarget>(EXTRA_CHOOSER_TARGETS))
- val refinementIntentSender =
- optional(value<IntentSender>(EXTRA_CHOOSER_REFINEMENT_INTENT_SENDER))
-
- ShareouselUpdate(
- customActions,
- modifyShareAction,
- alternateIntents,
- callerTargets,
- refinementIntentSender,
- )
- }
- }
-}
diff --git a/java/src/com/android/intentresolver/contentpreview/SelectionTracker.kt b/java/src/com/android/intentresolver/contentpreview/SelectionTracker.kt
deleted file mode 100644
index c9431731..00000000
--- a/java/src/com/android/intentresolver/contentpreview/SelectionTracker.kt
+++ /dev/null
@@ -1,175 +0,0 @@
-/*
- * 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.net.Uri
-import android.util.SparseArray
-import android.util.SparseIntArray
-import androidx.core.util.containsKey
-import androidx.core.util.isNotEmpty
-
-/**
- * Tracks selected items (including those that has not been read frm the cursor) and their relative
- * order.
- */
-class SelectionTracker<Item>(
- selectedItems: List<Item>,
- private val focusedItemIdx: Int,
- private val cursorCount: Int,
- private val getUri: Item.() -> Uri,
-) {
- /** Contains selected items keys. */
- private val selections = SparseArray<Item>(selectedItems.size)
-
- /**
- * A set of initially selected items that has not yet been observed by the lazy read of the
- * cursor and thus has unknown key (cursor position). Initially, all [selectedItems] are put in
- * this map with items at the index less than [focusedItemIdx] with negative keys (to the left
- * of all cursor items) and items at the index more or equal to [focusedItemIdx] with keys more
- * or equal to [cursorCount] (to the right of all cursor items) in their relative order. Upon
- * reading the cursor, [onEndItemsAdded]/[onStartItemsAdded], all pending items from that
- * collection in the corresponding direction get their key assigned and gets removed from the
- * map. Items that were missing from the cursor get removed from the map by
- * [getPendingItems] + [onStartItemsAdded]/[onEndItemsAdded] combination.
- */
- private val pendingKeys = HashMap<Uri, SparseIntArray>()
-
- init {
- selectedItems.forEachIndexed { i, item ->
- // all items before focusedItemIdx gets "positioned" before all the cursor items
- // and all the reset after all the cursor items in their relative order.
- // Also see the comments to pendingKeys property.
- val key =
- if (i < focusedItemIdx) {
- i - focusedItemIdx
- } else {
- i + cursorCount - focusedItemIdx
- }
- selections.append(key, item)
- pendingKeys.getOrPut(item.getUri()) { SparseIntArray(1) }.append(key, key)
- }
- }
-
- /** Update selections based on the set of items read from the end of the cursor */
- fun onEndItemsAdded(items: SparseArray<Item>) {
- for (i in 0 until items.size()) {
- val item = items.valueAt(i)
- pendingKeys[item.getUri()]
- // if only one pending (unmatched) item with this URI is left, removed this URI
- ?.also {
- if (it.size() <= 1) {
- pendingKeys.remove(item.getUri())
- }
- }
- // a safeguard, we should not observe empty arrays at this point
- ?.takeIf { it.isNotEmpty() }
- // pick a matching pending items from the right side
- ?.let { pendingUriPositions ->
- val key = items.keyAt(i)
- val insertPos =
- pendingUriPositions
- .findBestKeyPosition(key)
- .coerceIn(0, pendingUriPositions.size() - 1)
- // select next pending item from the right, if not such item exists then
- // the data is inconsistent and we pick the closes one from the left
- val keyPlaceholder = pendingUriPositions.keyAt(insertPos)
- pendingUriPositions.removeAt(insertPos)
- selections.remove(keyPlaceholder)
- selections[key] = item
- }
- }
- }
-
- /** Update selections based on the set of items read from the head of the cursor */
- fun onStartItemsAdded(items: SparseArray<Item>) {
- for (i in (items.size() - 1) downTo 0) {
- val item = items.valueAt(i)
- pendingKeys[item.getUri()]
- // if only one pending (unmatched) item with this URI is left, removed this URI
- ?.also {
- if (it.size() <= 1) {
- pendingKeys.remove(item.getUri())
- }
- }
- // a safeguard, we should not observe empty arrays at this point
- ?.takeIf { it.isNotEmpty() }
- // pick a matching pending items from the left side
- ?.let { pendingUriPositions ->
- val key = items.keyAt(i)
- val insertPos =
- pendingUriPositions
- .findBestKeyPosition(key)
- .coerceIn(1, pendingUriPositions.size())
- // select next pending item from the left, if not such item exists then
- // the data is inconsistent and we pick the closes one from the right
- val keyPlaceholder = pendingUriPositions.keyAt(insertPos - 1)
- pendingUriPositions.removeAt(insertPos - 1)
- selections.remove(keyPlaceholder)
- selections[key] = item
- }
- }
- }
-
- /** Updated selection status for the given item */
- fun setItemSelection(key: Int, item: Item, isSelected: Boolean): Boolean {
- val idx = selections.indexOfKey(key)
- if (isSelected && idx < 0) {
- selections[key] = item
- return true
- }
- if (!isSelected && idx >= 0 && selections.size() > 1) {
- selections.removeAt(idx)
- return true
- }
- return false
- }
-
- /** Return selection status for the given item */
- fun isItemSelected(key: Int): Boolean = selections.containsKey(key)
-
- fun getSelection(): List<Item> =
- buildList(selections.size()) {
- for (i in 0 until selections.size()) {
- add(selections.valueAt(i))
- }
- }
-
- /** Return all selected items that has not yet been read from the cursor */
- fun getPendingItems(): List<Item> =
- if (pendingKeys.isEmpty()) {
- emptyList()
- } else {
- buildList {
- for (i in 0 until selections.size()) {
- val item = selections.valueAt(i) ?: continue
- if (isPending(item, selections.keyAt(i))) {
- add(item)
- }
- }
- }
- }
-
- private fun isPending(item: Item, key: Int): Boolean {
- val keys = pendingKeys[item.getUri()] ?: return false
- return keys.containsKey(key)
- }
-
- private fun SparseIntArray.findBestKeyPosition(key: Int): Int =
- // undocumented, but indexOfKey behaves in the same was as
- // java.util.Collections#binarySearch()
- indexOfKey(key).let { if (it < 0) it.inv() else it }
-}
diff --git a/java/src/com/android/intentresolver/contentpreview/ShareouselContentPreviewUi.kt b/java/src/com/android/intentresolver/contentpreview/ShareouselContentPreviewUi.kt
index 82c09986..3530ede1 100644
--- a/java/src/com/android/intentresolver/contentpreview/ShareouselContentPreviewUi.kt
+++ b/java/src/com/android/intentresolver/contentpreview/ShareouselContentPreviewUi.kt
@@ -22,32 +22,20 @@ import android.view.ViewGroup
import android.widget.TextView
import androidx.annotation.VisibleForTesting
import androidx.compose.foundation.isSystemInDarkTheme
-import androidx.compose.foundation.layout.Spacer
-import androidx.compose.foundation.layout.height
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.dynamicDarkColorScheme
import androidx.compose.material3.dynamicLightColorScheme
import androidx.compose.runtime.LaunchedEffect
-import androidx.compose.runtime.getValue
-import androidx.compose.runtime.mutableStateOf
-import androidx.compose.runtime.remember
-import androidx.compose.runtime.setValue
-import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.ComposeView
import androidx.compose.ui.platform.LocalContext
-import androidx.compose.ui.res.dimensionResource
-import androidx.lifecycle.viewModelScope
import androidx.lifecycle.viewmodel.compose.viewModel
import com.android.intentresolver.R
-import com.android.intentresolver.contentpreview.ChooserContentPreviewUi.ActionFactory
-import com.android.intentresolver.contentpreview.shareousel.ui.composable.Shareousel
-import com.android.intentresolver.contentpreview.shareousel.ui.viewmodel.ShareouselViewModel
-import com.android.intentresolver.contentpreview.shareousel.ui.viewmodel.toShareouselViewModel
+import com.android.intentresolver.contentpreview.payloadtoggle.ui.composable.Shareousel
+import com.android.intentresolver.contentpreview.payloadtoggle.ui.viewmodel.ShareouselViewModel
+import com.android.intentresolver.v2.ui.viewmodel.ChooserViewModel
@VisibleForTesting(otherwise = VisibleForTesting.PACKAGE_PRIVATE)
-class ShareouselContentPreviewUi(
- private val actionFactory: ActionFactory,
-) : ContentPreviewUi() {
+class ShareouselContentPreviewUi : ContentPreviewUi() {
override fun getType(): Int = ContentPreviewType.CONTENT_PREVIEW_IMAGE
@@ -56,76 +44,45 @@ class ShareouselContentPreviewUi(
layoutInflater: LayoutInflater,
parent: ViewGroup,
headlineViewParent: View?,
- ): ViewGroup {
- return displayInternal(parent, headlineViewParent).also { layout ->
- displayModifyShareAction(headlineViewParent ?: layout, actionFactory)
- }
- }
+ ): ViewGroup = displayInternal(parent, headlineViewParent)
- private fun displayInternal(
- parent: ViewGroup,
- headlineViewParent: View?,
- ): ViewGroup {
+ private fun displayInternal(parent: ViewGroup, headlineViewParent: View?): ViewGroup {
if (headlineViewParent != null) {
inflateHeadline(headlineViewParent)
}
- val composeView =
- ComposeView(parent.context).apply {
- setContent {
- val vm: BasePreviewViewModel = viewModel()
- val interactor =
- requireNotNull(vm.payloadToggleInteractor) { "Should not be null" }
+ return ComposeView(parent.context).apply {
+ setContent {
+ val vm: ChooserViewModel = viewModel()
+ val viewModel: ShareouselViewModel = vm.shareouselViewModel
- var viewModel by remember { mutableStateOf<ShareouselViewModel?>(null) }
- LaunchedEffect(Unit) {
- viewModel =
- interactor.toShareouselViewModel(
- vm.imageLoader,
- actionFactory,
- vm.viewModelScope
- )
- }
+ headlineViewParent?.let {
+ LaunchedEffect(viewModel) { bindHeadline(viewModel, headlineViewParent) }
+ }
- headlineViewParent?.let {
- viewModel?.let { viewModel ->
- LaunchedEffect(viewModel) {
- viewModel.headline.collect { headline ->
- headlineViewParent
- .findViewById<TextView>(R.id.headline)
- ?.apply {
- if (headline.isNotBlank()) {
- text = headline
- visibility = View.VISIBLE
- } else {
- visibility = View.GONE
- }
- }
- }
- }
- }
- }
+ MaterialTheme(
+ colorScheme =
+ if (isSystemInDarkTheme()) {
+ dynamicDarkColorScheme(LocalContext.current)
+ } else {
+ dynamicLightColorScheme(LocalContext.current)
+ },
+ ) {
+ Shareousel(viewModel)
+ }
+ }
+ }
+ }
- viewModel?.let { viewModel ->
- MaterialTheme(
- colorScheme =
- if (isSystemInDarkTheme()) {
- dynamicDarkColorScheme(LocalContext.current)
- } else {
- dynamicLightColorScheme(LocalContext.current)
- },
- ) {
- Shareousel(viewModel = viewModel)
- }
- }
- ?: run {
- Spacer(
- Modifier.height(
- dimensionResource(R.dimen.chooser_preview_image_height_tall)
- )
- )
- }
+ 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
}
}
- return composeView
+ }
}
}
diff --git a/java/src/com/android/intentresolver/contentpreview/TextContentPreviewUi.java b/java/src/com/android/intentresolver/contentpreview/TextContentPreviewUi.java
index fbdc5853..a7ae81b0 100644
--- a/java/src/com/android/intentresolver/contentpreview/TextContentPreviewUi.java
+++ b/java/src/com/android/intentresolver/contentpreview/TextContentPreviewUi.java
@@ -83,10 +83,7 @@ class TextContentPreviewUi extends ContentPreviewUi {
LayoutInflater layoutInflater,
ViewGroup parent,
@Nullable View headlineViewParent) {
- ViewGroup layout = displayInternal(layoutInflater, parent, headlineViewParent);
- displayModifyShareAction(
- headlineViewParent == null ? layout : headlineViewParent, mActionFactory);
- return layout;
+ return displayInternal(layoutInflater, parent, headlineViewParent);
}
private ViewGroup displayInternal(
diff --git a/java/src/com/android/intentresolver/contentpreview/UnifiedContentPreviewUi.java b/java/src/com/android/intentresolver/contentpreview/UnifiedContentPreviewUi.java
index 0974c79b..b248e429 100644
--- a/java/src/com/android/intentresolver/contentpreview/UnifiedContentPreviewUi.java
+++ b/java/src/com/android/intentresolver/contentpreview/UnifiedContentPreviewUi.java
@@ -94,10 +94,7 @@ class UnifiedContentPreviewUi extends ContentPreviewUi {
LayoutInflater layoutInflater,
ViewGroup parent,
@Nullable View headlineViewParent) {
- ViewGroup layout = displayInternal(layoutInflater, parent, headlineViewParent);
- displayModifyShareAction(
- headlineViewParent == null ? layout : headlineViewParent, mActionFactory);
- return layout;
+ return displayInternal(layoutInflater, parent, headlineViewParent);
}
private void setFiles(List<FileInfo> files) {
diff --git a/java/src/com/android/intentresolver/contentpreview/UriMetadataReader.kt b/java/src/com/android/intentresolver/contentpreview/UriMetadataReader.kt
index 45515e25..b5361889 100644
--- a/java/src/com/android/intentresolver/contentpreview/UriMetadataReader.kt
+++ b/java/src/com/android/intentresolver/contentpreview/UriMetadataReader.kt
@@ -20,12 +20,24 @@ import android.content.ContentInterface
import android.media.MediaMetadata
import android.net.Uri
import android.provider.DocumentsContract
+import dagger.Binds
+import dagger.Module
+import dagger.Provides
+import dagger.hilt.InstallIn
+import dagger.hilt.components.SingletonComponent
+import javax.inject.Inject
-class UriMetadataReader(
+fun interface UriMetadataReader {
+ fun getMetadata(uri: Uri): FileInfo
+}
+
+class UriMetadataReaderImpl
+@Inject
+constructor(
private val contentResolver: ContentInterface,
private val typeClassifier: MimeTypeClassifier,
-) : (Uri) -> FileInfo {
- fun getMetadata(uri: Uri): FileInfo {
+) : UriMetadataReader {
+ override fun getMetadata(uri: Uri): FileInfo {
val builder = FileInfo.Builder(uri)
val mimeType = contentResolver.getTypeSafe(uri)
builder.withMimeType(mimeType)
@@ -44,8 +56,6 @@ class UriMetadataReader(
return builder.build()
}
- override fun invoke(uri: Uri): FileInfo = getMetadata(uri)
-
private fun ContentInterface.supportsImageType(uri: Uri): Boolean =
getStreamTypesSafe(uri).firstOrNull { typeClassifier.isImageType(it) } != null
@@ -64,3 +74,14 @@ class UriMetadataReader(
}
}
}
+
+@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..9aecc981
--- /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(emptySet<PreviewModel>())
+}
diff --git a/java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/cursor/CursorResolver.kt b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/cursor/CursorResolver.kt
new file mode 100644
index 00000000..3aa0d567
--- /dev/null
+++ b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/cursor/CursorResolver.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.cursor
+
+import com.android.intentresolver.util.cursor.CursorView
+
+/** 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..3cf2af13
--- /dev/null
+++ b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/cursor/PayloadToggleCursorResolver.kt
@@ -0,0 +1,69 @@
+/*
+ * Copyright (C) 2024 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.intentresolver.contentpreview.payloadtoggle.domain.cursor
+
+import android.content.ContentResolver
+import android.content.Intent
+import android.net.Uri
+import android.service.chooser.AdditionalContentContract.Columns.URI
+import androidx.core.os.bundleOf
+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: ContentResolver,
+ @AdditionalContent private val cursorUri: Uri,
+ @ChooserIntent private val chooserIntent: Intent,
+) : CursorResolver<Uri?> {
+ override suspend fun getCursor(): CursorView<Uri?>? = withCancellationSignal { signal ->
+ runCatching {
+ contentResolver.query(
+ cursorUri,
+ arrayOf(URI),
+ bundleOf(Intent.EXTRA_INTENT to chooserIntent),
+ signal,
+ )
+ }
+ .getOrNull()
+ ?.viewBy {
+ getString(0)?.let(Uri::parse)?.takeIf { it.authority != cursorUri.authority }
+ }
+ }
+
+ @Module
+ @InstallIn(ViewModelComponent::class)
+ interface Binding {
+ @Binds
+ @PayloadToggle
+ fun bind(cursorResolver: PayloadToggleCursorResolver): CursorResolver<Uri?>
+ }
+}
+
+/** [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/TargetIntentModifier.kt b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/intent/TargetIntentModifier.kt
index 58da5bc4..4a2a6932 100644
--- a/java/src/com/android/intentresolver/contentpreview/TargetIntentModifier.kt
+++ b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/intent/TargetIntentModifier.kt
@@ -1,5 +1,5 @@
/*
- * Copyright 2024 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.contentpreview
+package com.android.intentresolver.contentpreview.payloadtoggle.domain.intent
import android.content.ClipData
import android.content.ClipDescription.compareMimeTypes
@@ -23,29 +23,38 @@ 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. */
-class TargetIntentModifier<Item>(
+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?,
-) : (List<Item>) -> Intent {
- fun onSelectionChanged(selection: List<Item>): Intent {
- val uris = ArrayList<Uri>(selection.size)
- var targetMimeType: String? = null
- for (item in selection) {
- targetMimeType = updateMimeType(item.getMimeType(), targetMimeType)
- uris.add(item.getUri())
- }
- val action = if (uris.size == 1) ACTION_SEND else ACTION_SEND_MULTIPLE
+) : 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 {
- this.action = action
- this.type = targetMimeType
- if (action == ACTION_SEND) {
- putExtra(EXTRA_STREAM, uris[0])
+ 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 {
@@ -70,6 +79,14 @@ class TargetIntentModifier<Item>(
}
return "*/*"
}
+}
- override fun invoke(selection: List<Item>): Intent = onSelectionChanged(selection)
+@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..61c04ac1
--- /dev/null
+++ b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/interactor/ChooserRequestInteractor.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.contentpreview.payloadtoggle.domain.interactor
+
+import android.content.Intent
+import com.android.intentresolver.contentpreview.payloadtoggle.data.model.CustomActionModel
+import com.android.intentresolver.v2.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()
+}
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..f642f420
--- /dev/null
+++ b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/interactor/CursorPreviewsInteractor.kt
@@ -0,0 +1,294 @@
+/*
+ * Copyright (C) 2024 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT 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.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<Uri?>, 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<Uri?> = uriCursor.paged(pageSize)
+ val startPosition = uriCursor.extras?.getInt(POSITION, 0) ?: 0
+ val state = readInitialState(pagedCursor, startPosition, unclaimedRecords)
+ processLoadRequests(state, pagedCursor, unclaimedRecords)
+ }
+
+ /** Loop forever, processing any loading requests from the UI and updating local cache. */
+ private suspend fun processLoadRequests(
+ initialState: CursorWindow,
+ pagedCursor: PagedCursor<Uri?>,
+ 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(
+ previewsByKey = state.merged.values.toSet(),
+ 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<Uri?>,
+ 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<Uri?>,
+ 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.getPageUris(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.getPageUris(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<Uri?>,
+ 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<Uri?>,
+ 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<Uri?>,
+ pageNum: Int,
+ unclaimedRecords: MutableUnclaimedMap,
+ ): PreviewMap =
+ mutableMapOf<Uri, PreviewModel>()
+ .readAndPutPage(state, pagedCursor, pageNum, unclaimedRecords)
+
+ private suspend fun <M : MutablePreviewMap> M.readAndPutPage(
+ state: CursorWindow,
+ pagedCursor: PagedCursor<Uri?>,
+ pageNum: Int,
+ unclaimedRecords: MutableUnclaimedMap,
+ ): M =
+ pagedCursor
+ .getPageUris(pageNum) // TODO: what do we do if the load fails?
+ ?.filter { it !in state.merged }
+ ?.toPage(this, unclaimedRecords)
+ ?: this
+
+ private suspend fun <M : MutablePreviewMap> Sequence<Uri>.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) { uri -> createPreviewModel(uri, unclaimedRecords) }
+ .associateByTo(destination) { it.uri }
+
+ private fun createPreviewModel(uri: Uri, unclaimedRecords: MutableUnclaimedMap): PreviewModel =
+ unclaimedRecords.remove(uri)?.second
+ ?: PreviewModel(
+ uri = uri,
+ mimeType = uriMetadataReader.getMetadata(uri).mimeType,
+ )
+
+ 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<Uri?>.getPageUris(pageNum: Int): Sequence<Uri>? =
+ 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 = 3
+}
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..9bc7ae63
--- /dev/null
+++ b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/interactor/FetchPreviewsInteractor.kt
@@ -0,0 +1,65 @@
+/*
+ * Copyright (C) 2024 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.intentresolver.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.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 Uri?>,
+) {
+ suspend fun activate() = coroutineScope {
+ val cursor = async { cursorResolver.getCursor() }
+ val initialPreviewMap: Set<PreviewModel> = getInitialPreviews()
+ selectionRepository.selections.value = initialPreviewMap
+ setCursorPreviews.setPreviews(
+ previewsByKey = initialPreviewMap,
+ startIndex = focusedItemIdx,
+ hasMoreLeft = false,
+ hasMoreRight = false,
+ )
+ cursorInteractor.launch(cursor.await() ?: return@coroutineScope, initialPreviewMap)
+ }
+
+ private suspend fun getInitialPreviews(): Set<PreviewModel> =
+ selectedItems
+ // Restrict parallelism so as to not overload the metadata reader; anecdotally, too
+ // many parallel queries causes failures.
+ .mapParallel(parallelism = 4) { uri ->
+ PreviewModel(uri = uri, mimeType = uriMetadataReader.getMetadata(uri).mimeType)
+ }
+ .toSet()
+}
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..04416a3d
--- /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(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..a570f36e
--- /dev/null
+++ b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/interactor/SelectionInteractor.kt
@@ -0,0 +1,54 @@
+/*
+ * Copyright (C) 2024 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF 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.PreviewSelectionsRepository
+import com.android.intentresolver.contentpreview.payloadtoggle.domain.intent.TargetIntentModifier
+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,
+) {
+ /** Set of selected previews. */
+ val selections: StateFlow<Set<PreviewModel>>
+ get() = selectionsRepo.selections
+
+ /** Amount of selected previews. */
+ val amountSelected: Flow<Int> = selectionsRepo.selections.map { it.size }
+
+ fun select(model: PreviewModel) {
+ updateChooserRequest(selectionsRepo.selections.updateAndGet { it + model })
+ }
+
+ fun unselect(model: PreviewModel) {
+ updateChooserRequest(selectionsRepo.selections.updateAndGet { it - model })
+ }
+
+ private fun updateChooserRequest(selections: Set<PreviewModel>) {
+ val intent = targetIntentModifier.intentFromSelection(selections)
+ updateTargetIntentInteractor.updateTargetIntent(intent)
+ }
+}
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..21a599fa
--- /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 [previewsByKey], and returns a flow of load requests triggered by Shareousel. */
+ fun setPreviews(
+ previewsByKey: Set<PreviewModel>,
+ startIndex: Int,
+ hasMoreLeft: Boolean,
+ hasMoreRight: Boolean,
+ ): Flow<LoadDirection?> {
+ val loadingState = MutableStateFlow<LoadDirection?>(null)
+ previewsRepo.previewsModel.value =
+ PreviewsModel(
+ previewModels = previewsByKey,
+ 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/UpdateChooserRequestInteractor.kt b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/interactor/UpdateChooserRequestInteractor.kt
new file mode 100644
index 00000000..9e48cd28
--- /dev/null
+++ b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/interactor/UpdateChooserRequestInteractor.kt
@@ -0,0 +1,62 @@
+/*
+ * Copyright (C) 2024 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF 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.v2.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(update: ShareouselUpdate) {
+ repository.chooserRequest.update { current ->
+ current.copy(
+ 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..429e34e9
--- /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) {
+ chooserRequestInteractor.setTargetIntent(targetIntent)
+ repository.pendingTargetIntent.value = 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/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..20af264a
--- /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.v2.ui.viewmodel.readAlternateIntents
+import com.android.intentresolver.v2.ui.viewmodel.readChooserActions
+import com.android.intentresolver.v2.validation.Invalid
+import com.android.intentresolver.v2.validation.Valid
+import com.android.intentresolver.v2.validation.ValidationResult
+import com.android.intentresolver.v2.validation.log
+import com.android.intentresolver.v2.validation.types.array
+import com.android.intentresolver.v2.validation.types.value
+import com.android.intentresolver.v2.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/model/PreviewModel.kt b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/shared/model/PreviewModel.kt
new file mode 100644
index 00000000..ff96a9f4
--- /dev/null
+++ b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/shared/model/PreviewModel.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.shared.model
+
+import android.net.Uri
+
+/** An individual preview presented in Shareousel. */
+data class PreviewModel(
+ /**
+ * Uri for this preview; if this preview is selected, this will be shared with the target app.
+ */
+ val uri: Uri,
+ /** Mimetype for the data [uri] points to. */
+ val mimeType: String?,
+)
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..0ac99bd3
--- /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: Set<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/shareousel/ui/composable/ComposeIconComposable.kt b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/ui/composable/ComposeIconComposable.kt
index 87fb7618..38138225 100644
--- a/java/src/com/android/intentresolver/contentpreview/shareousel/ui/composable/ComposeIconComposable.kt
+++ b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/ui/composable/ComposeIconComposable.kt
@@ -13,7 +13,7 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
-package com.android.intentresolver.contentpreview.shareousel.ui.composable
+package com.android.intentresolver.contentpreview.payloadtoggle.ui.composable
import android.content.Context
import android.content.ContextWrapper
@@ -21,6 +21,7 @@ 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.asImageBitmap
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
@@ -30,10 +31,11 @@ import com.android.intentresolver.icon.ComposeIcon
import com.android.intentresolver.icon.ResourceIcon
@Composable
-fun Image(icon: ComposeIcon) {
+fun Image(icon: ComposeIcon, modifier: Modifier = Modifier) {
when (icon) {
- is AdaptiveIcon -> Image(icon.wrapped)
- is BitmapIcon -> Image(icon.bitmap.asImageBitmap(), contentDescription = null)
+ is AdaptiveIcon -> Image(icon.wrapped, modifier)
+ is BitmapIcon ->
+ Image(icon.bitmap.asImageBitmap(), contentDescription = null, modifier = modifier)
is ResourceIcon -> {
val localContext = LocalContext.current
val wrappedContext: Context =
@@ -41,7 +43,7 @@ fun Image(icon: ComposeIcon) {
override fun getResources(): Resources = icon.res
}
CompositionLocalProvider(LocalContext provides wrappedContext) {
- Image(painterResource(icon.resId), contentDescription = null)
+ Image(painterResource(icon.resId), contentDescription = null, modifier = modifier)
}
}
}
diff --git a/java/src/com/android/intentresolver/contentpreview/shareousel/ui/composable/ShareouselCardComposable.kt b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/ui/composable/ShareouselCardComposable.kt
index dc96e3c1..f33558c7 100644
--- a/java/src/com/android/intentresolver/contentpreview/shareousel/ui/composable/ShareouselCardComposable.kt
+++ b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/ui/composable/ShareouselCardComposable.kt
@@ -13,7 +13,7 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
-package com.android.intentresolver.contentpreview.shareousel.ui.composable
+package com.android.intentresolver.contentpreview.payloadtoggle.ui.composable
import androidx.compose.foundation.background
import androidx.compose.foundation.border
@@ -33,10 +33,12 @@ 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.ui.viewmodel.ContentType
@Composable
fun ShareouselCard(
image: @Composable () -> Unit,
+ contentType: ContentType,
selected: Boolean,
modifier: Modifier = Modifier,
) {
@@ -45,7 +47,9 @@ fun ShareouselCard(
val topButtonPadding = 12.dp
Box(modifier = Modifier.padding(topButtonPadding).matchParentSize()) {
SelectionIcon(selected, modifier = Modifier.align(Alignment.TopStart))
- AnimationIcon(modifier = Modifier.align(Alignment.TopEnd))
+ if (contentType == ContentType.Video) {
+ AnimationIcon(modifier = Modifier.align(Alignment.TopEnd))
+ }
}
}
}
diff --git a/java/src/com/android/intentresolver/contentpreview/shareousel/ui/composable/ShareouselComposable.kt b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/ui/composable/ShareouselComposable.kt
index 5cf35297..feb6f3a8 100644
--- a/java/src/com/android/intentresolver/contentpreview/shareousel/ui/composable/ShareouselComposable.kt
+++ b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/ui/composable/ShareouselComposable.kt
@@ -13,9 +13,10 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
-package com.android.intentresolver.contentpreview.shareousel.ui.composable
+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.clickable
import androidx.compose.foundation.layout.Arrangement
@@ -23,10 +24,14 @@ 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.lazy.LazyRow
import androidx.compose.foundation.lazy.items
+import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.AssistChip
@@ -34,65 +39,78 @@ 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.Color
+import androidx.compose.ui.graphics.RectangleShape
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.res.dimensionResource
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.android.intentresolver.R
-import com.android.intentresolver.contentpreview.shareousel.ui.viewmodel.ShareouselImageViewModel
-import com.android.intentresolver.contentpreview.shareousel.ui.viewmodel.ShareouselViewModel
+import com.android.intentresolver.contentpreview.payloadtoggle.shared.model.PreviewsModel
+import com.android.intentresolver.contentpreview.payloadtoggle.ui.viewmodel.ContentType
+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 centerIdx = viewModel.centerIndex.value
- val carouselState = rememberLazyListState(initialFirstVisibleItemIndex = centerIdx)
- val previewKeys by viewModel.previewKeys.collectAsStateWithLifecycle()
- Column {
- // TODO: 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))
- ) {
- items(previewKeys, key = viewModel.previewRowKey) { key ->
- ShareouselCard(viewModel.previewForKey(key))
- }
- }
- Spacer(modifier = Modifier.height(8.dp))
+ 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)
+ )
+ }
+}
- val actions by viewModel.actions.collectAsStateWithLifecycle(initialValue = emptyList())
- LazyRow(
- horizontalArrangement = Arrangement.spacedBy(4.dp),
- ) {
- items(actions) { actionViewModel ->
- ShareouselAction(
- label = actionViewModel.label,
- onClick = actionViewModel.onClick,
- ) {
- actionViewModel.icon?.let { Image(it) }
- }
- }
- }
+@Composable
+private fun Shareousel(viewModel: ShareouselViewModel, keySet: PreviewsModel) {
+ Column(
+ modifier =
+ Modifier.background(MaterialTheme.colorScheme.surfaceContainer)
+ .padding(vertical = 16.dp),
+ ) {
+ PreviewCarousel(keySet, viewModel)
+ Spacer(Modifier.height(16.dp))
+ ActionCarousel(viewModel)
}
}
-private const val MIN_ASPECT_RATIO = 0.4f
-private const val MAX_ASPECT_RATIO = 2.5f
+@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))
+ ) {
+ items(previews.previewModels.toList(), key = { it.uri }) { model ->
+ ShareouselCard(viewModel.preview(model))
+ }
+ }
+}
@Composable
-private fun ShareouselCard(viewModel: ShareouselImageViewModel) {
+private fun ShareouselCard(viewModel: ShareouselPreviewViewModel) {
val bitmap by viewModel.bitmap.collectAsStateWithLifecycle(initialValue = null)
val selected by viewModel.isSelected.collectAsStateWithLifecycle(initialValue = false)
- val contentDescription by
- viewModel.contentDescription.collectAsStateWithLifecycle(initialValue = null)
+ val contentType by
+ viewModel.contentType.collectAsStateWithLifecycle(initialValue = ContentType.Image)
val borderColor = MaterialTheme.colorScheme.primary
-
+ val scope = rememberCoroutineScope()
ShareouselCard(
image = {
bitmap?.let { bitmap ->
@@ -102,31 +120,55 @@ private fun ShareouselCard(viewModel: ShareouselImageViewModel) {
.coerceIn(MIN_ASPECT_RATIO, MAX_ASPECT_RATIO)
Image(
bitmap = bitmap.asImageBitmap(),
- contentDescription = contentDescription,
+ contentDescription = null,
contentScale = ContentScale.Crop,
modifier = Modifier.aspectRatio(aspectRatio),
)
}
?: run {
// TODO: look at ScrollableImagePreviewView.setLoading()
- Box(modifier = Modifier.aspectRatio(2f / 5f))
+ Box(
+ modifier =
+ Modifier.fillMaxHeight()
+ .aspectRatio(2f / 5f)
+ .border(1.dp, Color.Red, RectangleShape)
+ )
}
},
+ contentType = contentType,
selected = selected,
modifier =
Modifier.thenIf(selected) {
Modifier.border(
width = 4.dp,
color = borderColor,
- shape = RoundedCornerShape(size = 12.dp)
+ shape = RoundedCornerShape(size = 12.dp),
)
}
.clip(RoundedCornerShape(size = 12.dp))
- .clickable { viewModel.setSelected(!selected) },
+ .clickable { scope.launch { viewModel.setSelected(!selected) } },
)
}
@Composable
+private fun ActionCarousel(viewModel: ShareouselViewModel) {
+ val actions by viewModel.actions.collectAsStateWithLifecycle(initialValue = emptyList())
+ LazyRow(
+ horizontalArrangement = Arrangement.spacedBy(4.dp),
+ modifier = Modifier.height(32.dp),
+ ) {
+ itemsIndexed(actions) { idx, actionViewModel ->
+ ShareouselAction(
+ label = actionViewModel.label,
+ onClick = { actionViewModel.onClicked() },
+ ) {
+ actionViewModel.icon?.let { Image(icon = it, modifier = Modifier.size(16.dp)) }
+ }
+ }
+ }
+}
+
+@Composable
private fun ShareouselAction(
label: String,
onClick: () -> Unit,
@@ -143,3 +185,6 @@ private fun ShareouselAction(
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/com/android/intentresolver/contentpreview/payloadtoggle/ui/viewmodel/ActionChipViewModel.kt b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/ui/viewmodel/ActionChipViewModel.kt
new file mode 100644
index 00000000..728c573b
--- /dev/null
+++ b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/ui/viewmodel/ActionChipViewModel.kt
@@ -0,0 +1,29 @@
+/*
+ * Copyright (C) 2024 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.intentresolver.contentpreview.payloadtoggle.ui.viewmodel
+
+import com.android.intentresolver.icon.ComposeIcon
+
+/** An action chip presented to the user underneath Shareousel. */
+data class ActionChipViewModel(
+ /** Text label. */
+ val label: String,
+ /** Optional icon, displayed next to the text label. */
+ val icon: ComposeIcon?,
+ /** Handles user clicks on this action in the UI. */
+ val onClicked: () -> Unit,
+)
diff --git a/java/src/com/android/intentresolver/contentpreview/payloadtoggle/ui/viewmodel/ShareouselPreviewViewModel.kt b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/ui/viewmodel/ShareouselPreviewViewModel.kt
new file mode 100644
index 00000000..a245b3e3
--- /dev/null
+++ b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/ui/viewmodel/ShareouselPreviewViewModel.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.contentpreview.payloadtoggle.ui.viewmodel
+
+import android.graphics.Bitmap
+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: Flow<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,
+)
+
+/** Type of the content being previewed. */
+enum class ContentType {
+ Image,
+ Video,
+ Other
+}
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..6eccaffa
--- /dev/null
+++ b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/ui/viewmodel/ShareouselViewModel.kt
@@ -0,0 +1,141 @@
+/*
+ * Copyright (C) 2024 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF 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.content.Context
+import com.android.intentresolver.R
+import com.android.intentresolver.contentpreview.HeadlineGenerator
+import com.android.intentresolver.contentpreview.ImageLoader
+import com.android.intentresolver.contentpreview.ImagePreviewImageLoader
+import com.android.intentresolver.contentpreview.payloadtoggle.domain.cursor.PayloadToggle
+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.model.PreviewModel
+import com.android.intentresolver.contentpreview.payloadtoggle.shared.model.PreviewsModel
+import com.android.intentresolver.inject.Background
+import com.android.intentresolver.inject.ViewModelOwned
+import dagger.Module
+import dagger.Provides
+import dagger.hilt.InstallIn
+import dagger.hilt.android.components.ViewModelComponent
+import dagger.hilt.android.qualifiers.ApplicationContext
+import kotlinx.coroutines.CoroutineDispatcher
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.SharingStarted
+import kotlinx.coroutines.flow.flow
+import kotlinx.coroutines.flow.flowOf
+import kotlinx.coroutines.flow.map
+import kotlinx.coroutines.flow.stateIn
+import kotlinx.coroutines.plus
+
+/** 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>,
+ /**
+ * 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: (key: PreviewModel) -> ShareouselPreviewViewModel,
+)
+
+@Module
+@InstallIn(ViewModelComponent::class)
+object ShareouselViewModelModule {
+ @Provides
+ fun create(
+ interactor: SelectablePreviewsInteractor,
+ @PayloadToggle imageLoader: ImageLoader,
+ actionsInteractor: CustomActionsInteractor,
+ headlineGenerator: HeadlineGenerator,
+ selectionInteractor: SelectionInteractor,
+ // TODO: remove if possible
+ @ViewModelOwned scope: CoroutineScope,
+ ): ShareouselViewModel {
+ val keySet =
+ interactor.previews.stateIn(
+ scope,
+ SharingStarted.Eagerly,
+ initialValue = null,
+ )
+ return ShareouselViewModel(
+ headline =
+ selectionInteractor.amountSelected.map { numItems ->
+ val contentType = ContentType.Image // TODO: convert from metadata
+ when (contentType) {
+ ContentType.Other -> headlineGenerator.getFilesHeadline(numItems)
+ ContentType.Image -> headlineGenerator.getImagesHeadline(numItems)
+ ContentType.Video -> headlineGenerator.getVideosHeadline(numItems)
+ }
+ },
+ 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 = { key ->
+ keySet.value?.maybeLoad(key)
+ val previewInteractor = interactor.preview(key)
+ ShareouselPreviewViewModel(
+ bitmap = flow { emit(imageLoader(key.uri)) },
+ contentType = flowOf(ContentType.Image), // TODO: convert from metadata
+ isSelected = previewInteractor.isSelected,
+ setSelected = previewInteractor::setSelected,
+ )
+ },
+ )
+ }
+
+ @Provides
+ @PayloadToggle
+ fun imageLoader(
+ @ViewModelOwned viewModelScope: CoroutineScope,
+ @Background coroutineDispatcher: CoroutineDispatcher,
+ @ApplicationContext context: Context,
+ ): ImageLoader =
+ ImagePreviewImageLoader(
+ viewModelScope + coroutineDispatcher,
+ thumbnailSize =
+ context.resources.getDimensionPixelSize(R.dimen.chooser_preview_image_max_dimen),
+ context.contentResolver,
+ cacheSize = 16,
+ )
+}
+
+private fun PreviewsModel.maybeLoad(key: PreviewModel) {
+ when (key) {
+ previewModels.firstOrNull() -> loadMoreLeft?.invoke()
+ previewModels.lastOrNull() -> loadMoreRight?.invoke()
+ }
+}
diff --git a/java/src/com/android/intentresolver/contentpreview/shareousel/ui/viewmodel/ShareouselViewModel.kt b/java/src/com/android/intentresolver/contentpreview/shareousel/ui/viewmodel/ShareouselViewModel.kt
deleted file mode 100644
index 18ee2539..00000000
--- a/java/src/com/android/intentresolver/contentpreview/shareousel/ui/viewmodel/ShareouselViewModel.kt
+++ /dev/null
@@ -1,90 +0,0 @@
-/*
- * Copyright (C) 2024 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF 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.shareousel.ui.viewmodel
-
-import android.graphics.Bitmap
-import androidx.core.graphics.drawable.toBitmap
-import com.android.intentresolver.contentpreview.ChooserContentPreviewUi.ActionFactory
-import com.android.intentresolver.contentpreview.ImageLoader
-import com.android.intentresolver.contentpreview.MutableActionFactory
-import com.android.intentresolver.contentpreview.PayloadToggleInteractor
-import com.android.intentresolver.icon.BitmapIcon
-import com.android.intentresolver.icon.ComposeIcon
-import com.android.intentresolver.widget.ActionRow.Action
-import kotlinx.coroutines.CoroutineScope
-import kotlinx.coroutines.flow.Flow
-import kotlinx.coroutines.flow.MutableStateFlow
-import kotlinx.coroutines.flow.StateFlow
-import kotlinx.coroutines.flow.flow
-import kotlinx.coroutines.flow.map
-import kotlinx.coroutines.flow.stateIn
-
-data class ShareouselViewModel(
- val headline: Flow<String>,
- val previewKeys: StateFlow<List<Any>>,
- val actions: Flow<List<ActionChipViewModel>>,
- val centerIndex: StateFlow<Int>,
- val previewForKey: (key: Any) -> ShareouselImageViewModel,
- val previewRowKey: (Any) -> Any
-)
-
-data class ActionChipViewModel(val label: String, val icon: ComposeIcon?, val onClick: () -> Unit)
-
-data class ShareouselImageViewModel(
- val bitmap: Flow<Bitmap?>,
- val contentDescription: Flow<String>,
- val isSelected: Flow<Boolean>,
- val setSelected: (Boolean) -> Unit,
-)
-
-suspend fun PayloadToggleInteractor.toShareouselViewModel(
- imageLoader: ImageLoader,
- actionFactory: ActionFactory,
- scope: CoroutineScope,
-): ShareouselViewModel {
- return ShareouselViewModel(
- headline = MutableStateFlow("Shareousel"),
- previewKeys = previewKeys.stateIn(scope),
- actions =
- if (actionFactory is MutableActionFactory) {
- actionFactory.customActionsFlow.map { actions ->
- actions.map { it.toActionChipViewModel() }
- }
- } else {
- flow {
- emit(actionFactory.createCustomActions().map { it.toActionChipViewModel() })
- }
- },
- centerIndex = targetPosition.stateIn(scope),
- previewForKey = { key ->
- val previewInteractor = previewInteractor(key)
- ShareouselImageViewModel(
- bitmap = previewInteractor.previewUri.map { uri -> uri?.let { imageLoader(uri) } },
- contentDescription = MutableStateFlow(""),
- isSelected = previewInteractor.selected,
- setSelected = { isSelected -> previewInteractor.setSelected(isSelected) },
- )
- },
- previewRowKey = { getKey(it) },
- )
-}
-
-private fun Action.toActionChipViewModel() =
- ActionChipViewModel(
- label?.toString() ?: "",
- icon?.let { BitmapIcon(it.toBitmap()) },
- onClick = { onClicked.run() }
- )
diff --git a/java/src/com/android/intentresolver/grid/ChooserGridAdapter.java b/java/src/com/android/intentresolver/grid/ChooserGridAdapter.java
index 036b686b..ba76a4a0 100644
--- a/java/src/com/android/intentresolver/grid/ChooserGridAdapter.java
+++ b/java/src/com/android/intentresolver/grid/ChooserGridAdapter.java
@@ -40,7 +40,6 @@ 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;
@@ -50,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> {
/**
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..ff2bb14b
--- /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.util.ownedByCurrentUser
+import com.android.intentresolver.v2.data.model.ChooserRequest
+import com.android.intentresolver.v2.ui.model.ActivityModel
+import com.android.intentresolver.v2.ui.viewmodel.readChooserRequest
+import com.android.intentresolver.v2.validation.Valid
+import com.android.intentresolver.v2.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/Qualifiers.kt b/java/src/com/android/intentresolver/inject/Qualifiers.kt
index 157e8f76..f267328b 100644
--- a/java/src/com/android/intentresolver/inject/Qualifiers.kt
+++ b/java/src/com/android/intentresolver/inject/Qualifiers.kt
@@ -23,6 +23,11 @@ import javax.inject.Qualifier
@Qualifier
@MustBeDocumented
@Retention(AnnotationRetention.RUNTIME)
+annotation class ViewModelOwned
+
+@Qualifier
+@MustBeDocumented
+@Retention(AnnotationRetention.RUNTIME)
annotation class ApplicationOwned
@Qualifier
diff --git a/java/src/com/android/intentresolver/inject/SystemServices.kt b/java/src/com/android/intentresolver/inject/SystemServices.kt
index 32894d43..c09598e0 100644
--- a/java/src/com/android/intentresolver/inject/SystemServices.kt
+++ b/java/src/com/android/intentresolver/inject/SystemServices.kt
@@ -17,13 +17,19 @@ 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.v2.data.repository.UserScopedService
+import com.android.intentresolver.v2.data.repository.UserScopedServiceImpl
+import dagger.Binds
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
@@ -52,9 +58,13 @@ class ClipboardManagerModule {
@Module
@InstallIn(SingletonComponent::class)
-class ContentResolverModule {
- @Provides
- fun contentResolver(@ApplicationContext ctx: Context) = requireNotNull(ctx.contentResolver)
+interface ContentResolverModule {
+ @Binds fun bindContentInterface(cr: ContentResolver): ContentInterface
+
+ companion object {
+ @Provides
+ fun contentResolver(@ApplicationContext ctx: Context) = requireNotNull(ctx.contentResolver)
+ }
}
@Module
@@ -81,10 +91,29 @@ class PackageManagerModule {
@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 =
- ctx.requireSystemService()
+ fun shortcutManager(@ApplicationContext ctx: Context): ShortcutManager {
+ return ctx.requireSystemService()
+ }
+
+ @Provides
+ fun scopedShortcutManager(
+ @ApplicationContext ctx: Context,
+ ): UserScopedService<ShortcutManager> {
+ return UserScopedServiceImpl(ctx, ShortcutManager::class)
+ }
}
@Module
@@ -92,6 +121,11 @@ class ShortcutManagerModule {
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
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/EventLogImpl.java b/java/src/com/android/intentresolver/logging/EventLogImpl.java
index 84029e76..39d23865 100644
--- a/java/src/com/android/intentresolver/logging/EventLogImpl.java
+++ b/java/src/com/android/intentresolver/logging/EventLogImpl.java
@@ -379,7 +379,9 @@ public class EventLogImpl implements EventLog {
@UiEvent(doc = "Sharesheet app share ranking timed out.")
SHARESHEET_APP_SHARE_RANKING_TIMEOUT(831),
@UiEvent(doc = "Sharesheet empty direct share row.")
- SHARESHEET_EMPTY_DIRECT_SHARE_ROW(828);
+ SHARESHEET_EMPTY_DIRECT_SHARE_ROW(828),
+ @UiEvent(doc = "Shareousel payload item toggled")
+ SHARESHEET_PAYLOAD_TOGGLED(1662);
private final int mId;
SharesheetStandardEvent(int id) {
diff --git a/java/src/com/android/intentresolver/logging/EventLogModule.kt b/java/src/com/android/intentresolver/logging/EventLogModule.kt
index eba8ecc8..73af7d37 100644
--- a/java/src/com/android/intentresolver/logging/EventLogModule.kt
+++ b/java/src/com/android/intentresolver/logging/EventLogModule.kt
@@ -24,14 +24,14 @@ import dagger.Binds
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
-import dagger.hilt.android.components.ActivityComponent
-import dagger.hilt.android.scopes.ActivityScoped
+import dagger.hilt.android.components.ActivityRetainedComponent
+import dagger.hilt.android.scopes.ActivityRetainedScoped
@Module
-@InstallIn(ActivityComponent::class)
+@InstallIn(ActivityRetainedComponent::class)
interface EventLogModule {
- @Binds @ActivityScoped fun eventLog(value: EventLogImpl): EventLog
+ @Binds @ActivityRetainedScoped fun eventLog(value: EventLogImpl): EventLog
companion object {
@Provides
diff --git a/java/src/com/android/intentresolver/model/ResolverRankerServiceResolverComparator.java b/java/src/com/android/intentresolver/model/ResolverRankerServiceResolverComparator.java
index f3804154..963091b5 100644
--- a/java/src/com/android/intentresolver/model/ResolverRankerServiceResolverComparator.java
+++ b/java/src/com/android/intentresolver/model/ResolverRankerServiceResolverComparator.java
@@ -28,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;
@@ -48,6 +49,7 @@ 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;
@@ -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/shortcuts/AppPredictorFactory.kt b/java/src/com/android/intentresolver/shortcuts/AppPredictorFactory.kt
index e544e064..c7bd0336 100644
--- a/java/src/com/android/intentresolver/shortcuts/AppPredictorFactory.kt
+++ b/java/src/com/android/intentresolver/shortcuts/AppPredictorFactory.kt
@@ -31,12 +31,13 @@ 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,
@@ -50,16 +51,19 @@ class AppPredictorFactory(
fun create(userHandle: UserHandle): AppPredictor? {
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/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/v2/ChooserActionFactory.java b/java/src/com/android/intentresolver/v2/ChooserActionFactory.java
index 9077a18d..efd5bfd1 100644
--- a/java/src/com/android/intentresolver/v2/ChooserActionFactory.java
+++ b/java/src/com/android/intentresolver/v2/ChooserActionFactory.java
@@ -102,7 +102,6 @@ public final class ChooserActionFactory implements ChooserContentPreviewUi.Actio
@Nullable private Runnable mCopyButtonRunnable;
private Runnable mEditButtonRunnable;
private final ImmutableList<ChooserAction> mCustomActions;
- @Nullable private final ChooserAction mModifyShareAction;
private final Consumer<Boolean> mExcludeSharedTextAction;
@Nullable private final ShareResultSender mShareResultSender;
private final Consumer</* @Nullable */ Integer> mFinishCallback;
@@ -124,7 +123,6 @@ public final class ChooserActionFactory implements ChooserContentPreviewUi.Actio
Intent targetIntent,
String referrerPackageName,
List<ChooserAction> chooserActions,
- @Nullable ChooserAction modifyShareAction,
Optional<ComponentName> imageEditor,
EventLog log,
Consumer<Boolean> onUpdateSharedTextIsExcluded,
@@ -150,7 +148,6 @@ public final class ChooserActionFactory implements ChooserContentPreviewUi.Actio
activityStarter,
log),
chooserActions,
- modifyShareAction,
onUpdateSharedTextIsExcluded,
log,
shareResultSender,
@@ -164,7 +161,6 @@ public final class ChooserActionFactory implements ChooserContentPreviewUi.Actio
@Nullable Runnable copyButtonRunnable,
Runnable editButtonRunnable,
List<ChooserAction> customActions,
- @Nullable ChooserAction modifyShareAction,
Consumer<Boolean> onUpdateSharedTextIsExcluded,
EventLog log,
@Nullable ShareResultSender shareResultSender,
@@ -173,7 +169,6 @@ public final class ChooserActionFactory implements ChooserContentPreviewUi.Actio
mCopyButtonRunnable = copyButtonRunnable;
mEditButtonRunnable = editButtonRunnable;
mCustomActions = ImmutableList.copyOf(customActions);
- mModifyShareAction = modifyShareAction;
mExcludeSharedTextAction = onUpdateSharedTextIsExcluded;
mLog = log;
mShareResultSender = shareResultSender;
@@ -212,7 +207,11 @@ public final class ChooserActionFactory implements ChooserContentPreviewUi.Actio
for (int i = 0; i < mCustomActions.size(); i++) {
final int position = i;
ActionRow.Action actionRow = createCustomAction(
- mCustomActions.get(i), () -> logCustomAction(position));
+ mContext,
+ mCustomActions.get(i),
+ () -> logCustomAction(position),
+ mShareResultSender,
+ mFinishCallback);
if (actionRow != null) {
actions.add(actionRow);
}
@@ -221,15 +220,6 @@ public final class ChooserActionFactory implements ChooserContentPreviewUi.Actio
}
/**
- * Provides a share modification action, if any.
- */
- @Override
- @Nullable
- public ActionRow.Action getModifyShareAction() {
- return createCustomAction(mModifyShareAction, this::logModifyShareAction);
- }
-
- /**
* <p>
* Creates an exclude-text action that can be called when the user changes shared text
* status in the Media + Text preview.
@@ -360,11 +350,16 @@ public final class ChooserActionFactory implements ChooserContentPreviewUi.Actio
}
@Nullable
- ActionRow.Action createCustomAction(@Nullable ChooserAction action, Runnable loggingRunnable) {
+ static ActionRow.Action createCustomAction(
+ Context context,
+ @Nullable ChooserAction action,
+ Runnable loggingRunnable,
+ ShareResultSender shareResultSender,
+ Consumer</* @Nullable */ Integer> finishCallback) {
if (action == null) {
return null;
}
- Drawable icon = action.getIcon().loadDrawable(mContext);
+ Drawable icon = action.getIcon().loadDrawable(context);
if (icon == null && TextUtils.isEmpty(action.getLabel())) {
return null;
}
@@ -381,7 +376,7 @@ public final class ChooserActionFactory implements ChooserContentPreviewUi.Actio
null,
null,
ActivityOptions.makeCustomAnimation(
- mContext,
+ context,
R.anim.slide_in_right,
R.anim.slide_out_left)
.toBundle());
@@ -391,10 +386,10 @@ public final class ChooserActionFactory implements ChooserContentPreviewUi.Actio
if (loggingRunnable != null) {
loggingRunnable.run();
}
- if (mShareResultSender != null) {
- mShareResultSender.onActionSelected(ShareAction.APPLICATION_DEFINED);
+ if (shareResultSender != null) {
+ shareResultSender.onActionSelected(ShareAction.APPLICATION_DEFINED);
}
- mFinishCallback.accept(Activity.RESULT_OK);
+ finishCallback.accept(Activity.RESULT_OK);
}
);
}
@@ -402,8 +397,4 @@ public final class ChooserActionFactory implements ChooserContentPreviewUi.Actio
void logCustomAction(int position) {
mLog.logCustomActionSelected(position);
}
-
- private void logModifyShareAction() {
- mLog.logActionSelected(EventLog.SELECTION_TYPE_MODIFY_SHARE);
- }
}
diff --git a/java/src/com/android/intentresolver/v2/ChooserActivity.java b/java/src/com/android/intentresolver/v2/ChooserActivity.java
index 8387212a..d624c9e4 100644
--- a/java/src/com/android/intentresolver/v2/ChooserActivity.java
+++ b/java/src/com/android/intentresolver/v2/ChooserActivity.java
@@ -29,15 +29,13 @@ import static android.view.WindowManager.LayoutParams.SYSTEM_FLAG_HIDE_NON_SYSTE
import static androidx.lifecycle.LifecycleKt.getCoroutineScope;
-import static com.android.intentresolver.contentpreview.ContentPreviewType.CONTENT_PREVIEW_PAYLOAD_SELECTION;
import static com.android.intentresolver.v2.ext.CreationExtrasExtKt.addDefaultArgs;
+import static com.android.intentresolver.v2.profiles.MultiProfilePagerAdapter.PROFILE_PERSONAL;
+import static com.android.intentresolver.v2.profiles.MultiProfilePagerAdapter.PROFILE_WORK;
import static com.android.intentresolver.v2.ui.model.ActivityModel.ACTIVITY_MODEL_KEY;
-import static com.android.internal.annotations.VisibleForTesting.Visibility.PROTECTED;
import static com.android.internal.util.LatencyTracker.ACTION_LOAD_SHARE_SHEET;
-import static java.util.Collections.emptyList;
import static java.util.Objects.requireNonNull;
-import static java.util.Objects.requireNonNullElse;
import android.app.ActivityManager;
import android.app.ActivityOptions;
@@ -57,22 +55,18 @@ import android.content.IntentFilter;
import android.content.IntentSender;
import android.content.SharedPreferences;
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.UserInfo;
import android.content.res.Configuration;
import android.database.Cursor;
import android.graphics.Insets;
import android.net.Uri;
-import android.os.Build;
import android.os.Bundle;
import android.os.StrictMode;
import android.os.SystemClock;
import android.os.Trace;
import android.os.UserHandle;
-import android.os.UserManager;
import android.service.chooser.ChooserTarget;
import android.stats.devicepolicy.DevicePolicyEnums;
import android.text.TextUtils;
@@ -103,7 +97,6 @@ import androidx.recyclerview.widget.GridLayoutManager;
import androidx.recyclerview.widget.RecyclerView;
import androidx.viewpager.widget.ViewPager;
-import com.android.intentresolver.AnnotatedUserHandles;
import com.android.intentresolver.ChooserGridLayoutManager;
import com.android.intentresolver.ChooserListAdapter;
import com.android.intentresolver.ChooserRefinementManager;
@@ -118,14 +111,12 @@ import com.android.intentresolver.ResolverListAdapter;
import com.android.intentresolver.ResolverListController;
import com.android.intentresolver.ResolverViewPager;
import com.android.intentresolver.StartsSelectedItem;
-import com.android.intentresolver.WorkProfileAvailabilityManager;
import com.android.intentresolver.chooser.DisplayResolveInfo;
import com.android.intentresolver.chooser.MultiDisplayResolveInfo;
import com.android.intentresolver.chooser.TargetInfo;
import com.android.intentresolver.contentpreview.BasePreviewViewModel;
import com.android.intentresolver.contentpreview.ChooserContentPreviewUi;
import com.android.intentresolver.contentpreview.HeadlineGeneratorImpl;
-import com.android.intentresolver.contentpreview.PayloadToggleInteractor;
import com.android.intentresolver.contentpreview.PreviewViewModel;
import com.android.intentresolver.emptystate.CompositeEmptyStateProvider;
import com.android.intentresolver.emptystate.CrossProfileIntentsChecker;
@@ -140,7 +131,9 @@ import com.android.intentresolver.model.AppPredictionServiceResolverComparator;
import com.android.intentresolver.model.ResolverRankerServiceResolverComparator;
import com.android.intentresolver.shortcuts.AppPredictorFactory;
import com.android.intentresolver.shortcuts.ShortcutLoader;
+import com.android.intentresolver.v2.data.model.ChooserRequest;
import com.android.intentresolver.v2.data.repository.DevicePolicyResources;
+import com.android.intentresolver.v2.domain.interactor.UserInteractor;
import com.android.intentresolver.v2.emptystate.NoAppsAvailableEmptyStateProvider;
import com.android.intentresolver.v2.emptystate.NoCrossProfileEmptyStateProvider;
import com.android.intentresolver.v2.emptystate.NoCrossProfileEmptyStateProvider.DevicePolicyBlockerEmptyState;
@@ -149,17 +142,18 @@ import com.android.intentresolver.v2.platform.AppPredictionAvailable;
import com.android.intentresolver.v2.platform.ImageEditor;
import com.android.intentresolver.v2.platform.NearbyShare;
import com.android.intentresolver.v2.profiles.ChooserMultiProfilePagerAdapter;
-import com.android.intentresolver.v2.profiles.MultiProfilePagerAdapter;
import com.android.intentresolver.v2.profiles.MultiProfilePagerAdapter.ProfileType;
import com.android.intentresolver.v2.profiles.OnProfileSelectedListener;
import com.android.intentresolver.v2.profiles.OnSwitchOnWorkSelectedListener;
import com.android.intentresolver.v2.profiles.TabConfig;
+import com.android.intentresolver.v2.shared.model.Profile;
import com.android.intentresolver.v2.ui.ActionTitle;
+import com.android.intentresolver.v2.ui.ProfilePagerResources;
import com.android.intentresolver.v2.ui.ShareResultSender;
import com.android.intentresolver.v2.ui.ShareResultSenderFactory;
import com.android.intentresolver.v2.ui.model.ActivityModel;
-import com.android.intentresolver.v2.ui.model.ChooserRequest;
import com.android.intentresolver.v2.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;
@@ -173,7 +167,6 @@ import com.google.common.collect.ImmutableList;
import dagger.hilt.android.AndroidEntryPoint;
import kotlin.Pair;
-import kotlin.Unit;
import java.util.ArrayList;
import java.util.Arrays;
@@ -188,6 +181,7 @@ 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;
@@ -213,7 +207,6 @@ public class ChooserActivity extends Hilt_ChooserActivity 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";
@@ -233,14 +226,11 @@ public class ChooserActivity extends Hilt_ChooserActivity implements
private int mLayoutId;
private UserHandle mHeaderCreatorUser;
- protected static final int PROFILE_PERSONAL = MultiProfilePagerAdapter.PROFILE_PERSONAL;
- protected static final int PROFILE_WORK = MultiProfilePagerAdapter.PROFILE_WORK;
private boolean mRegistered;
private PackageMonitor mPersonalPackageMonitor;
private PackageMonitor mWorkPackageMonitor;
protected View mProfileView;
- protected ActivityLogic mLogic;
protected ResolverDrawerLayout mResolverDrawerLayout;
protected ChooserMultiProfilePagerAdapter mChooserMultiProfilePagerAdapter;
protected final LatencyTracker mLatencyTracker = getLatencyTracker();
@@ -274,6 +264,7 @@ public class ChooserActivity extends Hilt_ChooserActivity implements
private static final int SCROLL_STATUS_SCROLLING_VERTICAL = 1;
private static final int SCROLL_STATUS_SCROLLING_HORIZONTAL = 2;
+ @Inject public UserInteractor mUserInteractor;
@Inject public ChooserHelper mChooserHelper;
@Inject public FeatureFlags mFeatureFlags;
@Inject public android.service.chooser.FeatureFlags mChooserServiceFeatureFlags;
@@ -283,12 +274,17 @@ public class ChooserActivity extends Hilt_ChooserActivity implements
@Inject @NearbyShare public Optional<ComponentName> mNearbyShare;
@Inject public TargetDataLoader mTargetDataLoader;
@Inject public DevicePolicyResources mDevicePolicyResources;
+ @Inject public ProfilePagerResources mProfilePagerResources;
@Inject public PackageManager mPackageManager;
@Inject public ClipboardManager mClipboardManager;
@Inject public IntentForwarding mIntentForwarding;
@Inject public ShareResultSenderFactory mShareResultSenderFactory;
- @Nullable
- private ShareResultSender mShareResultSender;
+
+ private ActivityModel mActivityModel;
+ private ChooserRequest mRequest;
+ private ProfileHelper mProfiles;
+ private ProfileAvailability mProfileAvailability;
+ @Nullable private ShareResultSender mShareResultSender;
private ChooserRefinementManager mRefinementManager;
@@ -339,15 +335,6 @@ public class ChooserActivity extends Hilt_ChooserActivity implements
}
private ChooserViewModel mViewModel;
- private ActivityModel mActivityModel;
-
- @VisibleForTesting
- protected ChooserActivityLogic createActivityLogic() {
- return new ChooserActivityLogic(
- TAG,
- /* activity = */ this,
- this::onWorkProfileStatusUpdated);
- }
@NonNull
@Override
@@ -358,45 +345,23 @@ public class ChooserActivity extends Hilt_ChooserActivity implements
}
@Override
- protected final void onCreate(Bundle savedInstanceState) {
+ protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
Log.i(TAG, "onCreate");
- mViewModel = new ViewModelProvider(this).get(ChooserViewModel.class);
- mActivityModel = mViewModel.getActivityModel();
-
- int callerUid = mActivityModel.getLaunchedFromUid();
- if (callerUid < 0 || UserHandle.isIsolated(callerUid)) {
- Log.e(TAG, "Can't start a resolver from uid " + callerUid);
- finish();
- }
setTheme(R.style.Theme_DeviceDefault_Chooser);
- Tracer.INSTANCE.markLaunched();
- if (!mViewModel.init()) {
- finish();
- return;
- }
- // The post-create callback is invoked when this function returns, via Lifecycle.
- mChooserHelper.setPostCreateCallback(this::init);
-
- IntentSender chosenComponentSender =
- mViewModel.getChooserRequest().getChosenComponentSender();
- if (chosenComponentSender != null) {
- mShareResultSender = mShareResultSenderFactory
- .create(mActivityModel.getLaunchedFromUid(), chosenComponentSender);
+ // Initializer is invoked when this function returns, via Lifecycle.
+ mChooserHelper.setInitializer(this::initializeWith);
+ if (mChooserServiceFeatureFlags.chooserPayloadToggling()) {
+ mChooserHelper.setOnChooserRequestChanged(this::onChooserRequestChanged);
}
- mLogic = createActivityLogic();
}
@Override
protected final void onStart() {
super.onStart();
-
this.getWindow().addSystemFlags(SYSTEM_FLAG_HIDE_NON_SYSTEM_OVERLAY_WINDOWS);
- if (hasWorkProfile()) {
- mLogic.getWorkProfileAvailabilityManager().registerWorkProfileStateReceiver(this);
- }
}
@Override
@@ -437,7 +402,6 @@ public class ChooserActivity extends Hilt_ChooserActivity implements
finish();
}
}
- mLogic.getWorkProfileAvailabilityManager().unregisterWorkProfileStateReceiver(this);
if (mRefinementManager != null) {
mRefinementManager.onActivityStop(isChangingConfigurations());
@@ -465,9 +429,9 @@ public class ChooserActivity extends Hilt_ChooserActivity implements
mPersonalPackageMonitor.register(
this,
getMainLooper(),
- requireAnnotatedUserHandles().personalProfileUserHandle,
+ mProfiles.getPersonalHandle(),
false);
- if (hasWorkProfile()) {
+ if (mProfiles.getWorkProfilePresent()) {
if (mWorkPackageMonitor == null) {
mWorkPackageMonitor = createPackageMonitor(
mChooserMultiProfilePagerAdapter.getWorkListAdapter());
@@ -475,24 +439,23 @@ public class ChooserActivity extends Hilt_ChooserActivity implements
mWorkPackageMonitor.register(
this,
getMainLooper(),
- requireAnnotatedUserHandles().workProfileUserHandle,
+ mProfiles.getWorkHandle(),
false);
}
mRegistered = true;
}
- WorkProfileAvailabilityManager workProfileAvailabilityManager =
- mLogic.getWorkProfileAvailabilityManager();
- if (hasWorkProfile() && workProfileAvailabilityManager.isWaitingToEnableWorkProfile()) {
- if (workProfileAvailabilityManager.isQuietModeEnabled()) {
- workProfileAvailabilityManager.markWorkProfileEnabledBroadcastReceived();
- }
- }
mChooserMultiProfilePagerAdapter.getActiveListAdapter().handlePackagesChanged();
}
@Override
- protected final void onDestroy() {
+ protected void onDestroy() {
super.onDestroy();
+ if (!isChangingConfigurations() && mPickOptionRequest != null) {
+ mPickOptionRequest.cancel();
+ }
+ if (mChooserMultiProfilePagerAdapter != null) {
+ mChooserMultiProfilePagerAdapter.destroy();
+ }
if (isFinishing()) {
mLatencyTracker.onActionCancel(ACTION_LOAD_SHARE_SHEET);
@@ -503,52 +466,76 @@ public class ChooserActivity extends Hilt_ChooserActivity implements
destroyProfileRecords();
}
- private void init() {
+ /** DO NOT CALL. Only for use from ChooserHelper as a callback. */
+ private void initializeWith(InitialState initialState) {
+ Log.d(TAG, "initializeWith: " + initialState);
+
+ mViewModel = new ViewModelProvider(this).get(ChooserViewModel.class);
+ mRequest = mViewModel.getRequest().getValue();
+ mActivityModel = mViewModel.getActivityModel();
+
+ mProfiles = new ProfileHelper(
+ mUserInteractor,
+ mFeatureFlags,
+ initialState.getProfiles(),
+ initialState.getLaunchedAs());
+
+ mProfileAvailability = new ProfileAvailability(
+ getCoroutineScope(getLifecycle()),
+ mUserInteractor,
+ initialState.getAvailability());
+
+ 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);
mShouldDisplayLandscape =
shouldDisplayLandscape(getResources().getConfiguration().orientation);
- ChooserRequest chooserRequest = mViewModel.getChooserRequest();
- setRetainInOnStop(chooserRequest.shouldRetainInOnStop());
+ setRetainInOnStop(mRequest.shouldRetainInOnStop());
createProfileRecords(
new AppPredictorFactory(
this,
- Objects.toString(chooserRequest.getSharedText(), null),
- chooserRequest.getShareTargetFilter(),
+ Objects.toString(mRequest.getSharedText(), null),
+ mRequest.getShareTargetFilter(),
mAppPredictionAvailable
),
- chooserRequest.getShareTargetFilter()
+ mRequest.getShareTargetFilter()
);
- Intent intent = mViewModel.getChooserRequest().getTargetIntent();
- List<Intent> initialIntents = mViewModel.getChooserRequest().getInitialIntents();
mChooserMultiProfilePagerAdapter = createMultiProfilePagerAdapter(
- requireNonNullElse(initialIntents, emptyList()).toArray(new Intent[0]),
- /* resolutionList = */ null,
- false
- );
+ /* context = */ this,
+ mProfilePagerResources,
+ mRequest,
+ mProfiles,
+ mProfileAvailability,
+ mRequest.getInitialIntents(),
+ mMaxTargetsPerRow,
+ mFeatureFlags);
+
if (!configureContentView(mTargetDataLoader)) {
mPersonalPackageMonitor = createPackageMonitor(
mChooserMultiProfilePagerAdapter.getPersonalListAdapter());
mPersonalPackageMonitor.register(
this,
getMainLooper(),
- requireAnnotatedUserHandles().personalProfileUserHandle,
+ mProfiles.getPersonalHandle(),
false
);
- if (hasWorkProfile()) {
+ if (mProfiles.getWorkProfilePresent()) {
mWorkPackageMonitor = createPackageMonitor(
mChooserMultiProfilePagerAdapter.getWorkListAdapter());
mWorkPackageMonitor.register(
this,
getMainLooper(),
- requireAnnotatedUserHandles().workProfileUserHandle,
+ mProfiles.getWorkHandle(),
false
);
}
@@ -576,6 +563,8 @@ public class ChooserActivity extends Hilt_ChooserActivity implements
mResolverDrawerLayout = rdl;
}
+
+ Intent intent = mRequest.getTargetIntent();
final Set<String> categories = intent.getCategories();
MetricsLogger.action(this,
mChooserMultiProfilePagerAdapter.getActiveListAdapter().hasFilteredItem()
@@ -616,38 +605,20 @@ public class ChooserActivity extends Hilt_ChooserActivity implements
new ViewModelProvider(this, createPreviewViewModelFactory())
.get(BasePreviewViewModel.class);
previewViewModel.init(
- chooserRequest.getTargetIntent(),
- mActivityModel.getIntent(),
- chooserRequest.getAdditionalContentUri(),
- chooserRequest.getFocusedItemPosition(),
+ mRequest.getTargetIntent(),
+ mRequest.getAdditionalContentUri(),
mChooserServiceFeatureFlags.chooserPayloadToggling());
- ChooserActionFactory chooserActionFactory = createChooserActionFactory();
- ChooserContentPreviewUi.ActionFactory actionFactory = chooserActionFactory;
- if (previewViewModel.getPreviewDataProvider().getPreviewType()
- == CONTENT_PREVIEW_PAYLOAD_SELECTION
- && mChooserServiceFeatureFlags.chooserPayloadToggling()) {
- PayloadToggleInteractor payloadToggleInteractor =
- previewViewModel.getPayloadToggleInteractor();
- if (payloadToggleInteractor != null) {
- ChooserMutableActionFactory mutableActionFactory =
- new ChooserMutableActionFactory(chooserActionFactory);
- actionFactory = mutableActionFactory;
- JavaFlowHelper.collect(
- getCoroutineScope(getLifecycle()),
- payloadToggleInteractor.getCustomActions(),
- mutableActionFactory::updateCustomActions);
- }
- }
mChooserContentPreviewUi = new ChooserContentPreviewUi(
getCoroutineScope(getLifecycle()),
previewViewModel.getPreviewDataProvider(),
- chooserRequest.getTargetIntent(),
+ mRequest.getTargetIntent(),
previewViewModel.getImageLoader(),
- actionFactory,
+ createChooserActionFactory(),
+ createModifyShareActionFactory(),
mEnterTransitionAnimationDelegate,
new HeadlineGeneratorImpl(this),
- chooserRequest.getContentTypeHint(),
- chooserRequest.getMetadataText(),
+ mRequest.getContentTypeHint(),
+ mRequest.getMetadataText(),
mChooserServiceFeatureFlags.chooserPayloadToggling());
updateStickyContentPreview();
if (shouldShowStickyContentPreview()
@@ -659,7 +630,7 @@ public class ChooserActivity extends Hilt_ChooserActivity implements
mChooserShownTime = System.currentTimeMillis();
final long systemCost = mChooserShownTime - mIntentReceivedTime.get();
getEventLog().logChooserActivityShown(
- isWorkProfile(), chooserRequest.getTargetType(), systemCost);
+ isWorkProfile(), mRequest.getTargetType(), systemCost);
if (mResolverDrawerLayout != null) {
mResolverDrawerLayout.addOnLayoutChangeListener(this::handleLayoutChange);
@@ -673,29 +644,119 @@ public class ChooserActivity extends Hilt_ChooserActivity implements
Log.d(TAG, "System Time Cost is " + systemCost);
}
getEventLog().logShareStarted(
- chooserRequest.getReferrerPackage(),
- chooserRequest.getTargetType(),
- chooserRequest.getCallerChooserTargets().size(),
- chooserRequest.getInitialIntents().size(),
+ mRequest.getReferrerPackage(),
+ mRequest.getTargetType(),
+ mRequest.getCallerChooserTargets().size(),
+ mRequest.getInitialIntents().size(),
isWorkProfile(),
mChooserContentPreviewUi.getPreferredContentPreview(),
- chooserRequest.getTargetAction(),
- chooserRequest.getChooserActions().size(),
- chooserRequest.getModifyShareAction() != null
+ mRequest.getTargetAction(),
+ mRequest.getChooserActions().size(),
+ mRequest.getModifyShareAction() != null
);
mEnterTransitionAnimationDelegate.postponeTransition();
+ Tracer.INSTANCE.markLaunched();
+ }
+
+ private void onChooserRequestChanged(ChooserRequest chooserRequest) {
+ // intentional reference comarison
+ if (mRequest == chooserRequest) {
+ return;
+ }
+ boolean recreateAdapters = shouldUpdateAdapters(mRequest, chooserRequest);
+ mRequest = chooserRequest;
+ updateShareResultSender();
+ mChooserContentPreviewUi.updateModifyShareAction();
+ if (recreateAdapters) {
+ recreatePagerAdapter();
+ }
}
- 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));
+ 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()
+ );
+
+ if (mChooserMultiProfilePagerAdapter != null) {
+ mChooserMultiProfilePagerAdapter.destroy();
+ }
+ mChooserMultiProfilePagerAdapter = createMultiProfilePagerAdapter(
+ /* context = */ this,
+ mProfilePagerResources,
+ mRequest,
+ mProfiles,
+ mProfileAvailability,
+ mRequest.getInitialIntents(),
+ mMaxTargetsPerRow,
+ mFeatureFlags);
+ mChooserMultiProfilePagerAdapter.setupViewPager(
+ requireViewById(com.android.internal.R.id.profile_pager));
+ 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()));
+ }
+ @Override
+ protected void onRestoreInstanceState(@NonNull Bundle savedInstanceState) {
+ ViewPager viewPager = findViewById(com.android.internal.R.id.profile_pager);
+ if (viewPager != null) {
+ viewPager.setCurrentItem(savedInstanceState.getInt(LAST_SHOWN_TAB_KEY));
+ }
mChooserMultiProfilePagerAdapter.clearInactiveProfileCache();
}
@@ -728,7 +789,6 @@ public class ChooserActivity extends Hilt_ChooserActivity implements
return false;
}
-
private boolean isTwoPagePersonalAndWorkConfiguration() {
return (mChooserMultiProfilePagerAdapter.getCount() == 2)
&& mChooserMultiProfilePagerAdapter.hasPageForProfile(PROFILE_PERSONAL)
@@ -786,7 +846,7 @@ public class ChooserActivity extends Hilt_ChooserActivity implements
DevicePolicyEventLogger
.createEvent(DevicePolicyEnums.RESOLVER_AUTOLAUNCH_CROSS_PROFILE_TARGET)
.setBoolean(activeListAdapter.getUserHandle()
- .equals(requireAnnotatedUserHandles().personalProfileUserHandle))
+ .equals(mProfiles.getPersonalHandle()))
.setStrings(getMetricsCategory())
.write();
safelyStartActivity(activeProfileTarget);
@@ -870,7 +930,7 @@ public class ChooserActivity extends Hilt_ChooserActivity implements
&& !listAdapter.getUserHandle().equals(mHeaderCreatorUser)) {
return;
}
- if (!hasWorkProfile()
+ if (!mProfiles.getWorkProfilePresent()
&& listAdapter.getCount() == 0 && listAdapter.getPlaceholderCount() == 0) {
final TextView titleView = findViewById(com.android.internal.R.id.title);
if (titleView != null) {
@@ -878,10 +938,10 @@ public class ChooserActivity extends Hilt_ChooserActivity implements
}
}
- CharSequence title = mViewModel.getChooserRequest().getTitle() != null
- ? mViewModel.getChooserRequest().getTitle()
- : getTitleForAction(mViewModel.getChooserRequest().getTargetIntent(),
- mViewModel.getChooserRequest().getDefaultTitleResource());
+ 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);
@@ -936,7 +996,7 @@ public class ChooserActivity extends Hilt_ChooserActivity implements
// If needed, show that intent is forwarded
// from managed profile to owner or other way around.
String profileSwitchMessage = mIntentForwarding.forwardMessageFor(
- mViewModel.getChooserRequest().getTargetIntent());
+ mRequest.getTargetIntent());
if (profileSwitchMessage != null) {
Toast.makeText(this, profileSwitchMessage, Toast.LENGTH_LONG).show();
}
@@ -954,22 +1014,17 @@ public class ChooserActivity extends Hilt_ChooserActivity implements
}
private void maybeLogCrossProfileTargetLaunch(TargetInfo cti, UserHandle currentUserHandle) {
- if (!hasWorkProfile() || currentUserHandle.equals(getUser())) {
+ if (!mProfiles.getWorkProfilePresent() || currentUserHandle.equals(getUser())) {
return;
}
DevicePolicyEventLogger
.createEvent(DevicePolicyEnums.RESOLVER_CROSS_PROFILE_TARGET_OPENED)
- .setBoolean(
- currentUserHandle.equals(
- requireAnnotatedUserHandles().personalProfileUserHandle))
+ .setBoolean(currentUserHandle.equals(mProfiles.getPersonalHandle()))
.setStrings(getMetricsCategory(),
cti.isInDirectShareMetricsCategory() ? "direct_share" : "other_target")
.write();
}
- private boolean hasWorkProfile() {
- return requireAnnotatedUserHandles().workProfileUserHandle != null;
- }
private LatencyTracker getLatencyTracker() {
return LatencyTracker.getInstance(this);
}
@@ -989,13 +1044,16 @@ public class ChooserActivity extends Hilt_ChooserActivity implements
}
protected final EmptyStateProvider createEmptyStateProvider(
- @Nullable UserHandle workProfileUserHandle) {
- final EmptyStateProvider blockerEmptyStateProvider = createBlockerEmptyStateProvider();
+ ProfileHelper profileHelper,
+ ProfileAvailability profileAvailability) {
+ EmptyStateProvider blockerEmptyStateProvider = createBlockerEmptyStateProvider();
- final EmptyStateProvider workProfileOffEmptyStateProvider =
- new WorkProfilePausedEmptyStateProvider(this, workProfileUserHandle,
- mLogic.getWorkProfileAvailabilityManager(),
- /* onSwitchOnWorkSelectedListener= */
+ EmptyStateProvider workProfileOffEmptyStateProvider =
+ new WorkProfilePausedEmptyStateProvider(
+ this,
+ profileHelper,
+ profileAvailability,
+ /* onSwitchOnWorkSelectedListener = */
() -> {
if (mOnSwitchOnWorkSelectedListener != null) {
mOnSwitchOnWorkSelectedListener.onSwitchOnWorkSelected();
@@ -1003,12 +1061,12 @@ public class ChooserActivity extends Hilt_ChooserActivity implements
},
getMetricsCategory());
- final EmptyStateProvider noAppsEmptyStateProvider = new NoAppsAvailableEmptyStateProvider(
+ EmptyStateProvider noAppsEmptyStateProvider = new NoAppsAvailableEmptyStateProvider(
this,
- workProfileUserHandle,
- requireAnnotatedUserHandles().personalProfileUserHandle,
+ profileHelper.getWorkHandle(),
+ profileHelper.getPersonalHandle(),
getMetricsCategory(),
- requireAnnotatedUserHandles().tabOwnerUserHandleForLaunch
+ profileHelper.getTabOwnerUserHandleForLaunch()
);
// Return composite provider, the order matters (the higher, the more priority)
@@ -1019,74 +1077,23 @@ public class ChooserActivity extends Hilt_ChooserActivity implements
);
}
- private boolean supportsManagedProfiles(ResolveInfo resolveInfo) {
- try {
- ApplicationInfo appInfo = mPackageManager.getApplicationInfo(
- resolveInfo.activityInfo.packageName, 0 /* default flags */);
- return appInfo.targetSdkVersion >= Build.VERSION_CODES.LOLLIPOP;
- } catch (PackageManager.NameNotFoundException e) {
- return false;
- }
- }
-
- private boolean hasManagedProfile() {
- UserManager userManager = (UserManager) getSystemService(Context.USER_SERVICE);
- if (userManager == null) {
- return false;
- }
-
- try {
- List<UserInfo> profiles = userManager.getProfiles(getUserId());
- for (UserInfo userInfo : profiles) {
- if (userInfo != null && userInfo.isManagedProfile()) {
- return true;
- }
- }
- } catch (SecurityException e) {
- return false;
- }
- return false;
- }
-
- /**
- * Returns the {@link UserHandle} to use when querying resolutions for intents in a
- * {@link ResolverListController} configured for the provided {@code userHandle}.
- */
- protected final UserHandle getQueryIntentsUser(UserHandle userHandle) {
- return requireAnnotatedUserHandles().getQueryIntentsUser(userHandle);
- }
-
- protected final boolean isLaunchedAsCloneProfile() {
- UserHandle launchUser = requireAnnotatedUserHandles().userHandleSharesheetLaunchedAs;
- UserHandle cloneUser = requireAnnotatedUserHandles().cloneProfileUserHandle;
- return hasCloneProfile() && launchUser.equals(cloneUser);
- }
-
- private boolean hasCloneProfile() {
- return requireAnnotatedUserHandles().cloneProfileUserHandle != null;
- }
-
/**
* Returns the {@link List} of {@link UserHandle} to pass on to the
* {@link ResolverRankerServiceResolverComparator} as per the provided {@code userHandle}.
*/
- @VisibleForTesting(visibility = PROTECTED)
- public final List<UserHandle> getResolverRankerServiceUserHandleList(UserHandle userHandle) {
+ private List<UserHandle> getResolverRankerServiceUserHandleList(UserHandle userHandle) {
return getResolverRankerServiceUserHandleListInternal(userHandle);
}
-
- @VisibleForTesting
- protected List<UserHandle> getResolverRankerServiceUserHandleListInternal(
- UserHandle 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(requireAnnotatedUserHandles().personalProfileUserHandle)
- && hasCloneProfile()) {
- userList.add(requireAnnotatedUserHandles().cloneProfileUserHandle);
+ if (userHandle.equals(mProfiles.getPersonalHandle())
+ && mProfiles.getCloneUserPresent()) {
+ userList.add(mProfiles.getCloneHandle());
}
return userList;
}
@@ -1118,7 +1125,7 @@ public class ChooserActivity extends Hilt_ChooserActivity implements
public final void onHandlePackagesChanged(ResolverListAdapter listAdapter) {
if (!mChooserMultiProfilePagerAdapter.onHandlePackagesChanged(
(ChooserListAdapter) listAdapter,
- mLogic.getWorkProfileAvailabilityManager().isWaitingToEnableWorkProfile())) {
+ mProfileAvailability.getWaitingToEnableProfile())) {
// We no longer have any items... just finish the activity.
finish();
}
@@ -1164,7 +1171,8 @@ public class ChooserActivity extends Hilt_ChooserActivity 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 = mChooserMultiProfilePagerAdapter.rebuildTabs(hasWorkProfile());
+ boolean rebuildCompleted = mChooserMultiProfilePagerAdapter.rebuildTabs(
+ mProfiles.getWorkProfilePresent());
mLayoutId = mFeatureFlags.scrollablePreview()
? R.layout.chooser_grid_scrollable_preview
@@ -1199,7 +1207,7 @@ public class ChooserActivity extends Hilt_ChooserActivity implements
stub.setVisibility(View.VISIBLE);
TextView textView = (TextView) LayoutInflater.from(this).inflate(
R.layout.resolver_different_item_header, null, false);
- if (hasWorkProfile()) {
+ if (mProfiles.getWorkProfilePresent()) {
textView.setGravity(Gravity.CENTER);
}
stub.addView(textView);
@@ -1228,7 +1236,10 @@ public class ChooserActivity extends Hilt_ChooserActivity implements
setupViewVisibilities();
- if (hasWorkProfile()) {
+ if (mProfiles.getWorkProfilePresent()
+ || (mProfiles.getPrivateProfilePresent()
+ && mProfileAvailability.isAvailable(
+ requireNonNull(mProfiles.getPrivateProfile())))) {
setupProfileTabs();
}
@@ -1256,8 +1267,7 @@ public class ChooserActivity extends Hilt_ChooserActivity implements
}
});
mOnSwitchOnWorkSelectedListener = () -> {
- final View workTab =
- tabHost.getTabWidget().getChildAt(
+ View workTab = tabHost.getTabWidget().getChildAt(
mChooserMultiProfilePagerAdapter.getPageNumberForProfile(PROFILE_WORK));
workTab.setFocusable(true);
workTab.setFocusableInTouchMode(true);
@@ -1265,35 +1275,27 @@ public class ChooserActivity extends Hilt_ChooserActivity implements
};
}
- public void super_onConfigurationChanged(Configuration newConfig) {
- super.onConfigurationChanged(newConfig);
- mChooserMultiProfilePagerAdapter.getActiveListAdapter().handlePackagesChanged();
-
- if (mSystemWindowInsets != null) {
- mResolverDrawerLayout.setPadding(mSystemWindowInsets.left, mSystemWindowInsets.top,
- mSystemWindowInsets.right, 0);
- }
- }
-
//////////////////////////////////////////////////////////////////////////////////////////////
//////////////////////////////////////////////////////////////////////////////////////////////
- private AnnotatedUserHandles requireAnnotatedUserHandles() {
- return requireNonNull(mLogic.getAnnotatedUserHandles());
- }
-
private void createProfileRecords(
AppPredictorFactory factory, IntentFilter targetIntentFilter) {
- UserHandle mainUserHandle = requireAnnotatedUserHandles().personalProfileUserHandle;
+ UserHandle mainUserHandle = mProfiles.getPersonalHandle();
ProfileRecord record = createProfileRecord(mainUserHandle, targetIntentFilter, factory);
if (record.shortcutLoader == null) {
Tracer.INSTANCE.endLaunchToShortcutTrace();
}
- UserHandle workUserHandle = requireAnnotatedUserHandles().workProfileUserHandle;
+ 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(
@@ -1333,26 +1335,80 @@ public class ChooserActivity extends Hilt_ChooserActivity implements
callback);
}
- static SharedPreferences getPinnedSharedPrefs(Context context) {
+ private SharedPreferences getPinnedSharedPrefs(Context context) {
return context.getSharedPreferences(PINNED_SHARED_PREFS_NAME, MODE_PRIVATE);
}
- protected ChooserMultiProfilePagerAdapter createMultiProfilePagerAdapter(
- Intent[] initialIntents,
- List<ResolveInfo> rList,
- boolean filterLastUsed) {
- if (hasWorkProfile()) {
- mChooserMultiProfilePagerAdapter = createChooserMultiProfilePagerAdapterForTwoProfiles(
- initialIntents, rList, filterLastUsed);
- } else {
- mChooserMultiProfilePagerAdapter = createChooserMultiProfilePagerAdapterForOneProfile(
- initialIntents, rList, filterLastUsed);
+ protected ChooserMultiProfilePagerAdapter createMultiProfilePagerAdapter() {
+ return createMultiProfilePagerAdapter(
+ /* context = */ this,
+ mProfilePagerResources,
+ mViewModel.getRequest().getValue(),
+ mProfiles,
+ mProfileAvailability,
+ mRequest.getInitialIntents(),
+ mMaxTargetsPerRow,
+ mFeatureFlags);
+ }
+
+ private ChooserMultiProfilePagerAdapter createMultiProfilePagerAdapter(
+ Context context,
+ ProfilePagerResources profilePagerResources,
+ ChooserRequest request,
+ ProfileHelper profileHelper,
+ ProfileAvailability profileAvailability,
+ List<Intent> initialIntents,
+ int maxTargetsPerRow,
+ FeatureFlags featureFlags) {
+ 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));
}
- return mChooserMultiProfilePagerAdapter;
+
+ EmptyStateProvider emptyStateProvider =
+ createEmptyStateProvider(profileHelper, profileAvailability);
+
+ Supplier<Boolean> workProfileQuietModeChecker =
+ () -> !(profileHelper.getWorkProfilePresent()
+ && profileAvailability.isAvailable(
+ requireNonNull(profileHelper.getWorkProfile())));
+
+ return new ChooserMultiProfilePagerAdapter(
+ /* context */ this,
+ ImmutableList.copyOf(tabs),
+ emptyStateProvider,
+ workProfileQuietModeChecker,
+ launchedAs.getType().ordinal(),
+ profileHelper.getWorkHandle(),
+ profileHelper.getCloneHandle(),
+ maxTargetsPerRow,
+ featureFlags);
}
protected EmptyStateProvider createBlockerEmptyStateProvider() {
- final boolean isSendAction = mViewModel.getChooserRequest().isSendActionTarget();
+ final boolean isSendAction = mRequest.isSendActionTarget();
final EmptyState noWorkToPersonalEmptyState =
new DevicePolicyBlockerEmptyState(
@@ -1381,90 +1437,14 @@ public class ChooserActivity extends Hilt_ChooserActivity implements
/* devicePolicyEventCategory= */ ResolverActivity.METRICS_CATEGORY_CHOOSER);
return new NoCrossProfileEmptyStateProvider(
- requireAnnotatedUserHandles().personalProfileUserHandle,
+ mProfiles,
noWorkToPersonalEmptyState,
noPersonalToWorkEmptyState,
- createCrossProfileIntentsChecker(),
- requireAnnotatedUserHandles().tabOwnerUserHandleForLaunch);
- }
-
- private ChooserMultiProfilePagerAdapter createChooserMultiProfilePagerAdapterForOneProfile(
- Intent[] initialIntents,
- List<ResolveInfo> rList,
- boolean filterLastUsed) {
- ChooserGridAdapter adapter = createChooserGridAdapter(
- /* context */ this,
- mViewModel.getChooserRequest().getPayloadIntents(),
- initialIntents,
- rList,
- filterLastUsed,
- /* userHandle */ requireAnnotatedUserHandles().personalProfileUserHandle
- );
- return new ChooserMultiProfilePagerAdapter(
- /* context */ this,
- ImmutableList.of(
- new TabConfig<>(
- PROFILE_PERSONAL,
- mDevicePolicyResources.getPersonalTabLabel(),
- mDevicePolicyResources.getPersonalTabAccessibilityLabel(),
- TAB_TAG_PERSONAL,
- adapter)),
- createEmptyStateProvider(/* workProfileUserHandle= */ null),
- /* workProfileQuietModeChecker= */ () -> false,
- /* defaultProfile= */ PROFILE_PERSONAL,
- /* workProfileUserHandle= */ null,
- requireAnnotatedUserHandles().cloneProfileUserHandle,
- mMaxTargetsPerRow,
- mFeatureFlags);
- }
-
- private ChooserMultiProfilePagerAdapter createChooserMultiProfilePagerAdapterForTwoProfiles(
- Intent[] initialIntents,
- List<ResolveInfo> rList,
- boolean filterLastUsed) {
- int selectedProfile = findSelectedProfile();
- ChooserGridAdapter personalAdapter = createChooserGridAdapter(
- /* context */ this,
- mViewModel.getChooserRequest().getPayloadIntents(),
- selectedProfile == PROFILE_PERSONAL ? initialIntents : null,
- rList,
- filterLastUsed,
- /* userHandle */ requireAnnotatedUserHandles().personalProfileUserHandle
- );
- ChooserGridAdapter workAdapter = createChooserGridAdapter(
- /* context */ this,
- mViewModel.getChooserRequest().getPayloadIntents(),
- selectedProfile == PROFILE_WORK ? initialIntents : null,
- rList,
- filterLastUsed,
- /* userHandle */ requireAnnotatedUserHandles().workProfileUserHandle
- );
- return new ChooserMultiProfilePagerAdapter(
- /* context */ this,
- 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(requireAnnotatedUserHandles().workProfileUserHandle),
- () -> mLogic.getWorkProfileAvailabilityManager().isQuietModeEnabled(),
- selectedProfile,
- requireAnnotatedUserHandles().workProfileUserHandle,
- requireAnnotatedUserHandles().cloneProfileUserHandle,
- mMaxTargetsPerRow,
- mFeatureFlags);
+ createCrossProfileIntentsChecker());
}
private int findSelectedProfile() {
- return getProfileForUser(requireAnnotatedUserHandles().tabOwnerUserHandleForLaunch);
+ return mProfiles.getLaunchedAsProfileType().ordinal();
}
/**
@@ -1472,9 +1452,8 @@ public class ChooserActivity extends Hilt_ChooserActivity 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
@@ -1512,7 +1491,13 @@ public class ChooserActivity extends Hilt_ChooserActivity implements
@Override
public void onConfigurationChanged(Configuration newConfig) {
- super_onConfigurationChanged(newConfig);
+ super.onConfigurationChanged(newConfig);
+ mChooserMultiProfilePagerAdapter.getActiveListAdapter().handlePackagesChanged();
+
+ if (mSystemWindowInsets != null) {
+ mResolverDrawerLayout.setPadding(mSystemWindowInsets.left, mSystemWindowInsets.top,
+ mSystemWindowInsets.right, 0);
+ }
ViewPager viewPager = findViewById(com.android.internal.R.id.profile_pager);
if (viewPager.isLayoutRtl()) {
mChooserMultiProfilePagerAdapter.setupViewPager(viewPager);
@@ -1545,7 +1530,7 @@ public class ChooserActivity extends Hilt_ChooserActivity implements
}
private void updateTabPadding() {
- if (hasWorkProfile()) {
+ 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
@@ -1612,12 +1597,10 @@ public class ChooserActivity extends Hilt_ChooserActivity implements
@Override // ResolverListCommunicator
public Intent getReplacementIntent(ActivityInfo aInfo, Intent defIntent) {
- ChooserRequest chooserRequest = mViewModel.getChooserRequest();
-
Intent result = defIntent;
- if (chooserRequest.getReplacementExtras() != null) {
+ if (mRequest.getReplacementExtras() != null) {
final Bundle replExtras =
- chooserRequest.getReplacementExtras().getBundle(aInfo.packageName);
+ mRequest.getReplacementExtras().getBundle(aInfo.packageName);
if (replExtras != null) {
result = new Intent(defIntent);
result.putExtras(replExtras);
@@ -1646,13 +1629,12 @@ public class ChooserActivity extends Hilt_ChooserActivity implements
}
private void addCallerChooserTargets() {
- ChooserRequest chooserRequest = mViewModel.getChooserRequest();
- if (!chooserRequest.getCallerChooserTargets().isEmpty()) {
+ if (!mRequest.getCallerChooserTargets().isEmpty()) {
// Send the caller's chooser targets only to the default profile.
if (mChooserMultiProfilePagerAdapter.getActiveProfile() == findSelectedProfile()) {
mChooserMultiProfilePagerAdapter.getActiveListAdapter().addServiceResults(
/* origTarget */ null,
- new ArrayList<>(chooserRequest.getCallerChooserTargets()),
+ new ArrayList<>(mRequest.getCallerChooserTargets()),
TARGET_TYPE_DEFAULT,
/* directShareShortcutInfoCache */ Collections.emptyMap(),
/* directShareAppTargetCache */ Collections.emptyMap());
@@ -1670,8 +1652,9 @@ public class ChooserActivity extends Hilt_ChooserActivity implements
return false;
}
- return mActivityModel.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) {
@@ -1688,7 +1671,7 @@ public class ChooserActivity extends Hilt_ChooserActivity implements
boolean isShortcutPinned = targetInfo.isSelectableTargetInfo() && targetInfo.isPinned();
IntentFilter intentFilter;
intentFilter = targetInfo.isSelectableTargetInfo()
- ? mViewModel.getChooserRequest().getShareTargetFilter() : null;
+ ? mRequest.getShareTargetFilter() : null;
String shortcutTitle = targetInfo.isSelectableTargetInfo()
? targetInfo.getDisplayLabel().toString() : null;
String shortcutIdKey = targetInfo.getDirectShareShortcutId();
@@ -1708,7 +1691,7 @@ public class ChooserActivity extends Hilt_ChooserActivity implements
protected boolean onTargetSelected(TargetInfo target) {
if (mRefinementManager.maybeHandleSelection(
target,
- mViewModel.getChooserRequest().getRefinementIntentSender(),
+ mRequest.getRefinementIntentSender(),
getApplication(),
getMainThreadHandler())) {
return false;
@@ -1782,7 +1765,7 @@ public class ChooserActivity extends Hilt_ChooserActivity implements
targetInfo.getResolveInfo().activityInfo.processName,
which,
/* directTargetAlsoRanked= */ getRankedPosition(targetInfo),
- mViewModel.getChooserRequest().getCallerChooserTargets().size(),
+ mRequest.getCallerChooserTargets().size(),
targetInfo.getHashedTargetIdForMetrics(this),
targetInfo.isPinned(),
mIsSuccessfullySelected,
@@ -1861,7 +1844,7 @@ public class ChooserActivity extends Hilt_ChooserActivity implements
if (info != null) {
sendClickToAppPredictor(info);
final ResolveInfo ri = info.getResolveInfo();
- Intent targetIntent = mViewModel.getChooserRequest().getTargetIntent();
+ Intent targetIntent = mRequest.getTargetIntent();
if (ri != null && ri.activityInfo != null && targetIntent != null) {
ChooserListAdapter currentListAdapter =
mChooserMultiProfilePagerAdapter.getActiveListAdapter();
@@ -1889,7 +1872,7 @@ public class ChooserActivity extends Hilt_ChooserActivity implements
if (targetIntent == null) {
return;
}
- Intent originalTargetIntent = new Intent(mViewModel.getChooserRequest().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) {
@@ -1959,7 +1942,7 @@ public class ChooserActivity extends Hilt_ChooserActivity 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) || (requireAnnotatedUserHandles().cloneProfileUserHandle != null))
+ return ((record == null) || (mProfiles.getCloneUserPresent()))
? null : record.appPredictor;
}
@@ -1967,25 +1950,21 @@ public class ChooserActivity extends Hilt_ChooserActivity implements
return mEventLog;
}
- @VisibleForTesting
- public ChooserGridAdapter createChooserGridAdapter(
+ private ChooserGridAdapter createChooserGridAdapter(
Context context,
List<Intent> payloadIntents,
Intent[] initialIntents,
- List<ResolveInfo> rList,
- boolean filterLastUsed,
UserHandle userHandle) {
- ChooserRequest request = mViewModel.getChooserRequest();
ChooserListAdapter chooserListAdapter = createChooserListAdapter(
context,
payloadIntents,
initialIntents,
- rList,
- filterLastUsed,
+ /* TODO: not used, remove. rList= */ null,
+ /* TODO: not used, remove. filterLastUsed= */ false,
createListController(userHandle),
userHandle,
- request.getTargetIntent(),
- request.getReferrerFillInIntent(),
+ mRequest.getTargetIntent(),
+ mRequest.getReferrerFillInIntent(),
mMaxTargetsPerRow
);
@@ -1994,7 +1973,7 @@ public class ChooserActivity extends Hilt_ChooserActivity implements
new ChooserGridAdapter.ChooserActivityDelegate() {
@Override
public boolean shouldShowTabs() {
- return hasWorkProfile();
+ return mProfiles.getWorkProfilePresent();
}
@Override
@@ -2039,9 +2018,7 @@ public class ChooserActivity extends Hilt_ChooserActivity implements
Intent targetIntent,
Intent referrerFillInIntent,
int maxTargetsPerRow) {
- UserHandle initialIntentsUserSpace = isLaunchedAsCloneProfile()
- && userHandle.equals(requireAnnotatedUserHandles().personalProfileUserHandle)
- ? requireAnnotatedUserHandles().cloneProfileUserHandle : userHandle;
+ UserHandle initialIntentsUserSpace = mProfiles.getQueryIntentsHandle(userHandle);
return new ChooserListAdapter(
context,
payloadIntents,
@@ -2067,19 +2044,18 @@ public class ChooserActivity extends Hilt_ChooserActivity implements
mFeatureFlags);
}
- protected Unit onWorkProfileStatusUpdated() {
- UserHandle workUser = requireAnnotatedUserHandles().workProfileUserHandle;
+ private void onWorkProfileStatusUpdated() {
+ UserHandle workUser = mProfiles.getWorkHandle();
ProfileRecord record = workUser == null ? null : getProfileRecord(workUser);
if (record != null && record.shortcutLoader != null) {
record.shortcutLoader.reset();
}
if (mChooserMultiProfilePagerAdapter.getCurrentUserHandle().equals(
- requireAnnotatedUserHandles().workProfileUserHandle)) {
+ mProfiles.getWorkHandle())) {
mChooserMultiProfilePagerAdapter.rebuildActiveTab(true);
} else {
mChooserMultiProfilePagerAdapter.clearInactiveProfileCache();
}
- return Unit.INSTANCE;
}
@VisibleForTesting
@@ -2089,8 +2065,8 @@ public class ChooserActivity extends Hilt_ChooserActivity implements
if (appPredictor != null) {
resolverComparator = new AppPredictionServiceResolverComparator(
this,
- mViewModel.getChooserRequest().getTargetIntent(),
- mViewModel.getChooserRequest().getLaunchedFromPackage(),
+ mRequest.getTargetIntent(),
+ mRequest.getLaunchedFromPackage(),
appPredictor,
userHandle,
getEventLog(),
@@ -2100,8 +2076,8 @@ public class ChooserActivity extends Hilt_ChooserActivity implements
resolverComparator =
new ResolverRankerServiceResolverComparator(
this,
- mViewModel.getChooserRequest().getTargetIntent(),
- mViewModel.getChooserRequest().getReferrerPackage(),
+ mRequest.getTargetIntent(),
+ mRequest.getReferrerPackage(),
null,
getEventLog(),
getResolverRankerServiceUserHandleList(userHandle),
@@ -2111,12 +2087,12 @@ public class ChooserActivity extends Hilt_ChooserActivity implements
return new ChooserListController(
this,
mPackageManager,
- mViewModel.getChooserRequest().getTargetIntent(),
- mViewModel.getChooserRequest().getReferrerPackage(),
- requireAnnotatedUserHandles().userIdOfCallingApp,
+ mRequest.getTargetIntent(),
+ mRequest.getReferrerPackage(),
+ mViewModel.getActivityModel().getLaunchedFromUid(),
resolverComparator,
- getQueryIntentsUser(userHandle),
- mViewModel.getChooserRequest().getFilteredComponentNames(),
+ mProfiles.getQueryIntentsHandle(userHandle),
+ mRequest.getFilteredComponentNames(),
mPinnedSharedPrefs);
}
@@ -2126,13 +2102,11 @@ public class ChooserActivity extends Hilt_ChooserActivity implements
}
private ChooserActionFactory createChooserActionFactory() {
- ChooserRequest request = mViewModel.getChooserRequest();
return new ChooserActionFactory(
this,
- request.getTargetIntent(),
- request.getLaunchedFromPackage(),
- request.getChooserActions(),
- request.getModifyShareAction(),
+ mRequest.getTargetIntent(),
+ mRequest.getLaunchedFromPackage(),
+ mRequest.getChooserActions(),
mImageEditor,
getEventLog(),
(isExcluded) -> mExcludeSharedText = isExcluded,
@@ -2142,7 +2116,7 @@ public class ChooserActivity extends Hilt_ChooserActivity implements
public void safelyStartActivityAsPersonalProfileUser(TargetInfo targetInfo) {
safelyStartActivityAsUser(
targetInfo,
- requireAnnotatedUserHandles().personalProfileUserHandle
+ mProfiles.getPersonalHandle()
);
finish();
}
@@ -2154,7 +2128,7 @@ public class ChooserActivity extends Hilt_ChooserActivity implements
ChooserActivity.this, sharedElement, sharedElementName);
safelyStartActivityAsUser(
targetInfo,
- requireAnnotatedUserHandles().personalProfileUserHandle,
+ mProfiles.getPersonalHandle(),
options.toBundle());
// Can't finish right away because the shared element transition may not
// be ready to start.
@@ -2162,15 +2136,26 @@ public class ChooserActivity extends Hilt_ChooserActivity implements
}
},
mShareResultSender,
- (status) -> {
- if (status != null) {
- setResult(status);
- }
- finish();
- },
+ 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();
+ }
+
/*
* Need to dynamically adjust how many icons can fit per row before we add them,
* which also means setting the correct offset to initially show the content
@@ -2262,7 +2247,7 @@ public class ChooserActivity extends Hilt_ChooserActivity implements
offset += stickyContentPreview.getHeight();
}
- if (hasWorkProfile()) {
+ if (mProfiles.getWorkProfilePresent()) {
offset += findViewById(com.android.internal.R.id.tabs).getHeight();
}
@@ -2306,19 +2291,6 @@ public class ChooserActivity extends Hilt_ChooserActivity implements
.shouldShowEmptyStateScreenInAnyInactiveAdapter();
}
- /**
- * 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(requireAnnotatedUserHandles().workProfileUserHandle)) {
- 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;
- }
-
protected void onListRebuilt(ResolverListAdapter listAdapter, boolean rebuildComplete) {
setupScrollListener();
maybeSetupGlobalLayoutListener();
@@ -2403,8 +2375,8 @@ public class ChooserActivity extends Hilt_ChooserActivity implements
if (mResolverDrawerLayout == null) {
return;
}
- int elevatedViewResId = hasWorkProfile() ?
- 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 =
@@ -2442,7 +2414,7 @@ public class ChooserActivity extends Hilt_ChooserActivity implements
}
private void maybeSetupGlobalLayoutListener() {
- if (hasWorkProfile()) {
+ if (mProfiles.getWorkProfilePresent()) {
return;
}
final View recyclerView = mChooserMultiProfilePagerAdapter.getActiveAdapterView();
@@ -2476,9 +2448,10 @@ public class ChooserActivity extends Hilt_ChooserActivity implements
if (!shouldShowContentPreview()) {
return false;
}
- boolean isEmpty = mChooserMultiProfilePagerAdapter.getListAdapterForUserHandle(
- UserHandle.of(UserHandle.myUserId())).getCount() == 0;
- return (mFeatureFlags.scrollablePreview() || hasWorkProfile())
+ ResolverListAdapter adapter = mChooserMultiProfilePagerAdapter.getListAdapterForUserHandle(
+ UserHandle.of(UserHandle.myUserId()));
+ boolean isEmpty = adapter == null || adapter.getCount() == 0;
+ return (mFeatureFlags.scrollablePreview() || mProfiles.getWorkProfilePresent())
&& (!isEmpty || shouldShowContentPreviewWhenEmpty());
}
@@ -2497,8 +2470,7 @@ public class ChooserActivity extends Hilt_ChooserActivity implements
* @return true if we want to show the content preview area
*/
protected boolean shouldShowContentPreview() {
- ChooserRequest chooserRequest = mViewModel.getChooserRequest();
- return (chooserRequest != null) && chooserRequest.isSendActionTarget();
+ return mRequest.isSendActionTarget();
}
private void updateStickyContentPreview() {
@@ -2549,7 +2521,7 @@ public class ChooserActivity extends Hilt_ChooserActivity implements
protected void onProfileTabSelected(int currentPage) {
setupViewVisibilities();
maybeLogProfileChange();
- if (hasWorkProfile()) {
+ if (mProfiles.getWorkProfilePresent()) {
// The device policy logger is only concerned with sessions that include a work profile.
DevicePolicyEventLogger
.createEvent(DevicePolicyEnums.RESOLVER_SWITCH_TABS)
@@ -2568,7 +2540,7 @@ public class ChooserActivity extends Hilt_ChooserActivity implements
}
protected WindowInsets onApplyWindowInsets(View v, WindowInsets insets) {
- if (hasWorkProfile()) {
+ if (mProfiles.getWorkProfilePresent()) {
mChooserMultiProfilePagerAdapter
.setEmptyStateBottomOffset(insets.getSystemWindowInsetBottom());
}
diff --git a/java/src/com/android/intentresolver/v2/ChooserActivityLogic.kt b/java/src/com/android/intentresolver/v2/ChooserActivityLogic.kt
deleted file mode 100644
index 84b7d9a9..00000000
--- a/java/src/com/android/intentresolver/v2/ChooserActivityLogic.kt
+++ /dev/null
@@ -1,23 +0,0 @@
-package com.android.intentresolver.v2
-
-import androidx.activity.ComponentActivity
-import androidx.annotation.OpenForTesting
-
-/**
- * Activity logic for [ChooserActivity].
- *
- * TODO: Make this class no longer open once [ChooserActivity] no longer needs to cast to access
- * [chooserRequest]. For now, this class being open is better than using reflection there.
- */
-@OpenForTesting
-open class ChooserActivityLogic(
- tag: String,
- activity: ComponentActivity,
- onWorkProfileStatusUpdated: () -> Unit,
-) :
- ActivityLogic,
- CommonActivityLogic by CommonActivityLogicImpl(
- tag,
- activity,
- onWorkProfileStatusUpdated,
- )
diff --git a/java/src/com/android/intentresolver/v2/ChooserHelper.kt b/java/src/com/android/intentresolver/v2/ChooserHelper.kt
index 17bc2731..503e46d8 100644
--- a/java/src/com/android/intentresolver/v2/ChooserHelper.kt
+++ b/java/src/com/android/intentresolver/v2/ChooserHelper.kt
@@ -17,11 +17,59 @@
package com.android.intentresolver.v2
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.contentpreview.payloadtoggle.data.repository.ActivityResultRepository
+import com.android.intentresolver.inject.Background
+import com.android.intentresolver.v2.annotation.JavaInterop
+import com.android.intentresolver.v2.data.model.ChooserRequest
+import com.android.intentresolver.v2.domain.interactor.UserInteractor
+import com.android.intentresolver.v2.shared.model.Profile
+import com.android.intentresolver.v2.ui.viewmodel.ChooserViewModel
+import com.android.intentresolver.v2.validation.Invalid
+import com.android.intentresolver.v2.validation.Valid
+import com.android.intentresolver.v2.validation.log
import dagger.hilt.android.scopes.ActivityScoped
+import java.util.function.Consumer
import javax.inject.Inject
+import kotlinx.coroutines.CoroutineDispatcher
+import kotlinx.coroutines.flow.filterNotNull
+import kotlinx.coroutines.flow.first
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.runBlocking
+
+private const val TAG: String = "ChooserHelper"
+
+/**
+ * Provides initial values to ChooserActivity and completes initialization from onCreate.
+ *
+ * This information is collected and provided on behalf of ChooserActivity to eliminate the need for
+ * suspending functions within remaining synchronous startup code.
+ */
+@JavaInterop
+fun interface ChooserInitializer {
+ /** @param initialState the initial state to provide to initialization */
+ fun initializeWith(initialState: InitialState)
+}
+
+/**
+ * A parameter object for Initialize which contains all the values which are required "early", on
+ * the main thread and outside of any coroutines. This supports code which expects to be called by
+ * the system on the main thread only. (This includes everything originally called from onCreate).
+ */
+@JavaInterop
+data class InitialState(
+ val profiles: List<Profile>,
+ val availability: Map<Profile, Boolean>,
+ val launchedAs: Profile
+)
/**
* __Purpose__
@@ -30,52 +78,125 @@ import javax.inject.Inject
*
* __Incoming References__
*
- * For use by ChooserActivity only; must not be accessed by any code outside of ChooserActivity.
- * This prevents circular dependencies and coupling, and maintains unidirectional flow. This is
- * important for maintaining a migration path towards healthier architecture.
+ * 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 passed in and not pulled from other places. This
- * prevents circular dependencies from forming.
+ * [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. If existing objects from ChooserActivity
- * are required, supply a factory interface which satisfies the necessary dependencies and use it
- * during construction.
+ * constructed here, then provided to where it is needed.
*/
-
@ActivityScoped
-class ChooserHelper @Inject constructor(
+@JavaInterop
+class ChooserHelper
+@Inject
+constructor(
hostActivity: Activity,
+ private val userInteractor: UserInteractor,
+ private val activityResultRepo: ActivityResultRepository,
+ @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<ChooserViewModel>()
+
+ private lateinit var activityInitializer: ChooserInitializer
- private var activityPostCreate: Runnable? = null
+ var onChooserRequestChanged: Consumer<ChooserRequest> = Consumer {}
init {
activity.lifecycle.addObserver(this)
}
/**
- * Provides a optional callback to setup state which is not yet possible to do without circular
- * dependencies or by moving more code.
+ * Set the initialization hook for the host activity.
+ *
+ * This _must_ be called from [ChooserActivity.onCreate].
*/
- fun setPostCreateCallback(onPostCreate: Runnable) {
- activityPostCreate = onPostCreate
+ fun setInitializer(initializer: ChooserInitializer) {
+ check(activity.lifecycle.currentState == Lifecycle.State.INITIALIZED) {
+ "setInitializer must be called before onCreate returns"
+ }
+ activityInitializer = initializer
}
- /**
- * Invoked by Lifecycle, after Activity.onCreate() _returns_.
- */
+ /** Invoked by Lifecycle, after [ChooserActivity.onCreate] _returns_. */
override fun onCreate(owner: LifecycleOwner) {
- activityPostCreate?.run()
+ 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 {
+ activity.lifecycle.repeatOnLifecycle(Lifecycle.State.STARTED) {
+ viewModel.request.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) }
+
+ val initialState =
+ runBlocking(background) {
+ val initialProfiles = userInteractor.profiles.first()
+ val initialAvailability = userInteractor.availability.first()
+ val launchedAsProfile = userInteractor.launchedAsProfile.first()
+ InitialState(initialProfiles, initialAvailability, launchedAsProfile)
+ }
+ activityInitializer.initializeWith(initialState)
}
-} \ No newline at end of file
+}
diff --git a/java/src/com/android/intentresolver/v2/ChooserMutableActionFactory.kt b/java/src/com/android/intentresolver/v2/ChooserMutableActionFactory.kt
deleted file mode 100644
index 2f8ccf77..00000000
--- a/java/src/com/android/intentresolver/v2/ChooserMutableActionFactory.kt
+++ /dev/null
@@ -1,54 +0,0 @@
-/*
- * 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.v2
-
-import android.service.chooser.ChooserAction
-import com.android.intentresolver.contentpreview.ChooserContentPreviewUi
-import com.android.intentresolver.contentpreview.MutableActionFactory
-import com.android.intentresolver.widget.ActionRow
-import kotlinx.coroutines.flow.Flow
-import kotlinx.coroutines.flow.MutableStateFlow
-
-/** A wrapper around [ChooserActionFactory] that provides observable custom actions */
-class ChooserMutableActionFactory(
- private val actionFactory: ChooserActionFactory,
-) : MutableActionFactory, ChooserContentPreviewUi.ActionFactory by actionFactory {
- private val customActions =
- MutableStateFlow<List<ActionRow.Action>>(actionFactory.createCustomActions())
-
- override val customActionsFlow: Flow<List<ActionRow.Action>>
- get() = customActions
-
- override fun updateCustomActions(actions: List<ChooserAction>) {
- customActions.tryEmit(mapChooserActions(actions))
- }
-
- override fun createCustomActions(): List<ActionRow.Action> = customActions.value
-
- private fun mapChooserActions(chooserActions: List<ChooserAction>): List<ActionRow.Action> =
- buildList(chooserActions.size) {
- chooserActions.forEachIndexed { i, chooserAction ->
- val actionRow =
- actionFactory.createCustomAction(chooserAction) {
- actionFactory.logCustomAction(i)
- }
- if (actionRow != null) {
- add(actionRow)
- }
- }
- }
-}
diff --git a/java/src/com/android/intentresolver/v2/JavaFlowHelper.kt b/java/src/com/android/intentresolver/v2/JavaFlowHelper.kt
index c6c977f6..3c4bddd1 100644
--- a/java/src/com/android/intentresolver/v2/JavaFlowHelper.kt
+++ b/java/src/com/android/intentresolver/v2/JavaFlowHelper.kt
@@ -18,11 +18,13 @@
package com.android.intentresolver.v2
+import com.android.intentresolver.v2.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/src/com/android/intentresolver/v2/ProfileAvailability.kt b/java/src/com/android/intentresolver/v2/ProfileAvailability.kt
index 4d689724..ddb57991 100644
--- a/java/src/com/android/intentresolver/v2/ProfileAvailability.kt
+++ b/java/src/com/android/intentresolver/v2/ProfileAvailability.kt
@@ -16,33 +16,34 @@
package com.android.intentresolver.v2
+import com.android.intentresolver.v2.annotation.JavaInterop
import com.android.intentresolver.v2.domain.interactor.UserInteractor
import com.android.intentresolver.v2.shared.model.Profile
-import kotlin.time.Duration.Companion.seconds
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
-import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
-import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.first
-import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
-import kotlinx.coroutines.withTimeout
/** Provides availability status for profiles */
+@JavaInterop
class ProfileAvailability(
private val scope: CoroutineScope,
- private val userInteractor: UserInteractor
+ private val userInteractor: UserInteractor,
+ initialState: Map<Profile, Boolean>
) {
private val availability =
- userInteractor.availability.stateIn(scope, SharingStarted.Eagerly, mapOf())
+ userInteractor.availability.stateIn(scope, SharingStarted.Eagerly, initialState)
/** 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. */
fun isAvailable(profile: Profile) = availability.value[profile] ?: false
@@ -61,14 +62,14 @@ class ProfileAvailability(
waitingToEnableProfile = true
waitJob?.cancel()
- val job = scope.launch {
- // Wait for the profile to become available
- // Wait for the profile to be enabled, then clear this flag
- userInteractor.availability.filter { it[profile] == true }.first()
- waitingToEnableProfile = false
- }
+ val job =
+ scope.launch {
+ // Wait for the profile to become available
+ availability.filter { it[profile] == true }.first()
+ }
job.invokeOnCompletion {
waitingToEnableProfile = false
+ onProfileStatusChange?.run()
}
waitJob = job
}
@@ -76,4 +77,4 @@ class ProfileAvailability(
// Apply the change
scope.launch { userInteractor.updateState(profile, enableProfile) }
}
-} \ No newline at end of file
+}
diff --git a/java/src/com/android/intentresolver/v2/ProfileHelper.kt b/java/src/com/android/intentresolver/v2/ProfileHelper.kt
index 784096b4..8a8e6b54 100644
--- a/java/src/com/android/intentresolver/v2/ProfileHelper.kt
+++ b/java/src/com/android/intentresolver/v2/ProfileHelper.kt
@@ -1,42 +1,47 @@
/*
-* Copyright (C) 2024 The Android Open Source Project
-*
-* Licensed under the Apache License, Version 2.0 (the "License");
-* you may not use this file except in compliance with the License.
-* You may obtain a copy of the License at
-*
-* http://www.apache.org/licenses/LICENSE-2.0
-*
-* Unless required by applicable law or agreed to in writing, software
-* distributed under the License is distributed on an "AS IS" BASIS,
-* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-* See the License for the specific language governing permissions and
-* limitations under the License.
-*/
+ * Copyright (C) 2024 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF 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.os.UserHandle
import com.android.intentresolver.inject.IntentResolverFlags
+import com.android.intentresolver.v2.annotation.JavaInterop
import com.android.intentresolver.v2.domain.interactor.UserInteractor
import com.android.intentresolver.v2.shared.model.Profile
import com.android.intentresolver.v2.shared.model.User
import javax.inject.Inject
-class ProfileHelper @Inject constructor(
+@JavaInterop
+class ProfileHelper
+@Inject
+constructor(
interactor: UserInteractor,
private val flags: IntentResolverFlags,
- profiles: List<Profile>,
- launchedAsProfile: Profile,
+ val profiles: List<Profile>,
+ val launchedAsProfile: Profile,
) {
private val launchedByHandle: UserHandle = interactor.launchedAs
// Map UserHandle back to a user within launchedByProfile
- private val launchedByUser = when (launchedByHandle) {
- launchedAsProfile.primary.handle -> launchedAsProfile.primary
- launchedAsProfile.clone?.handle -> launchedAsProfile.clone
- else -> error("launchedByUser must be a member of launchedByProfile")
- }
+ private val launchedByUser =
+ when (launchedByHandle) {
+ launchedAsProfile.primary.handle -> launchedAsProfile.primary
+ launchedAsProfile.clone?.handle -> 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 }
@@ -45,7 +50,7 @@ class ProfileHelper @Inject constructor(
val personalHandle = personalProfile.primary.handle
val workHandle = workProfile?.primary?.handle
- val privateHandle = privateProfile?.primary?.handle?.takeIf { flags.enablePrivateProfile() }
+ val privateHandle = privateProfile?.primary?.handle
val cloneHandle = personalProfile.clone?.handle
val isLaunchedAsCloneProfile = launchedByUser == launchedAsProfile.clone
@@ -55,12 +60,19 @@ class ProfileHelper @Inject constructor(
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
+ 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 findProfileType(handle: UserHandle): Profile.Type? {
+ val matched =
+ profiles.firstOrNull { it.primary.handle == handle || it.clone?.handle == handle }
+ return matched?.type
}
// Name retained for ease of review, to be renamed later
diff --git a/java/src/com/android/intentresolver/v2/ResolverActivity.java b/java/src/com/android/intentresolver/v2/ResolverActivity.java
index a9d9f8b1..4e694c3a 100644
--- a/java/src/com/android/intentresolver/v2/ResolverActivity.java
+++ b/java/src/com/android/intentresolver/v2/ResolverActivity.java
@@ -102,8 +102,8 @@ import com.android.intentresolver.icons.TargetDataLoader;
import com.android.intentresolver.model.ResolverRankerServiceResolverComparator;
import com.android.intentresolver.v2.data.repository.DevicePolicyResources;
import com.android.intentresolver.v2.emptystate.NoAppsAvailableEmptyStateProvider;
-import com.android.intentresolver.v2.emptystate.NoCrossProfileEmptyStateProvider;
import com.android.intentresolver.v2.emptystate.NoCrossProfileEmptyStateProvider.DevicePolicyBlockerEmptyState;
+import com.android.intentresolver.v2.emptystate.ResolverNoCrossProfileEmptyStateProvider;
import com.android.intentresolver.v2.emptystate.ResolverWorkProfilePausedEmptyStateProvider;
import com.android.intentresolver.v2.profiles.MultiProfilePagerAdapter;
import com.android.intentresolver.v2.profiles.MultiProfilePagerAdapter.ProfileType;
@@ -238,7 +238,7 @@ public class ResolverActivity extends Hilt_ResolverActivity implements
}
@Override
- protected final void onCreate(Bundle savedInstanceState) {
+ protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setTheme(R.style.Theme_DeviceDefault_Resolver);
mActivityModel = createActivityModel();
@@ -407,7 +407,7 @@ public class ResolverActivity extends Hilt_ResolverActivity implements
/* devicePolicyEventCategory= */
ResolverActivity.METRICS_CATEGORY_RESOLVER);
- return new NoCrossProfileEmptyStateProvider(
+ return new ResolverNoCrossProfileEmptyStateProvider(
requireAnnotatedUserHandles().personalProfileUserHandle,
noWorkToPersonalEmptyState,
noPersonalToWorkEmptyState,
diff --git a/java/src/com/android/intentresolver/v2/annotation/JavaInterop.kt b/java/src/com/android/intentresolver/v2/annotation/JavaInterop.kt
index 15c5018a..a813358e 100644
--- a/java/src/com/android/intentresolver/v2/annotation/JavaInterop.kt
+++ b/java/src/com/android/intentresolver/v2/annotation/JavaInterop.kt
@@ -21,6 +21,8 @@ package com.android.intentresolver.v2.annotation
*
* 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.")
+@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/v2/ui/model/ChooserRequest.kt b/java/src/com/android/intentresolver/v2/data/model/ChooserRequest.kt
index 4f3cf3cd..7c9c8613 100644
--- a/java/src/com/android/intentresolver/v2/ui/model/ChooserRequest.kt
+++ b/java/src/com/android/intentresolver/v2/data/model/ChooserRequest.kt
@@ -13,7 +13,7 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
-package com.android.intentresolver.v2.ui.model
+package com.android.intentresolver.v2.data.model
import android.content.ComponentName
import android.content.Intent
diff --git a/java/src/com/android/intentresolver/v2/data/repository/ChooserRequestRepository.kt b/java/src/com/android/intentresolver/v2/data/repository/ChooserRequestRepository.kt
new file mode 100644
index 00000000..d23e07ee
--- /dev/null
+++ b/java/src/com/android/intentresolver/v2/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.v2.data.repository
+
+import com.android.intentresolver.contentpreview.payloadtoggle.data.model.CustomActionModel
+import com.android.intentresolver.v2.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/v2/data/repository/UserInfoExt.kt b/java/src/com/android/intentresolver/v2/data/repository/UserInfoExt.kt
index a0b2d1ef..a61d6d0d 100644
--- a/java/src/com/android/intentresolver/v2/data/repository/UserInfoExt.kt
+++ b/java/src/com/android/intentresolver/v2/data/repository/UserInfoExt.kt
@@ -1,3 +1,19 @@
+/*
+ * Copyright (C) 2024 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
package com.android.intentresolver.v2.data.repository
import android.content.pm.UserInfo
diff --git a/java/src/com/android/intentresolver/v2/data/repository/UserRepository.kt b/java/src/com/android/intentresolver/v2/data/repository/UserRepository.kt
index b57609e5..40672249 100644
--- a/java/src/com/android/intentresolver/v2/data/repository/UserRepository.kt
+++ b/java/src/com/android/intentresolver/v2/data/repository/UserRepository.kt
@@ -1,3 +1,19 @@
+/*
+ * Copyright (C) 2024 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
package com.android.intentresolver.v2.data.repository
import android.content.Context
@@ -12,6 +28,7 @@ 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
@@ -20,7 +37,6 @@ import com.android.intentresolver.inject.Background
import com.android.intentresolver.inject.Main
import com.android.intentresolver.inject.ProfileParent
import com.android.intentresolver.v2.data.broadcastFlow
-import com.android.intentresolver.v2.data.repository.UserRepositoryImpl.UserEvent
import com.android.intentresolver.v2.shared.model.User
import dagger.hilt.android.qualifiers.ApplicationContext
import javax.inject.Inject
@@ -57,7 +73,7 @@ interface UserRepository {
* 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.isAvailable] for the given user.
+ * [UserRepository.availability] for the given user.
*
* No actions are taken if the user is already in requested state.
*
@@ -68,9 +84,9 @@ interface UserRepository {
private const val TAG = "UserRepository"
-private data class UserWithState(val user: User, val available: Boolean)
+internal data class UserWithState(val user: User, val available: Boolean)
-private typealias UserStates = List<UserWithState>
+internal typealias UserStates = List<UserWithState>
/** Tracks and publishes state for the parent user and associated profiles. */
class UserRepositoryImpl
@@ -98,13 +114,21 @@ constructor(
background
)
- data class UserEvent(val action: String, val user: UserHandle, val quietMode: Boolean = false)
+ 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.
*/
- internal class UserStateException(
+ private class UserStateException(
override val message: String,
val event: UserEvent,
override val cause: Throwable? = null
@@ -113,35 +137,34 @@ constructor(
private val sharingScope = CoroutineScope(scope.coroutineContext + backgroundDispatcher)
private val usersWithState: Flow<UserStates> =
userEvents
- .onStart { emit(UserEvent(INITIALIZE, profileParent)) }
- .onEach { Log.i(TAG, "userEvent: $it") }
- .runningFold<UserEvent, UserStates>(emptyList()) { users, event ->
- try {
- // Handle an action by performing some operation, then returning a new map
- when (event.action) {
- INITIALIZE -> createNewUserStates(profileParent)
- ACTION_PROFILE_ADDED -> handleProfileAdded(event, users)
- ACTION_PROFILE_REMOVED -> handleProfileRemoved(event, users)
- ACTION_MANAGED_PROFILE_UNAVAILABLE,
- ACTION_MANAGED_PROFILE_AVAILABLE,
- ACTION_PROFILE_AVAILABLE,
- ACTION_PROFILE_UNAVAILABLE -> handleAvailability(event, users)
- else -> {
- Log.w(TAG, "Unhandled event: $event)")
- users
- }
- }
- } catch (e: UserStateException) {
- Log.e(TAG, "An error occurred handling an event: ${e.event}", e)
- Log.e(TAG, "Attempting to recover...")
- createNewUserStates(profileParent)
- }
- }
+ .onStart { emit(Initialize) }
+ .onEach { debugLog { "userEvent: $it" } }
+ .runningFold(emptyList(), ::handleEvent)
.distinctUntilChanged()
- .onEach { Log.i(TAG, "userStateList: $it") }
+ .onEach { debugLog { "userStateList: $it" } }
.stateIn(sharingScope, SharingStarted.Eagerly, emptyList())
.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 { userStateMap -> userStateMap.map { it.user } }.distinctUntilChanged()
@@ -151,9 +174,8 @@ constructor(
.distinctUntilChanged()
override suspend fun requestState(user: User, available: Boolean) {
- require(user.type == User.Type.PROFILE) { "Only profile users are supported" }
return withContext(backgroundDispatcher) {
- Log.i(TAG, "requestQuietModeEnabled: ${!available} for user $user")
+ debugLog { "requestQuietModeEnabled: ${!available} for user $user" }
userManager.requestQuietModeEnabled(/* enableQuietMode = */ !available, user.handle)
}
}
@@ -161,28 +183,28 @@ constructor(
private fun List<UserWithState>.update(handle: UserHandle, user: UserWithState) =
filter { it.user.id != handle.identifier } + user
- private fun handleAvailability(event: UserEvent, current: UserStates): UserStates {
+ 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: UserEvent, current: UserStates): UserStates {
+ 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: UserEvent, current: UserStates): UserStates {
+ 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, !event.quietMode)
+ return current + UserWithState(user, true)
}
private suspend fun createNewUserStates(user: UserHandle): UserStates {
@@ -209,29 +231,64 @@ constructor(
}
}
+/** 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]. */
-private fun Intent.toUserEvent(): UserEvent? {
+private fun Intent.toUserEvent(): UserEvent {
val action = action
val user = extras?.getParcelable(EXTRA_USER, UserHandle::class.java)
- val quietMode = extras?.getBoolean(EXTRA_QUIET_MODE, false) ?: false
- return if (user == null || action == null) {
- null
- } else {
- UserEvent(action, user, quietMode)
+ 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)
}
}
-const val INITIALIZE = "INITIALIZE"
-
private fun createFilter(actions: Iterable<String>): IntentFilter {
return IntentFilter().apply { actions.forEach(::addAction) }
}
-private fun UserInfo?.isAvailable(): Boolean {
+internal fun UserInfo?.isAvailable(): Boolean {
return this?.isQuietModeEnabled != true
}
-private fun userBroadcastFlow(context: Context, profileParent: UserHandle): Flow<UserEvent> {
+internal fun userBroadcastFlow(context: Context, profileParent: UserHandle): Flow<UserEvent> {
val userActions =
setOf(
ACTION_PROFILE_ADDED,
diff --git a/java/src/com/android/intentresolver/v2/data/repository/UserRepositoryModule.kt b/java/src/com/android/intentresolver/v2/data/repository/UserRepositoryModule.kt
index a84342f4..ad4faa17 100644
--- a/java/src/com/android/intentresolver/v2/data/repository/UserRepositoryModule.kt
+++ b/java/src/com/android/intentresolver/v2/data/repository/UserRepositoryModule.kt
@@ -1,3 +1,19 @@
+/*
+ * Copyright (C) 2024 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
package com.android.intentresolver.v2.data.repository
import android.content.Context
diff --git a/java/src/com/android/intentresolver/v2/data/repository/UserScopedService.kt b/java/src/com/android/intentresolver/v2/data/repository/UserScopedService.kt
index 3553744a..65a48a55 100644
--- a/java/src/com/android/intentresolver/v2/data/repository/UserScopedService.kt
+++ b/java/src/com/android/intentresolver/v2/data/repository/UserScopedService.kt
@@ -1,46 +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.v2.data.repository
import android.content.Context
+import android.os.UserHandle
import androidx.core.content.getSystemService
-import com.android.intentresolver.v2.shared.model.User
+import dagger.hilt.android.qualifiers.ApplicationContext
+import kotlin.reflect.KClass
/**
- * Provides cached instances of a [system service][Context.getSystemService] created with
+ * Provides instances of a [system service][Context.getSystemService] created with
* [the context of a specified user][Context.createContextAsUser].
*
- * System services which have only `@UserHandleAware` APIs operate on the user id available from
+ * 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:
* ```
- * val usageStats = userScopedService<UsageStatsManager>(context)
+ * @Provides
+ * fun scopedUserManager(@ApplicationContext ctx: Context): UserScopedService<UserManager> {
+ * return UserScopedServiceImpl(ctx, UserManager::class)
+ * }
*
- * fun getStatsForUser(
- * user: User,
- * from: Long,
- * to: Long
- * ): UsageStats {
- * return usageStats.forUser(user)
- * .queryUsageStats(INTERVAL_BEST, from, to)
- * }
+ * class MyUserHelper @Inject constructor(
+ * private val userMgr: UserScopedService<UserManager>,
+ * ) {
+ * fun isPrivateProfile(user: UserHandle): UserManager {
+ * return userMgr.forUser(user).isPrivateProfile()
+ * }
+ * }
* ```
*/
-interface UserScopedService<T> {
- fun forUser(user: User): T
+fun interface UserScopedService<T> {
+ /** Create a service instance for the given user. */
+ fun forUser(user: UserHandle): T
}
-inline fun <reified T> userScopedService(context: Context): UserScopedService<T> {
- return object : UserScopedService<T> {
- private val map = mutableMapOf<User, T>()
-
- override fun forUser(user: User): T {
- return synchronized(this) {
- map.getOrPut(user) {
- val userContext = context.createContextAsUser(user.handle, 0)
- requireNotNull(userContext.getSystemService())
- }
+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/v2/domain/interactor/UserInteractor.kt b/java/src/com/android/intentresolver/v2/domain/interactor/UserInteractor.kt
index 72b604c2..69374f88 100644
--- a/java/src/com/android/intentresolver/v2/domain/interactor/UserInteractor.kt
+++ b/java/src/com/android/intentresolver/v2/domain/interactor/UserInteractor.kt
@@ -71,9 +71,7 @@ constructor(
*/
val availability: Flow<Map<Profile, Boolean>> =
combine(profiles, userRepository.availability) { profiles, availability ->
- profiles.associateWith {
- availability.getOrDefault(it.primary, false)
- }
+ profiles.associateWith { availability.getOrDefault(it.primary, false) }
}
/**
diff --git a/java/src/com/android/intentresolver/v2/emptystate/NoCrossProfileEmptyStateProvider.java b/java/src/com/android/intentresolver/v2/emptystate/NoCrossProfileEmptyStateProvider.java
index b744c589..d52015bf 100644
--- a/java/src/com/android/intentresolver/v2/emptystate/NoCrossProfileEmptyStateProvider.java
+++ b/java/src/com/android/intentresolver/v2/emptystate/NoCrossProfileEmptyStateProvider.java
@@ -19,6 +19,7 @@ package com.android.intentresolver.v2.emptystate;
import android.app.admin.DevicePolicyEventLogger;
import android.app.admin.DevicePolicyManager;
import android.content.Context;
+import android.content.Intent;
import android.os.UserHandle;
import androidx.annotation.NonNull;
@@ -29,6 +30,11 @@ import com.android.intentresolver.ResolverListAdapter;
import com.android.intentresolver.emptystate.CrossProfileIntentsChecker;
import com.android.intentresolver.emptystate.EmptyState;
import com.android.intentresolver.emptystate.EmptyStateProvider;
+import com.android.intentresolver.v2.ProfileHelper;
+import com.android.intentresolver.v2.shared.model.Profile;
+import com.android.intentresolver.v2.shared.model.User;
+
+import java.util.List;
/**
* Empty state provider that does not allow cross profile sharing, it will return a blocker
@@ -36,45 +42,56 @@ import com.android.intentresolver.emptystate.EmptyStateProvider;
*/
public class NoCrossProfileEmptyStateProvider implements EmptyStateProvider {
- private final UserHandle mPersonalProfileUserHandle;
+ private final ProfileHelper mProfileHelper;
private final EmptyState mNoWorkToPersonalEmptyState;
private final EmptyState mNoPersonalToWorkEmptyState;
private final CrossProfileIntentsChecker mCrossProfileIntentsChecker;
- private final UserHandle mTabOwnerUserHandleForLaunch;
- public NoCrossProfileEmptyStateProvider(UserHandle personalUserHandle,
+ public NoCrossProfileEmptyStateProvider(
+ ProfileHelper profileHelper,
EmptyState noWorkToPersonalEmptyState,
EmptyState noPersonalToWorkEmptyState,
- CrossProfileIntentsChecker crossProfileIntentsChecker,
- UserHandle tabOwnerUserHandleForLaunch) {
- mPersonalProfileUserHandle = personalUserHandle;
+ CrossProfileIntentsChecker crossProfileIntentsChecker) {
+ mProfileHelper = profileHelper;
mNoWorkToPersonalEmptyState = noWorkToPersonalEmptyState;
mNoPersonalToWorkEmptyState = noPersonalToWorkEmptyState;
mCrossProfileIntentsChecker = crossProfileIntentsChecker;
- mTabOwnerUserHandleForLaunch = tabOwnerUserHandleForLaunch;
+ }
+
+ private boolean anyCrossProfileAllowedIntents(ResolverListAdapter selected, UserHandle source) {
+ List<Intent> intents = selected.getIntents();
+ UserHandle target = selected.getUserHandle();
+ return mCrossProfileIntentsChecker.hasCrossProfileIntents(intents,
+ source.getIdentifier(), target.getIdentifier());
}
@Nullable
@Override
- public EmptyState getEmptyState(ResolverListAdapter resolverListAdapter) {
- boolean shouldShowBlocker =
- !mTabOwnerUserHandleForLaunch.equals(resolverListAdapter.getUserHandle())
- && !mCrossProfileIntentsChecker
- .hasCrossProfileIntents(resolverListAdapter.getIntents(),
- mTabOwnerUserHandleForLaunch.getIdentifier(),
- resolverListAdapter.getUserHandle().getIdentifier());
-
- if (!shouldShowBlocker) {
+ public EmptyState getEmptyState(ResolverListAdapter adapter) {
+ Profile launchedAsProfile = mProfileHelper.getLaunchedAsProfile();
+ User launchedAs = mProfileHelper.getLaunchedAsProfile().getPrimary();
+ UserHandle tabOwnerHandle = adapter.getUserHandle();
+ boolean launchedAsSameUser = launchedAs.getHandle().equals(tabOwnerHandle);
+ Profile.Type tabOwnerType = mProfileHelper.findProfileType(tabOwnerHandle);
+
+ // Not applicable for private profile.
+ if (launchedAsProfile.getType() == Profile.Type.PRIVATE
+ || tabOwnerType == Profile.Type.PRIVATE) {
return null;
}
- if (resolverListAdapter.getUserHandle().equals(mPersonalProfileUserHandle)) {
- return mNoWorkToPersonalEmptyState;
- } else {
- return mNoPersonalToWorkEmptyState;
+ // Allow access to the tab when launched by the same user as the tab owner
+ // or when there is at least one target which is permitted for cross-profile.
+ if (launchedAsSameUser || anyCrossProfileAllowedIntents(adapter, tabOwnerHandle)) {
+ return null;
}
- }
+ switch (launchedAsProfile.getType()) {
+ case WORK: return mNoWorkToPersonalEmptyState;
+ case PERSONAL: return mNoPersonalToWorkEmptyState;
+ }
+ return null;
+ }
/**
* Empty state that gets strings from the device policy manager and tracks events into
diff --git a/java/src/com/android/intentresolver/v2/emptystate/ResolverNoCrossProfileEmptyStateProvider.java b/java/src/com/android/intentresolver/v2/emptystate/ResolverNoCrossProfileEmptyStateProvider.java
new file mode 100644
index 00000000..f133c31d
--- /dev/null
+++ b/java/src/com/android/intentresolver/v2/emptystate/ResolverNoCrossProfileEmptyStateProvider.java
@@ -0,0 +1,138 @@
+/*
+ * 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.v2.emptystate;
+
+import android.app.admin.DevicePolicyEventLogger;
+import android.app.admin.DevicePolicyManager;
+import android.content.Context;
+import android.os.UserHandle;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+import androidx.annotation.StringRes;
+
+import com.android.intentresolver.ResolverListAdapter;
+import com.android.intentresolver.emptystate.CrossProfileIntentsChecker;
+import com.android.intentresolver.emptystate.EmptyState;
+import com.android.intentresolver.emptystate.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 ResolverNoCrossProfileEmptyStateProvider implements EmptyStateProvider {
+
+ private final UserHandle mPersonalProfileUserHandle;
+ private final EmptyState mNoWorkToPersonalEmptyState;
+ private final EmptyState mNoPersonalToWorkEmptyState;
+ private final CrossProfileIntentsChecker mCrossProfileIntentsChecker;
+ private final UserHandle mTabOwnerUserHandleForLaunch;
+
+ public ResolverNoCrossProfileEmptyStateProvider(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(@NonNull Context context,
+ String devicePolicyStringTitleId, @StringRes int defaultTitleResource,
+ String devicePolicyStringSubtitleId, @StringRes int defaultSubtitleResource,
+ int devicePolicyEventId, @NonNull String devicePolicyEventCategory) {
+ mContext = context;
+ mDevicePolicyStringTitleId = devicePolicyStringTitleId;
+ mDefaultTitleResource = defaultTitleResource;
+ mDevicePolicyStringSubtitleId = devicePolicyStringSubtitleId;
+ mDefaultSubtitleResource = defaultSubtitleResource;
+ mEventId = devicePolicyEventId;
+ mEventCategory = devicePolicyEventCategory;
+ }
+
+ @Nullable
+ @Override
+ public String getTitle() {
+ return mContext.getSystemService(DevicePolicyManager.class).getResources().getString(
+ mDevicePolicyStringTitleId,
+ () -> mContext.getString(mDefaultTitleResource));
+ }
+
+ @Nullable
+ @Override
+ public String getSubtitle() {
+ return mContext.getSystemService(DevicePolicyManager.class).getResources().getString(
+ mDevicePolicyStringSubtitleId,
+ () -> mContext.getString(mDefaultSubtitleResource));
+ }
+
+ @Override
+ public void onEmptyStateShown() {
+ DevicePolicyEventLogger.createEvent(mEventId)
+ .setStrings(mEventCategory)
+ .write();
+ }
+
+ @Override
+ public boolean shouldSkipDataRebuild() {
+ return true;
+ }
+ }
+}
diff --git a/java/src/com/android/intentresolver/v2/emptystate/WorkProfilePausedEmptyStateProvider.java b/java/src/com/android/intentresolver/v2/emptystate/WorkProfilePausedEmptyStateProvider.java
index a6fee3ec..af13f8fe 100644
--- a/java/src/com/android/intentresolver/v2/emptystate/WorkProfilePausedEmptyStateProvider.java
+++ b/java/src/com/android/intentresolver/v2/emptystate/WorkProfilePausedEmptyStateProvider.java
@@ -18,6 +18,8 @@ package com.android.intentresolver.v2.emptystate;
import static android.app.admin.DevicePolicyResources.Strings.Core.RESOLVER_WORK_PAUSED_TITLE;
+import static java.util.Objects.requireNonNull;
+
import android.app.admin.DevicePolicyEventLogger;
import android.app.admin.DevicePolicyManager;
import android.content.Context;
@@ -30,9 +32,11 @@ import androidx.annotation.Nullable;
import com.android.intentresolver.MultiProfilePagerAdapter.OnSwitchOnWorkSelectedListener;
import com.android.intentresolver.R;
import com.android.intentresolver.ResolverListAdapter;
-import com.android.intentresolver.WorkProfileAvailabilityManager;
import com.android.intentresolver.emptystate.EmptyState;
import com.android.intentresolver.emptystate.EmptyStateProvider;
+import com.android.intentresolver.v2.ProfileAvailability;
+import com.android.intentresolver.v2.ProfileHelper;
+import com.android.intentresolver.v2.shared.model.Profile;
/**
* Chooser/ResolverActivity empty state provider that returns empty state which is shown when
@@ -40,20 +44,20 @@ import com.android.intentresolver.emptystate.EmptyStateProvider;
*/
public class WorkProfilePausedEmptyStateProvider implements EmptyStateProvider {
- private final UserHandle mWorkProfileUserHandle;
- private final WorkProfileAvailabilityManager mWorkProfileAvailability;
+ 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,
- @Nullable UserHandle workProfileUserHandle,
- @NonNull WorkProfileAvailabilityManager workProfileAvailability,
+ ProfileHelper profileHelper,
+ ProfileAvailability profileAvailability,
@Nullable OnSwitchOnWorkSelectedListener onSwitchOnWorkSelectedListener,
@NonNull String metricsCategory) {
mContext = context;
- mWorkProfileUserHandle = workProfileUserHandle;
- mWorkProfileAvailability = workProfileAvailability;
+ mProfileHelper = profileHelper;
+ mProfileAvailability = profileAvailability;
mMetricsCategory = metricsCategory;
mOnSwitchOnWorkSelectedListener = onSwitchOnWorkSelectedListener;
}
@@ -61,22 +65,33 @@ public class WorkProfilePausedEmptyStateProvider implements EmptyStateProvider {
@Nullable
@Override
public EmptyState getEmptyState(ResolverListAdapter resolverListAdapter) {
- if (!resolverListAdapter.getUserHandle().equals(mWorkProfileUserHandle)
- || !mWorkProfileAvailability.isQuietModeEnabled()
- || resolverListAdapter.getCount() == 0) {
+ 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;
}
- final String title = mContext.getSystemService(DevicePolicyManager.class)
+ 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) -> {
+ return new WorkProfileOffEmptyState(title, /* EmptyState.ClickListener */ (tab) -> {
tab.showSpinner();
if (mOnSwitchOnWorkSelectedListener != null) {
mOnSwitchOnWorkSelectedListener.onSwitchOnWorkSelected();
}
- mWorkProfileAvailability.requestQuietModeEnabled(false);
+ mProfileAvailability.requestQuietModeState(workProfile, false);
}, mMetricsCategory);
}
diff --git a/java/src/com/android/intentresolver/v2/platform/AppPredictionModule.kt b/java/src/com/android/intentresolver/v2/platform/AppPredictionModule.kt
index 9ca9d871..090fab6b 100644
--- a/java/src/com/android/intentresolver/v2/platform/AppPredictionModule.kt
+++ b/java/src/com/android/intentresolver/v2/platform/AppPredictionModule.kt
@@ -18,7 +18,6 @@ package com.android.intentresolver.v2.platform
import android.content.pm.PackageManager
import dagger.Module
import dagger.Provides
-import dagger.Reusable
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import javax.inject.Qualifier
@@ -33,13 +32,11 @@ annotation class AppPredictionAvailable
@InstallIn(SingletonComponent::class)
object AppPredictionModule {
- /**
- * Eventually replaced with: Optional<AppPredictionRepository>, etc.
- */
+ /** Eventually replaced with: Optional<AppPredictionRepository>, etc. */
@Provides
@Singleton
@AppPredictionAvailable
fun isAppPredictionAvailable(packageManager: PackageManager): Boolean {
return packageManager.appPredictionServicePackageName != null
}
-} \ No newline at end of file
+}
diff --git a/java/src/com/android/intentresolver/v2/profiles/ChooserMultiProfilePagerAdapter.java b/java/src/com/android/intentresolver/v2/profiles/ChooserMultiProfilePagerAdapter.java
index 0ee9d141..c078c43f 100644
--- a/java/src/com/android/intentresolver/v2/profiles/ChooserMultiProfilePagerAdapter.java
+++ b/java/src/com/android/intentresolver/v2/profiles/ChooserMultiProfilePagerAdapter.java
@@ -151,6 +151,16 @@ public class ChooserMultiProfilePagerAdapter extends MultiProfilePagerAdapter<
}
}
+ /** 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();
+ }
+ }
+ }
+
private static class BottomPaddingOverrideSupplier implements Supplier<Optional<Integer>> {
private final Context mContext;
private int mBottomOffset;
diff --git a/java/src/com/android/intentresolver/v2/profiles/MultiProfilePagerAdapter.java b/java/src/com/android/intentresolver/v2/profiles/MultiProfilePagerAdapter.java
index 43785db3..341e7043 100644
--- a/java/src/com/android/intentresolver/v2/profiles/MultiProfilePagerAdapter.java
+++ b/java/src/com/android/intentresolver/v2/profiles/MultiProfilePagerAdapter.java
@@ -15,7 +15,6 @@
*/
package com.android.intentresolver.v2.profiles;
-import android.annotation.IntDef;
import android.annotation.Nullable;
import android.os.Trace;
import android.os.UserHandle;
@@ -32,6 +31,7 @@ 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.v2.shared.model.Profile;
import com.android.internal.annotations.VisibleForTesting;
import com.google.common.collect.ImmutableList;
@@ -61,10 +61,11 @@ public class MultiProfilePagerAdapter<
SinglePageAdapterT,
ListAdapterT extends ResolverListAdapter> extends PagerAdapter {
- public static final int PROFILE_PERSONAL = 0;
- public static final int PROFILE_WORK = 1;
+ public static final int PROFILE_PERSONAL = Profile.Type.PERSONAL.ordinal();
+ public static final int PROFILE_WORK = Profile.Type.WORK.ordinal();
- @IntDef({PROFILE_PERSONAL, PROFILE_WORK})
+ // 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;
@@ -244,6 +245,7 @@ public class MultiProfilePagerAdapter<
Runnable onTabChangeListener,
OnProfileSelectedListener clientOnProfileSelectedListener) {
tabHost.setup();
+ tabHost.getTabWidget().removeAllViews();
viewPager.setSaveEnabled(false);
for (int pageNumber = 0; pageNumber < getItemCount(); ++pageNumber) {
diff --git a/java/src/com/android/intentresolver/v2/shared/model/User.kt b/java/src/com/android/intentresolver/v2/shared/model/User.kt
index 97db3280..46279ad0 100644
--- a/java/src/com/android/intentresolver/v2/shared/model/User.kt
+++ b/java/src/com/android/intentresolver/v2/shared/model/User.kt
@@ -18,8 +18,6 @@ package com.android.intentresolver.v2.shared.model
import android.annotation.UserIdInt
import android.os.UserHandle
-import com.android.intentresolver.v2.shared.model.User.Type.FULL
-import com.android.intentresolver.v2.shared.model.User.Type.PROFILE
/**
* A User represents the owner of a distinct set of content.
@@ -45,21 +43,10 @@ data class User(
) {
val handle: UserHandle = UserHandle.of(id)
- val type: Type
- get() = role.type
-
- enum class Type {
- FULL,
- PROFILE
- }
-
- enum class Role(
- /** The type of the role user. */
- val type: Type
- ) {
- PERSONAL(FULL),
- PRIVATE(PROFILE),
- WORK(PROFILE),
- CLONE(PROFILE)
+ enum class Role {
+ PERSONAL,
+ PRIVATE,
+ WORK,
+ CLONE
}
}
diff --git a/java/src/com/android/intentresolver/v2/ui/ProfilePagerResources.kt b/java/src/com/android/intentresolver/v2/ui/ProfilePagerResources.kt
index 1cd72ba5..ca7ae0fc 100644
--- a/java/src/com/android/intentresolver/v2/ui/ProfilePagerResources.kt
+++ b/java/src/com/android/intentresolver/v2/ui/ProfilePagerResources.kt
@@ -17,11 +17,11 @@
package com.android.intentresolver.v2.ui
import android.content.res.Resources
+import com.android.intentresolver.R
import com.android.intentresolver.inject.ApplicationOwned
import com.android.intentresolver.v2.data.repository.DevicePolicyResources
import com.android.intentresolver.v2.shared.model.Profile
import javax.inject.Inject
-import com.android.intentresolver.R
class ProfilePagerResources
@Inject
@@ -50,4 +50,4 @@ constructor(
Profile.Type.PRIVATE -> privateTabAccessibilityLabel
}
}
-} \ No newline at end of file
+}
diff --git a/java/src/com/android/intentresolver/v2/ui/ShortcutPolicyModule.kt b/java/src/com/android/intentresolver/v2/ui/ShortcutPolicyModule.kt
new file mode 100644
index 00000000..5e098cd5
--- /dev/null
+++ b/java/src/com/android/intentresolver/v2/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.v2.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/v2/ui/model/ActivityModel.kt b/java/src/com/android/intentresolver/v2/ui/model/ActivityModel.kt
index 07b17435..67c2a25e 100644
--- a/java/src/com/android/intentresolver/v2/ui/model/ActivityModel.kt
+++ b/java/src/com/android/intentresolver/v2/ui/model/ActivityModel.kt
@@ -20,6 +20,7 @@ import android.content.Intent
import android.net.Uri
import android.os.Parcel
import android.os.Parcelable
+import com.android.intentresolver.v2.data.model.ANDROID_APP_SCHEME
import com.android.intentresolver.v2.ext.readParcelable
import com.android.intentresolver.v2.ext.requireParcelable
import java.util.Objects
diff --git a/java/src/com/android/intentresolver/v2/ui/model/ResolverRequest.kt b/java/src/com/android/intentresolver/v2/ui/model/ResolverRequest.kt
index a4f74ca9..44010caf 100644
--- a/java/src/com/android/intentresolver/v2/ui/model/ResolverRequest.kt
+++ b/java/src/com/android/intentresolver/v2/ui/model/ResolverRequest.kt
@@ -19,8 +19,8 @@ package com.android.intentresolver.v2.ui.model
import android.content.Intent
import android.content.pm.ResolveInfo
import android.os.UserHandle
-import com.android.intentresolver.v2.shared.model.Profile
import com.android.intentresolver.v2.ext.isHomeIntent
+import com.android.intentresolver.v2.shared.model.Profile
/** All of the things that are consumed from an incoming Intent Resolution request (+Extras). */
data class ResolverRequest(
diff --git a/java/src/com/android/intentresolver/v2/ui/viewmodel/ChooserRequestReader.kt b/java/src/com/android/intentresolver/v2/ui/viewmodel/ChooserRequestReader.kt
index 91eed408..a25fcbea 100644
--- a/java/src/com/android/intentresolver/v2/ui/viewmodel/ChooserRequestReader.kt
+++ b/java/src/com/android/intentresolver/v2/ui/viewmodel/ChooserRequestReader.kt
@@ -44,10 +44,10 @@ import com.android.intentresolver.ContentTypeHint
import com.android.intentresolver.R
import com.android.intentresolver.inject.ChooserServiceFlags
import com.android.intentresolver.util.hasValidIcon
+import com.android.intentresolver.v2.data.model.ChooserRequest
import com.android.intentresolver.v2.ext.hasSendAction
import com.android.intentresolver.v2.ext.ifMatch
import com.android.intentresolver.v2.ui.model.ActivityModel
-import com.android.intentresolver.v2.ui.model.ChooserRequest
import com.android.intentresolver.v2.validation.Validation
import com.android.intentresolver.v2.validation.ValidationResult
import com.android.intentresolver.v2.validation.types.IntentOrUri
@@ -65,10 +65,10 @@ internal fun Intent.maybeAddSendActionFlags() =
}
fun readChooserRequest(
- launch: ActivityModel,
+ model: ActivityModel,
flags: ChooserServiceFlags
): ValidationResult<ChooserRequest> {
- val extras = launch.intent.extras ?: Bundle()
+ val extras = model.intent.extras ?: Bundle()
@Suppress("DEPRECATION")
return validateFrom(extras::get) {
val targetIntent = required(IntentOrUri(EXTRA_INTENT)).maybeAddSendActionFlags()
@@ -154,12 +154,12 @@ fun readChooserRequest(
isSendActionTarget = isSendAction,
targetType = targetIntent.type,
launchedFromPackage =
- requireNotNull(launch.launchedFromPackage) {
+ requireNotNull(model.launchedFromPackage) {
"launch.fromPackage was null, See Activity.getLaunchedFromPackage()"
},
title = customTitle,
defaultTitleResource = defaultTitleResource,
- referrer = launch.referrer,
+ referrer = model.referrer,
filteredComponentNames = filteredComponents,
callerChooserTargets = callerChooserTargets,
chooserActions = chooserActions,
diff --git a/java/src/com/android/intentresolver/v2/ui/viewmodel/ChooserViewModel.kt b/java/src/com/android/intentresolver/v2/ui/viewmodel/ChooserViewModel.kt
index 8ed2fa29..e39329b1 100644
--- a/java/src/com/android/intentresolver/v2/ui/viewmodel/ChooserViewModel.kt
+++ b/java/src/com/android/intentresolver/v2/ui/viewmodel/ChooserViewModel.kt
@@ -18,16 +18,26 @@ package com.android.intentresolver.v2.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.inject.Background
import com.android.intentresolver.inject.ChooserServiceFlags
+import com.android.intentresolver.v2.data.model.ChooserRequest
+import com.android.intentresolver.v2.data.repository.ChooserRequestRepository
import com.android.intentresolver.v2.ui.model.ActivityModel
import com.android.intentresolver.v2.ui.model.ActivityModel.Companion.ACTIVITY_MODEL_KEY
-import com.android.intentresolver.v2.ui.model.ChooserRequest
import com.android.intentresolver.v2.validation.Invalid
import com.android.intentresolver.v2.validation.Valid
import com.android.intentresolver.v2.validation.ValidationResult
-import com.android.intentresolver.v2.validation.log
+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"
@@ -36,7 +46,18 @@ class ChooserViewModel
@Inject
constructor(
args: SavedStateHandle,
- flags: ChooserServiceFlags,
+ 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 */
@@ -45,23 +66,29 @@ constructor(
"ActivityModel missing in SavedStateHandle! ($ACTIVITY_MODEL_KEY)"
}
- /** The result of reading and validating the inputs provided in savedState. */
- private val status: ValidationResult<ChooserRequest> = readChooserRequest(activityModel, flags)
-
- val chooserRequest: ChooserRequest by lazy {
- when (status) {
- is Valid -> status.value
- is Invalid -> error(status.errors)
+ 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()
}
- fun init(): Boolean {
- Log.i(TAG, "viewModel init")
- if (status is Invalid) {
- status.errors.forEach { finding -> finding.log(TAG) }
- return false
+ /**
+ * 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")
}
- Log.i(TAG, "request = $chooserRequest")
- return true
}
}
diff --git a/java/src/com/android/intentresolver/v2/validation/types/IntentOrUri.kt b/java/src/com/android/intentresolver/v2/validation/types/IntentOrUri.kt
index 050bd895..fc51ba1e 100644
--- a/java/src/com/android/intentresolver/v2/validation/types/IntentOrUri.kt
+++ b/java/src/com/android/intentresolver/v2/validation/types/IntentOrUri.kt
@@ -31,7 +31,6 @@ class IntentOrUri(override val key: String) : Validator<Intent> {
source: (String) -> Any?,
importance: Importance
): ValidationResult<Intent> {
-
return when (val value = source(key)) {
// An intent, return it.
is Intent -> Valid(value)
@@ -41,10 +40,11 @@ class IntentOrUri(override val key: String) : Validator<Intent> {
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))
- }
+ null ->
+ when (importance) {
+ Importance.WARNING -> Invalid() // No warnings if optional, but missing
+ Importance.CRITICAL -> Invalid(NoValue(key, importance, Intent::class))
+ }
// Some other type.
else -> {
diff --git a/java/src/com/android/intentresolver/v2/validation/types/ParceledArray.kt b/java/src/com/android/intentresolver/v2/validation/types/ParceledArray.kt
index 78adfd36..b68d972f 100644
--- a/java/src/com/android/intentresolver/v2/validation/types/ParceledArray.kt
+++ b/java/src/com/android/intentresolver/v2/validation/types/ParceledArray.kt
@@ -15,7 +15,6 @@
*/
package com.android.intentresolver.v2.validation.types
-import android.content.Intent
import com.android.intentresolver.v2.validation.Importance
import com.android.intentresolver.v2.validation.Invalid
import com.android.intentresolver.v2.validation.NoValue
@@ -36,13 +35,13 @@ class ParceledArray<T : Any>(
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))
- }
+ 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>.
diff --git a/java/src/com/android/intentresolver/v2/validation/types/SimpleValue.kt b/java/src/com/android/intentresolver/v2/validation/types/SimpleValue.kt
index 0105541d..0badebc4 100644
--- a/java/src/com/android/intentresolver/v2/validation/types/SimpleValue.kt
+++ b/java/src/com/android/intentresolver/v2/validation/types/SimpleValue.kt
@@ -37,21 +37,24 @@ class SimpleValue<T : Any>(
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))
- }
+ 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)
+ Invalid(
+ listOf(
+ ValueIsWrongType(
+ key,
+ importance,
+ actualType = value::class,
+ allowedTypes = listOf(expected)
+ )
)
- ))
+ )
}
}
}
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/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/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/tests/activity/Android.bp b/tests/activity/Android.bp
index f69caf0e..32077f98 100644
--- a/tests/activity/Android.bp
+++ b/tests/activity/Android.bp
@@ -15,6 +15,7 @@
//
package {
+ default_team: "trendy_team_capture_and_share",
default_applicable_licenses: ["Android-Apache-2.0"],
}
@@ -53,6 +54,7 @@ android_test {
"junit",
"kotlinx_coroutines_test",
"mockito-target-minus-junit4",
+ "mockito-kotlin2",
"testables",
"truth",
"truth-java8-extension",
diff --git a/tests/activity/src/com/android/intentresolver/ResolverActivityTest.java b/tests/activity/src/com/android/intentresolver/ResolverActivityTest.java
index dde2f980..05d397a2 100644
--- a/tests/activity/src/com/android/intentresolver/ResolverActivityTest.java
+++ b/tests/activity/src/com/android/intentresolver/ResolverActivityTest.java
@@ -49,7 +49,7 @@ import android.view.View;
import android.widget.RelativeLayout;
import android.widget.TextView;
-import androidx.test.InstrumentationRegistry;
+import androidx.test.platform.app.InstrumentationRegistry;
import androidx.test.espresso.Espresso;
import androidx.test.espresso.NoMatchingViewException;
import androidx.test.rule.ActivityTestRule;
diff --git a/tests/activity/src/com/android/intentresolver/UnbundledChooserActivityTest.java b/tests/activity/src/com/android/intentresolver/UnbundledChooserActivityTest.java
index f597d7f2..4077295c 100644
--- a/tests/activity/src/com/android/intentresolver/UnbundledChooserActivityTest.java
+++ b/tests/activity/src/com/android/intentresolver/UnbundledChooserActivityTest.java
@@ -119,6 +119,7 @@ import androidx.test.rule.ActivityTestRule;
import com.android.intentresolver.chooser.DisplayResolveInfo;
import com.android.intentresolver.contentpreview.ImageLoader;
+import com.android.intentresolver.ext.RecyclerViewExt;
import com.android.intentresolver.logging.EventLog;
import com.android.intentresolver.logging.FakeEventLog;
import com.android.intentresolver.shortcuts.ShortcutLoader;
@@ -800,7 +801,7 @@ public class UnbundledChooserActivityTest {
Uri uri = createTestContentProviderUri("image/png", null);
Intent sendIntent = createSendImageIntent(uri);
ChooserActivityOverrideData.getInstance().imageLoader =
- new TestPreviewImageLoader(Collections.emptyMap());
+ new FakeImageLoader(Collections.emptyMap());
sendIntent.putExtra(Intent.EXTRA_TEXT, "https://google.com/search?q=google");
List<ResolvedComponentInfo> resolvedComponentInfos = Arrays.asList(
@@ -935,6 +936,7 @@ public class UnbundledChooserActivityTest {
throw exception;
}
RecyclerView recyclerView = (RecyclerView) view;
+ RecyclerViewExt.endAnimations(recyclerView);
assertThat(recyclerView.getAdapter().getItemCount(), is(1));
assertThat(recyclerView.getChildCount(), is(1));
View imageView = recyclerView.getChildAt(0);
@@ -958,7 +960,7 @@ public class UnbundledChooserActivityTest {
Intent sendIntent = createSendUriIntentWithPreview(uris);
ChooserActivityOverrideData.getInstance().imageLoader =
- new TestPreviewImageLoader(Collections.emptyMap());
+ new FakeImageLoader(Collections.emptyMap());
List<ResolvedComponentInfo> resolvedComponentInfos = createResolvedComponentsForTest(2);
@@ -1076,7 +1078,7 @@ public class UnbundledChooserActivityTest {
bitmaps.put(imgTwoUri, createWideBitmap(Color.GREEN));
bitmaps.put(docUri, createWideBitmap(Color.BLUE));
ChooserActivityOverrideData.getInstance().imageLoader =
- new TestPreviewImageLoader(bitmaps);
+ new FakeImageLoader(bitmaps);
List<ResolvedComponentInfo> resolvedComponentInfos = createResolvedComponentsForTest(2);
setupResolverControllers(resolvedComponentInfos);
@@ -1094,6 +1096,7 @@ public class UnbundledChooserActivityTest {
throw exception;
}
RecyclerView recyclerView = (RecyclerView) view;
+ RecyclerViewExt.endAnimations(recyclerView);
assertThat(recyclerView.getChildCount()).isAtLeast(1);
// the first view is a preview
View imageView = recyclerView.getChildAt(0).findViewById(R.id.image);
@@ -3122,6 +3125,6 @@ public class UnbundledChooserActivityTest {
}
private static ImageLoader createImageLoader(Uri uri, Bitmap bitmap) {
- return new TestPreviewImageLoader(Collections.singletonMap(uri, bitmap));
+ return new FakeImageLoader(Collections.singletonMap(uri, bitmap));
}
}
diff --git a/tests/activity/src/com/android/intentresolver/UnbundledChooserActivityWorkProfileTest.java b/tests/activity/src/com/android/intentresolver/UnbundledChooserActivityWorkProfileTest.java
index da879f74..12def1de 100644
--- a/tests/activity/src/com/android/intentresolver/UnbundledChooserActivityWorkProfileTest.java
+++ b/tests/activity/src/com/android/intentresolver/UnbundledChooserActivityWorkProfileTest.java
@@ -44,7 +44,7 @@ import android.companion.DeviceFilter;
import android.content.Intent;
import android.os.UserHandle;
-import androidx.test.InstrumentationRegistry;
+import androidx.test.platform.app.InstrumentationRegistry;
import androidx.test.espresso.NoMatchingViewException;
import androidx.test.rule.ActivityTestRule;
diff --git a/java/src/com/android/intentresolver/contentpreview/MutableActionFactory.kt b/tests/activity/src/com/android/intentresolver/ext/RecyclerViewExt.kt
index 1cc1a6a6..90acaa60 100644
--- a/java/src/com/android/intentresolver/contentpreview/MutableActionFactory.kt
+++ b/tests/activity/src/com/android/intentresolver/ext/RecyclerViewExt.kt
@@ -14,16 +14,15 @@
* limitations under the License.
*/
-package com.android.intentresolver.contentpreview
+@file:JvmName("RecyclerViewExt")
-import android.service.chooser.ChooserAction
-import com.android.intentresolver.widget.ActionRow
-import kotlinx.coroutines.flow.Flow
+package com.android.intentresolver.ext
-interface MutableActionFactory {
- /** A flow of custom actions */
- val customActionsFlow: Flow<List<ActionRow.Action>>
+import androidx.recyclerview.widget.RecyclerView
- /** Update custom actions */
- fun updateCustomActions(actions: List<ChooserAction>)
+/** Ends active RecyclerView animations, if any */
+fun RecyclerView.endAnimations() {
+ if (isAnimating) {
+ itemAnimator?.endAnimations()
+ }
}
diff --git a/tests/activity/src/com/android/intentresolver/logging/TestEventLogModule.kt b/tests/activity/src/com/android/intentresolver/logging/TestEventLogModule.kt
index cd808af4..d1dea7c3 100644
--- a/tests/activity/src/com/android/intentresolver/logging/TestEventLogModule.kt
+++ b/tests/activity/src/com/android/intentresolver/logging/TestEventLogModule.kt
@@ -21,16 +21,16 @@ import com.android.internal.logging.InstanceIdSequence
import dagger.Binds
import dagger.Module
import dagger.Provides
-import dagger.hilt.android.components.ActivityComponent
-import dagger.hilt.android.scopes.ActivityScoped
+import dagger.hilt.android.components.ActivityRetainedComponent
+import dagger.hilt.android.scopes.ActivityRetainedScoped
import dagger.hilt.testing.TestInstallIn
/** Binds a [FakeEventLog] as [EventLog] in tests. */
@Module
-@TestInstallIn(components = [ActivityComponent::class], replaces = [EventLogModule::class])
+@TestInstallIn(components = [ActivityRetainedComponent::class], replaces = [EventLogModule::class])
interface TestEventLogModule {
- @Binds @ActivityScoped fun fakeEventLog(impl: FakeEventLog): EventLog
+ @Binds @ActivityRetainedScoped fun fakeEventLog(impl: FakeEventLog): EventLog
companion object {
@Provides
diff --git a/tests/activity/src/com/android/intentresolver/v2/ChooserActivityOverrideData.java b/tests/activity/src/com/android/intentresolver/v2/ChooserActivityOverrideData.java
index d6ee706a..1f3f6429 100644
--- a/tests/activity/src/com/android/intentresolver/v2/ChooserActivityOverrideData.java
+++ b/tests/activity/src/com/android/intentresolver/v2/ChooserActivityOverrideData.java
@@ -25,8 +25,6 @@ import android.content.res.Resources;
import android.database.Cursor;
import android.os.UserHandle;
-import com.android.intentresolver.AnnotatedUserHandles;
-import com.android.intentresolver.WorkProfileAvailabilityManager;
import com.android.intentresolver.chooser.TargetInfo;
import com.android.intentresolver.contentpreview.ImageLoader;
import com.android.intentresolver.emptystate.CrossProfileIntentsChecker;
@@ -61,13 +59,10 @@ public class ChooserActivityOverrideData {
public Cursor resolverCursor;
public boolean resolverForceException;
public ImageLoader imageLoader;
- public int alternateProfileSetting;
public Resources resources;
- public AnnotatedUserHandles annotatedUserHandles;
public boolean hasCrossProfileIntents;
public boolean isQuietModeEnabled;
public Integer myUserId;
- public WorkProfileAvailabilityManager mWorkProfileAvailability;
public CrossProfileIntentsChecker mCrossProfileIntentsChecker;
public void reset() {
@@ -78,42 +73,11 @@ public class ChooserActivityOverrideData {
resolverForceException = false;
resolverListController = mock(ChooserListController.class);
workResolverListController = mock(ChooserListController.class);
- alternateProfileSetting = 0;
resources = null;
- annotatedUserHandles = AnnotatedUserHandles.newBuilder()
- .setUserIdOfCallingApp(1234) // Must be non-negative.
- .setUserHandleSharesheetLaunchedAs(UserHandle.SYSTEM)
- .setPersonalProfileUserHandle(UserHandle.SYSTEM)
- .build();
hasCrossProfileIntents = true;
isQuietModeEnabled = false;
myUserId = 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);
diff --git a/tests/activity/src/com/android/intentresolver/v2/ChooserWrapperActivity.java b/tests/activity/src/com/android/intentresolver/v2/ChooserWrapperActivity.java
index 07e6e7b4..47d9c8c2 100644
--- a/tests/activity/src/com/android/intentresolver/v2/ChooserWrapperActivity.java
+++ b/tests/activity/src/com/android/intentresolver/v2/ChooserWrapperActivity.java
@@ -40,7 +40,6 @@ import com.android.intentresolver.chooser.DisplayResolveInfo;
import com.android.intentresolver.chooser.TargetInfo;
import com.android.intentresolver.emptystate.CrossProfileIntentsChecker;
import com.android.intentresolver.shortcuts.ShortcutLoader;
-import com.android.internal.logging.nano.MetricsProto.MetricsEvent;
import java.util.List;
import java.util.function.Consumer;
@@ -54,17 +53,7 @@ public class ChooserWrapperActivity extends ChooserActivity implements IChooserW
private UsageStatsManager mUsm;
@Override
- protected final ChooserActivityLogic createActivityLogic() {
- return new TestChooserActivityLogic(
- "ChooserWrapper",
- /* activity = */ this,
- this::onWorkProfileStatusUpdated,
- sOverrides.annotatedUserHandles,
- sOverrides.mWorkProfileAvailability);
- }
-
- @Override
- public ChooserListAdapter createChooserListAdapter(
+ public final ChooserListAdapter createChooserListAdapter(
Context context,
List<Intent> payloadIntents,
Intent[] initialIntents,
@@ -151,7 +140,7 @@ public class ChooserWrapperActivity extends ChooserActivity implements IChooserW
}
@Override
- protected ChooserListController createListController(UserHandle userHandle) {
+ public final ChooserListController createListController(UserHandle userHandle) {
if (userHandle == UserHandle.SYSTEM) {
return sOverrides.resolverListController;
}
@@ -187,14 +176,6 @@ public class ChooserWrapperActivity extends ChooserActivity implements IChooserW
}
@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,
diff --git a/tests/activity/src/com/android/intentresolver/v2/ResolverActivityTest.java b/tests/activity/src/com/android/intentresolver/v2/ResolverActivityTest.java
index 993f1760..21fe2904 100644
--- a/tests/activity/src/com/android/intentresolver/v2/ResolverActivityTest.java
+++ b/tests/activity/src/com/android/intentresolver/v2/ResolverActivityTest.java
@@ -49,7 +49,7 @@ import android.view.View;
import android.widget.RelativeLayout;
import android.widget.TextView;
-import androidx.test.InstrumentationRegistry;
+import androidx.test.platform.app.InstrumentationRegistry;
import androidx.test.espresso.Espresso;
import androidx.test.espresso.NoMatchingViewException;
import androidx.test.rule.ActivityTestRule;
diff --git a/tests/activity/src/com/android/intentresolver/v2/TestChooserActivityLogic.kt b/tests/activity/src/com/android/intentresolver/v2/TestChooserActivityLogic.kt
deleted file mode 100644
index fe649819..00000000
--- a/tests/activity/src/com/android/intentresolver/v2/TestChooserActivityLogic.kt
+++ /dev/null
@@ -1,25 +0,0 @@
-package com.android.intentresolver.v2
-
-import androidx.activity.ComponentActivity
-import com.android.intentresolver.AnnotatedUserHandles
-import com.android.intentresolver.WorkProfileAvailabilityManager
-
-/** Activity logic for use when testing [ChooserActivity]. */
-class TestChooserActivityLogic(
- tag: String,
- activity: ComponentActivity,
- onWorkProfileStatusUpdated: () -> Unit,
- private val annotatedUserHandlesOverride: AnnotatedUserHandles?,
- private val workProfileAvailabilityOverride: WorkProfileAvailabilityManager?,
-) :
- ChooserActivityLogic(
- tag,
- activity,
- onWorkProfileStatusUpdated,
- ) {
- override val annotatedUserHandles: AnnotatedUserHandles?
- get() = annotatedUserHandlesOverride ?: super.annotatedUserHandles
-
- override val workProfileAvailabilityManager: WorkProfileAvailabilityManager
- get() = workProfileAvailabilityOverride ?: super.workProfileAvailabilityManager
-}
diff --git a/tests/activity/src/com/android/intentresolver/v2/UnbundledChooserActivityTest.java b/tests/activity/src/com/android/intentresolver/v2/UnbundledChooserActivityTest.java
index b8113422..7848983e 100644
--- a/tests/activity/src/com/android/intentresolver/v2/UnbundledChooserActivityTest.java
+++ b/tests/activity/src/com/android/intentresolver/v2/UnbundledChooserActivityTest.java
@@ -117,27 +117,33 @@ import androidx.test.espresso.matcher.ViewMatchers;
import androidx.test.platform.app.InstrumentationRegistry;
import androidx.test.rule.ActivityTestRule;
-import com.android.intentresolver.AnnotatedUserHandles;
import com.android.intentresolver.ChooserListAdapter;
+import com.android.intentresolver.FakeImageLoader;
import com.android.intentresolver.Flags;
import com.android.intentresolver.IChooserWrapper;
import com.android.intentresolver.R;
import com.android.intentresolver.ResolvedComponentInfo;
import com.android.intentresolver.ResolverDataProvider;
import com.android.intentresolver.TestContentProvider;
-import com.android.intentresolver.TestPreviewImageLoader;
import com.android.intentresolver.chooser.DisplayResolveInfo;
import com.android.intentresolver.contentpreview.ImageLoader;
+import com.android.intentresolver.contentpreview.ImageLoaderModule;
+import com.android.intentresolver.ext.RecyclerViewExt;
+import com.android.intentresolver.inject.ApplicationUser;
import com.android.intentresolver.inject.PackageManagerModule;
+import com.android.intentresolver.inject.ProfileParent;
import com.android.intentresolver.logging.EventLog;
import com.android.intentresolver.logging.FakeEventLog;
import com.android.intentresolver.shortcuts.ShortcutLoader;
+import com.android.intentresolver.v2.data.repository.FakeUserRepository;
+import com.android.intentresolver.v2.data.repository.UserRepository;
+import com.android.intentresolver.v2.data.repository.UserRepositoryModule;
import com.android.intentresolver.v2.platform.AppPredictionAvailable;
import com.android.intentresolver.v2.platform.AppPredictionModule;
import com.android.intentresolver.v2.platform.ImageEditor;
import com.android.intentresolver.v2.platform.ImageEditorModule;
+import com.android.intentresolver.v2.shared.model.User;
import com.android.internal.config.sysui.SystemUiDeviceConfigFlags;
-import com.android.internal.logging.nano.MetricsProto.MetricsEvent;
import dagger.hilt.android.qualifiers.ApplicationContext;
import dagger.hilt.android.testing.BindValue;
@@ -160,7 +166,6 @@ import org.mockito.Mockito;
import java.util.ArrayList;
import java.util.Arrays;
-import java.util.Collections;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
@@ -183,7 +188,9 @@ import javax.inject.Inject;
@UninstallModules({
AppPredictionModule.class,
ImageEditorModule.class,
- PackageManagerModule.class
+ PackageManagerModule.class,
+ ImageLoaderModule.class,
+ UserRepositoryModule.class,
})
public class UnbundledChooserActivityTest {
@@ -193,9 +200,20 @@ public class UnbundledChooserActivityTest {
private static final UserHandle PERSONAL_USER_HANDLE = InstrumentationRegistry
.getInstrumentation().getTargetContext().getUser();
+
+ private static final User PERSONAL_USER =
+ new User(PERSONAL_USER_HANDLE.getIdentifier(), User.Role.PERSONAL);
+
private static final UserHandle WORK_PROFILE_USER_HANDLE = UserHandle.of(10);
+
+ private static final User WORK_USER =
+ new User(WORK_PROFILE_USER_HANDLE.getIdentifier(), User.Role.WORK);
+
private static final UserHandle CLONE_PROFILE_USER_HANDLE = UserHandle.of(11);
+ private static final User CLONE_USER =
+ new User(CLONE_PROFILE_USER_HANDLE.getIdentifier(), User.Role.CLONE);
+
@Parameters(name = "appPrediction={0}")
public static Iterable<?> parameters() {
return Arrays.asList(
@@ -239,6 +257,25 @@ public class UnbundledChooserActivityTest {
@BindValue
PackageManager mPackageManager;
+ /** "launchedAs" */
+ @BindValue
+ @ApplicationUser
+ UserHandle mApplicationUser = PERSONAL_USER_HANDLE;
+
+ @BindValue
+ @ProfileParent
+ UserHandle mProfileParent = PERSONAL_USER_HANDLE;
+
+ private final FakeUserRepository mFakeUserRepo = new FakeUserRepository(List.of(PERSONAL_USER));
+
+ @BindValue
+ final UserRepository mUserRepository = mFakeUserRepo;
+
+ private final FakeImageLoader mFakeImageLoader = new FakeImageLoader();
+
+ @BindValue
+ final ImageLoader mImageLoader = mFakeImageLoader;
+
@Before
public void setUp() {
// TODO: use the other form of `adoptShellPermissionIdentity()` where we explicitly list the
@@ -257,6 +294,9 @@ public class UnbundledChooserActivityTest {
// values to the dependency graph at activity launch time. This allows replacing
// arbitrary bindings per-test case if needed.
mPackageManager = mContext.getPackageManager();
+
+ // TODO: inject image loader in the prod code and remove this override
+ ChooserActivityOverrideData.getInstance().imageLoader = mFakeImageLoader;
}
public UnbundledChooserActivityTest(boolean appPredictionAvailable) {
@@ -434,14 +474,13 @@ public class UnbundledChooserActivityTest {
}
@Test
- public void visiblePreviewTitleAndThumbnail() throws InterruptedException {
+ public void visiblePreviewTitleAndThumbnail() {
String previewTitle = "My Content Preview Title";
Uri uri = Uri.parse(
"android.resource://com.android.frameworks.coretests/"
+ com.android.intentresolver.tests.R.drawable.test320x240);
Intent sendIntent = createSendTextIntentWithPreview(previewTitle, uri);
- ChooserActivityOverrideData.getInstance().imageLoader =
- createImageLoader(uri, createBitmap());
+ mFakeImageLoader.setBitmap(uri, createBitmap());
List<ResolvedComponentInfo> resolvedComponentInfos = createResolvedComponentsForTest(2);
setupResolverControllers(resolvedComponentInfos);
@@ -707,8 +746,7 @@ public class UnbundledChooserActivityTest {
public void testFilePlusTextSharing_ExcludeText() {
Uri uri = createTestContentProviderUri(null, "image/png");
Intent sendIntent = createSendImageIntent(uri);
- ChooserActivityOverrideData.getInstance().imageLoader =
- createImageLoader(uri, createBitmap());
+ mFakeImageLoader.setBitmap(uri, createBitmap());
sendIntent.putExtra(Intent.EXTRA_TEXT, "https://google.com/search?q=google");
List<ResolvedComponentInfo> resolvedComponentInfos = Arrays.asList(
@@ -749,8 +787,7 @@ public class UnbundledChooserActivityTest {
public void testFilePlusTextSharing_RemoveAndAddBackText() {
Uri uri = createTestContentProviderUri("application/pdf", "image/png");
Intent sendIntent = createSendImageIntent(uri);
- ChooserActivityOverrideData.getInstance().imageLoader =
- createImageLoader(uri, createBitmap());
+ mFakeImageLoader.setBitmap(uri, createBitmap());
final String text = "https://google.com/search?q=google";
sendIntent.putExtra(Intent.EXTRA_TEXT, text);
@@ -797,8 +834,7 @@ public class UnbundledChooserActivityTest {
public void testFilePlusTextSharing_TextExclusionDoesNotAffectAlternativeIntent() {
Uri uri = createTestContentProviderUri("image/png", null);
Intent sendIntent = createSendImageIntent(uri);
- ChooserActivityOverrideData.getInstance().imageLoader =
- createImageLoader(uri, createBitmap());
+ mFakeImageLoader.setBitmap(uri, createBitmap());
sendIntent.putExtra(Intent.EXTRA_TEXT, "https://google.com/search?q=google");
Intent alternativeIntent = createSendTextIntent();
@@ -841,8 +877,6 @@ public class UnbundledChooserActivityTest {
public void testImagePlusTextSharing_failedThumbnailAndExcludedText_textChanges() {
Uri uri = createTestContentProviderUri("image/png", null);
Intent sendIntent = createSendImageIntent(uri);
- ChooserActivityOverrideData.getInstance().imageLoader =
- new TestPreviewImageLoader(Collections.emptyMap());
sendIntent.putExtra(Intent.EXTRA_TEXT, "https://google.com/search?q=google");
List<ResolvedComponentInfo> resolvedComponentInfos = Arrays.asList(
@@ -937,8 +971,7 @@ public class UnbundledChooserActivityTest {
Uri uri = createTestContentProviderUri("image/png", null);
Intent sendIntent = createSendImageIntent(uri);
- ChooserActivityOverrideData.getInstance().imageLoader =
- createImageLoader(uri, createBitmap());
+ mFakeImageLoader.setBitmap(uri, createBitmap());
List<ResolvedComponentInfo> resolvedComponentInfos = createResolvedComponentsForTest(2);
@@ -962,8 +995,7 @@ public class UnbundledChooserActivityTest {
uris.add(uri);
Intent sendIntent = createSendUriIntentWithPreview(uris);
- ChooserActivityOverrideData.getInstance().imageLoader =
- createImageLoader(uri, createWideBitmap());
+ mFakeImageLoader.setBitmap(uri, createWideBitmap());
List<ResolvedComponentInfo> resolvedComponentInfos = createResolvedComponentsForTest(2);
@@ -976,6 +1008,7 @@ public class UnbundledChooserActivityTest {
throw exception;
}
RecyclerView recyclerView = (RecyclerView) view;
+ RecyclerViewExt.endAnimations(recyclerView);
assertThat("recyclerView adapter item count",
recyclerView.getAdapter().getItemCount(), is(1));
assertThat("recyclerView child view count",
@@ -1000,8 +1033,6 @@ public class UnbundledChooserActivityTest {
uris.add(uri);
Intent sendIntent = createSendUriIntentWithPreview(uris);
- ChooserActivityOverrideData.getInstance().imageLoader =
- new TestPreviewImageLoader(Collections.emptyMap());
List<ResolvedComponentInfo> resolvedComponentInfos = createResolvedComponentsForTest(2);
@@ -1019,8 +1050,7 @@ public class UnbundledChooserActivityTest {
ArrayList<Uri> uris = new ArrayList<>(1);
uris.add(uri);
Intent sendIntent = createSendUriIntentWithPreview(uris);
- ChooserActivityOverrideData.getInstance().imageLoader =
- createImageLoader(uri, createBitmap());
+ mFakeImageLoader.setBitmap(uri, createBitmap());
List<ResolvedComponentInfo> resolvedComponentInfos = createResolvedComponentsForTest(2);
@@ -1046,8 +1076,7 @@ public class UnbundledChooserActivityTest {
}
uris.add(imageUri);
Intent sendIntent = createSendUriIntentWithPreview(uris);
- ChooserActivityOverrideData.getInstance().imageLoader =
- createImageLoader(imageUri, createBitmap());
+ mFakeImageLoader.setBitmap(imageUri, createBitmap());
List<ResolvedComponentInfo> resolvedComponentInfos = createResolvedComponentsForTest(2);
setupResolverControllers(resolvedComponentInfos);
@@ -1079,8 +1108,7 @@ public class UnbundledChooserActivityTest {
uris.add(uri);
Intent sendIntent = createSendUriIntentWithPreview(uris);
- ChooserActivityOverrideData.getInstance().imageLoader =
- createImageLoader(uri, createBitmap());
+ mFakeImageLoader.setBitmap(uri, createBitmap());
List<ResolvedComponentInfo> resolvedComponentInfos = createResolvedComponentsForTest(2);
@@ -1114,12 +1142,9 @@ public class UnbundledChooserActivityTest {
uris.add(docUri);
Intent sendIntent = createSendUriIntentWithPreview(uris);
- Map<Uri, Bitmap> bitmaps = new HashMap<>();
- bitmaps.put(imgOneUri, createWideBitmap(Color.RED));
- bitmaps.put(imgTwoUri, createWideBitmap(Color.GREEN));
- bitmaps.put(docUri, createWideBitmap(Color.BLUE));
- ChooserActivityOverrideData.getInstance().imageLoader =
- new TestPreviewImageLoader(bitmaps);
+ mFakeImageLoader.setBitmap(imgOneUri, createWideBitmap(Color.RED));
+ mFakeImageLoader.setBitmap(imgTwoUri, createWideBitmap(Color.GREEN));
+ mFakeImageLoader.setBitmap(docUri, createWideBitmap(Color.BLUE));
List<ResolvedComponentInfo> resolvedComponentInfos = createResolvedComponentsForTest(2);
setupResolverControllers(resolvedComponentInfos);
@@ -1137,6 +1162,7 @@ public class UnbundledChooserActivityTest {
throw exception;
}
RecyclerView recyclerView = (RecyclerView) view;
+ RecyclerViewExt.endAnimations(recyclerView);
assertThat(recyclerView.getChildCount()).isAtLeast(1);
// the first view is a preview
View imageView = recyclerView.getChildAt(0).findViewById(R.id.image);
@@ -1167,8 +1193,7 @@ public class UnbundledChooserActivityTest {
Intent sendIntent = createSendUriIntentWithPreview(uris);
sendIntent.putExtra(Intent.EXTRA_TEXT, sharedText);
- ChooserActivityOverrideData.getInstance().imageLoader =
- createImageLoader(uri, createBitmap());
+ mFakeImageLoader.setBitmap(uri, createBitmap());
List<ResolvedComponentInfo> resolvedComponentInfos = createResolvedComponentsForTest(2);
@@ -1197,8 +1222,7 @@ public class UnbundledChooserActivityTest {
Intent sendIntent = createSendUriIntentWithPreview(uris);
sendIntent.putExtra(Intent.EXTRA_TEXT, sharedText);
- ChooserActivityOverrideData.getInstance().imageLoader =
- createImageLoader(uri, createBitmap());
+ mFakeImageLoader.setBitmap(uri, createBitmap());
List<ResolvedComponentInfo> resolvedComponentInfos = createResolvedComponentsForTest(2);
@@ -1234,8 +1258,7 @@ public class UnbundledChooserActivityTest {
Intent sendIntent = createSendUriIntentWithPreview(uris);
sendIntent.putExtra(Intent.EXTRA_TEXT, sharedText);
- ChooserActivityOverrideData.getInstance().imageLoader =
- createImageLoader(uri, createBitmap());
+ mFakeImageLoader.setBitmap(uri, createBitmap());
List<ResolvedComponentInfo> resolvedComponentInfos = createResolvedComponentsForTest(2);
@@ -1275,8 +1298,10 @@ public class UnbundledChooserActivityTest {
public void testOnCreateLoggingFromWorkProfile() {
Intent sendIntent = createSendTextIntent();
sendIntent.setType(TEST_MIME_TYPE);
- ChooserActivityOverrideData.getInstance().alternateProfileSetting =
- MetricsEvent.MANAGED_PROFILE;
+
+ // Launch as work user.
+ mFakeUserRepo.addUser(WORK_USER, true);
+ mApplicationUser = WORK_PROFILE_USER_HANDLE;
ChooserWrapperActivity activity =
mActivityRule.launchActivity(Intent.createChooser(sendIntent, "logger test"));
@@ -1331,8 +1356,7 @@ public class UnbundledChooserActivityTest {
uris.add(uri);
Intent sendIntent = createSendUriIntentWithPreview(uris);
- ChooserActivityOverrideData.getInstance().imageLoader =
- createImageLoader(uri, createBitmap());
+ mFakeImageLoader.setBitmap(uri, createBitmap());
List<ResolvedComponentInfo> resolvedComponentInfos = createResolvedComponentsForTest(2);
@@ -2174,7 +2198,7 @@ public class UnbundledChooserActivityTest {
createResolvedComponentsForTestWithOtherProfile(3, /* userId */ 10);
List<ResolvedComponentInfo> workResolvedComponentInfos =
createResolvedComponentsForTest(workProfileTargets);
- ChooserActivityOverrideData.getInstance().isQuietModeEnabled = true;
+ mFakeUserRepo.updateState(WORK_USER, false);
setupResolverControllers(personalResolvedComponentInfos, workResolvedComponentInfos);
Intent sendIntent = createSendTextIntent();
sendIntent.setType(TEST_MIME_TYPE);
@@ -2228,8 +2252,7 @@ public class UnbundledChooserActivityTest {
uris.add(uri);
Intent sendIntent = createSendUriIntentWithPreview(uris);
- ChooserActivityOverrideData.getInstance().imageLoader =
- createImageLoader(uri, createWideBitmap());
+ mFakeImageLoader.setBitmap(uri, createWideBitmap());
mActivityRule.launchActivity(Intent.createChooser(sendIntent, "Scrollable preview test"));
waitForIdle();
@@ -2257,7 +2280,7 @@ public class UnbundledChooserActivityTest {
List<ResolvedComponentInfo> workResolvedComponentInfos =
createResolvedComponentsForTest(0);
setupResolverControllers(personalResolvedComponentInfos, workResolvedComponentInfos);
- ChooserActivityOverrideData.getInstance().isQuietModeEnabled = true;
+ mFakeUserRepo.updateState(WORK_USER, false);
ChooserActivityOverrideData.getInstance().hasCrossProfileIntents = false;
Intent sendIntent = createSendTextIntent();
sendIntent.setType(TEST_MIME_TYPE);
@@ -2281,7 +2304,7 @@ public class UnbundledChooserActivityTest {
List<ResolvedComponentInfo> workResolvedComponentInfos =
createResolvedComponentsForTest(0);
setupResolverControllers(personalResolvedComponentInfos, workResolvedComponentInfos);
- ChooserActivityOverrideData.getInstance().isQuietModeEnabled = true;
+ mFakeUserRepo.updateState(WORK_USER, false);
Intent sendIntent = createSendTextIntent();
sendIntent.setType(TEST_MIME_TYPE);
@@ -3017,18 +3040,12 @@ public class UnbundledChooserActivityTest {
}
private void markOtherProfileAvailability(boolean workAvailable, boolean cloneAvailable) {
- AnnotatedUserHandles.Builder handles = AnnotatedUserHandles.newBuilder();
- handles
- .setUserIdOfCallingApp(1234) // Must be non-negative.
- .setUserHandleSharesheetLaunchedAs(PERSONAL_USER_HANDLE)
- .setPersonalProfileUserHandle(PERSONAL_USER_HANDLE);
if (workAvailable) {
- handles.setWorkProfileUserHandle(WORK_PROFILE_USER_HANDLE);
+ mFakeUserRepo.addUser(WORK_USER, /* available= */ true);
}
if (cloneAvailable) {
- handles.setCloneProfileUserHandle(CLONE_PROFILE_USER_HANDLE);
+ mFakeUserRepo.addUser(CLONE_USER, /* available= */ true);
}
- ChooserWrapperActivity.sOverrides.annotatedUserHandles = handles.build();
}
private void setupResolverControllers(
@@ -3048,19 +3065,8 @@ public class UnbundledChooserActivityTest {
Mockito.anyBoolean(),
Mockito.anyBoolean(),
Mockito.isA(List.class),
- eq(UserHandle.SYSTEM)))
- .thenReturn(new ArrayList<>(personalResolvedComponentInfos));
- when(
- ChooserActivityOverrideData
- .getInstance()
- .workResolverListController
- .getResolversForIntentAsUser(
- Mockito.anyBoolean(),
- Mockito.anyBoolean(),
- Mockito.anyBoolean(),
- Mockito.isA(List.class),
- eq(UserHandle.SYSTEM)))
- .thenReturn(new ArrayList<>(personalResolvedComponentInfos));
+ eq(PERSONAL_USER_HANDLE)))
+ .thenReturn(new ArrayList<>(personalResolvedComponentInfos));
when(
ChooserActivityOverrideData
.getInstance()
@@ -3070,8 +3076,8 @@ public class UnbundledChooserActivityTest {
Mockito.anyBoolean(),
Mockito.anyBoolean(),
Mockito.isA(List.class),
- eq(UserHandle.of(10))))
- .thenReturn(new ArrayList<>(workResolvedComponentInfos));
+ eq(WORK_PROFILE_USER_HANDLE)))
+ .thenReturn(new ArrayList<>(workResolvedComponentInfos));
}
private static GridRecyclerSpanCountMatcher withGridColumnCount(int columnCount) {
@@ -3134,8 +3140,4 @@ public class UnbundledChooserActivityTest {
};
return shortcutLoaders;
}
-
- private static ImageLoader createImageLoader(Uri uri, Bitmap bitmap) {
- return new TestPreviewImageLoader(Collections.singletonMap(uri, bitmap));
- }
}
diff --git a/tests/activity/src/com/android/intentresolver/v2/UnbundledChooserActivityWorkProfileTest.java b/tests/activity/src/com/android/intentresolver/v2/UnbundledChooserActivityWorkProfileTest.java
index e4ec1776..8d83773e 100644
--- a/tests/activity/src/com/android/intentresolver/v2/UnbundledChooserActivityWorkProfileTest.java
+++ b/tests/activity/src/com/android/intentresolver/v2/UnbundledChooserActivityWorkProfileTest.java
@@ -17,6 +17,7 @@
package com.android.intentresolver.v2;
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;
@@ -25,6 +26,7 @@ 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.v2.ChooserWrapperActivity.sOverrides;
import static com.android.intentresolver.v2.UnbundledChooserActivityWorkProfileTest.TestCase.ExpectedBlocker.NO_BLOCKER;
import static com.android.intentresolver.v2.UnbundledChooserActivityWorkProfileTest.TestCase.ExpectedBlocker.PERSONAL_PROFILE_ACCESS_BLOCKER;
@@ -33,6 +35,7 @@ import static com.android.intentresolver.v2.UnbundledChooserActivityWorkProfileT
import static com.android.intentresolver.v2.UnbundledChooserActivityWorkProfileTest.TestCase.ExpectedBlocker.WORK_PROFILE_SHARE_BLOCKER;
import static com.android.intentresolver.v2.UnbundledChooserActivityWorkProfileTest.TestCase.Tab.PERSONAL;
import static com.android.intentresolver.v2.UnbundledChooserActivityWorkProfileTest.TestCase.Tab.WORK;
+
import static org.hamcrest.CoreMatchers.not;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.when;
@@ -41,15 +44,25 @@ import android.companion.DeviceFilter;
import android.content.Intent;
import android.os.UserHandle;
-import androidx.test.InstrumentationRegistry;
+import androidx.test.platform.app.InstrumentationRegistry;
import androidx.test.espresso.NoMatchingViewException;
import androidx.test.rule.ActivityTestRule;
-import com.android.intentresolver.AnnotatedUserHandles;
import com.android.intentresolver.R;
import com.android.intentresolver.ResolvedComponentInfo;
import com.android.intentresolver.ResolverDataProvider;
+import com.android.intentresolver.inject.ApplicationUser;
+import com.android.intentresolver.inject.ProfileParent;
import com.android.intentresolver.v2.UnbundledChooserActivityWorkProfileTest.TestCase.Tab;
+import com.android.intentresolver.v2.data.repository.FakeUserRepository;
+import com.android.intentresolver.v2.data.repository.UserRepository;
+import com.android.intentresolver.v2.data.repository.UserRepositoryModule;
+import com.android.intentresolver.v2.shared.model.User;
+
+import dagger.hilt.android.testing.BindValue;
+import dagger.hilt.android.testing.HiltAndroidRule;
+import dagger.hilt.android.testing.HiltAndroidTest;
+import dagger.hilt.android.testing.UninstallModules;
import junit.framework.AssertionFailedError;
@@ -65,12 +78,10 @@ import java.util.Arrays;
import java.util.Collection;
import java.util.List;
-import dagger.hilt.android.testing.HiltAndroidRule;
-import dagger.hilt.android.testing.HiltAndroidTest;
-
@DeviceFilter.MediumType
@RunWith(Parameterized.class)
@HiltAndroidTest
+@UninstallModules(UserRepositoryModule.class)
public class UnbundledChooserActivityWorkProfileTest {
private static final UserHandle PERSONAL_USER_HANDLE = InstrumentationRegistry
@@ -84,10 +95,31 @@ public class UnbundledChooserActivityWorkProfileTest {
public ActivityTestRule<ChooserWrapperActivity> mActivityRule =
new ActivityTestRule<>(ChooserWrapperActivity.class, false,
false);
+
+ @BindValue
+ @ApplicationUser
+ public final UserHandle mApplicationUser;
+
+ @BindValue
+ @ProfileParent
+ public final UserHandle mProfileParent;
+
+ /** For setup of test state, a mutable reference of mUserRepository */
+ private final FakeUserRepository mFakeUserRepo = new FakeUserRepository(
+ List.of(new User(PERSONAL_USER_HANDLE.getIdentifier(), User.Role.PERSONAL)));
+
+ @BindValue
+ public final UserRepository mUserRepository;
+
private final TestCase mTestCase;
public UnbundledChooserActivityWorkProfileTest(TestCase testCase) {
mTestCase = testCase;
+ mApplicationUser = mTestCase.getMyUserHandle();
+ mProfileParent = PERSONAL_USER_HANDLE;
+ mUserRepository = new FakeUserRepository(List.of(
+ new User(PERSONAL_USER_HANDLE.getIdentifier(), User.Role.PERSONAL),
+ new User(WORK_USER_HANDLE.getIdentifier(), User.Role.WORK)));
}
@Before
@@ -268,12 +300,6 @@ public class UnbundledChooserActivityWorkProfileTest {
}
private void setUpPersonalAndWorkComponentInfos() {
- ChooserWrapperActivity.sOverrides.annotatedUserHandles = AnnotatedUserHandles.newBuilder()
- .setUserIdOfCallingApp(1234) // Must be non-negative.
- .setUserHandleSharesheetLaunchedAs(mTestCase.getMyUserHandle())
- .setPersonalProfileUserHandle(PERSONAL_USER_HANDLE)
- .setWorkProfileUserHandle(WORK_USER_HANDLE)
- .build();
int workProfileTargets = 4;
List<ResolvedComponentInfo> personalResolvedComponentInfos =
createResolvedComponentsForTestWithOtherProfile(3,
diff --git a/tests/integration/Android.bp b/tests/integration/Android.bp
index f17df160..4c8fc37a 100644
--- a/tests/integration/Android.bp
+++ b/tests/integration/Android.bp
@@ -15,6 +15,7 @@
//
package {
+ default_team: "trendy_team_capture_and_share",
default_applicable_licenses: ["Android-Apache-2.0"],
}
@@ -40,5 +41,5 @@ android_test {
"truth",
"truth-java8-extension",
],
- test_suites: ["general-tests"]
+ test_suites: ["general-tests"],
}
diff --git a/tests/shared/Android.bp b/tests/shared/Android.bp
index 55188ee3..249bf38f 100644
--- a/tests/shared/Android.bp
+++ b/tests/shared/Android.bp
@@ -31,7 +31,8 @@ java_library {
static_libs: [
"hamcrest",
"IntentResolver-core",
+ "mockito-kotlin2",
"mockito-target-minus-junit4",
- "truth"
+ "truth",
],
}
diff --git a/tests/shared/src/com/android/intentresolver/TestPreviewImageLoader.kt b/tests/shared/src/com/android/intentresolver/FakeImageLoader.kt
index f0203bb6..c57ea78b 100644
--- a/tests/shared/src/com/android/intentresolver/TestPreviewImageLoader.kt
+++ b/tests/shared/src/com/android/intentresolver/FakeImageLoader.kt
@@ -22,7 +22,9 @@ import com.android.intentresolver.contentpreview.ImageLoader
import java.util.function.Consumer
import kotlinx.coroutines.CoroutineScope
-class TestPreviewImageLoader(private val bitmaps: Map<Uri, Bitmap>) : ImageLoader {
+class FakeImageLoader(initialBitmaps: Map<Uri, Bitmap> = emptyMap()) : ImageLoader {
+ private val bitmaps = HashMap<Uri, Bitmap>().apply { putAll(initialBitmaps) }
+
override fun loadImage(callerScope: CoroutineScope, uri: Uri, callback: Consumer<Bitmap?>) {
callback.accept(bitmaps[uri])
}
@@ -30,4 +32,8 @@ class TestPreviewImageLoader(private val bitmaps: Map<Uri, Bitmap>) : ImageLoade
override suspend fun invoke(uri: Uri, caching: Boolean): Bitmap? = bitmaps[uri]
override fun prePopulate(uris: List<Uri>) = Unit
+
+ fun setBitmap(uri: Uri, bitmap: Bitmap) {
+ bitmaps[uri] = bitmap
+ }
}
diff --git a/tests/shared/src/com/android/intentresolver/MockitoKotlinHelpers.kt b/tests/shared/src/com/android/intentresolver/MockitoKotlinHelpers.kt
index db9fbd93..b7b97d6f 100644
--- a/tests/shared/src/com/android/intentresolver/MockitoKotlinHelpers.kt
+++ b/tests/shared/src/com/android/intentresolver/MockitoKotlinHelpers.kt
@@ -14,14 +14,16 @@
* limitations under the License.
*/
+@file:Suppress("NOTHING_TO_INLINE")
+
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
+ * 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
@@ -33,42 +35,49 @@ import org.mockito.stubbing.OngoingStubbing
import org.mockito.stubbing.Stubber
/**
- * Returns Mockito.eq() as nullable type to avoid java.lang.IllegalStateException when
- * null is returned.
+ * 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)
+inline fun <T> eq(obj: T): T = Mockito.eq<T>(obj) ?: obj
/**
- * Returns Mockito.any() as nullable type to avoid java.lang.IllegalStateException when
- * null is returned.
+ * Returns Mockito.same() 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)
+inline fun <T> same(obj: T): T = Mockito.same<T>(obj) ?: obj
/**
- * Returns Mockito.argThat() as nullable type to avoid java.lang.IllegalStateException when
- * null is returned.
+ * 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> argThat(matcher: ArgumentMatcher<T>): T = Mockito.argThat(matcher)
+inline fun <T> any(type: Class<T>): T = Mockito.any<T>(type)
+
+inline fun <reified T> any(): T = any(T::class.java)
/**
- * Kotlin type-inferred version of Mockito.nullable()
+ * Returns Mockito.argThat() as nullable type to avoid java.lang.IllegalStateException when null is
+ * returned.
+ *
+ * Generic T is nullable because implicitly bounded by Any?.
*/
+inline 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.
+ * 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()
+inline fun <T> capture(argumentCaptor: ArgumentCaptor<T>): T = argumentCaptor.capture()
/**
* Helper function for creating an argumentCaptor in kotlin.
@@ -90,17 +99,18 @@ inline fun <reified T : Any> mock(
apply: T.() -> Unit = {}
): T = Mockito.mock(T::class.java, mockSettings).apply(apply)
+/** Matches any array of type T. */
+inline fun <reified T : Any?> anyArray(): Array<T> = Mockito.any(Array<T>::class.java) ?: arrayOf()
+
/**
* Helper function for stubbing methods without the need to use backticks.
*
* @see Mockito.when
*/
-fun <T> whenever(methodCall: T): OngoingStubbing<T> = Mockito.`when`(methodCall)
+inline fun <T> whenever(methodCall: T): OngoingStubbing<T> = Mockito.`when`(methodCall)
-/**
- * Helper function for stubbing methods without the need to use backticks.
- */
-fun <T> Stubber.whenever(mock: T): T = `when`(mock)
+/** Helper function for stubbing methods without the need to use backticks. */
+inline fun <T> Stubber.whenever(mock: T): T = `when`(mock)
/**
* A kotlin implemented wrapper of [ArgumentCaptor] which prevents the following exception when
@@ -128,13 +138,12 @@ inline fun <reified T : Any> kotlinArgumentCaptor(): KotlinArgumentCaptor<T> =
/**
* Helper function for creating and using a single-use ArgumentCaptor in kotlin.
*
- * val captor = argumentCaptor<Foo>()
- * verify(...).someMethod(captor.capture())
- * val captured = captor.value
+ * val captor = argumentCaptor<Foo>() verify(...).someMethod(captor.capture()) val captured =
+ * captor.value
*
* becomes:
*
- * val captured = withArgCaptor<Foo> { verify(...).someMethod(capture()) }
+ * val captured = withArgCaptor<Foo> { verify(...).someMethod(capture()) }
*
* NOTE: this uses the KotlinArgumentCaptor to avoid the NullPointerException.
*/
@@ -144,13 +153,12 @@ inline fun <reified T : Any> withArgCaptor(block: KotlinArgumentCaptor<T>.() ->
/**
* Variant of [withArgCaptor] for capturing multiple arguments.
*
- * val captor = argumentCaptor<Foo>()
- * verify(...).someMethod(captor.capture())
- * val captured: List<Foo> = captor.allValues
+ * val captor = argumentCaptor<Foo>() verify(...).someMethod(captor.capture()) val captured:
+ * List<Foo> = captor.allValues
*
* becomes:
*
- * val capturedList = captureMany<Foo> { verify(...).someMethod(capture()) }
+ * val capturedList = captureMany<Foo> { verify(...).someMethod(capture()) }
*/
inline fun <reified T : Any> captureMany(block: KotlinArgumentCaptor<T>.() -> Unit): List<T> =
kotlinArgumentCaptor<T>().apply { block() }.allValues
diff --git a/tests/shared/src/com/android/intentresolver/TestContentPreviewViewModel.kt b/tests/shared/src/com/android/intentresolver/TestContentPreviewViewModel.kt
index b352f360..8f246424 100644
--- a/tests/shared/src/com/android/intentresolver/TestContentPreviewViewModel.kt
+++ b/tests/shared/src/com/android/intentresolver/TestContentPreviewViewModel.kt
@@ -23,7 +23,6 @@ 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.PayloadToggleInteractor
/** A test content preview model that supports image loader override. */
class TestContentPreviewViewModel(
@@ -34,23 +33,12 @@ class TestContentPreviewViewModel(
override val previewDataProvider
get() = viewModel.previewDataProvider
- override val payloadToggleInteractor: PayloadToggleInteractor?
- get() = viewModel.payloadToggleInteractor
-
override fun init(
targetIntent: Intent,
- chooserIntent: Intent,
additionalContentUri: Uri?,
- focusedItemIdx: Int,
isPayloadTogglingEnabled: Boolean,
) {
- viewModel.init(
- targetIntent,
- chooserIntent,
- additionalContentUri,
- focusedItemIdx,
- isPayloadTogglingEnabled
- )
+ viewModel.init(targetIntent, additionalContentUri, isPayloadTogglingEnabled)
}
companion object {
diff --git a/tests/shared/src/com/android/intentresolver/v2/platform/FakeUserManager.kt b/tests/shared/src/com/android/intentresolver/v2/platform/FakeUserManager.kt
index 370e5a00..d1b56d5f 100644
--- a/tests/shared/src/com/android/intentresolver/v2/platform/FakeUserManager.kt
+++ b/tests/shared/src/com/android/intentresolver/v2/platform/FakeUserManager.kt
@@ -1,12 +1,6 @@
package com.android.intentresolver.v2.platform
import android.content.Context
-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.pm.UserInfo
import android.content.pm.UserInfo.FLAG_FULL
import android.content.pm.UserInfo.FLAG_INITIALIZED
@@ -18,7 +12,10 @@ import android.os.UserManager
import androidx.annotation.NonNull
import com.android.intentresolver.THROWS_EXCEPTION
import com.android.intentresolver.mock
-import com.android.intentresolver.v2.data.repository.UserRepositoryImpl.UserEvent
+import com.android.intentresolver.v2.data.repository.AvailabilityChange
+import com.android.intentresolver.v2.data.repository.ProfileAdded
+import com.android.intentresolver.v2.data.repository.ProfileRemoved
+import com.android.intentresolver.v2.data.repository.UserEvent
import com.android.intentresolver.v2.platform.FakeUserManager.State
import com.android.intentresolver.whenever
import kotlin.random.Random
@@ -155,21 +152,7 @@ class FakeUserManager(val state: State = State()) :
} else {
it.flags and UserInfo.FLAG_QUIET_MODE.inv()
}
- val actions = mutableListOf<String>()
- if (quietMode) {
- actions += ACTION_PROFILE_UNAVAILABLE
- if (it.isManagedProfile) {
- actions += ACTION_MANAGED_PROFILE_UNAVAILABLE
- }
- } else {
- actions += ACTION_PROFILE_AVAILABLE
- if (it.isManagedProfile) {
- actions += ACTION_MANAGED_PROFILE_AVAILABLE
- }
- }
- actions.forEach { action ->
- eventChannel.trySend(UserEvent(action, user, quietMode))
- }
+ eventChannel.trySend(AvailabilityChange(user, quietMode))
}
}
@@ -187,7 +170,7 @@ class FakeUserManager(val state: State = State()) :
profileGroupId = parentUser.profileGroupId
}
userInfoMap[userInfo.userHandle] = userInfo
- eventChannel.trySend(UserEvent(ACTION_PROFILE_ADDED, userInfo.userHandle))
+ eventChannel.trySend(ProfileAdded(userInfo.userHandle))
return userInfo.userHandle
}
@@ -195,7 +178,7 @@ class FakeUserManager(val state: State = State()) :
return userInfoMap[handle]?.let { user ->
require(user.isProfile) { "Only profiles can be removed" }
userInfoMap.remove(user.userHandle)
- eventChannel.trySend(UserEvent(ACTION_PROFILE_REMOVED, user.userHandle))
+ eventChannel.trySend(ProfileRemoved(user.userHandle))
return true
}
?: false
diff --git a/tests/unit/Android.bp b/tests/unit/Android.bp
index f8b80c72..78d32ae7 100644
--- a/tests/unit/Android.bp
+++ b/tests/unit/Android.bp
@@ -15,6 +15,7 @@
//
package {
+ default_team: "trendy_team_capture_and_share",
default_applicable_licenses: ["Android-Apache-2.0"],
}
@@ -52,6 +53,7 @@ android_test {
"junit",
"kotlinx_coroutines_test",
"mockito-target-minus-junit4",
+ "mockito-kotlin2",
"platform-compat-test-rules", // PlatformCompatChangeRule
"testables", // TestableContext/TestableResources
"truth",
diff --git a/tests/unit/src/com/android/intentresolver/contentpreview/ChooserContentPreviewUiTest.kt b/tests/unit/src/com/android/intentresolver/contentpreview/ChooserContentPreviewUiTest.kt
index c7c3c516..e4489bd1 100644
--- a/tests/unit/src/com/android/intentresolver/contentpreview/ChooserContentPreviewUiTest.kt
+++ b/tests/unit/src/com/android/intentresolver/contentpreview/ChooserContentPreviewUiTest.kt
@@ -21,7 +21,7 @@ import android.net.Uri
import android.platform.test.flag.junit.CheckFlagsRule
import android.platform.test.flag.junit.DeviceFlagsValueProvider
import com.android.intentresolver.ContentTypeHint
-import com.android.intentresolver.TestPreviewImageLoader
+import com.android.intentresolver.FakeImageLoader
import com.android.intentresolver.contentpreview.ChooserContentPreviewUi.ActionFactory
import com.android.intentresolver.mock
import com.android.intentresolver.whenever
@@ -43,7 +43,7 @@ class ChooserContentPreviewUiTest {
private val testScope = TestScope(EmptyCoroutineContext + UnconfinedTestDispatcher())
private val previewData = mock<PreviewDataProvider>()
private val headlineGenerator = mock<HeadlineGenerator>()
- private val imageLoader = TestPreviewImageLoader(emptyMap())
+ private val imageLoader = FakeImageLoader(emptyMap())
private val testMetadataText: CharSequence = "Test metadata text"
private val actionFactory =
object : ActionFactory {
@@ -70,6 +70,7 @@ class ChooserContentPreviewUiTest {
targetIntent,
imageLoader,
actionFactory,
+ { null },
transitionCallback,
headlineGenerator,
ContentTypeHint.NONE,
diff --git a/tests/unit/src/com/android/intentresolver/contentpreview/CursorUriReaderTest.kt b/tests/unit/src/com/android/intentresolver/contentpreview/CursorUriReaderTest.kt
deleted file mode 100644
index cd1c503a..00000000
--- a/tests/unit/src/com/android/intentresolver/contentpreview/CursorUriReaderTest.kt
+++ /dev/null
@@ -1,125 +0,0 @@
-/*
- * 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.content.Intent
-import android.database.MatrixCursor
-import android.net.Uri
-import android.util.SparseArray
-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 com.google.common.truth.Truth.assertWithMessage
-import kotlinx.coroutines.test.TestScope
-import kotlinx.coroutines.test.runTest
-import org.junit.Test
-
-class CursorUriReaderTest {
- private val scope = TestScope()
-
- @Test
- fun readEmptyCursor() {
- val testSubject =
- CursorUriReader(
- cursor = MatrixCursor(arrayOf("uri")),
- startPos = 0,
- pageSize = 128,
- ) {
- true
- }
-
- assertThat(testSubject.hasMoreBefore).isFalse()
- assertThat(testSubject.hasMoreAfter).isFalse()
- assertThat(testSubject.count).isEqualTo(0)
- assertThat(testSubject.readPageBefore().size()).isEqualTo(0)
- assertThat(testSubject.readPageAfter().size()).isEqualTo(0)
- }
-
- @Test
- fun readCursorFromTheMiddle() {
- val count = 3
- val testSubject =
- CursorUriReader(
- cursor =
- MatrixCursor(arrayOf("uri")).apply {
- for (i in 1..count) {
- addRow(arrayOf(createUri(i)))
- }
- },
- startPos = 1,
- pageSize = 2,
- ) {
- true
- }
-
- assertThat(testSubject.hasMoreBefore).isTrue()
- assertThat(testSubject.hasMoreAfter).isTrue()
- assertThat(testSubject.count).isEqualTo(3)
-
- testSubject.readPageBefore().let { page ->
- assertThat(testSubject.hasMoreBefore).isFalse()
- assertThat(testSubject.hasMoreAfter).isTrue()
- assertThat(page.size()).isEqualTo(1)
- assertThat(page.keyAt(0)).isEqualTo(0)
- assertThat(page.valueAt(0)).isEqualTo(createUri(1))
- }
-
- testSubject.readPageAfter().let { page ->
- assertThat(testSubject.hasMoreBefore).isFalse()
- assertThat(testSubject.hasMoreAfter).isFalse()
- assertThat(page.size()).isEqualTo(2)
- assertThat(page.getKeys()).asList().containsExactly(1, 2).inOrder()
- assertThat(page.getValues())
- .asList()
- .containsExactly(createUri(2), createUri(3))
- .inOrder()
- }
- }
-
- // TODO: add tests with filtered-out items
- // TODO: add tests with a failing cursor
-
- @Test
- fun testFailingQueryCall_emptyCursorCreated() =
- scope.runTest {
- val contentResolver =
- mock<ContentInterface> {
- whenever(query(any(), any(), anyOrNull(), any()))
- .thenThrow(SecurityException("Test exception"))
- }
- val cursorReader =
- CursorUriReader.createCursorReader(
- contentResolver,
- Uri.parse("content://auth"),
- Intent(Intent.ACTION_CHOOSER)
- )
-
- assertWithMessage("Empty cursor reader is expected")
- .that(cursorReader.count)
- .isEqualTo(0)
- }
-}
-
-private fun createUri(id: Int) = Uri.parse("content://org.pkg/$id")
-
-private fun <T> SparseArray<T>.getKeys(): IntArray = IntArray(size()) { i -> keyAt(i) }
-
-private inline fun <reified T> SparseArray<T>.getValues(): Array<T> =
- Array(size()) { i -> valueAt(i) }
diff --git a/tests/unit/src/com/android/intentresolver/contentpreview/ImagePreviewImageLoaderTest.kt b/tests/unit/src/com/android/intentresolver/contentpreview/ImagePreviewImageLoaderTest.kt
index 89978707..41989bda 100644
--- a/tests/unit/src/com/android/intentresolver/contentpreview/ImagePreviewImageLoaderTest.kt
+++ b/tests/unit/src/com/android/intentresolver/contentpreview/ImagePreviewImageLoaderTest.kt
@@ -20,9 +20,6 @@ import android.content.ContentResolver
import android.graphics.Bitmap
import android.net.Uri
import android.util.Size
-import androidx.lifecycle.Lifecycle
-import androidx.lifecycle.coroutineScope
-import androidx.lifecycle.testing.TestLifecycleOwner
import com.android.intentresolver.any
import com.android.intentresolver.anyOrNull
import com.android.intentresolver.mock
@@ -38,25 +35,22 @@ import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.CoroutineName
+import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.CoroutineStart.UNDISPATCHED
-import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.Runnable
import kotlinx.coroutines.async
+import kotlinx.coroutines.cancel
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.TestScope
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.Assert.assertTrue
-import org.junit.Before
import org.junit.Test
import org.mockito.Mockito.never
import org.mockito.Mockito.times
@@ -72,281 +66,287 @@ class ImagePreviewImageLoaderTest {
mock<ContentResolver> {
whenever(loadThumbnail(any(), any(), anyOrNull())).thenReturn(bitmap)
}
- private val lifecycleOwner = TestLifecycleOwner()
- private val dispatcher = UnconfinedTestDispatcher()
- private lateinit var testSubject: ImagePreviewImageLoader
-
- @Before
- fun setup() {
- Dispatchers.setMain(dispatcher)
- lifecycleOwner.handleLifecycleEvent(Lifecycle.Event.ON_CREATE)
- // create test subject after we've updated the lifecycle dispatcher
- testSubject =
- ImagePreviewImageLoader(
- lifecycleOwner.lifecycle.coroutineScope + dispatcher,
- imageSize.width,
- contentResolver,
- cacheSize = 1,
- )
- }
-
- @After
- fun cleanup() {
- lifecycleOwner.handleLifecycleEvent(Lifecycle.Event.ON_DESTROY)
- Dispatchers.resetMain()
- }
+ private val scheduler = TestCoroutineScheduler()
+ private val dispatcher = UnconfinedTestDispatcher(scheduler)
+ private val scope = TestScope(dispatcher)
+ private val testSubject =
+ ImagePreviewImageLoader(
+ dispatcher,
+ imageSize.width,
+ contentResolver,
+ cacheSize = 1,
+ )
@Test
- fun prePopulate_cachesImagesUpToTheCacheSize() = runTest {
- testSubject.prePopulate(listOf(uriOne, uriTwo))
+ fun prePopulate_cachesImagesUpToTheCacheSize() =
+ scope.runTest {
+ testSubject.prePopulate(listOf(uriOne, uriTwo))
- verify(contentResolver, times(1)).loadThumbnail(uriOne, imageSize, null)
- verify(contentResolver, never()).loadThumbnail(uriTwo, imageSize, null)
+ 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)
- }
+ testSubject(uriOne)
+ verify(contentResolver, times(1)).loadThumbnail(uriOne, imageSize, null)
+ }
@Test
- fun invoke_returnCachedImageWhenCalledTwice() = runTest {
- testSubject(uriOne)
- testSubject(uriOne)
+ fun invoke_returnCachedImageWhenCalledTwice() =
+ scope.runTest {
+ testSubject(uriOne)
+ testSubject(uriOne)
- verify(contentResolver, times(1)).loadThumbnail(any(), any(), anyOrNull())
- }
+ verify(contentResolver, times(1)).loadThumbnail(any(), any(), anyOrNull())
+ }
@Test
- fun invoke_whenInstructed_doesNotCache() = runTest {
- testSubject(uriOne, false)
- testSubject(uriOne, false)
+ fun invoke_whenInstructed_doesNotCache() =
+ scope.runTest {
+ testSubject(uriOne, false)
+ testSubject(uriOne, false)
- verify(contentResolver, times(2)).loadThumbnail(any(), any(), anyOrNull())
- }
+ 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()
- }
+ fun invoke_overlappedRequests_Deduplicate() =
+ scope.runTest {
+ val dispatcher = StandardTestDispatcher(scheduler)
+ val testSubject =
+ ImagePreviewImageLoader(
+ 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())
- }
+ 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)
- }
+ fun invoke_oldRecordsEvictedFromTheCache() =
+ scope.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)
+ fun invoke_doNotCacheNulls() =
+ scope.runTest {
+ whenever(contentResolver.loadThumbnail(any(), any(), anyOrNull())).thenReturn(null)
+ testSubject(uriOne)
+ testSubject(uriOne)
- verify(contentResolver, times(2)).loadThumbnail(uriOne, imageSize, null)
- }
+ verify(contentResolver, times(2)).loadThumbnail(uriOne, imageSize, null)
+ }
@Test(expected = CancellationException::class)
- fun invoke_onClosedImageLoaderScope_throwsCancellationException() = runTest {
- lifecycleOwner.handleLifecycleEvent(Lifecycle.Event.ON_DESTROY)
- testSubject(uriOne)
- }
+ fun invoke_onClosedImageLoaderScope_throwsCancellationException() =
+ scope.runTest {
+ val imageLoaderScope = CoroutineScope(coroutineContext)
+ val testSubject =
+ ImagePreviewImageLoader(
+ imageLoaderScope,
+ imageSize.width,
+ contentResolver,
+ cacheSize = 1,
+ )
+ imageLoaderScope.cancel()
+ testSubject(uriOne)
+ }
@Test(expected = CancellationException::class)
- fun invoke_imageLoaderScopeClosedMidflight_throwsCancellationException() = runTest {
- val scheduler = TestCoroutineScheduler()
- val dispatcher = StandardTestDispatcher(scheduler)
- val testSubject =
- ImagePreviewImageLoader(
- lifecycleOwner.lifecycle.coroutineScope + dispatcher,
- imageSize.width,
- contentResolver,
- cacheSize = 1,
- )
- coroutineScope {
- val deferred = async(start = UNDISPATCHED) { testSubject(uriOne, false) }
- lifecycleOwner.handleLifecycleEvent(Lifecycle.Event.ON_DESTROY)
- scheduler.advanceUntilIdle()
- deferred.await()
+ fun invoke_imageLoaderScopeClosedMidflight_throwsCancellationException() =
+ scope.runTest {
+ val dispatcher = StandardTestDispatcher(scheduler)
+ val imageLoaderScope = CoroutineScope(coroutineContext + dispatcher)
+ val testSubject =
+ ImagePreviewImageLoader(
+ imageLoaderScope,
+ imageSize.width,
+ contentResolver,
+ cacheSize = 1,
+ )
+ coroutineScope {
+ val deferred = async(start = UNDISPATCHED) { testSubject(uriOne, false) }
+ imageLoaderScope.cancel()
+ 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)
+ fun invoke_multipleCallsWithDifferentCacheInstructions_cachingPrevails() =
+ scope.runTest {
+ val dispatcher = StandardTestDispatcher(scheduler)
+ val imageLoaderScope = CoroutineScope(coroutineContext + dispatcher)
+ val testSubject =
+ ImagePreviewImageLoader(
+ imageLoaderScope,
+ 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)
- }
+ 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()
+ fun invoke_semaphoreGuardsContentResolverCalls() =
+ scope.runTest {
+ val contentResolver =
+ mock<ContentResolver> {
+ whenever(loadThumbnail(any(), any(), anyOrNull()))
+ .thenThrow(SecurityException("test"))
}
-
- override fun tryAcquire(): Boolean {
- error("Unexpected invocation")
+ 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()
+ }
}
- 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)
- }
+ val testSubject =
+ ImagePreviewImageLoader(
+ CoroutineScope(coroutineContext + 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")
+ fun invoke_semaphoreIsReleasedAfterContentResolverFailure() =
+ scope.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()
+ }
}
- override fun release() {
- releaseCount.getAndIncrement()
- }
- }
-
- val testSubject =
- ImagePreviewImageLoader(
- lifecycleOwner.lifecycle.coroutineScope + dispatcher,
- imageSize.width,
- contentResolver,
- cacheSize = 1,
- testSemaphore,
- )
- launch(start = UNDISPATCHED) { testSubject(uriOne, false) }
+ val testSubject =
+ ImagePreviewImageLoader(
+ CoroutineScope(coroutineContext + dispatcher),
+ imageSize.width,
+ contentResolver,
+ cacheSize = 1,
+ testSemaphore,
+ )
+ launch(start = UNDISPATCHED) { testSubject(uriOne, false) }
- verify(contentResolver, never()).loadThumbnail(any(), any(), anyOrNull())
+ verify(contentResolver, never()).loadThumbnail(any(), any(), anyOrNull())
- semaphoreDeferred.complete(Unit)
+ semaphoreDeferred.complete(Unit)
- verify(contentResolver, times(1)).loadThumbnail(uriOne, imageSize, null)
- assertThat(releaseCount.get()).isEqualTo(1)
- }
+ 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()
- assertTrue("Timeout waiting thumbnail calls", latch.await(1, SECONDS))
- bitmap
+ fun invoke_multipleSimultaneousCalls_limitOnNumberOfSimultaneousOutgoingCallsIsRespected() =
+ scope.runTest {
+ 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()
+ assertTrue("Timeout waiting thumbnail calls", latch.await(1, SECONDS))
+ 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()
+ val name = "LoadImage"
+ val maxSimultaneousRequests = 2
+ val threadsStartedCdl = CountDownLatch(requestCount)
+ val dispatcher = NewThreadDispatcher(name) { threadsStartedCdl.countDown() }
+ val testSubject =
+ ImagePreviewImageLoader(
+ CoroutineScope(coroutineContext + dispatcher + CoroutineName(name)),
+ imageSize.width,
+ contentResolver,
+ cacheSize = 1,
+ maxSimultaneousRequests,
+ )
+ coroutineScope {
+ 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)
- }
+ 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)).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()
+ 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(
diff --git a/tests/unit/src/com/android/intentresolver/contentpreview/PayloadToggleInteractorTest.kt b/tests/unit/src/com/android/intentresolver/contentpreview/PayloadToggleInteractorTest.kt
deleted file mode 100644
index 25c27468..00000000
--- a/tests/unit/src/com/android/intentresolver/contentpreview/PayloadToggleInteractorTest.kt
+++ /dev/null
@@ -1,162 +0,0 @@
-/*
- * 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.Intent
-import android.database.Cursor
-import android.database.MatrixCursor
-import android.net.Uri
-import com.google.common.truth.Truth.assertWithMessage
-import kotlinx.coroutines.flow.first
-import kotlinx.coroutines.test.TestCoroutineScheduler
-import kotlinx.coroutines.test.TestScope
-import kotlinx.coroutines.test.runTest
-import org.junit.Test
-
-class PayloadToggleInteractorTest {
- private val scheduler = TestCoroutineScheduler()
- private val testScope = TestScope(scheduler)
-
- @Test
- fun initialState() =
- testScope.runTest {
- val cursorReader = CursorUriReader(createCursor(10), 2, 2) { true }
- val testSubject =
- PayloadToggleInteractor(
- scope = testScope.backgroundScope,
- initiallySharedUris = listOf(makeUri(0), makeUri(2), makeUri(5)),
- focusedUriIdx = 1,
- mimeTypeClassifier = DefaultMimeTypeClassifier,
- cursorReaderProvider = { cursorReader },
- uriMetadataReader = { uri ->
- FileInfo.Builder(uri)
- .withMimeType("image/png")
- .withPreviewUri(uri)
- .build()
- },
- selectionCallback = { null },
- targetIntentModifier = { Intent(Intent.ACTION_SEND) },
- )
- .apply { start() }
-
- scheduler.runCurrent()
-
- testSubject.stateFlow.first().let { initialState ->
- assertWithMessage("Two pages (2 items each) are expected to be initially read")
- .that(initialState.items)
- .hasSize(4)
- assertWithMessage("Unexpected cursor values")
- .that(initialState.items.map { it.uri })
- .containsExactly(*Array<Uri>(4, ::makeUri))
- .inOrder()
- assertWithMessage("No more items are expected to the left")
- .that(initialState.hasMoreItemsBefore)
- .isFalse()
- assertWithMessage("No more items are expected to the right")
- .that(initialState.hasMoreItemsAfter)
- .isTrue()
- assertWithMessage("Selections should no be disabled")
- .that(initialState.allowSelectionChange)
- .isTrue()
- }
-
- testSubject.loadMoreNextItems()
- // this one is expected to be deduplicated
- testSubject.loadMoreNextItems()
- scheduler.runCurrent()
-
- testSubject.stateFlow.first().let { state ->
- assertWithMessage("Unexpected cursor values")
- .that(state.items.map { it.uri })
- .containsExactly(*Array(6, ::makeUri))
- .inOrder()
- assertWithMessage("No more items are expected to the left")
- .that(state.hasMoreItemsBefore)
- .isFalse()
- assertWithMessage("No more items are expected to the right")
- .that(state.hasMoreItemsAfter)
- .isTrue()
- assertWithMessage("Selections should no be disabled")
- .that(state.allowSelectionChange)
- .isTrue()
- assertWithMessage("Wrong selected items")
- .that(state.items.map { testSubject.selected(it).first() })
- .containsExactly(true, false, true, false, false, true)
- .inOrder()
- }
- }
-
- @Test
- fun testItemsSelection() =
- testScope.runTest {
- val cursorReader = CursorUriReader(createCursor(10), 2, 2) { true }
- val testSubject =
- PayloadToggleInteractor(
- scope = testScope.backgroundScope,
- initiallySharedUris = listOf(makeUri(0)),
- focusedUriIdx = 1,
- mimeTypeClassifier = DefaultMimeTypeClassifier,
- cursorReaderProvider = { cursorReader },
- uriMetadataReader = { uri ->
- FileInfo.Builder(uri)
- .withMimeType("image/png")
- .withPreviewUri(uri)
- .build()
- },
- selectionCallback = { null },
- targetIntentModifier = { Intent(Intent.ACTION_SEND) },
- )
- .apply { start() }
-
- scheduler.runCurrent()
- val items = testSubject.stateFlow.first().items
- assertWithMessage("An initially selected item should be selected")
- .that(testSubject.selected(items[0]).first())
- .isTrue()
- assertWithMessage("An item that was not initially selected should not be selected")
- .that(testSubject.selected(items[1]).first())
- .isFalse()
-
- testSubject.setSelected(items[0], false)
- scheduler.runCurrent()
- assertWithMessage("The only selected item can not be unselected")
- .that(testSubject.selected(items[0]).first())
- .isTrue()
-
- testSubject.setSelected(items[1], true)
- scheduler.runCurrent()
- assertWithMessage("An item selection status should be published")
- .that(testSubject.selected(items[1]).first())
- .isTrue()
-
- testSubject.setSelected(items[0], false)
- scheduler.runCurrent()
- assertWithMessage("An item can be unselected when there's another selected item")
- .that(testSubject.selected(items[0]).first())
- .isFalse()
- }
-}
-
-private fun createCursor(count: Int): Cursor {
- return MatrixCursor(arrayOf("uri")).apply {
- for (i in 0 until count) {
- addRow(arrayOf(makeUri(i)))
- }
- }
-}
-
-private fun makeUri(id: Int) = Uri.parse("content://org.pkg.app/img-$id.png")
diff --git a/tests/unit/src/com/android/intentresolver/contentpreview/PreviewViewModelTest.kt b/tests/unit/src/com/android/intentresolver/contentpreview/PreviewViewModelTest.kt
deleted file mode 100644
index 1a59a930..00000000
--- a/tests/unit/src/com/android/intentresolver/contentpreview/PreviewViewModelTest.kt
+++ /dev/null
@@ -1,81 +0,0 @@
-/*
- * 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.Intent
-import android.net.Uri
-import androidx.test.ext.junit.runners.AndroidJUnit4
-import androidx.test.platform.app.InstrumentationRegistry
-import com.google.common.truth.Truth.assertThat
-import kotlinx.coroutines.ExperimentalCoroutinesApi
-import kotlinx.coroutines.test.UnconfinedTestDispatcher
-import org.junit.Test
-import org.junit.runner.RunWith
-
-@RunWith(AndroidJUnit4::class)
-class PreviewViewModelTest {
- @OptIn(ExperimentalCoroutinesApi::class) private val dispatcher = UnconfinedTestDispatcher()
-
- private val context
- get() = InstrumentationRegistry.getInstrumentation().targetContext
-
- private val targetIntent = Intent(Intent.ACTION_SEND)
- private val chooserIntent = Intent.createChooser(targetIntent, null)
- private val additionalContentUri = Uri.parse("content://org.pkg.content")
-
- @Test
- fun featureFlagDisabled_noPayloadToggleInteractorCreated() {
- val testSubject =
- PreviewViewModel(context.contentResolver, 200, dispatcher).apply {
- init(
- targetIntent,
- chooserIntent,
- additionalContentUri,
- focusedItemIdx = 0,
- isPayloadTogglingEnabled = false
- )
- }
-
- assertThat(testSubject.payloadToggleInteractor).isNull()
- }
-
- @Test
- fun noAdditionalContentUri_noPayloadToggleInteractorCreated() {
- val testSubject =
- PreviewViewModel(context.contentResolver, 200, dispatcher).apply {
- init(
- targetIntent,
- chooserIntent,
- additionalContentUri = null,
- focusedItemIdx = 0,
- true
- )
- }
-
- assertThat(testSubject.payloadToggleInteractor).isNull()
- }
-
- @Test
- fun flagEnabledAndAdditionalContentUriProvided_createPayloadToggleInteractor() {
- val testSubject =
- PreviewViewModel(context.contentResolver, 200, dispatcher).apply {
- init(targetIntent, chooserIntent, additionalContentUri, focusedItemIdx = 0, true)
- }
-
- assertThat(testSubject.payloadToggleInteractor).isNotNull()
- }
-}
diff --git a/tests/unit/src/com/android/intentresolver/contentpreview/SelectionTrackerTest.kt b/tests/unit/src/com/android/intentresolver/contentpreview/SelectionTrackerTest.kt
deleted file mode 100644
index 6ba18466..00000000
--- a/tests/unit/src/com/android/intentresolver/contentpreview/SelectionTrackerTest.kt
+++ /dev/null
@@ -1,330 +0,0 @@
-/*
- * 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.net.Uri
-import android.util.SparseArray
-import com.google.common.truth.Truth.assertThat
-import org.junit.Test
-
-class SelectionTrackerTest {
- @Test
- fun noSelectedItems() {
- val testSubject = SelectionTracker<Uri>(emptyList(), 0, 10) { this }
-
- val items =
- (1..5).fold(SparseArray<Uri>(5)) { acc, i ->
- acc.apply { append(i * 2, makeUri(i * 2)) }
- }
- testSubject.onEndItemsAdded(items)
-
- assertThat(testSubject.getSelection()).isEmpty()
- }
-
- @Test
- fun testNoItems() {
- val u1 = makeUri(1)
- val u2 = makeUri(2)
- val u3 = makeUri(3)
- val testSubject = SelectionTracker(listOf(u1, u2, u3), 1, 0) { this }
-
- assertThat(testSubject.getSelection()).containsExactly(u1, u2, u3).inOrder()
- }
-
- @Test
- fun focusedItemInPlaceAllItemsOnTheRight_selectionsInTheInitialOrder() {
- val u1 = makeUri(1)
- val u2 = makeUri(2)
- val u3 = makeUri(3)
- val count = 7
- val testSubject = SelectionTracker(listOf(u1, u2, u3), 0, count) { this }
-
- testSubject.onEndItemsAdded(
- SparseArray<Uri>(3).apply {
- append(1, u1)
- append(2, makeUri(4))
- append(3, makeUri(5))
- }
- )
- assertThat(testSubject.getSelection()).containsExactly(u1, u2, u3).inOrder()
- testSubject.onEndItemsAdded(
- SparseArray<Uri>(3).apply {
- append(3, makeUri(6))
- append(4, u2)
- append(5, u3)
- }
- )
- assertThat(testSubject.getSelection()).containsExactly(u1, u2, u3).inOrder()
- }
-
- @Test
- fun focusedItemInPlaceElementsOnBothSides_selectionsInTheInitialOrder() {
- val u1 = makeUri(1)
- val u2 = makeUri(2)
- val u3 = makeUri(3)
- val count = 10
- val testSubject = SelectionTracker(listOf(u1, u2, u3), 1, count) { this }
-
- testSubject.onEndItemsAdded(
- SparseArray<Uri>(3).apply {
- append(4, u2)
- append(5, makeUri(4))
- append(6, makeUri(5))
- }
- )
- assertThat(testSubject.getSelection()).containsExactly(u1, u2, u3).inOrder()
-
- testSubject.onStartItemsAdded(
- SparseArray<Uri>(3).apply {
- append(1, makeUri(6))
- append(2, u1)
- append(3, makeUri(7))
- }
- )
- assertThat(testSubject.getSelection()).containsExactly(u1, u2, u3).inOrder()
-
- testSubject.onEndItemsAdded(SparseArray<Uri>(3).apply { append(8, u3) })
- assertThat(testSubject.getSelection()).containsExactly(u1, u2, u3).inOrder()
- }
-
- @Test
- fun focusedItemInPlaceAllItemsOnTheLeft_selectionsInTheInitialOrder() {
- val u1 = makeUri(1)
- val u2 = makeUri(2)
- val u3 = makeUri(3)
- val count = 7
- val testSubject = SelectionTracker(listOf(u1, u2, u3), 2, count) { this }
-
- testSubject.onEndItemsAdded(SparseArray<Uri>(3).apply { append(6, u3) })
-
- assertThat(testSubject.getSelection()).containsExactly(u1, u2, u3).inOrder()
-
- testSubject.onStartItemsAdded(
- SparseArray<Uri>(3).apply {
- append(3, makeUri(4))
- append(4, u2)
- append(5, makeUri(5))
- }
- )
- assertThat(testSubject.getSelection()).containsExactly(u1, u2, u3).inOrder()
-
- testSubject.onStartItemsAdded(
- SparseArray<Uri>(3).apply {
- append(1, u1)
- append(2, makeUri(6))
- }
- )
- assertThat(testSubject.getSelection()).containsExactly(u1, u2, u3).inOrder()
- }
-
- @Test
- fun focusedItemInPlaceDuplicatesOnBothSides_selectionsInTheInitialOrder() {
- val u1 = makeUri(1)
- val u2 = makeUri(2)
- val u3 = makeUri(3)
- val count = 5
- val testSubject = SelectionTracker(listOf(u1, u2, u1), 1, count) { this }
-
- testSubject.onEndItemsAdded(SparseArray<Uri>(3).apply { append(2, u2) })
- assertThat(testSubject.getSelection()).containsExactly(u1, u2, u1).inOrder()
-
- testSubject.onStartItemsAdded(
- SparseArray<Uri>(3).apply {
- append(0, u1)
- append(1, u3)
- }
- )
- assertThat(testSubject.getSelection()).containsExactly(u1, u2, u1).inOrder()
-
- testSubject.onStartItemsAdded(
- SparseArray<Uri>(3).apply {
- append(3, u1)
- append(4, u3)
- }
- )
- assertThat(testSubject.getSelection()).containsExactly(u1, u2, u1).inOrder()
- }
-
- @Test
- fun focusedItemInPlaceDuplicatesOnTheRight_selectionsInTheInitialOrder() {
- val u1 = makeUri(1)
- val u2 = makeUri(2)
- val count = 4
- val testSubject = SelectionTracker(listOf(u1, u2), 0, count) { this }
-
- testSubject.onEndItemsAdded(SparseArray<Uri>(1).apply { append(0, u1) })
- assertThat(testSubject.getSelection()).containsExactly(u1, u2).inOrder()
-
- testSubject.onEndItemsAdded(
- SparseArray<Uri>(3).apply {
- append(1, u2)
- append(2, u1)
- append(3, u2)
- }
- )
- assertThat(testSubject.getSelection()).containsExactly(u1, u2).inOrder()
- }
-
- @Test
- fun focusedItemInPlaceDuplicatesOnTheLeft_selectionsInTheInitialOrder() {
- val u1 = makeUri(1)
- val u2 = makeUri(2)
- val count = 4
- val testSubject = SelectionTracker(listOf(u1, u2), 1, count) { this }
-
- testSubject.onEndItemsAdded(SparseArray<Uri>(1).apply { append(3, u2) })
- assertThat(testSubject.getSelection()).containsExactly(u1, u2).inOrder()
-
- testSubject.onStartItemsAdded(
- SparseArray<Uri>(3).apply {
- append(0, u1)
- append(1, u2)
- append(2, u1)
- }
- )
- assertThat(testSubject.getSelection()).containsExactly(u1, u2).inOrder()
- }
-
- @Test
- fun differentItemsOrder_selectionsInTheCursorOrder() {
- val u1 = makeUri(1)
- val u2 = makeUri(2)
- val u3 = makeUri(3)
- val u4 = makeUri(3)
- val count = 10
- val testSubject = SelectionTracker(listOf(u1, u2, u3, u4), 2, count) { this }
-
- testSubject.onEndItemsAdded(
- SparseArray<Uri>(3).apply {
- append(4, makeUri(5))
- append(5, u1)
- append(6, makeUri(6))
- }
- )
- testSubject.onStartItemsAdded(
- SparseArray<Uri>(3).apply {
- append(2, makeUri(7))
- append(3, u4)
- }
- )
- testSubject.onEndItemsAdded(
- SparseArray<Uri>(3).apply {
- append(7, u3)
- append(8, makeUri(8))
- }
- )
- testSubject.onStartItemsAdded(
- SparseArray<Uri>(3).apply {
- append(0, makeUri(9))
- append(1, u2)
- }
- )
- assertThat(testSubject.getSelection()).containsExactly(u2, u4, u1, u3).inOrder()
- }
-
- @Test
- fun testPendingItems() {
- val u1 = makeUri(1)
- val u2 = makeUri(2)
- val u3 = makeUri(3)
- val u4 = makeUri(4)
- val u5 = makeUri(5)
-
- val testSubject = SelectionTracker(listOf(u1, u2, u3, u4, u5), 2, 5) { this }
-
- testSubject.onEndItemsAdded(
- SparseArray<Uri>(2).apply {
- append(2, u3)
- append(3, u4)
- }
- )
- testSubject.onStartItemsAdded(SparseArray<Uri>(2).apply { append(1, u2) })
-
- assertThat(testSubject.getPendingItems()).containsExactly(u1, u5).inOrder()
- }
-
- @Test
- fun testItemSelection() {
- val u1 = makeUri(1)
- val u2 = makeUri(2)
- val u3 = makeUri(3)
- val u4 = makeUri(4)
- val u5 = makeUri(5)
-
- val testSubject = SelectionTracker(listOf(u1, u2, u3, u4, u5), 2, 10) { this }
-
- testSubject.onEndItemsAdded(
- SparseArray<Uri>(2).apply {
- append(2, u3)
- append(3, u4)
- }
- )
- assertThat(testSubject.getSelection()).containsExactly(u1, u2, u3, u4, u5).inOrder()
-
- assertThat(testSubject.setItemSelection(2, u3, false)).isTrue()
- assertThat(testSubject.setItemSelection(3, u4, true)).isFalse()
- assertThat(testSubject.getSelection()).containsExactly(u1, u2, u4, u5).inOrder()
-
- testSubject.onEndItemsAdded(
- SparseArray<Uri>(1).apply {
- append(4, u5)
- append(5, u3)
- }
- )
- testSubject.onStartItemsAdded(
- SparseArray<Uri>(2).apply {
- append(0, u1)
- append(1, u2)
- }
- )
- assertThat(testSubject.getSelection()).containsExactly(u1, u2, u4, u5).inOrder()
-
- assertThat(testSubject.setItemSelection(2, u3, true)).isTrue()
- assertThat(testSubject.getSelection()).containsExactly(u1, u2, u3, u4, u5).inOrder()
- assertThat(testSubject.setItemSelection(5, u3, true)).isTrue()
- assertThat(testSubject.getSelection()).containsExactly(u1, u2, u3, u4, u5, u3).inOrder()
- }
-
- @Test
- fun testItemSelectionWithDuplicates() {
- val u1 = makeUri(1)
- val u2 = makeUri(2)
-
- val testSubject = SelectionTracker(listOf(u1, u2, u1), 1, 3) { this }
- testSubject.onEndItemsAdded(
- SparseArray<Uri>(2).apply {
- append(1, u2)
- append(2, u1)
- }
- )
-
- assertThat(testSubject.getPendingItems()).containsExactly(u1)
- }
-
- @Test
- fun testUnselectOnlySelectedItem_itemRemainsSelected() {
- val u1 = makeUri(1)
-
- val testSubject = SelectionTracker(listOf(u1), 0, 1) { this }
- testSubject.onEndItemsAdded(SparseArray<Uri>(1).apply { append(0, u1) })
- assertThat(testSubject.isItemSelected(0)).isTrue()
- assertThat(testSubject.setItemSelection(0, u1, false)).isFalse()
- assertThat(testSubject.isItemSelected(0)).isTrue()
- }
-}
-
-private fun makeUri(id: Int) = Uri.parse("content://org.pkg.app/img-$id.png")
diff --git a/tests/unit/src/com/android/intentresolver/contentpreview/UriMetadataReaderTest.kt b/tests/unit/src/com/android/intentresolver/contentpreview/UriMetadataReaderTest.kt
index f7bf33fd..07f3a3f2 100644
--- a/tests/unit/src/com/android/intentresolver/contentpreview/UriMetadataReaderTest.kt
+++ b/tests/unit/src/com/android/intentresolver/contentpreview/UriMetadataReaderTest.kt
@@ -37,7 +37,7 @@ class UriMetadataReaderTest {
fun testImageUri() {
val mimeType = "image/png"
whenever(contentResolver.getType(uri)).thenReturn(mimeType)
- val testSubject = UriMetadataReader(contentResolver, DefaultMimeTypeClassifier)
+ val testSubject = UriMetadataReaderImpl(contentResolver, DefaultMimeTypeClassifier)
testSubject.getMetadata(uri).let { fileInfo ->
assertWithMessage("Wrong uri").that(fileInfo.uri).isEqualTo(uri)
@@ -52,7 +52,7 @@ class UriMetadataReaderTest {
val imageType = "image/png"
whenever(contentResolver.getType(uri)).thenReturn(mimeType)
whenever(contentResolver.getStreamTypes(eq(uri), any())).thenReturn(arrayOf(imageType))
- val testSubject = UriMetadataReader(contentResolver, DefaultMimeTypeClassifier)
+ val testSubject = UriMetadataReaderImpl(contentResolver, DefaultMimeTypeClassifier)
testSubject.getMetadata(uri).let { fileInfo ->
assertWithMessage("Wrong uri").that(fileInfo.uri).isEqualTo(uri)
@@ -72,7 +72,7 @@ class UriMetadataReaderTest {
addRow(arrayOf(DocumentsContract.Document.FLAG_SUPPORTS_THUMBNAIL))
}
)
- val testSubject = UriMetadataReader(contentResolver, DefaultMimeTypeClassifier)
+ val testSubject = UriMetadataReaderImpl(contentResolver, DefaultMimeTypeClassifier)
testSubject.getMetadata(uri).let { fileInfo ->
assertWithMessage("Wrong uri").that(fileInfo.uri).isEqualTo(uri)
@@ -89,7 +89,7 @@ class UriMetadataReaderTest {
val columns = arrayOf(MediaMetadata.METADATA_KEY_DISPLAY_ICON_URI)
whenever(contentResolver.query(eq(uri), eq(columns), anyOrNull(), anyOrNull()))
.thenReturn(MatrixCursor(columns).apply { addRow(arrayOf(previewUri.toString())) })
- val testSubject = UriMetadataReader(contentResolver, DefaultMimeTypeClassifier)
+ val testSubject = UriMetadataReaderImpl(contentResolver, DefaultMimeTypeClassifier)
testSubject.getMetadata(uri).let { fileInfo ->
assertWithMessage("Wrong uri").that(fileInfo.uri).isEqualTo(uri)
diff --git a/tests/unit/src/com/android/intentresolver/contentpreview/TargetIntentModifierTest.kt b/tests/unit/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/intent/TargetIntentModifierImplTest.kt
index b589f566..7c36ef55 100644
--- a/tests/unit/src/com/android/intentresolver/contentpreview/TargetIntentModifierTest.kt
+++ b/tests/unit/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/intent/TargetIntentModifierImplTest.kt
@@ -14,7 +14,7 @@
* limitations under the License.
*/
-package com.android.intentresolver.contentpreview
+package com.android.intentresolver.contentpreview.payloadtoggle.domain.intent
import android.content.Intent
import android.content.Intent.ACTION_SEND
@@ -24,21 +24,22 @@ import android.net.Uri
import com.google.common.truth.Truth.assertThat
import org.junit.Test
-class TargetIntentModifierTest {
+class TargetIntentModifierImplTest {
@Test
fun testIntentActionChange() {
- val testSubject = TargetIntentModifier<Uri>(Intent(ACTION_SEND), { this }, { "image/png" })
+ val testSubject =
+ TargetIntentModifierImpl<Uri>(Intent(ACTION_SEND), { this }, { "image/png" })
val u1 = createUri(1)
val u2 = createUri(2)
- testSubject.onSelectionChanged(listOf(u1, u2)).let { intent ->
+ testSubject.intentFromSelection(listOf(u1, u2)).let { intent ->
assertThat(intent.action).isEqualTo(ACTION_SEND_MULTIPLE)
assertThat(intent.getParcelableArrayListExtra(EXTRA_STREAM, Uri::class.java))
.containsExactly(u1, u2)
.inOrder()
}
- testSubject.onSelectionChanged(listOf(u1)).let { intent ->
+ testSubject.intentFromSelection(listOf(u1)).let { intent ->
assertThat(intent.action).isEqualTo(ACTION_SEND)
assertThat(intent.getParcelableExtra(EXTRA_STREAM, Uri::class.java)).isEqualTo(u1)
}
@@ -47,24 +48,26 @@ class TargetIntentModifierTest {
@Test
fun testMimeTypeChange() {
val testSubject =
- TargetIntentModifier<Pair<Uri, String?>>(Intent(ACTION_SEND), { first }, { second })
+ TargetIntentModifierImpl<Pair<Uri, String?>>(Intent(ACTION_SEND), { first }, { second })
val u1 = createUri(1)
val u2 = createUri(2)
- testSubject.onSelectionChanged(listOf(u1 to "image/png", u2 to "image/png")).let { intent ->
+ testSubject.intentFromSelection(listOf(u1 to "image/png", u2 to "image/png")).let { intent
+ ->
assertThat(intent.type).isEqualTo("image/png")
}
- testSubject.onSelectionChanged(listOf(u1 to "image/png", u2 to "image/jpg")).let { intent ->
+ testSubject.intentFromSelection(listOf(u1 to "image/png", u2 to "image/jpg")).let { intent
+ ->
assertThat(intent.type).isEqualTo("image/*")
}
- testSubject.onSelectionChanged(listOf(u1 to "image/png", u2 to "video/mpeg")).let { intent
+ testSubject.intentFromSelection(listOf(u1 to "image/png", u2 to "video/mpeg")).let { intent
->
assertThat(intent.type).isEqualTo("*/*")
}
- testSubject.onSelectionChanged(listOf(u1 to "image/png", u2 to null)).let { intent ->
+ testSubject.intentFromSelection(listOf(u1 to "image/png", u2 to null)).let { intent ->
assertThat(intent.type).isEqualTo("*/*")
}
}
diff --git a/tests/unit/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/interactor/CursorPreviewsInteractorTest.kt b/tests/unit/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/interactor/CursorPreviewsInteractorTest.kt
new file mode 100644
index 00000000..b2d9be94
--- /dev/null
+++ b/tests/unit/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/interactor/CursorPreviewsInteractorTest.kt
@@ -0,0 +1,271 @@
+/*
+ * Copyright (C) 2024 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+@file:OptIn(ExperimentalCoroutinesApi::class)
+
+package com.android.intentresolver.contentpreview.payloadtoggle.domain.interactor
+
+import android.database.MatrixCursor
+import android.net.Uri
+import androidx.core.os.bundleOf
+import com.android.intentresolver.contentpreview.FileInfo
+import com.android.intentresolver.contentpreview.payloadtoggle.data.repository.CursorPreviewsRepository
+import com.android.intentresolver.contentpreview.payloadtoggle.shared.model.PreviewModel
+import com.android.intentresolver.util.cursor.viewBy
+import com.google.common.truth.Truth.assertThat
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.test.TestScope
+import kotlinx.coroutines.test.runCurrent
+import kotlinx.coroutines.test.runTest
+import org.junit.Test
+
+class CursorPreviewsInteractorTest {
+
+ private fun runTestWithDeps(
+ initialSelection: Iterable<Int> = (1..2),
+ focusedItemIndex: Int = initialSelection.count() / 2,
+ cursor: Iterable<Int> = (0 until 4),
+ cursorStartPosition: Int = cursor.count() / 2,
+ pageSize: Int = 16,
+ maxLoadedPages: Int = 3,
+ block: TestScope.(TestDeps) -> Unit,
+ ): Unit = runTest {
+ block(
+ TestDeps(
+ initialSelection,
+ focusedItemIndex,
+ cursor,
+ cursorStartPosition,
+ pageSize,
+ maxLoadedPages,
+ )
+ )
+ }
+
+ private class TestDeps(
+ initialSelectionRange: Iterable<Int>,
+ focusedItemIndex: Int,
+ private val cursorRange: Iterable<Int>,
+ private val cursorStartPosition: Int,
+ pageSize: Int,
+ maxLoadedPages: Int,
+ ) {
+ val cursor =
+ MatrixCursor(arrayOf("uri"))
+ .apply {
+ extras = bundleOf("position" to cursorStartPosition)
+ for (i in cursorRange) {
+ newRow().add("uri", uri(i).toString())
+ }
+ }
+ .viewBy { getString(0)?.let(Uri::parse) }
+ val previewsRepo = CursorPreviewsRepository()
+ val underTest =
+ CursorPreviewsInteractor(
+ interactor = SetCursorPreviewsInteractor(previewsRepo = previewsRepo),
+ focusedItemIdx = focusedItemIndex,
+ uriMetadataReader = { FileInfo.Builder(it).withMimeType("image/bitmap").build() },
+ pageSize = pageSize,
+ maxLoadedPages = maxLoadedPages,
+ )
+ val initialPreviews: List<PreviewModel> =
+ initialSelectionRange.map { i -> PreviewModel(uri = uri(i), mimeType = "image/bitmap") }
+
+ private fun uri(index: Int) = Uri.fromParts("scheme$index", "ssp$index", "fragment$index")
+ }
+
+ @Test
+ fun initialCursorLoad() = runTestWithDeps { deps ->
+ backgroundScope.launch { deps.underTest.launch(deps.cursor, deps.initialPreviews) }
+ runCurrent()
+
+ assertThat(deps.previewsRepo.previewsModel.value).isNotNull()
+ assertThat(deps.previewsRepo.previewsModel.value!!.startIdx).isEqualTo(0)
+ assertThat(deps.previewsRepo.previewsModel.value!!.loadMoreLeft).isNull()
+ assertThat(deps.previewsRepo.previewsModel.value!!.loadMoreRight).isNull()
+ assertThat(deps.previewsRepo.previewsModel.value!!.previewModels)
+ .containsExactly(
+ PreviewModel(Uri.fromParts("scheme0", "ssp0", "fragment0"), "image/bitmap"),
+ PreviewModel(Uri.fromParts("scheme1", "ssp1", "fragment1"), "image/bitmap"),
+ PreviewModel(Uri.fromParts("scheme2", "ssp2", "fragment2"), "image/bitmap"),
+ PreviewModel(Uri.fromParts("scheme3", "ssp3", "fragment3"), "image/bitmap"),
+ )
+ .inOrder()
+ }
+
+ @Test
+ fun loadMoreLeft_evictRight() =
+ runTestWithDeps(
+ initialSelection = listOf(24),
+ cursor = (0 until 48),
+ pageSize = 16,
+ maxLoadedPages = 1,
+ ) { deps ->
+ backgroundScope.launch { deps.underTest.launch(deps.cursor, deps.initialPreviews) }
+ runCurrent()
+
+ assertThat(deps.previewsRepo.previewsModel.value).isNotNull()
+ assertThat(deps.previewsRepo.previewsModel.value!!.previewModels).hasSize(16)
+ assertThat(deps.previewsRepo.previewsModel.value!!.previewModels.first().uri)
+ .isEqualTo(Uri.fromParts("scheme16", "ssp16", "fragment16"))
+ assertThat(deps.previewsRepo.previewsModel.value!!.previewModels.last().uri)
+ .isEqualTo(Uri.fromParts("scheme31", "ssp31", "fragment31"))
+ assertThat(deps.previewsRepo.previewsModel.value!!.loadMoreLeft).isNotNull()
+
+ deps.previewsRepo.previewsModel.value!!.loadMoreLeft!!.invoke()
+ runCurrent()
+
+ assertThat(deps.previewsRepo.previewsModel.value).isNotNull()
+ assertThat(deps.previewsRepo.previewsModel.value!!.previewModels).hasSize(16)
+ assertThat(deps.previewsRepo.previewsModel.value!!.previewModels.first().uri)
+ .isEqualTo(Uri.fromParts("scheme0", "ssp0", "fragment0"))
+ assertThat(deps.previewsRepo.previewsModel.value!!.previewModels.last().uri)
+ .isEqualTo(Uri.fromParts("scheme15", "ssp15", "fragment15"))
+ assertThat(deps.previewsRepo.previewsModel.value!!.loadMoreLeft).isNull()
+ }
+
+ @Test
+ fun loadMoreLeft_keepRight() =
+ runTestWithDeps(
+ initialSelection = listOf(24),
+ cursor = (0 until 48),
+ pageSize = 16,
+ maxLoadedPages = 2,
+ ) { deps ->
+ backgroundScope.launch { deps.underTest.launch(deps.cursor, deps.initialPreviews) }
+ runCurrent()
+
+ assertThat(deps.previewsRepo.previewsModel.value).isNotNull()
+ assertThat(deps.previewsRepo.previewsModel.value!!.previewModels).hasSize(16)
+ assertThat(deps.previewsRepo.previewsModel.value!!.previewModels.first().uri)
+ .isEqualTo(Uri.fromParts("scheme16", "ssp16", "fragment16"))
+ assertThat(deps.previewsRepo.previewsModel.value!!.previewModels.last().uri)
+ .isEqualTo(Uri.fromParts("scheme31", "ssp31", "fragment31"))
+ assertThat(deps.previewsRepo.previewsModel.value!!.loadMoreLeft).isNotNull()
+
+ deps.previewsRepo.previewsModel.value!!.loadMoreLeft!!.invoke()
+ runCurrent()
+
+ assertThat(deps.previewsRepo.previewsModel.value).isNotNull()
+ assertThat(deps.previewsRepo.previewsModel.value!!.previewModels).hasSize(32)
+ assertThat(deps.previewsRepo.previewsModel.value!!.previewModels.first().uri)
+ .isEqualTo(Uri.fromParts("scheme0", "ssp0", "fragment0"))
+ assertThat(deps.previewsRepo.previewsModel.value!!.previewModels.last().uri)
+ .isEqualTo(Uri.fromParts("scheme31", "ssp31", "fragment31"))
+ assertThat(deps.previewsRepo.previewsModel.value!!.loadMoreLeft).isNull()
+ }
+
+ @Test
+ fun loadMoreRight_evictLeft() =
+ runTestWithDeps(
+ initialSelection = listOf(24),
+ cursor = (0 until 48),
+ pageSize = 16,
+ maxLoadedPages = 1,
+ ) { deps ->
+ backgroundScope.launch { deps.underTest.launch(deps.cursor, deps.initialPreviews) }
+ runCurrent()
+
+ assertThat(deps.previewsRepo.previewsModel.value).isNotNull()
+ assertThat(deps.previewsRepo.previewsModel.value!!.previewModels).hasSize(16)
+ assertThat(deps.previewsRepo.previewsModel.value!!.previewModels.first().uri)
+ .isEqualTo(Uri.fromParts("scheme16", "ssp16", "fragment16"))
+ assertThat(deps.previewsRepo.previewsModel.value!!.previewModels.last().uri)
+ .isEqualTo(Uri.fromParts("scheme31", "ssp31", "fragment31"))
+ assertThat(deps.previewsRepo.previewsModel.value!!.loadMoreRight).isNotNull()
+
+ deps.previewsRepo.previewsModel.value!!.loadMoreRight!!.invoke()
+ runCurrent()
+
+ assertThat(deps.previewsRepo.previewsModel.value).isNotNull()
+ assertThat(deps.previewsRepo.previewsModel.value!!.previewModels).hasSize(16)
+ assertThat(deps.previewsRepo.previewsModel.value!!.previewModels.first().uri)
+ .isEqualTo(Uri.fromParts("scheme32", "ssp32", "fragment32"))
+ assertThat(deps.previewsRepo.previewsModel.value!!.previewModels.last().uri)
+ .isEqualTo(Uri.fromParts("scheme47", "ssp47", "fragment47"))
+ }
+
+ @Test
+ fun loadMoreRight_keepLeft() =
+ runTestWithDeps(
+ initialSelection = listOf(24),
+ cursor = (0 until 48),
+ pageSize = 16,
+ maxLoadedPages = 2,
+ ) { deps ->
+ backgroundScope.launch { deps.underTest.launch(deps.cursor, deps.initialPreviews) }
+ runCurrent()
+
+ assertThat(deps.previewsRepo.previewsModel.value).isNotNull()
+ assertThat(deps.previewsRepo.previewsModel.value!!.previewModels).hasSize(16)
+ assertThat(deps.previewsRepo.previewsModel.value!!.previewModels.first().uri)
+ .isEqualTo(Uri.fromParts("scheme16", "ssp16", "fragment16"))
+ assertThat(deps.previewsRepo.previewsModel.value!!.previewModels.last().uri)
+ .isEqualTo(Uri.fromParts("scheme31", "ssp31", "fragment31"))
+ assertThat(deps.previewsRepo.previewsModel.value!!.loadMoreRight).isNotNull()
+
+ deps.previewsRepo.previewsModel.value!!.loadMoreRight!!.invoke()
+ runCurrent()
+
+ assertThat(deps.previewsRepo.previewsModel.value).isNotNull()
+ assertThat(deps.previewsRepo.previewsModel.value!!.previewModels).hasSize(32)
+ assertThat(deps.previewsRepo.previewsModel.value!!.previewModels.first().uri)
+ .isEqualTo(Uri.fromParts("scheme16", "ssp16", "fragment16"))
+ assertThat(deps.previewsRepo.previewsModel.value!!.previewModels.last().uri)
+ .isEqualTo(Uri.fromParts("scheme47", "ssp47", "fragment47"))
+ }
+
+ @Test
+ fun noMoreRight_appendUnclaimedFromInitialSelection() =
+ runTestWithDeps(
+ initialSelection = listOf(24, 50),
+ cursor = listOf(24),
+ pageSize = 16,
+ maxLoadedPages = 2,
+ ) { deps ->
+ backgroundScope.launch { deps.underTest.launch(deps.cursor, deps.initialPreviews) }
+ runCurrent()
+
+ assertThat(deps.previewsRepo.previewsModel.value).isNotNull()
+ assertThat(deps.previewsRepo.previewsModel.value!!.previewModels).hasSize(2)
+ assertThat(deps.previewsRepo.previewsModel.value!!.previewModels.first().uri)
+ .isEqualTo(Uri.fromParts("scheme24", "ssp24", "fragment24"))
+ assertThat(deps.previewsRepo.previewsModel.value!!.previewModels.last().uri)
+ .isEqualTo(Uri.fromParts("scheme50", "ssp50", "fragment50"))
+ assertThat(deps.previewsRepo.previewsModel.value!!.loadMoreRight).isNull()
+ }
+
+ @Test
+ fun noMoreLeft_appendUnclaimedFromInitialSelection() =
+ runTestWithDeps(
+ initialSelection = listOf(0, 24),
+ cursor = listOf(24),
+ pageSize = 16,
+ maxLoadedPages = 2,
+ ) { deps ->
+ backgroundScope.launch { deps.underTest.launch(deps.cursor, deps.initialPreviews) }
+ runCurrent()
+
+ assertThat(deps.previewsRepo.previewsModel.value).isNotNull()
+ assertThat(deps.previewsRepo.previewsModel.value!!.previewModels).hasSize(2)
+ assertThat(deps.previewsRepo.previewsModel.value!!.previewModels.first().uri)
+ .isEqualTo(Uri.fromParts("scheme0", "ssp0", "fragment0"))
+ assertThat(deps.previewsRepo.previewsModel.value!!.previewModels.last().uri)
+ .isEqualTo(Uri.fromParts("scheme24", "ssp24", "fragment24"))
+ assertThat(deps.previewsRepo.previewsModel.value!!.loadMoreLeft).isNull()
+ }
+}
diff --git a/tests/unit/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/interactor/CustomActionsInteractorTest.kt b/tests/unit/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/interactor/CustomActionsInteractorTest.kt
new file mode 100644
index 00000000..ceb20dab
--- /dev/null
+++ b/tests/unit/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/interactor/CustomActionsInteractorTest.kt
@@ -0,0 +1,166 @@
+/*
+ * Copyright (C) 2024 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+@file:OptIn(ExperimentalCoroutinesApi::class)
+
+package com.android.intentresolver.contentpreview.payloadtoggle.domain.interactor
+
+import android.app.Activity
+import android.graphics.Bitmap
+import android.graphics.drawable.Icon
+import com.android.intentresolver.contentpreview.payloadtoggle.data.model.CustomActionModel
+import com.android.intentresolver.contentpreview.payloadtoggle.data.repository.ActivityResultRepository
+import com.android.intentresolver.contentpreview.payloadtoggle.domain.model.ActionModel
+import com.android.intentresolver.icon.BitmapIcon
+import com.android.intentresolver.mock
+import com.android.intentresolver.util.comparingElementsUsingTransform
+import com.android.intentresolver.v2.data.model.fakeChooserRequest
+import com.android.intentresolver.v2.data.repository.ChooserRequestRepository
+import com.google.common.truth.Truth.assertThat
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.stateIn
+import kotlinx.coroutines.test.StandardTestDispatcher
+import kotlinx.coroutines.test.runCurrent
+import kotlinx.coroutines.test.runTest
+import org.junit.Test
+
+class CustomActionsInteractorTest {
+
+ private val testDispatcher = StandardTestDispatcher()
+
+ @Test
+ fun customActions_initialRepoValue() =
+ runTest(testDispatcher) {
+ val bitmap = Bitmap.createBitmap(100, 100, Bitmap.Config.ALPHA_8)
+ val icon = Icon.createWithBitmap(bitmap)
+ val chooserRequestRepository =
+ ChooserRequestRepository(
+ initialRequest = fakeChooserRequest(),
+ initialActions =
+ listOf(
+ CustomActionModel(label = "label1", icon = icon, performAction = {}),
+ ),
+ )
+ val underTest =
+ CustomActionsInteractor(
+ activityResultRepo = ActivityResultRepository(),
+ bgDispatcher = testDispatcher,
+ contentResolver = mock {},
+ eventLog = mock {},
+ packageManager = mock {},
+ chooserRequestInteractor =
+ ChooserRequestInteractor(repository = chooserRequestRepository),
+ )
+ val customActions: StateFlow<List<ActionModel>> =
+ underTest.customActions.stateIn(backgroundScope)
+ assertThat(customActions.value)
+ .comparingElementsUsingTransform("has a label of") { model: ActionModel ->
+ model.label
+ }
+ .containsExactly("label1")
+ .inOrder()
+ assertThat(customActions.value)
+ .comparingElementsUsingTransform("has an icon of") { model: ActionModel ->
+ model.icon
+ }
+ .containsExactly(BitmapIcon(icon.bitmap))
+ .inOrder()
+ }
+
+ @Test
+ fun customActions_tracksRepoUpdates() =
+ runTest(testDispatcher) {
+ val chooserRequestRepository =
+ ChooserRequestRepository(
+ initialRequest = fakeChooserRequest(),
+ initialActions = emptyList(),
+ )
+ val underTest =
+ CustomActionsInteractor(
+ activityResultRepo = ActivityResultRepository(),
+ bgDispatcher = testDispatcher,
+ contentResolver = mock {},
+ eventLog = mock {},
+ packageManager = mock {},
+ chooserRequestInteractor =
+ ChooserRequestInteractor(repository = chooserRequestRepository),
+ )
+
+ val customActions: StateFlow<List<ActionModel>> =
+ underTest.customActions.stateIn(backgroundScope)
+ val bitmap = Bitmap.createBitmap(100, 100, Bitmap.Config.ALPHA_8)
+ val icon = Icon.createWithBitmap(bitmap)
+ val chooserActions = listOf(CustomActionModel("label1", icon) {})
+ chooserRequestRepository.customActions.value = chooserActions
+ runCurrent()
+
+ assertThat(customActions.value)
+ .comparingElementsUsingTransform("has a label of") { model: ActionModel ->
+ model.label
+ }
+ .containsExactly("label1")
+ .inOrder()
+ assertThat(customActions.value)
+ .comparingElementsUsingTransform("has an icon of") { model: ActionModel ->
+ model.icon
+ }
+ .containsExactly(BitmapIcon(icon.bitmap))
+ .inOrder()
+ }
+
+ @Test
+ fun customActions_performAction_sendsPendingIntent() =
+ runTest(testDispatcher) {
+ val bitmap = Bitmap.createBitmap(100, 100, Bitmap.Config.ALPHA_8)
+ val icon = Icon.createWithBitmap(bitmap)
+ var actionSent = false
+ val activityResultRepository = ActivityResultRepository()
+ val chooserRequestRepository =
+ ChooserRequestRepository(
+ initialRequest = fakeChooserRequest(),
+ initialActions =
+ listOf(
+ CustomActionModel(
+ label = "label1",
+ icon = icon,
+ performAction = { actionSent = true },
+ )
+ ),
+ )
+ val underTest =
+ CustomActionsInteractor(
+ activityResultRepo = activityResultRepository,
+ bgDispatcher = testDispatcher,
+ contentResolver = mock {},
+ eventLog = mock {},
+ packageManager = mock {},
+ chooserRequestInteractor =
+ ChooserRequestInteractor(
+ repository = chooserRequestRepository,
+ ),
+ )
+ val customActions: StateFlow<List<ActionModel>> =
+ underTest.customActions.stateIn(backgroundScope)
+
+ assertThat(customActions.value).hasSize(1)
+
+ customActions.value[0].performAction(123)
+
+ assertThat(actionSent).isTrue()
+ assertThat(activityResultRepository.activityResult.value).isEqualTo(Activity.RESULT_OK)
+ }
+}
diff --git a/tests/unit/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/interactor/FetchPreviewsInteractorTest.kt b/tests/unit/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/interactor/FetchPreviewsInteractorTest.kt
new file mode 100644
index 00000000..08a667b9
--- /dev/null
+++ b/tests/unit/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/interactor/FetchPreviewsInteractorTest.kt
@@ -0,0 +1,335 @@
+/*
+ * Copyright (C) 2024 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+@file:OptIn(ExperimentalCoroutinesApi::class)
+
+package com.android.intentresolver.contentpreview.payloadtoggle.domain.interactor
+
+import android.database.MatrixCursor
+import android.net.Uri
+import androidx.core.os.bundleOf
+import com.android.intentresolver.contentpreview.FileInfo
+import com.android.intentresolver.contentpreview.UriMetadataReader
+import com.android.intentresolver.contentpreview.payloadtoggle.data.repository.CursorPreviewsRepository
+import com.android.intentresolver.contentpreview.payloadtoggle.data.repository.PreviewSelectionsRepository
+import com.android.intentresolver.contentpreview.payloadtoggle.domain.cursor.CursorResolver
+import com.android.intentresolver.contentpreview.payloadtoggle.shared.model.PreviewModel
+import com.android.intentresolver.contentpreview.payloadtoggle.shared.model.PreviewsModel
+import com.android.intentresolver.util.cursor.CursorView
+import com.android.intentresolver.util.cursor.viewBy
+import com.google.common.truth.Truth.assertThat
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.sync.Mutex
+import kotlinx.coroutines.sync.withLock
+import kotlinx.coroutines.test.TestScope
+import kotlinx.coroutines.test.runCurrent
+import kotlinx.coroutines.test.runTest
+import org.junit.Test
+
+class FetchPreviewsInteractorTest {
+
+ private fun runTestWithDeps(
+ initialSelection: Iterable<Int> = (1..2),
+ focusedItemIndex: Int = initialSelection.count() / 2,
+ cursor: Iterable<Int> = (0 until 4),
+ cursorStartPosition: Int = cursor.count() / 2,
+ pageSize: Int = 16,
+ maxLoadedPages: Int = 3,
+ block: TestScope.(TestDeps) -> Unit,
+ ): Unit = runTest {
+ block(
+ TestDeps(
+ initialSelection,
+ focusedItemIndex,
+ cursor,
+ cursorStartPosition,
+ pageSize,
+ maxLoadedPages,
+ )
+ )
+ }
+
+ private class TestDeps(
+ initialSelectionRange: Iterable<Int>,
+ focusedItemIndex: Int,
+ private val cursorRange: Iterable<Int>,
+ private val cursorStartPosition: Int,
+ pageSize: Int,
+ maxLoadedPages: Int,
+ ) {
+
+ private fun uri(index: Int) = Uri.fromParts("scheme$index", "ssp$index", "fragment$index")
+
+ val previewsRepo = CursorPreviewsRepository()
+
+ val cursorResolver = FakeCursorResolver()
+
+ private val uriMetadataReader = UriMetadataReader {
+ FileInfo.Builder(it).withMimeType("image/bitmap").build()
+ }
+
+ val underTest =
+ FetchPreviewsInteractor(
+ setCursorPreviews = SetCursorPreviewsInteractor(previewsRepo),
+ selectionRepository = PreviewSelectionsRepository(),
+ cursorInteractor =
+ CursorPreviewsInteractor(
+ interactor = SetCursorPreviewsInteractor(previewsRepo = previewsRepo),
+ focusedItemIdx = focusedItemIndex,
+ uriMetadataReader = uriMetadataReader,
+ pageSize = pageSize,
+ maxLoadedPages = maxLoadedPages,
+ ),
+ focusedItemIdx = focusedItemIndex,
+ selectedItems = initialSelectionRange.map { idx -> uri(idx) },
+ uriMetadataReader = uriMetadataReader,
+ cursorResolver = cursorResolver,
+ )
+
+ inner class FakeCursorResolver : CursorResolver<Uri?> {
+ private val mutex = Mutex(locked = true)
+
+ fun complete() = mutex.unlock()
+
+ override suspend fun getCursor(): CursorView<Uri?> =
+ mutex.withLock {
+ MatrixCursor(arrayOf("uri"))
+ .apply {
+ extras = bundleOf("position" to cursorStartPosition)
+ for (i in cursorRange) {
+ newRow().add("uri", uri(i).toString())
+ }
+ }
+ .viewBy { getString(0)?.let(Uri::parse) }
+ }
+ }
+ }
+
+ @Test
+ fun setsInitialPreviews() = runTestWithDeps { deps ->
+ backgroundScope.launch { deps.underTest.activate() }
+ runCurrent()
+
+ assertThat(deps.previewsRepo.previewsModel.value)
+ .isEqualTo(
+ PreviewsModel(
+ previewModels =
+ setOf(
+ PreviewModel(
+ Uri.fromParts("scheme1", "ssp1", "fragment1"),
+ "image/bitmap",
+ ),
+ PreviewModel(
+ Uri.fromParts("scheme2", "ssp2", "fragment2"),
+ "image/bitmap",
+ ),
+ ),
+ startIdx = 1,
+ loadMoreLeft = null,
+ loadMoreRight = null,
+ )
+ )
+ }
+
+ @Test
+ fun lookupCursorFromContentResolver() = runTestWithDeps { deps ->
+ backgroundScope.launch { deps.underTest.activate() }
+ deps.cursorResolver.complete()
+ runCurrent()
+
+ assertThat(deps.previewsRepo.previewsModel.value).isNotNull()
+ assertThat(deps.previewsRepo.previewsModel.value!!.startIdx).isEqualTo(0)
+ assertThat(deps.previewsRepo.previewsModel.value!!.loadMoreLeft).isNull()
+ assertThat(deps.previewsRepo.previewsModel.value!!.loadMoreRight).isNull()
+ assertThat(deps.previewsRepo.previewsModel.value!!.previewModels)
+ .containsExactly(
+ PreviewModel(Uri.fromParts("scheme0", "ssp0", "fragment0"), "image/bitmap"),
+ PreviewModel(Uri.fromParts("scheme1", "ssp1", "fragment1"), "image/bitmap"),
+ PreviewModel(Uri.fromParts("scheme2", "ssp2", "fragment2"), "image/bitmap"),
+ PreviewModel(Uri.fromParts("scheme3", "ssp3", "fragment3"), "image/bitmap"),
+ )
+ .inOrder()
+ }
+
+ @Test
+ fun loadMoreLeft_evictRight() =
+ runTestWithDeps(
+ initialSelection = listOf(24),
+ cursor = (0 until 48),
+ pageSize = 16,
+ maxLoadedPages = 1,
+ ) { deps ->
+ backgroundScope.launch { deps.underTest.activate() }
+ deps.cursorResolver.complete()
+ runCurrent()
+
+ assertThat(deps.previewsRepo.previewsModel.value).isNotNull()
+ assertThat(deps.previewsRepo.previewsModel.value!!.previewModels).hasSize(16)
+ assertThat(deps.previewsRepo.previewsModel.value!!.previewModels.first().uri)
+ .isEqualTo(Uri.fromParts("scheme16", "ssp16", "fragment16"))
+ assertThat(deps.previewsRepo.previewsModel.value!!.previewModels.last().uri)
+ .isEqualTo(Uri.fromParts("scheme31", "ssp31", "fragment31"))
+ assertThat(deps.previewsRepo.previewsModel.value!!.loadMoreLeft).isNotNull()
+
+ deps.previewsRepo.previewsModel.value!!.loadMoreLeft!!.invoke()
+ runCurrent()
+
+ assertThat(deps.previewsRepo.previewsModel.value).isNotNull()
+ assertThat(deps.previewsRepo.previewsModel.value!!.previewModels).hasSize(16)
+ assertThat(deps.previewsRepo.previewsModel.value!!.previewModels.first().uri)
+ .isEqualTo(Uri.fromParts("scheme0", "ssp0", "fragment0"))
+ assertThat(deps.previewsRepo.previewsModel.value!!.previewModels.last().uri)
+ .isEqualTo(Uri.fromParts("scheme15", "ssp15", "fragment15"))
+ assertThat(deps.previewsRepo.previewsModel.value!!.loadMoreLeft).isNull()
+ }
+
+ @Test
+ fun loadMoreLeft_keepRight() =
+ runTestWithDeps(
+ initialSelection = listOf(24),
+ cursor = (0 until 48),
+ pageSize = 16,
+ maxLoadedPages = 2,
+ ) { deps ->
+ backgroundScope.launch { deps.underTest.activate() }
+ deps.cursorResolver.complete()
+ runCurrent()
+
+ assertThat(deps.previewsRepo.previewsModel.value).isNotNull()
+ assertThat(deps.previewsRepo.previewsModel.value!!.previewModels).hasSize(16)
+ assertThat(deps.previewsRepo.previewsModel.value!!.previewModels.first().uri)
+ .isEqualTo(Uri.fromParts("scheme16", "ssp16", "fragment16"))
+ assertThat(deps.previewsRepo.previewsModel.value!!.previewModels.last().uri)
+ .isEqualTo(Uri.fromParts("scheme31", "ssp31", "fragment31"))
+ assertThat(deps.previewsRepo.previewsModel.value!!.loadMoreLeft).isNotNull()
+
+ deps.previewsRepo.previewsModel.value!!.loadMoreLeft!!.invoke()
+ runCurrent()
+
+ assertThat(deps.previewsRepo.previewsModel.value).isNotNull()
+ assertThat(deps.previewsRepo.previewsModel.value!!.previewModels).hasSize(32)
+ assertThat(deps.previewsRepo.previewsModel.value!!.previewModels.first().uri)
+ .isEqualTo(Uri.fromParts("scheme0", "ssp0", "fragment0"))
+ assertThat(deps.previewsRepo.previewsModel.value!!.previewModels.last().uri)
+ .isEqualTo(Uri.fromParts("scheme31", "ssp31", "fragment31"))
+ assertThat(deps.previewsRepo.previewsModel.value!!.loadMoreLeft).isNull()
+ }
+
+ @Test
+ fun loadMoreRight_evictLeft() =
+ runTestWithDeps(
+ initialSelection = listOf(24),
+ cursor = (0 until 48),
+ pageSize = 16,
+ maxLoadedPages = 1,
+ ) { deps ->
+ backgroundScope.launch { deps.underTest.activate() }
+ deps.cursorResolver.complete()
+ runCurrent()
+
+ assertThat(deps.previewsRepo.previewsModel.value).isNotNull()
+ assertThat(deps.previewsRepo.previewsModel.value!!.previewModels).hasSize(16)
+ assertThat(deps.previewsRepo.previewsModel.value!!.previewModels.first().uri)
+ .isEqualTo(Uri.fromParts("scheme16", "ssp16", "fragment16"))
+ assertThat(deps.previewsRepo.previewsModel.value!!.previewModels.last().uri)
+ .isEqualTo(Uri.fromParts("scheme31", "ssp31", "fragment31"))
+ assertThat(deps.previewsRepo.previewsModel.value!!.loadMoreRight).isNotNull()
+
+ deps.previewsRepo.previewsModel.value!!.loadMoreRight!!.invoke()
+ runCurrent()
+
+ assertThat(deps.previewsRepo.previewsModel.value).isNotNull()
+ assertThat(deps.previewsRepo.previewsModel.value!!.previewModels).hasSize(16)
+ assertThat(deps.previewsRepo.previewsModel.value!!.previewModels.first().uri)
+ .isEqualTo(Uri.fromParts("scheme32", "ssp32", "fragment32"))
+ assertThat(deps.previewsRepo.previewsModel.value!!.previewModels.last().uri)
+ .isEqualTo(Uri.fromParts("scheme47", "ssp47", "fragment47"))
+ }
+
+ @Test
+ fun loadMoreRight_keepLeft() =
+ runTestWithDeps(
+ initialSelection = listOf(24),
+ cursor = (0 until 48),
+ pageSize = 16,
+ maxLoadedPages = 2,
+ ) { deps ->
+ backgroundScope.launch { deps.underTest.activate() }
+ deps.cursorResolver.complete()
+ runCurrent()
+
+ assertThat(deps.previewsRepo.previewsModel.value).isNotNull()
+ assertThat(deps.previewsRepo.previewsModel.value!!.previewModels).hasSize(16)
+ assertThat(deps.previewsRepo.previewsModel.value!!.previewModels.first().uri)
+ .isEqualTo(Uri.fromParts("scheme16", "ssp16", "fragment16"))
+ assertThat(deps.previewsRepo.previewsModel.value!!.previewModels.last().uri)
+ .isEqualTo(Uri.fromParts("scheme31", "ssp31", "fragment31"))
+ assertThat(deps.previewsRepo.previewsModel.value!!.loadMoreRight).isNotNull()
+
+ deps.previewsRepo.previewsModel.value!!.loadMoreRight!!.invoke()
+ runCurrent()
+
+ assertThat(deps.previewsRepo.previewsModel.value).isNotNull()
+ assertThat(deps.previewsRepo.previewsModel.value!!.previewModels).hasSize(32)
+ assertThat(deps.previewsRepo.previewsModel.value!!.previewModels.first().uri)
+ .isEqualTo(Uri.fromParts("scheme16", "ssp16", "fragment16"))
+ assertThat(deps.previewsRepo.previewsModel.value!!.previewModels.last().uri)
+ .isEqualTo(Uri.fromParts("scheme47", "ssp47", "fragment47"))
+ }
+
+ @Test
+ fun noMoreRight_appendUnclaimedFromInitialSelection() =
+ runTestWithDeps(
+ initialSelection = listOf(24, 50),
+ cursor = listOf(24),
+ pageSize = 16,
+ maxLoadedPages = 2,
+ ) { deps ->
+ backgroundScope.launch { deps.underTest.activate() }
+ deps.cursorResolver.complete()
+ runCurrent()
+
+ assertThat(deps.previewsRepo.previewsModel.value).isNotNull()
+ assertThat(deps.previewsRepo.previewsModel.value!!.previewModels).hasSize(2)
+ assertThat(deps.previewsRepo.previewsModel.value!!.previewModels.first().uri)
+ .isEqualTo(Uri.fromParts("scheme24", "ssp24", "fragment24"))
+ assertThat(deps.previewsRepo.previewsModel.value!!.previewModels.last().uri)
+ .isEqualTo(Uri.fromParts("scheme50", "ssp50", "fragment50"))
+ assertThat(deps.previewsRepo.previewsModel.value!!.loadMoreRight).isNull()
+ }
+
+ @Test
+ fun noMoreLeft_appendUnclaimedFromInitialSelection() =
+ runTestWithDeps(
+ initialSelection = listOf(0, 24),
+ cursor = listOf(24),
+ pageSize = 16,
+ maxLoadedPages = 2,
+ ) { deps ->
+ backgroundScope.launch { deps.underTest.activate() }
+ deps.cursorResolver.complete()
+ runCurrent()
+
+ assertThat(deps.previewsRepo.previewsModel.value).isNotNull()
+ assertThat(deps.previewsRepo.previewsModel.value!!.previewModels).hasSize(2)
+ assertThat(deps.previewsRepo.previewsModel.value!!.previewModels.first().uri)
+ .isEqualTo(Uri.fromParts("scheme0", "ssp0", "fragment0"))
+ assertThat(deps.previewsRepo.previewsModel.value!!.previewModels.last().uri)
+ .isEqualTo(Uri.fromParts("scheme24", "ssp24", "fragment24"))
+ assertThat(deps.previewsRepo.previewsModel.value!!.loadMoreLeft).isNull()
+ }
+}
diff --git a/tests/unit/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/interactor/SelectablePreviewInteractorTest.kt b/tests/unit/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/interactor/SelectablePreviewInteractorTest.kt
new file mode 100644
index 00000000..ff22f37b
--- /dev/null
+++ b/tests/unit/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/interactor/SelectablePreviewInteractorTest.kt
@@ -0,0 +1,148 @@
+/*
+ * Copyright (C) 2024 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+@file:OptIn(ExperimentalCoroutinesApi::class)
+
+package com.android.intentresolver.contentpreview.payloadtoggle.domain.interactor
+
+import android.content.Intent
+import android.net.Uri
+import com.android.intentresolver.contentpreview.payloadtoggle.data.repository.PendingSelectionCallbackRepository
+import com.android.intentresolver.contentpreview.payloadtoggle.data.repository.PreviewSelectionsRepository
+import com.android.intentresolver.contentpreview.payloadtoggle.shared.model.PreviewModel
+import com.android.intentresolver.v2.data.model.fakeChooserRequest
+import com.android.intentresolver.v2.data.repository.ChooserRequestRepository
+import com.google.common.truth.Truth.assertThat
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.flow.first
+import kotlinx.coroutines.test.runCurrent
+import kotlinx.coroutines.test.runTest
+import org.junit.Test
+
+class SelectablePreviewInteractorTest {
+
+ @Test
+ fun reflectPreviewRepo_initState() = runTest {
+ val selectionRepo = PreviewSelectionsRepository()
+ val chooserRequestRepo =
+ ChooserRequestRepository(
+ initialRequest = fakeChooserRequest(),
+ initialActions = emptyList(),
+ )
+ val pendingSelectionCallbackRepo = PendingSelectionCallbackRepository()
+ val underTest =
+ SelectablePreviewInteractor(
+ key = PreviewModel(Uri.fromParts("scheme", "ssp", "fragment"), null),
+ selectionInteractor =
+ SelectionInteractor(
+ selectionsRepo = selectionRepo,
+ targetIntentModifier = { error("unexpected invocation") },
+ updateTargetIntentInteractor =
+ UpdateTargetIntentInteractor(
+ repository = pendingSelectionCallbackRepo,
+ chooserRequestInteractor =
+ UpdateChooserRequestInteractor(
+ repository = chooserRequestRepo,
+ pendingIntentSender = { error("unexpected invocation") },
+ )
+ )
+ ),
+ )
+ runCurrent()
+
+ assertThat(underTest.isSelected.first()).isFalse()
+ }
+
+ @Test
+ fun reflectPreviewRepo_updatedState() = runTest {
+ val selectionRepo = PreviewSelectionsRepository()
+ val chooserRequestRepo =
+ ChooserRequestRepository(
+ initialRequest = fakeChooserRequest(),
+ initialActions = emptyList(),
+ )
+ val pendingSelectionCallbackRepo = PendingSelectionCallbackRepository()
+ val underTest =
+ SelectablePreviewInteractor(
+ key = PreviewModel(Uri.fromParts("scheme", "ssp", "fragment"), "image/bitmap"),
+ selectionInteractor =
+ SelectionInteractor(
+ selectionsRepo = selectionRepo,
+ targetIntentModifier = { error("unexpected invocation") },
+ updateTargetIntentInteractor =
+ UpdateTargetIntentInteractor(
+ repository = pendingSelectionCallbackRepo,
+ chooserRequestInteractor =
+ UpdateChooserRequestInteractor(
+ repository = chooserRequestRepo,
+ pendingIntentSender = { error("unexpected invocation") },
+ )
+ )
+ ),
+ )
+
+ assertThat(underTest.isSelected.first()).isFalse()
+
+ selectionRepo.selections.value =
+ setOf(PreviewModel(Uri.fromParts("scheme", "ssp", "fragment"), "image/bitmap"))
+ runCurrent()
+
+ assertThat(underTest.isSelected.first()).isTrue()
+ }
+
+ @Test
+ fun setSelected_updatesChooserRequestRepo() = runTest {
+ val modifiedIntent = Intent()
+ val selectionRepo = PreviewSelectionsRepository()
+ val chooserRequestRepo =
+ ChooserRequestRepository(
+ initialRequest = fakeChooserRequest(),
+ initialActions = emptyList(),
+ )
+ val pendingSelectionCallbackRepo = PendingSelectionCallbackRepository()
+ val underTest =
+ SelectablePreviewInteractor(
+ key = PreviewModel(Uri.fromParts("scheme", "ssp", "fragment"), "image/bitmap"),
+ selectionInteractor =
+ SelectionInteractor(
+ selectionsRepo = selectionRepo,
+ targetIntentModifier = { modifiedIntent },
+ updateTargetIntentInteractor =
+ UpdateTargetIntentInteractor(
+ repository = pendingSelectionCallbackRepo,
+ chooserRequestInteractor =
+ UpdateChooserRequestInteractor(
+ repository = chooserRequestRepo,
+ pendingIntentSender = { error("unexpected invocation") },
+ )
+ )
+ ),
+ )
+
+ underTest.setSelected(true)
+ runCurrent()
+
+ assertThat(selectionRepo.selections.value)
+ .containsExactly(
+ PreviewModel(Uri.fromParts("scheme", "ssp", "fragment"), "image/bitmap")
+ )
+
+ assertThat(chooserRequestRepo.chooserRequest.value.targetIntent)
+ .isSameInstanceAs(modifiedIntent)
+ assertThat(pendingSelectionCallbackRepo.pendingTargetIntent.value)
+ .isSameInstanceAs(modifiedIntent)
+ }
+}
diff --git a/tests/unit/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/interactor/SelectablePreviewsInteractorTest.kt b/tests/unit/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/interactor/SelectablePreviewsInteractorTest.kt
new file mode 100644
index 00000000..3f02c0cd
--- /dev/null
+++ b/tests/unit/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/interactor/SelectablePreviewsInteractorTest.kt
@@ -0,0 +1,193 @@
+/*
+ * Copyright (C) 2024 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+@file:OptIn(ExperimentalCoroutinesApi::class)
+
+package com.android.intentresolver.contentpreview.payloadtoggle.domain.interactor
+
+import android.net.Uri
+import com.android.intentresolver.contentpreview.payloadtoggle.data.repository.CursorPreviewsRepository
+import com.android.intentresolver.contentpreview.payloadtoggle.data.repository.PendingSelectionCallbackRepository
+import com.android.intentresolver.contentpreview.payloadtoggle.data.repository.PreviewSelectionsRepository
+import com.android.intentresolver.contentpreview.payloadtoggle.shared.model.PreviewModel
+import com.android.intentresolver.contentpreview.payloadtoggle.shared.model.PreviewsModel
+import com.android.intentresolver.v2.data.model.fakeChooserRequest
+import com.android.intentresolver.v2.data.repository.ChooserRequestRepository
+import com.google.common.truth.Truth.assertThat
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.flow.first
+import kotlinx.coroutines.flow.stateIn
+import kotlinx.coroutines.test.runCurrent
+import kotlinx.coroutines.test.runTest
+import org.junit.Test
+
+class SelectablePreviewsInteractorTest {
+
+ @Test
+ fun keySet_reflectsRepositoryInit() = runTest {
+ val repo =
+ CursorPreviewsRepository().apply {
+ previewsModel.value =
+ PreviewsModel(
+ previewModels =
+ setOf(
+ PreviewModel(
+ Uri.fromParts("scheme", "ssp", "fragment"),
+ "image/bitmap",
+ ),
+ PreviewModel(
+ Uri.fromParts("scheme2", "ssp2", "fragment2"),
+ "image/bitmap",
+ ),
+ ),
+ startIdx = 0,
+ loadMoreLeft = null,
+ loadMoreRight = null,
+ )
+ }
+ val selectionRepo =
+ PreviewSelectionsRepository().apply {
+ selections.value =
+ setOf(
+ PreviewModel(Uri.fromParts("scheme", "ssp", "fragment"), null),
+ )
+ }
+ val chooserRequestRepo =
+ ChooserRequestRepository(
+ initialRequest = fakeChooserRequest(),
+ initialActions = emptyList(),
+ )
+ val underTest =
+ SelectablePreviewsInteractor(
+ previewsRepo = repo,
+ selectionInteractor =
+ SelectionInteractor(
+ selectionRepo,
+ targetIntentModifier = { error("unexpected invocation") },
+ updateTargetIntentInteractor =
+ UpdateTargetIntentInteractor(
+ repository = PendingSelectionCallbackRepository(),
+ chooserRequestInteractor =
+ UpdateChooserRequestInteractor(
+ repository = chooserRequestRepo,
+ pendingIntentSender = { error("unexpected invocation") },
+ )
+ )
+ ),
+ )
+ val keySet = underTest.previews.stateIn(backgroundScope)
+
+ assertThat(keySet.value).isNotNull()
+ assertThat(keySet.value!!.previewModels)
+ .containsExactly(
+ PreviewModel(Uri.fromParts("scheme", "ssp", "fragment"), "image/bitmap"),
+ PreviewModel(Uri.fromParts("scheme2", "ssp2", "fragment2"), "image/bitmap"),
+ )
+ .inOrder()
+ assertThat(keySet.value!!.startIdx).isEqualTo(0)
+ assertThat(keySet.value!!.loadMoreLeft).isNull()
+ assertThat(keySet.value!!.loadMoreRight).isNull()
+
+ val firstModel =
+ underTest.preview(PreviewModel(Uri.fromParts("scheme", "ssp", "fragment"), null))
+ assertThat(firstModel.isSelected.first()).isTrue()
+
+ val secondModel =
+ underTest.preview(PreviewModel(Uri.fromParts("scheme2", "ssp2", "fragment2"), null))
+ assertThat(secondModel.isSelected.first()).isFalse()
+ }
+
+ @Test
+ fun keySet_reflectsRepositoryUpdate() = runTest {
+ val previewsRepo = CursorPreviewsRepository()
+ val selectionRepo =
+ PreviewSelectionsRepository().apply {
+ selections.value =
+ setOf(
+ PreviewModel(Uri.fromParts("scheme", "ssp", "fragment"), null),
+ )
+ }
+ val chooserRequestRepo =
+ ChooserRequestRepository(
+ initialRequest = fakeChooserRequest(),
+ initialActions = emptyList(),
+ )
+ val underTest =
+ SelectablePreviewsInteractor(
+ previewsRepo = previewsRepo,
+ selectionInteractor =
+ SelectionInteractor(
+ selectionRepo,
+ targetIntentModifier = { error("unexpected invocation") },
+ updateTargetIntentInteractor =
+ UpdateTargetIntentInteractor(
+ repository = PendingSelectionCallbackRepository(),
+ chooserRequestInteractor =
+ UpdateChooserRequestInteractor(
+ repository = chooserRequestRepo,
+ pendingIntentSender = { error("unexpected invocation") },
+ )
+ )
+ ),
+ )
+
+ val previews = underTest.previews.stateIn(backgroundScope)
+ val firstModel =
+ underTest.preview(PreviewModel(Uri.fromParts("scheme", "ssp", "fragment"), null))
+
+ assertThat(previews.value).isNull()
+ assertThat(firstModel.isSelected.first()).isTrue()
+
+ var loadRequested = false
+
+ previewsRepo.previewsModel.value =
+ PreviewsModel(
+ previewModels =
+ setOf(
+ PreviewModel(
+ Uri.fromParts("scheme", "ssp", "fragment"),
+ "image/bitmap",
+ ),
+ PreviewModel(
+ Uri.fromParts("scheme2", "ssp2", "fragment2"),
+ "image/bitmap",
+ ),
+ ),
+ startIdx = 5,
+ loadMoreLeft = null,
+ loadMoreRight = { loadRequested = true },
+ )
+ selectionRepo.selections.value = emptySet()
+ runCurrent()
+
+ assertThat(previews.value).isNotNull()
+ assertThat(previews.value!!.previewModels)
+ .containsExactly(
+ PreviewModel(Uri.fromParts("scheme", "ssp", "fragment"), "image/bitmap"),
+ PreviewModel(Uri.fromParts("scheme2", "ssp2", "fragment2"), "image/bitmap"),
+ )
+ .inOrder()
+ assertThat(previews.value!!.startIdx).isEqualTo(5)
+ assertThat(previews.value!!.loadMoreLeft).isNull()
+ assertThat(previews.value!!.loadMoreRight).isNotNull()
+
+ assertThat(firstModel.isSelected.first()).isFalse()
+
+ previews.value!!.loadMoreRight!!.invoke()
+
+ assertThat(loadRequested).isTrue()
+ }
+}
diff --git a/tests/unit/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/interactor/SetCursorPreviewsInteractorTest.kt b/tests/unit/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/interactor/SetCursorPreviewsInteractorTest.kt
new file mode 100644
index 00000000..9683d01f
--- /dev/null
+++ b/tests/unit/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/interactor/SetCursorPreviewsInteractorTest.kt
@@ -0,0 +1,108 @@
+/*
+ * Copyright (C) 2024 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+@file:OptIn(ExperimentalCoroutinesApi::class)
+
+package com.android.intentresolver.contentpreview.payloadtoggle.domain.interactor
+
+import android.net.Uri
+import com.android.intentresolver.contentpreview.payloadtoggle.data.repository.CursorPreviewsRepository
+import com.android.intentresolver.contentpreview.payloadtoggle.domain.model.LoadDirection
+import com.android.intentresolver.contentpreview.payloadtoggle.shared.model.PreviewModel
+import com.google.common.truth.Truth.assertThat
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.flow.first
+import kotlinx.coroutines.flow.stateIn
+import kotlinx.coroutines.test.runCurrent
+import kotlinx.coroutines.test.runTest
+import org.junit.Test
+
+class SetCursorPreviewsInteractorTest {
+ @Test
+ fun setPreviews_noAdditionalData() = runTest {
+ val repo = CursorPreviewsRepository()
+ val underTest = SetCursorPreviewsInteractor(repo)
+
+ val loadState =
+ underTest.setPreviews(
+ previewsByKey =
+ setOf(
+ PreviewModel(
+ uri = Uri.fromParts("scheme", "ssp", "fragment"),
+ mimeType = null,
+ )
+ ),
+ startIndex = 100,
+ hasMoreLeft = false,
+ hasMoreRight = false,
+ )
+
+ assertThat(loadState.first()).isNull()
+ repo.previewsModel.value.let {
+ assertThat(it).isNotNull()
+ it!!
+ assertThat(it.loadMoreRight).isNull()
+ assertThat(it.loadMoreLeft).isNull()
+ assertThat(it.startIdx).isEqualTo(100)
+ assertThat(it.previewModels)
+ .containsExactly(
+ PreviewModel(
+ uri = Uri.fromParts("scheme", "ssp", "fragment"),
+ mimeType = null,
+ )
+ )
+ .inOrder()
+ }
+ }
+
+ @Test
+ fun setPreviews_additionalData() = runTest {
+ val repo = CursorPreviewsRepository()
+ val underTest = SetCursorPreviewsInteractor(repo)
+
+ val loadState =
+ underTest
+ .setPreviews(
+ previewsByKey =
+ setOf(
+ PreviewModel(
+ uri = Uri.fromParts("scheme", "ssp", "fragment"),
+ mimeType = null,
+ )
+ ),
+ startIndex = 100,
+ hasMoreLeft = true,
+ hasMoreRight = true,
+ )
+ .stateIn(backgroundScope)
+
+ assertThat(loadState.value).isNull()
+ repo.previewsModel.value.let {
+ assertThat(it).isNotNull()
+ it!!
+ assertThat(it.loadMoreRight).isNotNull()
+ assertThat(it.loadMoreLeft).isNotNull()
+
+ it.loadMoreRight!!.invoke()
+ runCurrent()
+ assertThat(loadState.value).isEqualTo(LoadDirection.Right)
+
+ it.loadMoreLeft!!.invoke()
+ runCurrent()
+ assertThat(loadState.value).isEqualTo(LoadDirection.Left)
+ }
+ }
+}
diff --git a/tests/unit/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/interactor/UpdateChooserRequestInteractorTest.kt b/tests/unit/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/interactor/UpdateChooserRequestInteractorTest.kt
new file mode 100644
index 00000000..05c7646a
--- /dev/null
+++ b/tests/unit/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/interactor/UpdateChooserRequestInteractorTest.kt
@@ -0,0 +1,74 @@
+/*
+ * Copyright (C) 2024 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+@file:OptIn(ExperimentalCoroutinesApi::class)
+
+package com.android.intentresolver.contentpreview.payloadtoggle.domain.interactor
+
+import android.content.Intent
+import com.android.intentresolver.contentpreview.payloadtoggle.data.repository.PendingSelectionCallbackRepository
+import com.android.intentresolver.contentpreview.payloadtoggle.domain.intent.PendingIntentSender
+import com.android.intentresolver.contentpreview.payloadtoggle.domain.model.ShareouselUpdate
+import com.android.intentresolver.contentpreview.payloadtoggle.domain.model.ValueUpdate
+import com.android.intentresolver.v2.data.model.fakeChooserRequest
+import com.android.intentresolver.v2.data.repository.ChooserRequestRepository
+import com.google.common.truth.Truth.assertThat
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.test.runCurrent
+import kotlinx.coroutines.test.runTest
+import org.junit.Test
+
+class UpdateChooserRequestInteractorTest {
+ @Test
+ fun updateTargetIntentWithSelection() = runTest {
+ val pendingIntentSender = PendingIntentSender {}
+ val chooserRequestRepository =
+ ChooserRequestRepository(
+ initialRequest = fakeChooserRequest(),
+ initialActions = emptyList(),
+ )
+ val selectionCallbackResult = ShareouselUpdate(metadataText = ValueUpdate.Value("update"))
+ val pendingSelectionCallbackRepository = PendingSelectionCallbackRepository()
+ val updateTargetIntentInteractor =
+ UpdateTargetIntentInteractor(
+ repository = pendingSelectionCallbackRepository,
+ chooserRequestInteractor =
+ UpdateChooserRequestInteractor(
+ repository = chooserRequestRepository,
+ pendingIntentSender = pendingIntentSender,
+ )
+ )
+ val processTargetIntentUpdatesInteractor =
+ ProcessTargetIntentUpdatesInteractor(
+ selectionCallback = { selectionCallbackResult },
+ repository = pendingSelectionCallbackRepository,
+ chooserRequestInteractor =
+ UpdateChooserRequestInteractor(
+ repository = chooserRequestRepository,
+ pendingIntentSender = pendingIntentSender,
+ )
+ )
+
+ backgroundScope.launch { processTargetIntentUpdatesInteractor.activate() }
+
+ updateTargetIntentInteractor.updateTargetIntent(Intent())
+ runCurrent()
+
+ assertThat(pendingSelectionCallbackRepository.pendingTargetIntent.value).isNull()
+ assertThat(chooserRequestRepository.chooserRequest.value.metadataText).isEqualTo("update")
+ }
+}
diff --git a/tests/unit/src/com/android/intentresolver/contentpreview/SelectionChangeCallbackTest.kt b/tests/unit/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/update/SelectionChangeCallbackImplTest.kt
index 40f2ab26..55b32509 100644
--- a/tests/unit/src/com/android/intentresolver/contentpreview/SelectionChangeCallbackTest.kt
+++ b/tests/unit/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/update/SelectionChangeCallbackImplTest.kt
@@ -14,7 +14,7 @@
* limitations under the License.
*/
-package com.android.intentresolver.contentpreview
+package com.android.intentresolver.contentpreview.payloadtoggle.domain.update
import android.app.PendingIntent
import android.content.ComponentName
@@ -27,8 +27,10 @@ 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.Intent.EXTRA_STREAM
import android.graphics.drawable.Icon
import android.net.Uri
@@ -36,32 +38,44 @@ import android.os.Bundle
import android.service.chooser.AdditionalContentContract.MethodNames.ON_SELECTION_CHANGED
import android.service.chooser.ChooserAction
import android.service.chooser.ChooserTarget
+import android.service.chooser.Flags
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
import com.android.intentresolver.any
import com.android.intentresolver.argumentCaptor
import com.android.intentresolver.capture
+import com.android.intentresolver.contentpreview.payloadtoggle.domain.model.ValueUpdate
+import com.android.intentresolver.contentpreview.payloadtoggle.domain.model.ValueUpdate.Absent
+import com.android.intentresolver.inject.FakeChooserServiceFlags
import com.android.intentresolver.mock
import com.android.intentresolver.whenever
import com.google.common.truth.Correspondence
import com.google.common.truth.Correspondence.BinaryPredicate
import com.google.common.truth.Truth.assertThat
import com.google.common.truth.Truth.assertWithMessage
+import java.lang.IllegalArgumentException
+import kotlinx.coroutines.test.runTest
import org.junit.Test
import org.junit.runner.RunWith
import org.mockito.Mockito.times
import org.mockito.Mockito.verify
@RunWith(AndroidJUnit4::class)
-class SelectionChangeCallbackTest {
+class SelectionChangeCallbackImplTest {
private val uri = Uri.parse("content://org.pkg/content-provider")
private val chooserIntent = Intent(ACTION_CHOOSER)
private val contentResolver = mock<ContentInterface>()
private val context = InstrumentationRegistry.getInstrumentation().context
+ private val flags =
+ FakeChooserServiceFlags().apply {
+ setFlag(Flags.FLAG_CHOOSER_PAYLOAD_TOGGLING, false)
+ setFlag(Flags.FLAG_CHOOSER_ALBUM_TEXT, false)
+ setFlag(Flags.FLAG_ENABLE_SHARESHEET_METADATA_EXTRA, false)
+ }
@Test
- fun testPayloadChangeCallbackContact() {
- val testSubject = SelectionChangeCallback(uri, chooserIntent, contentResolver)
+ fun testPayloadChangeCallbackContact() = runTest {
+ val testSubject = SelectionChangeCallbackImpl(uri, chooserIntent, contentResolver, flags)
val u1 = createUri(1)
val u2 = createUri(2)
@@ -127,7 +141,7 @@ class SelectionChangeCallbackTest {
}
@Test
- fun testPayloadChangeCallbackUpdatesCustomActions() {
+ fun testPayloadChangeCallbackUpdatesCustomActions() = runTest {
val a1 =
ChooserAction.Builder(
Icon.createWithContentUri(createUri(10)),
@@ -157,25 +171,27 @@ class SelectionChangeCallbackTest {
Bundle().apply { putParcelableArray(EXTRA_CHOOSER_CUSTOM_ACTIONS, arrayOf(a1, a2)) }
)
- val testSubject = SelectionChangeCallback(uri, chooserIntent, contentResolver)
+ val testSubject = SelectionChangeCallbackImpl(uri, chooserIntent, contentResolver, flags)
val targetIntent = Intent(ACTION_SEND_MULTIPLE)
val result = testSubject.onSelectionChanged(targetIntent)
assertWithMessage("Callback result should not be null").that(result).isNotNull()
requireNotNull(result)
assertWithMessage("Unexpected custom actions")
- .that(result.customActions?.map { it.icon to it.label })
+ .that(result.customActions.getOrThrow().map { it.icon to it.label })
.containsExactly(a1.icon to a1.label, a2.icon to a2.label)
.inOrder()
- assertThat(result.modifyShareAction).isNull()
- assertThat(result.alternateIntents).isNull()
- assertThat(result.callerTargets).isNull()
- assertThat(result.refinementIntentSender).isNull()
+ assertThat(result.modifyShareAction).isEqualTo(Absent)
+ assertThat(result.alternateIntents).isEqualTo(Absent)
+ assertThat(result.callerTargets).isEqualTo(Absent)
+ assertThat(result.refinementIntentSender).isEqualTo(Absent)
+ assertThat(result.resultIntentSender).isEqualTo(Absent)
+ assertThat(result.metadataText).isEqualTo(Absent)
}
@Test
- fun testPayloadChangeCallbackUpdatesReselectionAction() {
+ fun testPayloadChangeCallbackUpdatesReselectionAction() = runTest {
val modifyShare =
ChooserAction.Builder(
Icon.createWithContentUri(createUri(10)),
@@ -193,27 +209,29 @@ class SelectionChangeCallbackTest {
Bundle().apply { putParcelable(EXTRA_CHOOSER_MODIFY_SHARE_ACTION, modifyShare) }
)
- val testSubject = SelectionChangeCallback(uri, chooserIntent, contentResolver)
+ val testSubject = SelectionChangeCallbackImpl(uri, chooserIntent, contentResolver, flags)
val targetIntent = Intent(ACTION_SEND)
val result = testSubject.onSelectionChanged(targetIntent)
assertWithMessage("Callback result should not be null").that(result).isNotNull()
requireNotNull(result)
assertWithMessage("Unexpected modify share action: wrong icon")
- .that(result.modifyShareAction?.icon)
+ .that(result.modifyShareAction.getOrThrow()?.icon)
.isEqualTo(modifyShare.icon)
assertWithMessage("Unexpected modify share action: wrong label")
- .that(result.modifyShareAction?.label)
+ .that(result.modifyShareAction.getOrThrow()?.label)
.isEqualTo(modifyShare.label)
- assertThat(result.customActions).isNull()
- assertThat(result.alternateIntents).isNull()
- assertThat(result.callerTargets).isNull()
- assertThat(result.refinementIntentSender).isNull()
+ assertThat(result.customActions).isEqualTo(Absent)
+ assertThat(result.alternateIntents).isEqualTo(Absent)
+ assertThat(result.callerTargets).isEqualTo(Absent)
+ assertThat(result.refinementIntentSender).isEqualTo(Absent)
+ assertThat(result.resultIntentSender).isEqualTo(Absent)
+ assertThat(result.metadataText).isEqualTo(Absent)
}
@Test
- fun testPayloadChangeCallbackUpdatesAlternateIntents() {
+ fun testPayloadChangeCallbackUpdatesAlternateIntents() = runTest {
val alternateIntents =
arrayOf(
Intent(ACTION_SEND_MULTIPLE).apply {
@@ -226,33 +244,35 @@ class SelectionChangeCallbackTest {
Bundle().apply { putParcelableArray(EXTRA_ALTERNATE_INTENTS, alternateIntents) }
)
- val testSubject = SelectionChangeCallback(uri, chooserIntent, contentResolver)
+ val testSubject = SelectionChangeCallbackImpl(uri, chooserIntent, contentResolver, flags)
val targetIntent = Intent(ACTION_SEND)
val result = testSubject.onSelectionChanged(targetIntent)
assertWithMessage("Callback result should not be null").that(result).isNotNull()
requireNotNull(result)
assertWithMessage("Wrong number of alternate intents")
- .that(result.alternateIntents)
+ .that(result.alternateIntents.getOrThrow())
.hasSize(1)
assertWithMessage("Wrong alternate intent: action")
- .that(result.alternateIntents?.get(0)?.action)
+ .that(result.alternateIntents.getOrThrow()[0].action)
.isEqualTo(alternateIntents[0].action)
assertWithMessage("Wrong alternate intent: categories")
- .that(result.alternateIntents?.get(0)?.categories)
+ .that(result.alternateIntents.getOrThrow()[0].categories)
.containsExactlyElementsIn(alternateIntents[0].categories)
assertWithMessage("Wrong alternate intent: mime type")
- .that(result.alternateIntents?.get(0)?.type)
+ .that(result.alternateIntents.getOrThrow()[0].type)
.isEqualTo(alternateIntents[0].type)
- assertThat(result.customActions).isNull()
- assertThat(result.modifyShareAction).isNull()
- assertThat(result.callerTargets).isNull()
- assertThat(result.refinementIntentSender).isNull()
+ assertThat(result.customActions).isEqualTo(Absent)
+ assertThat(result.modifyShareAction).isEqualTo(Absent)
+ assertThat(result.callerTargets).isEqualTo(Absent)
+ assertThat(result.refinementIntentSender).isEqualTo(Absent)
+ assertThat(result.resultIntentSender).isEqualTo(Absent)
+ assertThat(result.metadataText).isEqualTo(Absent)
}
@Test
- fun testPayloadChangeCallbackUpdatesCallerTargets() {
+ fun testPayloadChangeCallbackUpdatesCallerTargets() = runTest {
val t1 =
ChooserTarget(
"Target 1",
@@ -274,14 +294,14 @@ class SelectionChangeCallbackTest {
Bundle().apply { putParcelableArray(EXTRA_CHOOSER_TARGETS, arrayOf(t1, t2)) }
)
- val testSubject = SelectionChangeCallback(uri, chooserIntent, contentResolver)
+ val testSubject = SelectionChangeCallbackImpl(uri, chooserIntent, contentResolver, flags)
val targetIntent = Intent(ACTION_SEND)
val result = testSubject.onSelectionChanged(targetIntent)
assertWithMessage("Callback result should not be null").that(result).isNotNull()
requireNotNull(result)
assertWithMessage("Wrong caller targets")
- .that(result.callerTargets)
+ .that(result.callerTargets.getOrThrow())
.comparingElementsUsing(
Correspondence.from(
BinaryPredicate<ChooserTarget?, ChooserTarget> { actual, expected ->
@@ -296,14 +316,16 @@ class SelectionChangeCallbackTest {
.containsExactly(t1, t2)
.inOrder()
- assertThat(result.customActions).isNull()
- assertThat(result.modifyShareAction).isNull()
- assertThat(result.alternateIntents).isNull()
- assertThat(result.refinementIntentSender).isNull()
+ assertThat(result.customActions).isEqualTo(Absent)
+ assertThat(result.modifyShareAction).isEqualTo(Absent)
+ assertThat(result.alternateIntents).isEqualTo(Absent)
+ assertThat(result.refinementIntentSender).isEqualTo(Absent)
+ assertThat(result.resultIntentSender).isEqualTo(Absent)
+ assertThat(result.metadataText).isEqualTo(Absent)
}
@Test
- fun testPayloadChangeCallbackUpdatesRefinementIntentSender() {
+ fun testPayloadChangeCallbackUpdatesRefinementIntentSender() = runTest {
val broadcast =
PendingIntent.getBroadcast(context, 1, Intent("test"), PendingIntent.FLAG_IMMUTABLE)
@@ -314,21 +336,94 @@ class SelectionChangeCallbackTest {
}
)
- val testSubject = SelectionChangeCallback(uri, chooserIntent, contentResolver)
+ val testSubject = SelectionChangeCallbackImpl(uri, chooserIntent, contentResolver, flags)
val targetIntent = Intent(ACTION_SEND)
val result = testSubject.onSelectionChanged(targetIntent)
assertWithMessage("Callback result should not be null").that(result).isNotNull()
requireNotNull(result)
- assertThat(result.customActions).isNull()
- assertThat(result.modifyShareAction).isNull()
- assertThat(result.alternateIntents).isNull()
- assertThat(result.callerTargets).isNull()
- assertThat(result.refinementIntentSender).isNotNull()
+ assertThat(result.customActions).isEqualTo(Absent)
+ assertThat(result.modifyShareAction).isEqualTo(Absent)
+ assertThat(result.alternateIntents).isEqualTo(Absent)
+ assertThat(result.callerTargets).isEqualTo(Absent)
+ assertThat(result.refinementIntentSender.getOrThrow()).isNotNull()
+ assertThat(result.resultIntentSender).isEqualTo(Absent)
+ assertThat(result.metadataText).isEqualTo(Absent)
}
@Test
- fun testPayloadChangeCallbackProvidesInvalidData_invalidDataIgnored() {
+ fun testPayloadChangeCallbackUpdatesResultIntentSender() = runTest {
+ val broadcast =
+ PendingIntent.getBroadcast(context, 1, Intent("test"), PendingIntent.FLAG_IMMUTABLE)
+
+ whenever(contentResolver.call(any<String>(), any(), any(), any()))
+ .thenReturn(
+ Bundle().apply {
+ putParcelable(EXTRA_CHOOSER_RESULT_INTENT_SENDER, broadcast.intentSender)
+ }
+ )
+
+ val testSubject = SelectionChangeCallbackImpl(uri, chooserIntent, contentResolver, flags)
+
+ val targetIntent = Intent(ACTION_SEND)
+ val result = testSubject.onSelectionChanged(targetIntent)
+ assertWithMessage("Callback result should not be null").that(result).isNotNull()
+ requireNotNull(result)
+ assertThat(result.customActions).isEqualTo(Absent)
+ assertThat(result.modifyShareAction).isEqualTo(Absent)
+ assertThat(result.alternateIntents).isEqualTo(Absent)
+ assertThat(result.callerTargets).isEqualTo(Absent)
+ assertThat(result.refinementIntentSender).isEqualTo(Absent)
+ assertThat(result.resultIntentSender.getOrThrow()).isNotNull()
+ assertThat(result.metadataText).isEqualTo(Absent)
+ }
+
+ @Test
+ fun testPayloadChangeCallbackUpdatesMetadataTextWithDisabledFlag_noUpdates() = runTest {
+ val metadataText = "[Metadata]"
+ whenever(contentResolver.call(any<String>(), any(), any(), any()))
+ .thenReturn(Bundle().apply { putCharSequence(EXTRA_METADATA_TEXT, metadataText) })
+
+ val testSubject = SelectionChangeCallbackImpl(uri, chooserIntent, contentResolver, flags)
+
+ val targetIntent = Intent(ACTION_SEND)
+ val result = testSubject.onSelectionChanged(targetIntent)
+ assertWithMessage("Callback result should not be null").that(result).isNotNull()
+ requireNotNull(result)
+ assertThat(result.customActions).isEqualTo(Absent)
+ assertThat(result.modifyShareAction).isEqualTo(Absent)
+ assertThat(result.alternateIntents).isEqualTo(Absent)
+ assertThat(result.callerTargets).isEqualTo(Absent)
+ assertThat(result.refinementIntentSender).isEqualTo(Absent)
+ assertThat(result.resultIntentSender).isEqualTo(Absent)
+ assertThat(result.metadataText).isEqualTo(Absent)
+ }
+
+ @Test
+ fun testPayloadChangeCallbackUpdatesMetadataTextWithEnabledFlag_valueUpdated() = runTest {
+ val metadataText = "[Metadata]"
+ flags.setFlag(Flags.FLAG_ENABLE_SHARESHEET_METADATA_EXTRA, true)
+ whenever(contentResolver.call(any<String>(), any(), any(), any()))
+ .thenReturn(Bundle().apply { putCharSequence(EXTRA_METADATA_TEXT, metadataText) })
+
+ val testSubject = SelectionChangeCallbackImpl(uri, chooserIntent, contentResolver, flags)
+
+ val targetIntent = Intent(ACTION_SEND)
+ val result = testSubject.onSelectionChanged(targetIntent)
+ assertWithMessage("Callback result should not be null").that(result).isNotNull()
+ requireNotNull(result)
+ assertThat(result.customActions).isEqualTo(Absent)
+ assertThat(result.modifyShareAction).isEqualTo(Absent)
+ assertThat(result.alternateIntents).isEqualTo(Absent)
+ assertThat(result.callerTargets).isEqualTo(Absent)
+ assertThat(result.refinementIntentSender).isEqualTo(Absent)
+ assertThat(result.resultIntentSender).isEqualTo(Absent)
+ assertThat(result.metadataText.getOrThrow()).isEqualTo(metadataText)
+ }
+
+ @Test
+ fun testPayloadChangeCallbackProvidesInvalidData_invalidDataIgnored() = runTest {
+ flags.setFlag(Flags.FLAG_ENABLE_SHARESHEET_METADATA_EXTRA, true)
whenever(contentResolver.call(any<String>(), any(), any(), any()))
.thenReturn(
Bundle().apply {
@@ -337,21 +432,31 @@ class SelectionChangeCallbackTest {
putParcelableArrayList(EXTRA_ALTERNATE_INTENTS, ArrayList<Intent>())
putParcelableArrayList(EXTRA_CHOOSER_TARGETS, ArrayList<ChooserTarget>())
putParcelable(EXTRA_CHOOSER_REFINEMENT_INTENT_SENDER, createUri(2))
+ putParcelable(EXTRA_CHOOSER_RESULT_INTENT_SENDER, createUri(1))
+ putInt(EXTRA_METADATA_TEXT, 123)
}
)
- val testSubject = SelectionChangeCallback(uri, chooserIntent, contentResolver)
+ val testSubject = SelectionChangeCallbackImpl(uri, chooserIntent, contentResolver, flags)
val targetIntent = Intent(ACTION_SEND)
val result = testSubject.onSelectionChanged(targetIntent)
assertWithMessage("Callback result should not be null").that(result).isNotNull()
requireNotNull(result)
- assertThat(result.customActions).isNull()
- assertThat(result.modifyShareAction).isNull()
- assertThat(result.alternateIntents).isNull()
- assertThat(result.callerTargets).isNull()
- assertThat(result.refinementIntentSender).isNull()
+ assertThat(result.customActions.getOrThrow()).isEmpty()
+ assertThat(result.modifyShareAction.getOrThrow()).isNull()
+ assertThat(result.alternateIntents.getOrThrow()).isEmpty()
+ assertThat(result.callerTargets.getOrThrow()).isEmpty()
+ assertThat(result.refinementIntentSender.getOrThrow()).isNull()
+ assertThat(result.resultIntentSender.getOrThrow()).isNull()
+ assertThat(result.metadataText.getOrThrow()).isNull()
}
}
+private fun <T> ValueUpdate<T>.getOrThrow(): T =
+ when (this) {
+ is ValueUpdate.Value -> value
+ else -> throw IllegalArgumentException("Value is expected")
+ }
+
private fun createUri(id: Int) = Uri.parse("content://org.pkg.images/$id.png")
diff --git a/tests/unit/src/com/android/intentresolver/contentpreview/payloadtoggle/ui/viewmodel/ShareouselViewModelTest.kt b/tests/unit/src/com/android/intentresolver/contentpreview/payloadtoggle/ui/viewmodel/ShareouselViewModelTest.kt
new file mode 100644
index 00000000..5d95df04
--- /dev/null
+++ b/tests/unit/src/com/android/intentresolver/contentpreview/payloadtoggle/ui/viewmodel/ShareouselViewModelTest.kt
@@ -0,0 +1,305 @@
+/*
+ * Copyright (C) 2024 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+@file:OptIn(ExperimentalCoroutinesApi::class)
+
+package com.android.intentresolver.contentpreview.payloadtoggle.ui.viewmodel
+
+import android.app.Activity
+import android.content.ContentResolver
+import android.content.Intent
+import android.content.pm.PackageManager
+import android.graphics.Bitmap
+import android.graphics.drawable.Icon
+import android.net.Uri
+import com.android.intentresolver.FakeImageLoader
+import com.android.intentresolver.contentpreview.HeadlineGenerator
+import com.android.intentresolver.contentpreview.payloadtoggle.data.model.CustomActionModel
+import com.android.intentresolver.contentpreview.payloadtoggle.data.repository.ActivityResultRepository
+import com.android.intentresolver.contentpreview.payloadtoggle.data.repository.CursorPreviewsRepository
+import com.android.intentresolver.contentpreview.payloadtoggle.data.repository.PendingSelectionCallbackRepository
+import com.android.intentresolver.contentpreview.payloadtoggle.data.repository.PreviewSelectionsRepository
+import com.android.intentresolver.contentpreview.payloadtoggle.domain.intent.PendingIntentSender
+import com.android.intentresolver.contentpreview.payloadtoggle.domain.intent.TargetIntentModifier
+import com.android.intentresolver.contentpreview.payloadtoggle.domain.interactor.ChooserRequestInteractor
+import com.android.intentresolver.contentpreview.payloadtoggle.domain.interactor.CustomActionsInteractor
+import com.android.intentresolver.contentpreview.payloadtoggle.domain.interactor.SelectablePreviewsInteractor
+import com.android.intentresolver.contentpreview.payloadtoggle.domain.interactor.SelectionInteractor
+import com.android.intentresolver.contentpreview.payloadtoggle.domain.interactor.UpdateChooserRequestInteractor
+import com.android.intentresolver.contentpreview.payloadtoggle.domain.interactor.UpdateTargetIntentInteractor
+import com.android.intentresolver.contentpreview.payloadtoggle.shared.model.PreviewModel
+import com.android.intentresolver.contentpreview.payloadtoggle.shared.model.PreviewsModel
+import com.android.intentresolver.icon.BitmapIcon
+import com.android.intentresolver.logging.FakeEventLog
+import com.android.intentresolver.mock
+import com.android.intentresolver.util.comparingElementsUsingTransform
+import com.android.intentresolver.v2.data.model.fakeChooserRequest
+import com.android.intentresolver.v2.data.repository.ChooserRequestRepository
+import com.android.internal.logging.InstanceId
+import com.google.common.truth.Truth.assertThat
+import com.google.common.truth.Truth.assertWithMessage
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.flow.first
+import kotlinx.coroutines.test.StandardTestDispatcher
+import kotlinx.coroutines.test.TestScope
+import kotlinx.coroutines.test.runCurrent
+import kotlinx.coroutines.test.runTest
+import org.junit.Test
+
+class ShareouselViewModelTest {
+
+ class Dependencies(
+ val pendingIntentSender: PendingIntentSender,
+ val targetIntentModifier: TargetIntentModifier<PreviewModel>,
+ ) {
+ val testDispatcher = StandardTestDispatcher()
+ val testScope = TestScope(testDispatcher)
+ val previewsRepository = CursorPreviewsRepository()
+ val selectionRepository =
+ PreviewSelectionsRepository().apply {
+ selections.value =
+ setOf(PreviewModel(Uri.fromParts("scheme", "ssp", "fragment"), null))
+ }
+ val activityResultRepository = ActivityResultRepository()
+ val contentResolver = mock<ContentResolver> {}
+ val packageManager = mock<PackageManager> {}
+ val eventLog = FakeEventLog(instanceId = InstanceId.fakeInstanceId(1))
+ val chooserRequestRepo =
+ ChooserRequestRepository(
+ initialRequest = fakeChooserRequest(),
+ initialActions = emptyList(),
+ )
+ val pendingSelectionCallbackRepo = PendingSelectionCallbackRepository()
+
+ val actionsInteractor
+ get() =
+ CustomActionsInteractor(
+ activityResultRepo = activityResultRepository,
+ bgDispatcher = testDispatcher,
+ contentResolver = contentResolver,
+ eventLog = eventLog,
+ packageManager = packageManager,
+ chooserRequestInteractor = chooserRequestInteractor,
+ )
+
+ val selectionInteractor
+ get() =
+ SelectionInteractor(
+ selectionsRepo = selectionRepository,
+ targetIntentModifier = targetIntentModifier,
+ updateTargetIntentInteractor = updateTargetIntentInteractor,
+ )
+
+ val updateTargetIntentInteractor
+ get() =
+ UpdateTargetIntentInteractor(
+ repository = pendingSelectionCallbackRepo,
+ chooserRequestInteractor = updateChooserRequestInteractor,
+ )
+
+ val updateChooserRequestInteractor
+ get() =
+ UpdateChooserRequestInteractor(
+ repository = chooserRequestRepo,
+ pendingIntentSender = pendingIntentSender,
+ )
+
+ val chooserRequestInteractor
+ get() = ChooserRequestInteractor(repository = chooserRequestRepo)
+
+ val previewsInteractor
+ get() =
+ SelectablePreviewsInteractor(
+ previewsRepo = previewsRepository,
+ selectionInteractor = selectionInteractor,
+ )
+
+ val underTest =
+ ShareouselViewModelModule.create(
+ interactor = previewsInteractor,
+ imageLoader =
+ FakeImageLoader(
+ initialBitmaps =
+ mapOf(
+ Uri.fromParts("scheme1", "ssp1", "fragment1") to
+ Bitmap.createBitmap(100, 100, Bitmap.Config.ALPHA_8)
+ )
+ ),
+ actionsInteractor = actionsInteractor,
+ headlineGenerator =
+ object : HeadlineGenerator {
+ override fun getImagesHeadline(count: Int): String = "IMAGES: $count"
+
+ override fun getTextHeadline(text: CharSequence): String =
+ error("not supported")
+
+ override fun getAlbumHeadline(): String = error("not supported")
+
+ override fun getImagesWithTextHeadline(
+ text: CharSequence,
+ count: Int
+ ): String = error("not supported")
+
+ override fun getVideosWithTextHeadline(
+ text: CharSequence,
+ count: Int
+ ): String = error("not supported")
+
+ override fun getFilesWithTextHeadline(
+ text: CharSequence,
+ count: Int
+ ): String = error("not supported")
+
+ override fun getVideosHeadline(count: Int): String = error("not supported")
+
+ override fun getFilesHeadline(count: Int): String = error("not supported")
+ },
+ selectionInteractor = selectionInteractor,
+ scope = testScope.backgroundScope,
+ )
+ }
+
+ private inline fun runTestWithDeps(
+ pendingIntentSender: PendingIntentSender = PendingIntentSender {},
+ targetIntentModifier: TargetIntentModifier<PreviewModel> = TargetIntentModifier {
+ error("unexpected invocation")
+ },
+ crossinline block: suspend TestScope.(Dependencies) -> Unit,
+ ): Unit =
+ Dependencies(pendingIntentSender, targetIntentModifier).run {
+ testScope.runTest {
+ runCurrent()
+ block(this@run)
+ }
+ }
+
+ @Test
+ fun headline() = runTestWithDeps { deps ->
+ with(deps) {
+ assertThat(underTest.headline.first()).isEqualTo("IMAGES: 1")
+ selectionRepository.selections.value =
+ setOf(
+ PreviewModel(
+ Uri.fromParts("scheme", "ssp", "fragment"),
+ null,
+ ),
+ PreviewModel(
+ Uri.fromParts("scheme1", "ssp1", "fragment1"),
+ null,
+ )
+ )
+ runCurrent()
+ assertThat(underTest.headline.first()).isEqualTo("IMAGES: 2")
+ }
+ }
+
+ @Test
+ fun previews() =
+ runTestWithDeps(targetIntentModifier = { Intent() }) { deps ->
+ with(deps) {
+ previewsRepository.previewsModel.value =
+ PreviewsModel(
+ previewModels =
+ setOf(
+ PreviewModel(
+ Uri.fromParts("scheme", "ssp", "fragment"),
+ null,
+ ),
+ PreviewModel(
+ Uri.fromParts("scheme1", "ssp1", "fragment1"),
+ null,
+ )
+ ),
+ startIdx = 1,
+ loadMoreLeft = null,
+ loadMoreRight = null,
+ )
+ runCurrent()
+
+ assertWithMessage("previewsKeys is null")
+ .that(underTest.previews.first())
+ .isNotNull()
+ assertThat(underTest.previews.first()!!.previewModels)
+ .comparingElementsUsingTransform("has uri of") { it: PreviewModel -> it.uri }
+ .containsExactly(
+ Uri.fromParts("scheme", "ssp", "fragment"),
+ Uri.fromParts("scheme1", "ssp1", "fragment1"),
+ )
+ .inOrder()
+
+ val previewVm =
+ underTest.preview(
+ PreviewModel(Uri.fromParts("scheme1", "ssp1", "fragment1"), null)
+ )
+
+ assertWithMessage("preview bitmap is null")
+ .that(previewVm.bitmap.first())
+ .isNotNull()
+ assertThat(previewVm.isSelected.first()).isFalse()
+
+ previewVm.setSelected(true)
+
+ assertThat(selectionRepository.selections.value)
+ .comparingElementsUsingTransform("has uri of") { model: PreviewModel ->
+ model.uri
+ }
+ .contains(Uri.fromParts("scheme1", "ssp1", "fragment1"))
+ }
+ }
+
+ @Test
+ fun actions() {
+ runTestWithDeps { deps ->
+ with(deps) {
+ assertThat(underTest.actions.first()).isEmpty()
+
+ val bitmap = Bitmap.createBitmap(100, 100, Bitmap.Config.ALPHA_8)
+ val icon = Icon.createWithBitmap(bitmap)
+ var actionSent = false
+ chooserRequestRepo.customActions.value =
+ listOf(
+ CustomActionModel(
+ label = "label1",
+ icon = icon,
+ performAction = { actionSent = true },
+ )
+ )
+ runCurrent()
+
+ assertThat(underTest.actions.first())
+ .comparingElementsUsingTransform("has a label of") { vm: ActionChipViewModel ->
+ vm.label
+ }
+ .containsExactly("label1")
+ .inOrder()
+ assertThat(underTest.actions.first())
+ .comparingElementsUsingTransform("has an icon of") { vm: ActionChipViewModel ->
+ vm.icon
+ }
+ .containsExactly(BitmapIcon(icon.bitmap))
+ .inOrder()
+
+ underTest.actions.first()[0].onClicked()
+
+ assertThat(actionSent).isTrue()
+ assertThat(eventLog.customActionSelected)
+ .isEqualTo(FakeEventLog.CustomActionSelected(0))
+ assertThat(activityResultRepository.activityResult.value)
+ .isEqualTo(Activity.RESULT_OK)
+ }
+ }
+ }
+}
diff --git a/tests/unit/src/com/android/intentresolver/model/AbstractResolverComparatorTest.java b/tests/unit/src/com/android/intentresolver/model/AbstractResolverComparatorTest.java
index 2140a67d..5cec9734 100644
--- a/tests/unit/src/com/android/intentresolver/model/AbstractResolverComparatorTest.java
+++ b/tests/unit/src/com/android/intentresolver/model/AbstractResolverComparatorTest.java
@@ -25,7 +25,7 @@ import android.content.pm.ActivityInfo;
import android.content.pm.ResolveInfo;
import android.os.Message;
-import androidx.test.InstrumentationRegistry;
+import androidx.test.platform.app.InstrumentationRegistry;
import com.android.intentresolver.ResolvedComponentInfo;
import com.android.intentresolver.chooser.TargetInfo;
@@ -47,7 +47,7 @@ public class AbstractResolverComparatorTest {
ResolvedComponentInfo r2 = createResolvedComponentInfo(
new ComponentName("zackage", "zlass"));
- Context context = InstrumentationRegistry.getTargetContext();
+ Context context = InstrumentationRegistry.getInstrumentation().getTargetContext();
AbstractResolverComparator comparator = getTestComparator(context, null);
assertEquals("Pinned ranks over unpinned", -1, comparator.compare(r1, r2));
@@ -64,7 +64,7 @@ public class AbstractResolverComparatorTest {
new ComponentName("zackage", "zlass"));
r2.setPinned(true);
- Context context = InstrumentationRegistry.getTargetContext();
+ Context context = InstrumentationRegistry.getInstrumentation().getTargetContext();
AbstractResolverComparator comparator = getTestComparator(context, null);
assertEquals("Both pinned should rank alphabetically", -1, comparator.compare(r1, r2));
@@ -78,7 +78,7 @@ public class AbstractResolverComparatorTest {
ResolvedComponentInfo r2 = createResolvedComponentInfo(
new ComponentName("package", "class"));
- Context context = InstrumentationRegistry.getTargetContext();
+ Context context = InstrumentationRegistry.getInstrumentation().getTargetContext();
AbstractResolverComparator comparator = getTestComparator(context, promoteToFirst);
assertEquals("PromoteToFirst ranks over non-cemented", -1, comparator.compare(r1, r2));
@@ -94,7 +94,7 @@ public class AbstractResolverComparatorTest {
new ComponentName("package", "class"));
r2.setPinned(true);
- Context context = InstrumentationRegistry.getTargetContext();
+ Context context = InstrumentationRegistry.getInstrumentation().getTargetContext();
AbstractResolverComparator comparator = getTestComparator(context, cementedComponent);
assertEquals("PromoteToFirst ranks over pinned", -1, comparator.compare(r1, r2));
diff --git a/tests/unit/src/com/android/intentresolver/util/TruthUtils.kt b/tests/unit/src/com/android/intentresolver/util/TruthUtils.kt
new file mode 100644
index 00000000..b96b6f05
--- /dev/null
+++ b/tests/unit/src/com/android/intentresolver/util/TruthUtils.kt
@@ -0,0 +1,26 @@
+/*
+ * Copyright (C) 2024 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.intentresolver.util
+
+import com.google.common.truth.Correspondence
+import com.google.common.truth.IterableSubject
+
+fun <A, B> IterableSubject.comparingElementsUsingTransform(
+ description: String,
+ function: (A) -> B,
+): IterableSubject.UsingCorrespondence<A, B> =
+ comparingElementsUsing(Correspondence.transforming(function, description))
diff --git a/tests/unit/src/com/android/intentresolver/v2/ChooserActionFactoryTest.kt b/tests/unit/src/com/android/intentresolver/v2/ChooserActionFactoryTest.kt
index 95e4c377..8c55ffa5 100644
--- a/tests/unit/src/com/android/intentresolver/v2/ChooserActionFactoryTest.kt
+++ b/tests/unit/src/com/android/intentresolver/v2/ChooserActionFactoryTest.kt
@@ -46,7 +46,6 @@ import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
-import org.mockito.ArgumentMatchers.eq
import org.mockito.Mockito.eq
import org.mockito.Mockito.times
import org.mockito.Mockito.verify
@@ -57,7 +56,6 @@ class ChooserActionFactoryTest {
private val logger = mock<EventLog>()
private val actionLabel = "Action label"
- private val modifyShareLabel = "Modify share"
private val testAction = "com.android.intentresolver.testaction"
private val countdown = CountDownLatch(1)
private val testReceiver: BroadcastReceiver =
@@ -105,26 +103,6 @@ class ChooserActionFactoryTest {
}
@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()
-
- verify(logger).logActionSelected(eq(EventLog.SELECTION_TYPE_MODIFY_SHARE))
- assertEquals(Activity.RESULT_OK, resultConsumer.latestReturn)
- // Verify the pending intent has been called
- assertTrue("Timed out waiting for broadcast", countdown.await(2500, TimeUnit.MILLISECONDS))
- }
-
- @Test
fun nonSendAction_noCopyRunnable() {
val targetIntent =
Intent(Intent.ACTION_SEND_MULTIPLE).apply {
@@ -142,7 +120,6 @@ class ChooserActionFactoryTest {
/* targetIntent = */ chooserRequest.targetIntent,
/* referrerPackageName = */ chooserRequest.referrerPackageName,
/* chooserActions = */ chooserRequest.chooserActions,
- /* modifyShareAction = */ chooserRequest.modifyShareAction,
/* imageEditor = */ Optional.empty(),
/* log = */ logger,
/* onUpdateSharedTextIsExcluded = */ {},
@@ -170,7 +147,6 @@ class ChooserActionFactoryTest {
/* targetIntent = */ chooserRequest.targetIntent,
/* referrerPackageName = */ chooserRequest.referrerPackageName,
/* chooserActions = */ chooserRequest.chooserActions,
- /* modifyShareAction = */ chooserRequest.modifyShareAction,
/* imageEditor = */ Optional.empty(),
/* log = */ logger,
/* onUpdateSharedTextIsExcluded = */ {},
@@ -200,7 +176,6 @@ class ChooserActionFactoryTest {
/* targetIntent = */ chooserRequest.targetIntent,
/* referrerPackageName = */ chooserRequest.referrerPackageName,
/* chooserActions = */ chooserRequest.chooserActions,
- /* modifyShareAction = */ chooserRequest.modifyShareAction,
/* imageEditor = */ Optional.empty(),
/* log = */ logger,
/* onUpdateSharedTextIsExcluded = */ {},
@@ -217,7 +192,7 @@ class ChooserActionFactoryTest {
verify(resultSender, times(1)).onActionSelected(ShareAction.SYSTEM_COPY)
}
- private fun createFactory(includeModifyShare: Boolean = false): ChooserActionFactory {
+ private fun createFactory(): ChooserActionFactory {
val testPendingIntent =
PendingIntent.getBroadcast(context, 0, Intent(testAction), PendingIntent.FLAG_IMMUTABLE)
val targetIntent = Intent()
@@ -232,23 +207,11 @@ class ChooserActionFactoryTest {
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 = */ context,
/* targetIntent = */ chooserRequest.targetIntent,
/* referrerPackageName = */ chooserRequest.referrerPackageName,
/* chooserActions = */ chooserRequest.chooserActions,
- /* modifyShareAction = */ chooserRequest.modifyShareAction,
/* imageEditor = */ Optional.empty(),
/* log = */ logger,
/* onUpdateSharedTextIsExcluded = */ {},
diff --git a/tests/unit/src/com/android/intentresolver/v2/ChooserMutableActionFactoryTest.kt b/tests/unit/src/com/android/intentresolver/v2/ChooserMutableActionFactoryTest.kt
deleted file mode 100644
index ec2b807d..00000000
--- a/tests/unit/src/com/android/intentresolver/v2/ChooserMutableActionFactoryTest.kt
+++ /dev/null
@@ -1,139 +0,0 @@
-/*
- * 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.v2
-
-import android.app.PendingIntent
-import android.content.Intent
-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.android.intentresolver.ChooserRequestParameters
-import com.android.intentresolver.logging.EventLog
-import com.android.intentresolver.mock
-import com.android.intentresolver.whenever
-import com.google.common.collect.ImmutableList
-import com.google.common.truth.Truth.assertWithMessage
-import java.util.Optional
-import java.util.function.Consumer
-import kotlinx.coroutines.flow.first
-import kotlinx.coroutines.test.TestScope
-import kotlinx.coroutines.test.runTest
-import org.junit.Test
-import org.junit.runner.RunWith
-
-@RunWith(AndroidJUnit4::class)
-class ChooserMutableActionFactoryTest {
- private val context
- get() = InstrumentationRegistry.getInstrumentation().context
-
- private val logger = mock<EventLog>()
- private val testAction = "com.android.intentresolver.testaction"
- private val resultConsumer =
- object : Consumer<Int> {
- var latestReturn = Integer.MIN_VALUE
-
- override fun accept(resultCode: Int) {
- latestReturn = resultCode
- }
- }
-
- private val scope = TestScope()
-
- @Test
- fun testInitialValue() =
- scope.runTest {
- val actions = createChooserActions(2)
- val actionFactory = createFactory(actions)
- val testSubject = ChooserMutableActionFactory(actionFactory)
-
- val createdActions = testSubject.createCustomActions()
- val observedActions = testSubject.customActionsFlow.first()
-
- assertWithMessage("Unexpected actions")
- .that(createdActions.map { it.label })
- .containsExactlyElementsIn(actions.map { it.label })
- .inOrder()
- assertWithMessage("Initially created and initially observed actions should be the same")
- .that(createdActions)
- .containsExactlyElementsIn(observedActions)
- .inOrder()
- }
-
- @Test
- fun testUpdateActions_newActionsPublished() =
- scope.runTest {
- val initialActions = createChooserActions(2)
- val updatedActions = createChooserActions(3)
- val actionFactory = createFactory(initialActions)
- val testSubject = ChooserMutableActionFactory(actionFactory)
-
- testSubject.updateCustomActions(updatedActions)
- val observedActions = testSubject.customActionsFlow.first()
-
- assertWithMessage("Unexpected updated actions")
- .that(observedActions.map { it.label })
- .containsAtLeastElementsIn(updatedActions.map { it.label })
- .inOrder()
- }
-
- private fun createFactory(actions: List<ChooserAction>): ChooserActionFactory {
- val targetIntent = Intent()
- val chooserRequest = mock<ChooserRequestParameters>()
- whenever(chooserRequest.targetIntent).thenReturn(targetIntent)
- whenever(chooserRequest.chooserActions).thenReturn(ImmutableList.copyOf(actions))
-
- return ChooserActionFactory(
- /* context = */ context,
- /* targetIntent = */ chooserRequest.targetIntent,
- /* referrerPackageName = */ chooserRequest.referrerPackageName,
- /* chooserActions = */ chooserRequest.chooserActions,
- /* modifyShareAction = */ chooserRequest.modifyShareAction,
- /* imageEditor = */ Optional.empty(),
- /* log = */ logger,
- /* onUpdateSharedTextIsExcluded = */ {},
- /* firstVisibleImageQuery = */ { null },
- /* activityStarter = */ mock(),
- /* shareResultSender = */ null,
- /* finishCallback = */ resultConsumer,
- mock()
- )
- }
-
- private fun createChooserActions(count: Int): List<ChooserAction> {
- return buildList(count) {
- for (i in 1..count) {
- val testPendingIntent =
- PendingIntent.getBroadcast(
- context,
- i,
- Intent(testAction),
- PendingIntent.FLAG_IMMUTABLE
- )
- val action =
- ChooserAction.Builder(
- Icon.createWithResource("", Resources.ID_NULL),
- "Label $i",
- testPendingIntent
- )
- .build()
- add(action)
- }
- }
- }
-}
diff --git a/tests/unit/src/com/android/intentresolver/v2/ProfileAvailabilityTest.kt b/tests/unit/src/com/android/intentresolver/v2/ProfileAvailabilityTest.kt
index b4df058c..9f2b3e0f 100644
--- a/tests/unit/src/com/android/intentresolver/v2/ProfileAvailabilityTest.kt
+++ b/tests/unit/src/com/android/intentresolver/v2/ProfileAvailabilityTest.kt
@@ -16,7 +16,7 @@
package com.android.intentresolver.v2
-import android.util.Log
+import com.android.intentresolver.v2.annotation.JavaInterop
import com.android.intentresolver.v2.data.repository.FakeUserRepository
import com.android.intentresolver.v2.domain.interactor.UserInteractor
import com.android.intentresolver.v2.shared.model.Profile
@@ -27,9 +27,7 @@ import kotlinx.coroutines.test.runCurrent
import kotlinx.coroutines.test.runTest
import org.junit.Test
-private const val TAG = "ProfileAvailabilityTest"
-
-@OptIn(ExperimentalCoroutinesApi::class)
+@OptIn(ExperimentalCoroutinesApi::class, JavaInterop::class)
class ProfileAvailabilityTest {
private val personalUser = User(0, User.Role.PERSONAL)
private val workUser = User(10, User.Role.WORK)
@@ -42,7 +40,7 @@ class ProfileAvailabilityTest {
@Test
fun testProfileAvailable() = runTest {
- val availability = ProfileAvailability(backgroundScope, interactor)
+ val availability = ProfileAvailability(backgroundScope, interactor, mapOf())
runCurrent()
assertThat(availability.isAvailable(personalProfile)).isTrue()
@@ -61,7 +59,7 @@ class ProfileAvailabilityTest {
@Test
fun waitingToEnableProfile() = runTest {
- val availability = ProfileAvailability(backgroundScope, interactor)
+ val availability = ProfileAvailability(backgroundScope, interactor, mapOf())
runCurrent()
availability.requestQuietModeState(workProfile, true)
@@ -75,4 +73,4 @@ class ProfileAvailabilityTest {
assertThat(availability.waitingToEnableProfile).isFalse()
}
-} \ No newline at end of file
+}
diff --git a/tests/unit/src/com/android/intentresolver/v2/ProfileHelperTest.kt b/tests/unit/src/com/android/intentresolver/v2/ProfileHelperTest.kt
index 9cbbfcd8..cb4b1d0a 100644
--- a/tests/unit/src/com/android/intentresolver/v2/ProfileHelperTest.kt
+++ b/tests/unit/src/com/android/intentresolver/v2/ProfileHelperTest.kt
@@ -20,6 +20,7 @@ import com.android.intentresolver.Flags.FLAG_ENABLE_PRIVATE_PROFILE
import com.android.intentresolver.inject.FakeChooserServiceFlags
import com.android.intentresolver.inject.FakeIntentResolverFlags
import com.android.intentresolver.inject.IntentResolverFlags
+import com.android.intentresolver.v2.annotation.JavaInterop
import com.android.intentresolver.v2.data.repository.FakeUserRepository
import com.android.intentresolver.v2.domain.interactor.UserInteractor
import com.android.intentresolver.v2.shared.model.Profile
@@ -32,6 +33,7 @@ import org.junit.Assert.*
import org.junit.Test
+@OptIn(JavaInterop::class)
class ProfileHelperTest {
private val personalUser = User(0, User.Role.PERSONAL)
@@ -93,7 +95,6 @@ class ProfileHelperTest {
fun launchedByPersonal() = runTest {
val repository = FakeUserRepository(listOf(personalUser))
val interactor = UserInteractor(repository, launchedAs = personalUser.handle)
- val availability = interactor.availability.first()
val launchedBy = interactor.launchedAsProfile.first()
val helper = ProfileHelper(
@@ -115,7 +116,6 @@ class ProfileHelperTest {
fun launchedByPersonal_withClone() = runTest {
val repository = FakeUserRepository(listOf(personalUser, cloneUser))
val interactor = UserInteractor(repository, launchedAs = personalUser.handle)
- val availability = interactor.availability.first()
val launchedBy = interactor.launchedAsProfile.first()
val helper = ProfileHelper(
@@ -136,7 +136,6 @@ class ProfileHelperTest {
fun launchedByClone() = runTest {
val repository = FakeUserRepository(listOf(personalUser, cloneUser))
val interactor = UserInteractor(repository, launchedAs = cloneUser.handle)
- val availability = interactor.availability.first()
val launchedBy = interactor.launchedAsProfile.first()
val helper = ProfileHelper(
@@ -159,7 +158,6 @@ class ProfileHelperTest {
fun launchedByPersonal_withWork() = runTest {
val repository = FakeUserRepository(listOf(personalUser, workUser))
val interactor = UserInteractor(repository, launchedAs = personalUser.handle)
- val availability = interactor.availability.first()
val launchedBy = interactor.launchedAsProfile.first()
val helper = ProfileHelper(
@@ -186,7 +184,6 @@ class ProfileHelperTest {
fun launchedByWork() = runTest {
val repository = FakeUserRepository(listOf(personalUser, workUser))
val interactor = UserInteractor(repository, launchedAs = workUser.handle)
- val availability = interactor.availability.first()
val launchedBy = interactor.launchedAsProfile.first()
val helper = ProfileHelper(
@@ -213,7 +210,6 @@ class ProfileHelperTest {
fun launchedByPersonal_withPrivate() = runTest {
val repository = FakeUserRepository(listOf(personalUser, privateUser))
val interactor = UserInteractor(repository, launchedAs = personalUser.handle)
- val availability = interactor.availability.first()
val launchedBy = interactor.launchedAsProfile.first()
val helper = ProfileHelper(
@@ -239,7 +235,6 @@ class ProfileHelperTest {
fun launchedByPrivate() = runTest {
val repository = FakeUserRepository(listOf(personalUser, privateUser))
val interactor = UserInteractor(repository, launchedAs = privateUser.handle)
- val availability = interactor.availability.first()
val launchedBy = interactor.launchedAsProfile.first()
val helper = ProfileHelper(
@@ -268,7 +263,6 @@ class ProfileHelperTest {
val repository = FakeUserRepository(listOf(personalUser, privateUser))
val interactor = UserInteractor(repository, launchedAs = personalUser.handle)
- val availability = interactor.availability.first()
val launchedBy = interactor.launchedAsProfile.first()
val helper = ProfileHelper(
diff --git a/tests/unit/src/com/android/intentresolver/v2/data/model/FakeChooserRequest.kt b/tests/unit/src/com/android/intentresolver/v2/data/model/FakeChooserRequest.kt
new file mode 100644
index 00000000..559e3b77
--- /dev/null
+++ b/tests/unit/src/com/android/intentresolver/v2/data/model/FakeChooserRequest.kt
@@ -0,0 +1,26 @@
+/*
+ * Copyright (C) 2024 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.intentresolver.v2.data.model
+
+import android.content.Intent
+import android.net.Uri
+
+fun fakeChooserRequest(
+ intent: Intent = Intent(),
+ packageName: String = "pkg",
+ referrer: Uri? = null,
+) = ChooserRequest(intent, packageName, null)
diff --git a/tests/unit/src/com/android/intentresolver/v2/data/repository/UserRepositoryImplTest.kt b/tests/unit/src/com/android/intentresolver/v2/data/repository/UserRepositoryImplTest.kt
index 16e8c9bb..3fcc4c84 100644
--- a/tests/unit/src/com/android/intentresolver/v2/data/repository/UserRepositoryImplTest.kt
+++ b/tests/unit/src/com/android/intentresolver/v2/data/repository/UserRepositoryImplTest.kt
@@ -1,6 +1,5 @@
package com.android.intentresolver.v2.data.repository
-import android.content.Intent
import android.content.pm.UserInfo
import android.os.UserHandle
import android.os.UserHandle.SYSTEM
@@ -43,9 +42,10 @@ internal class UserRepositoryImplTest {
val users by collectLastValue(repo.users)
assertWithMessage("collectLastValue(repo.users)").that(users).isNotNull()
- assertThat(users!!.filter { it.role.type == User.Type.PROFILE }).isEmpty()
+ assertThat(users).hasSize(1)
val profile = userState.createProfile(ProfileType.WORK)
+ assertThat(users).hasSize(2)
assertThat(users).contains(User(profile.identifier, Role.WORK))
}
@@ -89,11 +89,11 @@ internal class UserRepositoryImplTest {
repo.requestState(privateUser, false)
repo.requestState(privateUser, true)
- assertWithMessage("users.size")
- .that(users?.size ?: 0).isEqualTo(2) // personal + private
+ assertWithMessage("users.size").that(users?.size ?: 0).isEqualTo(2) // personal + private
assertWithMessage("No duplicate IDs")
- .that(users?.count { it.id == private.identifier }).isEqualTo(1)
+ .that(users?.count { it.id == private.identifier })
+ .isEqualTo(1)
}
@Test
@@ -112,13 +112,6 @@ internal class UserRepositoryImplTest {
assertThat(available?.get(workUser)).isTrue()
}
- @Test(expected = IllegalArgumentException::class)
- fun requestState_invalidForFullUser() = runTest {
- val repo = createUserRepository(userManager)
- val primaryUser = User(userState.primaryUserHandle.identifier, Role.PERSONAL)
- repo.requestState(primaryUser, available = false)
- }
-
/**
* This and all the 'recovers_from_*' tests below all configure a static event flow instead of
* using [FakeUserManager]. These tests verify that a invalid broadcast causes the flow to
@@ -128,13 +121,7 @@ internal class UserRepositoryImplTest {
fun recovers_from_invalid_profile_added_event() = runTest {
val userManager =
mockUserManager(validUser = USER_SYSTEM, invalidUser = UserHandle.USER_NULL)
- val events =
- flowOf(
- UserRepositoryImpl.UserEvent(
- Intent.ACTION_PROFILE_ADDED,
- UserHandle.of(UserHandle.USER_NULL)
- )
- )
+ val events = flowOf(ProfileAdded(UserHandle.of(UserHandle.USER_NULL)))
val repo =
UserRepositoryImpl(
profileParent = SYSTEM,
@@ -153,13 +140,7 @@ internal class UserRepositoryImplTest {
fun recovers_from_invalid_profile_removed_event() = runTest {
val userManager =
mockUserManager(validUser = USER_SYSTEM, invalidUser = UserHandle.USER_NULL)
- val events =
- flowOf(
- UserRepositoryImpl.UserEvent(
- Intent.ACTION_PROFILE_REMOVED,
- UserHandle.of(UserHandle.USER_NULL)
- )
- )
+ val events = flowOf(ProfileRemoved(UserHandle.of(UserHandle.USER_NULL)))
val repo =
UserRepositoryImpl(
profileParent = SYSTEM,
@@ -178,13 +159,7 @@ internal class UserRepositoryImplTest {
fun recovers_from_invalid_profile_available_event() = runTest {
val userManager =
mockUserManager(validUser = USER_SYSTEM, invalidUser = UserHandle.USER_NULL)
- val events =
- flowOf(
- UserRepositoryImpl.UserEvent(
- Intent.ACTION_PROFILE_AVAILABLE,
- UserHandle.of(UserHandle.USER_NULL)
- )
- )
+ val events = flowOf(AvailabilityChange(UserHandle.of(UserHandle.USER_NULL)))
val repo =
UserRepositoryImpl(SYSTEM, userManager, events, backgroundScope, Dispatchers.Unconfined)
val users by collectLastValue(repo.users)
@@ -197,10 +172,7 @@ internal class UserRepositoryImplTest {
fun recovers_from_unknown_event() = runTest {
val userManager =
mockUserManager(validUser = USER_SYSTEM, invalidUser = UserHandle.USER_NULL)
- val events =
- flowOf(
- UserRepositoryImpl.UserEvent("UNKNOWN_EVENT", UserHandle.of(UserHandle.USER_NULL))
- )
+ val events = flowOf(UnknownEvent("UNKNOWN_EVENT"))
val repo =
UserRepositoryImpl(
profileParent = SYSTEM,
diff --git a/tests/unit/src/com/android/intentresolver/v2/ui/ShareResultSenderImplTest.kt b/tests/unit/src/com/android/intentresolver/v2/ui/ShareResultSenderImplTest.kt
index 371f9c26..d894cad5 100644
--- a/tests/unit/src/com/android/intentresolver/v2/ui/ShareResultSenderImplTest.kt
+++ b/tests/unit/src/com/android/intentresolver/v2/ui/ShareResultSenderImplTest.kt
@@ -23,7 +23,7 @@ import android.content.Intent
import android.os.Process
import android.service.chooser.ChooserResult
import android.service.chooser.Flags
-import androidx.test.InstrumentationRegistry
+import androidx.test.platform.app.InstrumentationRegistry
import com.android.intentresolver.inject.FakeChooserServiceFlags
import com.android.intentresolver.v2.ui.model.ShareAction
import com.google.common.truth.Truth.assertThat
diff --git a/tests/unit/src/com/android/intentresolver/v2/ui/viewmodel/ChooserRequestTest.kt b/tests/unit/src/com/android/intentresolver/v2/ui/viewmodel/ChooserRequestTest.kt
index d3b9f559..987d55fc 100644
--- a/tests/unit/src/com/android/intentresolver/v2/ui/viewmodel/ChooserRequestTest.kt
+++ b/tests/unit/src/com/android/intentresolver/v2/ui/viewmodel/ChooserRequestTest.kt
@@ -31,8 +31,8 @@ import androidx.core.net.toUri
import androidx.core.os.bundleOf
import com.android.intentresolver.ContentTypeHint
import com.android.intentresolver.inject.FakeChooserServiceFlags
+import com.android.intentresolver.v2.data.model.ChooserRequest
import com.android.intentresolver.v2.ui.model.ActivityModel
-import com.android.intentresolver.v2.ui.model.ChooserRequest
import com.android.intentresolver.v2.validation.Importance
import com.android.intentresolver.v2.validation.Invalid
import com.android.intentresolver.v2.validation.NoValue
@@ -126,10 +126,7 @@ class ChooserRequestTest {
fun payloadIntents_includesTargetThenAdditional() {
val intent1 = Intent(ACTION_SEND)
val intent2 = Intent(ACTION_SEND_MULTIPLE)
- val model = createActivityModel(
- targetIntent = intent1,
- additionalIntents = listOf(intent2)
- )
+ val model = createActivityModel(targetIntent = intent1, additionalIntents = listOf(intent2))
val result = readChooserRequest(model, fakeChooserServiceFlags)
@@ -229,7 +226,8 @@ class ChooserRequestTest {
fakeChooserServiceFlags.setFlag(Flags.FLAG_CHOOSER_PAYLOAD_TOGGLING, true)
val uri = Uri.parse("content://org.pkg/path")
val position = 10
- val model = createActivityModel(targetIntent = Intent(ACTION_VIEW)).apply {
+ val model =
+ createActivityModel(targetIntent = Intent(ACTION_VIEW)).apply {
intent.putExtra(EXTRA_CHOOSER_ADDITIONAL_CONTENT_URI, uri)
intent.putExtra(EXTRA_CHOOSER_FOCUSED_ITEM_POSITION, position)
}
@@ -266,7 +264,8 @@ class ChooserRequestTest {
fun metadataText_whenFlagFalse_isNull() {
fakeChooserServiceFlags.setFlag(Flags.FLAG_ENABLE_SHARESHEET_METADATA_EXTRA, false)
val metadataText: CharSequence = "Test metadata text"
- val model = createActivityModel(targetIntent = Intent()).apply {
+ val model =
+ createActivityModel(targetIntent = Intent()).apply {
intent.putExtra(Intent.EXTRA_METADATA_TEXT, metadataText)
}
@@ -283,7 +282,8 @@ class ChooserRequestTest {
// Arrange
fakeChooserServiceFlags.setFlag(Flags.FLAG_ENABLE_SHARESHEET_METADATA_EXTRA, true)
val metadataText: CharSequence = "Test metadata text"
- val model = createActivityModel(targetIntent = Intent()).apply {
+ val model =
+ createActivityModel(targetIntent = Intent()).apply {
intent.putExtra(Intent.EXTRA_METADATA_TEXT, metadataText)
}
diff --git a/tests/unit/src/com/android/intentresolver/v2/ui/viewmodel/ResolverRequestTest.kt b/tests/unit/src/com/android/intentresolver/v2/ui/viewmodel/ResolverRequestTest.kt
index 6f1ed853..f6475663 100644
--- a/tests/unit/src/com/android/intentresolver/v2/ui/viewmodel/ResolverRequestTest.kt
+++ b/tests/unit/src/com/android/intentresolver/v2/ui/viewmodel/ResolverRequestTest.kt
@@ -24,7 +24,6 @@ import androidx.core.os.bundleOf
import com.android.intentresolver.v2.ResolverActivity.PROFILE_WORK
import com.android.intentresolver.v2.shared.model.Profile.Type.WORK
import com.android.intentresolver.v2.ui.model.ActivityModel
-import com.android.intentresolver.v2.ui.model.ChooserRequest
import com.android.intentresolver.v2.ui.model.ResolverRequest
import com.android.intentresolver.v2.validation.Invalid
import com.android.intentresolver.v2.validation.UncaughtException