From cd3c804563ea58c83c3dc1287c30acb557099cc7 Mon Sep 17 00:00:00 2001 From: Mark Renouf Date: Sun, 25 Feb 2024 21:46:03 -0500 Subject: Make 'ShortcutSelectionLogic' Injectable. This adds a module to provide the shortcut per-app limit and the existing DeviceConfig flag used to control whether that limit is applied. Once fully integrated, tests should be updated to simply increase the limit by injecting a different value, instead of altering the device state by modfying DeviceConfig values. To complete, inject into the caller of ChooserListAdapter.addServiceResults, moving usage injecting this component to the caller to use. Bug: 300157408 Test: (this change does not yet affect live code) Flag: n/a Change-Id: I63a661ca304c8d5653992761f10983b5776d50dd --- .../intentresolver/ShortcutSelectionLogic.java | 17 +++-- .../intentresolver/v2/ui/ShortcutPolicyModule.kt | 83 ++++++++++++++++++++++ 2 files changed, 95 insertions(+), 5 deletions(-) create mode 100644 java/src/com/android/intentresolver/v2/ui/ShortcutPolicyModule.kt (limited to 'java/src') 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 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/v2/ui/ShortcutPolicyModule.kt b/java/src/com/android/intentresolver/v2/ui/ShortcutPolicyModule.kt new file mode 100644 index 00000000..9ed5f9dd --- /dev/null +++ b/java/src/com/android/intentresolver/v2/ui/ShortcutPolicyModule.kt @@ -0,0 +1,83 @@ +/* + * Copyright (C) 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF 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) + } +} \ No newline at end of file -- cgit v1.2.3-59-g8ed1b From d4d7e961cb7a21e02a40168e3911d69279d4ce1b Mon Sep 17 00:00:00 2001 From: Andrey Epin Date: Thu, 22 Feb 2024 10:23:37 -0800 Subject: Make ImageLoader injectable Do not inject ImageLoader anywhere by v2 activity tests. Rename TestPreviewImageLoader to FakeImageLoader for naming consistency. Bug: 302691505 Test: atest IntentResolver-tests-activity Test: atest IntentResolver-tests-unit Change-Id: I5630664eab6c9546d5de19fa7410184138d15602 --- .../contentpreview/ImageLoaderModule.kt | 44 ++ .../contentpreview/ImagePreviewImageLoader.kt | 34 ++ .../UnbundledChooserActivityTest.java | 8 +- .../v2/UnbundledChooserActivityTest.java | 76 ++-- .../com/android/intentresolver/FakeImageLoader.kt | 39 ++ .../intentresolver/TestPreviewImageLoader.kt | 33 -- .../contentpreview/ChooserContentPreviewUiTest.kt | 4 +- .../contentpreview/ImagePreviewImageLoaderTest.kt | 476 ++++++++++----------- 8 files changed, 391 insertions(+), 323 deletions(-) create mode 100644 java/src/com/android/intentresolver/contentpreview/ImageLoaderModule.kt create mode 100644 tests/shared/src/com/android/intentresolver/FakeImageLoader.kt delete mode 100644 tests/shared/src/com/android/intentresolver/TestPreviewImageLoader.kt (limited to 'java/src') 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/tests/activity/src/com/android/intentresolver/UnbundledChooserActivityTest.java b/tests/activity/src/com/android/intentresolver/UnbundledChooserActivityTest.java index f597d7f2..c7b41ce0 100644 --- a/tests/activity/src/com/android/intentresolver/UnbundledChooserActivityTest.java +++ b/tests/activity/src/com/android/intentresolver/UnbundledChooserActivityTest.java @@ -800,7 +800,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 resolvedComponentInfos = Arrays.asList( @@ -958,7 +958,7 @@ public class UnbundledChooserActivityTest { Intent sendIntent = createSendUriIntentWithPreview(uris); ChooserActivityOverrideData.getInstance().imageLoader = - new TestPreviewImageLoader(Collections.emptyMap()); + new FakeImageLoader(Collections.emptyMap()); List resolvedComponentInfos = createResolvedComponentsForTest(2); @@ -1076,7 +1076,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 resolvedComponentInfos = createResolvedComponentsForTest(2); setupResolverControllers(resolvedComponentInfos); @@ -3122,6 +3122,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/v2/UnbundledChooserActivityTest.java b/tests/activity/src/com/android/intentresolver/v2/UnbundledChooserActivityTest.java index b8113422..a7221c10 100644 --- a/tests/activity/src/com/android/intentresolver/v2/UnbundledChooserActivityTest.java +++ b/tests/activity/src/com/android/intentresolver/v2/UnbundledChooserActivityTest.java @@ -119,15 +119,16 @@ 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.inject.PackageManagerModule; import com.android.intentresolver.logging.EventLog; import com.android.intentresolver.logging.FakeEventLog; @@ -160,7 +161,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 +183,8 @@ import javax.inject.Inject; @UninstallModules({ AppPredictionModule.class, ImageEditorModule.class, - PackageManagerModule.class + PackageManagerModule.class, + ImageLoaderModule.class, }) public class UnbundledChooserActivityTest { @@ -239,6 +240,11 @@ public class UnbundledChooserActivityTest { @BindValue PackageManager mPackageManager; + 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 +263,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 +443,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 resolvedComponentInfos = createResolvedComponentsForTest(2); setupResolverControllers(resolvedComponentInfos); @@ -707,8 +715,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 resolvedComponentInfos = Arrays.asList( @@ -749,8 +756,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 +803,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 +846,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 resolvedComponentInfos = Arrays.asList( @@ -937,8 +940,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 resolvedComponentInfos = createResolvedComponentsForTest(2); @@ -962,8 +964,7 @@ public class UnbundledChooserActivityTest { uris.add(uri); Intent sendIntent = createSendUriIntentWithPreview(uris); - ChooserActivityOverrideData.getInstance().imageLoader = - createImageLoader(uri, createWideBitmap()); + mFakeImageLoader.setBitmap(uri, createWideBitmap()); List resolvedComponentInfos = createResolvedComponentsForTest(2); @@ -1000,8 +1001,6 @@ public class UnbundledChooserActivityTest { uris.add(uri); Intent sendIntent = createSendUriIntentWithPreview(uris); - ChooserActivityOverrideData.getInstance().imageLoader = - new TestPreviewImageLoader(Collections.emptyMap()); List resolvedComponentInfos = createResolvedComponentsForTest(2); @@ -1019,8 +1018,7 @@ public class UnbundledChooserActivityTest { ArrayList uris = new ArrayList<>(1); uris.add(uri); Intent sendIntent = createSendUriIntentWithPreview(uris); - ChooserActivityOverrideData.getInstance().imageLoader = - createImageLoader(uri, createBitmap()); + mFakeImageLoader.setBitmap(uri, createBitmap()); List resolvedComponentInfos = createResolvedComponentsForTest(2); @@ -1046,8 +1044,7 @@ public class UnbundledChooserActivityTest { } uris.add(imageUri); Intent sendIntent = createSendUriIntentWithPreview(uris); - ChooserActivityOverrideData.getInstance().imageLoader = - createImageLoader(imageUri, createBitmap()); + mFakeImageLoader.setBitmap(imageUri, createBitmap()); List resolvedComponentInfos = createResolvedComponentsForTest(2); setupResolverControllers(resolvedComponentInfos); @@ -1079,8 +1076,7 @@ public class UnbundledChooserActivityTest { uris.add(uri); Intent sendIntent = createSendUriIntentWithPreview(uris); - ChooserActivityOverrideData.getInstance().imageLoader = - createImageLoader(uri, createBitmap()); + mFakeImageLoader.setBitmap(uri, createBitmap()); List resolvedComponentInfos = createResolvedComponentsForTest(2); @@ -1114,12 +1110,9 @@ public class UnbundledChooserActivityTest { uris.add(docUri); Intent sendIntent = createSendUriIntentWithPreview(uris); - Map 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 resolvedComponentInfos = createResolvedComponentsForTest(2); setupResolverControllers(resolvedComponentInfos); @@ -1167,8 +1160,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 resolvedComponentInfos = createResolvedComponentsForTest(2); @@ -1197,8 +1189,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 resolvedComponentInfos = createResolvedComponentsForTest(2); @@ -1234,8 +1225,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 resolvedComponentInfos = createResolvedComponentsForTest(2); @@ -1331,8 +1321,7 @@ public class UnbundledChooserActivityTest { uris.add(uri); Intent sendIntent = createSendUriIntentWithPreview(uris); - ChooserActivityOverrideData.getInstance().imageLoader = - createImageLoader(uri, createBitmap()); + mFakeImageLoader.setBitmap(uri, createBitmap()); List resolvedComponentInfos = createResolvedComponentsForTest(2); @@ -2228,8 +2217,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(); @@ -3134,8 +3122,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/shared/src/com/android/intentresolver/FakeImageLoader.kt b/tests/shared/src/com/android/intentresolver/FakeImageLoader.kt new file mode 100644 index 00000000..c57ea78b --- /dev/null +++ b/tests/shared/src/com/android/intentresolver/FakeImageLoader.kt @@ -0,0 +1,39 @@ +/* + * Copyright (C) 2022 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.intentresolver + +import android.graphics.Bitmap +import android.net.Uri +import com.android.intentresolver.contentpreview.ImageLoader +import java.util.function.Consumer +import kotlinx.coroutines.CoroutineScope + +class FakeImageLoader(initialBitmaps: Map = emptyMap()) : ImageLoader { + private val bitmaps = HashMap().apply { putAll(initialBitmaps) } + + override fun loadImage(callerScope: CoroutineScope, uri: Uri, callback: Consumer) { + callback.accept(bitmaps[uri]) + } + + override suspend fun invoke(uri: Uri, caching: Boolean): Bitmap? = bitmaps[uri] + + override fun prePopulate(uris: List) = Unit + + fun setBitmap(uri: Uri, bitmap: Bitmap) { + bitmaps[uri] = bitmap + } +} diff --git a/tests/shared/src/com/android/intentresolver/TestPreviewImageLoader.kt b/tests/shared/src/com/android/intentresolver/TestPreviewImageLoader.kt deleted file mode 100644 index f0203bb6..00000000 --- a/tests/shared/src/com/android/intentresolver/TestPreviewImageLoader.kt +++ /dev/null @@ -1,33 +0,0 @@ -/* - * Copyright (C) 2022 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.android.intentresolver - -import android.graphics.Bitmap -import android.net.Uri -import com.android.intentresolver.contentpreview.ImageLoader -import java.util.function.Consumer -import kotlinx.coroutines.CoroutineScope - -class TestPreviewImageLoader(private val bitmaps: Map) : ImageLoader { - override fun loadImage(callerScope: CoroutineScope, uri: Uri, callback: Consumer) { - callback.accept(bitmaps[uri]) - } - - override suspend fun invoke(uri: Uri, caching: Boolean): Bitmap? = bitmaps[uri] - - override fun prePopulate(uris: List) = Unit -} diff --git a/tests/unit/src/com/android/intentresolver/contentpreview/ChooserContentPreviewUiTest.kt b/tests/unit/src/com/android/intentresolver/contentpreview/ChooserContentPreviewUiTest.kt index c7c3c516..68b277e7 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() private val headlineGenerator = mock() - private val imageLoader = TestPreviewImageLoader(emptyMap()) + private val imageLoader = FakeImageLoader(emptyMap()) private val testMetadataText: CharSequence = "Test metadata text" private val actionFactory = object : ActionFactory { 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 { 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 { - 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 { + 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() - 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() + 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() - val contentResolver = - mock { - 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() + val contentResolver = + mock { + 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( -- cgit v1.2.3-59-g8ed1b From 347999aa706194ec47034c13a481b2857ffcd015 Mon Sep 17 00:00:00 2001 From: Steve Elliott Date: Wed, 14 Feb 2024 11:25:41 -0500 Subject: Set shareousel background color Bug: 302691505 Flag: ACONFIG android.service.chooser.chooser_payload_toggling DEVELOPMENT Test: N/A - code isn't live Change-Id: Iba603fa63fdecc5b927d80f7d4bd3bae2b65201c --- .../contentpreview/shareousel/ui/composable/ShareouselComposable.kt | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) (limited to 'java/src') diff --git a/java/src/com/android/intentresolver/contentpreview/shareousel/ui/composable/ShareouselComposable.kt b/java/src/com/android/intentresolver/contentpreview/shareousel/ui/composable/ShareouselComposable.kt index 5cf35297..0b3cdd83 100644 --- a/java/src/com/android/intentresolver/contentpreview/shareousel/ui/composable/ShareouselComposable.kt +++ b/java/src/com/android/intentresolver/contentpreview/shareousel/ui/composable/ShareouselComposable.kt @@ -16,6 +16,7 @@ package com.android.intentresolver.contentpreview.shareousel.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 @@ -50,7 +51,7 @@ fun Shareousel(viewModel: ShareouselViewModel) { val centerIdx = viewModel.centerIndex.value val carouselState = rememberLazyListState(initialFirstVisibleItemIndex = centerIdx) val previewKeys by viewModel.previewKeys.collectAsStateWithLifecycle() - Column { + Column(modifier = Modifier.background(MaterialTheme.colorScheme.surfaceContainer)) { // TODO: item needs to be centered, check out ScalingLazyColumn impl or see if // HorizontalPager works for our use-case LazyRow( -- cgit v1.2.3-59-g8ed1b From 805e11b9ea1f167fe790bda9170a6e56cbd86c56 Mon Sep 17 00:00:00 2001 From: Mark Renouf Date: Tue, 5 Mar 2024 16:42:23 -0500 Subject: Create a Resolver-specific NoCrossProfileEmptyStateprovider This is a temporary measure to split upcoming CLs between actively shipping code and future work (ResolverActivity). This allows correcting the rules to work with private profile, while deferring changes to ResolverActivity which will follow soon. After ResolverActivity is updated, this copy can be removed. Bug: 328029692 Test: atest IntentResolver-tests-activity Test: atest IntentResolver-tests-unit Change-Id: I48ba6e3d1c575feec838ac829753158134c9023b --- .../intentresolver/v2/ResolverActivity.java | 4 +- .../ResolverNoCrossProfileEmptyStateProvider.java | 138 +++++++++++++++++++++ 2 files changed, 140 insertions(+), 2 deletions(-) create mode 100644 java/src/com/android/intentresolver/v2/emptystate/ResolverNoCrossProfileEmptyStateProvider.java (limited to 'java/src') diff --git a/java/src/com/android/intentresolver/v2/ResolverActivity.java b/java/src/com/android/intentresolver/v2/ResolverActivity.java index a9d9f8b1..be3d7ce9 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; @@ -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/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; + } + } +} -- cgit v1.2.3-59-g8ed1b From da8c0a0cdef1dd191204a0119fab176c60d673ec Mon Sep 17 00:00:00 2001 From: Andrey Epin Date: Mon, 4 Mar 2024 13:15:24 -0800 Subject: ChooserActivity: return missing ResolverActivity onDestroy logic A destroy method is added to ChooserMultiProfilePagerAdapter that calls destroy on every page's list adapter; ChooserActivity uses this method instead of the legacy code that called destroy only on the active page's list adapter. A weak reference is added between Binder callbacks to limit retained lifetime of remotely referenced objects. Fix: 328084479 Test: manual functionality testing Test: launch Chooser multiple times, collect a process heap dump forcing garbage collection, check the heap dump and verify that no ChooserActivity remains in memory. Change-Id: Ie6e51ed447c6a609a890edaf97aa19f8ad824238 --- .../ResolverRankerServiceResolverComparator.java | 43 +++++++++++++++------- .../android/intentresolver/v2/ChooserActivity.java | 6 +++ .../profiles/ChooserMultiProfilePagerAdapter.java | 10 +++++ 3 files changed, 45 insertions(+), 14 deletions(-) (limited to 'java/src') 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 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 mHandlerRef; + + private ResolverRankerResultCallback(Object lock, Handler handler) { + mLock = lock; + mHandlerRef = new WeakReference<>(handler); + } + + @Override + public void sendResult(List 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/v2/ChooserActivity.java b/java/src/com/android/intentresolver/v2/ChooserActivity.java index 765d7c2d..4ffe942b 100644 --- a/java/src/com/android/intentresolver/v2/ChooserActivity.java +++ b/java/src/com/android/intentresolver/v2/ChooserActivity.java @@ -485,6 +485,12 @@ public class ChooserActivity extends Hilt_ChooserActivity implements @Override protected final void onDestroy() { super.onDestroy(); + if (!isChangingConfigurations() && mPickOptionRequest != null) { + mPickOptionRequest.cancel(); + } + if (mChooserMultiProfilePagerAdapter != null) { + mChooserMultiProfilePagerAdapter.destroy(); + } if (isFinishing()) { mLatencyTracker.onActionCancel(ACTION_LOAD_SHARE_SHEET); 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> { private final Context mContext; private int mBottomOffset; -- cgit v1.2.3-59-g8ed1b From bb0893ed53590a8c116a3ca7ee8b2fbd869fe5c6 Mon Sep 17 00:00:00 2001 From: mrenouf Date: Fri, 1 Mar 2024 11:43:26 -0500 Subject: ChooserActivity Profile integration [1/2] * connects ChooserActivity with UserInteractor * replaces all existing references as the source of UserHandles * app continues to explicity use the same profile types as previous * updates Activity tests to use FakeUserRepository A following change will modify initialization to incorporate build the UI based on the list of profiles, instead of explicit references to individual profile types. Bug: 300157408 Bug: 311348033 Test: atest IntentResolver-tests-activity:com.android.intentresolver.v2 Change-Id: I6043e57fd7a8aff8c252e2b12171d457612d35dc --- .../android/intentresolver/v2/ChooserActivity.java | 374 ++++++++------------- .../intentresolver/v2/ChooserActivityLogic.kt | 23 -- .../com/android/intentresolver/v2/ChooserHelper.kt | 128 +++++-- .../intentresolver/v2/ProfileAvailability.kt | 22 +- .../WorkProfilePausedEmptyStateProvider.java | 41 ++- .../v2/ui/viewmodel/ChooserRequestReader.kt | 8 +- .../v2/ui/viewmodel/ChooserViewModel.kt | 42 ++- .../v2/ChooserActivityOverrideData.java | 36 -- .../intentresolver/v2/ChooserWrapperActivity.java | 19 -- .../intentresolver/v2/TestChooserActivityLogic.kt | 25 -- .../v2/UnbundledChooserActivityTest.java | 75 +++-- .../UnbundledChooserActivityWorkProfileTest.java | 46 ++- 12 files changed, 401 insertions(+), 438 deletions(-) delete mode 100644 java/src/com/android/intentresolver/v2/ChooserActivityLogic.kt delete mode 100644 tests/activity/src/com/android/intentresolver/v2/TestChooserActivityLogic.kt (limited to 'java/src') diff --git a/java/src/com/android/intentresolver/v2/ChooserActivity.java b/java/src/com/android/intentresolver/v2/ChooserActivity.java index bf651bbf..8fe64da7 100644 --- a/java/src/com/android/intentresolver/v2/ChooserActivity.java +++ b/java/src/com/android/intentresolver/v2/ChooserActivity.java @@ -32,7 +32,6 @@ 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.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; @@ -57,22 +56,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 +98,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,7 +112,6 @@ 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; @@ -141,6 +134,7 @@ import com.android.intentresolver.model.ResolverRankerServiceResolverComparator; import com.android.intentresolver.shortcuts.AppPredictorFactory; import com.android.intentresolver.shortcuts.ShortcutLoader; 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; @@ -154,6 +148,7 @@ import com.android.intentresolver.v2.profiles.MultiProfilePagerAdapter.ProfileTy 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.ShareResultSender; import com.android.intentresolver.v2.ui.ShareResultSenderFactory; @@ -173,7 +168,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; @@ -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"; @@ -240,7 +233,6 @@ public class ChooserActivity extends Hilt_ChooserActivity implements private PackageMonitor mWorkPackageMonitor; protected View mProfileView; - protected ActivityLogic mLogic; protected ResolverDrawerLayout mResolverDrawerLayout; protected ChooserMultiProfilePagerAdapter mChooserMultiProfilePagerAdapter; protected final LatencyTracker mLatencyTracker = getLatencyTracker(); @@ -274,6 +266,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; @@ -287,8 +280,12 @@ public class ChooserActivity extends Hilt_ChooserActivity implements @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 +336,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 @@ -361,42 +349,17 @@ public class ChooserActivity extends Hilt_ChooserActivity implements protected final 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); - } - mLogic = createActivityLogic(); + // Initializer is invoked when this function returns, via Lifecycle. + mChooserHelper.setInitializer(this::initialize); } @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 +400,6 @@ public class ChooserActivity extends Hilt_ChooserActivity implements finish(); } } - mLogic.getWorkProfileAvailabilityManager().unregisterWorkProfileStateReceiver(this); if (mRefinementManager != null) { mRefinementManager.onActivityStop(isChangingConfigurations()); @@ -465,7 +427,7 @@ public class ChooserActivity extends Hilt_ChooserActivity implements mPersonalPackageMonitor.register( this, getMainLooper(), - requireAnnotatedUserHandles().personalProfileUserHandle, + mProfiles.getPersonalHandle(), false); if (hasWorkProfile()) { if (mWorkPackageMonitor == null) { @@ -475,18 +437,11 @@ 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(); } @@ -509,31 +464,47 @@ public class ChooserActivity extends Hilt_ChooserActivity implements destroyProfileRecords(); } - private void init() { + /** DO NOT CALL. Only for use from ChooserHelper as a callback. */ + private void initialize(Profile launchedAs, List profiles) { + mViewModel = new ViewModelProvider(this).get(ChooserViewModel.class); + mRequest = mViewModel.getRequest().getValue(); + mActivityModel = mViewModel.getActivityModel(); + + mProfiles = new ProfileHelper(mUserInteractor, mFeatureFlags, profiles, launchedAs); + mProfileAvailability = + new ProfileAvailability(getCoroutineScope(getLifecycle()), mUserInteractor); + mProfileAvailability.setOnProfileStatusChange(this::onWorkProfileStatusUpdated); + mIntentReceivedTime.set(System.currentTimeMillis()); mLatencyTracker.onActionStart(ACTION_LOAD_SHARE_SHEET); mPinnedSharedPrefs = getPinnedSharedPrefs(this); + IntentSender chosenComponentSender = mRequest.getChosenComponentSender(); + if (chosenComponentSender != null) { + mShareResultSender = mShareResultSenderFactory.create( + mViewModel.getActivityModel().getLaunchedFromUid(), chosenComponentSender); + } + 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 initialIntents = mViewModel.getChooserRequest().getInitialIntents(); + Intent intent = mRequest.getTargetIntent(); + List initialIntents = mRequest.getInitialIntents(); + Log.d(TAG, "createMultiProfilePagerAdapter"); mChooserMultiProfilePagerAdapter = createMultiProfilePagerAdapter( requireNonNullElse(initialIntents, emptyList()).toArray(new Intent[0]), /* resolutionList = */ null, @@ -545,7 +516,7 @@ public class ChooserActivity extends Hilt_ChooserActivity implements mPersonalPackageMonitor.register( this, getMainLooper(), - requireAnnotatedUserHandles().personalProfileUserHandle, + mProfiles.getPersonalHandle(), false ); if (hasWorkProfile()) { @@ -554,7 +525,7 @@ public class ChooserActivity extends Hilt_ChooserActivity implements mWorkPackageMonitor.register( this, getMainLooper(), - requireAnnotatedUserHandles().workProfileUserHandle, + mProfiles.getWorkHandle(), false ); } @@ -622,10 +593,10 @@ 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(), + mViewModel.getActivityModel().getIntent(), + mRequest.getAdditionalContentUri(), + mRequest.getFocusedItemPosition(), mChooserServiceFeatureFlags.chooserPayloadToggling()); ChooserActionFactory chooserActionFactory = createChooserActionFactory(); ChooserContentPreviewUi.ActionFactory actionFactory = chooserActionFactory; @@ -647,13 +618,13 @@ public class ChooserActivity extends Hilt_ChooserActivity implements mChooserContentPreviewUi = new ChooserContentPreviewUi( getCoroutineScope(getLifecycle()), previewViewModel.getPreviewDataProvider(), - chooserRequest.getTargetIntent(), + mRequest.getTargetIntent(), previewViewModel.getImageLoader(), actionFactory, mEnterTransitionAnimationDelegate, new HeadlineGeneratorImpl(this), - chooserRequest.getContentTypeHint(), - chooserRequest.getMetadataText(), + mRequest.getContentTypeHint(), + mRequest.getMetadataText(), mChooserServiceFeatureFlags.chooserPayloadToggling()); updateStickyContentPreview(); if (shouldShowStickyContentPreview() @@ -665,7 +636,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); @@ -679,29 +650,26 @@ 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 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)); - } + @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(); } @@ -792,7 +760,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); @@ -884,10 +852,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); @@ -942,7 +910,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(); } @@ -967,14 +935,14 @@ public class ChooserActivity extends Hilt_ChooserActivity implements .createEvent(DevicePolicyEnums.RESOLVER_CROSS_PROFILE_TARGET_OPENED) .setBoolean( currentUserHandle.equals( - requireAnnotatedUserHandles().personalProfileUserHandle)) + mProfiles.getPersonalHandle())) .setStrings(getMetricsCategory(), cti.isInDirectShareMetricsCategory() ? "direct_share" : "other_target") .write(); } private boolean hasWorkProfile() { - return requireAnnotatedUserHandles().workProfileUserHandle != null; + return mProfiles.getWorkHandle() != null; } private LatencyTracker getLatencyTracker() { return LatencyTracker.getInstance(this); @@ -999,8 +967,10 @@ public class ChooserActivity extends Hilt_ChooserActivity implements final EmptyStateProvider blockerEmptyStateProvider = createBlockerEmptyStateProvider(); final EmptyStateProvider workProfileOffEmptyStateProvider = - new WorkProfilePausedEmptyStateProvider(this, workProfileUserHandle, - mLogic.getWorkProfileAvailabilityManager(), + new WorkProfilePausedEmptyStateProvider( + this, + mProfiles, + mProfileAvailability, /* onSwitchOnWorkSelectedListener= */ () -> { if (mOnSwitchOnWorkSelectedListener != null) { @@ -1012,9 +982,9 @@ public class ChooserActivity extends Hilt_ChooserActivity implements final EmptyStateProvider noAppsEmptyStateProvider = new NoAppsAvailableEmptyStateProvider( this, workProfileUserHandle, - requireAnnotatedUserHandles().personalProfileUserHandle, + mProfiles.getPersonalHandle(), getMetricsCategory(), - requireAnnotatedUserHandles().tabOwnerUserHandleForLaunch + mProfiles.getTabOwnerUserHandleForLaunch() ); // Return composite provider, the order matters (the higher, the more priority) @@ -1025,74 +995,24 @@ 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 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 getResolverRankerServiceUserHandleList(UserHandle userHandle) { + private List getResolverRankerServiceUserHandleList(UserHandle userHandle) { return getResolverRankerServiceUserHandleListInternal(userHandle); } - - @VisibleForTesting - protected List getResolverRankerServiceUserHandleListInternal( + private List getResolverRankerServiceUserHandleListInternal( UserHandle userHandle) { List 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; } @@ -1124,7 +1044,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(); } @@ -1284,22 +1204,24 @@ public class ChooserActivity extends Hilt_ChooserActivity implements ////////////////////////////////////////////////////////////////////////////////////////////// ////////////////////////////////////////////////////////////////////////////////////////////// - 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( @@ -1339,7 +1261,7 @@ 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); } @@ -1358,7 +1280,7 @@ public class ChooserActivity extends Hilt_ChooserActivity implements } protected EmptyStateProvider createBlockerEmptyStateProvider() { - final boolean isSendAction = mViewModel.getChooserRequest().isSendActionTarget(); + final boolean isSendAction = mRequest.isSendActionTarget(); final EmptyState noWorkToPersonalEmptyState = new DevicePolicyBlockerEmptyState( @@ -1387,11 +1309,11 @@ public class ChooserActivity extends Hilt_ChooserActivity implements /* devicePolicyEventCategory= */ ResolverActivity.METRICS_CATEGORY_CHOOSER); return new NoCrossProfileEmptyStateProvider( - requireAnnotatedUserHandles().personalProfileUserHandle, + mProfiles.getPersonalHandle(), noWorkToPersonalEmptyState, noPersonalToWorkEmptyState, createCrossProfileIntentsChecker(), - requireAnnotatedUserHandles().tabOwnerUserHandleForLaunch); + mProfiles.getTabOwnerUserHandleForLaunch()); } private ChooserMultiProfilePagerAdapter createChooserMultiProfilePagerAdapterForOneProfile( @@ -1400,11 +1322,11 @@ public class ChooserActivity extends Hilt_ChooserActivity implements boolean filterLastUsed) { ChooserGridAdapter adapter = createChooserGridAdapter( /* context */ this, - mViewModel.getChooserRequest().getPayloadIntents(), + mRequest.getPayloadIntents(), initialIntents, rList, filterLastUsed, - /* userHandle */ requireAnnotatedUserHandles().personalProfileUserHandle + /* userHandle */ mProfiles.getPersonalHandle() ); return new ChooserMultiProfilePagerAdapter( /* context */ this, @@ -1419,7 +1341,7 @@ public class ChooserActivity extends Hilt_ChooserActivity implements /* workProfileQuietModeChecker= */ () -> false, /* defaultProfile= */ PROFILE_PERSONAL, /* workProfileUserHandle= */ null, - requireAnnotatedUserHandles().cloneProfileUserHandle, + mProfiles.getCloneHandle(), mMaxTargetsPerRow, mFeatureFlags); } @@ -1431,19 +1353,19 @@ public class ChooserActivity extends Hilt_ChooserActivity implements int selectedProfile = findSelectedProfile(); ChooserGridAdapter personalAdapter = createChooserGridAdapter( /* context */ this, - mViewModel.getChooserRequest().getPayloadIntents(), + mRequest.getPayloadIntents(), selectedProfile == PROFILE_PERSONAL ? initialIntents : null, rList, filterLastUsed, - /* userHandle */ requireAnnotatedUserHandles().personalProfileUserHandle + /* userHandle */ mProfiles.getPersonalHandle() ); ChooserGridAdapter workAdapter = createChooserGridAdapter( /* context */ this, - mViewModel.getChooserRequest().getPayloadIntents(), + mRequest.getPayloadIntents(), selectedProfile == PROFILE_WORK ? initialIntents : null, rList, filterLastUsed, - /* userHandle */ requireAnnotatedUserHandles().workProfileUserHandle + /* userHandle */ mProfiles.getWorkHandle() ); return new ChooserMultiProfilePagerAdapter( /* context */ this, @@ -1460,17 +1382,20 @@ public class ChooserActivity extends Hilt_ChooserActivity implements mDevicePolicyResources.getWorkTabAccessibilityLabel(), TAB_TAG_WORK, workAdapter)), - createEmptyStateProvider(requireAnnotatedUserHandles().workProfileUserHandle), - () -> mLogic.getWorkProfileAvailabilityManager().isQuietModeEnabled(), + createEmptyStateProvider(mProfiles.getWorkHandle()), + /* Supplier (QuietMode enabled) == !(available) */ + () -> !(mProfiles.getWorkProfilePresent() + && mProfileAvailability.isAvailable( + requireNonNull(mProfiles.getWorkProfile()))), selectedProfile, - requireAnnotatedUserHandles().workProfileUserHandle, - requireAnnotatedUserHandles().cloneProfileUserHandle, + mProfiles.getWorkHandle(), + mProfiles.getCloneHandle(), mMaxTargetsPerRow, mFeatureFlags); } private int findSelectedProfile() { - return getProfileForUser(requireAnnotatedUserHandles().tabOwnerUserHandleForLaunch); + return getProfileForUser(mProfiles.getTabOwnerUserHandleForLaunch()); } /** @@ -1478,9 +1403,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 @@ -1618,12 +1542,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); @@ -1652,13 +1574,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()); @@ -1676,8 +1597,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) { @@ -1694,7 +1616,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(); @@ -1714,7 +1636,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; @@ -1788,7 +1710,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, @@ -1867,7 +1789,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(); @@ -1895,7 +1817,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) { @@ -1965,7 +1887,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; } @@ -1981,7 +1903,6 @@ public class ChooserActivity extends Hilt_ChooserActivity implements List rList, boolean filterLastUsed, UserHandle userHandle) { - ChooserRequest request = mViewModel.getChooserRequest(); ChooserListAdapter chooserListAdapter = createChooserListAdapter( context, payloadIntents, @@ -1990,8 +1911,8 @@ public class ChooserActivity extends Hilt_ChooserActivity implements filterLastUsed, createListController(userHandle), userHandle, - request.getTargetIntent(), - request.getReferrerFillInIntent(), + mRequest.getTargetIntent(), + mRequest.getReferrerFillInIntent(), mMaxTargetsPerRow ); @@ -2045,9 +1966,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, @@ -2073,19 +1992,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 @@ -2095,8 +2013,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(), @@ -2106,8 +2024,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), @@ -2117,12 +2035,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); } @@ -2132,13 +2050,12 @@ 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(), + mRequest.getModifyShareAction(), mImageEditor, getEventLog(), (isExcluded) -> mExcludeSharedText = isExcluded, @@ -2148,7 +2065,7 @@ public class ChooserActivity extends Hilt_ChooserActivity implements public void safelyStartActivityAsPersonalProfileUser(TargetInfo targetInfo) { safelyStartActivityAsUser( targetInfo, - requireAnnotatedUserHandles().personalProfileUserHandle + mProfiles.getPersonalHandle() ); finish(); } @@ -2160,7 +2077,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. @@ -2317,7 +2234,7 @@ public class ChooserActivity extends Hilt_ChooserActivity implements * Returns {@link #PROFILE_PERSONAL}, otherwise. **/ private int getProfileForUser(UserHandle currentUserHandle) { - if (currentUserHandle.equals(requireAnnotatedUserHandles().workProfileUserHandle)) { + if (currentUserHandle.equals(mProfiles.getWorkHandle())) { return PROFILE_WORK; } // We return personal profile, as it is the default when there is no work profile, personal @@ -2503,8 +2420,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() { 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..5b514614 100644 --- a/java/src/com/android/intentresolver/v2/ChooserHelper.kt +++ b/java/src/com/android/intentresolver/v2/ChooserHelper.kt @@ -17,11 +17,43 @@ 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 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.ui.model.ChooserRequest +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 javax.inject.Inject +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.launch + +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 launchedAs the profile which launched this instance + * @param initialProfiles a snapshot of the launching user's profile group + */ + fun initialize(launchedAs: Profile, initialProfiles: List) +} /** * __Purpose__ @@ -30,52 +62,108 @@ 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, ) : 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() + private val lifecycleScope = activity.lifecycleScope - private var activityPostCreate: Runnable? = null + private lateinit var activityInitializer: ChooserInitializer 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) + } + } + 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) { + request.errors.forEach { it.log(TAG) } + activity.finish() + } + + private fun initializeActivity(request: Valid) { + request.warnings.forEach { it.log(TAG) } + + // Note: Activity lifecycleScope uses Dispatchers.Main.immediate + lifecycleScope.launch { + val initialProfiles = userInteractor.profiles.first() + val launchedAsProfile = userInteractor.launchedAsProfile.first() + activityInitializer.initialize(launchedAsProfile, initialProfiles) + } } -} \ No newline at end of file +} diff --git a/java/src/com/android/intentresolver/v2/ProfileAvailability.kt b/java/src/com/android/intentresolver/v2/ProfileAvailability.kt index 4d689724..2df25c41 100644 --- a/java/src/com/android/intentresolver/v2/ProfileAvailability.kt +++ b/java/src/com/android/intentresolver/v2/ProfileAvailability.kt @@ -18,18 +18,13 @@ package com.android.intentresolver.v2 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 */ class ProfileAvailability( @@ -43,6 +38,9 @@ class ProfileAvailability( 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 +59,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 + userInteractor.availability.filter { it[profile] == true }.first() + } job.invokeOnCompletion { waitingToEnableProfile = false + onProfileStatusChange?.run() } waitJob = job } @@ -76,4 +74,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/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/ui/viewmodel/ChooserRequestReader.kt b/java/src/com/android/intentresolver/v2/ui/viewmodel/ChooserRequestReader.kt index 91eed408..7ebf65a9 100644 --- a/java/src/com/android/intentresolver/v2/ui/viewmodel/ChooserRequestReader.kt +++ b/java/src/com/android/intentresolver/v2/ui/viewmodel/ChooserRequestReader.kt @@ -65,10 +65,10 @@ internal fun Intent.maybeAddSendActionFlags() = } fun readChooserRequest( - launch: ActivityModel, + model: ActivityModel, flags: ChooserServiceFlags ): ValidationResult { - 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..4d87b2cb 100644 --- a/java/src/com/android/intentresolver/v2/ui/viewmodel/ChooserViewModel.kt +++ b/java/src/com/android/intentresolver/v2/ui/viewmodel/ChooserViewModel.kt @@ -24,10 +24,11 @@ import com.android.intentresolver.v2.ui.model.ActivityModel.Companion.ACTIVITY_M 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.hilt.android.lifecycle.HiltViewModel import javax.inject.Inject +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow private const val TAG = "ChooserViewModel" @@ -45,23 +46,30 @@ constructor( "ActivityModel missing in SavedStateHandle! ($ACTIVITY_MODEL_KEY)" } - /** The result of reading and validating the inputs provided in savedState. */ - private val status: ValidationResult = readChooserRequest(activityModel, flags) + /** + * Provided only for the express purpose of early exit in the event of an invalid request. + * + * Note: [request] can only be safely accessed after checking if this value is [Valid]. + */ + internal val initialRequest = readChooserRequest(activityModel, flags) - val chooserRequest: ChooserRequest by lazy { - when (status) { - is Valid -> status.value - is Invalid -> error(status.errors) - } - } + private lateinit var _request: MutableStateFlow + + /** + * A [StateFlow] of [ChooserRequest]. + * + * Note: Only safe to access after checking if [initialRequest] is [Valid]. + */ + lateinit var request: StateFlow + private set - fun init(): Boolean { - Log.i(TAG, "viewModel init") - if (status is Invalid) { - status.errors.forEach { finding -> finding.log(TAG) } - return false + init { + when (initialRequest) { + is Valid -> { + _request = MutableStateFlow(initialRequest.value) + request = _request.asStateFlow() + } + is Invalid -> Log.w(TAG, "initialRequest is Invalid, initialization failed") } - Log.i(TAG, "request = $chooserRequest") - return true } } 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..d13677e1 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; @@ -53,16 +52,6 @@ public class ChooserWrapperActivity extends ChooserActivity implements IChooserW static final ChooserActivityOverrideData sOverrides = ChooserActivityOverrideData.getInstance(); private UsageStatsManager mUsm; - @Override - protected final ChooserActivityLogic createActivityLogic() { - return new TestChooserActivityLogic( - "ChooserWrapper", - /* activity = */ this, - this::onWorkProfileStatusUpdated, - sOverrides.annotatedUserHandles, - sOverrides.mWorkProfileAvailability); - } - @Override public ChooserListAdapter createChooserListAdapter( Context context, @@ -186,14 +175,6 @@ public class ChooserWrapperActivity extends ChooserActivity implements IChooserW return super.queryResolver(resolver, uri); } - @Override - protected boolean isWorkProfile() { - if (sOverrides.alternateProfileSetting != 0) { - return sOverrides.alternateProfileSetting == MetricsEvent.MANAGED_PROFILE; - } - return super.isWorkProfile(); - } - @Override public DisplayResolveInfo createTestDisplayResolveInfo( Intent originalIntent, 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 b910e4f6..7848983e 100644 --- a/tests/activity/src/com/android/intentresolver/v2/UnbundledChooserActivityTest.java +++ b/tests/activity/src/com/android/intentresolver/v2/UnbundledChooserActivityTest.java @@ -117,7 +117,6 @@ 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; @@ -130,16 +129,21 @@ 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; @@ -186,6 +190,7 @@ import javax.inject.Inject; ImageEditorModule.class, PackageManagerModule.class, ImageLoaderModule.class, + UserRepositoryModule.class, }) public class UnbundledChooserActivityTest { @@ -195,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( @@ -241,6 +257,20 @@ 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 @@ -1268,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")); @@ -2166,7 +2198,7 @@ public class UnbundledChooserActivityTest { createResolvedComponentsForTestWithOtherProfile(3, /* userId */ 10); List workResolvedComponentInfos = createResolvedComponentsForTest(workProfileTargets); - ChooserActivityOverrideData.getInstance().isQuietModeEnabled = true; + mFakeUserRepo.updateState(WORK_USER, false); setupResolverControllers(personalResolvedComponentInfos, workResolvedComponentInfos); Intent sendIntent = createSendTextIntent(); sendIntent.setType(TEST_MIME_TYPE); @@ -2248,7 +2280,7 @@ public class UnbundledChooserActivityTest { List 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); @@ -2272,7 +2304,7 @@ public class UnbundledChooserActivityTest { List workResolvedComponentInfos = createResolvedComponentsForTest(0); setupResolverControllers(personalResolvedComponentInfos, workResolvedComponentInfos); - ChooserActivityOverrideData.getInstance().isQuietModeEnabled = true; + mFakeUserRepo.updateState(WORK_USER, false); Intent sendIntent = createSendTextIntent(); sendIntent.setType(TEST_MIME_TYPE); @@ -3008,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( @@ -3039,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() @@ -3061,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) { diff --git a/tests/activity/src/com/android/intentresolver/v2/UnbundledChooserActivityWorkProfileTest.java b/tests/activity/src/com/android/intentresolver/v2/UnbundledChooserActivityWorkProfileTest.java index e4ec1776..92037ec9 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; @@ -45,11 +48,21 @@ import androidx.test.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 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 personalResolvedComponentInfos = createResolvedComponentsForTestWithOtherProfile(3, -- cgit v1.2.3-59-g8ed1b From 35d5f23ff4526ba7410bef545fbbd6ec75d41649 Mon Sep 17 00:00:00 2001 From: Mark Renouf Date: Mon, 4 Mar 2024 15:46:54 -0500 Subject: ChooserActivity Profile integration [2/2] * updates 'MultiProfilePagerAdapter' creation * create tab config list from available profiles * replace MPPA profile constants with Profile.Type.ordinal * replace Tab tags with Profile.Type.name() Bug: 311348033 Test: atest IntentResolver-tests-activity:com.android.intentresolver.v2 Test: atest IntentResolver-tests-unit Change-Id: Id874721eef89661a5c2dbeb795c2096da544deca --- .../intentresolver/grid/ChooserGridAdapter.java | 2 - .../android/intentresolver/v2/ChooserActivity.java | 302 ++++++++++----------- .../com/android/intentresolver/v2/ChooserHelper.kt | 40 ++- .../intentresolver/v2/ProfileAvailability.kt | 7 +- .../com/android/intentresolver/v2/ProfileHelper.kt | 68 +++-- .../NoCrossProfileEmptyStateProvider.java | 59 ++-- .../v2/profiles/MultiProfilePagerAdapter.java | 9 +- .../intentresolver/v2/ChooserWrapperActivity.java | 4 +- .../intentresolver/v2/ProfileAvailabilityTest.kt | 9 +- 9 files changed, 253 insertions(+), 247 deletions(-) (limited to 'java/src') 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 { /** diff --git a/java/src/com/android/intentresolver/v2/ChooserActivity.java b/java/src/com/android/intentresolver/v2/ChooserActivity.java index 8fe64da7..a95caddc 100644 --- a/java/src/com/android/intentresolver/v2/ChooserActivity.java +++ b/java/src/com/android/intentresolver/v2/ChooserActivity.java @@ -31,12 +31,12 @@ 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.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; @@ -143,13 +143,13 @@ 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; @@ -182,6 +182,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; @@ -226,8 +227,6 @@ 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; @@ -276,6 +275,7 @@ public class ChooserActivity extends Hilt_ChooserActivity implements @Inject @NearbyShare public Optional 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; @@ -353,7 +353,7 @@ public class ChooserActivity extends Hilt_ChooserActivity implements setTheme(R.style.Theme_DeviceDefault_Chooser); // Initializer is invoked when this function returns, via Lifecycle. - mChooserHelper.setInitializer(this::initialize); + mChooserHelper.setInitializer(this::initializeWith); } @Override @@ -429,7 +429,7 @@ public class ChooserActivity extends Hilt_ChooserActivity implements getMainLooper(), mProfiles.getPersonalHandle(), false); - if (hasWorkProfile()) { + if (mProfiles.getWorkProfilePresent()) { if (mWorkPackageMonitor == null) { mWorkPackageMonitor = createPackageMonitor( mChooserMultiProfilePagerAdapter.getWorkListAdapter()); @@ -465,14 +465,24 @@ public class ChooserActivity extends Hilt_ChooserActivity implements } /** DO NOT CALL. Only for use from ChooserHelper as a callback. */ - private void initialize(Profile launchedAs, List profiles) { + 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, profiles, launchedAs); - mProfileAvailability = - new ProfileAvailability(getCoroutineScope(getLifecycle()), mUserInteractor); + 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()); @@ -501,15 +511,17 @@ public class ChooserActivity extends Hilt_ChooserActivity implements mRequest.getShareTargetFilter() ); - Intent intent = mRequest.getTargetIntent(); - List initialIntents = mRequest.getInitialIntents(); - Log.d(TAG, "createMultiProfilePagerAdapter"); mChooserMultiProfilePagerAdapter = createMultiProfilePagerAdapter( - requireNonNullElse(initialIntents, emptyList()).toArray(new Intent[0]), - /* resolutionList = */ null, - false - ); + /* context = */ this, + mProfilePagerResources, + mViewModel.getRequest().getValue(), + mProfiles, + mProfileAvailability, + mRequest.getInitialIntents(), + mMaxTargetsPerRow, + mFeatureFlags); + if (!configureContentView(mTargetDataLoader)) { mPersonalPackageMonitor = createPackageMonitor( mChooserMultiProfilePagerAdapter.getPersonalListAdapter()); @@ -519,7 +531,7 @@ public class ChooserActivity extends Hilt_ChooserActivity implements mProfiles.getPersonalHandle(), false ); - if (hasWorkProfile()) { + if (mProfiles.getWorkProfilePresent()) { mWorkPackageMonitor = createPackageMonitor( mChooserMultiProfilePagerAdapter.getWorkListAdapter()); mWorkPackageMonitor.register( @@ -553,6 +565,8 @@ public class ChooserActivity extends Hilt_ChooserActivity implements mResolverDrawerLayout = rdl; } + + Intent intent = mRequest.getTargetIntent(); final Set categories = intent.getCategories(); MetricsLogger.action(this, mChooserMultiProfilePagerAdapter.getActiveListAdapter().hasFilteredItem() @@ -702,7 +716,6 @@ public class ChooserActivity extends Hilt_ChooserActivity implements return false; } - private boolean isTwoPagePersonalAndWorkConfiguration() { return (mChooserMultiProfilePagerAdapter.getCount() == 2) && mChooserMultiProfilePagerAdapter.hasPageForProfile(PROFILE_PERSONAL) @@ -844,7 +857,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) { @@ -928,22 +941,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( - mProfiles.getPersonalHandle())) + .setBoolean(currentUserHandle.equals(mProfiles.getPersonalHandle())) .setStrings(getMetricsCategory(), cti.isInDirectShareMetricsCategory() ? "direct_share" : "other_target") .write(); } - private boolean hasWorkProfile() { - return mProfiles.getWorkHandle() != null; - } private LatencyTracker getLatencyTracker() { return LatencyTracker.getInstance(this); } @@ -963,15 +971,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 = + EmptyStateProvider workProfileOffEmptyStateProvider = new WorkProfilePausedEmptyStateProvider( this, - mProfiles, - mProfileAvailability, - /* onSwitchOnWorkSelectedListener= */ + profileHelper, + profileAvailability, + /* onSwitchOnWorkSelectedListener = */ () -> { if (mOnSwitchOnWorkSelectedListener != null) { mOnSwitchOnWorkSelectedListener.onSwitchOnWorkSelected(); @@ -979,12 +988,12 @@ public class ChooserActivity extends Hilt_ChooserActivity implements }, getMetricsCategory()); - final EmptyStateProvider noAppsEmptyStateProvider = new NoAppsAvailableEmptyStateProvider( + EmptyStateProvider noAppsEmptyStateProvider = new NoAppsAvailableEmptyStateProvider( this, - workProfileUserHandle, - mProfiles.getPersonalHandle(), + profileHelper.getWorkHandle(), + profileHelper.getPersonalHandle(), getMetricsCategory(), - mProfiles.getTabOwnerUserHandleForLaunch() + profileHelper.getTabOwnerUserHandleForLaunch() ); // Return composite provider, the order matters (the higher, the more priority) @@ -1003,8 +1012,7 @@ public class ChooserActivity extends Hilt_ChooserActivity implements return getResolverRankerServiceUserHandleListInternal(userHandle); } - private List getResolverRankerServiceUserHandleListInternal( - UserHandle userHandle) { + private List getResolverRankerServiceUserHandleListInternal(UserHandle userHandle) { List userList = new ArrayList<>(); userList.add(userHandle); // Add clonedProfileUserHandle to the list only if we are: @@ -1090,7 +1098,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 @@ -1125,7 +1134,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); @@ -1154,7 +1163,10 @@ public class ChooserActivity extends Hilt_ChooserActivity implements setupViewVisibilities(); - if (hasWorkProfile()) { + if (mProfiles.getWorkProfilePresent() + || (mProfiles.getPrivateProfilePresent() + && mProfileAvailability.isAvailable( + requireNonNull(mProfiles.getPrivateProfile())))) { setupProfileTabs(); } @@ -1182,8 +1194,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,18 +1276,72 @@ public class ChooserActivity extends Hilt_ChooserActivity implements return context.getSharedPreferences(PINNED_SHARED_PREFS_NAME, MODE_PRIVATE); } - protected ChooserMultiProfilePagerAdapter createMultiProfilePagerAdapter( - Intent[] initialIntents, - List 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 initialIntents, + int maxTargetsPerRow, + FeatureFlags featureFlags) { + Log.d(TAG, "createMultiProfilePagerAdapter"); + + Profile launchedAs = profileHelper.getLaunchedAsProfile(); + + Intent[] initialIntentArray = initialIntents.toArray(new Intent[0]); + List payloadIntents = request.getPayloadIntents(); + + List> 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 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() { @@ -1309,93 +1374,14 @@ public class ChooserActivity extends Hilt_ChooserActivity implements /* devicePolicyEventCategory= */ ResolverActivity.METRICS_CATEGORY_CHOOSER); return new NoCrossProfileEmptyStateProvider( - mProfiles.getPersonalHandle(), + mProfiles, noWorkToPersonalEmptyState, noPersonalToWorkEmptyState, - createCrossProfileIntentsChecker(), - mProfiles.getTabOwnerUserHandleForLaunch()); - } - - private ChooserMultiProfilePagerAdapter createChooserMultiProfilePagerAdapterForOneProfile( - Intent[] initialIntents, - List rList, - boolean filterLastUsed) { - ChooserGridAdapter adapter = createChooserGridAdapter( - /* context */ this, - mRequest.getPayloadIntents(), - initialIntents, - rList, - filterLastUsed, - /* userHandle */ mProfiles.getPersonalHandle() - ); - 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, - mProfiles.getCloneHandle(), - mMaxTargetsPerRow, - mFeatureFlags); - } - - private ChooserMultiProfilePagerAdapter createChooserMultiProfilePagerAdapterForTwoProfiles( - Intent[] initialIntents, - List rList, - boolean filterLastUsed) { - int selectedProfile = findSelectedProfile(); - ChooserGridAdapter personalAdapter = createChooserGridAdapter( - /* context */ this, - mRequest.getPayloadIntents(), - selectedProfile == PROFILE_PERSONAL ? initialIntents : null, - rList, - filterLastUsed, - /* userHandle */ mProfiles.getPersonalHandle() - ); - ChooserGridAdapter workAdapter = createChooserGridAdapter( - /* context */ this, - mRequest.getPayloadIntents(), - selectedProfile == PROFILE_WORK ? initialIntents : null, - rList, - filterLastUsed, - /* userHandle */ mProfiles.getWorkHandle() - ); - 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(mProfiles.getWorkHandle()), - /* Supplier (QuietMode enabled) == !(available) */ - () -> !(mProfiles.getWorkProfilePresent() - && mProfileAvailability.isAvailable( - requireNonNull(mProfiles.getWorkProfile()))), - selectedProfile, - mProfiles.getWorkHandle(), - mProfiles.getCloneHandle(), - mMaxTargetsPerRow, - mFeatureFlags); + createCrossProfileIntentsChecker()); } private int findSelectedProfile() { - return getProfileForUser(mProfiles.getTabOwnerUserHandleForLaunch()); + return mProfiles.getLaunchedAsProfileType().ordinal(); } /** @@ -1475,7 +1461,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 @@ -1895,20 +1881,17 @@ public class ChooserActivity extends Hilt_ChooserActivity implements return mEventLog; } - @VisibleForTesting - public ChooserGridAdapter createChooserGridAdapter( + private ChooserGridAdapter createChooserGridAdapter( Context context, List payloadIntents, Intent[] initialIntents, - List rList, - boolean filterLastUsed, UserHandle userHandle) { ChooserListAdapter chooserListAdapter = createChooserListAdapter( context, payloadIntents, initialIntents, - rList, - filterLastUsed, + /* TODO: not used, remove. rList= */ null, + /* TODO: not used, remove. filterLastUsed= */ false, createListController(userHandle), userHandle, mRequest.getTargetIntent(), @@ -1921,7 +1904,7 @@ public class ChooserActivity extends Hilt_ChooserActivity implements new ChooserGridAdapter.ChooserActivityDelegate() { @Override public boolean shouldShowTabs() { - return hasWorkProfile(); + return mProfiles.getWorkProfilePresent(); } @Override @@ -2185,7 +2168,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(); } @@ -2229,19 +2212,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(mProfiles.getWorkHandle())) { - 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(); @@ -2326,8 +2296,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 = @@ -2365,7 +2335,7 @@ public class ChooserActivity extends Hilt_ChooserActivity implements } private void maybeSetupGlobalLayoutListener() { - if (hasWorkProfile()) { + if (mProfiles.getWorkProfilePresent()) { return; } final View recyclerView = mChooserMultiProfilePagerAdapter.getActiveAdapterView(); @@ -2401,7 +2371,7 @@ public class ChooserActivity extends Hilt_ChooserActivity implements } boolean isEmpty = mChooserMultiProfilePagerAdapter.getListAdapterForUserHandle( UserHandle.of(UserHandle.myUserId())).getCount() == 0; - return (mFeatureFlags.scrollablePreview() || hasWorkProfile()) + return (mFeatureFlags.scrollablePreview() || mProfiles.getWorkProfilePresent()) && (!isEmpty || shouldShowContentPreviewWhenEmpty()); } @@ -2471,7 +2441,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) @@ -2490,7 +2460,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/ChooserHelper.kt b/java/src/com/android/intentresolver/v2/ChooserHelper.kt index 5b514614..1498453b 100644 --- a/java/src/com/android/intentresolver/v2/ChooserHelper.kt +++ b/java/src/com/android/intentresolver/v2/ChooserHelper.kt @@ -24,7 +24,7 @@ import androidx.activity.viewModels import androidx.lifecycle.DefaultLifecycleObserver import androidx.lifecycle.Lifecycle import androidx.lifecycle.LifecycleOwner -import androidx.lifecycle.lifecycleScope +import com.android.intentresolver.inject.Background import com.android.intentresolver.v2.annotation.JavaInterop import com.android.intentresolver.v2.domain.interactor.UserInteractor import com.android.intentresolver.v2.shared.model.Profile @@ -35,8 +35,9 @@ import com.android.intentresolver.v2.validation.Valid import com.android.intentresolver.v2.validation.log import dagger.hilt.android.scopes.ActivityScoped import javax.inject.Inject +import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.flow.first -import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking private const val TAG: String = "ChooserHelper" @@ -48,13 +49,22 @@ private const val TAG: String = "ChooserHelper" */ @JavaInterop fun interface ChooserInitializer { - /** - * @param launchedAs the profile which launched this instance - * @param initialProfiles a snapshot of the launching user's profile group - */ - fun initialize(launchedAs: Profile, initialProfiles: List) + /** @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, + val availability: Map, + val launchedAs: Profile +) + /** * __Purpose__ * @@ -90,11 +100,11 @@ class ChooserHelper constructor( hostActivity: Activity, private val userInteractor: UserInteractor, + @Background private val background: CoroutineDispatcher, ) : DefaultLifecycleObserver { // This is guaranteed by Hilt, since only a ComponentActivity is injectable. private val activity: ComponentActivity = hostActivity as ComponentActivity private val viewModel by activity.viewModels() - private val lifecycleScope = activity.lifecycleScope private lateinit var activityInitializer: ChooserInitializer @@ -159,11 +169,13 @@ constructor( private fun initializeActivity(request: Valid) { request.warnings.forEach { it.log(TAG) } - // Note: Activity lifecycleScope uses Dispatchers.Main.immediate - lifecycleScope.launch { - val initialProfiles = userInteractor.profiles.first() - val launchedAsProfile = userInteractor.launchedAsProfile.first() - activityInitializer.initialize(launchedAsProfile, initialProfiles) - } + 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) } } diff --git a/java/src/com/android/intentresolver/v2/ProfileAvailability.kt b/java/src/com/android/intentresolver/v2/ProfileAvailability.kt index 2df25c41..4b183ecb 100644 --- a/java/src/com/android/intentresolver/v2/ProfileAvailability.kt +++ b/java/src/com/android/intentresolver/v2/ProfileAvailability.kt @@ -29,10 +29,11 @@ import kotlinx.coroutines.launch /** Provides availability status for profiles */ class ProfileAvailability( private val scope: CoroutineScope, - private val userInteractor: UserInteractor + private val userInteractor: UserInteractor, + initialState: Map ) { private val availability = - userInteractor.availability.stateIn(scope, SharingStarted.Eagerly, mapOf()) + userInteractor.availability.stateIn(scope, SharingStarted.Eagerly, initialState) /** Used by WorkProfilePausedEmptyStateProvider */ var waitingToEnableProfile = false @@ -62,7 +63,7 @@ class ProfileAvailability( val job = scope.launch { // Wait for the profile to become available - userInteractor.availability.filter { it[profile] == true }.first() + availability.filter { it[profile] == true }.first() } job.invokeOnCompletion { waitingToEnableProfile = false diff --git a/java/src/com/android/intentresolver/v2/ProfileHelper.kt b/java/src/com/android/intentresolver/v2/ProfileHelper.kt index 784096b4..29aab770 100644 --- a/java/src/com/android/intentresolver/v2/ProfileHelper.kt +++ b/java/src/com/android/intentresolver/v2/ProfileHelper.kt @@ -1,18 +1,18 @@ /* -* Copyright (C) 2024 The Android Open Source Project -* -* Licensed under the Apache License, Version 2.0 (the "License"); -* you may not use this file except in compliance with the License. -* You may obtain a copy of the License at -* -* http://www.apache.org/licenses/LICENSE-2.0 -* -* Unless required by applicable law or agreed to in writing, software -* distributed under the License is distributed on an "AS IS" BASIS, -* WITHOUT 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 @@ -23,20 +23,23 @@ import com.android.intentresolver.v2.shared.model.Profile import com.android.intentresolver.v2.shared.model.User import javax.inject.Inject -class ProfileHelper @Inject constructor( +class ProfileHelper +@Inject +constructor( interactor: UserInteractor, private val flags: IntentResolverFlags, - profiles: List, - launchedAsProfile: Profile, + val profiles: List, + 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 +48,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 +58,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/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 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/profiles/MultiProfilePagerAdapter.java b/java/src/com/android/intentresolver/v2/profiles/MultiProfilePagerAdapter.java index 43785db3..5d7cf26e 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 mListAdapterExtractor; diff --git a/tests/activity/src/com/android/intentresolver/v2/ChooserWrapperActivity.java b/tests/activity/src/com/android/intentresolver/v2/ChooserWrapperActivity.java index d13677e1..47d9c8c2 100644 --- a/tests/activity/src/com/android/intentresolver/v2/ChooserWrapperActivity.java +++ b/tests/activity/src/com/android/intentresolver/v2/ChooserWrapperActivity.java @@ -53,7 +53,7 @@ public class ChooserWrapperActivity extends ChooserActivity implements IChooserW private UsageStatsManager mUsm; @Override - public ChooserListAdapter createChooserListAdapter( + public final ChooserListAdapter createChooserListAdapter( Context context, List payloadIntents, Intent[] initialIntents, @@ -140,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; } diff --git a/tests/unit/src/com/android/intentresolver/v2/ProfileAvailabilityTest.kt b/tests/unit/src/com/android/intentresolver/v2/ProfileAvailabilityTest.kt index b4df058c..2022d967 100644 --- a/tests/unit/src/com/android/intentresolver/v2/ProfileAvailabilityTest.kt +++ b/tests/unit/src/com/android/intentresolver/v2/ProfileAvailabilityTest.kt @@ -16,7 +16,6 @@ package com.android.intentresolver.v2 -import android.util.Log 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,8 +26,6 @@ import kotlinx.coroutines.test.runCurrent import kotlinx.coroutines.test.runTest import org.junit.Test -private const val TAG = "ProfileAvailabilityTest" - @OptIn(ExperimentalCoroutinesApi::class) class ProfileAvailabilityTest { private val personalUser = User(0, User.Role.PERSONAL) @@ -42,7 +39,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 +58,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 +72,4 @@ class ProfileAvailabilityTest { assertThat(availability.waitingToEnableProfile).isFalse() } -} \ No newline at end of file +} -- cgit v1.2.3-59-g8ed1b From e53d019e8c14c7d17c99b6d75843cdb03fb81d81 Mon Sep 17 00:00:00 2001 From: Steve Elliott Date: Wed, 6 Mar 2024 10:51:18 -0500 Subject: Introduce payloadtoggle data layer Bug: 302691505 Flag: ACONFIG android.service.chooser.chooser_payload_toggling DEVELOPMENT Test: N/A - code isn't live Change-Id: I5f96b97919db21f3705add6d67fd97ae267c09e2 --- .../payloadtoggle/data/model/CustomActionModel.kt | 29 ++++++++++ .../data/repository/ActivityResultRepository.kt | 28 ++++++++++ .../data/repository/CursorPreviewsRepository.kt | 32 +++++++++++ .../data/repository/PreviewSelectionsRepository.kt | 29 ++++++++++ .../data/repository/TargetIntentRepository.kt | 40 ++++++++++++++ .../intent/CustomActionPendingIntentSender.kt | 64 ++++++++++++++++++++++ .../domain/intent/InitialCustomActionsModule.kt | 55 +++++++++++++++++++ .../domain/intent/PendingIntentSender.kt | 24 ++++++++ .../payloadtoggle/shared/model/PreviewModel.kt | 29 ++++++++++ .../payloadtoggle/shared/model/PreviewsModel.kt | 35 ++++++++++++ .../intentresolver/inject/ActivityModelModule.kt | 62 +++++++++++++++++++++ 11 files changed, 427 insertions(+) create mode 100644 java/src/com/android/intentresolver/contentpreview/payloadtoggle/data/model/CustomActionModel.kt create mode 100644 java/src/com/android/intentresolver/contentpreview/payloadtoggle/data/repository/ActivityResultRepository.kt create mode 100644 java/src/com/android/intentresolver/contentpreview/payloadtoggle/data/repository/CursorPreviewsRepository.kt create mode 100644 java/src/com/android/intentresolver/contentpreview/payloadtoggle/data/repository/PreviewSelectionsRepository.kt create mode 100644 java/src/com/android/intentresolver/contentpreview/payloadtoggle/data/repository/TargetIntentRepository.kt create mode 100644 java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/intent/CustomActionPendingIntentSender.kt create mode 100644 java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/intent/InitialCustomActionsModule.kt create mode 100644 java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/intent/PendingIntentSender.kt create mode 100644 java/src/com/android/intentresolver/contentpreview/payloadtoggle/shared/model/PreviewModel.kt create mode 100644 java/src/com/android/intentresolver/contentpreview/payloadtoggle/shared/model/PreviewsModel.kt create mode 100644 java/src/com/android/intentresolver/inject/ActivityModelModule.kt (limited to 'java/src') 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(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(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..8035580d --- /dev/null +++ b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/data/repository/PreviewSelectionsRepository.kt @@ -0,0 +1,29 @@ +/* + * Copyright (C) 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.intentresolver.contentpreview.payloadtoggle.data.repository + +import 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() { + /** Set of selected previews. */ + val selections = MutableStateFlow>(emptySet()) +} diff --git a/java/src/com/android/intentresolver/contentpreview/payloadtoggle/data/repository/TargetIntentRepository.kt b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/data/repository/TargetIntentRepository.kt new file mode 100644 index 00000000..c8436846 --- /dev/null +++ b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/data/repository/TargetIntentRepository.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.data.repository + +import android.content.Intent +import com.android.intentresolver.contentpreview.payloadtoggle.data.model.CustomActionModel +import com.android.intentresolver.inject.TargetIntent +import dagger.hilt.android.scopes.ViewModelScoped +import javax.inject.Inject +import kotlinx.coroutines.flow.MutableStateFlow + +/** Stores the target intent of the share sheet, and custom actions derived from the intent. */ +@ViewModelScoped +class TargetIntentRepository +@Inject +constructor( + @TargetIntent initialIntent: Intent, + initialActions: List, +) { + val targetIntent = MutableStateFlow(initialIntent) + + // TODO: this can probably be derived from [targetIntent]; right now, the [initialActions] are + // coming from a different place (ChooserRequest) than later ones (SelectionChangeCallback) + // and so this serves as the source of truth between the two. + val customActions = MutableStateFlow(initialActions) +} 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, + @CustomAction pendingIntentSender: PendingIntentSender, + ): List = chooserActions.map { it.toCustomActionModel(pendingIntentSender) } +} + +/** + * Returns a [CustomActionModel] that sends this [ChooserAction]'s + * [PendingIntent][ChooserAction.getAction]. + */ +fun ChooserAction.toCustomActionModel(pendingIntentSender: PendingIntentSender) = + CustomActionModel( + label = label, + icon = icon, + performAction = { + try { + pendingIntentSender.send(action) + } catch (_: PendingIntent.CanceledException) { + Log.d(TAG, "Custom action, $label, has been cancelled") + } + } + ) + +private const val TAG = "CustomShareActions" diff --git a/java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/intent/PendingIntentSender.kt b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/intent/PendingIntentSender.kt new file mode 100644 index 00000000..23ba31ba --- /dev/null +++ b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/intent/PendingIntentSender.kt @@ -0,0 +1,24 @@ +/* + * Copyright (C) 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.intentresolver.contentpreview.payloadtoggle.domain.intent + +import android.app.PendingIntent + +/** Sends [PendingIntent]s. */ +fun interface PendingIntentSender { + fun send(pendingIntent: PendingIntent) +} diff --git a/java/src/com/android/intentresolver/contentpreview/payloadtoggle/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, + /** 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/inject/ActivityModelModule.kt b/java/src/com/android/intentresolver/inject/ActivityModelModule.kt new file mode 100644 index 00000000..9a8b8768 --- /dev/null +++ b/java/src/com/android/intentresolver/inject/ActivityModelModule.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.inject + +import android.content.Intent +import android.service.chooser.ChooserAction +import androidx.lifecycle.SavedStateHandle +import com.android.intentresolver.v2.ui.model.ActivityModel +import com.android.intentresolver.v2.ui.model.ChooserRequest +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 + @ViewModelScoped + fun provideChooserRequest( + activityModel: ActivityModel, + flags: ChooserServiceFlags, + ): ValidationResult = readChooserRequest(activityModel, flags) + + @Provides + @TargetIntent + fun targetIntent(chooserReq: ValidationResult): Intent = + requireNotNull((chooserReq as? Valid)?.value?.targetIntent) { "no target intent available" } + + @Provides + fun customActions(chooserReq: ValidationResult): List = + requireNotNull((chooserReq as? Valid)?.value?.chooserActions) { + "no chooser actions available" + } +} + +@Qualifier @MustBeDocumented @Retention(AnnotationRetention.RUNTIME) annotation class TargetIntent -- cgit v1.2.3-59-g8ed1b From 280fd53ff42834beba56a4eff56e5e7802b250ee Mon Sep 17 00:00:00 2001 From: Steve Elliott Date: Wed, 6 Mar 2024 12:45:02 -0500 Subject: PayloadToggle domain layer preview data population This change introduces the logic by which Shareousel is initialized with previews, as well as how additional previews can be loaded if requested by the UI. Initially, previews are taken from the EXTRA_STREAM contents of the sharing intent. Simultaneously, a connection to the sharing-application's cursor is established and then queried; the initial data loaded from the cursor then replaces the initial content. When the UI requests, additional data from the cursor is loaded into memory; the loaded data is always contiguous, and so requests can only be made to load the data immediately before or after whatever is already loaded. Once an end of the cursor has been reached (either first or last row), any elements in the intitial selection set (EXTRA_STREAM) that have not yet appeared in the Cursor are appended, *if* those elements would have appeared in that direction relative to the "focused index" of the set. In order to limit memory usage, we limit the amount of data cached in memory from the Cursor; as additional data is loaded in one direction, data from the other direction can be evicted from the cache. Bug: 302691505 Flag: ACONFIG android.service.chooser.chooser_payload_toggling DEVELOPMENT Test: atest IntentResolver-tests-unit Change-Id: I0ba7bffc10b4369d55a14aec626ca2c22f95dbb7 --- .../contentpreview/PreviewViewModel.kt | 2 +- .../contentpreview/UriMetadataReader.kt | 31 +- .../payloadtoggle/domain/cursor/CursorResolver.kt | 24 ++ .../domain/cursor/PayloadToggleCursorResolver.kt | 69 +++++ .../domain/interactor/CursorPreviewsInteractor.kt | 294 ++++++++++++++++++ .../domain/interactor/FetchPreviewsInteractor.kt | 65 ++++ .../interactor/SetCursorPreviewsInteractor.kt | 59 ++++ .../payloadtoggle/domain/model/LoadDirection.kt | 23 ++ .../payloadtoggle/domain/model/LoadedWindow.kt | 102 +++++++ .../intentresolver/inject/ActivityModelModule.kt | 57 ++++ .../intentresolver/inject/SystemServices.kt | 13 +- .../com/android/intentresolver/util/BundleUtils.kt | 22 ++ .../intentresolver/util/CancellationSignalUtils.kt | 41 +++ .../intentresolver/util/ParallelIteration.kt | 50 +++ .../com/android/intentresolver/util/SyncUtils.kt | 33 ++ .../intentresolver/util/cursor/CursorView.kt | 59 ++++ .../android/intentresolver/util/cursor/Cursors.kt | 87 ++++++ .../intentresolver/util/cursor/PagedCursor.kt | 52 ++++ .../contentpreview/UriMetadataReaderTest.kt | 8 +- .../interactor/CursorPreviewsInteractorTest.kt | 271 +++++++++++++++++ .../interactor/FetchPreviewsInteractorTest.kt | 335 +++++++++++++++++++++ .../interactor/SetCursorPreviewsInteractorTest.kt | 108 +++++++ 22 files changed, 1792 insertions(+), 13 deletions(-) create mode 100644 java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/cursor/CursorResolver.kt create mode 100644 java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/cursor/PayloadToggleCursorResolver.kt create mode 100644 java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/interactor/CursorPreviewsInteractor.kt create mode 100644 java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/interactor/FetchPreviewsInteractor.kt create mode 100644 java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/interactor/SetCursorPreviewsInteractor.kt create mode 100644 java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/model/LoadDirection.kt create mode 100644 java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/model/LoadedWindow.kt create mode 100644 java/src/com/android/intentresolver/util/BundleUtils.kt create mode 100644 java/src/com/android/intentresolver/util/CancellationSignalUtils.kt create mode 100644 java/src/com/android/intentresolver/util/ParallelIteration.kt create mode 100644 java/src/com/android/intentresolver/util/SyncUtils.kt create mode 100644 java/src/com/android/intentresolver/util/cursor/CursorView.kt create mode 100644 java/src/com/android/intentresolver/util/cursor/Cursors.kt create mode 100644 java/src/com/android/intentresolver/util/cursor/PagedCursor.kt create mode 100644 tests/unit/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/interactor/CursorPreviewsInteractorTest.kt create mode 100644 tests/unit/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/interactor/FetchPreviewsInteractorTest.kt create mode 100644 tests/unit/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/interactor/SetCursorPreviewsInteractorTest.kt (limited to 'java/src') diff --git a/java/src/com/android/intentresolver/contentpreview/PreviewViewModel.kt b/java/src/com/android/intentresolver/contentpreview/PreviewViewModel.kt index d694c6ff..2468bb57 100644 --- a/java/src/com/android/intentresolver/contentpreview/PreviewViewModel.kt +++ b/java/src/com/android/intentresolver/contentpreview/PreviewViewModel.kt @@ -114,7 +114,7 @@ class PreviewViewModel( chooserIntent ) }, - UriMetadataReader(contentResolver, DefaultMimeTypeClassifier), + UriMetadataReaderImpl(contentResolver, DefaultMimeTypeClassifier)::getMetadata, TargetIntentModifier(targetIntent, getUri = { uri }, getMimeType = { mimeType }), SelectionChangeCallback(contentProviderUri, chooserIntent, contentResolver) ) 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/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 { + suspend fun getCursor(): CursorView? +} 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..286891d1 --- /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 com.android.intentresolver.inject.AdditionalContent +import com.android.intentresolver.inject.ChooserIntent +import com.android.intentresolver.util.Bundle +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 { + override suspend fun getCursor(): CursorView? = withCancellationSignal { signal -> + runCatching { + contentResolver.query( + cursorUri, + arrayOf(URI), + Bundle { putParcelable(Intent.EXTRA_INTENT, 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 + } +} + +/** [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/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, initialPreviews: Iterable) { + // 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 = 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, + 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 = + 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.handleOneLoadRequest( + state: CursorWindow, + pagedCursor: PagedCursor, + 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, + 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, + 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, + 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, + pageNum: Int, + unclaimedRecords: MutableUnclaimedMap, + ): PreviewMap = + mutableMapOf() + .readAndPutPage(state, pagedCursor, pageNum, unclaimedRecords) + + private suspend fun M.readAndPutPage( + state: CursorWindow, + pagedCursor: PagedCursor, + 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 Sequence.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.putAllUnclaimedRight(unclaimed: UnclaimedMap): M = + putAllUnclaimedWhere(unclaimed) { it >= focusedItemIdx } + + private fun M.putAllUnclaimedLeft(unclaimed: UnclaimedMap): M = + putAllUnclaimedWhere(unclaimed) { it < focusedItemIdx } +} + +private typealias CursorWindow = LoadedWindow + +/** + * 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> + +/** Mutable version of [UnclaimedMap]. */ +private typealias MutableUnclaimedMap = MutableMap> + +private typealias MutablePreviewMap = MutableMap + +private typealias PreviewMap = Map + +private fun 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.getPageUris(pageNum: Int): Sequence? = + 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/FetchPreviewsInteractor.kt b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/interactor/FetchPreviewsInteractor.kt new file mode 100644 index 00000000..032692cd --- /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 launch() = coroutineScope { + val cursor = async { cursorResolver.getCursor() } + val initialPreviewMap: Set = 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 = + 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/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, + startIndex: Int, + hasMoreLeft: Boolean, + hasMoreRight: Boolean, + ): Flow { + val loadingState = MutableStateFlow(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/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( + /** 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>, + /** Merged set of all cursor data within this window. */ + val merged: Map, + /** 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 LoadedWindow.shiftWindowRight( + newPage: Map, + hasMore: Boolean, +): LoadedWindow = + 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 LoadedWindow.expandWindowRight( + newPage: Map, + hasMore: Boolean, +): LoadedWindow = + 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 LoadedWindow.shiftWindowLeft( + newPage: Map, + hasMore: Boolean, +): LoadedWindow = + 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 LoadedWindow.expandWindowLeft( + newPage: Map, + hasMore: Boolean, +): LoadedWindow = + 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/inject/ActivityModelModule.kt b/java/src/com/android/intentresolver/inject/ActivityModelModule.kt index 9a8b8768..c08c7f4c 100644 --- a/java/src/com/android/intentresolver/inject/ActivityModelModule.kt +++ b/java/src/com/android/intentresolver/inject/ActivityModelModule.kt @@ -17,8 +17,10 @@ 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.ui.model.ActivityModel import com.android.intentresolver.v2.ui.model.ChooserRequest import com.android.intentresolver.v2.ui.viewmodel.readChooserRequest @@ -40,6 +42,10 @@ object ActivityModelModule { "ActivityModel missing in SavedStateHandle! (${ActivityModel.ACTIVITY_MODEL_KEY})" } + @Provides + @ChooserIntent + fun chooserIntent(activityModel: ActivityModel): Intent = activityModel.intent + @Provides @ViewModelScoped fun provideChooserRequest( @@ -57,6 +63,57 @@ object ActivityModelModule { requireNotNull((chooserReq as? Valid)?.value?.chooserActions) { "no chooser actions available" } + + @Provides + @ViewModelScoped + @ContentUris + fun selectedUris(chooserRequest: ValidationResult): List = + requireNotNull((chooserRequest as? Valid)?.value?.targetIntent?.contentUris?.toList()) { + "no selected uris available" + } + + @Provides + @FocusedItemIndex + fun focusedItemIndex(chooserReq: ValidationResult): Int = + requireNotNull((chooserReq as? Valid)?.value?.focusedItemPosition) { + "no focused item position available" + } + + @Provides + @AdditionalContent + fun additionalContentUri(chooserReq: ValidationResult): 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 + 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/SystemServices.kt b/java/src/com/android/intentresolver/inject/SystemServices.kt index 32894d43..4762f4a1 100644 --- a/java/src/com/android/intentresolver/inject/SystemServices.kt +++ b/java/src/com/android/intentresolver/inject/SystemServices.kt @@ -18,12 +18,15 @@ package com.android.intentresolver.inject import android.app.ActivityManager import android.app.admin.DevicePolicyManager 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 dagger.Binds import dagger.Module import dagger.Provides import dagger.hilt.InstallIn @@ -52,9 +55,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 diff --git a/java/src/com/android/intentresolver/util/BundleUtils.kt b/java/src/com/android/intentresolver/util/BundleUtils.kt new file mode 100644 index 00000000..da06afef --- /dev/null +++ b/java/src/com/android/intentresolver/util/BundleUtils.kt @@ -0,0 +1,22 @@ +/* + * Copyright (C) 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.intentresolver.util + +import android.os.Bundle + +/** Shorthand for `Bundle().apply { ... } */ +inline fun Bundle(block: Bundle.() -> Unit): Bundle = Bundle().apply(block) 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 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/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 Iterable.mapParallel( + parallelism: Int? = null, + block: suspend (A) -> B, +): List = + 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 Sequence.mapParallel( + parallelism: Int? = null, + block: suspend (A) -> B, +): List = asIterable().mapParallel(parallelism, block) + +private suspend fun Iterable.mapParallel(block: suspend (A) -> B): List = + 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 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 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 : 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 Cursor.viewBy(readRow: Cursor.() -> E): CursorView = + object : CursorView, Cursor by this@viewBy { + override fun readRow(): E? = immobilized().readRow() + } + +/** Returns a [CursorView] that begins (index 0) at [newStartIndex] of the given cursor. */ +fun CursorView.startAt(newStartIndex: Int): CursorView = + object : CursorView, 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 CursorView.limit(count: Int): CursorView = + object : CursorView, 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 CursorView.get(idx: Int): E? = if (moveToPosition(idx)) readRow() else null + +/** Returns a [Sequence] that iterates over the [CursorView] returning each row. */ +fun CursorView.asSequence(): Sequence = 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 : CursorView> { + /** 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 CursorView.paged(pageSize: Int): PagedCursor = + object : PagedCursor, 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 = + this@paged.startAt(position * pageSize).limit(pageSize).asSequence() + } 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/payloadtoggle/domain/interactor/CursorPreviewsInteractorTest.kt b/tests/unit/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/interactor/CursorPreviewsInteractorTest.kt new file mode 100644 index 00000000..b17b77e0 --- /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 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.Bundle +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 = (1..2), + focusedItemIndex: Int = initialSelection.count() / 2, + cursor: Iterable = (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, + focusedItemIndex: Int, + private val cursorRange: Iterable, + private val cursorStartPosition: Int, + pageSize: Int, + maxLoadedPages: Int, + ) { + val cursor = + MatrixCursor(arrayOf("uri")) + .apply { + extras = Bundle { putInt("position", 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 = + 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/FetchPreviewsInteractorTest.kt b/tests/unit/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/interactor/FetchPreviewsInteractorTest.kt new file mode 100644 index 00000000..2838b176 --- /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 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.Bundle +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 = (1..2), + focusedItemIndex: Int = initialSelection.count() / 2, + cursor: Iterable = (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, + focusedItemIndex: Int, + private val cursorRange: Iterable, + 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 { + private val mutex = Mutex(locked = true) + + fun complete() = mutex.unlock() + + override suspend fun getCursor(): CursorView = + mutex.withLock { + MatrixCursor(arrayOf("uri")) + .apply { + extras = Bundle { putInt("position", 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.launch() } + 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.launch() } + 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.launch() } + 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.launch() } + 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.launch() } + 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.launch() } + 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.launch() } + 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.launch() } + 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/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) + } + } +} -- cgit v1.2.3-59-g8ed1b From 71871fa2621be09aebfe18680cf7d84a66365cf9 Mon Sep 17 00:00:00 2001 From: Steve Elliott Date: Wed, 6 Mar 2024 14:37:26 -0500 Subject: PayloadToggle domain layer selection tracking Bug: 302691505 Flag: ACONFIG android.service.chooser.chooser_payload_toggling DEVELOPMENT Test: atest IntentResolver-tests-unit Change-Id: Ibb3c242abc90048304dc4c57b18a3102a0fd8fed --- .../contentpreview/PayloadToggleInteractor.kt | 16 +- .../contentpreview/PreviewViewModel.kt | 11 +- .../contentpreview/SelectionChangeCallback.kt | 97 ------ .../contentpreview/TargetIntentModifier.kt | 75 ----- .../domain/intent/TargetIntentModifier.kt | 92 ++++++ .../domain/interactor/CustomActionsInteractor.kt | 67 ++++ .../interactor/SelectablePreviewInteractor.kt | 45 +++ .../interactor/SelectablePreviewsInteractor.kt | 42 +++ .../domain/interactor/SelectionInteractor.kt | 32 ++ .../interactor/UpdateTargetIntentInteractor.kt | 65 ++++ .../payloadtoggle/domain/model/ActionModel.kt | 31 ++ .../domain/update/SelectionChangeCallback.kt | 128 ++++++++ .../intentresolver/logging/EventLogModule.kt | 8 +- .../com/android/intentresolver/v2/ChooserHelper.kt | 11 + .../contentpreview/SelectionChangeCallbackTest.kt | 357 -------------------- .../contentpreview/TargetIntentModifierTest.kt | 77 ----- .../domain/intent/TargetIntentModifierImplTest.kt | 78 +++++ .../interactor/CustomActionsInteractorTest.kt | 151 +++++++++ .../interactor/SelectablePreviewInteractorTest.kt | 97 ++++++ .../interactor/SelectablePreviewsInteractorTest.kt | 147 +++++++++ .../interactor/UpdateTargetIntentInteractorTest.kt | 85 +++++ .../update/SelectionChangeCallbackImplTest.kt | 358 +++++++++++++++++++++ .../com/android/intentresolver/util/TruthUtils.kt | 26 ++ 23 files changed, 1471 insertions(+), 625 deletions(-) delete mode 100644 java/src/com/android/intentresolver/contentpreview/SelectionChangeCallback.kt delete mode 100644 java/src/com/android/intentresolver/contentpreview/TargetIntentModifier.kt create mode 100644 java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/intent/TargetIntentModifier.kt create mode 100644 java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/interactor/CustomActionsInteractor.kt create mode 100644 java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/interactor/SelectablePreviewInteractor.kt create mode 100644 java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/interactor/SelectablePreviewsInteractor.kt create mode 100644 java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/interactor/SelectionInteractor.kt create mode 100644 java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/interactor/UpdateTargetIntentInteractor.kt create mode 100644 java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/model/ActionModel.kt create mode 100644 java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/update/SelectionChangeCallback.kt delete mode 100644 tests/unit/src/com/android/intentresolver/contentpreview/SelectionChangeCallbackTest.kt delete mode 100644 tests/unit/src/com/android/intentresolver/contentpreview/TargetIntentModifierTest.kt create mode 100644 tests/unit/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/intent/TargetIntentModifierImplTest.kt create mode 100644 tests/unit/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/interactor/CustomActionsInteractorTest.kt create mode 100644 tests/unit/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/interactor/SelectablePreviewInteractorTest.kt create mode 100644 tests/unit/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/interactor/SelectablePreviewsInteractorTest.kt create mode 100644 tests/unit/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/interactor/UpdateTargetIntentInteractorTest.kt create mode 100644 tests/unit/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/update/SelectionChangeCallbackImplTest.kt create mode 100644 tests/unit/src/com/android/intentresolver/util/TruthUtils.kt (limited to 'java/src') diff --git a/java/src/com/android/intentresolver/contentpreview/PayloadToggleInteractor.kt b/java/src/com/android/intentresolver/contentpreview/PayloadToggleInteractor.kt index eda5c4ca..cc82c0a9 100644 --- a/java/src/com/android/intentresolver/contentpreview/PayloadToggleInteractor.kt +++ b/java/src/com/android/intentresolver/contentpreview/PayloadToggleInteractor.kt @@ -17,12 +17,11 @@ 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 com.android.intentresolver.contentpreview.payloadtoggle.domain.update.SelectionChangeCallback.ShareouselUpdate import java.io.Closeable import java.util.LinkedList import java.util.concurrent.atomic.AtomicBoolean @@ -53,7 +52,7 @@ class PayloadToggleInteractor( private val cursorReaderProvider: suspend () -> CursorReader, private val uriMetadataReader: (Uri) -> FileInfo, private val targetIntentModifier: (List) -> Intent, - private val selectionCallback: (Intent) -> ShareouselUpdate?, + private val selectionCallback: suspend (Intent) -> ShareouselUpdate?, ) { private var cursorDataRef = CompletableDeferred() private val records = LinkedList() @@ -279,7 +278,7 @@ class PayloadToggleInteractor( private suspend fun waitForCursorData() = cursorDataRef.await() - private fun notifySelectionChanged(targetIntent: Intent) { + private suspend fun notifySelectionChanged(targetIntent: Intent) { selectionCallback(targetIntent)?.customActions?.let { customActions.tryEmit(it) } } @@ -338,15 +337,6 @@ class PayloadToggleInteractor( val isSelected = MutableStateFlow(false) } - data class ShareouselUpdate( - // for all properties, null value means no change - val customActions: List? = null, - val modifyShareAction: ChooserAction? = null, - val alternateIntents: List? = null, - val callerTargets: List? = null, - val refinementIntentSender: IntentSender? = null, - ) - private data class CursorData( val reader: CursorReader, val selectionTracker: SelectionTracker, diff --git a/java/src/com/android/intentresolver/contentpreview/PreviewViewModel.kt b/java/src/com/android/intentresolver/contentpreview/PreviewViewModel.kt index 2468bb57..f79f0525 100644 --- a/java/src/com/android/intentresolver/contentpreview/PreviewViewModel.kt +++ b/java/src/com/android/intentresolver/contentpreview/PreviewViewModel.kt @@ -27,6 +27,8 @@ import androidx.lifecycle.ViewModelProvider.AndroidViewModelFactory.Companion.AP import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewmodel.CreationExtras import com.android.intentresolver.R +import com.android.intentresolver.contentpreview.payloadtoggle.domain.intent.TargetIntentModifierImpl +import com.android.intentresolver.contentpreview.payloadtoggle.domain.update.SelectionChangeCallbackImpl import com.android.intentresolver.inject.Background import java.util.concurrent.Executors import kotlinx.coroutines.CoroutineDispatcher @@ -115,8 +117,13 @@ class PreviewViewModel( ) }, UriMetadataReaderImpl(contentResolver, DefaultMimeTypeClassifier)::getMetadata, - TargetIntentModifier(targetIntent, getUri = { uri }, getMimeType = { mimeType }), - SelectionChangeCallback(contentProviderUri, chooserIntent, contentResolver) + TargetIntentModifierImpl( + targetIntent, + getUri = { uri }, + getMimeType = { mimeType }, + )::onSelectionChanged, + SelectionChangeCallbackImpl(contentProviderUri, chooserIntent, contentResolver):: + onSelectionChanged, ) } 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 { - return validateFrom(bundle::get) { - val customActions = readChooserActions() - val modifyShareAction = - optional(value(EXTRA_CHOOSER_MODIFY_SHARE_ACTION)) - val alternateIntents = readAlternateIntents() - val callerTargets = optional(array(EXTRA_CHOOSER_TARGETS)) - val refinementIntentSender = - optional(value(EXTRA_CHOOSER_REFINEMENT_INTENT_SENDER)) - - ShareouselUpdate( - customActions, - modifyShareAction, - alternateIntents, - callerTargets, - refinementIntentSender, - ) - } - } -} diff --git a/java/src/com/android/intentresolver/contentpreview/TargetIntentModifier.kt b/java/src/com/android/intentresolver/contentpreview/TargetIntentModifier.kt deleted file mode 100644 index 58da5bc4..00000000 --- a/java/src/com/android/intentresolver/contentpreview/TargetIntentModifier.kt +++ /dev/null @@ -1,75 +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.ClipData -import android.content.ClipDescription.compareMimeTypes -import android.content.Intent -import android.content.Intent.ACTION_SEND -import android.content.Intent.ACTION_SEND_MULTIPLE -import android.content.Intent.EXTRA_STREAM -import android.net.Uri - -/** Modifies target intent based on current payload selection. */ -class TargetIntentModifier( - private val originalTargetIntent: Intent, - private val getUri: Item.() -> Uri, - private val getMimeType: Item.() -> String?, -) : (List) -> Intent { - fun onSelectionChanged(selection: List): Intent { - val uris = ArrayList(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 - return Intent(originalTargetIntent).apply { - this.action = action - this.type = targetMimeType - if (action == ACTION_SEND) { - putExtra(EXTRA_STREAM, uris[0]) - } else { - putParcelableArrayListExtra(EXTRA_STREAM, uris) - } - if (uris.isNotEmpty()) { - clipData = - ClipData("", arrayOf(targetMimeType), ClipData.Item(uris[0])).also { - for (i in 1 until uris.size) { - it.addItem(ClipData.Item(uris[i])) - } - } - } - } - } - - private fun updateMimeType(itemMimeType: String?, unitedMimeType: String?): String { - itemMimeType ?: return "*/*" - unitedMimeType ?: return itemMimeType - if (compareMimeTypes(itemMimeType, unitedMimeType)) return unitedMimeType - val slashIdx = unitedMimeType.indexOf('/') - if (slashIdx >= 0 && unitedMimeType.regionMatches(0, itemMimeType, 0, slashIdx + 1)) { - return buildString { - append(unitedMimeType.substring(0, slashIdx + 1)) - append('*') - } - } - return "*/*" - } - - override fun invoke(selection: List): Intent = onSelectionChanged(selection) -} diff --git a/java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/intent/TargetIntentModifier.kt b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/intent/TargetIntentModifier.kt new file mode 100644 index 00000000..577dc34c --- /dev/null +++ b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/intent/TargetIntentModifier.kt @@ -0,0 +1,92 @@ +/* + * Copyright (C) 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.intentresolver.contentpreview.payloadtoggle.domain.intent + +import android.content.ClipData +import android.content.ClipDescription.compareMimeTypes +import android.content.Intent +import android.content.Intent.ACTION_SEND +import android.content.Intent.ACTION_SEND_MULTIPLE +import android.content.Intent.EXTRA_STREAM +import android.net.Uri +import com.android.intentresolver.contentpreview.payloadtoggle.shared.model.PreviewModel +import com.android.intentresolver.inject.TargetIntent +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.android.components.ViewModelComponent + +/** Modifies target intent based on current payload selection. */ +fun interface TargetIntentModifier { + fun onSelectionChanged(selection: Collection): Intent +} + +class TargetIntentModifierImpl( + private val originalTargetIntent: Intent, + private val getUri: Item.() -> Uri, + private val getMimeType: Item.() -> String?, +) : TargetIntentModifier { + override fun onSelectionChanged(selection: Collection): Intent { + val uris = selection.mapTo(ArrayList()) { it.getUri() } + val targetMimeType = + selection.fold(null) { target: String?, item: Item -> + updateMimeType(item.getMimeType(), target) + } + return Intent(originalTargetIntent).apply { + if (selection.size == 1) { + action = ACTION_SEND + putExtra(EXTRA_STREAM, selection.first().getUri()) + } else { + action = ACTION_SEND_MULTIPLE + putParcelableArrayListExtra(EXTRA_STREAM, uris) + } + type = targetMimeType + if (uris.isNotEmpty()) { + clipData = + ClipData("", arrayOf(targetMimeType), ClipData.Item(uris[0])).also { + for (i in 1 until uris.size) { + it.addItem(ClipData.Item(uris[i])) + } + } + } + } + } + + private fun updateMimeType(itemMimeType: String?, unitedMimeType: String?): String { + itemMimeType ?: return "*/*" + unitedMimeType ?: return itemMimeType + if (compareMimeTypes(itemMimeType, unitedMimeType)) return unitedMimeType + val slashIdx = unitedMimeType.indexOf('/') + if (slashIdx >= 0 && unitedMimeType.regionMatches(0, itemMimeType, 0, slashIdx + 1)) { + return buildString { + append(unitedMimeType.substring(0, slashIdx + 1)) + append('*') + } + } + return "*/*" + } +} + +@Module +@InstallIn(ViewModelComponent::class) +object TargetIntentModifierModule { + @Provides + fun targetIntentModifier( + @TargetIntent targetIntent: Intent, + ): TargetIntentModifier = + TargetIntentModifierImpl(targetIntent, { uri }, { mimeType }) +} 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..56f781fb --- /dev/null +++ b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/interactor/CustomActionsInteractor.kt @@ -0,0 +1,67 @@ +/* + * Copyright (C) 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.intentresolver.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.data.repository.TargetIntentRepository +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 targetIntentRepo: TargetIntentRepository, +) { + /** List of [ActionModel] that can be presented in Shareousel. */ + val customActions: Flow> + get() = + targetIntentRepo.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/SelectablePreviewInteractor.kt b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/interactor/SelectablePreviewInteractor.kt new file mode 100644 index 00000000..d94b1078 --- /dev/null +++ b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/interactor/SelectablePreviewInteractor.kt @@ -0,0 +1,45 @@ +/* + * Copyright (C) 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.intentresolver.contentpreview.payloadtoggle.domain.interactor + +import android.net.Uri +import com.android.intentresolver.contentpreview.payloadtoggle.data.repository.PreviewSelectionsRepository +import com.android.intentresolver.contentpreview.payloadtoggle.shared.model.PreviewModel +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.update + +/** An individual preview in Shareousel. */ +class SelectablePreviewInteractor( + private val key: PreviewModel, + private val selectionRepo: PreviewSelectionsRepository, +) { + val uri: Uri = key.uri + + /** Whether or not this preview is selected by the user. */ + val isSelected: Flow + get() = selectionRepo.selections.map { key in it } + + /** Sets whether this preview is selected by the user. */ + fun setSelected(isSelected: Boolean) { + if (isSelected) { + selectionRepo.selections.update { it + key } + } else { + selectionRepo.selections.update { it - 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..78e208f6 --- /dev/null +++ b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/interactor/SelectablePreviewsInteractor.kt @@ -0,0 +1,42 @@ +/* + * Copyright (C) 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.intentresolver.contentpreview.payloadtoggle.domain.interactor + +import com.android.intentresolver.contentpreview.payloadtoggle.data.repository.CursorPreviewsRepository +import com.android.intentresolver.contentpreview.payloadtoggle.data.repository.PreviewSelectionsRepository +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 selectionRepo: PreviewSelectionsRepository, +) { + /** Keys of previews available for display in Shareousel. */ + val previews: Flow + 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 = key, selectionRepo = selectionRepo) +} 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..ee9bd689 --- /dev/null +++ b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/interactor/SelectionInteractor.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.domain.interactor + +import com.android.intentresolver.contentpreview.payloadtoggle.data.repository.PreviewSelectionsRepository +import javax.inject.Inject +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map + +class SelectionInteractor +@Inject +constructor( + private val selectionRepo: PreviewSelectionsRepository, +) { + /** Amount of selected previews. */ + val amountSelected: Flow + get() = selectionRepo.selections.map { it.size } +} 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..e7bdafbc --- /dev/null +++ b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/interactor/UpdateTargetIntentInteractor.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. + */ + +@file:OptIn(ExperimentalCoroutinesApi::class) + +package com.android.intentresolver.contentpreview.payloadtoggle.domain.interactor + +import com.android.intentresolver.contentpreview.payloadtoggle.data.repository.PreviewSelectionsRepository +import com.android.intentresolver.contentpreview.payloadtoggle.data.repository.TargetIntentRepository +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.TargetIntentModifier +import com.android.intentresolver.contentpreview.payloadtoggle.domain.intent.toCustomActionModel +import com.android.intentresolver.contentpreview.payloadtoggle.domain.update.SelectionChangeCallback +import com.android.intentresolver.contentpreview.payloadtoggle.shared.model.PreviewModel +import javax.inject.Inject +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.mapLatest +import kotlinx.coroutines.launch + +/** Updates [TargetIntentRepository] in reaction to user selection changes. */ +class UpdateTargetIntentInteractor +@Inject +constructor( + private val intentRepository: TargetIntentRepository, + @CustomAction private val pendingIntentSender: PendingIntentSender, + private val selectionCallback: SelectionChangeCallback, + private val selectionRepo: PreviewSelectionsRepository, + private val targetIntentModifier: TargetIntentModifier, +) { + /** Listen for events and update state. */ + suspend fun launch(): Unit = coroutineScope { + launch { + intentRepository.targetIntent + .mapLatest { targetIntent -> + selectionCallback.onSelectionChanged(targetIntent)?.customActions ?: emptyList() + } + .collect { actions -> + intentRepository.customActions.value = + actions.map { it.toCustomActionModel(pendingIntentSender) } + } + } + launch { + selectionRepo.selections.collectLatest { + intentRepository.targetIntent.value = targetIntentModifier.onSelectionChanged(it) + } + } + } +} 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/update/SelectionChangeCallback.kt b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/update/SelectionChangeCallback.kt new file mode 100644 index 00000000..03295a31 --- /dev/null +++ b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/update/SelectionChangeCallback.kt @@ -0,0 +1,128 @@ +/* + * Copyright (C) 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF 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_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.payloadtoggle.domain.update.SelectionChangeCallback.ShareouselUpdate +import com.android.intentresolver.inject.AdditionalContent +import com.android.intentresolver.inject.ChooserIntent +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? + + data class ShareouselUpdate( + // for all properties, null value means no change + val customActions: List? = null, + val modifyShareAction: ChooserAction? = null, + val alternateIntents: List? = null, + val callerTargets: List? = null, + val refinementIntentSender: IntentSender? = null, + ) +} + +class SelectionChangeCallbackImpl +@Inject +constructor( + @AdditionalContent private val uri: Uri, + @ChooserIntent private val chooserIntent: Intent, + private val contentResolver: ContentInterface, +) : SelectionChangeCallback { + private val mutex = Mutex() + + override suspend fun onSelectionChanged(targetIntent: Intent): ShareouselUpdate? = + mutex + .withLock { + contentResolver.call( + requireNotNull(uri.authority) { "URI authority can not be null" }, + ON_SELECTION_CHANGED, + uri.toString(), + Bundle().apply { + putParcelable( + EXTRA_INTENT, + Intent(chooserIntent).apply { putExtra(EXTRA_INTENT, targetIntent) } + ) + } + ) + } + ?.let { bundle -> + return when (val result = readCallbackResponse(bundle)) { + is Valid -> result.value + is Invalid -> { + result.errors.forEach { it.log(TAG) } + null + } + } + } +} + +private fun readCallbackResponse(bundle: Bundle): ValidationResult { + return validateFrom(bundle::get) { + val customActions = readChooserActions() + val modifyShareAction = optional(value(EXTRA_CHOOSER_MODIFY_SHARE_ACTION)) + val alternateIntents = readAlternateIntents() + val callerTargets = optional(array(EXTRA_CHOOSER_TARGETS)) + val refinementIntentSender = + optional(value(EXTRA_CHOOSER_REFINEMENT_INTENT_SENDER)) + + ShareouselUpdate( + customActions, + modifyShareAction, + alternateIntents, + callerTargets, + refinementIntentSender, + ) + } +} + +@Module +@InstallIn(ViewModelComponent::class) +interface SelectionChangeCallbackModule { + @Binds fun bind(impl: SelectionChangeCallbackImpl): SelectionChangeCallback +} 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/v2/ChooserHelper.kt b/java/src/com/android/intentresolver/v2/ChooserHelper.kt index 1498453b..d34e0b36 100644 --- a/java/src/com/android/intentresolver/v2/ChooserHelper.kt +++ b/java/src/com/android/intentresolver/v2/ChooserHelper.kt @@ -24,6 +24,8 @@ import androidx.activity.viewModels import androidx.lifecycle.DefaultLifecycleObserver import androidx.lifecycle.Lifecycle import androidx.lifecycle.LifecycleOwner +import androidx.lifecycle.lifecycleScope +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.domain.interactor.UserInteractor @@ -36,7 +38,9 @@ import com.android.intentresolver.v2.validation.log import dagger.hilt.android.scopes.ActivityScoped 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" @@ -100,6 +104,7 @@ class ChooserHelper 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. @@ -140,7 +145,13 @@ constructor( is Valid -> initializeActivity(request) is Invalid -> reportErrorsAndFinish(request) } + + activity.lifecycleScope.launch { + activity.setResult(activityResultRepo.activityResult.filterNotNull().first()) + activity.finish() + } } + override fun onStart(owner: LifecycleOwner) { Log.i(TAG, "START") } diff --git a/tests/unit/src/com/android/intentresolver/contentpreview/SelectionChangeCallbackTest.kt b/tests/unit/src/com/android/intentresolver/contentpreview/SelectionChangeCallbackTest.kt deleted file mode 100644 index 40f2ab26..00000000 --- a/tests/unit/src/com/android/intentresolver/contentpreview/SelectionChangeCallbackTest.kt +++ /dev/null @@ -1,357 +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.app.PendingIntent -import android.content.ComponentName -import android.content.ContentInterface -import android.content.Intent -import android.content.Intent.ACTION_CHOOSER -import android.content.Intent.ACTION_SEND -import android.content.Intent.ACTION_SEND_MULTIPLE -import android.content.Intent.EXTRA_ALTERNATE_INTENTS -import android.content.Intent.EXTRA_CHOOSER_CUSTOM_ACTIONS -import android.content.Intent.EXTRA_CHOOSER_MODIFY_SHARE_ACTION -import android.content.Intent.EXTRA_CHOOSER_REFINEMENT_INTENT_SENDER -import android.content.Intent.EXTRA_CHOOSER_TARGETS -import android.content.Intent.EXTRA_INTENT -import android.content.Intent.EXTRA_STREAM -import android.graphics.drawable.Icon -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 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.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 org.junit.Test -import org.junit.runner.RunWith -import org.mockito.Mockito.times -import org.mockito.Mockito.verify - -@RunWith(AndroidJUnit4::class) -class SelectionChangeCallbackTest { - private val uri = Uri.parse("content://org.pkg/content-provider") - private val chooserIntent = Intent(ACTION_CHOOSER) - private val contentResolver = mock() - private val context = InstrumentationRegistry.getInstrumentation().context - - @Test - fun testPayloadChangeCallbackContact() { - val testSubject = SelectionChangeCallback(uri, chooserIntent, contentResolver) - - val u1 = createUri(1) - val u2 = createUri(2) - val targetIntent = - Intent(ACTION_SEND_MULTIPLE).apply { - val uris = - ArrayList().apply { - add(u1) - add(u2) - } - putExtra(EXTRA_STREAM, uris) - type = "image/jpg" - } - testSubject.onSelectionChanged(targetIntent) - - val authorityCaptor = argumentCaptor() - val methodCaptor = argumentCaptor() - val argCaptor = argumentCaptor() - val extraCaptor = argumentCaptor() - verify(contentResolver, times(1)) - .call( - capture(authorityCaptor), - capture(methodCaptor), - capture(argCaptor), - capture(extraCaptor) - ) - assertWithMessage("Wrong additional content provider authority") - .that(authorityCaptor.value) - .isEqualTo(uri.authority) - assertWithMessage("Wrong additional content provider #call() method name") - .that(methodCaptor.value) - .isEqualTo(ON_SELECTION_CHANGED) - assertWithMessage("Wrong additional content provider argument value") - .that(argCaptor.value) - .isEqualTo(uri.toString()) - val extraBundle = extraCaptor.value - assertWithMessage("Additional content provider #call() should have a non-null extras arg.") - .that(extraBundle) - .isNotNull() - requireNotNull(extraBundle) - val argChooserIntent = extraBundle.getParcelable(EXTRA_INTENT, Intent::class.java) - assertWithMessage("#call() extras arg. should contain Intent#EXTRA_INTENT") - .that(argChooserIntent) - .isNotNull() - requireNotNull(argChooserIntent) - assertWithMessage("#call() extras arg's Intent#EXTRA_INTENT should be a Chooser intent") - .that(argChooserIntent.action) - .isEqualTo(chooserIntent.action) - val argTargetIntent = argChooserIntent.getParcelableExtra(EXTRA_INTENT, Intent::class.java) - assertWithMessage( - "A chooser intent passed into #call() method should contain updated target intent" - ) - .that(argTargetIntent) - .isNotNull() - requireNotNull(argTargetIntent) - assertWithMessage("Incorrect target intent") - .that(argTargetIntent.action) - .isEqualTo(targetIntent.action) - assertWithMessage("Incorrect target intent") - .that(argTargetIntent.getParcelableArrayListExtra(EXTRA_STREAM, Uri::class.java)) - .containsExactly(u1, u2) - .inOrder() - } - - @Test - fun testPayloadChangeCallbackUpdatesCustomActions() { - val a1 = - ChooserAction.Builder( - Icon.createWithContentUri(createUri(10)), - "Action 1", - PendingIntent.getBroadcast( - context, - 1, - Intent("test"), - PendingIntent.FLAG_IMMUTABLE - ) - ) - .build() - val a2 = - ChooserAction.Builder( - Icon.createWithContentUri(createUri(11)), - "Action 2", - PendingIntent.getBroadcast( - context, - 1, - Intent("test"), - PendingIntent.FLAG_IMMUTABLE - ) - ) - .build() - whenever(contentResolver.call(any(), any(), any(), any())) - .thenReturn( - Bundle().apply { putParcelableArray(EXTRA_CHOOSER_CUSTOM_ACTIONS, arrayOf(a1, a2)) } - ) - - val testSubject = SelectionChangeCallback(uri, chooserIntent, contentResolver) - - val targetIntent = Intent(ACTION_SEND_MULTIPLE) - val result = testSubject.onSelectionChanged(targetIntent) - assertWithMessage("Callback result should not be null").that(result).isNotNull() - requireNotNull(result) - assertWithMessage("Unexpected custom actions") - .that(result.customActions?.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() - } - - @Test - fun testPayloadChangeCallbackUpdatesReselectionAction() { - val modifyShare = - ChooserAction.Builder( - Icon.createWithContentUri(createUri(10)), - "Modify Share", - PendingIntent.getBroadcast( - context, - 1, - Intent("test"), - PendingIntent.FLAG_IMMUTABLE - ) - ) - .build() - whenever(contentResolver.call(any(), any(), any(), any())) - .thenReturn( - Bundle().apply { putParcelable(EXTRA_CHOOSER_MODIFY_SHARE_ACTION, modifyShare) } - ) - - val testSubject = SelectionChangeCallback(uri, chooserIntent, contentResolver) - - val targetIntent = Intent(ACTION_SEND) - val result = testSubject.onSelectionChanged(targetIntent) - assertWithMessage("Callback result should not be null").that(result).isNotNull() - requireNotNull(result) - assertWithMessage("Unexpected modify share action: wrong icon") - .that(result.modifyShareAction?.icon) - .isEqualTo(modifyShare.icon) - assertWithMessage("Unexpected modify share action: wrong label") - .that(result.modifyShareAction?.label) - .isEqualTo(modifyShare.label) - - assertThat(result.customActions).isNull() - assertThat(result.alternateIntents).isNull() - assertThat(result.callerTargets).isNull() - assertThat(result.refinementIntentSender).isNull() - } - - @Test - fun testPayloadChangeCallbackUpdatesAlternateIntents() { - val alternateIntents = - arrayOf( - Intent(ACTION_SEND_MULTIPLE).apply { - addCategory("test") - type = "" - } - ) - whenever(contentResolver.call(any(), any(), any(), any())) - .thenReturn( - Bundle().apply { putParcelableArray(EXTRA_ALTERNATE_INTENTS, alternateIntents) } - ) - - val testSubject = SelectionChangeCallback(uri, chooserIntent, contentResolver) - - val targetIntent = Intent(ACTION_SEND) - val result = testSubject.onSelectionChanged(targetIntent) - assertWithMessage("Callback result should not be null").that(result).isNotNull() - requireNotNull(result) - assertWithMessage("Wrong number of alternate intents") - .that(result.alternateIntents) - .hasSize(1) - assertWithMessage("Wrong alternate intent: action") - .that(result.alternateIntents?.get(0)?.action) - .isEqualTo(alternateIntents[0].action) - assertWithMessage("Wrong alternate intent: categories") - .that(result.alternateIntents?.get(0)?.categories) - .containsExactlyElementsIn(alternateIntents[0].categories) - assertWithMessage("Wrong alternate intent: mime type") - .that(result.alternateIntents?.get(0)?.type) - .isEqualTo(alternateIntents[0].type) - - assertThat(result.customActions).isNull() - assertThat(result.modifyShareAction).isNull() - assertThat(result.callerTargets).isNull() - assertThat(result.refinementIntentSender).isNull() - } - - @Test - fun testPayloadChangeCallbackUpdatesCallerTargets() { - val t1 = - ChooserTarget( - "Target 1", - Icon.createWithContentUri(createUri(1)), - 0.99f, - ComponentName("org.pkg.app", ".ClassA"), - null - ) - val t2 = - ChooserTarget( - "Target 2", - Icon.createWithContentUri(createUri(1)), - 1f, - ComponentName("org.pkg.app", ".ClassB"), - null - ) - whenever(contentResolver.call(any(), any(), any(), any())) - .thenReturn( - Bundle().apply { putParcelableArray(EXTRA_CHOOSER_TARGETS, arrayOf(t1, t2)) } - ) - - val testSubject = SelectionChangeCallback(uri, chooserIntent, contentResolver) - - val targetIntent = Intent(ACTION_SEND) - val result = testSubject.onSelectionChanged(targetIntent) - assertWithMessage("Callback result should not be null").that(result).isNotNull() - requireNotNull(result) - assertWithMessage("Wrong caller targets") - .that(result.callerTargets) - .comparingElementsUsing( - Correspondence.from( - BinaryPredicate { actual, expected -> - expected.componentName == actual?.componentName && - expected.title == actual?.title && - expected.icon == actual?.icon && - expected.score == actual?.score - }, - "" - ) - ) - .containsExactly(t1, t2) - .inOrder() - - assertThat(result.customActions).isNull() - assertThat(result.modifyShareAction).isNull() - assertThat(result.alternateIntents).isNull() - assertThat(result.refinementIntentSender).isNull() - } - - @Test - fun testPayloadChangeCallbackUpdatesRefinementIntentSender() { - val broadcast = - PendingIntent.getBroadcast(context, 1, Intent("test"), PendingIntent.FLAG_IMMUTABLE) - - whenever(contentResolver.call(any(), any(), any(), any())) - .thenReturn( - Bundle().apply { - putParcelable(EXTRA_CHOOSER_REFINEMENT_INTENT_SENDER, broadcast.intentSender) - } - ) - - val testSubject = SelectionChangeCallback(uri, chooserIntent, contentResolver) - - val targetIntent = Intent(ACTION_SEND) - val result = testSubject.onSelectionChanged(targetIntent) - assertWithMessage("Callback result should not be null").that(result).isNotNull() - requireNotNull(result) - assertThat(result.customActions).isNull() - assertThat(result.modifyShareAction).isNull() - assertThat(result.alternateIntents).isNull() - assertThat(result.callerTargets).isNull() - assertThat(result.refinementIntentSender).isNotNull() - } - - @Test - fun testPayloadChangeCallbackProvidesInvalidData_invalidDataIgnored() { - whenever(contentResolver.call(any(), any(), any(), any())) - .thenReturn( - Bundle().apply { - putParcelableArrayList(EXTRA_CHOOSER_CUSTOM_ACTIONS, ArrayList()) - putParcelable(EXTRA_CHOOSER_MODIFY_SHARE_ACTION, createUri(1)) - putParcelableArrayList(EXTRA_ALTERNATE_INTENTS, ArrayList()) - putParcelableArrayList(EXTRA_CHOOSER_TARGETS, ArrayList()) - putParcelable(EXTRA_CHOOSER_REFINEMENT_INTENT_SENDER, createUri(2)) - } - ) - - val testSubject = SelectionChangeCallback(uri, chooserIntent, contentResolver) - - val targetIntent = Intent(ACTION_SEND) - val result = testSubject.onSelectionChanged(targetIntent) - assertWithMessage("Callback result should not be null").that(result).isNotNull() - requireNotNull(result) - assertThat(result.customActions).isNull() - assertThat(result.modifyShareAction).isNull() - assertThat(result.alternateIntents).isNull() - assertThat(result.callerTargets).isNull() - assertThat(result.refinementIntentSender).isNull() - } -} - -private fun createUri(id: Int) = Uri.parse("content://org.pkg.images/$id.png") diff --git a/tests/unit/src/com/android/intentresolver/contentpreview/TargetIntentModifierTest.kt b/tests/unit/src/com/android/intentresolver/contentpreview/TargetIntentModifierTest.kt deleted file mode 100644 index b589f566..00000000 --- a/tests/unit/src/com/android/intentresolver/contentpreview/TargetIntentModifierTest.kt +++ /dev/null @@ -1,77 +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.Intent.ACTION_SEND -import android.content.Intent.ACTION_SEND_MULTIPLE -import android.content.Intent.EXTRA_STREAM -import android.net.Uri -import com.google.common.truth.Truth.assertThat -import org.junit.Test - -class TargetIntentModifierTest { - @Test - fun testIntentActionChange() { - val testSubject = TargetIntentModifier(Intent(ACTION_SEND), { this }, { "image/png" }) - - val u1 = createUri(1) - val u2 = createUri(2) - testSubject.onSelectionChanged(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 -> - assertThat(intent.action).isEqualTo(ACTION_SEND) - assertThat(intent.getParcelableExtra(EXTRA_STREAM, Uri::class.java)).isEqualTo(u1) - } - } - - @Test - fun testMimeTypeChange() { - val testSubject = - TargetIntentModifier>(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 -> - assertThat(intent.type).isEqualTo("image/png") - } - - testSubject.onSelectionChanged(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 - -> - assertThat(intent.type).isEqualTo("*/*") - } - - testSubject.onSelectionChanged(listOf(u1 to "image/png", u2 to null)).let { intent -> - assertThat(intent.type).isEqualTo("*/*") - } - } - - // TODO: test that the original intent's extras and flags remains the same -} - -private fun createUri(id: Int) = Uri.parse("content://org.pkg/$id") - -private data class Item(val uri: Uri, val mimeType: String?) diff --git a/tests/unit/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/intent/TargetIntentModifierImplTest.kt b/tests/unit/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/intent/TargetIntentModifierImplTest.kt new file mode 100644 index 00000000..f4be47ed --- /dev/null +++ b/tests/unit/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/intent/TargetIntentModifierImplTest.kt @@ -0,0 +1,78 @@ +/* + * Copyright 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.intentresolver.contentpreview.payloadtoggle.domain.intent + +import android.content.Intent +import android.content.Intent.ACTION_SEND +import android.content.Intent.ACTION_SEND_MULTIPLE +import android.content.Intent.EXTRA_STREAM +import android.net.Uri +import com.google.common.truth.Truth.assertThat +import org.junit.Test + +class TargetIntentModifierImplTest { + @Test + fun testIntentActionChange() { + val testSubject = + TargetIntentModifierImpl(Intent(ACTION_SEND), { this }, { "image/png" }) + + val u1 = createUri(1) + val u2 = createUri(2) + testSubject.onSelectionChanged(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 -> + assertThat(intent.action).isEqualTo(ACTION_SEND) + assertThat(intent.getParcelableExtra(EXTRA_STREAM, Uri::class.java)).isEqualTo(u1) + } + } + + @Test + fun testMimeTypeChange() { + val testSubject = + TargetIntentModifierImpl>(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 -> + assertThat(intent.type).isEqualTo("image/png") + } + + testSubject.onSelectionChanged(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 + -> + assertThat(intent.type).isEqualTo("*/*") + } + + testSubject.onSelectionChanged(listOf(u1 to "image/png", u2 to null)).let { intent -> + assertThat(intent.type).isEqualTo("*/*") + } + } + + // TODO: test that the original intent's extras and flags remains the same +} + +private fun createUri(id: Int) = Uri.parse("content://org.pkg/$id") + +private data class Item(val uri: Uri, val mimeType: String?) diff --git a/tests/unit/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/interactor/CustomActionsInteractorTest.kt b/tests/unit/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/interactor/CustomActionsInteractorTest.kt new file mode 100644 index 00000000..95ad966e --- /dev/null +++ b/tests/unit/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/interactor/CustomActionsInteractorTest.kt @@ -0,0 +1,151 @@ +/* + * Copyright (C) 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +@file:OptIn(ExperimentalCoroutinesApi::class) + +package com.android.intentresolver.contentpreview.payloadtoggle.domain.interactor + +import android.app.Activity +import android.content.Intent +import android.graphics.Bitmap +import android.graphics.drawable.Icon +import com.android.intentresolver.contentpreview.payloadtoggle.data.model.CustomActionModel +import com.android.intentresolver.contentpreview.payloadtoggle.data.repository.ActivityResultRepository +import com.android.intentresolver.contentpreview.payloadtoggle.data.repository.TargetIntentRepository +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.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 chooserActions = listOf(CustomActionModel("label1", icon) {}) + val underTest = + CustomActionsInteractor( + activityResultRepo = ActivityResultRepository(), + bgDispatcher = testDispatcher, + contentResolver = mock {}, + eventLog = mock {}, + packageManager = mock {}, + targetIntentRepo = + TargetIntentRepository( + initialIntent = Intent(), + initialActions = chooserActions, + ), + ) + val customActions: StateFlow> = + 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 targetIntentRepository = + TargetIntentRepository( + initialIntent = Intent(), + initialActions = emptyList(), + ) + val underTest = + CustomActionsInteractor( + activityResultRepo = ActivityResultRepository(), + bgDispatcher = testDispatcher, + contentResolver = mock {}, + eventLog = mock {}, + packageManager = mock {}, + targetIntentRepo = targetIntentRepository, + ) + + val customActions: StateFlow> = + 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) {}) + targetIntentRepository.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 chooserActions = listOf(CustomActionModel("label1", icon) { actionSent = true }) + val activityResultRepository = ActivityResultRepository() + val underTest = + CustomActionsInteractor( + activityResultRepo = activityResultRepository, + bgDispatcher = testDispatcher, + contentResolver = mock {}, + eventLog = mock {}, + packageManager = mock {}, + targetIntentRepo = + TargetIntentRepository( + initialIntent = Intent(), + initialActions = chooserActions, + ), + ) + val customActions: StateFlow> = + 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/SelectablePreviewInteractorTest.kt b/tests/unit/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/interactor/SelectablePreviewInteractorTest.kt new file mode 100644 index 00000000..00690307 --- /dev/null +++ b/tests/unit/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/interactor/SelectablePreviewInteractorTest.kt @@ -0,0 +1,97 @@ +/* + * Copyright (C) 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.intentresolver.contentpreview.payloadtoggle.domain.interactor + +import android.net.Uri +import com.android.intentresolver.contentpreview.payloadtoggle.data.repository.CursorPreviewsRepository +import com.android.intentresolver.contentpreview.payloadtoggle.data.repository.PreviewSelectionsRepository +import com.android.intentresolver.contentpreview.payloadtoggle.shared.model.PreviewModel +import com.android.intentresolver.contentpreview.payloadtoggle.shared.model.PreviewsModel +import com.google.common.truth.Truth.assertThat +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.test.runTest +import org.junit.Test + +class SelectablePreviewInteractorTest { + + @Test + fun reflectPreviewRepo_initState() = 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 underTest = + SelectablePreviewInteractor( + key = PreviewModel(Uri.fromParts("scheme", "ssp", "fragment"), null), + selectionRepo = PreviewSelectionsRepository(), + ) + + assertThat(underTest.isSelected.first()).isFalse() + } + + @Test + fun reflectPreviewRepo_updatedState() = runTest { + val repo = CursorPreviewsRepository() + val selectionRepository = PreviewSelectionsRepository() + val underTest = + SelectablePreviewInteractor( + key = PreviewModel(Uri.fromParts("scheme", "ssp", "fragment"), "image/bitmap"), + selectionRepo = selectionRepository, + ) + + assertThat(underTest.isSelected.first()).isFalse() + + repo.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, + ) + + selectionRepository.selections.value = + setOf(PreviewModel(Uri.fromParts("scheme", "ssp", "fragment"), "image/bitmap")) + + assertThat(underTest.isSelected.first()).isTrue() + } +} 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..53d2ac46 --- /dev/null +++ b/tests/unit/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/interactor/SelectablePreviewsInteractorTest.kt @@ -0,0 +1,147 @@ +/* + * Copyright (C) 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +@file:OptIn(ExperimentalCoroutinesApi::class) + +package com.android.intentresolver.contentpreview.payloadtoggle.domain.interactor + +import android.net.Uri +import com.android.intentresolver.contentpreview.payloadtoggle.data.repository.CursorPreviewsRepository +import com.android.intentresolver.contentpreview.payloadtoggle.data.repository.PreviewSelectionsRepository +import com.android.intentresolver.contentpreview.payloadtoggle.shared.model.PreviewModel +import com.android.intentresolver.contentpreview.payloadtoggle.shared.model.PreviewsModel +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 underTest = + SelectablePreviewsInteractor( + previewsRepo = repo, + selectionRepo = selectionRepo, + ) + 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 underTest = SelectablePreviewsInteractor(previewsRepo, selectionRepo) + 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/UpdateTargetIntentInteractorTest.kt b/tests/unit/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/interactor/UpdateTargetIntentInteractorTest.kt new file mode 100644 index 00000000..cd6b8de7 --- /dev/null +++ b/tests/unit/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/interactor/UpdateTargetIntentInteractorTest.kt @@ -0,0 +1,85 @@ +/* + * Copyright (C) 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +@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.PreviewSelectionsRepository +import com.android.intentresolver.contentpreview.payloadtoggle.data.repository.TargetIntentRepository +import com.android.intentresolver.contentpreview.payloadtoggle.shared.model.PreviewModel +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 UpdateTargetIntentInteractorTest { + @Test + fun updateTargetIntentWithSelection() = runTest { + val initialIntent = Intent() + val intentRepository = TargetIntentRepository(initialIntent, emptyList()) + val selectionRepository = PreviewSelectionsRepository() + val underTest = + UpdateTargetIntentInteractor( + intentRepository = intentRepository, + selectionCallback = { intent -> null }, + selectionRepo = selectionRepository, + targetIntentModifier = { selection -> + Intent() + .putParcelableArrayListExtra( + "selection", + selection.mapTo(ArrayList()) { it.uri }, + ) + }, + pendingIntentSender = {}, + ) + + backgroundScope.launch { underTest.launch() } + runCurrent() + + assertThat( + intentRepository.targetIntent.value.getParcelableArrayListExtra( + "selection", + Uri::class.java, + ) + ) + .isEmpty() + + selectionRepository.selections.value = + setOf( + PreviewModel(Uri.fromParts("scheme0", "ssp0", "fragment0"), null), + PreviewModel(Uri.fromParts("scheme1", "ssp1", "fragment1"), null), + PreviewModel(Uri.fromParts("scheme2", "ssp2", "fragment2"), null), + ) + runCurrent() + + assertThat( + intentRepository.targetIntent.value.getParcelableArrayListExtra( + "selection", + Uri::class.java, + ) + ) + .containsExactly( + Uri.fromParts("scheme0", "ssp0", "fragment0"), + Uri.fromParts("scheme1", "ssp1", "fragment1"), + Uri.fromParts("scheme2", "ssp2", "fragment2"), + ) + } +} diff --git a/tests/unit/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/update/SelectionChangeCallbackImplTest.kt b/tests/unit/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/update/SelectionChangeCallbackImplTest.kt new file mode 100644 index 00000000..d7c4170d --- /dev/null +++ b/tests/unit/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/update/SelectionChangeCallbackImplTest.kt @@ -0,0 +1,358 @@ +/* + * Copyright 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.intentresolver.contentpreview.payloadtoggle.domain.update + +import android.app.PendingIntent +import android.content.ComponentName +import android.content.ContentInterface +import android.content.Intent +import android.content.Intent.ACTION_CHOOSER +import android.content.Intent.ACTION_SEND +import android.content.Intent.ACTION_SEND_MULTIPLE +import android.content.Intent.EXTRA_ALTERNATE_INTENTS +import android.content.Intent.EXTRA_CHOOSER_CUSTOM_ACTIONS +import android.content.Intent.EXTRA_CHOOSER_MODIFY_SHARE_ACTION +import android.content.Intent.EXTRA_CHOOSER_REFINEMENT_INTENT_SENDER +import android.content.Intent.EXTRA_CHOOSER_TARGETS +import android.content.Intent.EXTRA_INTENT +import android.content.Intent.EXTRA_STREAM +import android.graphics.drawable.Icon +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 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.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 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 SelectionChangeCallbackImplTest { + private val uri = Uri.parse("content://org.pkg/content-provider") + private val chooserIntent = Intent(ACTION_CHOOSER) + private val contentResolver = mock() + private val context = InstrumentationRegistry.getInstrumentation().context + + @Test + fun testPayloadChangeCallbackContact() = runTest { + val testSubject = SelectionChangeCallbackImpl(uri, chooserIntent, contentResolver) + + val u1 = createUri(1) + val u2 = createUri(2) + val targetIntent = + Intent(ACTION_SEND_MULTIPLE).apply { + val uris = + ArrayList().apply { + add(u1) + add(u2) + } + putExtra(EXTRA_STREAM, uris) + type = "image/jpg" + } + testSubject.onSelectionChanged(targetIntent) + + val authorityCaptor = argumentCaptor() + val methodCaptor = argumentCaptor() + val argCaptor = argumentCaptor() + val extraCaptor = argumentCaptor() + verify(contentResolver, times(1)) + .call( + capture(authorityCaptor), + capture(methodCaptor), + capture(argCaptor), + capture(extraCaptor) + ) + assertWithMessage("Wrong additional content provider authority") + .that(authorityCaptor.value) + .isEqualTo(uri.authority) + assertWithMessage("Wrong additional content provider #call() method name") + .that(methodCaptor.value) + .isEqualTo(ON_SELECTION_CHANGED) + assertWithMessage("Wrong additional content provider argument value") + .that(argCaptor.value) + .isEqualTo(uri.toString()) + val extraBundle = extraCaptor.value + assertWithMessage("Additional content provider #call() should have a non-null extras arg.") + .that(extraBundle) + .isNotNull() + requireNotNull(extraBundle) + val argChooserIntent = extraBundle.getParcelable(EXTRA_INTENT, Intent::class.java) + assertWithMessage("#call() extras arg. should contain Intent#EXTRA_INTENT") + .that(argChooserIntent) + .isNotNull() + requireNotNull(argChooserIntent) + assertWithMessage("#call() extras arg's Intent#EXTRA_INTENT should be a Chooser intent") + .that(argChooserIntent.action) + .isEqualTo(chooserIntent.action) + val argTargetIntent = argChooserIntent.getParcelableExtra(EXTRA_INTENT, Intent::class.java) + assertWithMessage( + "A chooser intent passed into #call() method should contain updated target intent" + ) + .that(argTargetIntent) + .isNotNull() + requireNotNull(argTargetIntent) + assertWithMessage("Incorrect target intent") + .that(argTargetIntent.action) + .isEqualTo(targetIntent.action) + assertWithMessage("Incorrect target intent") + .that(argTargetIntent.getParcelableArrayListExtra(EXTRA_STREAM, Uri::class.java)) + .containsExactly(u1, u2) + .inOrder() + } + + @Test + fun testPayloadChangeCallbackUpdatesCustomActions() = runTest { + val a1 = + ChooserAction.Builder( + Icon.createWithContentUri(createUri(10)), + "Action 1", + PendingIntent.getBroadcast( + context, + 1, + Intent("test"), + PendingIntent.FLAG_IMMUTABLE + ) + ) + .build() + val a2 = + ChooserAction.Builder( + Icon.createWithContentUri(createUri(11)), + "Action 2", + PendingIntent.getBroadcast( + context, + 1, + Intent("test"), + PendingIntent.FLAG_IMMUTABLE + ) + ) + .build() + whenever(contentResolver.call(any(), any(), any(), any())) + .thenReturn( + Bundle().apply { putParcelableArray(EXTRA_CHOOSER_CUSTOM_ACTIONS, arrayOf(a1, a2)) } + ) + + val testSubject = SelectionChangeCallbackImpl(uri, chooserIntent, contentResolver) + + val targetIntent = Intent(ACTION_SEND_MULTIPLE) + val result = testSubject.onSelectionChanged(targetIntent) + assertWithMessage("Callback result should not be null").that(result).isNotNull() + requireNotNull(result) + assertWithMessage("Unexpected custom actions") + .that(result.customActions?.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() + } + + @Test + fun testPayloadChangeCallbackUpdatesReselectionAction() = runTest { + val modifyShare = + ChooserAction.Builder( + Icon.createWithContentUri(createUri(10)), + "Modify Share", + PendingIntent.getBroadcast( + context, + 1, + Intent("test"), + PendingIntent.FLAG_IMMUTABLE + ) + ) + .build() + whenever(contentResolver.call(any(), any(), any(), any())) + .thenReturn( + Bundle().apply { putParcelable(EXTRA_CHOOSER_MODIFY_SHARE_ACTION, modifyShare) } + ) + + val testSubject = SelectionChangeCallbackImpl(uri, chooserIntent, contentResolver) + + val targetIntent = Intent(ACTION_SEND) + val result = testSubject.onSelectionChanged(targetIntent) + assertWithMessage("Callback result should not be null").that(result).isNotNull() + requireNotNull(result) + assertWithMessage("Unexpected modify share action: wrong icon") + .that(result.modifyShareAction?.icon) + .isEqualTo(modifyShare.icon) + assertWithMessage("Unexpected modify share action: wrong label") + .that(result.modifyShareAction?.label) + .isEqualTo(modifyShare.label) + + assertThat(result.customActions).isNull() + assertThat(result.alternateIntents).isNull() + assertThat(result.callerTargets).isNull() + assertThat(result.refinementIntentSender).isNull() + } + + @Test + fun testPayloadChangeCallbackUpdatesAlternateIntents() = runTest { + val alternateIntents = + arrayOf( + Intent(ACTION_SEND_MULTIPLE).apply { + addCategory("test") + type = "" + } + ) + whenever(contentResolver.call(any(), any(), any(), any())) + .thenReturn( + Bundle().apply { putParcelableArray(EXTRA_ALTERNATE_INTENTS, alternateIntents) } + ) + + val testSubject = SelectionChangeCallbackImpl(uri, chooserIntent, contentResolver) + + val targetIntent = Intent(ACTION_SEND) + val result = testSubject.onSelectionChanged(targetIntent) + assertWithMessage("Callback result should not be null").that(result).isNotNull() + requireNotNull(result) + assertWithMessage("Wrong number of alternate intents") + .that(result.alternateIntents) + .hasSize(1) + assertWithMessage("Wrong alternate intent: action") + .that(result.alternateIntents?.get(0)?.action) + .isEqualTo(alternateIntents[0].action) + assertWithMessage("Wrong alternate intent: categories") + .that(result.alternateIntents?.get(0)?.categories) + .containsExactlyElementsIn(alternateIntents[0].categories) + assertWithMessage("Wrong alternate intent: mime type") + .that(result.alternateIntents?.get(0)?.type) + .isEqualTo(alternateIntents[0].type) + + assertThat(result.customActions).isNull() + assertThat(result.modifyShareAction).isNull() + assertThat(result.callerTargets).isNull() + assertThat(result.refinementIntentSender).isNull() + } + + @Test + fun testPayloadChangeCallbackUpdatesCallerTargets() = runTest { + val t1 = + ChooserTarget( + "Target 1", + Icon.createWithContentUri(createUri(1)), + 0.99f, + ComponentName("org.pkg.app", ".ClassA"), + null + ) + val t2 = + ChooserTarget( + "Target 2", + Icon.createWithContentUri(createUri(1)), + 1f, + ComponentName("org.pkg.app", ".ClassB"), + null + ) + whenever(contentResolver.call(any(), any(), any(), any())) + .thenReturn( + Bundle().apply { putParcelableArray(EXTRA_CHOOSER_TARGETS, arrayOf(t1, t2)) } + ) + + val testSubject = SelectionChangeCallbackImpl(uri, chooserIntent, contentResolver) + + val targetIntent = Intent(ACTION_SEND) + val result = testSubject.onSelectionChanged(targetIntent) + assertWithMessage("Callback result should not be null").that(result).isNotNull() + requireNotNull(result) + assertWithMessage("Wrong caller targets") + .that(result.callerTargets) + .comparingElementsUsing( + Correspondence.from( + BinaryPredicate { actual, expected -> + expected.componentName == actual?.componentName && + expected.title == actual?.title && + expected.icon == actual?.icon && + expected.score == actual?.score + }, + "" + ) + ) + .containsExactly(t1, t2) + .inOrder() + + assertThat(result.customActions).isNull() + assertThat(result.modifyShareAction).isNull() + assertThat(result.alternateIntents).isNull() + assertThat(result.refinementIntentSender).isNull() + } + + @Test + fun testPayloadChangeCallbackUpdatesRefinementIntentSender() = runTest { + val broadcast = + PendingIntent.getBroadcast(context, 1, Intent("test"), PendingIntent.FLAG_IMMUTABLE) + + whenever(contentResolver.call(any(), any(), any(), any())) + .thenReturn( + Bundle().apply { + putParcelable(EXTRA_CHOOSER_REFINEMENT_INTENT_SENDER, broadcast.intentSender) + } + ) + + val testSubject = SelectionChangeCallbackImpl(uri, chooserIntent, contentResolver) + + val targetIntent = Intent(ACTION_SEND) + val result = testSubject.onSelectionChanged(targetIntent) + assertWithMessage("Callback result should not be null").that(result).isNotNull() + requireNotNull(result) + assertThat(result.customActions).isNull() + assertThat(result.modifyShareAction).isNull() + assertThat(result.alternateIntents).isNull() + assertThat(result.callerTargets).isNull() + assertThat(result.refinementIntentSender).isNotNull() + } + + @Test + fun testPayloadChangeCallbackProvidesInvalidData_invalidDataIgnored() = runTest { + whenever(contentResolver.call(any(), any(), any(), any())) + .thenReturn( + Bundle().apply { + putParcelableArrayList(EXTRA_CHOOSER_CUSTOM_ACTIONS, ArrayList()) + putParcelable(EXTRA_CHOOSER_MODIFY_SHARE_ACTION, createUri(1)) + putParcelableArrayList(EXTRA_ALTERNATE_INTENTS, ArrayList()) + putParcelableArrayList(EXTRA_CHOOSER_TARGETS, ArrayList()) + putParcelable(EXTRA_CHOOSER_REFINEMENT_INTENT_SENDER, createUri(2)) + } + ) + + val testSubject = SelectionChangeCallbackImpl(uri, chooserIntent, contentResolver) + + val targetIntent = Intent(ACTION_SEND) + val result = testSubject.onSelectionChanged(targetIntent) + assertWithMessage("Callback result should not be null").that(result).isNotNull() + requireNotNull(result) + assertThat(result.customActions).isNull() + assertThat(result.modifyShareAction).isNull() + assertThat(result.alternateIntents).isNull() + assertThat(result.callerTargets).isNull() + assertThat(result.refinementIntentSender).isNull() + } +} + +private fun createUri(id: Int) = Uri.parse("content://org.pkg.images/$id.png") 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 IterableSubject.comparingElementsUsingTransform( + description: String, + function: (A) -> B, +): IterableSubject.UsingCorrespondence = + comparingElementsUsing(Correspondence.transforming(function, description)) -- cgit v1.2.3-59-g8ed1b From 8f8cf85bd1252bc62065b42afef104440fb4fe26 Mon Sep 17 00:00:00 2001 From: Steve Elliott Date: Wed, 6 Mar 2024 15:54:50 -0500 Subject: Shareousel ui layer Bug: 302691505 Flag: ACONFIG android.service.chooser.chooser_payload_toggling DEVELOPMENT Test: atest IntentResolver-tests-unit Change-Id: Ia292f2274c94259b173aa09d187840f03921dfc5 --- .../android/intentresolver/ChooserActivity.java | 2 - .../contentpreview/BasePreviewViewModel.kt | 3 - .../contentpreview/CursorUriReader.kt | 147 -------- .../contentpreview/HeadlineGeneratorImpl.kt | 18 +- .../contentpreview/MutableActionFactory.kt | 29 -- .../contentpreview/PayloadToggleInteractor.kt | 372 --------------------- .../contentpreview/PreviewViewModel.kt | 51 --- .../contentpreview/SelectionTracker.kt | 175 ---------- .../contentpreview/ShareouselContentPreviewUi.kt | 107 ++---- .../viewmodel/ShareouselContentPreviewViewModel.kt | 46 +++ .../ui/composable/ComposeIconComposable.kt | 50 +++ .../ui/composable/ShareouselCardComposable.kt | 102 ++++++ .../ui/composable/ShareouselComposable.kt | 190 +++++++++++ .../ui/viewmodel/ActionChipViewModel.kt | 29 ++ .../ui/viewmodel/ShareouselPreviewViewModel.kt | 39 +++ .../ui/viewmodel/ShareouselViewModel.kt | 141 ++++++++ .../ui/composable/ComposeIconComposable.kt | 48 --- .../ui/composable/ShareouselCardComposable.kt | 98 ------ .../ui/composable/ShareouselComposable.kt | 146 -------- .../shareousel/ui/viewmodel/ShareouselViewModel.kt | 90 ----- .../android/intentresolver/inject/Qualifiers.kt | 5 + .../inject/ViewModelCoroutineScopeModule.kt | 42 +++ .../android/intentresolver/v2/ChooserActivity.java | 23 +- .../v2/ChooserMutableActionFactory.kt | 54 --- .../intentresolver/logging/TestEventLogModule.kt | 8 +- .../android/intentresolver/MockitoKotlinHelpers.kt | 66 ++-- .../intentresolver/TestContentPreviewViewModel.kt | 14 +- .../contentpreview/CursorUriReaderTest.kt | 125 ------- .../contentpreview/PayloadToggleInteractorTest.kt | 162 --------- .../contentpreview/PreviewViewModelTest.kt | 81 ----- .../contentpreview/SelectionTrackerTest.kt | 330 ------------------ .../ui/viewmodel/ShareouselViewModelTest.kt | 243 ++++++++++++++ .../v2/ChooserMutableActionFactoryTest.kt | 139 -------- 33 files changed, 982 insertions(+), 2193 deletions(-) delete mode 100644 java/src/com/android/intentresolver/contentpreview/CursorUriReader.kt delete mode 100644 java/src/com/android/intentresolver/contentpreview/MutableActionFactory.kt delete mode 100644 java/src/com/android/intentresolver/contentpreview/PayloadToggleInteractor.kt delete mode 100644 java/src/com/android/intentresolver/contentpreview/SelectionTracker.kt create mode 100644 java/src/com/android/intentresolver/contentpreview/payloadtoggle/app/viewmodel/ShareouselContentPreviewViewModel.kt create mode 100644 java/src/com/android/intentresolver/contentpreview/payloadtoggle/ui/composable/ComposeIconComposable.kt create mode 100644 java/src/com/android/intentresolver/contentpreview/payloadtoggle/ui/composable/ShareouselCardComposable.kt create mode 100644 java/src/com/android/intentresolver/contentpreview/payloadtoggle/ui/composable/ShareouselComposable.kt create mode 100644 java/src/com/android/intentresolver/contentpreview/payloadtoggle/ui/viewmodel/ActionChipViewModel.kt create mode 100644 java/src/com/android/intentresolver/contentpreview/payloadtoggle/ui/viewmodel/ShareouselPreviewViewModel.kt create mode 100644 java/src/com/android/intentresolver/contentpreview/payloadtoggle/ui/viewmodel/ShareouselViewModel.kt delete mode 100644 java/src/com/android/intentresolver/contentpreview/shareousel/ui/composable/ComposeIconComposable.kt delete mode 100644 java/src/com/android/intentresolver/contentpreview/shareousel/ui/composable/ShareouselCardComposable.kt delete mode 100644 java/src/com/android/intentresolver/contentpreview/shareousel/ui/composable/ShareouselComposable.kt delete mode 100644 java/src/com/android/intentresolver/contentpreview/shareousel/ui/viewmodel/ShareouselViewModel.kt create mode 100644 java/src/com/android/intentresolver/inject/ViewModelCoroutineScopeModule.kt delete mode 100644 java/src/com/android/intentresolver/v2/ChooserMutableActionFactory.kt delete mode 100644 tests/unit/src/com/android/intentresolver/contentpreview/CursorUriReaderTest.kt delete mode 100644 tests/unit/src/com/android/intentresolver/contentpreview/PayloadToggleInteractorTest.kt delete mode 100644 tests/unit/src/com/android/intentresolver/contentpreview/PreviewViewModelTest.kt delete mode 100644 tests/unit/src/com/android/intentresolver/contentpreview/SelectionTrackerTest.kt create mode 100644 tests/unit/src/com/android/intentresolver/contentpreview/payloadtoggle/ui/viewmodel/ShareouselViewModelTest.kt delete mode 100644 tests/unit/src/com/android/intentresolver/v2/ChooserMutableActionFactoryTest.kt (limited to 'java/src') diff --git a/java/src/com/android/intentresolver/ChooserActivity.java b/java/src/com/android/intentresolver/ChooserActivity.java index 039fad56..e36e9df3 100644 --- a/java/src/com/android/intentresolver/ChooserActivity.java +++ b/java/src/com/android/intentresolver/ChooserActivity.java @@ -305,9 +305,7 @@ public class ChooserActivity extends Hilt_ChooserActivity implements .get(BasePreviewViewModel.class); previewViewModel.init( mChooserRequest.getTargetIntent(), - getIntent(), /*additionalContentUri = */ null, - /*focusedItemIdx = */ 0, /*isPayloadTogglingEnabled = */ false); mChooserContentPreviewUi = new ChooserContentPreviewUi( getCoroutineScope(getLifecycle()), 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/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 { - 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(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 { - 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(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/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/MutableActionFactory.kt b/java/src/com/android/intentresolver/contentpreview/MutableActionFactory.kt deleted file mode 100644 index 1cc1a6a6..00000000 --- a/java/src/com/android/intentresolver/contentpreview/MutableActionFactory.kt +++ /dev/null @@ -1,29 +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.service.chooser.ChooserAction -import com.android.intentresolver.widget.ActionRow -import kotlinx.coroutines.flow.Flow - -interface MutableActionFactory { - /** A flow of custom actions */ - val customActionsFlow: Flow> - - /** Update custom actions */ - fun updateCustomActions(actions: List) -} 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 cc82c0a9..00000000 --- a/java/src/com/android/intentresolver/contentpreview/PayloadToggleInteractor.kt +++ /dev/null @@ -1,372 +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 android.service.chooser.ChooserAction -import android.util.Log -import android.util.SparseArray -import com.android.intentresolver.contentpreview.payloadtoggle.domain.update.SelectionChangeCallback.ShareouselUpdate -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, - private val focusedUriIdx: Int, - private val mimeTypeClassifier: MimeTypeClassifier, - private val cursorReaderProvider: suspend () -> CursorReader, - private val uriMetadataReader: (Uri) -> FileInfo, - private val targetIntentModifier: (List) -> Intent, - private val selectionCallback: suspend (Intent) -> ShareouselUpdate?, -) { - private var cursorDataRef = CompletableDeferred() - private val records = LinkedList() - private val prevPageLoadingGate = AtomicBoolean(true) - private val nextPageLoadingGate = AtomicBoolean(true) - private val notifySelectionJobRef = AtomicReference() - private val emptyState = - State( - emptyList(), - hasMoreItemsBefore = false, - hasMoreItemsAfter = false, - allowSelectionChange = false - ) - - private val stateFlowSource = MutableStateFlow(emptyState) - - val customActions = - MutableSharedFlow>(replay = 1, onBufferOverflow = DROP_LATEST) - - val stateFlow: Flow - get() = stateFlowSource.filter { it !== emptyState } - - val targetPosition: Flow = stateFlow.map { it.targetPos } - val previewKeys: Flow> = stateFlow.map { it.items } - - fun getKey(item: Any): Int = (item as Item).key - - fun selected(key: Item): Flow = (key as Record).isSelected - - fun previewUri(key: Item): Flow = 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()) { 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(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.toItems(): SparseArray { - val items = SparseArray(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 suspend 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(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, - 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) - } - - private data class CursorData( - val reader: CursorReader, - val selectionTracker: SelectionTracker, - ) - - interface CursorReader : Closeable { - val count: Int - val hasMoreBefore: Boolean - val hasMoreAfter: Boolean - - fun readPageAfter(): SparseArray - - fun readPageBefore(): SparseArray - } -} - -class PayloadTogglePreviewInteractor( - private val item: PayloadToggleInteractor.Item, - private val interactor: PayloadToggleInteractor, -) { - fun setSelected(selected: Boolean) { - interactor.setSelected(item, selected) - } - - val previewUri: Flow - get() = interactor.previewUri(item) - - val selected: Flow - 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 f79f0525..6a729945 100644 --- a/java/src/com/android/intentresolver/contentpreview/PreviewViewModel.kt +++ b/java/src/com/android/intentresolver/contentpreview/PreviewViewModel.kt @@ -27,13 +27,9 @@ import androidx.lifecycle.ViewModelProvider.AndroidViewModelFactory.Companion.AP import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewmodel.CreationExtras import com.android.intentresolver.R -import com.android.intentresolver.contentpreview.payloadtoggle.domain.intent.TargetIntentModifierImpl -import com.android.intentresolver.contentpreview.payloadtoggle.domain.update.SelectionChangeCallbackImpl 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 */ @@ -44,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 { @@ -69,64 +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 - ) - }, - UriMetadataReaderImpl(contentResolver, DefaultMimeTypeClassifier)::getMetadata, - TargetIntentModifierImpl( - targetIntent, - getUri = { uri }, - getMimeType = { mimeType }, - )::onSelectionChanged, - SelectionChangeCallbackImpl(contentProviderUri, chooserIntent, contentResolver):: - onSelectionChanged, - ) - } - companion object { val Factory: ViewModelProvider.Factory = object : ViewModelProvider.Factory { 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( - selectedItems: List, - private val focusedItemIdx: Int, - private val cursorCount: Int, - private val getUri: Item.() -> Uri, -) { - /** Contains selected items keys. */ - private val selections = SparseArray(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() - - 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) { - 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) { - 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 = - 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 = - 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..80f7c25a 100644 --- a/java/src/com/android/intentresolver/contentpreview/ShareouselContentPreviewUi.kt +++ b/java/src/com/android/intentresolver/contentpreview/ShareouselContentPreviewUi.kt @@ -22,27 +22,18 @@ 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.app.viewmodel.ShareouselContentPreviewViewModel +import com.android.intentresolver.contentpreview.payloadtoggle.ui.composable.Shareousel +import com.android.intentresolver.contentpreview.payloadtoggle.ui.viewmodel.ShareouselViewModel @VisibleForTesting(otherwise = VisibleForTesting.PACKAGE_PRIVATE) class ShareouselContentPreviewUi( @@ -56,76 +47,48 @@ class ShareouselContentPreviewUi( layoutInflater: LayoutInflater, parent: ViewGroup, headlineViewParent: View?, - ): ViewGroup { - return displayInternal(parent, headlineViewParent).also { layout -> + ): ViewGroup = + displayInternal(parent, headlineViewParent).also { layout -> displayModifyShareAction(headlineViewParent ?: layout, actionFactory) } - } - 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: ShareouselContentPreviewViewModel = viewModel() + val viewModel: ShareouselViewModel = vm.viewModel - var viewModel by remember { mutableStateOf(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(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(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/payloadtoggle/app/viewmodel/ShareouselContentPreviewViewModel.kt b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/app/viewmodel/ShareouselContentPreviewViewModel.kt new file mode 100644 index 00000000..479f0ec8 --- /dev/null +++ b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/app/viewmodel/ShareouselContentPreviewViewModel.kt @@ -0,0 +1,46 @@ +/* + * Copyright (C) 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.intentresolver.contentpreview.payloadtoggle.app.viewmodel + +import androidx.lifecycle.ViewModel +import com.android.intentresolver.contentpreview.payloadtoggle.domain.interactor.FetchPreviewsInteractor +import com.android.intentresolver.contentpreview.payloadtoggle.domain.interactor.UpdateTargetIntentInteractor +import com.android.intentresolver.contentpreview.payloadtoggle.ui.viewmodel.ShareouselViewModel +import com.android.intentresolver.inject.Background +import com.android.intentresolver.inject.ViewModelOwned +import dagger.hilt.android.lifecycle.HiltViewModel +import javax.inject.Inject +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch + +/** View-model for [com.android.intentresolver.contentpreview.ShareouselContentPreviewUi]. */ +@HiltViewModel +class ShareouselContentPreviewViewModel +@Inject +constructor( + val viewModel: ShareouselViewModel, + updateTargetIntentInteractor: UpdateTargetIntentInteractor, + fetchPreviewsInteractor: FetchPreviewsInteractor, + @Background private val bgDispatcher: CoroutineDispatcher, + @ViewModelOwned private val scope: CoroutineScope, +) : ViewModel() { + init { + scope.launch(bgDispatcher) { updateTargetIntentInteractor.launch() } + scope.launch(bgDispatcher) { fetchPreviewsInteractor.launch() } + } +} diff --git a/java/src/com/android/intentresolver/contentpreview/payloadtoggle/ui/composable/ComposeIconComposable.kt b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/ui/composable/ComposeIconComposable.kt new file mode 100644 index 00000000..38138225 --- /dev/null +++ b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/ui/composable/ComposeIconComposable.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.contentpreview.payloadtoggle.ui.composable + +import android.content.Context +import android.content.ContextWrapper +import android.content.res.Resources +import androidx.compose.foundation.Image +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.asImageBitmap +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.painterResource +import com.android.intentresolver.icon.AdaptiveIcon +import com.android.intentresolver.icon.BitmapIcon +import com.android.intentresolver.icon.ComposeIcon +import com.android.intentresolver.icon.ResourceIcon + +@Composable +fun Image(icon: ComposeIcon, modifier: Modifier = Modifier) { + when (icon) { + 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 = + object : ContextWrapper(localContext) { + override fun getResources(): Resources = icon.res + } + CompositionLocalProvider(LocalContext provides wrappedContext) { + Image(painterResource(icon.resId), contentDescription = null, modifier = modifier) + } + } + } +} diff --git a/java/src/com/android/intentresolver/contentpreview/payloadtoggle/ui/composable/ShareouselCardComposable.kt b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/ui/composable/ShareouselCardComposable.kt new file mode 100644 index 00000000..f33558c7 --- /dev/null +++ b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/ui/composable/ShareouselCardComposable.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.ui.composable + +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.drawBehind +import androidx.compose.ui.draw.shadow +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.unit.dp +import com.android.intentresolver.R +import com.android.intentresolver.contentpreview.payloadtoggle.ui.viewmodel.ContentType + +@Composable +fun ShareouselCard( + image: @Composable () -> Unit, + contentType: ContentType, + selected: Boolean, + modifier: Modifier = Modifier, +) { + Box(modifier) { + image() + val topButtonPadding = 12.dp + Box(modifier = Modifier.padding(topButtonPadding).matchParentSize()) { + SelectionIcon(selected, modifier = Modifier.align(Alignment.TopStart)) + if (contentType == ContentType.Video) { + AnimationIcon(modifier = Modifier.align(Alignment.TopEnd)) + } + } + } +} + +@Composable +private fun AnimationIcon(modifier: Modifier = Modifier) { + Icon( + painterResource(id = R.drawable.ic_play_circle_filled_24px), + "animating", + tint = Color.White, + modifier = Modifier.size(20.dp).then(modifier) + ) +} + +@Composable +private fun SelectionIcon(selected: Boolean, modifier: Modifier = Modifier) { + if (selected) { + val bgColor = MaterialTheme.colorScheme.primary + Icon( + painter = painterResource(id = R.drawable.checkbox), + tint = Color.White, + contentDescription = "selected", + modifier = + Modifier.shadow( + elevation = 50.dp, + spotColor = Color(0x40000000), + ambientColor = Color(0x40000000) + ) + .size(20.dp) + .drawBehind { + drawCircle(color = bgColor, radius = (this.size.width / 2f) - 1f) + } + .then(modifier) + ) + } else { + Box( + modifier = + Modifier.shadow( + elevation = 50.dp, + spotColor = Color(0x40000000), + ambientColor = Color(0x40000000), + ) + .border(width = 2.dp, color = Color(0xFFFFFFFF), shape = CircleShape) + .clip(CircleShape) + .size(20.dp) + .background(color = Color(0x7DC4C4C4)) + .then(modifier) + ) + } +} diff --git a/java/src/com/android/intentresolver/contentpreview/payloadtoggle/ui/composable/ShareouselComposable.kt b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/ui/composable/ShareouselComposable.kt new file mode 100644 index 00000000..feb6f3a8 --- /dev/null +++ b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/ui/composable/ShareouselComposable.kt @@ -0,0 +1,190 @@ +/* + * Copyright (C) 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.android.intentresolver.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 +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 +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.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 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) + ) + } +} + +@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) + } +} + +@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: ShareouselPreviewViewModel) { + val bitmap by viewModel.bitmap.collectAsStateWithLifecycle(initialValue = null) + val selected by viewModel.isSelected.collectAsStateWithLifecycle(initialValue = false) + val contentType by + viewModel.contentType.collectAsStateWithLifecycle(initialValue = ContentType.Image) + val borderColor = MaterialTheme.colorScheme.primary + val scope = rememberCoroutineScope() + ShareouselCard( + image = { + bitmap?.let { bitmap -> + val aspectRatio = + (bitmap.width.toFloat() / bitmap.height.toFloat()) + // TODO: max ratio is actually equal to the viewport ratio + .coerceIn(MIN_ASPECT_RATIO, MAX_ASPECT_RATIO) + Image( + bitmap = bitmap.asImageBitmap(), + contentDescription = null, + contentScale = ContentScale.Crop, + modifier = Modifier.aspectRatio(aspectRatio), + ) + } + ?: run { + // TODO: look at ScrollableImagePreviewView.setLoading() + 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), + ) + } + .clip(RoundedCornerShape(size = 12.dp)) + .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, + modifier: Modifier = Modifier, + leadingIcon: (@Composable () -> Unit)? = null, +) { + AssistChip( + onClick = onClick, + label = { Text(label) }, + leadingIcon = leadingIcon, + modifier = modifier + ) +} + +inline fun Modifier.thenIf(condition: Boolean, crossinline factory: () -> Modifier): Modifier = + if (condition) this.then(factory()) else this + +private const val MIN_ASPECT_RATIO = 0.4f +private const val MAX_ASPECT_RATIO = 2.5f diff --git a/java/src/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, + /** Type of data to be shared. */ + val contentType: Flow, + /** Whether this preview has been selected by the user. */ + val isSelected: Flow, + /** 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, + /** + * Previews which are available for presentation within Shareousel. Use [preview] to create a + * [ShareouselPreviewViewModel] for a given [PreviewModel]. + */ + val previews: Flow, + /** List of action chips presented underneath Shareousel. */ + val actions: Flow>, + /** 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/composable/ComposeIconComposable.kt b/java/src/com/android/intentresolver/contentpreview/shareousel/ui/composable/ComposeIconComposable.kt deleted file mode 100644 index 87fb7618..00000000 --- a/java/src/com/android/intentresolver/contentpreview/shareousel/ui/composable/ComposeIconComposable.kt +++ /dev/null @@ -1,48 +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.composable - -import android.content.Context -import android.content.ContextWrapper -import android.content.res.Resources -import androidx.compose.foundation.Image -import androidx.compose.runtime.Composable -import androidx.compose.runtime.CompositionLocalProvider -import androidx.compose.ui.graphics.asImageBitmap -import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.res.painterResource -import com.android.intentresolver.icon.AdaptiveIcon -import com.android.intentresolver.icon.BitmapIcon -import com.android.intentresolver.icon.ComposeIcon -import com.android.intentresolver.icon.ResourceIcon - -@Composable -fun Image(icon: ComposeIcon) { - when (icon) { - is AdaptiveIcon -> Image(icon.wrapped) - is BitmapIcon -> Image(icon.bitmap.asImageBitmap(), contentDescription = null) - is ResourceIcon -> { - val localContext = LocalContext.current - val wrappedContext: Context = - object : ContextWrapper(localContext) { - override fun getResources(): Resources = icon.res - } - CompositionLocalProvider(LocalContext provides wrappedContext) { - Image(painterResource(icon.resId), contentDescription = null) - } - } - } -} diff --git a/java/src/com/android/intentresolver/contentpreview/shareousel/ui/composable/ShareouselCardComposable.kt b/java/src/com/android/intentresolver/contentpreview/shareousel/ui/composable/ShareouselCardComposable.kt deleted file mode 100644 index dc96e3c1..00000000 --- a/java/src/com/android/intentresolver/contentpreview/shareousel/ui/composable/ShareouselCardComposable.kt +++ /dev/null @@ -1,98 +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.composable - -import androidx.compose.foundation.background -import androidx.compose.foundation.border -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.shape.CircleShape -import androidx.compose.material3.Icon -import androidx.compose.material3.MaterialTheme -import androidx.compose.runtime.Composable -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip -import androidx.compose.ui.draw.drawBehind -import androidx.compose.ui.draw.shadow -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.res.painterResource -import androidx.compose.ui.unit.dp -import com.android.intentresolver.R - -@Composable -fun ShareouselCard( - image: @Composable () -> Unit, - selected: Boolean, - modifier: Modifier = Modifier, -) { - Box(modifier) { - image() - val topButtonPadding = 12.dp - Box(modifier = Modifier.padding(topButtonPadding).matchParentSize()) { - SelectionIcon(selected, modifier = Modifier.align(Alignment.TopStart)) - AnimationIcon(modifier = Modifier.align(Alignment.TopEnd)) - } - } -} - -@Composable -private fun AnimationIcon(modifier: Modifier = Modifier) { - Icon( - painterResource(id = R.drawable.ic_play_circle_filled_24px), - "animating", - tint = Color.White, - modifier = Modifier.size(20.dp).then(modifier) - ) -} - -@Composable -private fun SelectionIcon(selected: Boolean, modifier: Modifier = Modifier) { - if (selected) { - val bgColor = MaterialTheme.colorScheme.primary - Icon( - painter = painterResource(id = R.drawable.checkbox), - tint = Color.White, - contentDescription = "selected", - modifier = - Modifier.shadow( - elevation = 50.dp, - spotColor = Color(0x40000000), - ambientColor = Color(0x40000000) - ) - .size(20.dp) - .drawBehind { - drawCircle(color = bgColor, radius = (this.size.width / 2f) - 1f) - } - .then(modifier) - ) - } else { - Box( - modifier = - Modifier.shadow( - elevation = 50.dp, - spotColor = Color(0x40000000), - ambientColor = Color(0x40000000), - ) - .border(width = 2.dp, color = Color(0xFFFFFFFF), shape = CircleShape) - .clip(CircleShape) - .size(20.dp) - .background(color = Color(0x7DC4C4C4)) - .then(modifier) - ) - } -} diff --git a/java/src/com/android/intentresolver/contentpreview/shareousel/ui/composable/ShareouselComposable.kt b/java/src/com/android/intentresolver/contentpreview/shareousel/ui/composable/ShareouselComposable.kt deleted file mode 100644 index 0b3cdd83..00000000 --- a/java/src/com/android/intentresolver/contentpreview/shareousel/ui/composable/ShareouselComposable.kt +++ /dev/null @@ -1,146 +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.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 -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.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.lazy.LazyRow -import androidx.compose.foundation.lazy.items -import androidx.compose.foundation.lazy.rememberLazyListState -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material3.AssistChip -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip -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 - -@Composable -fun Shareousel(viewModel: ShareouselViewModel) { - val centerIdx = viewModel.centerIndex.value - val carouselState = rememberLazyListState(initialFirstVisibleItemIndex = centerIdx) - val previewKeys by viewModel.previewKeys.collectAsStateWithLifecycle() - Column(modifier = Modifier.background(MaterialTheme.colorScheme.surfaceContainer)) { - // 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 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) } - } - } - } - } -} - -private const val MIN_ASPECT_RATIO = 0.4f -private const val MAX_ASPECT_RATIO = 2.5f - -@Composable -private fun ShareouselCard(viewModel: ShareouselImageViewModel) { - 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 borderColor = MaterialTheme.colorScheme.primary - - ShareouselCard( - image = { - bitmap?.let { bitmap -> - val aspectRatio = - (bitmap.width.toFloat() / bitmap.height.toFloat()) - // TODO: max ratio is actually equal to the viewport ratio - .coerceIn(MIN_ASPECT_RATIO, MAX_ASPECT_RATIO) - Image( - bitmap = bitmap.asImageBitmap(), - contentDescription = contentDescription, - contentScale = ContentScale.Crop, - modifier = Modifier.aspectRatio(aspectRatio), - ) - } - ?: run { - // TODO: look at ScrollableImagePreviewView.setLoading() - Box(modifier = Modifier.aspectRatio(2f / 5f)) - } - }, - selected = selected, - modifier = - Modifier.thenIf(selected) { - Modifier.border( - width = 4.dp, - color = borderColor, - shape = RoundedCornerShape(size = 12.dp) - ) - } - .clip(RoundedCornerShape(size = 12.dp)) - .clickable { viewModel.setSelected(!selected) }, - ) -} - -@Composable -private fun ShareouselAction( - label: String, - onClick: () -> Unit, - modifier: Modifier = Modifier, - leadingIcon: (@Composable () -> Unit)? = null, -) { - AssistChip( - onClick = onClick, - label = { Text(label) }, - leadingIcon = leadingIcon, - modifier = modifier - ) -} - -inline fun Modifier.thenIf(condition: Boolean, crossinline factory: () -> Modifier): Modifier = - if (condition) this.then(factory()) else this 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, - val previewKeys: StateFlow>, - val actions: Flow>, - val centerIndex: StateFlow, - 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, - val contentDescription: Flow, - val isSelected: Flow, - 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/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 @@ -20,6 +20,11 @@ import javax.inject.Qualifier @Qualifier @MustBeDocumented @Retention(AnnotationRetention.RUNTIME) annotation class ActivityOwned +@Qualifier +@MustBeDocumented +@Retention(AnnotationRetention.RUNTIME) +annotation class ViewModelOwned + @Qualifier @MustBeDocumented @Retention(AnnotationRetention.RUNTIME) 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/v2/ChooserActivity.java b/java/src/com/android/intentresolver/v2/ChooserActivity.java index a95caddc..9a5ec173 100644 --- a/java/src/com/android/intentresolver/v2/ChooserActivity.java +++ b/java/src/com/android/intentresolver/v2/ChooserActivity.java @@ -29,7 +29,6 @@ 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; @@ -118,7 +117,6 @@ 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; @@ -608,33 +606,14 @@ public class ChooserActivity extends Hilt_ChooserActivity implements .get(BasePreviewViewModel.class); previewViewModel.init( mRequest.getTargetIntent(), - mViewModel.getActivityModel().getIntent(), mRequest.getAdditionalContentUri(), - mRequest.getFocusedItemPosition(), 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(), mRequest.getTargetIntent(), previewViewModel.getImageLoader(), - actionFactory, + createChooserActionFactory(), mEnterTransitionAnimationDelegate, new HeadlineGeneratorImpl(this), mRequest.getContentTypeHint(), 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>(actionFactory.createCustomActions()) - - override val customActionsFlow: Flow> - get() = customActions - - override fun updateCustomActions(actions: List) { - customActions.tryEmit(mapChooserActions(actions)) - } - - override fun createCustomActions(): List = customActions.value - - private fun mapChooserActions(chooserActions: List): List = - buildList(chooserActions.size) { - chooserActions.forEachIndexed { i, chooserAction -> - val actionRow = - actionFactory.createCustomAction(chooserAction) { - actionFactory.logCustomAction(i) - } - if (actionRow != null) { - add(actionRow) - } - } - } -} 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/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 eq(obj: T): T = Mockito.eq(obj) +inline fun eq(obj: T): T = Mockito.eq(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 any(type: Class): T = Mockito.any(type) -inline fun any(): T = any(T::class.java) +inline fun same(obj: T): T = Mockito.same(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 argThat(matcher: ArgumentMatcher): T = Mockito.argThat(matcher) +inline fun any(type: Class): T = Mockito.any(type) + +inline fun 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 argThat(matcher: ArgumentMatcher): T = Mockito.argThat(matcher) + +/** Kotlin type-inferred version of Mockito.nullable() */ inline fun 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 capture(argumentCaptor: ArgumentCaptor): T = argumentCaptor.capture() +inline fun capture(argumentCaptor: ArgumentCaptor): T = argumentCaptor.capture() /** * Helper function for creating an argumentCaptor in kotlin. @@ -90,17 +99,18 @@ inline fun mock( apply: T.() -> Unit = {} ): T = Mockito.mock(T::class.java, mockSettings).apply(apply) +/** Matches any array of type T. */ +inline fun anyArray(): Array = Mockito.any(Array::class.java) ?: arrayOf() + /** * Helper function for stubbing methods without the need to use backticks. * * @see Mockito.when */ -fun whenever(methodCall: T): OngoingStubbing = Mockito.`when`(methodCall) +inline fun whenever(methodCall: T): OngoingStubbing = Mockito.`when`(methodCall) -/** - * Helper function for stubbing methods without the need to use backticks. - */ -fun Stubber.whenever(mock: T): T = `when`(mock) +/** Helper function for stubbing methods without the need to use backticks. */ +inline fun 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 kotlinArgumentCaptor(): KotlinArgumentCaptor = /** * Helper function for creating and using a single-use ArgumentCaptor in kotlin. * - * val captor = argumentCaptor() - * verify(...).someMethod(captor.capture()) - * val captured = captor.value + * val captor = argumentCaptor() verify(...).someMethod(captor.capture()) val captured = + * captor.value * * becomes: * - * val captured = withArgCaptor { verify(...).someMethod(capture()) } + * val captured = withArgCaptor { verify(...).someMethod(capture()) } * * NOTE: this uses the KotlinArgumentCaptor to avoid the NullPointerException. */ @@ -144,13 +153,12 @@ inline fun withArgCaptor(block: KotlinArgumentCaptor.() -> /** * Variant of [withArgCaptor] for capturing multiple arguments. * - * val captor = argumentCaptor() - * verify(...).someMethod(captor.capture()) - * val captured: List = captor.allValues + * val captor = argumentCaptor() verify(...).someMethod(captor.capture()) val captured: + * List = captor.allValues * * becomes: * - * val capturedList = captureMany { verify(...).someMethod(capture()) } + * val capturedList = captureMany { verify(...).someMethod(capture()) } */ inline fun captureMany(block: KotlinArgumentCaptor.() -> Unit): List = kotlinArgumentCaptor().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/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 { - 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 SparseArray.getKeys(): IntArray = IntArray(size()) { i -> keyAt(i) } - -private inline fun SparseArray.getValues(): Array = - Array(size()) { i -> valueAt(i) } 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(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(emptyList(), 0, 10) { this } - - val items = - (1..5).fold(SparseArray(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(3).apply { - append(1, u1) - append(2, makeUri(4)) - append(3, makeUri(5)) - } - ) - assertThat(testSubject.getSelection()).containsExactly(u1, u2, u3).inOrder() - testSubject.onEndItemsAdded( - SparseArray(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(3).apply { - append(4, u2) - append(5, makeUri(4)) - append(6, makeUri(5)) - } - ) - assertThat(testSubject.getSelection()).containsExactly(u1, u2, u3).inOrder() - - testSubject.onStartItemsAdded( - SparseArray(3).apply { - append(1, makeUri(6)) - append(2, u1) - append(3, makeUri(7)) - } - ) - assertThat(testSubject.getSelection()).containsExactly(u1, u2, u3).inOrder() - - testSubject.onEndItemsAdded(SparseArray(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(3).apply { append(6, u3) }) - - assertThat(testSubject.getSelection()).containsExactly(u1, u2, u3).inOrder() - - testSubject.onStartItemsAdded( - SparseArray(3).apply { - append(3, makeUri(4)) - append(4, u2) - append(5, makeUri(5)) - } - ) - assertThat(testSubject.getSelection()).containsExactly(u1, u2, u3).inOrder() - - testSubject.onStartItemsAdded( - SparseArray(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(3).apply { append(2, u2) }) - assertThat(testSubject.getSelection()).containsExactly(u1, u2, u1).inOrder() - - testSubject.onStartItemsAdded( - SparseArray(3).apply { - append(0, u1) - append(1, u3) - } - ) - assertThat(testSubject.getSelection()).containsExactly(u1, u2, u1).inOrder() - - testSubject.onStartItemsAdded( - SparseArray(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(1).apply { append(0, u1) }) - assertThat(testSubject.getSelection()).containsExactly(u1, u2).inOrder() - - testSubject.onEndItemsAdded( - SparseArray(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(1).apply { append(3, u2) }) - assertThat(testSubject.getSelection()).containsExactly(u1, u2).inOrder() - - testSubject.onStartItemsAdded( - SparseArray(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(3).apply { - append(4, makeUri(5)) - append(5, u1) - append(6, makeUri(6)) - } - ) - testSubject.onStartItemsAdded( - SparseArray(3).apply { - append(2, makeUri(7)) - append(3, u4) - } - ) - testSubject.onEndItemsAdded( - SparseArray(3).apply { - append(7, u3) - append(8, makeUri(8)) - } - ) - testSubject.onStartItemsAdded( - SparseArray(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(2).apply { - append(2, u3) - append(3, u4) - } - ) - testSubject.onStartItemsAdded(SparseArray(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(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(1).apply { - append(4, u5) - append(5, u3) - } - ) - testSubject.onStartItemsAdded( - SparseArray(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(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(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/payloadtoggle/ui/viewmodel/ShareouselViewModelTest.kt b/tests/unit/src/com/android/intentresolver/contentpreview/payloadtoggle/ui/viewmodel/ShareouselViewModelTest.kt new file mode 100644 index 00000000..854e0319 --- /dev/null +++ b/tests/unit/src/com/android/intentresolver/contentpreview/payloadtoggle/ui/viewmodel/ShareouselViewModelTest.kt @@ -0,0 +1,243 @@ +/* + * Copyright (C) 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +@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.PreviewSelectionsRepository +import com.android.intentresolver.contentpreview.payloadtoggle.data.repository.TargetIntentRepository +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.icon.BitmapIcon +import com.android.intentresolver.logging.FakeEventLog +import com.android.intentresolver.mock +import com.android.intentresolver.util.comparingElementsUsingTransform +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 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 {} + val packageManager = mock {} + val eventLog = FakeEventLog(instanceId = InstanceId.fakeInstanceId(1)) + val targetIntentRepo = + TargetIntentRepository( + initialIntent = Intent(), + initialActions = listOf(), + ) + val underTest = + ShareouselViewModelModule.create( + interactor = + SelectablePreviewsInteractor( + previewsRepo = previewsRepository, + selectionRepo = selectionRepository + ), + imageLoader = + FakeImageLoader( + initialBitmaps = + mapOf( + Uri.fromParts("scheme1", "ssp1", "fragment1") to + Bitmap.createBitmap(100, 100, Bitmap.Config.ALPHA_8) + ) + ), + actionsInteractor = + CustomActionsInteractor( + activityResultRepo = activityResultRepository, + bgDispatcher = testDispatcher, + contentResolver = contentResolver, + eventLog = eventLog, + packageManager = packageManager, + targetIntentRepo = targetIntentRepo, + ), + 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( + selectionRepo = selectionRepository, + ), + scope = testScope.backgroundScope, + ) + } + + private inline fun runTestWithDeps( + crossinline block: suspend TestScope.(Dependencies) -> Unit, + ): Unit = + Dependencies().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 { 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 + targetIntentRepo.customActions.value = + listOf(CustomActionModel("label1", icon) { 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/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() - private val testAction = "com.android.intentresolver.testaction" - private val resultConsumer = - object : Consumer { - 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): ChooserActionFactory { - val targetIntent = Intent() - val chooserRequest = mock() - 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 { - 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) - } - } - } -} -- cgit v1.2.3-59-g8ed1b From 10f4760b806ea71770fd703d73f282ace8e62b13 Mon Sep 17 00:00:00 2001 From: Mark Renouf Date: Fri, 8 Mar 2024 16:13:43 -0500 Subject: Remove final from framework methods Hilt generates a base class and somehow inherits the final modifier in the process, causes compilation to fail when final methods are overridden in the generated class hierarchy. There errors from dagger generated classes were: error: onCreate(Bundle) in ChooserActivity cannot override onCreate(Bundle) in Hilt_ChooserActivity overridden method is final error: onDestroy() in ChooserActivity cannot override onDestroy() in Hilt_ChooserActivity overridden method is final error: onCreate(Bundle) in ResolverActivity cannot override onCreate(Bundle) in Hilt_ResolverActivity protected final void onCreate(Bundle savedInstanceState) { Flag: NA Test: atest IntentResolver-tests-activity:com.android.intentresolver.v2 Bug: NA Change-Id: Id925938ab9ce48caaa451a943547f1912058cfdf --- .../android/intentresolver/v2/ChooserActivity.java | 22 +++++++++------------- .../intentresolver/v2/ResolverActivity.java | 2 +- 2 files changed, 10 insertions(+), 14 deletions(-) (limited to 'java/src') diff --git a/java/src/com/android/intentresolver/v2/ChooserActivity.java b/java/src/com/android/intentresolver/v2/ChooserActivity.java index 9a5ec173..510e6d14 100644 --- a/java/src/com/android/intentresolver/v2/ChooserActivity.java +++ b/java/src/com/android/intentresolver/v2/ChooserActivity.java @@ -344,7 +344,7 @@ 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"); @@ -444,7 +444,7 @@ public class ChooserActivity extends Hilt_ChooserActivity implements } @Override - protected final void onDestroy() { + protected void onDestroy() { super.onDestroy(); if (!isChangingConfigurations() && mPickOptionRequest != null) { mPickOptionRequest.cancel(); @@ -1181,16 +1181,6 @@ 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); - } - } - ////////////////////////////////////////////////////////////////////////////////////////////// ////////////////////////////////////////////////////////////////////////////////////////////// @@ -1407,7 +1397,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); diff --git a/java/src/com/android/intentresolver/v2/ResolverActivity.java b/java/src/com/android/intentresolver/v2/ResolverActivity.java index be3d7ce9..4e694c3a 100644 --- a/java/src/com/android/intentresolver/v2/ResolverActivity.java +++ b/java/src/com/android/intentresolver/v2/ResolverActivity.java @@ -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(); -- cgit v1.2.3-59-g8ed1b From e204bcf642d44f87a85c4e2b8037834eeb1ab0b4 Mon Sep 17 00:00:00 2001 From: mrenouf Date: Fri, 8 Mar 2024 20:04:10 -0500 Subject: Replaces BundleUtil with AndroidX bundleOf Flag: NA Test: atest IntentResolver-tests-unit Bug: NA Change-Id: I0057955079ec164b4ff640a48aeb4dac3f1e6620 --- .../domain/cursor/PayloadToggleCursorResolver.kt | 4 ++-- .../com/android/intentresolver/util/BundleUtils.kt | 22 ---------------------- .../interactor/CursorPreviewsInteractorTest.kt | 4 ++-- .../interactor/FetchPreviewsInteractorTest.kt | 4 ++-- 4 files changed, 6 insertions(+), 28 deletions(-) delete mode 100644 java/src/com/android/intentresolver/util/BundleUtils.kt (limited to 'java/src') 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 index 286891d1..3cf2af13 100644 --- a/java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/cursor/PayloadToggleCursorResolver.kt +++ b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/cursor/PayloadToggleCursorResolver.kt @@ -20,9 +20,9 @@ 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.Bundle import com.android.intentresolver.util.cursor.CursorView import com.android.intentresolver.util.cursor.viewBy import com.android.intentresolver.util.withCancellationSignal @@ -46,7 +46,7 @@ constructor( contentResolver.query( cursorUri, arrayOf(URI), - Bundle { putParcelable(Intent.EXTRA_INTENT, chooserIntent) }, + bundleOf(Intent.EXTRA_INTENT to chooserIntent), signal, ) } diff --git a/java/src/com/android/intentresolver/util/BundleUtils.kt b/java/src/com/android/intentresolver/util/BundleUtils.kt deleted file mode 100644 index da06afef..00000000 --- a/java/src/com/android/intentresolver/util/BundleUtils.kt +++ /dev/null @@ -1,22 +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.util - -import android.os.Bundle - -/** Shorthand for `Bundle().apply { ... } */ -inline fun Bundle(block: Bundle.() -> Unit): Bundle = Bundle().apply(block) 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 index b17b77e0..b2d9be94 100644 --- 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 @@ -20,10 +20,10 @@ package com.android.intentresolver.contentpreview.payloadtoggle.domain.interacto 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.Bundle import com.android.intentresolver.util.cursor.viewBy import com.google.common.truth.Truth.assertThat import kotlinx.coroutines.ExperimentalCoroutinesApi @@ -67,7 +67,7 @@ class CursorPreviewsInteractorTest { val cursor = MatrixCursor(arrayOf("uri")) .apply { - extras = Bundle { putInt("position", cursorStartPosition) } + extras = bundleOf("position" to cursorStartPosition) for (i in cursorRange) { newRow().add("uri", uri(i).toString()) } 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 index 2838b176..9317f798 100644 --- 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 @@ -20,6 +20,7 @@ package com.android.intentresolver.contentpreview.payloadtoggle.domain.interacto 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 @@ -27,7 +28,6 @@ import com.android.intentresolver.contentpreview.payloadtoggle.data.repository.P 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.Bundle import com.android.intentresolver.util.cursor.CursorView import com.android.intentresolver.util.cursor.viewBy import com.google.common.truth.Truth.assertThat @@ -109,7 +109,7 @@ class FetchPreviewsInteractorTest { mutex.withLock { MatrixCursor(arrayOf("uri")) .apply { - extras = Bundle { putInt("position", cursorStartPosition) } + extras = bundleOf("position" to cursorStartPosition) for (i in cursorRange) { newRow().add("uri", uri(i).toString()) } -- cgit v1.2.3-59-g8ed1b From 505c9bd930b83a12a4eb5a97ebec95e54b6af2dc Mon Sep 17 00:00:00 2001 From: Matt Casey Date: Mon, 11 Mar 2024 14:43:53 +0000 Subject: Add UiEvent for shareousel payload toggle Bug: 329068612 Test: Builds Change-Id: I616cccc60d550b6961526faa473d5fc4131d16b2 --- java/src/com/android/intentresolver/logging/EventLogImpl.java | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) (limited to 'java/src') 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) { -- cgit v1.2.3-59-g8ed1b From 418363e77491189cbd44656adc57b2006e37c58b Mon Sep 17 00:00:00 2001 From: mrenouf Date: Sun, 10 Mar 2024 04:18:08 -0400 Subject: Remove unused Role.type from User This removes a single assertion and a test covering it. Asserting on type can instead be done by just testing if the user is the profile parent. This will be modeled in the new design. Bug: 328927949 Test: atest IntentResolver-tests-unit Change-Id: I3a28d986f36f87190a4af5c98ed954999250fd92 --- .../v2/data/repository/UserRepository.kt | 1 - .../android/intentresolver/v2/shared/model/User.kt | 23 +++++----------------- .../v2/data/repository/UserRepositoryImplTest.kt | 16 +++++---------- 3 files changed, 10 insertions(+), 30 deletions(-) (limited to 'java/src') 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..b13ea871 100644 --- a/java/src/com/android/intentresolver/v2/data/repository/UserRepository.kt +++ b/java/src/com/android/intentresolver/v2/data/repository/UserRepository.kt @@ -151,7 +151,6 @@ 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") userManager.requestQuietModeEnabled(/* enableQuietMode = */ !available, user.handle) 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/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..8628bd14 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 @@ -43,9 +43,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 +90,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 +113,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 -- cgit v1.2.3-59-g8ed1b From 01d9014ba14b33f76c09bd1901224f7e674eefff Mon Sep 17 00:00:00 2001 From: mrenouf Date: Fri, 8 Mar 2024 20:06:51 -0500 Subject: Add JavaInterop @RequiresOptin to Java support classes Restrict usage to Java. Can be overridden by adding an @OpIn in Kotlin code if necessary. Bug: 309960444 Test: atest IntentResolver-tests-unit Flag: NA Change-Id: I6952aa295cc67deee5e372e20fd2bb3baa9cc056 --- java/src/com/android/intentresolver/v2/JavaFlowHelper.kt | 2 ++ java/src/com/android/intentresolver/v2/ProfileAvailability.kt | 2 ++ java/src/com/android/intentresolver/v2/ProfileHelper.kt | 2 ++ .../unit/src/com/android/intentresolver/v2/ProfileAvailabilityTest.kt | 3 ++- tests/unit/src/com/android/intentresolver/v2/ProfileHelperTest.kt | 2 ++ 5 files changed, 10 insertions(+), 1 deletion(-) (limited to 'java/src') 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 collect(scope: CoroutineScope, flow: Flow, collector: Consumer): 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 4b183ecb..ddb57991 100644 --- a/java/src/com/android/intentresolver/v2/ProfileAvailability.kt +++ b/java/src/com/android/intentresolver/v2/ProfileAvailability.kt @@ -16,6 +16,7 @@ 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 kotlinx.coroutines.CoroutineScope @@ -27,6 +28,7 @@ import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch /** Provides availability status for profiles */ +@JavaInterop class ProfileAvailability( private val scope: CoroutineScope, private val userInteractor: UserInteractor, diff --git a/java/src/com/android/intentresolver/v2/ProfileHelper.kt b/java/src/com/android/intentresolver/v2/ProfileHelper.kt index 29aab770..8a8e6b54 100644 --- a/java/src/com/android/intentresolver/v2/ProfileHelper.kt +++ b/java/src/com/android/intentresolver/v2/ProfileHelper.kt @@ -18,11 +18,13 @@ 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 +@JavaInterop class ProfileHelper @Inject constructor( diff --git a/tests/unit/src/com/android/intentresolver/v2/ProfileAvailabilityTest.kt b/tests/unit/src/com/android/intentresolver/v2/ProfileAvailabilityTest.kt index 2022d967..9f2b3e0f 100644 --- a/tests/unit/src/com/android/intentresolver/v2/ProfileAvailabilityTest.kt +++ b/tests/unit/src/com/android/intentresolver/v2/ProfileAvailabilityTest.kt @@ -16,6 +16,7 @@ package com.android.intentresolver.v2 +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 @@ -26,7 +27,7 @@ import kotlinx.coroutines.test.runCurrent import kotlinx.coroutines.test.runTest import org.junit.Test -@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) diff --git a/tests/unit/src/com/android/intentresolver/v2/ProfileHelperTest.kt b/tests/unit/src/com/android/intentresolver/v2/ProfileHelperTest.kt index 78c079ef..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) -- cgit v1.2.3-59-g8ed1b From e7e3f788b64f5b33ffde5df8471c9d82565c8a95 Mon Sep 17 00:00:00 2001 From: mrenouf Date: Mon, 11 Mar 2024 10:07:46 -0400 Subject: UserScopedService: Cache and share contexts This change caches both the Context and the Service instance to reuse both as needed. Previously a new Context is created for every service for a given user. In addition, switch to LruCache instead of a plain main, which provides an upper bound on the number of cached instances as a safeguard. Finally, switch the key to UserHandle to decouple from other sharesheet concepts. This mechanism is an implementation detail and not for general use so framework types are fine here. Bug: 324428064 Test: Not yet used Flag: Not yet used Change-Id: I0115853f0c8888d59ebb0407391a1e6d3c6e7e6c --- .../intentresolver/inject/SystemServices.kt | 7 +++ .../v2/data/repository/UserScopedService.kt | 72 ++++++++++++++++++---- 2 files changed, 68 insertions(+), 11 deletions(-) (limited to 'java/src') diff --git a/java/src/com/android/intentresolver/inject/SystemServices.kt b/java/src/com/android/intentresolver/inject/SystemServices.kt index 4762f4a1..069c926c 100644 --- a/java/src/com/android/intentresolver/inject/SystemServices.kt +++ b/java/src/com/android/intentresolver/inject/SystemServices.kt @@ -26,6 +26,9 @@ 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.UserScopedContext +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 @@ -99,6 +102,10 @@ class ShortcutManagerModule { class UserManagerModule { @Provides fun userManager(@ApplicationContext ctx: Context): UserManager = ctx.requireSystemService() + + @Provides fun scopedUserManager(ctx: UserScopedContext): UserScopedService { + return UserScopedServiceImpl(ctx, UserManager::class) + } } @Module 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..07903a7b 100644 --- a/java/src/com/android/intentresolver/v2/data/repository/UserScopedService.kt +++ b/java/src/com/android/intentresolver/v2/data/repository/UserScopedService.kt @@ -1,8 +1,27 @@ +/* + * Copyright (C) 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + package com.android.intentresolver.v2.data.repository import android.content.Context +import android.os.UserHandle +import android.util.LruCache import androidx.core.content.getSystemService -import com.android.intentresolver.v2.shared.model.User +import javax.inject.Inject +import kotlin.reflect.KClass /** * Provides cached instances of a [system service][Context.getSystemService] created with @@ -27,20 +46,51 @@ import com.android.intentresolver.v2.shared.model.User * ``` */ interface UserScopedService { - fun forUser(user: User): T + fun forUser(user: UserHandle): T } -inline fun userScopedService(context: Context): UserScopedService { - return object : UserScopedService { - private val map = mutableMapOf() +/** + * Provides cached Context instances each distinct per-User. + * + * @see [UserScopedService] + */ +class UserScopedContext @Inject constructor(private val applicationContext: Context) { + private val contextCacheSizeLimit = 8 + + private val instances = + object : LruCache(contextCacheSizeLimit) { + override fun create(key: UserHandle): Context { + return applicationContext.createContextAsUser(key, 0) + } + } - override fun forUser(user: User): T { - return synchronized(this) { - map.getOrPut(user) { - val userContext = context.createContextAsUser(user.handle, 0) - requireNotNull(userContext.getSystemService()) - } + fun forUser(user: UserHandle): Context { + synchronized(this) { + return if (applicationContext.user == user) { + applicationContext + } else { + return instances[user] } } } } + +/** Returns a cache of service instances, distinct by user */ +class UserScopedServiceImpl( + contexts: UserScopedContext, + serviceType: KClass +): UserScopedService { + private val instances = + object : LruCache(8) { + override fun create(key: UserHandle): T { + val context = contexts.forUser(key) + return requireNotNull(context.getSystemService(serviceType.java)) + } + } + + override fun forUser(user: UserHandle): T { + synchronized(this) { + return instances[user] + } + } +} -- cgit v1.2.3-59-g8ed1b From 118a77b69fe8ffa8db0dbda7a636b7d7046f4f54 Mon Sep 17 00:00:00 2001 From: Andrey Epin Date: Mon, 11 Mar 2024 08:01:12 -0700 Subject: Update app targets on payload selection change ShareouselContentPreviewViewModel is merged into ChooserViewModel (to make all shareousel injectable available in ChooserViewModel without changing their scope). The shareousel machinery is initialized lazily to match the legacy behavior. PreviewSelectionRepository now publish selections only after the initial value has been read (vs. starting with the empty selection) to avoid triggering unnecessary targets resolution. A new interactor is added that updates ChooserRequest upon changes in the target request repository. Bug: 302691505 Test: manual functinality test with and without payload toggling flag Test: atest IntentResolver-tests-unit Test: atest IntentResolver-tests-activity Change-Id: I2f3d4f636e8ebe4a003e955cb5f089c28f2a7f1d --- .../contentpreview/ShareouselContentPreviewUi.kt | 6 +- .../viewmodel/ShareouselContentPreviewViewModel.kt | 46 -------- .../data/repository/PreviewSelectionsRepository.kt | 34 +++++- .../domain/interactor/FetchPreviewsInteractor.kt | 2 +- .../interactor/SelectablePreviewInteractor.kt | 5 +- .../interactor/UpdateTargetIntentInteractor.kt | 1 - .../android/intentresolver/v2/ChooserActivity.java | 70 +++++++++++- .../com/android/intentresolver/v2/ChooserHelper.kt | 10 ++ .../interactor/ChooserRequestUpdateInteractor.kt | 82 ++++++++++++++ .../v2/profiles/MultiProfilePagerAdapter.java | 1 + .../v2/ui/viewmodel/ChooserViewModel.kt | 29 ++++- .../interactor/SelectablePreviewInteractorTest.kt | 14 ++- .../interactor/SelectablePreviewsInteractorTest.kt | 8 +- .../interactor/UpdateTargetIntentInteractorTest.kt | 6 +- .../ui/viewmodel/ShareouselViewModelTest.kt | 8 +- .../ChooserRequestUpdateInteractorTest.kt | 126 +++++++++++++++++++++ 16 files changed, 379 insertions(+), 69 deletions(-) delete mode 100644 java/src/com/android/intentresolver/contentpreview/payloadtoggle/app/viewmodel/ShareouselContentPreviewViewModel.kt create mode 100644 java/src/com/android/intentresolver/v2/domain/interactor/ChooserRequestUpdateInteractor.kt create mode 100644 tests/unit/src/com/android/intentresolver/v2/domain/interactor/ChooserRequestUpdateInteractorTest.kt (limited to 'java/src') diff --git a/java/src/com/android/intentresolver/contentpreview/ShareouselContentPreviewUi.kt b/java/src/com/android/intentresolver/contentpreview/ShareouselContentPreviewUi.kt index 80f7c25a..463da5fa 100644 --- a/java/src/com/android/intentresolver/contentpreview/ShareouselContentPreviewUi.kt +++ b/java/src/com/android/intentresolver/contentpreview/ShareouselContentPreviewUi.kt @@ -31,9 +31,9 @@ import androidx.compose.ui.platform.LocalContext import androidx.lifecycle.viewmodel.compose.viewModel import com.android.intentresolver.R import com.android.intentresolver.contentpreview.ChooserContentPreviewUi.ActionFactory -import com.android.intentresolver.contentpreview.payloadtoggle.app.viewmodel.ShareouselContentPreviewViewModel 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( @@ -58,8 +58,8 @@ class ShareouselContentPreviewUi( } return ComposeView(parent.context).apply { setContent { - val vm: ShareouselContentPreviewViewModel = viewModel() - val viewModel: ShareouselViewModel = vm.viewModel + val vm: ChooserViewModel = viewModel() + val viewModel: ShareouselViewModel = vm.shareouselViewModel headlineViewParent?.let { LaunchedEffect(viewModel) { bindHeadline(viewModel, headlineViewParent) } diff --git a/java/src/com/android/intentresolver/contentpreview/payloadtoggle/app/viewmodel/ShareouselContentPreviewViewModel.kt b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/app/viewmodel/ShareouselContentPreviewViewModel.kt deleted file mode 100644 index 479f0ec8..00000000 --- a/java/src/com/android/intentresolver/contentpreview/payloadtoggle/app/viewmodel/ShareouselContentPreviewViewModel.kt +++ /dev/null @@ -1,46 +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.payloadtoggle.app.viewmodel - -import androidx.lifecycle.ViewModel -import com.android.intentresolver.contentpreview.payloadtoggle.domain.interactor.FetchPreviewsInteractor -import com.android.intentresolver.contentpreview.payloadtoggle.domain.interactor.UpdateTargetIntentInteractor -import com.android.intentresolver.contentpreview.payloadtoggle.ui.viewmodel.ShareouselViewModel -import com.android.intentresolver.inject.Background -import com.android.intentresolver.inject.ViewModelOwned -import dagger.hilt.android.lifecycle.HiltViewModel -import javax.inject.Inject -import kotlinx.coroutines.CoroutineDispatcher -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.launch - -/** View-model for [com.android.intentresolver.contentpreview.ShareouselContentPreviewUi]. */ -@HiltViewModel -class ShareouselContentPreviewViewModel -@Inject -constructor( - val viewModel: ShareouselViewModel, - updateTargetIntentInteractor: UpdateTargetIntentInteractor, - fetchPreviewsInteractor: FetchPreviewsInteractor, - @Background private val bgDispatcher: CoroutineDispatcher, - @ViewModelOwned private val scope: CoroutineScope, -) : ViewModel() { - init { - scope.launch(bgDispatcher) { updateTargetIntentInteractor.launch() } - scope.launch(bgDispatcher) { fetchPreviewsInteractor.launch() } - } -} 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 index 8035580d..2d849d14 100644 --- a/java/src/com/android/intentresolver/contentpreview/payloadtoggle/data/repository/PreviewSelectionsRepository.kt +++ b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/data/repository/PreviewSelectionsRepository.kt @@ -16,14 +16,46 @@ package com.android.intentresolver.contentpreview.payloadtoggle.data.repository +import android.util.Log import com.android.intentresolver.contentpreview.payloadtoggle.shared.model.PreviewModel import dagger.hilt.android.scopes.ViewModelScoped import javax.inject.Inject +import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.filterNotNull +import kotlinx.coroutines.flow.update + +private const val TAG = "PreviewSelectionsRep" /** Stores set of selected previews. */ @ViewModelScoped class PreviewSelectionsRepository @Inject constructor() { /** Set of selected previews. */ - val selections = MutableStateFlow>(emptySet()) + private val _selections = MutableStateFlow?>(null) + + val selections: Flow> = _selections.filterNotNull() + + fun setSelection(selection: Set) { + _selections.value = selection + } + + fun select(item: PreviewModel) { + _selections.update { selection -> + selection?.let { it + item } + ?: run { + Log.w(TAG, "Changing selection before it is initialized") + null + } + } + } + + fun unselect(item: PreviewModel) { + _selections.update { selection -> + selection?.let { it - item } + ?: run { + Log.w(TAG, "Changing selection before it is initialized") + null + } + } + } } 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 index 032692cd..a7749c92 100644 --- a/java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/interactor/FetchPreviewsInteractor.kt +++ b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/interactor/FetchPreviewsInteractor.kt @@ -44,7 +44,7 @@ constructor( suspend fun launch() = coroutineScope { val cursor = async { cursorResolver.getCursor() } val initialPreviewMap: Set = getInitialPreviews() - selectionRepository.selections.value = initialPreviewMap + selectionRepository.setSelection(initialPreviewMap) setCursorPreviews.setPreviews( previewsByKey = initialPreviewMap, startIndex = focusedItemIdx, 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 index d94b1078..0b1038f5 100644 --- a/java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/interactor/SelectablePreviewInteractor.kt +++ b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/interactor/SelectablePreviewInteractor.kt @@ -21,7 +21,6 @@ import com.android.intentresolver.contentpreview.payloadtoggle.data.repository.P import com.android.intentresolver.contentpreview.payloadtoggle.shared.model.PreviewModel import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.map -import kotlinx.coroutines.flow.update /** An individual preview in Shareousel. */ class SelectablePreviewInteractor( @@ -37,9 +36,9 @@ class SelectablePreviewInteractor( /** Sets whether this preview is selected by the user. */ fun setSelected(isSelected: Boolean) { if (isSelected) { - selectionRepo.selections.update { it + key } + selectionRepo.select(key) } else { - selectionRepo.selections.update { it - key } + selectionRepo.unselect(key) } } } 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 index e7bdafbc..4619e478 100644 --- a/java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/interactor/UpdateTargetIntentInteractor.kt +++ b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/interactor/UpdateTargetIntentInteractor.kt @@ -30,7 +30,6 @@ import javax.inject.Inject import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.flow.collectLatest -import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.mapLatest import kotlinx.coroutines.launch diff --git a/java/src/com/android/intentresolver/v2/ChooserActivity.java b/java/src/com/android/intentresolver/v2/ChooserActivity.java index 9a5ec173..da9eb750 100644 --- a/java/src/com/android/intentresolver/v2/ChooserActivity.java +++ b/java/src/com/android/intentresolver/v2/ChooserActivity.java @@ -352,6 +352,9 @@ public class ChooserActivity extends Hilt_ChooserActivity implements // Initializer is invoked when this function returns, via Lifecycle. mChooserHelper.setInitializer(this::initializeWith); + if (mChooserServiceFeatureFlags.chooserPayloadToggling()) { + mChooserHelper.setOnChooserRequestChanged(this::onChooserRequestChanged); + } } @Override @@ -513,7 +516,7 @@ public class ChooserActivity extends Hilt_ChooserActivity implements mChooserMultiProfilePagerAdapter = createMultiProfilePagerAdapter( /* context = */ this, mProfilePagerResources, - mViewModel.getRequest().getValue(), + mRequest, mProfiles, mProfileAvailability, mRequest.getInitialIntents(), @@ -657,6 +660,71 @@ public class ChooserActivity extends Hilt_ChooserActivity implements Tracer.INSTANCE.markLaunched(); } + private void onChooserRequestChanged(ChooserRequest chooserRequest) { + if (mRequest == chooserRequest) { + return; + } + mRequest = chooserRequest; + recreatePagerAdapter(); + } + + 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); diff --git a/java/src/com/android/intentresolver/v2/ChooserHelper.kt b/java/src/com/android/intentresolver/v2/ChooserHelper.kt index d34e0b36..f2a2726a 100644 --- a/java/src/com/android/intentresolver/v2/ChooserHelper.kt +++ b/java/src/com/android/intentresolver/v2/ChooserHelper.kt @@ -25,6 +25,7 @@ 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 @@ -36,6 +37,7 @@ 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 @@ -113,6 +115,8 @@ constructor( private lateinit var activityInitializer: ChooserInitializer + var onChooserRequestChanged: Consumer = Consumer {} + init { activity.lifecycle.addObserver(this) } @@ -150,6 +154,12 @@ constructor( 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) { diff --git a/java/src/com/android/intentresolver/v2/domain/interactor/ChooserRequestUpdateInteractor.kt b/java/src/com/android/intentresolver/v2/domain/interactor/ChooserRequestUpdateInteractor.kt new file mode 100644 index 00000000..99da5c81 --- /dev/null +++ b/java/src/com/android/intentresolver/v2/domain/interactor/ChooserRequestUpdateInteractor.kt @@ -0,0 +1,82 @@ +/* + * 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.domain.interactor + +import android.content.Intent +import android.util.Log +import com.android.intentresolver.contentpreview.payloadtoggle.data.repository.TargetIntentRepository +import com.android.intentresolver.inject.ChooserServiceFlags +import com.android.intentresolver.inject.TargetIntent +import com.android.intentresolver.v2.ui.model.ActivityModel +import com.android.intentresolver.v2.ui.model.ChooserRequest +import com.android.intentresolver.v2.ui.viewmodel.readChooserRequest +import com.android.intentresolver.v2.validation.Invalid +import com.android.intentresolver.v2.validation.Valid +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject +import dagger.hilt.android.scopes.ViewModelScoped +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.filter + +private const val TAG = "ChooserRequestUpdate" + +/** Updates updates ChooserRequest with a new target intent */ +// TODO: make fully injectable +class ChooserRequestUpdateInteractor +@AssistedInject +constructor( + private val activityModel: ActivityModel, + @TargetIntent private val initialIntent: Intent, + private val targetIntentRepository: TargetIntentRepository, + // TODO: replace with a proper repository, when available + @Assisted private val chooserRequestRepository: MutableStateFlow, + private val flags: ChooserServiceFlags, +) { + + suspend fun launch() { + targetIntentRepository.targetIntent + // TODO: maybe find a better way to exclude the initial intent (as here it's compared by + // reference) + .filter { it !== initialIntent } + .collect(::updateTargetIntent) + } + + private fun updateTargetIntent(targetIntent: Intent) { + val updatedActivityModel = activityModel.updateWithTargetIntent(targetIntent) + when (val updatedChooserRequest = readChooserRequest(updatedActivityModel, flags)) { + is Valid -> chooserRequestRepository.value = updatedChooserRequest.value + is Invalid -> Log.w(TAG, "Failed to apply payload selection changes") + } + } + + private fun ActivityModel.updateWithTargetIntent(targetIntent: Intent) = + ActivityModel( + Intent(intent).apply { putExtra(Intent.EXTRA_INTENT, targetIntent) }, + launchedFromUid, + launchedFromPackage, + referrer, + ) +} + +@AssistedFactory +@ViewModelScoped +interface ChooserRequestUpdateInteractorFactory { + fun create( + chooserRequestRepository: MutableStateFlow + ): ChooserRequestUpdateInteractor +} diff --git a/java/src/com/android/intentresolver/v2/profiles/MultiProfilePagerAdapter.java b/java/src/com/android/intentresolver/v2/profiles/MultiProfilePagerAdapter.java index 5d7cf26e..341e7043 100644 --- a/java/src/com/android/intentresolver/v2/profiles/MultiProfilePagerAdapter.java +++ b/java/src/com/android/intentresolver/v2/profiles/MultiProfilePagerAdapter.java @@ -245,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/ui/viewmodel/ChooserViewModel.kt b/java/src/com/android/intentresolver/v2/ui/viewmodel/ChooserViewModel.kt index 4d87b2cb..4431a545 100644 --- a/java/src/com/android/intentresolver/v2/ui/viewmodel/ChooserViewModel.kt +++ b/java/src/com/android/intentresolver/v2/ui/viewmodel/ChooserViewModel.kt @@ -18,17 +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.UpdateTargetIntentInteractor +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.domain.interactor.ChooserRequestUpdateInteractorFactory 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 dagger.Lazy import dagger.hilt.android.lifecycle.HiltViewModel import javax.inject.Inject +import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch private const val TAG = "ChooserViewModel" @@ -37,7 +46,12 @@ class ChooserViewModel @Inject constructor( args: SavedStateHandle, - flags: ChooserServiceFlags, + private val shareouselViewModelProvider: Lazy, + private val updateTargetIntentInteractor: Lazy, + private val fetchPreviewsInteractor: Lazy, + @Background private val bgDispatcher: CoroutineDispatcher, + private val chooserRequestUpdateInteractorFactory: ChooserRequestUpdateInteractorFactory, + private val flags: ChooserServiceFlags, ) : ViewModel() { /** Parcelable-only references provided from the creating Activity */ @@ -46,6 +60,19 @@ constructor( "ActivityModel missing in SavedStateHandle! ($ACTIVITY_MODEL_KEY)" } + val 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) { updateTargetIntentInteractor.get().launch() } + viewModelScope.launch(bgDispatcher) { fetchPreviewsInteractor.get().launch() } + viewModelScope.launch { chooserRequestUpdateInteractorFactory.create(_request).launch() } + shareouselViewModelProvider.get() + } + /** * Provided only for the express purpose of early exit in the event of an invalid request. * 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 index 00690307..3dba5329 100644 --- 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 @@ -14,6 +14,8 @@ * limitations under the License. */ +@file:OptIn(ExperimentalCoroutinesApi::class) + package com.android.intentresolver.contentpreview.payloadtoggle.domain.interactor import android.net.Uri @@ -22,7 +24,9 @@ import com.android.intentresolver.contentpreview.payloadtoggle.data.repository.P import com.android.intentresolver.contentpreview.payloadtoggle.shared.model.PreviewModel import com.android.intentresolver.contentpreview.payloadtoggle.shared.model.PreviewsModel 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 @@ -50,11 +54,14 @@ class SelectablePreviewInteractorTest { loadMoreRight = null, ) } + val selectionRepo = PreviewSelectionsRepository() val underTest = SelectablePreviewInteractor( key = PreviewModel(Uri.fromParts("scheme", "ssp", "fragment"), null), - selectionRepo = PreviewSelectionsRepository(), + selectionRepo = selectionRepo, ) + selectionRepo.setSelection(emptySet()) + testScheduler.runCurrent() assertThat(underTest.isSelected.first()).isFalse() } @@ -68,6 +75,7 @@ class SelectablePreviewInteractorTest { key = PreviewModel(Uri.fromParts("scheme", "ssp", "fragment"), "image/bitmap"), selectionRepo = selectionRepository, ) + selectionRepository.setSelection(emptySet()) assertThat(underTest.isSelected.first()).isFalse() @@ -89,8 +97,10 @@ class SelectablePreviewInteractorTest { loadMoreRight = null, ) - selectionRepository.selections.value = + selectionRepository.setSelection( setOf(PreviewModel(Uri.fromParts("scheme", "ssp", "fragment"), "image/bitmap")) + ) + runCurrent() assertThat(underTest.isSelected.first()).isTrue() } 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 index 53d2ac46..a5d09f56 100644 --- 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 @@ -57,10 +57,11 @@ class SelectablePreviewsInteractorTest { } val selectionRepo = PreviewSelectionsRepository().apply { - selections.value = + setSelection( setOf( PreviewModel(Uri.fromParts("scheme", "ssp", "fragment"), null), ) + ) } val underTest = SelectablePreviewsInteractor( @@ -94,8 +95,7 @@ class SelectablePreviewsInteractorTest { val previewsRepo = CursorPreviewsRepository() val selectionRepo = PreviewSelectionsRepository().apply { - selections.value = - setOf(PreviewModel(Uri.fromParts("scheme", "ssp", "fragment"), null)) + setSelection(setOf(PreviewModel(Uri.fromParts("scheme", "ssp", "fragment"), null))) } val underTest = SelectablePreviewsInteractor(previewsRepo, selectionRepo) val previews = underTest.previews.stateIn(backgroundScope) @@ -124,7 +124,7 @@ class SelectablePreviewsInteractorTest { loadMoreLeft = null, loadMoreRight = { loadRequested = true }, ) - selectionRepo.selections.value = emptySet() + selectionRepo.setSelection(emptySet()) runCurrent() assertThat(previews.value).isNotNull() diff --git a/tests/unit/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/interactor/UpdateTargetIntentInteractorTest.kt b/tests/unit/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/interactor/UpdateTargetIntentInteractorTest.kt index cd6b8de7..bb8a09a3 100644 --- a/tests/unit/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/interactor/UpdateTargetIntentInteractorTest.kt +++ b/tests/unit/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/interactor/UpdateTargetIntentInteractorTest.kt @@ -54,20 +54,22 @@ class UpdateTargetIntentInteractorTest { backgroundScope.launch { underTest.launch() } runCurrent() + // as we do not publish the initial empty selection, we should not modify the intent assertThat( intentRepository.targetIntent.value.getParcelableArrayListExtra( "selection", Uri::class.java, ) ) - .isEmpty() + .isNull() - selectionRepository.selections.value = + selectionRepository.setSelection( setOf( PreviewModel(Uri.fromParts("scheme0", "ssp0", "fragment0"), null), PreviewModel(Uri.fromParts("scheme1", "ssp1", "fragment1"), null), PreviewModel(Uri.fromParts("scheme2", "ssp2", "fragment2"), null), ) + ) runCurrent() assertThat( 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 index 854e0319..057906f7 100644 --- 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 @@ -60,8 +60,7 @@ class ShareouselViewModelTest { val previewsRepository = CursorPreviewsRepository() val selectionRepository = PreviewSelectionsRepository().apply { - selections.value = - setOf(PreviewModel(Uri.fromParts("scheme", "ssp", "fragment"), null)) + setSelection(setOf(PreviewModel(Uri.fromParts("scheme", "ssp", "fragment"), null))) } val activityResultRepository = ActivityResultRepository() val contentResolver = mock {} @@ -146,7 +145,7 @@ class ShareouselViewModelTest { fun headline() = runTestWithDeps { deps -> with(deps) { assertThat(underTest.headline.first()).isEqualTo("IMAGES: 1") - selectionRepository.selections.value = + selectionRepository.setSelection( setOf( PreviewModel( Uri.fromParts("scheme", "ssp", "fragment"), @@ -157,6 +156,7 @@ class ShareouselViewModelTest { null, ) ) + ) runCurrent() assertThat(underTest.headline.first()).isEqualTo("IMAGES: 2") } @@ -201,7 +201,7 @@ class ShareouselViewModelTest { previewVm.setSelected(true) - assertThat(selectionRepository.selections.value) + assertThat(selectionRepository.selections.first()) .comparingElementsUsingTransform("has uri of") { model: PreviewModel -> model.uri } .contains(Uri.fromParts("scheme1", "ssp1", "fragment1")) } diff --git a/tests/unit/src/com/android/intentresolver/v2/domain/interactor/ChooserRequestUpdateInteractorTest.kt b/tests/unit/src/com/android/intentresolver/v2/domain/interactor/ChooserRequestUpdateInteractorTest.kt new file mode 100644 index 00000000..dc6a2d47 --- /dev/null +++ b/tests/unit/src/com/android/intentresolver/v2/domain/interactor/ChooserRequestUpdateInteractorTest.kt @@ -0,0 +1,126 @@ +/* + * 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.domain.interactor + +import android.content.Intent +import android.content.Intent.ACTION_SEND +import android.content.Intent.ACTION_SEND_MULTIPLE +import android.content.Intent.EXTRA_STREAM +import android.net.Uri +import android.service.chooser.Flags +import com.android.intentresolver.contentpreview.payloadtoggle.data.repository.TargetIntentRepository +import com.android.intentresolver.inject.FakeChooserServiceFlags +import com.android.intentresolver.v2.ui.model.ActivityModel +import com.android.intentresolver.v2.ui.model.ChooserRequest +import com.google.common.truth.Truth.assertWithMessage +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.runTest +import org.junit.Test + +class ChooserRequestUpdateInteractorTest { + private val targetIntent = + Intent(ACTION_SEND_MULTIPLE).apply { + putExtra( + EXTRA_STREAM, + ArrayList().apply { + add(createUri(1)) + add(createUri(2)) + } + ) + type = "image/png" + } + val initialRequest = createSomeChooserRequest(targetIntent) + private val chooserIntent = Intent.createChooser(targetIntent, null) + private val activityModel = + ActivityModel( + chooserIntent, + launchedFromUid = 1, + launchedFromPackage = "org.pkg.app", + referrer = null, + ) + private val targetIntentRepository = + TargetIntentRepository( + targetIntent, + emptyList(), + ) + private val fakeFlags = + FakeChooserServiceFlags().apply { + setFlag(Flags.FLAG_CHOOSER_PAYLOAD_TOGGLING, true) + setFlag(Flags.FLAG_CHOOSER_ALBUM_TEXT, false) + setFlag(Flags.FLAG_ENABLE_SHARESHEET_METADATA_EXTRA, false) + } + private val testScope = TestScope() + + @Test + fun testInitialIntentOnly_noUpdates() = + testScope.runTest { + val requestFlow = MutableStateFlow(initialRequest) + val testSubject = + ChooserRequestUpdateInteractor( + activityModel, + targetIntent, + targetIntentRepository, + requestFlow, + fakeFlags, + ) + backgroundScope.launch { testSubject.launch() } + testScheduler.runCurrent() + + assertWithMessage("No updates expected") + .that(requestFlow.value) + .isSameInstanceAs(initialRequest) + } + + @Test + fun testIntentUpdate_newRequestPublished() = + testScope.runTest { + val requestFlow = MutableStateFlow(initialRequest) + val testSubject = + ChooserRequestUpdateInteractor( + activityModel, + targetIntent, + targetIntentRepository, + requestFlow, + fakeFlags, + ) + backgroundScope.launch { testSubject.launch() } + targetIntentRepository.targetIntent.value = + Intent(targetIntent).apply { + action = ACTION_SEND + putExtra(EXTRA_STREAM, createUri(2)) + } + testScheduler.runCurrent() + + assertWithMessage("No updates expected") + .that(requestFlow.value) + .isNotEqualTo(initialRequest) + } +} + +private fun createSomeChooserRequest(targetIntent: Intent) = + ChooserRequest( + targetIntent = targetIntent, + targetAction = targetIntent.action, + isSendActionTarget = true, + targetType = null, + launchedFromPackage = "", + referrer = null, + ) + +private fun createUri(id: Int) = Uri.parse("content://org.pkg.app/image-$id.png") -- cgit v1.2.3-59-g8ed1b From 5b8fe15b51e136504f60b9335b3e0c7cdc1496c9 Mon Sep 17 00:00:00 2001 From: mrenouf Date: Tue, 12 Mar 2024 09:38:17 -0400 Subject: Add missing copyright headers. Flag: NA Bug: NA Test: NA Change-Id: Ic19cf0a16f49e8664daca977610453ec74121a28 --- .../intentresolver/v2/data/repository/UserInfoExt.kt | 16 ++++++++++++++++ .../intentresolver/v2/data/repository/UserRepository.kt | 16 ++++++++++++++++ .../v2/data/repository/UserRepositoryModule.kt | 16 ++++++++++++++++ 3 files changed, 48 insertions(+) (limited to 'java/src') 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..d582844c 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 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 -- cgit v1.2.3-59-g8ed1b From e3d2e850f9caeb4df9389ef40d17a650b7bb505a Mon Sep 17 00:00:00 2001 From: Andrey Epin Date: Tue, 12 Mar 2024 17:50:15 -0700 Subject: Update remaining payload change callback values Moves ShareouselUpdate out of SelectionChangeCallback into the model package. Receives result intent sender and metadata text from the callback. Propagate chooser requst changes from the callback to the ChooserViewModel; update result sender in ChooserActivity. Bug: 302691505 Test: IntentResolver-tests-unit Test: manual test with the updated CTS-V test Change-Id: I3b05cb35b94cb820c5f4fdc9fbc336dd072c36bd --- .../repository/ChooserParamsUpdateRepository.kt | 34 +++++++ .../interactor/UpdateTargetIntentInteractor.kt | 12 ++- .../payloadtoggle/domain/model/ShareouselUpdate.kt | 34 +++++++ .../domain/update/SelectionChangeCallback.kt | 31 +++--- .../android/intentresolver/v2/ChooserActivity.java | 34 +++++-- .../interactor/ChooserRequestUpdateInteractor.kt | 58 ++++++++++- .../interactor/UpdateTargetIntentInteractorTest.kt | 11 ++- .../update/SelectionChangeCallbackImplTest.kt | 109 +++++++++++++++++++-- .../ChooserRequestUpdateInteractorTest.kt | 40 +++++++- 9 files changed, 327 insertions(+), 36 deletions(-) create mode 100644 java/src/com/android/intentresolver/contentpreview/payloadtoggle/data/repository/ChooserParamsUpdateRepository.kt create mode 100644 java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/model/ShareouselUpdate.kt (limited to 'java/src') diff --git a/java/src/com/android/intentresolver/contentpreview/payloadtoggle/data/repository/ChooserParamsUpdateRepository.kt b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/data/repository/ChooserParamsUpdateRepository.kt new file mode 100644 index 00000000..1a4f2b83 --- /dev/null +++ b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/data/repository/ChooserParamsUpdateRepository.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.data.repository + +import com.android.intentresolver.contentpreview.payloadtoggle.domain.model.ShareouselUpdate +import dagger.hilt.android.scopes.ViewModelScoped +import javax.inject.Inject +import kotlinx.coroutines.flow.MutableStateFlow + +/** Chooser parameters Updates received from the sharing application payload change callback */ +// TODO: a scaffolding repository to deliver chooser parameter updates before we developed some +// other, more thought-through solution. +@ViewModelScoped +class ChooserParamsUpdateRepository @Inject constructor() { + val updates = MutableStateFlow(null) + + fun setUpdates(update: ShareouselUpdate) { + updates.tryEmit(update) + } +} 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 index 4619e478..4cb1f5b6 100644 --- a/java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/interactor/UpdateTargetIntentInteractor.kt +++ b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/interactor/UpdateTargetIntentInteractor.kt @@ -18,6 +18,7 @@ package com.android.intentresolver.contentpreview.payloadtoggle.domain.interactor +import com.android.intentresolver.contentpreview.payloadtoggle.data.repository.ChooserParamsUpdateRepository import com.android.intentresolver.contentpreview.payloadtoggle.data.repository.PreviewSelectionsRepository import com.android.intentresolver.contentpreview.payloadtoggle.data.repository.TargetIntentRepository import com.android.intentresolver.contentpreview.payloadtoggle.domain.intent.CustomAction @@ -30,6 +31,7 @@ import javax.inject.Inject import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.filterNotNull import kotlinx.coroutines.flow.mapLatest import kotlinx.coroutines.launch @@ -38,6 +40,7 @@ class UpdateTargetIntentInteractor @Inject constructor( private val intentRepository: TargetIntentRepository, + private val chooserParamsUpdateRepository: ChooserParamsUpdateRepository, @CustomAction private val pendingIntentSender: PendingIntentSender, private val selectionCallback: SelectionChangeCallback, private val selectionRepo: PreviewSelectionsRepository, @@ -47,12 +50,13 @@ constructor( suspend fun launch(): Unit = coroutineScope { launch { intentRepository.targetIntent - .mapLatest { targetIntent -> - selectionCallback.onSelectionChanged(targetIntent)?.customActions ?: emptyList() - } - .collect { actions -> + .mapLatest { targetIntent -> selectionCallback.onSelectionChanged(targetIntent) } + .filterNotNull() + .collect { updates -> + val actions = updates.customActions ?: emptyList() intentRepository.customActions.value = actions.map { it.toCustomActionModel(pendingIntentSender) } + chooserParamsUpdateRepository.setUpdates(updates) } } launch { 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..41a34d1a --- /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: List? = null, + val modifyShareAction: ChooserAction? = null, + val alternateIntents: List? = null, + val callerTargets: List? = null, + val refinementIntentSender: IntentSender? = null, + val resultIntentSender: IntentSender? = null, + val metadataText: CharSequence? = null, +) 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 index 03295a31..e7644dc5 100644 --- a/java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/update/SelectionChangeCallback.kt +++ b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/update/SelectionChangeCallback.kt @@ -20,17 +20,20 @@ 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_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.update.SelectionChangeCallback.ShareouselUpdate +import com.android.intentresolver.contentpreview.payloadtoggle.domain.model.ShareouselUpdate 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 @@ -56,15 +59,6 @@ private const val TAG = "SelectionChangeCallback" */ fun interface SelectionChangeCallback { suspend fun onSelectionChanged(targetIntent: Intent): ShareouselUpdate? - - data class ShareouselUpdate( - // for all properties, null value means no change - val customActions: List? = null, - val modifyShareAction: ChooserAction? = null, - val alternateIntents: List? = null, - val callerTargets: List? = null, - val refinementIntentSender: IntentSender? = null, - ) } class SelectionChangeCallbackImpl @@ -73,6 +67,7 @@ constructor( @AdditionalContent private val uri: Uri, @ChooserIntent private val chooserIntent: Intent, private val contentResolver: ContentInterface, + private val flags: ChooserServiceFlags, ) : SelectionChangeCallback { private val mutex = Mutex() @@ -92,7 +87,7 @@ constructor( ) } ?.let { bundle -> - return when (val result = readCallbackResponse(bundle)) { + return when (val result = readCallbackResponse(bundle, flags)) { is Valid -> result.value is Invalid -> { result.errors.forEach { it.log(TAG) } @@ -102,7 +97,10 @@ constructor( } } -private fun readCallbackResponse(bundle: Bundle): ValidationResult { +private fun readCallbackResponse( + bundle: Bundle, + flags: ChooserServiceFlags +): ValidationResult { return validateFrom(bundle::get) { val customActions = readChooserActions() val modifyShareAction = optional(value(EXTRA_CHOOSER_MODIFY_SHARE_ACTION)) @@ -110,6 +108,13 @@ private fun readCallbackResponse(bundle: Bundle): ValidationResult(EXTRA_CHOOSER_TARGETS)) val refinementIntentSender = optional(value(EXTRA_CHOOSER_REFINEMENT_INTENT_SENDER)) + val resultIntentSender = optional(value(EXTRA_CHOOSER_RESULT_INTENT_SENDER)) + val metadataText = + if (flags.enableSharesheetMetadataExtra()) { + optional(value(EXTRA_METADATA_TEXT)) + } else { + null + } ShareouselUpdate( customActions, @@ -117,6 +122,8 @@ private fun readCallbackResponse(bundle: Bundle): ValidationResult, private val flags: ChooserServiceFlags, ) { suspend fun launch() { - targetIntentRepository.targetIntent - // TODO: maybe find a better way to exclude the initial intent (as here it's compared by - // reference) - .filter { it !== initialIntent } - .collect(::updateTargetIntent) + coroutineScope { + launch { + targetIntentRepository.targetIntent + // TODO: maybe find a better way to exclude the initial intent (as here it's + // compared by + // reference) + .filter { it !== initialIntent } + .collect(::updateTargetIntent) + } + + launch { + paramsUpdateRepository.updates.filterNotNull().collect(::updateChooserParameters) + } + } } private fun updateTargetIntent(targetIntent: Intent) { @@ -64,6 +80,38 @@ constructor( } } + private fun updateChooserParameters(update: ShareouselUpdate) { + chooserRequestRepository.update { current -> + ChooserRequest( + current.targetIntent, + current.targetAction, + current.isSendActionTarget, + current.targetType, + current.launchedFromPackage, + current.title, + current.defaultTitleResource, + current.referrer, + current.filteredComponentNames, + update.callerTargets ?: current.callerChooserTargets, + // chooser actions are handled separately + current.chooserActions, + update.modifyShareAction ?: current.modifyShareAction, + current.shouldRetainInOnStop, + update.alternateIntents ?: current.additionalTargets, + current.replacementExtras, + current.initialIntents, + update.resultIntentSender ?: current.chosenComponentSender, + update.refinementIntentSender ?: current.refinementIntentSender, + current.sharedText, + current.shareTargetFilter, + current.additionalContentUri, + current.focusedItemPosition, + current.contentTypeHint, + update.metadataText ?: current.metadataText, + ) + } + } + private fun ActivityModel.updateWithTargetIntent(targetIntent: Intent) = ActivityModel( Intent(intent).apply { putExtra(Intent.EXTRA_INTENT, targetIntent) }, diff --git a/tests/unit/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/interactor/UpdateTargetIntentInteractorTest.kt b/tests/unit/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/interactor/UpdateTargetIntentInteractorTest.kt index bb8a09a3..3bcdcacd 100644 --- a/tests/unit/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/interactor/UpdateTargetIntentInteractorTest.kt +++ b/tests/unit/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/interactor/UpdateTargetIntentInteractorTest.kt @@ -20,11 +20,15 @@ package com.android.intentresolver.contentpreview.payloadtoggle.domain.interacto import android.content.Intent import android.net.Uri +import com.android.intentresolver.contentpreview.payloadtoggle.data.repository.ChooserParamsUpdateRepository import com.android.intentresolver.contentpreview.payloadtoggle.data.repository.PreviewSelectionsRepository import com.android.intentresolver.contentpreview.payloadtoggle.data.repository.TargetIntentRepository +import com.android.intentresolver.contentpreview.payloadtoggle.domain.model.ShareouselUpdate import com.android.intentresolver.contentpreview.payloadtoggle.shared.model.PreviewModel import com.google.common.truth.Truth.assertThat import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.filterNotNull +import kotlinx.coroutines.flow.first import kotlinx.coroutines.launch import kotlinx.coroutines.test.runCurrent import kotlinx.coroutines.test.runTest @@ -36,10 +40,13 @@ class UpdateTargetIntentInteractorTest { val initialIntent = Intent() val intentRepository = TargetIntentRepository(initialIntent, emptyList()) val selectionRepository = PreviewSelectionsRepository() + val chooserParamsUpdateRepository = ChooserParamsUpdateRepository() + val selectionCallbackResult = ShareouselUpdate() val underTest = UpdateTargetIntentInteractor( intentRepository = intentRepository, - selectionCallback = { intent -> null }, + chooserParamsUpdateRepository = chooserParamsUpdateRepository, + selectionCallback = { selectionCallbackResult }, selectionRepo = selectionRepository, targetIntentModifier = { selection -> Intent() @@ -83,5 +90,7 @@ class UpdateTargetIntentInteractorTest { Uri.fromParts("scheme1", "ssp1", "fragment1"), Uri.fromParts("scheme2", "ssp2", "fragment2"), ) + assertThat(chooserParamsUpdateRepository.updates.filterNotNull().first()) + .isEqualTo(selectionCallbackResult) } } diff --git a/tests/unit/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/update/SelectionChangeCallbackImplTest.kt b/tests/unit/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/update/SelectionChangeCallbackImplTest.kt index d7c4170d..0dfbfff2 100644 --- a/tests/unit/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/update/SelectionChangeCallbackImplTest.kt +++ b/tests/unit/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/update/SelectionChangeCallbackImplTest.kt @@ -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,11 +38,13 @@ 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.inject.FakeChooserServiceFlags import com.android.intentresolver.mock import com.android.intentresolver.whenever import com.google.common.truth.Correspondence @@ -59,10 +63,16 @@ class SelectionChangeCallbackImplTest { private val chooserIntent = Intent(ACTION_CHOOSER) private val contentResolver = mock() 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() = runTest { - val testSubject = SelectionChangeCallbackImpl(uri, chooserIntent, contentResolver) + val testSubject = SelectionChangeCallbackImpl(uri, chooserIntent, contentResolver, flags) val u1 = createUri(1) val u2 = createUri(2) @@ -158,7 +168,7 @@ class SelectionChangeCallbackImplTest { Bundle().apply { putParcelableArray(EXTRA_CHOOSER_CUSTOM_ACTIONS, arrayOf(a1, a2)) } ) - val testSubject = SelectionChangeCallbackImpl(uri, chooserIntent, contentResolver) + val testSubject = SelectionChangeCallbackImpl(uri, chooserIntent, contentResolver, flags) val targetIntent = Intent(ACTION_SEND_MULTIPLE) val result = testSubject.onSelectionChanged(targetIntent) @@ -173,6 +183,8 @@ class SelectionChangeCallbackImplTest { assertThat(result.alternateIntents).isNull() assertThat(result.callerTargets).isNull() assertThat(result.refinementIntentSender).isNull() + assertThat(result.resultIntentSender).isNull() + assertThat(result.metadataText).isNull() } @Test @@ -194,7 +206,7 @@ class SelectionChangeCallbackImplTest { Bundle().apply { putParcelable(EXTRA_CHOOSER_MODIFY_SHARE_ACTION, modifyShare) } ) - val testSubject = SelectionChangeCallbackImpl(uri, chooserIntent, contentResolver) + val testSubject = SelectionChangeCallbackImpl(uri, chooserIntent, contentResolver, flags) val targetIntent = Intent(ACTION_SEND) val result = testSubject.onSelectionChanged(targetIntent) @@ -211,6 +223,8 @@ class SelectionChangeCallbackImplTest { assertThat(result.alternateIntents).isNull() assertThat(result.callerTargets).isNull() assertThat(result.refinementIntentSender).isNull() + assertThat(result.resultIntentSender).isNull() + assertThat(result.metadataText).isNull() } @Test @@ -227,7 +241,7 @@ class SelectionChangeCallbackImplTest { Bundle().apply { putParcelableArray(EXTRA_ALTERNATE_INTENTS, alternateIntents) } ) - val testSubject = SelectionChangeCallbackImpl(uri, chooserIntent, contentResolver) + val testSubject = SelectionChangeCallbackImpl(uri, chooserIntent, contentResolver, flags) val targetIntent = Intent(ACTION_SEND) val result = testSubject.onSelectionChanged(targetIntent) @@ -250,6 +264,8 @@ class SelectionChangeCallbackImplTest { assertThat(result.modifyShareAction).isNull() assertThat(result.callerTargets).isNull() assertThat(result.refinementIntentSender).isNull() + assertThat(result.resultIntentSender).isNull() + assertThat(result.metadataText).isNull() } @Test @@ -275,7 +291,7 @@ class SelectionChangeCallbackImplTest { Bundle().apply { putParcelableArray(EXTRA_CHOOSER_TARGETS, arrayOf(t1, t2)) } ) - val testSubject = SelectionChangeCallbackImpl(uri, chooserIntent, contentResolver) + val testSubject = SelectionChangeCallbackImpl(uri, chooserIntent, contentResolver, flags) val targetIntent = Intent(ACTION_SEND) val result = testSubject.onSelectionChanged(targetIntent) @@ -301,6 +317,8 @@ class SelectionChangeCallbackImplTest { assertThat(result.modifyShareAction).isNull() assertThat(result.alternateIntents).isNull() assertThat(result.refinementIntentSender).isNull() + assertThat(result.resultIntentSender).isNull() + assertThat(result.metadataText).isNull() } @Test @@ -315,7 +333,7 @@ class SelectionChangeCallbackImplTest { } ) - val testSubject = SelectionChangeCallbackImpl(uri, chooserIntent, contentResolver) + val testSubject = SelectionChangeCallbackImpl(uri, chooserIntent, contentResolver, flags) val targetIntent = Intent(ACTION_SEND) val result = testSubject.onSelectionChanged(targetIntent) @@ -326,10 +344,83 @@ class SelectionChangeCallbackImplTest { assertThat(result.alternateIntents).isNull() assertThat(result.callerTargets).isNull() assertThat(result.refinementIntentSender).isNotNull() + assertThat(result.resultIntentSender).isNull() + assertThat(result.metadataText).isNull() + } + + @Test + fun testPayloadChangeCallbackUpdatesResultIntentSender() = runTest { + val broadcast = + PendingIntent.getBroadcast(context, 1, Intent("test"), PendingIntent.FLAG_IMMUTABLE) + + whenever(contentResolver.call(any(), 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).isNull() + assertThat(result.modifyShareAction).isNull() + assertThat(result.alternateIntents).isNull() + assertThat(result.callerTargets).isNull() + assertThat(result.refinementIntentSender).isNull() + assertThat(result.resultIntentSender).isNotNull() + assertThat(result.metadataText).isNull() + } + + @Test + fun testPayloadChangeCallbackUpdatesMetadataTextWithDisabledFlag_noUpdates() = runTest { + val metadataText = "[Metadata]" + whenever(contentResolver.call(any(), 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).isNull() + assertThat(result.modifyShareAction).isNull() + assertThat(result.alternateIntents).isNull() + assertThat(result.callerTargets).isNull() + assertThat(result.refinementIntentSender).isNull() + assertThat(result.resultIntentSender).isNull() + assertThat(result.metadataText).isNull() + } + + @Test + fun testPayloadChangeCallbackUpdatesMetadataTextWithEnabledFlag_valueUpdated() = runTest { + val metadataText = "[Metadata]" + flags.setFlag(Flags.FLAG_ENABLE_SHARESHEET_METADATA_EXTRA, true) + whenever(contentResolver.call(any(), 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).isNull() + assertThat(result.modifyShareAction).isNull() + assertThat(result.alternateIntents).isNull() + assertThat(result.callerTargets).isNull() + assertThat(result.refinementIntentSender).isNull() + assertThat(result.resultIntentSender).isNull() + assertThat(result.metadataText).isEqualTo(metadataText) } @Test fun testPayloadChangeCallbackProvidesInvalidData_invalidDataIgnored() = runTest { + flags.setFlag(Flags.FLAG_ENABLE_SHARESHEET_METADATA_EXTRA, true) whenever(contentResolver.call(any(), any(), any(), any())) .thenReturn( Bundle().apply { @@ -338,10 +429,12 @@ class SelectionChangeCallbackImplTest { putParcelableArrayList(EXTRA_ALTERNATE_INTENTS, ArrayList()) putParcelableArrayList(EXTRA_CHOOSER_TARGETS, ArrayList()) putParcelable(EXTRA_CHOOSER_REFINEMENT_INTENT_SENDER, createUri(2)) + putParcelable(EXTRA_CHOOSER_RESULT_INTENT_SENDER, createUri(1)) + putInt(EXTRA_METADATA_TEXT, 123) } ) - val testSubject = SelectionChangeCallbackImpl(uri, chooserIntent, contentResolver) + val testSubject = SelectionChangeCallbackImpl(uri, chooserIntent, contentResolver, flags) val targetIntent = Intent(ACTION_SEND) val result = testSubject.onSelectionChanged(targetIntent) @@ -352,6 +445,8 @@ class SelectionChangeCallbackImplTest { assertThat(result.alternateIntents).isNull() assertThat(result.callerTargets).isNull() assertThat(result.refinementIntentSender).isNull() + assertThat(result.resultIntentSender).isNull() + assertThat(result.metadataText).isNull() } } diff --git a/tests/unit/src/com/android/intentresolver/v2/domain/interactor/ChooserRequestUpdateInteractorTest.kt b/tests/unit/src/com/android/intentresolver/v2/domain/interactor/ChooserRequestUpdateInteractorTest.kt index dc6a2d47..111ba7db 100644 --- a/tests/unit/src/com/android/intentresolver/v2/domain/interactor/ChooserRequestUpdateInteractorTest.kt +++ b/tests/unit/src/com/android/intentresolver/v2/domain/interactor/ChooserRequestUpdateInteractorTest.kt @@ -20,10 +20,14 @@ import android.content.Intent import android.content.Intent.ACTION_SEND import android.content.Intent.ACTION_SEND_MULTIPLE import android.content.Intent.EXTRA_STREAM +import android.content.IntentSender import android.net.Uri import android.service.chooser.Flags +import com.android.intentresolver.contentpreview.payloadtoggle.data.repository.ChooserParamsUpdateRepository import com.android.intentresolver.contentpreview.payloadtoggle.data.repository.TargetIntentRepository +import com.android.intentresolver.contentpreview.payloadtoggle.domain.model.ShareouselUpdate import com.android.intentresolver.inject.FakeChooserServiceFlags +import com.android.intentresolver.mock import com.android.intentresolver.v2.ui.model.ActivityModel import com.android.intentresolver.v2.ui.model.ChooserRequest import com.google.common.truth.Truth.assertWithMessage @@ -59,6 +63,7 @@ class ChooserRequestUpdateInteractorTest { targetIntent, emptyList(), ) + private val chooserParamsUpdateRepository = ChooserParamsUpdateRepository() private val fakeFlags = FakeChooserServiceFlags().apply { setFlag(Flags.FLAG_CHOOSER_PAYLOAD_TOGGLING, true) @@ -76,6 +81,7 @@ class ChooserRequestUpdateInteractorTest { activityModel, targetIntent, targetIntentRepository, + chooserParamsUpdateRepository, requestFlow, fakeFlags, ) @@ -96,6 +102,7 @@ class ChooserRequestUpdateInteractorTest { activityModel, targetIntent, targetIntentRepository, + chooserParamsUpdateRepository, requestFlow, fakeFlags, ) @@ -107,9 +114,40 @@ class ChooserRequestUpdateInteractorTest { } testScheduler.runCurrent() - assertWithMessage("No updates expected") + assertWithMessage("Another chooser request is expected") + .that(requestFlow.value) + .isNotEqualTo(initialRequest) + } + + @Test + fun testChooserParamsUpdate_newRequestPublished() = + testScope.runTest { + val requestFlow = MutableStateFlow(initialRequest) + val testSubject = + ChooserRequestUpdateInteractor( + activityModel, + targetIntent, + targetIntentRepository, + chooserParamsUpdateRepository, + requestFlow, + fakeFlags, + ) + backgroundScope.launch { testSubject.launch() } + val newResultSender = mock() + chooserParamsUpdateRepository.setUpdates( + ShareouselUpdate( + resultIntentSender = newResultSender, + ) + ) + testScheduler.runCurrent() + + assertWithMessage("Another chooser request is expected") .that(requestFlow.value) .isNotEqualTo(initialRequest) + + assertWithMessage("Another chooser request is expected") + .that(requestFlow.value.chosenComponentSender) + .isSameInstanceAs(newResultSender) } } -- cgit v1.2.3-59-g8ed1b From d64715f0b5f72c4a6f73a113c55638bbb6f05c05 Mon Sep 17 00:00:00 2001 From: Mark Renouf Date: Thu, 14 Mar 2024 14:08:56 -0400 Subject: Readability clean up for UserRepository Use a sealed interface for events to eliminate unused properties. Extract the event branching from the flow statement. Improve logging and extract to a simple method. Bug: NA Test: atest IntentResolver-tests-unit Flag: NA Change-Id: I348558b3ca1506b7ece1c19295263e8824ac2575 --- .../v2/data/repository/UserRepository.kt | 134 ++++++++++++++------- .../intentresolver/v2/platform/FakeUserManager.kt | 31 ++--- .../v2/data/repository/UserRepositoryImplTest.kt | 30 +---- 3 files changed, 99 insertions(+), 96 deletions(-) (limited to 'java/src') 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 4c42e2cd..40672249 100644 --- a/java/src/com/android/intentresolver/v2/data/repository/UserRepository.kt +++ b/java/src/com/android/intentresolver/v2/data/repository/UserRepository.kt @@ -28,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 @@ -36,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 @@ -73,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. * @@ -84,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 +internal typealias UserStates = List /** Tracks and publishes state for the parent user and associated profiles. */ class UserRepositoryImpl @@ -114,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 @@ -129,35 +137,34 @@ constructor( private val sharingScope = CoroutineScope(scope.coroutineContext + backgroundDispatcher) private val usersWithState: Flow = userEvents - .onStart { emit(UserEvent(INITIALIZE, profileParent)) } - .onEach { Log.i(TAG, "userEvent: $it") } - .runningFold(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> = usersWithState.map { userStateMap -> userStateMap.map { it.user } }.distinctUntilChanged() @@ -168,7 +175,7 @@ constructor( override suspend fun requestState(user: User, available: Boolean) { return withContext(backgroundDispatcher) { - Log.i(TAG, "requestQuietModeEnabled: ${!available} for user $user") + debugLog { "requestQuietModeEnabled: ${!available} for user $user" } userManager.requestQuietModeEnabled(/* enableQuietMode = */ !available, user.handle) } } @@ -176,28 +183,28 @@ constructor( private fun List.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 { @@ -224,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): 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 { +internal fun userBroadcastFlow(context: Context, profileParent: UserHandle): Flow { val userActions = setOf( ACTION_PROFILE_ADDED, 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() - 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/src/com/android/intentresolver/v2/data/repository/UserRepositoryImplTest.kt b/tests/unit/src/com/android/intentresolver/v2/data/repository/UserRepositoryImplTest.kt index 8628bd14..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 @@ -122,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, @@ -147,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, @@ -172,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) @@ -191,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, -- cgit v1.2.3-59-g8ed1b From 1a887c18ad79b5a935799f5100233db1be982ee1 Mon Sep 17 00:00:00 2001 From: Mark Renouf Date: Thu, 14 Mar 2024 22:05:09 -0400 Subject: Correct all Kotlin formatting errors This is the result of applying ktfmt across all IntentResolver Kotlin files. Only a few minor issues are present, and the resulting format seems acceptable. Modifying any Kotlin file will trigger ktlint errors at upload time, forcing usage of '--no-verify' to avoid changing unrelated lines. This only leads to further formatting issues slipping in. By clearing all the existing formatting problems, new issues should only only be found in modified or inserted lines of existing files as well as added files. external/ktfmt/ktfmt.py pacakges/modules/IntentResolver Test: NA Bug: NA Change-Id: Ia8dfe1780b384a685df0d137a8de7c473e899a20 --- .../EnterTransitionAnimationDelegate.kt | 35 +++++++++++++--------- .../intentresolver/ItemRevealAnimationTracker.kt | 4 +-- .../com/android/intentresolver/SecureSettings.kt | 4 +-- .../intentresolver/contentpreview/IsHttpUri.kt | 11 ++++--- .../intentresolver/inject/SystemServices.kt | 3 +- .../shortcuts/AppPredictorFactory.kt | 34 +++++++++++---------- java/src/com/android/intentresolver/util/Flow.kt | 10 +++---- .../intentresolver/v2/annotation/JavaInterop.kt | 6 ++-- .../v2/data/repository/UserScopedService.kt | 6 ++-- .../v2/domain/interactor/UserInteractor.kt | 4 +-- .../v2/platform/AppPredictionModule.kt | 7 ++--- .../intentresolver/v2/ui/ProfilePagerResources.kt | 4 +-- .../intentresolver/v2/ui/ShortcutPolicyModule.kt | 27 ++++++++++++----- .../intentresolver/v2/ui/model/ResolverRequest.kt | 2 +- .../v2/validation/types/IntentOrUri.kt | 10 +++---- .../v2/validation/types/ParceledArray.kt | 11 ++++--- .../v2/validation/types/SimpleValue.kt | 25 +++++++++------- .../com/android/intentresolver/widget/ActionRow.kt | 4 ++- .../intentresolver/widget/ImagePreviewView.kt | 13 ++++---- .../widget/RecyclerViewExtensions.kt | 8 ++--- .../intentresolver/widget/ViewExtensions.kt | 27 ++++++++++------- 21 files changed, 141 insertions(+), 114 deletions(-) (limited to 'java/src') 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, sharedElements: MutableMap + names: MutableList, + sharedElements: MutableMap ) { - 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) { - 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/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/inject/SystemServices.kt b/java/src/com/android/intentresolver/inject/SystemServices.kt index 069c926c..9e7c67b6 100644 --- a/java/src/com/android/intentresolver/inject/SystemServices.kt +++ b/java/src/com/android/intentresolver/inject/SystemServices.kt @@ -103,7 +103,8 @@ class UserManagerModule { @Provides fun userManager(@ApplicationContext ctx: Context): UserManager = ctx.requireSystemService() - @Provides fun scopedUserManager(ctx: UserScopedContext): UserScopedService { + @Provides + fun scopedUserManager(ctx: UserScopedContext): UserScopedService { return UserScopedServiceImpl(ctx, UserManager::class) } } 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/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 Flow.throttle(periodMs: Long): Flow = 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/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/data/repository/UserScopedService.kt b/java/src/com/android/intentresolver/v2/data/repository/UserScopedService.kt index 07903a7b..507979a0 100644 --- a/java/src/com/android/intentresolver/v2/data/repository/UserScopedService.kt +++ b/java/src/com/android/intentresolver/v2/data/repository/UserScopedService.kt @@ -76,10 +76,8 @@ class UserScopedContext @Inject constructor(private val applicationContext: Cont } /** Returns a cache of service instances, distinct by user */ -class UserScopedServiceImpl( - contexts: UserScopedContext, - serviceType: KClass -): UserScopedService { +class UserScopedServiceImpl(contexts: UserScopedContext, serviceType: KClass) : + UserScopedService { private val instances = object : LruCache(8) { override fun create(key: UserHandle): T { 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> = 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/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, etc. - */ + /** Eventually replaced with: Optional, 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/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 index 9ed5f9dd..5e098cd5 100644 --- a/java/src/com/android/intentresolver/v2/ui/ShortcutPolicyModule.kt +++ b/java/src/com/android/intentresolver/v2/ui/ShortcutPolicyModule.kt @@ -29,11 +29,19 @@ import javax.inject.Qualifier import javax.inject.Singleton @Qualifier -@MustBeDocumented @Retention(AnnotationRetention.RUNTIME) annotation class AppShortcutLimit +@MustBeDocumented +@Retention(AnnotationRetention.RUNTIME) +annotation class AppShortcutLimit + @Qualifier -@MustBeDocumented @Retention(AnnotationRetention.RUNTIME) annotation class EnforceShortcutLimit +@MustBeDocumented +@Retention(AnnotationRetention.RUNTIME) +annotation class EnforceShortcutLimit + @Qualifier -@MustBeDocumented @Retention(AnnotationRetention.RUNTIME) annotation class ShortcutRowLimit +@MustBeDocumented +@Retention(AnnotationRetention.RUNTIME) +annotation class ShortcutRowLimit @Module @InstallIn(SingletonComponent::class) @@ -41,8 +49,8 @@ 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. + * This value applies to both results from Shortcut-service and app-provided targets on a + * per-package basis. */ @Provides @Singleton @@ -64,8 +72,11 @@ object ShortcutPolicyModule { @Singleton @EnforceShortcutLimit fun applyShortcutLimit(): Boolean { - return DeviceConfig.getBoolean(DeviceConfig.NAMESPACE_SYSTEMUI, - SystemUiDeviceConfigFlags.APPLY_SHARING_APP_LIMITS_IN_SYSUI, true) + return DeviceConfig.getBoolean( + DeviceConfig.NAMESPACE_SYSTEMUI, + SystemUiDeviceConfigFlags.APPLY_SHARING_APP_LIMITS_IN_SYSUI, + true + ) } /** @@ -80,4 +91,4 @@ object ShortcutPolicyModule { fun shortcutRowLimit(@ApplicationOwned resources: Resources): Int { return resources.getInteger(R.integer.config_chooser_max_targets_per_row) } -} \ No newline at end of file +} 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/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 { source: (String) -> Any?, importance: Importance ): ValidationResult { - 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 { 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( source: (String) -> Any?, importance: Importance ): ValidationResult> { - 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, which is // incompatible with Array. 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( 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) - 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() } } -- cgit v1.2.3-59-g8ed1b From e8b42e069661ebf8f143edda6d5c82ab39fc67f0 Mon Sep 17 00:00:00 2001 From: Mark Renouf Date: Fri, 15 Mar 2024 15:14:32 -0400 Subject: Remove caching from UserScopedService Caching the intermediate values isn't necessary and could present an opportunity for a memory leak. Retrieved service instances should be stored for as long as they are needed, and once out of scope, be available for collection. This aligns with the copy now in SystemUI: ag/26600464 Flag: None; no usages yet Test: NA; this class is stateless Bug: 327613051 Change-Id: I2b1225cad3d17baf1a7c45e6b5e8e9ca0a79df03 --- .../intentresolver/inject/SystemServices.kt | 27 ++++++-- .../v2/data/repository/UserScopedService.kt | 79 +++++++--------------- 2 files changed, 49 insertions(+), 57 deletions(-) (limited to 'java/src') diff --git a/java/src/com/android/intentresolver/inject/SystemServices.kt b/java/src/com/android/intentresolver/inject/SystemServices.kt index 9e7c67b6..c09598e0 100644 --- a/java/src/com/android/intentresolver/inject/SystemServices.kt +++ b/java/src/com/android/intentresolver/inject/SystemServices.kt @@ -17,6 +17,7 @@ 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 @@ -26,7 +27,6 @@ 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.UserScopedContext import com.android.intentresolver.v2.data.repository.UserScopedService import com.android.intentresolver.v2.data.repository.UserScopedServiceImpl import dagger.Binds @@ -89,12 +89,31 @@ class PackageManagerModule { fun packageManager(@ApplicationContext ctx: Context) = requireNotNull(ctx.packageManager) } +@Module +@InstallIn(SingletonComponent::class) +class PredictionManagerModule { + @Provides + fun scopedPredictionManager( + @ApplicationContext ctx: Context, + ): UserScopedService { + 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 { + return UserScopedServiceImpl(ctx, ShortcutManager::class) + } } @Module @@ -104,7 +123,7 @@ class UserManagerModule { fun userManager(@ApplicationContext ctx: Context): UserManager = ctx.requireSystemService() @Provides - fun scopedUserManager(ctx: UserScopedContext): UserScopedService { + fun scopedUserManager(@ApplicationContext ctx: Context): UserScopedService { return UserScopedServiceImpl(ctx, UserManager::class) } } 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 507979a0..65a48a55 100644 --- a/java/src/com/android/intentresolver/v2/data/repository/UserScopedService.kt +++ b/java/src/com/android/intentresolver/v2/data/repository/UserScopedService.kt @@ -18,77 +18,50 @@ package com.android.intentresolver.v2.data.repository import android.content.Context import android.os.UserHandle -import android.util.LruCache import androidx.core.content.getSystemService -import javax.inject.Inject +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(context) + * @Provides + * fun scopedUserManager(@ApplicationContext ctx: Context): UserScopedService { + * 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, + * ) { + * fun isPrivateProfile(user: UserHandle): UserManager { + * return userMgr.forUser(user).isPrivateProfile() + * } + * } * ``` */ -interface UserScopedService { +fun interface UserScopedService { + /** Create a service instance for the given user. */ fun forUser(user: UserHandle): T } -/** - * Provides cached Context instances each distinct per-User. - * - * @see [UserScopedService] - */ -class UserScopedContext @Inject constructor(private val applicationContext: Context) { - private val contextCacheSizeLimit = 8 - - private val instances = - object : LruCache(contextCacheSizeLimit) { - override fun create(key: UserHandle): Context { - return applicationContext.createContextAsUser(key, 0) - } - } - - fun forUser(user: UserHandle): Context { - synchronized(this) { - return if (applicationContext.user == user) { - applicationContext +class UserScopedServiceImpl( + @ApplicationContext private val context: Context, + private val serviceType: KClass, +) : UserScopedService { + override fun forUser(user: UserHandle): T { + val context = + if (context.user == user) { + context } else { - return instances[user] + context.createContextAsUser(user, 0) } - } - } -} - -/** Returns a cache of service instances, distinct by user */ -class UserScopedServiceImpl(contexts: UserScopedContext, serviceType: KClass) : - UserScopedService { - private val instances = - object : LruCache(8) { - override fun create(key: UserHandle): T { - val context = contexts.forUser(key) - return requireNotNull(context.getSystemService(serviceType.java)) - } - } - - override fun forUser(user: UserHandle): T { - synchronized(this) { - return instances[user] - } + return requireNotNull(context.getSystemService(serviceType.java)) } } -- cgit v1.2.3-59-g8ed1b From d24b4164f1b8f4fb9378463b0ca87fcb288a18d4 Mon Sep 17 00:00:00 2001 From: Andrey Epin Date: Sat, 16 Mar 2024 17:43:34 -0700 Subject: Fix erroneous selection callback invocation. Setting the initial selection was treated as a selection change and caused the selection callback invocation. Fixed by making PreviewsSelectionRepository and TargetIntentRepository publishing annotated statuses that allow their clients to avoid performing unnecessary actions. Fix: 330000366 Test: atest IntentResolver-tests-unit Test: verified with an injected logging Change-Id: I1d8bee97ebf0ecaf9db98988a96af887ee0d9cd7 --- .../payloadtoggle/data/model/SelectionRecord.kt | 30 +++++++++++++++ .../payloadtoggle/data/model/TargetIntentRecord.kt | 21 +++++++++++ .../data/repository/PreviewSelectionsRepository.kt | 42 ++++++++++++--------- .../data/repository/TargetIntentRepository.kt | 7 +++- .../interactor/SelectablePreviewInteractor.kt | 3 +- .../domain/interactor/SelectionInteractor.kt | 5 +-- .../interactor/UpdateTargetIntentInteractor.kt | 15 ++++++-- .../interactor/ChooserRequestUpdateInteractor.kt | 9 ++--- .../repository/PreviewSelectionsRepositoryTest.kt | 43 ++++++++++++++++++++++ .../interactor/UpdateTargetIntentInteractorTest.kt | 20 +++++----- .../ui/viewmodel/ShareouselViewModelTest.kt | 2 +- .../ChooserRequestUpdateInteractorTest.kt | 6 +-- 12 files changed, 155 insertions(+), 48 deletions(-) create mode 100644 java/src/com/android/intentresolver/contentpreview/payloadtoggle/data/model/SelectionRecord.kt create mode 100644 java/src/com/android/intentresolver/contentpreview/payloadtoggle/data/model/TargetIntentRecord.kt create mode 100644 tests/unit/src/com/android/intentresolver/contentpreview/payloadtoggle/data/repository/PreviewSelectionsRepositoryTest.kt (limited to 'java/src') diff --git a/java/src/com/android/intentresolver/contentpreview/payloadtoggle/data/model/SelectionRecord.kt b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/data/model/SelectionRecord.kt new file mode 100644 index 00000000..c8fcb9d5 --- /dev/null +++ b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/data/model/SelectionRecord.kt @@ -0,0 +1,30 @@ +/* + * Copyright 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.intentresolver.contentpreview.payloadtoggle.data.model + +import com.android.intentresolver.contentpreview.payloadtoggle.shared.model.PreviewModel + +data class SelectionRecord( + val type: SelectionRecordType, + val selection: Set, +) + +enum class SelectionRecordType { + Uninitialized, + Initial, + Updated, +} diff --git a/java/src/com/android/intentresolver/contentpreview/payloadtoggle/data/model/TargetIntentRecord.kt b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/data/model/TargetIntentRecord.kt new file mode 100644 index 00000000..17393023 --- /dev/null +++ b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/data/model/TargetIntentRecord.kt @@ -0,0 +1,21 @@ +/* + * 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.data.model + +import android.content.Intent + +data class TargetIntentRecord(val isInitial: Boolean, val intent: Intent) 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 index 2d849d14..b461d10b 100644 --- a/java/src/com/android/intentresolver/contentpreview/payloadtoggle/data/repository/PreviewSelectionsRepository.kt +++ b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/data/repository/PreviewSelectionsRepository.kt @@ -17,12 +17,16 @@ package com.android.intentresolver.contentpreview.payloadtoggle.data.repository import android.util.Log +import com.android.intentresolver.contentpreview.payloadtoggle.data.model.SelectionRecord +import com.android.intentresolver.contentpreview.payloadtoggle.data.model.SelectionRecordType.Initial +import com.android.intentresolver.contentpreview.payloadtoggle.data.model.SelectionRecordType.Uninitialized +import com.android.intentresolver.contentpreview.payloadtoggle.data.model.SelectionRecordType.Updated import com.android.intentresolver.contentpreview.payloadtoggle.shared.model.PreviewModel import dagger.hilt.android.scopes.ViewModelScoped import javax.inject.Inject -import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.filterNotNull +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.update private const val TAG = "PreviewSelectionsRep" @@ -30,32 +34,34 @@ private const val TAG = "PreviewSelectionsRep" /** Stores set of selected previews. */ @ViewModelScoped class PreviewSelectionsRepository @Inject constructor() { - /** Set of selected previews. */ - private val _selections = MutableStateFlow?>(null) + private val _selections = MutableStateFlow(SelectionRecord(Uninitialized, emptySet())) - val selections: Flow> = _selections.filterNotNull() + /** Selected previews data */ + val selections: StateFlow = _selections.asStateFlow() fun setSelection(selection: Set) { - _selections.value = selection + _selections.value = SelectionRecord(Initial, selection) } fun select(item: PreviewModel) { - _selections.update { selection -> - selection?.let { it + item } - ?: run { - Log.w(TAG, "Changing selection before it is initialized") - null - } + _selections.update { record -> + if (record.type == Uninitialized) { + Log.w(TAG, "Changing selection before it is initialized") + record + } else { + SelectionRecord(Updated, record.selection + item) + } } } fun unselect(item: PreviewModel) { - _selections.update { selection -> - selection?.let { it - item } - ?: run { - Log.w(TAG, "Changing selection before it is initialized") - null - } + _selections.update { record -> + if (record.type == Uninitialized) { + Log.w(TAG, "Changing selection before it is initialized") + record + } else { + SelectionRecord(Updated, record.selection - item) + } } } } diff --git a/java/src/com/android/intentresolver/contentpreview/payloadtoggle/data/repository/TargetIntentRepository.kt b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/data/repository/TargetIntentRepository.kt index c8436846..bb43323a 100644 --- a/java/src/com/android/intentresolver/contentpreview/payloadtoggle/data/repository/TargetIntentRepository.kt +++ b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/data/repository/TargetIntentRepository.kt @@ -18,6 +18,7 @@ package com.android.intentresolver.contentpreview.payloadtoggle.data.repository import android.content.Intent import com.android.intentresolver.contentpreview.payloadtoggle.data.model.CustomActionModel +import com.android.intentresolver.contentpreview.payloadtoggle.data.model.TargetIntentRecord import com.android.intentresolver.inject.TargetIntent import dagger.hilt.android.scopes.ViewModelScoped import javax.inject.Inject @@ -31,10 +32,14 @@ constructor( @TargetIntent initialIntent: Intent, initialActions: List, ) { - val targetIntent = MutableStateFlow(initialIntent) + val targetIntent = MutableStateFlow(TargetIntentRecord(isInitial = true, initialIntent)) // TODO: this can probably be derived from [targetIntent]; right now, the [initialActions] are // coming from a different place (ChooserRequest) than later ones (SelectionChangeCallback) // and so this serves as the source of truth between the two. val customActions = MutableStateFlow(initialActions) + + fun updateTargetIntent(intent: Intent) { + targetIntent.value = TargetIntentRecord(isInitial = false, intent) + } } 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 index 0b1038f5..3b5b0ddf 100644 --- a/java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/interactor/SelectablePreviewInteractor.kt +++ b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/interactor/SelectablePreviewInteractor.kt @@ -30,8 +30,7 @@ class SelectablePreviewInteractor( val uri: Uri = key.uri /** Whether or not this preview is selected by the user. */ - val isSelected: Flow - get() = selectionRepo.selections.map { key in it } + val isSelected: Flow = selectionRepo.selections.map { key in it.selection } /** Sets whether this preview is selected by the user. */ fun setSelected(isSelected: Boolean) { 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 index ee9bd689..0b8bcdd7 100644 --- a/java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/interactor/SelectionInteractor.kt +++ b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/interactor/SelectionInteractor.kt @@ -24,9 +24,8 @@ import kotlinx.coroutines.flow.map class SelectionInteractor @Inject constructor( - private val selectionRepo: PreviewSelectionsRepository, + selectionRepo: PreviewSelectionsRepository, ) { /** Amount of selected previews. */ - val amountSelected: Flow - get() = selectionRepo.selections.map { it.size } + val amountSelected: Flow = selectionRepo.selections.map { it.selection.size } } 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 index 4cb1f5b6..3ce9aaff 100644 --- a/java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/interactor/UpdateTargetIntentInteractor.kt +++ b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/interactor/UpdateTargetIntentInteractor.kt @@ -18,6 +18,7 @@ package com.android.intentresolver.contentpreview.payloadtoggle.domain.interactor +import com.android.intentresolver.contentpreview.payloadtoggle.data.model.SelectionRecordType import com.android.intentresolver.contentpreview.payloadtoggle.data.repository.ChooserParamsUpdateRepository import com.android.intentresolver.contentpreview.payloadtoggle.data.repository.PreviewSelectionsRepository import com.android.intentresolver.contentpreview.payloadtoggle.data.repository.TargetIntentRepository @@ -31,6 +32,7 @@ import javax.inject.Inject import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.filter import kotlinx.coroutines.flow.filterNotNull import kotlinx.coroutines.flow.mapLatest import kotlinx.coroutines.launch @@ -50,7 +52,8 @@ constructor( suspend fun launch(): Unit = coroutineScope { launch { intentRepository.targetIntent - .mapLatest { targetIntent -> selectionCallback.onSelectionChanged(targetIntent) } + .filter { !it.isInitial } + .mapLatest { record -> selectionCallback.onSelectionChanged(record.intent) } .filterNotNull() .collect { updates -> val actions = updates.customActions ?: emptyList() @@ -60,9 +63,13 @@ constructor( } } launch { - selectionRepo.selections.collectLatest { - intentRepository.targetIntent.value = targetIntentModifier.onSelectionChanged(it) - } + selectionRepo.selections + .filter { it.type == SelectionRecordType.Updated } + .collectLatest { + intentRepository.updateTargetIntent( + targetIntentModifier.onSelectionChanged(it.selection) + ) + } } } } diff --git a/java/src/com/android/intentresolver/v2/domain/interactor/ChooserRequestUpdateInteractor.kt b/java/src/com/android/intentresolver/v2/domain/interactor/ChooserRequestUpdateInteractor.kt index 534b2be3..871701cf 100644 --- a/java/src/com/android/intentresolver/v2/domain/interactor/ChooserRequestUpdateInteractor.kt +++ b/java/src/com/android/intentresolver/v2/domain/interactor/ChooserRequestUpdateInteractor.kt @@ -22,7 +22,6 @@ import com.android.intentresolver.contentpreview.payloadtoggle.data.repository.C import com.android.intentresolver.contentpreview.payloadtoggle.data.repository.TargetIntentRepository import com.android.intentresolver.contentpreview.payloadtoggle.domain.model.ShareouselUpdate import com.android.intentresolver.inject.ChooserServiceFlags -import com.android.intentresolver.inject.TargetIntent import com.android.intentresolver.v2.ui.model.ActivityModel import com.android.intentresolver.v2.ui.model.ChooserRequest import com.android.intentresolver.v2.ui.viewmodel.readChooserRequest @@ -36,6 +35,7 @@ import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.filter import kotlinx.coroutines.flow.filterNotNull +import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch @@ -47,7 +47,6 @@ class ChooserRequestUpdateInteractor @AssistedInject constructor( private val activityModel: ActivityModel, - @TargetIntent private val initialIntent: Intent, private val targetIntentRepository: TargetIntentRepository, private val paramsUpdateRepository: ChooserParamsUpdateRepository, // TODO: replace with a proper repository, when available @@ -59,10 +58,8 @@ constructor( coroutineScope { launch { targetIntentRepository.targetIntent - // TODO: maybe find a better way to exclude the initial intent (as here it's - // compared by - // reference) - .filter { it !== initialIntent } + .filter { !it.isInitial } + .map { it.intent } .collect(::updateTargetIntent) } diff --git a/tests/unit/src/com/android/intentresolver/contentpreview/payloadtoggle/data/repository/PreviewSelectionsRepositoryTest.kt b/tests/unit/src/com/android/intentresolver/contentpreview/payloadtoggle/data/repository/PreviewSelectionsRepositoryTest.kt new file mode 100644 index 00000000..48c8b58a --- /dev/null +++ b/tests/unit/src/com/android/intentresolver/contentpreview/payloadtoggle/data/repository/PreviewSelectionsRepositoryTest.kt @@ -0,0 +1,43 @@ +/* + * Copyright 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.intentresolver.contentpreview.payloadtoggle.data.repository + +import android.net.Uri +import com.android.intentresolver.contentpreview.payloadtoggle.data.model.SelectionRecordType.Initial +import com.android.intentresolver.contentpreview.payloadtoggle.data.model.SelectionRecordType.Uninitialized +import com.android.intentresolver.contentpreview.payloadtoggle.data.model.SelectionRecordType.Updated +import com.android.intentresolver.contentpreview.payloadtoggle.shared.model.PreviewModel +import com.google.common.truth.Truth.assertThat +import org.junit.Test + +class PreviewSelectionsRepositoryTest { + + @Test + fun testSelectionStatus() { + val testSubject = PreviewSelectionsRepository() + + assertThat(testSubject.selections.value.type).isEqualTo(Uninitialized) + + testSubject.setSelection(setOf(PreviewModel(Uri.parse("content://pkg/1.png"), "image/png"))) + + assertThat(testSubject.selections.value.type).isEqualTo(Initial) + + testSubject.select(PreviewModel(Uri.parse("content://pkg/2.png"), "image/png")) + + assertThat(testSubject.selections.value.type).isEqualTo(Updated) + } +} diff --git a/tests/unit/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/interactor/UpdateTargetIntentInteractorTest.kt b/tests/unit/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/interactor/UpdateTargetIntentInteractorTest.kt index 3bcdcacd..3f437b22 100644 --- a/tests/unit/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/interactor/UpdateTargetIntentInteractorTest.kt +++ b/tests/unit/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/interactor/UpdateTargetIntentInteractorTest.kt @@ -59,28 +59,30 @@ class UpdateTargetIntentInteractorTest { ) backgroundScope.launch { underTest.launch() } + selectionRepository.setSelection( + setOf( + PreviewModel(Uri.fromParts("scheme0", "ssp0", "fragment0"), null), + PreviewModel(Uri.fromParts("scheme1", "ssp1", "fragment1"), null), + ) + ) runCurrent() - // as we do not publish the initial empty selection, we should not modify the intent + // only changes in selection should trigger intent updates assertThat( - intentRepository.targetIntent.value.getParcelableArrayListExtra( + intentRepository.targetIntent.value.intent.getParcelableArrayListExtra( "selection", Uri::class.java, ) ) .isNull() - selectionRepository.setSelection( - setOf( - PreviewModel(Uri.fromParts("scheme0", "ssp0", "fragment0"), null), - PreviewModel(Uri.fromParts("scheme1", "ssp1", "fragment1"), null), - PreviewModel(Uri.fromParts("scheme2", "ssp2", "fragment2"), null), - ) + selectionRepository.select( + PreviewModel(Uri.fromParts("scheme2", "ssp2", "fragment2"), null), ) runCurrent() assertThat( - intentRepository.targetIntent.value.getParcelableArrayListExtra( + intentRepository.targetIntent.value.intent.getParcelableArrayListExtra( "selection", Uri::class.java, ) 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 index 057906f7..24035c54 100644 --- 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 @@ -201,7 +201,7 @@ class ShareouselViewModelTest { previewVm.setSelected(true) - assertThat(selectionRepository.selections.first()) + assertThat(selectionRepository.selections.first().selection) .comparingElementsUsingTransform("has uri of") { model: PreviewModel -> model.uri } .contains(Uri.fromParts("scheme1", "ssp1", "fragment1")) } diff --git a/tests/unit/src/com/android/intentresolver/v2/domain/interactor/ChooserRequestUpdateInteractorTest.kt b/tests/unit/src/com/android/intentresolver/v2/domain/interactor/ChooserRequestUpdateInteractorTest.kt index 111ba7db..a15a6eff 100644 --- a/tests/unit/src/com/android/intentresolver/v2/domain/interactor/ChooserRequestUpdateInteractorTest.kt +++ b/tests/unit/src/com/android/intentresolver/v2/domain/interactor/ChooserRequestUpdateInteractorTest.kt @@ -79,7 +79,6 @@ class ChooserRequestUpdateInteractorTest { val testSubject = ChooserRequestUpdateInteractor( activityModel, - targetIntent, targetIntentRepository, chooserParamsUpdateRepository, requestFlow, @@ -100,18 +99,18 @@ class ChooserRequestUpdateInteractorTest { val testSubject = ChooserRequestUpdateInteractor( activityModel, - targetIntent, targetIntentRepository, chooserParamsUpdateRepository, requestFlow, fakeFlags, ) backgroundScope.launch { testSubject.launch() } - targetIntentRepository.targetIntent.value = + targetIntentRepository.updateTargetIntent( Intent(targetIntent).apply { action = ACTION_SEND putExtra(EXTRA_STREAM, createUri(2)) } + ) testScheduler.runCurrent() assertWithMessage("Another chooser request is expected") @@ -126,7 +125,6 @@ class ChooserRequestUpdateInteractorTest { val testSubject = ChooserRequestUpdateInteractor( activityModel, - targetIntent, targetIntentRepository, chooserParamsUpdateRepository, requestFlow, -- cgit v1.2.3-59-g8ed1b From e066a12a4a4cadabfba8da1af340908c4fdf02f4 Mon Sep 17 00:00:00 2001 From: Andrey Epin Date: Mon, 18 Mar 2024 16:29:47 -0700 Subject: Reload app targets when alternate intents are updated Bug: 302691505 Test: manuate testing with the ShareTest app Change-Id: I061d9d73832f8778e47f2024ee3f25e2dae0c69e --- java/src/com/android/intentresolver/v2/ChooserActivity.java | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) (limited to 'java/src') diff --git a/java/src/com/android/intentresolver/v2/ChooserActivity.java b/java/src/com/android/intentresolver/v2/ChooserActivity.java index 110983d0..b164bd9f 100644 --- a/java/src/com/android/intentresolver/v2/ChooserActivity.java +++ b/java/src/com/android/intentresolver/v2/ChooserActivity.java @@ -683,11 +683,12 @@ public class ChooserActivity extends Hilt_ChooserActivity implements ChooserRequest oldChooserRequest, ChooserRequest newChooserRequest) { Intent oldTargetIntent = oldChooserRequest.getTargetIntent(); Intent newTargetIntent = newChooserRequest.getTargetIntent(); + List oldAltIntents = oldChooserRequest.getAdditionalTargets(); + List newAltIntents = newChooserRequest.getAdditionalTargets(); // TODO: a workaround for the unnecessary target reloading caused by multiple flow updates - // an artifact of the current implementation; revisit. - // reference comparison is intentional - return oldTargetIntent != newTargetIntent; + return !oldTargetIntent.equals(newTargetIntent) || !oldAltIntents.equals(newAltIntents); } private void recreatePagerAdapter() { -- cgit v1.2.3-59-g8ed1b From af3207bd8b0d44724d6a33983b466833b8ed34e7 Mon Sep 17 00:00:00 2001 From: Andrey Epin Date: Tue, 19 Mar 2024 15:56:26 -0700 Subject: Update modify share action on selection change update 1. Trivial refactoring: move repetitive displayModifyShareAction call from each ConventPreviewUi#display implenentation into ChooserContentPreviewUi#displayContentPreview. 2. Trivial refactoring: add new dependency to ChooserContentPreviewUi for the modify share action factory but still use ChooserActionFactory to provide the action. 3. Use a new factory for the modify share action that alway reads the lates ChooserRequest values. Update the modify share action upon ChooserRequest changes. Bug: 302691505 Test: manual functionality test with the ShareTest app Flag: ACONFIG android.service.chooser.chooser_payload_toggling DEVELOPMENT Change-Id: I3ee55746387bc8ba413244b76aca374a361d696d --- .../android/intentresolver/ChooserActivity.java | 4 ++- .../contentpreview/ChooserContentPreviewUi.java | 31 ++++++++++++++-- .../contentpreview/ContentPreviewUi.java | 23 ++++++------ .../contentpreview/FileContentPreviewUi.java | 5 +-- .../FilesPlusTextContentPreviewUi.java | 5 +-- .../contentpreview/ShareouselContentPreviewUi.kt | 10 ++---- .../contentpreview/TextContentPreviewUi.java | 5 +-- .../contentpreview/UnifiedContentPreviewUi.java | 5 +-- .../intentresolver/v2/ChooserActionFactory.java | 41 +++++++++------------- .../android/intentresolver/v2/ChooserActivity.java | 27 ++++++++++---- .../contentpreview/ChooserContentPreviewUiTest.kt | 1 + .../intentresolver/v2/ChooserActionFactoryTest.kt | 39 +------------------- 12 files changed, 88 insertions(+), 108 deletions(-) (limited to 'java/src') diff --git a/java/src/com/android/intentresolver/ChooserActivity.java b/java/src/com/android/intentresolver/ChooserActivity.java index e36e9df3..9557b25b 100644 --- a/java/src/com/android/intentresolver/ChooserActivity.java +++ b/java/src/com/android/intentresolver/ChooserActivity.java @@ -307,12 +307,14 @@ public class ChooserActivity extends Hilt_ChooserActivity implements mChooserRequest.getTargetIntent(), /*additionalContentUri = */ null, /*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/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; + } /** *

@@ -93,6 +96,9 @@ public final class ChooserContentPreviewUi { @VisibleForTesting final ContentPreviewUi mContentPreviewUi; + private final Supplier mModifyShareActionFactory; + @Nullable + private View mHeadlineParent; public ChooserContentPreviewUi( CoroutineScope scope, @@ -100,6 +106,7 @@ public final class ChooserContentPreviewUi { Intent targetIntent, ImageLoader imageLoader, ActionFactory actionFactory, + Supplier 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/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 files) { diff --git a/java/src/com/android/intentresolver/contentpreview/ShareouselContentPreviewUi.kt b/java/src/com/android/intentresolver/contentpreview/ShareouselContentPreviewUi.kt index 463da5fa..3530ede1 100644 --- a/java/src/com/android/intentresolver/contentpreview/ShareouselContentPreviewUi.kt +++ b/java/src/com/android/intentresolver/contentpreview/ShareouselContentPreviewUi.kt @@ -30,15 +30,12 @@ import androidx.compose.ui.platform.ComposeView import androidx.compose.ui.platform.LocalContext import androidx.lifecycle.viewmodel.compose.viewModel import com.android.intentresolver.R -import com.android.intentresolver.contentpreview.ChooserContentPreviewUi.ActionFactory 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 @@ -47,10 +44,7 @@ class ShareouselContentPreviewUi( layoutInflater: LayoutInflater, parent: ViewGroup, headlineViewParent: View?, - ): ViewGroup = - displayInternal(parent, headlineViewParent).also { layout -> - displayModifyShareAction(headlineViewParent ?: layout, actionFactory) - } + ): ViewGroup = displayInternal(parent, headlineViewParent) private fun displayInternal(parent: ViewGroup, headlineViewParent: View?): ViewGroup { if (headlineViewParent != null) { 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 files) { 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 mCustomActions; - @Nullable private final ChooserAction mModifyShareAction; private final Consumer mExcludeSharedTextAction; @Nullable private final ShareResultSender mShareResultSender; private final Consumer mFinishCallback; @@ -124,7 +123,6 @@ public final class ChooserActionFactory implements ChooserContentPreviewUi.Actio Intent targetIntent, String referrerPackageName, List chooserActions, - @Nullable ChooserAction modifyShareAction, Optional imageEditor, EventLog log, Consumer 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 customActions, - @Nullable ChooserAction modifyShareAction, Consumer 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); } @@ -220,15 +219,6 @@ public final class ChooserActionFactory implements ChooserContentPreviewUi.Actio return actions; } - /** - * Provides a share modification action, if any. - */ - @Override - @Nullable - public ActionRow.Action getModifyShareAction() { - return createCustomAction(mModifyShareAction, this::logModifyShareAction); - } - /** *

* Creates an exclude-text action that can be called when the user changes shared text @@ -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 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 b164bd9f..cb97d94f 100644 --- a/java/src/com/android/intentresolver/v2/ChooserActivity.java +++ b/java/src/com/android/intentresolver/v2/ChooserActivity.java @@ -153,6 +153,7 @@ 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; @@ -613,6 +614,7 @@ public class ChooserActivity extends Hilt_ChooserActivity implements mRequest.getTargetIntent(), previewViewModel.getImageLoader(), createChooserActionFactory(), + createModifyShareActionFactory(), mEnterTransitionAnimationDelegate, new HeadlineGeneratorImpl(this), mRequest.getContentTypeHint(), @@ -664,6 +666,7 @@ public class ChooserActivity extends Hilt_ChooserActivity implements boolean recreateAdapters = shouldUpdateAdapters(mRequest, chooserRequest); mRequest = chooserRequest; updateShareResultSender(); + mChooserContentPreviewUi.updateModifyShareAction(); if (recreateAdapters) { recreatePagerAdapter(); } @@ -2104,7 +2107,6 @@ public class ChooserActivity extends Hilt_ChooserActivity implements mRequest.getTargetIntent(), mRequest.getLaunchedFromPackage(), mRequest.getChooserActions(), - mRequest.getModifyShareAction(), mImageEditor, getEventLog(), (isExcluded) -> mExcludeSharedText = isExcluded, @@ -2134,15 +2136,26 @@ public class ChooserActivity extends Hilt_ChooserActivity implements } }, mShareResultSender, - (status) -> { - if (status != null) { - setResult(status); - } - finish(); - }, + this::finishWithStatus, mClipboardManager); } + private Supplier 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 diff --git a/tests/unit/src/com/android/intentresolver/contentpreview/ChooserContentPreviewUiTest.kt b/tests/unit/src/com/android/intentresolver/contentpreview/ChooserContentPreviewUiTest.kt index 68b277e7..e4489bd1 100644 --- a/tests/unit/src/com/android/intentresolver/contentpreview/ChooserContentPreviewUiTest.kt +++ b/tests/unit/src/com/android/intentresolver/contentpreview/ChooserContentPreviewUiTest.kt @@ -70,6 +70,7 @@ class ChooserContentPreviewUiTest { targetIntent, imageLoader, actionFactory, + { null }, transitionCallback, headlineGenerator, ContentTypeHint.NONE, 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() 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 = @@ -104,26 +102,6 @@ class ChooserActionFactoryTest { assertTrue("Timed out waiting for broadcast", countdown.await(2500, TimeUnit.MILLISECONDS)) } - @Test - fun testNoModifyShareAction() { - val factory = createFactory(includeModifyShare = false) - - assertThat(factory.modifyShareAction).isNull() - } - - @Test - fun testModifyShareAction() { - val factory = createFactory(includeModifyShare = true) - - val action = factory.modifyShareAction ?: error("Modify share action should not be null") - action.onClicked.run() - - 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 = @@ -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 = */ {}, -- cgit v1.2.3-59-g8ed1b From aea8191d82a1c4743e53ec9767c046aa62f3a6b5 Mon Sep 17 00:00:00 2001 From: Andrey Epin Date: Wed, 20 Mar 2024 17:06:24 -0700 Subject: Fix the target intent update overrides other chooser params Bug: 302691505 Test: atest IntentResolver-tests-unit Flags: ACONFIG android.service.chooser.chooser_payload_toggling DEVELOPMENT Change-Id: Id0cb886e68ccff5cf527cc7b3ef5c3c1fdd51c3c --- .../interactor/ChooserRequestUpdateInteractor.kt | 96 +++++++++++----------- .../ChooserRequestUpdateInteractorTest.kt | 52 ++++++------ 2 files changed, 77 insertions(+), 71 deletions(-) (limited to 'java/src') diff --git a/java/src/com/android/intentresolver/v2/domain/interactor/ChooserRequestUpdateInteractor.kt b/java/src/com/android/intentresolver/v2/domain/interactor/ChooserRequestUpdateInteractor.kt index 871701cf..4afe46b0 100644 --- a/java/src/com/android/intentresolver/v2/domain/interactor/ChooserRequestUpdateInteractor.kt +++ b/java/src/com/android/intentresolver/v2/domain/interactor/ChooserRequestUpdateInteractor.kt @@ -17,16 +17,13 @@ package com.android.intentresolver.v2.domain.interactor import android.content.Intent -import android.util.Log +import android.content.IntentSender +import android.service.chooser.ChooserAction +import android.service.chooser.ChooserTarget import com.android.intentresolver.contentpreview.payloadtoggle.data.repository.ChooserParamsUpdateRepository import com.android.intentresolver.contentpreview.payloadtoggle.data.repository.TargetIntentRepository import com.android.intentresolver.contentpreview.payloadtoggle.domain.model.ShareouselUpdate -import com.android.intentresolver.inject.ChooserServiceFlags -import com.android.intentresolver.v2.ui.model.ActivityModel import com.android.intentresolver.v2.ui.model.ChooserRequest -import com.android.intentresolver.v2.ui.viewmodel.readChooserRequest -import com.android.intentresolver.v2.validation.Invalid -import com.android.intentresolver.v2.validation.Valid import dagger.assisted.Assisted import dagger.assisted.AssistedFactory import dagger.assisted.AssistedInject @@ -39,19 +36,15 @@ import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch -private const val TAG = "ChooserRequestUpdate" - /** Updates updates ChooserRequest with a new target intent */ // TODO: make fully injectable class ChooserRequestUpdateInteractor @AssistedInject constructor( - private val activityModel: ActivityModel, private val targetIntentRepository: TargetIntentRepository, private val paramsUpdateRepository: ChooserParamsUpdateRepository, // TODO: replace with a proper repository, when available @Assisted private val chooserRequestRepository: MutableStateFlow, - private val flags: ChooserServiceFlags, ) { suspend fun launch() { @@ -70,54 +63,61 @@ constructor( } private fun updateTargetIntent(targetIntent: Intent) { - val updatedActivityModel = activityModel.updateWithTargetIntent(targetIntent) - when (val updatedChooserRequest = readChooserRequest(updatedActivityModel, flags)) { - is Valid -> chooserRequestRepository.value = updatedChooserRequest.value - is Invalid -> Log.w(TAG, "Failed to apply payload selection changes") + chooserRequestRepository.update { current -> + current.updatedWith(targetIntent = targetIntent) } } private fun updateChooserParameters(update: ShareouselUpdate) { chooserRequestRepository.update { current -> - ChooserRequest( - current.targetIntent, - current.targetAction, - current.isSendActionTarget, - current.targetType, - current.launchedFromPackage, - current.title, - current.defaultTitleResource, - current.referrer, - current.filteredComponentNames, - update.callerTargets ?: current.callerChooserTargets, - // chooser actions are handled separately - current.chooserActions, - update.modifyShareAction ?: current.modifyShareAction, - current.shouldRetainInOnStop, - update.alternateIntents ?: current.additionalTargets, - current.replacementExtras, - current.initialIntents, - update.resultIntentSender ?: current.chosenComponentSender, - update.refinementIntentSender ?: current.refinementIntentSender, - current.sharedText, - current.shareTargetFilter, - current.additionalContentUri, - current.focusedItemPosition, - current.contentTypeHint, - update.metadataText ?: current.metadataText, + current.updatedWith( + callerChooserTargets = update.callerTargets, + modifyShareAction = update.modifyShareAction, + additionalTargets = update.alternateIntents, + chosenComponentSender = update.resultIntentSender, + refinementIntentSender = update.refinementIntentSender, + metadataText = update.metadataText, ) } } - - private fun ActivityModel.updateWithTargetIntent(targetIntent: Intent) = - ActivityModel( - Intent(intent).apply { putExtra(Intent.EXTRA_INTENT, targetIntent) }, - launchedFromUid, - launchedFromPackage, - referrer, - ) } +private fun ChooserRequest.updatedWith( + targetIntent: Intent? = null, + callerChooserTargets: List? = null, + modifyShareAction: ChooserAction? = null, + additionalTargets: List? = null, + chosenComponentSender: IntentSender? = null, + refinementIntentSender: IntentSender? = null, + metadataText: CharSequence? = null, +) = + ChooserRequest( + targetIntent ?: this.targetIntent, + this.targetAction, + this.isSendActionTarget, + this.targetType, + this.launchedFromPackage, + this.title, + this.defaultTitleResource, + this.referrer, + this.filteredComponentNames, + callerChooserTargets ?: this.callerChooserTargets, + this.chooserActions, + modifyShareAction ?: this.modifyShareAction, + this.shouldRetainInOnStop, + additionalTargets ?: this.additionalTargets, + this.replacementExtras, + this.initialIntents, + chosenComponentSender ?: this.chosenComponentSender, + refinementIntentSender ?: this.refinementIntentSender, + this.sharedText, + this.shareTargetFilter, + this.additionalContentUri, + this.focusedItemPosition, + this.contentTypeHint, + metadataText ?: this.metadataText, + ) + @AssistedFactory @ViewModelScoped interface ChooserRequestUpdateInteractorFactory { diff --git a/tests/unit/src/com/android/intentresolver/v2/domain/interactor/ChooserRequestUpdateInteractorTest.kt b/tests/unit/src/com/android/intentresolver/v2/domain/interactor/ChooserRequestUpdateInteractorTest.kt index a15a6eff..d6288e2f 100644 --- a/tests/unit/src/com/android/intentresolver/v2/domain/interactor/ChooserRequestUpdateInteractorTest.kt +++ b/tests/unit/src/com/android/intentresolver/v2/domain/interactor/ChooserRequestUpdateInteractorTest.kt @@ -22,14 +22,12 @@ import android.content.Intent.ACTION_SEND_MULTIPLE import android.content.Intent.EXTRA_STREAM import android.content.IntentSender import android.net.Uri -import android.service.chooser.Flags import com.android.intentresolver.contentpreview.payloadtoggle.data.repository.ChooserParamsUpdateRepository import com.android.intentresolver.contentpreview.payloadtoggle.data.repository.TargetIntentRepository import com.android.intentresolver.contentpreview.payloadtoggle.domain.model.ShareouselUpdate -import com.android.intentresolver.inject.FakeChooserServiceFlags import com.android.intentresolver.mock -import com.android.intentresolver.v2.ui.model.ActivityModel import com.android.intentresolver.v2.ui.model.ChooserRequest +import com.google.common.truth.Truth.assertThat import com.google.common.truth.Truth.assertWithMessage import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.launch @@ -50,26 +48,12 @@ class ChooserRequestUpdateInteractorTest { type = "image/png" } val initialRequest = createSomeChooserRequest(targetIntent) - private val chooserIntent = Intent.createChooser(targetIntent, null) - private val activityModel = - ActivityModel( - chooserIntent, - launchedFromUid = 1, - launchedFromPackage = "org.pkg.app", - referrer = null, - ) private val targetIntentRepository = TargetIntentRepository( targetIntent, emptyList(), ) private val chooserParamsUpdateRepository = ChooserParamsUpdateRepository() - private val fakeFlags = - FakeChooserServiceFlags().apply { - setFlag(Flags.FLAG_CHOOSER_PAYLOAD_TOGGLING, true) - setFlag(Flags.FLAG_CHOOSER_ALBUM_TEXT, false) - setFlag(Flags.FLAG_ENABLE_SHARESHEET_METADATA_EXTRA, false) - } private val testScope = TestScope() @Test @@ -78,11 +62,9 @@ class ChooserRequestUpdateInteractorTest { val requestFlow = MutableStateFlow(initialRequest) val testSubject = ChooserRequestUpdateInteractor( - activityModel, targetIntentRepository, chooserParamsUpdateRepository, requestFlow, - fakeFlags, ) backgroundScope.launch { testSubject.launch() } testScheduler.runCurrent() @@ -98,11 +80,9 @@ class ChooserRequestUpdateInteractorTest { val requestFlow = MutableStateFlow(initialRequest) val testSubject = ChooserRequestUpdateInteractor( - activityModel, targetIntentRepository, chooserParamsUpdateRepository, requestFlow, - fakeFlags, ) backgroundScope.launch { testSubject.launch() } targetIntentRepository.updateTargetIntent( @@ -124,11 +104,9 @@ class ChooserRequestUpdateInteractorTest { val requestFlow = MutableStateFlow(initialRequest) val testSubject = ChooserRequestUpdateInteractor( - activityModel, targetIntentRepository, chooserParamsUpdateRepository, requestFlow, - fakeFlags, ) backgroundScope.launch { testSubject.launch() } val newResultSender = mock() @@ -147,6 +125,34 @@ class ChooserRequestUpdateInteractorTest { .that(requestFlow.value.chosenComponentSender) .isSameInstanceAs(newResultSender) } + + @Test + fun testTargetIntentUpdateDoesNotOverrideOtherParameters() = + testScope.runTest { + val requestFlow = MutableStateFlow(initialRequest) + val testSubject = + ChooserRequestUpdateInteractor( + targetIntentRepository, + chooserParamsUpdateRepository, + requestFlow, + ) + backgroundScope.launch { testSubject.launch() } + + val newResultSender = mock() + val newTargetIntent = Intent(Intent.ACTION_VIEW) + chooserParamsUpdateRepository.setUpdates( + ShareouselUpdate( + resultIntentSender = newResultSender, + ) + ) + testScheduler.runCurrent() + targetIntentRepository.updateTargetIntent(newTargetIntent) + testScheduler.runCurrent() + + assertThat(requestFlow.value.targetIntent).isSameInstanceAs(newTargetIntent) + + assertThat(requestFlow.value.chosenComponentSender).isSameInstanceAs(newResultSender) + } } private fun createSomeChooserRequest(targetIntent: Intent) = -- cgit v1.2.3-59-g8ed1b From d6649924bfd84aebf506f5662a387dc0fb1572f3 Mon Sep 17 00:00:00 2001 From: Andrey Epin Date: Thu, 21 Mar 2024 19:21:28 -0700 Subject: Payload selection callback: explicitely encode absent property values. Add a sealed type to encode either a value or the absense of it in the payload selection change callback result. Bug: 302691505 Test: atest IntentResolver-tests-unit Test: manual functionaliryt test using ShareTest app Change-Id: I3b736df3fbf62b1841506f2c41324841d2a3d617 --- .../interactor/UpdateTargetIntentInteractor.kt | 8 +- .../payloadtoggle/domain/model/ShareouselUpdate.kt | 14 +- .../payloadtoggle/domain/model/ValueUpdate.kt | 37 ++++++ .../domain/update/SelectionChangeCallback.kt | 56 ++++++-- .../interactor/ChooserRequestUpdateInteractor.kt | 62 ++------- .../update/SelectionChangeCallbackImplTest.kt | 143 +++++++++++---------- .../ChooserRequestUpdateInteractorTest.kt | 40 +++++- 7 files changed, 223 insertions(+), 137 deletions(-) create mode 100644 java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/model/ValueUpdate.kt (limited to 'java/src') 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 index 3ce9aaff..06e28cba 100644 --- a/java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/interactor/UpdateTargetIntentInteractor.kt +++ b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/interactor/UpdateTargetIntentInteractor.kt @@ -26,6 +26,7 @@ import com.android.intentresolver.contentpreview.payloadtoggle.domain.intent.Cus import com.android.intentresolver.contentpreview.payloadtoggle.domain.intent.PendingIntentSender import com.android.intentresolver.contentpreview.payloadtoggle.domain.intent.TargetIntentModifier import com.android.intentresolver.contentpreview.payloadtoggle.domain.intent.toCustomActionModel +import com.android.intentresolver.contentpreview.payloadtoggle.domain.model.onValue import com.android.intentresolver.contentpreview.payloadtoggle.domain.update.SelectionChangeCallback import com.android.intentresolver.contentpreview.payloadtoggle.shared.model.PreviewModel import javax.inject.Inject @@ -56,9 +57,10 @@ constructor( .mapLatest { record -> selectionCallback.onSelectionChanged(record.intent) } .filterNotNull() .collect { updates -> - val actions = updates.customActions ?: emptyList() - intentRepository.customActions.value = - actions.map { it.toCustomActionModel(pendingIntentSender) } + updates.customActions.onValue { actions -> + intentRepository.customActions.value = + actions.map { it.toCustomActionModel(pendingIntentSender) } + } chooserParamsUpdateRepository.setUpdates(updates) } } 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 index 41a34d1a..821e88a5 100644 --- a/java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/model/ShareouselUpdate.kt +++ b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/model/ShareouselUpdate.kt @@ -24,11 +24,11 @@ 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: List? = null, - val modifyShareAction: ChooserAction? = null, - val alternateIntents: List? = null, - val callerTargets: List? = null, - val refinementIntentSender: IntentSender? = null, - val resultIntentSender: IntentSender? = null, - val metadataText: CharSequence? = null, + val customActions: ValueUpdate> = ValueUpdate.Absent, + val modifyShareAction: ValueUpdate = ValueUpdate.Absent, + val alternateIntents: ValueUpdate> = ValueUpdate.Absent, + val callerTargets: ValueUpdate> = ValueUpdate.Absent, + val refinementIntentSender: ValueUpdate = ValueUpdate.Absent, + val resultIntentSender: ValueUpdate = ValueUpdate.Absent, + val metadataText: ValueUpdate = 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 { + data class Value(val value: T) : ValueUpdate + data object Absent : ValueUpdate +} + +/** Return encapsulated value if this instance represent Value or `default` if Absent */ +fun ValueUpdate.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 ValueUpdate.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 index e7644dc5..20af264a 100644 --- a/java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/update/SelectionChangeCallback.kt +++ b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/update/SelectionChangeCallback.kt @@ -18,6 +18,8 @@ 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 @@ -31,6 +33,7 @@ import android.service.chooser.AdditionalContentContract.MethodNames.ON_SELECTIO 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 @@ -88,7 +91,10 @@ constructor( } ?.let { bundle -> return when (val result = readCallbackResponse(bundle, flags)) { - is Valid -> result.value + is Valid -> { + result.warnings.forEach { it.log(TAG) } + result.value + } is Invalid -> { result.errors.forEach { it.log(TAG) } null @@ -102,18 +108,40 @@ private fun readCallbackResponse( flags: ChooserServiceFlags ): ValidationResult { return validateFrom(bundle::get) { - val customActions = readChooserActions() - val modifyShareAction = optional(value(EXTRA_CHOOSER_MODIFY_SHARE_ACTION)) - val alternateIntents = readAlternateIntents() - val callerTargets = optional(array(EXTRA_CHOOSER_TARGETS)) + // 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(key)) + } + val alternateIntents = + bundle.readValueUpdate(EXTRA_ALTERNATE_INTENTS) { + readAlternateIntents() ?: emptyList() + } + val callerTargets = + bundle.readValueUpdate(EXTRA_CHOOSER_TARGETS) { key -> + optional(array(key)) ?: emptyList() + } val refinementIntentSender = - optional(value(EXTRA_CHOOSER_REFINEMENT_INTENT_SENDER)) - val resultIntentSender = optional(value(EXTRA_CHOOSER_RESULT_INTENT_SENDER)) + bundle.readValueUpdate(EXTRA_CHOOSER_REFINEMENT_INTENT_SENDER) { key -> + optional(value(key)) + } + val resultIntentSender = + bundle.readValueUpdate(EXTRA_CHOOSER_RESULT_INTENT_SENDER) { key -> + optional(value(key)) + } val metadataText = if (flags.enableSharesheetMetadataExtra()) { - optional(value(EXTRA_METADATA_TEXT)) + bundle.readValueUpdate(EXTRA_METADATA_TEXT) { key -> + optional(value(key)) + } } else { - null + ValueUpdate.Absent } ShareouselUpdate( @@ -128,6 +156,16 @@ private fun readCallbackResponse( } } +private inline fun Bundle.readValueUpdate( + key: String, + block: (String) -> T +): ValueUpdate = + if (containsKey(key)) { + ValueUpdate.Value(block(key)) + } else { + ValueUpdate.Absent + } + @Module @InstallIn(ViewModelComponent::class) interface SelectionChangeCallbackModule { diff --git a/java/src/com/android/intentresolver/v2/domain/interactor/ChooserRequestUpdateInteractor.kt b/java/src/com/android/intentresolver/v2/domain/interactor/ChooserRequestUpdateInteractor.kt index 4afe46b0..37213403 100644 --- a/java/src/com/android/intentresolver/v2/domain/interactor/ChooserRequestUpdateInteractor.kt +++ b/java/src/com/android/intentresolver/v2/domain/interactor/ChooserRequestUpdateInteractor.kt @@ -17,12 +17,10 @@ package com.android.intentresolver.v2.domain.interactor import android.content.Intent -import android.content.IntentSender -import android.service.chooser.ChooserAction -import android.service.chooser.ChooserTarget import com.android.intentresolver.contentpreview.payloadtoggle.data.repository.ChooserParamsUpdateRepository import com.android.intentresolver.contentpreview.payloadtoggle.data.repository.TargetIntentRepository import com.android.intentresolver.contentpreview.payloadtoggle.domain.model.ShareouselUpdate +import com.android.intentresolver.contentpreview.payloadtoggle.domain.model.getOrDefault import com.android.intentresolver.v2.ui.model.ChooserRequest import dagger.assisted.Assisted import dagger.assisted.AssistedFactory @@ -63,61 +61,27 @@ constructor( } private fun updateTargetIntent(targetIntent: Intent) { - chooserRequestRepository.update { current -> - current.updatedWith(targetIntent = targetIntent) - } + chooserRequestRepository.update { current -> current.copy(targetIntent = targetIntent) } } private fun updateChooserParameters(update: ShareouselUpdate) { chooserRequestRepository.update { current -> - current.updatedWith( - callerChooserTargets = update.callerTargets, - modifyShareAction = update.modifyShareAction, - additionalTargets = update.alternateIntents, - chosenComponentSender = update.resultIntentSender, - refinementIntentSender = update.refinementIntentSender, - metadataText = update.metadataText, + 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), ) } } } -private fun ChooserRequest.updatedWith( - targetIntent: Intent? = null, - callerChooserTargets: List? = null, - modifyShareAction: ChooserAction? = null, - additionalTargets: List? = null, - chosenComponentSender: IntentSender? = null, - refinementIntentSender: IntentSender? = null, - metadataText: CharSequence? = null, -) = - ChooserRequest( - targetIntent ?: this.targetIntent, - this.targetAction, - this.isSendActionTarget, - this.targetType, - this.launchedFromPackage, - this.title, - this.defaultTitleResource, - this.referrer, - this.filteredComponentNames, - callerChooserTargets ?: this.callerChooserTargets, - this.chooserActions, - modifyShareAction ?: this.modifyShareAction, - this.shouldRetainInOnStop, - additionalTargets ?: this.additionalTargets, - this.replacementExtras, - this.initialIntents, - chosenComponentSender ?: this.chosenComponentSender, - refinementIntentSender ?: this.refinementIntentSender, - this.sharedText, - this.shareTargetFilter, - this.additionalContentUri, - this.focusedItemPosition, - this.contentTypeHint, - metadataText ?: this.metadataText, - ) - @AssistedFactory @ViewModelScoped interface ChooserRequestUpdateInteractorFactory { diff --git a/tests/unit/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/update/SelectionChangeCallbackImplTest.kt b/tests/unit/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/update/SelectionChangeCallbackImplTest.kt index 0dfbfff2..55b32509 100644 --- a/tests/unit/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/update/SelectionChangeCallbackImplTest.kt +++ b/tests/unit/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/update/SelectionChangeCallbackImplTest.kt @@ -44,6 +44,8 @@ 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 @@ -51,6 +53,7 @@ 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 @@ -175,16 +178,16 @@ class SelectionChangeCallbackImplTest { 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.resultIntentSender).isNull() - assertThat(result.metadataText).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 @@ -213,18 +216,18 @@ class SelectionChangeCallbackImplTest { 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.resultIntentSender).isNull() - assertThat(result.metadataText).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 @@ -248,24 +251,24 @@ class SelectionChangeCallbackImplTest { 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.resultIntentSender).isNull() - assertThat(result.metadataText).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 @@ -298,7 +301,7 @@ class SelectionChangeCallbackImplTest { 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 { actual, expected -> @@ -313,12 +316,12 @@ class SelectionChangeCallbackImplTest { .containsExactly(t1, t2) .inOrder() - assertThat(result.customActions).isNull() - assertThat(result.modifyShareAction).isNull() - assertThat(result.alternateIntents).isNull() - assertThat(result.refinementIntentSender).isNull() - assertThat(result.resultIntentSender).isNull() - assertThat(result.metadataText).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 @@ -339,13 +342,13 @@ class SelectionChangeCallbackImplTest { 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.resultIntentSender).isNull() - assertThat(result.metadataText).isNull() + 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 @@ -366,13 +369,13 @@ class SelectionChangeCallbackImplTest { 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.resultIntentSender).isNotNull() - assertThat(result.metadataText).isNull() + 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 @@ -387,13 +390,13 @@ class SelectionChangeCallbackImplTest { 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.resultIntentSender).isNull() - assertThat(result.metadataText).isNull() + 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 @@ -409,13 +412,13 @@ class SelectionChangeCallbackImplTest { 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.resultIntentSender).isNull() - assertThat(result.metadataText).isEqualTo(metadataText) + 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 @@ -440,14 +443,20 @@ class SelectionChangeCallbackImplTest { 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.resultIntentSender).isNull() - assertThat(result.metadataText).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 ValueUpdate.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/v2/domain/interactor/ChooserRequestUpdateInteractorTest.kt b/tests/unit/src/com/android/intentresolver/v2/domain/interactor/ChooserRequestUpdateInteractorTest.kt index d6288e2f..d05a0a91 100644 --- a/tests/unit/src/com/android/intentresolver/v2/domain/interactor/ChooserRequestUpdateInteractorTest.kt +++ b/tests/unit/src/com/android/intentresolver/v2/domain/interactor/ChooserRequestUpdateInteractorTest.kt @@ -25,6 +25,7 @@ import android.net.Uri import com.android.intentresolver.contentpreview.payloadtoggle.data.repository.ChooserParamsUpdateRepository import com.android.intentresolver.contentpreview.payloadtoggle.data.repository.TargetIntentRepository import com.android.intentresolver.contentpreview.payloadtoggle.domain.model.ShareouselUpdate +import com.android.intentresolver.contentpreview.payloadtoggle.domain.model.ValueUpdate import com.android.intentresolver.mock import com.android.intentresolver.v2.ui.model.ChooserRequest import com.google.common.truth.Truth.assertThat @@ -112,7 +113,7 @@ class ChooserRequestUpdateInteractorTest { val newResultSender = mock() chooserParamsUpdateRepository.setUpdates( ShareouselUpdate( - resultIntentSender = newResultSender, + resultIntentSender = ValueUpdate.Value(newResultSender), ) ) testScheduler.runCurrent() @@ -142,7 +143,7 @@ class ChooserRequestUpdateInteractorTest { val newTargetIntent = Intent(Intent.ACTION_VIEW) chooserParamsUpdateRepository.setUpdates( ShareouselUpdate( - resultIntentSender = newResultSender, + resultIntentSender = ValueUpdate.Value(newResultSender), ) ) testScheduler.runCurrent() @@ -153,6 +154,41 @@ class ChooserRequestUpdateInteractorTest { assertThat(requestFlow.value.chosenComponentSender).isSameInstanceAs(newResultSender) } + + @Test + fun testUpdateWithNullValues() = + testScope.runTest { + val initialRequest = + ChooserRequest( + targetIntent = targetIntent, + targetAction = targetIntent.action, + isSendActionTarget = true, + targetType = null, + launchedFromPackage = "", + referrer = null, + refinementIntentSender = mock(), + chosenComponentSender = mock(), + ) + val requestFlow = MutableStateFlow(initialRequest) + val testSubject = + ChooserRequestUpdateInteractor( + targetIntentRepository, + chooserParamsUpdateRepository, + requestFlow, + ) + backgroundScope.launch { testSubject.launch() } + + chooserParamsUpdateRepository.setUpdates( + ShareouselUpdate( + resultIntentSender = ValueUpdate.Value(null), + refinementIntentSender = ValueUpdate.Value(null), + ) + ) + testScheduler.runCurrent() + + assertThat(requestFlow.value.chosenComponentSender).isNull() + assertThat(requestFlow.value.refinementIntentSender).isNull() + } } private fun createSomeChooserRequest(targetIntent: Intent) = -- cgit v1.2.3-59-g8ed1b From c2d8c7f7a9fad0151e67b467f64b54b4b13d9c9d Mon Sep 17 00:00:00 2001 From: Matt Casey Date: Mon, 25 Mar 2024 14:40:05 +0000 Subject: Apply ag/26029901 to V2 Avoid NPE when checking if an adapter is empty. Bug: 328172905 Test: atest IntentResolver-tests-activity Change-Id: Id5f46cb876cf27c526f601bc8890d59533a1788f --- java/src/com/android/intentresolver/v2/ChooserActivity.java | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) (limited to 'java/src') diff --git a/java/src/com/android/intentresolver/v2/ChooserActivity.java b/java/src/com/android/intentresolver/v2/ChooserActivity.java index cb97d94f..ffa0469c 100644 --- a/java/src/com/android/intentresolver/v2/ChooserActivity.java +++ b/java/src/com/android/intentresolver/v2/ChooserActivity.java @@ -2448,8 +2448,9 @@ public class ChooserActivity extends Hilt_ChooserActivity implements if (!shouldShowContentPreview()) { return false; } - boolean isEmpty = mChooserMultiProfilePagerAdapter.getListAdapterForUserHandle( - UserHandle.of(UserHandle.myUserId())).getCount() == 0; + ResolverListAdapter adapter = mChooserMultiProfilePagerAdapter.getListAdapterForUserHandle( + UserHandle.of(UserHandle.myUserId())); + boolean isEmpty = adapter == null || adapter.getCount() == 0; return (mFeatureFlags.scrollablePreview() || mProfiles.getWorkProfilePresent()) && (!isEmpty || shouldShowContentPreviewWhenEmpty()); } -- cgit v1.2.3-59-g8ed1b From 38a6a7637ed22bed196ede400ff72fea5407b17b Mon Sep 17 00:00:00 2001 From: Steve Elliott Date: Tue, 26 Mar 2024 17:36:06 -0400 Subject: Introduce ChooserRequestRepository - Replace TargetIntentRepository with ChooserRequestRepository, using the ChooserRequest as the source of truth for the target intent. - Caveat: custom actions are tracked separately to facilitate with testing; long-term we will want to update/replace ChooserRequest so that it isn't relying on un-mockable/un-fakeable types. - Remove concept of "initialization" from repositories. - Usages are better captured as "events", and so are handled in interactor codepaths that flow *into* the repositories. Bug: 302691505 Flag: ACONFIG android.service.chooser.chooser_payload_toggling DEVELOPMENT Test: atest IntentResolver-tests-unit Change-Id: I8451a495478dbe750a44e6b049d4751fa7badf81 --- .../payloadtoggle/data/model/SelectionRecord.kt | 30 --- .../payloadtoggle/data/model/TargetIntentRecord.kt | 21 -- .../repository/ChooserParamsUpdateRepository.kt | 34 --- .../PendingSelectionCallbackRepository.kt | 32 +++ .../data/repository/PreviewSelectionsRepository.kt | 41 +--- .../data/repository/TargetIntentRepository.kt | 45 ---- .../domain/intent/TargetIntentModifier.kt | 4 +- .../domain/interactor/ChooserRequestInteractor.kt | 38 ++++ .../domain/interactor/CustomActionsInteractor.kt | 5 +- .../domain/interactor/FetchPreviewsInteractor.kt | 4 +- .../ProcessTargetIntentUpdatesInteractor.kt | 42 ++++ .../interactor/SelectablePreviewInteractor.kt | 9 +- .../interactor/SelectablePreviewsInteractor.kt | 6 +- .../domain/interactor/SelectionInteractor.kt | 27 ++- .../interactor/UpdateChooserRequestInteractor.kt | 62 ++++++ .../interactor/UpdateTargetIntentInteractor.kt | 62 +----- .../intentresolver/inject/ActivityModelModule.kt | 12 +- .../android/intentresolver/v2/ChooserActivity.java | 2 +- .../com/android/intentresolver/v2/ChooserHelper.kt | 2 +- .../intentresolver/v2/data/model/ChooserRequest.kt | 209 +++++++++++++++++ .../v2/data/repository/ChooserRequestRepository.kt | 39 ++++ .../interactor/ChooserRequestUpdateInteractor.kt | 91 -------- .../intentresolver/v2/ui/model/ActivityModel.kt | 1 + .../intentresolver/v2/ui/model/ChooserRequest.kt | 209 ----------------- .../v2/ui/viewmodel/ChooserRequestReader.kt | 2 +- .../v2/ui/viewmodel/ChooserViewModel.kt | 46 ++-- .../repository/PreviewSelectionsRepositoryTest.kt | 43 ---- .../domain/intent/TargetIntentModifierImplTest.kt | 14 +- .../interactor/CustomActionsInteractorTest.kt | 51 +++-- .../interactor/FetchPreviewsInteractorTest.kt | 16 +- .../interactor/SelectablePreviewInteractorTest.kt | 137 ++++++++---- .../interactor/SelectablePreviewsInteractorTest.kt | 58 ++++- .../UpdateChooserRequestInteractorTest.kt | 74 +++++++ .../interactor/UpdateTargetIntentInteractorTest.kt | 98 -------- .../ui/viewmodel/ShareouselViewModelTest.kt | 246 +++++++++++++-------- .../v2/data/model/FakeChooserRequest.kt | 26 +++ .../ChooserRequestUpdateInteractorTest.kt | 204 ----------------- .../v2/ui/viewmodel/ChooserRequestTest.kt | 16 +- .../v2/ui/viewmodel/ResolverRequestTest.kt | 1 - 39 files changed, 956 insertions(+), 1103 deletions(-) delete mode 100644 java/src/com/android/intentresolver/contentpreview/payloadtoggle/data/model/SelectionRecord.kt delete mode 100644 java/src/com/android/intentresolver/contentpreview/payloadtoggle/data/model/TargetIntentRecord.kt delete mode 100644 java/src/com/android/intentresolver/contentpreview/payloadtoggle/data/repository/ChooserParamsUpdateRepository.kt create mode 100644 java/src/com/android/intentresolver/contentpreview/payloadtoggle/data/repository/PendingSelectionCallbackRepository.kt delete mode 100644 java/src/com/android/intentresolver/contentpreview/payloadtoggle/data/repository/TargetIntentRepository.kt create mode 100644 java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/interactor/ChooserRequestInteractor.kt create mode 100644 java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/interactor/ProcessTargetIntentUpdatesInteractor.kt create mode 100644 java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/interactor/UpdateChooserRequestInteractor.kt create mode 100644 java/src/com/android/intentresolver/v2/data/model/ChooserRequest.kt create mode 100644 java/src/com/android/intentresolver/v2/data/repository/ChooserRequestRepository.kt delete mode 100644 java/src/com/android/intentresolver/v2/domain/interactor/ChooserRequestUpdateInteractor.kt delete mode 100644 java/src/com/android/intentresolver/v2/ui/model/ChooserRequest.kt delete mode 100644 tests/unit/src/com/android/intentresolver/contentpreview/payloadtoggle/data/repository/PreviewSelectionsRepositoryTest.kt create mode 100644 tests/unit/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/interactor/UpdateChooserRequestInteractorTest.kt delete mode 100644 tests/unit/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/interactor/UpdateTargetIntentInteractorTest.kt create mode 100644 tests/unit/src/com/android/intentresolver/v2/data/model/FakeChooserRequest.kt delete mode 100644 tests/unit/src/com/android/intentresolver/v2/domain/interactor/ChooserRequestUpdateInteractorTest.kt (limited to 'java/src') diff --git a/java/src/com/android/intentresolver/contentpreview/payloadtoggle/data/model/SelectionRecord.kt b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/data/model/SelectionRecord.kt deleted file mode 100644 index c8fcb9d5..00000000 --- a/java/src/com/android/intentresolver/contentpreview/payloadtoggle/data/model/SelectionRecord.kt +++ /dev/null @@ -1,30 +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.payloadtoggle.data.model - -import com.android.intentresolver.contentpreview.payloadtoggle.shared.model.PreviewModel - -data class SelectionRecord( - val type: SelectionRecordType, - val selection: Set, -) - -enum class SelectionRecordType { - Uninitialized, - Initial, - Updated, -} diff --git a/java/src/com/android/intentresolver/contentpreview/payloadtoggle/data/model/TargetIntentRecord.kt b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/data/model/TargetIntentRecord.kt deleted file mode 100644 index 17393023..00000000 --- a/java/src/com/android/intentresolver/contentpreview/payloadtoggle/data/model/TargetIntentRecord.kt +++ /dev/null @@ -1,21 +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.payloadtoggle.data.model - -import android.content.Intent - -data class TargetIntentRecord(val isInitial: Boolean, val intent: Intent) diff --git a/java/src/com/android/intentresolver/contentpreview/payloadtoggle/data/repository/ChooserParamsUpdateRepository.kt b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/data/repository/ChooserParamsUpdateRepository.kt deleted file mode 100644 index 1a4f2b83..00000000 --- a/java/src/com/android/intentresolver/contentpreview/payloadtoggle/data/repository/ChooserParamsUpdateRepository.kt +++ /dev/null @@ -1,34 +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.payloadtoggle.data.repository - -import com.android.intentresolver.contentpreview.payloadtoggle.domain.model.ShareouselUpdate -import dagger.hilt.android.scopes.ViewModelScoped -import javax.inject.Inject -import kotlinx.coroutines.flow.MutableStateFlow - -/** Chooser parameters Updates received from the sharing application payload change callback */ -// TODO: a scaffolding repository to deliver chooser parameter updates before we developed some -// other, more thought-through solution. -@ViewModelScoped -class ChooserParamsUpdateRepository @Inject constructor() { - val updates = MutableStateFlow(null) - - fun setUpdates(update: ShareouselUpdate) { - updates.tryEmit(update) - } -} 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 = 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 index b461d10b..9aecc981 100644 --- a/java/src/com/android/intentresolver/contentpreview/payloadtoggle/data/repository/PreviewSelectionsRepository.kt +++ b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/data/repository/PreviewSelectionsRepository.kt @@ -16,52 +16,13 @@ package com.android.intentresolver.contentpreview.payloadtoggle.data.repository -import android.util.Log -import com.android.intentresolver.contentpreview.payloadtoggle.data.model.SelectionRecord -import com.android.intentresolver.contentpreview.payloadtoggle.data.model.SelectionRecordType.Initial -import com.android.intentresolver.contentpreview.payloadtoggle.data.model.SelectionRecordType.Uninitialized -import com.android.intentresolver.contentpreview.payloadtoggle.data.model.SelectionRecordType.Updated import com.android.intentresolver.contentpreview.payloadtoggle.shared.model.PreviewModel import dagger.hilt.android.scopes.ViewModelScoped import javax.inject.Inject import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.flow.update - -private const val TAG = "PreviewSelectionsRep" /** Stores set of selected previews. */ @ViewModelScoped class PreviewSelectionsRepository @Inject constructor() { - private val _selections = MutableStateFlow(SelectionRecord(Uninitialized, emptySet())) - - /** Selected previews data */ - val selections: StateFlow = _selections.asStateFlow() - - fun setSelection(selection: Set) { - _selections.value = SelectionRecord(Initial, selection) - } - - fun select(item: PreviewModel) { - _selections.update { record -> - if (record.type == Uninitialized) { - Log.w(TAG, "Changing selection before it is initialized") - record - } else { - SelectionRecord(Updated, record.selection + item) - } - } - } - - fun unselect(item: PreviewModel) { - _selections.update { record -> - if (record.type == Uninitialized) { - Log.w(TAG, "Changing selection before it is initialized") - record - } else { - SelectionRecord(Updated, record.selection - item) - } - } - } + val selections = MutableStateFlow(emptySet()) } diff --git a/java/src/com/android/intentresolver/contentpreview/payloadtoggle/data/repository/TargetIntentRepository.kt b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/data/repository/TargetIntentRepository.kt deleted file mode 100644 index bb43323a..00000000 --- a/java/src/com/android/intentresolver/contentpreview/payloadtoggle/data/repository/TargetIntentRepository.kt +++ /dev/null @@ -1,45 +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.payloadtoggle.data.repository - -import android.content.Intent -import com.android.intentresolver.contentpreview.payloadtoggle.data.model.CustomActionModel -import com.android.intentresolver.contentpreview.payloadtoggle.data.model.TargetIntentRecord -import com.android.intentresolver.inject.TargetIntent -import dagger.hilt.android.scopes.ViewModelScoped -import javax.inject.Inject -import kotlinx.coroutines.flow.MutableStateFlow - -/** Stores the target intent of the share sheet, and custom actions derived from the intent. */ -@ViewModelScoped -class TargetIntentRepository -@Inject -constructor( - @TargetIntent initialIntent: Intent, - initialActions: List, -) { - val targetIntent = MutableStateFlow(TargetIntentRecord(isInitial = true, initialIntent)) - - // TODO: this can probably be derived from [targetIntent]; right now, the [initialActions] are - // coming from a different place (ChooserRequest) than later ones (SelectionChangeCallback) - // and so this serves as the source of truth between the two. - val customActions = MutableStateFlow(initialActions) - - fun updateTargetIntent(intent: Intent) { - targetIntent.value = TargetIntentRecord(isInitial = false, intent) - } -} diff --git a/java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/intent/TargetIntentModifier.kt b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/intent/TargetIntentModifier.kt index 577dc34c..4a2a6932 100644 --- a/java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/intent/TargetIntentModifier.kt +++ b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/intent/TargetIntentModifier.kt @@ -32,7 +32,7 @@ import dagger.hilt.android.components.ViewModelComponent /** Modifies target intent based on current payload selection. */ fun interface TargetIntentModifier { - fun onSelectionChanged(selection: Collection): Intent + fun intentFromSelection(selection: Collection): Intent } class TargetIntentModifierImpl( @@ -40,7 +40,7 @@ class TargetIntentModifierImpl( private val getUri: Item.() -> Uri, private val getMimeType: Item.() -> String?, ) : TargetIntentModifier { - override fun onSelectionChanged(selection: Collection): Intent { + override fun intentFromSelection(selection: Collection): Intent { val uris = selection.mapTo(ArrayList()) { it.getUri() } val targetMimeType = selection.fold(null) { target: String?, item: Item -> 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 + get() = repository.chooserRequest.map { it.targetIntent } + + val customActions: Flow> + get() = repository.customActions.asSharedFlow() +} 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 index 56f781fb..e973e844 100644 --- a/java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/interactor/CustomActionsInteractor.kt +++ b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/interactor/CustomActionsInteractor.kt @@ -21,7 +21,6 @@ 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.data.repository.TargetIntentRepository import com.android.intentresolver.contentpreview.payloadtoggle.domain.model.ActionModel import com.android.intentresolver.icon.toComposeIcon import com.android.intentresolver.inject.Background @@ -41,12 +40,12 @@ constructor( private val contentResolver: ContentResolver, private val eventLog: EventLog, private val packageManager: PackageManager, - private val targetIntentRepo: TargetIntentRepository, + private val chooserRequestInteractor: ChooserRequestInteractor, ) { /** List of [ActionModel] that can be presented in Shareousel. */ val customActions: Flow> get() = - targetIntentRepo.customActions + chooserRequestInteractor.customActions .map { actions -> actions.map { action -> ActionModel( 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 index a7749c92..9bc7ae63 100644 --- a/java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/interactor/FetchPreviewsInteractor.kt +++ b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/interactor/FetchPreviewsInteractor.kt @@ -41,10 +41,10 @@ constructor( private val uriMetadataReader: UriMetadataReader, @PayloadToggle private val cursorResolver: CursorResolver<@JvmSuppressWildcards Uri?>, ) { - suspend fun launch() = coroutineScope { + suspend fun activate() = coroutineScope { val cursor = async { cursorResolver.getCursor() } val initialPreviewMap: Set = getInitialPreviews() - selectionRepository.setSelection(initialPreviewMap) + selectionRepository.selections.value = initialPreviewMap setCursorPreviews.setPreviews( previewsByKey = initialPreviewMap, startIndex = focusedItemIdx, 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 index 3b5b0ddf..55a995f5 100644 --- a/java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/interactor/SelectablePreviewInteractor.kt +++ b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/interactor/SelectablePreviewInteractor.kt @@ -17,7 +17,6 @@ package com.android.intentresolver.contentpreview.payloadtoggle.domain.interactor import android.net.Uri -import com.android.intentresolver.contentpreview.payloadtoggle.data.repository.PreviewSelectionsRepository import com.android.intentresolver.contentpreview.payloadtoggle.shared.model.PreviewModel import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.map @@ -25,19 +24,19 @@ import kotlinx.coroutines.flow.map /** An individual preview in Shareousel. */ class SelectablePreviewInteractor( private val key: PreviewModel, - private val selectionRepo: PreviewSelectionsRepository, + private val selectionInteractor: SelectionInteractor, ) { val uri: Uri = key.uri /** Whether or not this preview is selected by the user. */ - val isSelected: Flow = selectionRepo.selections.map { key in it.selection } + val isSelected: Flow = selectionInteractor.selections.map { key in it } /** Sets whether this preview is selected by the user. */ fun setSelected(isSelected: Boolean) { if (isSelected) { - selectionRepo.select(key) + selectionInteractor.select(key) } else { - selectionRepo.unselect(key) + 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 index 78e208f6..a578d0e2 100644 --- a/java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/interactor/SelectablePreviewsInteractor.kt +++ b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/interactor/SelectablePreviewsInteractor.kt @@ -17,7 +17,6 @@ package com.android.intentresolver.contentpreview.payloadtoggle.domain.interactor import com.android.intentresolver.contentpreview.payloadtoggle.data.repository.CursorPreviewsRepository -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 javax.inject.Inject @@ -27,7 +26,7 @@ class SelectablePreviewsInteractor @Inject constructor( private val previewsRepo: CursorPreviewsRepository, - private val selectionRepo: PreviewSelectionsRepository, + private val selectionInteractor: SelectionInteractor, ) { /** Keys of previews available for display in Shareousel. */ val previews: Flow @@ -37,6 +36,5 @@ constructor( * Returns a [SelectablePreviewInteractor] that can be used to interact with the individual * preview associated with [key]. */ - fun preview(key: PreviewModel) = - SelectablePreviewInteractor(key = key, selectionRepo = selectionRepo) + 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 index 0b8bcdd7..a570f36e 100644 --- a/java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/interactor/SelectionInteractor.kt +++ b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/interactor/SelectionInteractor.kt @@ -17,15 +17,38 @@ 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( - selectionRepo: PreviewSelectionsRepository, + private val selectionsRepo: PreviewSelectionsRepository, + private val targetIntentModifier: TargetIntentModifier, + private val updateTargetIntentInteractor: UpdateTargetIntentInteractor, ) { + /** Set of selected previews. */ + val selections: StateFlow> + get() = selectionsRepo.selections + /** Amount of selected previews. */ - val amountSelected: Flow = selectionRepo.selections.map { it.selection.size } + val amountSelected: Flow = 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) { + val intent = targetIntentModifier.intentFromSelection(selections) + updateTargetIntentInteractor.updateTargetIntent(intent) + } } 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 index 06e28cba..429e34e9 100644 --- a/java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/interactor/UpdateTargetIntentInteractor.kt +++ b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/interactor/UpdateTargetIntentInteractor.kt @@ -14,64 +14,24 @@ * limitations under the License. */ -@file:OptIn(ExperimentalCoroutinesApi::class) - package com.android.intentresolver.contentpreview.payloadtoggle.domain.interactor -import com.android.intentresolver.contentpreview.payloadtoggle.data.model.SelectionRecordType -import com.android.intentresolver.contentpreview.payloadtoggle.data.repository.ChooserParamsUpdateRepository -import com.android.intentresolver.contentpreview.payloadtoggle.data.repository.PreviewSelectionsRepository -import com.android.intentresolver.contentpreview.payloadtoggle.data.repository.TargetIntentRepository -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.TargetIntentModifier -import com.android.intentresolver.contentpreview.payloadtoggle.domain.intent.toCustomActionModel -import com.android.intentresolver.contentpreview.payloadtoggle.domain.model.onValue -import com.android.intentresolver.contentpreview.payloadtoggle.domain.update.SelectionChangeCallback -import com.android.intentresolver.contentpreview.payloadtoggle.shared.model.PreviewModel +import android.content.Intent +import com.android.intentresolver.contentpreview.payloadtoggle.data.repository.PendingSelectionCallbackRepository import javax.inject.Inject -import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.coroutineScope -import kotlinx.coroutines.flow.collectLatest -import kotlinx.coroutines.flow.filter -import kotlinx.coroutines.flow.filterNotNull -import kotlinx.coroutines.flow.mapLatest -import kotlinx.coroutines.launch -/** Updates [TargetIntentRepository] in reaction to user selection changes. */ class UpdateTargetIntentInteractor @Inject constructor( - private val intentRepository: TargetIntentRepository, - private val chooserParamsUpdateRepository: ChooserParamsUpdateRepository, - @CustomAction private val pendingIntentSender: PendingIntentSender, - private val selectionCallback: SelectionChangeCallback, - private val selectionRepo: PreviewSelectionsRepository, - private val targetIntentModifier: TargetIntentModifier, + private val repository: PendingSelectionCallbackRepository, + private val chooserRequestInteractor: UpdateChooserRequestInteractor, ) { - /** Listen for events and update state. */ - suspend fun launch(): Unit = coroutineScope { - launch { - intentRepository.targetIntent - .filter { !it.isInitial } - .mapLatest { record -> selectionCallback.onSelectionChanged(record.intent) } - .filterNotNull() - .collect { updates -> - updates.customActions.onValue { actions -> - intentRepository.customActions.value = - actions.map { it.toCustomActionModel(pendingIntentSender) } - } - chooserParamsUpdateRepository.setUpdates(updates) - } - } - launch { - selectionRepo.selections - .filter { it.type == SelectionRecordType.Updated } - .collectLatest { - intentRepository.updateTargetIntent( - targetIntentModifier.onSelectionChanged(it.selection) - ) - } - } + /** + * 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/inject/ActivityModelModule.kt b/java/src/com/android/intentresolver/inject/ActivityModelModule.kt index c08c7f4c..ff2bb14b 100644 --- a/java/src/com/android/intentresolver/inject/ActivityModelModule.kt +++ b/java/src/com/android/intentresolver/inject/ActivityModelModule.kt @@ -21,8 +21,8 @@ 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.model.ChooserRequest import com.android.intentresolver.v2.ui.viewmodel.readChooserRequest import com.android.intentresolver.v2.validation.Valid import com.android.intentresolver.v2.validation.ValidationResult @@ -48,11 +48,19 @@ object ActivityModelModule { @Provides @ViewModelScoped - fun provideChooserRequest( + fun provideInitialRequest( activityModel: ActivityModel, flags: ChooserServiceFlags, ): ValidationResult = readChooserRequest(activityModel, flags) + @Provides + fun provideChooserRequest( + initialRequest: ValidationResult, + ): ChooserRequest = + requireNotNull((initialRequest as? Valid)?.value) { + "initialRequest is Invalid, no chooser request available" + } + @Provides @TargetIntent fun targetIntent(chooserReq: ValidationResult): Intent = diff --git a/java/src/com/android/intentresolver/v2/ChooserActivity.java b/java/src/com/android/intentresolver/v2/ChooserActivity.java index ffa0469c..d624c9e4 100644 --- a/java/src/com/android/intentresolver/v2/ChooserActivity.java +++ b/java/src/com/android/intentresolver/v2/ChooserActivity.java @@ -131,6 +131,7 @@ 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; @@ -151,7 +152,6 @@ 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; diff --git a/java/src/com/android/intentresolver/v2/ChooserHelper.kt b/java/src/com/android/intentresolver/v2/ChooserHelper.kt index f2a2726a..503e46d8 100644 --- a/java/src/com/android/intentresolver/v2/ChooserHelper.kt +++ b/java/src/com/android/intentresolver/v2/ChooserHelper.kt @@ -29,9 +29,9 @@ 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.model.ChooserRequest import com.android.intentresolver.v2.ui.viewmodel.ChooserViewModel import com.android.intentresolver.v2.validation.Invalid import com.android.intentresolver.v2.validation.Valid diff --git a/java/src/com/android/intentresolver/v2/data/model/ChooserRequest.kt b/java/src/com/android/intentresolver/v2/data/model/ChooserRequest.kt new file mode 100644 index 00000000..7c9c8613 --- /dev/null +++ b/java/src/com/android/intentresolver/v2/data/model/ChooserRequest.kt @@ -0,0 +1,209 @@ +/* + * Copyright (C) 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF 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.ComponentName +import android.content.Intent +import android.content.Intent.ACTION_SEND +import android.content.Intent.ACTION_SEND_MULTIPLE +import android.content.Intent.EXTRA_REFERRER +import android.content.IntentFilter +import android.content.IntentSender +import android.net.Uri +import android.os.Bundle +import android.service.chooser.ChooserAction +import android.service.chooser.ChooserTarget +import androidx.annotation.StringRes +import com.android.intentresolver.ContentTypeHint +import com.android.intentresolver.v2.ext.hasAction + +const val ANDROID_APP_SCHEME = "android-app" + +/** All of the things that are consumed from an incoming share Intent (+Extras). */ +data class ChooserRequest( + /** Required. Represents the content being sent. */ + val targetIntent: Intent, + + /** The action from [targetIntent] as retrieved with [Intent.getAction]. */ + val targetAction: String?, + + /** + * Whether [targetAction] is ACTION_SEND or ACTION_SEND_MULTIPLE. These are considered the + * canonical "Share" actions. When handling other actions, this flag controls behavioral and + * visual changes. + */ + val isSendActionTarget: Boolean, + + /** The top-level content type as retrieved using [Intent.getType]. */ + val targetType: String?, + + /** The package name of the app which started the current activity instance. */ + val launchedFromPackage: String, + + /** A custom tile for the main UI. Ignored when the intent is ACTION_SEND(_MULTIPLE). */ + val title: CharSequence? = null, + + /** A String resource ID to load when [title] is null. */ + @get:StringRes val defaultTitleResource: Int = 0, + + /** + * The referrer value as received by the caller. It may have been supplied via [EXTRA_REFERRER] + * or synthesized from callerPackageName. This value is merged into outgoing intents. + */ + val referrer: Uri?, + + /** + * Choices to exclude from results. + * + * Any resolved intents with a component in this list will be omitted before presentation. + */ + val filteredComponentNames: List = emptyList(), + + /** + * App provided shortcut share intents (aka "direct share targets") + * + * Normally share shortcuts are published and consumed using + * [ShortcutManager][android.content.pm.ShortcutManager]. This is an alternate channel to allow + * apps to directly inject the same information. + * + * Historical note: This option was initially integrated with other results from the + * ChooserTargetService API (since deprecated and removed), hence the name and data format. + * These are more correctly called "Share Shortcuts" now. + */ + val callerChooserTargets: List = emptyList(), + + /** + * Actions the user may perform. These are presented as separate affordances from the main list + * of choices. Selecting a choice is a terminal action which results in finishing. The item + * limit is [MAX_CHOOSER_ACTIONS]. This may be further constrained as appropriate. + */ + val chooserActions: List = emptyList(), + + /** + * An action to start an Activity which for user updating of shared content. Selection is a + * terminal action, closing the current activity and launching the target of the action. + */ + val modifyShareAction: ChooserAction? = null, + + /** + * When false the host activity will be [finished][android.app.Activity.finish] when stopped. + */ + @get:JvmName("shouldRetainInOnStop") val shouldRetainInOnStop: Boolean = false, + + /** + * Intents which contain alternate representations of the content being shared. Any results from + * resolving these _alternate_ intents are included with the results of the primary intent as + * additional choices (e.g. share as image content vs. link to content). + */ + val additionalTargets: List = emptyList(), + + /** + * Alternate [extras][Intent.getExtras] to substitute when launching a selected app. + * + * For a given app (by package name), the Bundle describes what parameters to substitute when + * that app is selected. + * + * // TODO: Map + */ + val replacementExtras: Bundle? = null, + + /** + * App-supplied choices to be presented first in the list. + * + * Custom labels and icons may be supplied using + * [LabeledIntent][android.content.pm.LabeledIntent]. + * + * Limit 2. + */ + val initialIntents: List = emptyList(), + + /** + * Provides for callers to be notified when a component is selected. + * + * The selection is reported in the Intent as [Intent.EXTRA_CHOSEN_COMPONENT] with the + * [ComponentName] of the item. + */ + val chosenComponentSender: IntentSender? = null, + + /** + * Provides a mechanism for callers to post-process a target when a selection is made. + * + * The received intent will contain: + * * **EXTRA_INTENT** The chosen target + * * **EXTRA_ALTERNATE_INTENTS** Additional intents which also match the target + * * **EXTRA_RESULT_RECEIVER** A [ResultReceiver][android.os.ResultReceiver] providing a + * mechanism for the caller to return information. An updated intent to send must be included + * as [Intent.EXTRA_INTENT]. + */ + val refinementIntentSender: IntentSender? = null, + + /** + * Contains the text content to share supplied by the source app. + * + * TODO: Constrain length? + */ + val sharedText: CharSequence? = null, + + /** + * Supplied to + * [ShortcutManager.getShareTargets][android.content.pm.ShortcutManager.getShareTargets] to + * query for matching shortcuts. Specifically, only the [dataTypes][IntentFilter.hasDataType] + * are considered for matching share shortcuts currently. + */ + val shareTargetFilter: IntentFilter? = null, + + /** A URI for additional content */ + val additionalContentUri: Uri? = null, + + /** Focused item index (from target intent's STREAM_EXTRA) */ + val focusedItemPosition: Int = 0, + + /** Value for [Intent.EXTRA_CHOOSER_CONTENT_TYPE_HINT] on the incoming chooser intent. */ + val contentTypeHint: ContentTypeHint = ContentTypeHint.NONE, + + /** + * Metadata to be shown to the user as a part of the sharesheet window. + * + * Specified by the [Intent.EXTRA_METADATA_TEXT] + */ + val metadataText: CharSequence? = null, +) { + val referrerPackage = referrer?.takeIf { it.scheme == ANDROID_APP_SCHEME }?.authority + + fun getReferrerFillInIntent(): Intent { + return Intent().apply { + referrerPackage?.also { pkg -> + putExtra(EXTRA_REFERRER, Uri.parse("$ANDROID_APP_SCHEME://$pkg")) + } + } + } + + val payloadIntents = listOf(targetIntent) + additionalTargets + + /** Constructs an instance from only the required values. */ + constructor( + targetIntent: Intent, + launchedFromPackage: String, + referrer: Uri? + ) : this( + targetIntent = targetIntent, + targetAction = targetIntent.action, + isSendActionTarget = targetIntent.hasAction(ACTION_SEND, ACTION_SEND_MULTIPLE), + targetType = targetIntent.type, + launchedFromPackage = launchedFromPackage, + referrer = referrer + ) +} 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, +) { + /** All information from the sharing application pertaining to the chooser. */ + val chooserRequest: MutableStateFlow = 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> = MutableStateFlow(initialActions) +} diff --git a/java/src/com/android/intentresolver/v2/domain/interactor/ChooserRequestUpdateInteractor.kt b/java/src/com/android/intentresolver/v2/domain/interactor/ChooserRequestUpdateInteractor.kt deleted file mode 100644 index 37213403..00000000 --- a/java/src/com/android/intentresolver/v2/domain/interactor/ChooserRequestUpdateInteractor.kt +++ /dev/null @@ -1,91 +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.domain.interactor - -import android.content.Intent -import com.android.intentresolver.contentpreview.payloadtoggle.data.repository.ChooserParamsUpdateRepository -import com.android.intentresolver.contentpreview.payloadtoggle.data.repository.TargetIntentRepository -import com.android.intentresolver.contentpreview.payloadtoggle.domain.model.ShareouselUpdate -import com.android.intentresolver.contentpreview.payloadtoggle.domain.model.getOrDefault -import com.android.intentresolver.v2.ui.model.ChooserRequest -import dagger.assisted.Assisted -import dagger.assisted.AssistedFactory -import dagger.assisted.AssistedInject -import dagger.hilt.android.scopes.ViewModelScoped -import kotlinx.coroutines.coroutineScope -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.filter -import kotlinx.coroutines.flow.filterNotNull -import kotlinx.coroutines.flow.map -import kotlinx.coroutines.flow.update -import kotlinx.coroutines.launch - -/** Updates updates ChooserRequest with a new target intent */ -// TODO: make fully injectable -class ChooserRequestUpdateInteractor -@AssistedInject -constructor( - private val targetIntentRepository: TargetIntentRepository, - private val paramsUpdateRepository: ChooserParamsUpdateRepository, - // TODO: replace with a proper repository, when available - @Assisted private val chooserRequestRepository: MutableStateFlow, -) { - - suspend fun launch() { - coroutineScope { - launch { - targetIntentRepository.targetIntent - .filter { !it.isInitial } - .map { it.intent } - .collect(::updateTargetIntent) - } - - launch { - paramsUpdateRepository.updates.filterNotNull().collect(::updateChooserParameters) - } - } - } - - private fun updateTargetIntent(targetIntent: Intent) { - chooserRequestRepository.update { current -> current.copy(targetIntent = targetIntent) } - } - - private fun updateChooserParameters(update: ShareouselUpdate) { - chooserRequestRepository.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), - ) - } - } -} - -@AssistedFactory -@ViewModelScoped -interface ChooserRequestUpdateInteractorFactory { - fun create( - chooserRequestRepository: MutableStateFlow - ): ChooserRequestUpdateInteractor -} 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/ChooserRequest.kt b/java/src/com/android/intentresolver/v2/ui/model/ChooserRequest.kt deleted file mode 100644 index 4f3cf3cd..00000000 --- a/java/src/com/android/intentresolver/v2/ui/model/ChooserRequest.kt +++ /dev/null @@ -1,209 +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.v2.ui.model - -import android.content.ComponentName -import android.content.Intent -import android.content.Intent.ACTION_SEND -import android.content.Intent.ACTION_SEND_MULTIPLE -import android.content.Intent.EXTRA_REFERRER -import android.content.IntentFilter -import android.content.IntentSender -import android.net.Uri -import android.os.Bundle -import android.service.chooser.ChooserAction -import android.service.chooser.ChooserTarget -import androidx.annotation.StringRes -import com.android.intentresolver.ContentTypeHint -import com.android.intentresolver.v2.ext.hasAction - -const val ANDROID_APP_SCHEME = "android-app" - -/** All of the things that are consumed from an incoming share Intent (+Extras). */ -data class ChooserRequest( - /** Required. Represents the content being sent. */ - val targetIntent: Intent, - - /** The action from [targetIntent] as retrieved with [Intent.getAction]. */ - val targetAction: String?, - - /** - * Whether [targetAction] is ACTION_SEND or ACTION_SEND_MULTIPLE. These are considered the - * canonical "Share" actions. When handling other actions, this flag controls behavioral and - * visual changes. - */ - val isSendActionTarget: Boolean, - - /** The top-level content type as retrieved using [Intent.getType]. */ - val targetType: String?, - - /** The package name of the app which started the current activity instance. */ - val launchedFromPackage: String, - - /** A custom tile for the main UI. Ignored when the intent is ACTION_SEND(_MULTIPLE). */ - val title: CharSequence? = null, - - /** A String resource ID to load when [title] is null. */ - @get:StringRes val defaultTitleResource: Int = 0, - - /** - * The referrer value as received by the caller. It may have been supplied via [EXTRA_REFERRER] - * or synthesized from callerPackageName. This value is merged into outgoing intents. - */ - val referrer: Uri?, - - /** - * Choices to exclude from results. - * - * Any resolved intents with a component in this list will be omitted before presentation. - */ - val filteredComponentNames: List = emptyList(), - - /** - * App provided shortcut share intents (aka "direct share targets") - * - * Normally share shortcuts are published and consumed using - * [ShortcutManager][android.content.pm.ShortcutManager]. This is an alternate channel to allow - * apps to directly inject the same information. - * - * Historical note: This option was initially integrated with other results from the - * ChooserTargetService API (since deprecated and removed), hence the name and data format. - * These are more correctly called "Share Shortcuts" now. - */ - val callerChooserTargets: List = emptyList(), - - /** - * Actions the user may perform. These are presented as separate affordances from the main list - * of choices. Selecting a choice is a terminal action which results in finishing. The item - * limit is [MAX_CHOOSER_ACTIONS]. This may be further constrained as appropriate. - */ - val chooserActions: List = emptyList(), - - /** - * An action to start an Activity which for user updating of shared content. Selection is a - * terminal action, closing the current activity and launching the target of the action. - */ - val modifyShareAction: ChooserAction? = null, - - /** - * When false the host activity will be [finished][android.app.Activity.finish] when stopped. - */ - @get:JvmName("shouldRetainInOnStop") val shouldRetainInOnStop: Boolean = false, - - /** - * Intents which contain alternate representations of the content being shared. Any results from - * resolving these _alternate_ intents are included with the results of the primary intent as - * additional choices (e.g. share as image content vs. link to content). - */ - val additionalTargets: List = emptyList(), - - /** - * Alternate [extras][Intent.getExtras] to substitute when launching a selected app. - * - * For a given app (by package name), the Bundle describes what parameters to substitute when - * that app is selected. - * - * // TODO: Map - */ - val replacementExtras: Bundle? = null, - - /** - * App-supplied choices to be presented first in the list. - * - * Custom labels and icons may be supplied using - * [LabeledIntent][android.content.pm.LabeledIntent]. - * - * Limit 2. - */ - val initialIntents: List = emptyList(), - - /** - * Provides for callers to be notified when a component is selected. - * - * The selection is reported in the Intent as [Intent.EXTRA_CHOSEN_COMPONENT] with the - * [ComponentName] of the item. - */ - val chosenComponentSender: IntentSender? = null, - - /** - * Provides a mechanism for callers to post-process a target when a selection is made. - * - * The received intent will contain: - * * **EXTRA_INTENT** The chosen target - * * **EXTRA_ALTERNATE_INTENTS** Additional intents which also match the target - * * **EXTRA_RESULT_RECEIVER** A [ResultReceiver][android.os.ResultReceiver] providing a - * mechanism for the caller to return information. An updated intent to send must be included - * as [Intent.EXTRA_INTENT]. - */ - val refinementIntentSender: IntentSender? = null, - - /** - * Contains the text content to share supplied by the source app. - * - * TODO: Constrain length? - */ - val sharedText: CharSequence? = null, - - /** - * Supplied to - * [ShortcutManager.getShareTargets][android.content.pm.ShortcutManager.getShareTargets] to - * query for matching shortcuts. Specifically, only the [dataTypes][IntentFilter.hasDataType] - * are considered for matching share shortcuts currently. - */ - val shareTargetFilter: IntentFilter? = null, - - /** A URI for additional content */ - val additionalContentUri: Uri? = null, - - /** Focused item index (from target intent's STREAM_EXTRA) */ - val focusedItemPosition: Int = 0, - - /** Value for [Intent.EXTRA_CHOOSER_CONTENT_TYPE_HINT] on the incoming chooser intent. */ - val contentTypeHint: ContentTypeHint = ContentTypeHint.NONE, - - /** - * Metadata to be shown to the user as a part of the sharesheet window. - * - * Specified by the [Intent.EXTRA_METADATA_TEXT] - */ - val metadataText: CharSequence? = null, -) { - val referrerPackage = referrer?.takeIf { it.scheme == ANDROID_APP_SCHEME }?.authority - - fun getReferrerFillInIntent(): Intent { - return Intent().apply { - referrerPackage?.also { pkg -> - putExtra(EXTRA_REFERRER, Uri.parse("$ANDROID_APP_SCHEME://$pkg")) - } - } - } - - val payloadIntents = listOf(targetIntent) + additionalTargets - - /** Constructs an instance from only the required values. */ - constructor( - targetIntent: Intent, - launchedFromPackage: String, - referrer: Uri? - ) : this( - targetIntent = targetIntent, - targetAction = targetIntent.action, - isSendActionTarget = targetIntent.hasAction(ACTION_SEND, ACTION_SEND_MULTIPLE), - targetType = targetIntent.type, - launchedFromPackage = launchedFromPackage, - referrer = referrer - ) -} 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 7ebf65a9..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 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 4431a545..e39329b1 100644 --- a/java/src/com/android/intentresolver/v2/ui/viewmodel/ChooserViewModel.kt +++ b/java/src/com/android/intentresolver/v2/ui/viewmodel/ChooserViewModel.kt @@ -20,21 +20,21 @@ 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.UpdateTargetIntentInteractor +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.domain.interactor.ChooserRequestUpdateInteractorFactory +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 dagger.Lazy import dagger.hilt.android.lifecycle.HiltViewModel import javax.inject.Inject import kotlinx.coroutines.CoroutineDispatcher -import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.launch @@ -47,11 +47,17 @@ class ChooserViewModel constructor( args: SavedStateHandle, private val shareouselViewModelProvider: Lazy, - private val updateTargetIntentInteractor: Lazy, + private val processUpdatesInteractor: Lazy, private val fetchPreviewsInteractor: Lazy, @Background private val bgDispatcher: CoroutineDispatcher, - private val chooserRequestUpdateInteractorFactory: ChooserRequestUpdateInteractorFactory, 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, + private val chooserRequestRepository: Lazy, ) : ViewModel() { /** Parcelable-only references provided from the creating Activity */ @@ -60,43 +66,29 @@ constructor( "ActivityModel missing in SavedStateHandle! ($ACTIVITY_MODEL_KEY)" } - val shareouselViewModel by lazy { + 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) { updateTargetIntentInteractor.get().launch() } - viewModelScope.launch(bgDispatcher) { fetchPreviewsInteractor.get().launch() } - viewModelScope.launch { chooserRequestUpdateInteractorFactory.create(_request).launch() } + viewModelScope.launch(bgDispatcher) { processUpdatesInteractor.get().activate() } + viewModelScope.launch(bgDispatcher) { fetchPreviewsInteractor.get().activate() } shareouselViewModelProvider.get() } - /** - * Provided only for the express purpose of early exit in the event of an invalid request. - * - * Note: [request] can only be safely accessed after checking if this value is [Valid]. - */ - internal val initialRequest = readChooserRequest(activityModel, flags) - - private lateinit var _request: MutableStateFlow - /** * A [StateFlow] of [ChooserRequest]. * * Note: Only safe to access after checking if [initialRequest] is [Valid]. */ - lateinit var request: StateFlow - private set + val request: StateFlow + get() = chooserRequestRepository.get().chooserRequest.asStateFlow() init { - when (initialRequest) { - is Valid -> { - _request = MutableStateFlow(initialRequest.value) - request = _request.asStateFlow() - } - is Invalid -> Log.w(TAG, "initialRequest is Invalid, initialization failed") + if (initialRequest is Invalid) { + Log.w(TAG, "initialRequest is Invalid, initialization failed") } } } diff --git a/tests/unit/src/com/android/intentresolver/contentpreview/payloadtoggle/data/repository/PreviewSelectionsRepositoryTest.kt b/tests/unit/src/com/android/intentresolver/contentpreview/payloadtoggle/data/repository/PreviewSelectionsRepositoryTest.kt deleted file mode 100644 index 48c8b58a..00000000 --- a/tests/unit/src/com/android/intentresolver/contentpreview/payloadtoggle/data/repository/PreviewSelectionsRepositoryTest.kt +++ /dev/null @@ -1,43 +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.payloadtoggle.data.repository - -import android.net.Uri -import com.android.intentresolver.contentpreview.payloadtoggle.data.model.SelectionRecordType.Initial -import com.android.intentresolver.contentpreview.payloadtoggle.data.model.SelectionRecordType.Uninitialized -import com.android.intentresolver.contentpreview.payloadtoggle.data.model.SelectionRecordType.Updated -import com.android.intentresolver.contentpreview.payloadtoggle.shared.model.PreviewModel -import com.google.common.truth.Truth.assertThat -import org.junit.Test - -class PreviewSelectionsRepositoryTest { - - @Test - fun testSelectionStatus() { - val testSubject = PreviewSelectionsRepository() - - assertThat(testSubject.selections.value.type).isEqualTo(Uninitialized) - - testSubject.setSelection(setOf(PreviewModel(Uri.parse("content://pkg/1.png"), "image/png"))) - - assertThat(testSubject.selections.value.type).isEqualTo(Initial) - - testSubject.select(PreviewModel(Uri.parse("content://pkg/2.png"), "image/png")) - - assertThat(testSubject.selections.value.type).isEqualTo(Updated) - } -} diff --git a/tests/unit/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/intent/TargetIntentModifierImplTest.kt b/tests/unit/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/intent/TargetIntentModifierImplTest.kt index f4be47ed..7c36ef55 100644 --- a/tests/unit/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/intent/TargetIntentModifierImplTest.kt +++ b/tests/unit/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/intent/TargetIntentModifierImplTest.kt @@ -32,14 +32,14 @@ class TargetIntentModifierImplTest { 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) } @@ -52,20 +52,22 @@ class TargetIntentModifierImplTest { 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/CustomActionsInteractorTest.kt b/tests/unit/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/interactor/CustomActionsInteractorTest.kt index 95ad966e..ceb20dab 100644 --- 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 @@ -19,16 +19,16 @@ package com.android.intentresolver.contentpreview.payloadtoggle.domain.interactor import android.app.Activity -import android.content.Intent import android.graphics.Bitmap import android.graphics.drawable.Icon import com.android.intentresolver.contentpreview.payloadtoggle.data.model.CustomActionModel import com.android.intentresolver.contentpreview.payloadtoggle.data.repository.ActivityResultRepository -import com.android.intentresolver.contentpreview.payloadtoggle.data.repository.TargetIntentRepository 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 @@ -47,7 +47,14 @@ class CustomActionsInteractorTest { runTest(testDispatcher) { val bitmap = Bitmap.createBitmap(100, 100, Bitmap.Config.ALPHA_8) val icon = Icon.createWithBitmap(bitmap) - val chooserActions = listOf(CustomActionModel("label1", icon) {}) + val chooserRequestRepository = + ChooserRequestRepository( + initialRequest = fakeChooserRequest(), + initialActions = + listOf( + CustomActionModel(label = "label1", icon = icon, performAction = {}), + ), + ) val underTest = CustomActionsInteractor( activityResultRepo = ActivityResultRepository(), @@ -55,11 +62,8 @@ class CustomActionsInteractorTest { contentResolver = mock {}, eventLog = mock {}, packageManager = mock {}, - targetIntentRepo = - TargetIntentRepository( - initialIntent = Intent(), - initialActions = chooserActions, - ), + chooserRequestInteractor = + ChooserRequestInteractor(repository = chooserRequestRepository), ) val customActions: StateFlow> = underTest.customActions.stateIn(backgroundScope) @@ -80,9 +84,9 @@ class CustomActionsInteractorTest { @Test fun customActions_tracksRepoUpdates() = runTest(testDispatcher) { - val targetIntentRepository = - TargetIntentRepository( - initialIntent = Intent(), + val chooserRequestRepository = + ChooserRequestRepository( + initialRequest = fakeChooserRequest(), initialActions = emptyList(), ) val underTest = @@ -92,7 +96,8 @@ class CustomActionsInteractorTest { contentResolver = mock {}, eventLog = mock {}, packageManager = mock {}, - targetIntentRepo = targetIntentRepository, + chooserRequestInteractor = + ChooserRequestInteractor(repository = chooserRequestRepository), ) val customActions: StateFlow> = @@ -100,7 +105,7 @@ class CustomActionsInteractorTest { val bitmap = Bitmap.createBitmap(100, 100, Bitmap.Config.ALPHA_8) val icon = Icon.createWithBitmap(bitmap) val chooserActions = listOf(CustomActionModel("label1", icon) {}) - targetIntentRepository.customActions.value = chooserActions + chooserRequestRepository.customActions.value = chooserActions runCurrent() assertThat(customActions.value) @@ -123,8 +128,19 @@ class CustomActionsInteractorTest { val bitmap = Bitmap.createBitmap(100, 100, Bitmap.Config.ALPHA_8) val icon = Icon.createWithBitmap(bitmap) var actionSent = false - val chooserActions = listOf(CustomActionModel("label1", icon) { actionSent = true }) val activityResultRepository = ActivityResultRepository() + val chooserRequestRepository = + ChooserRequestRepository( + initialRequest = fakeChooserRequest(), + initialActions = + listOf( + CustomActionModel( + label = "label1", + icon = icon, + performAction = { actionSent = true }, + ) + ), + ) val underTest = CustomActionsInteractor( activityResultRepo = activityResultRepository, @@ -132,10 +148,9 @@ class CustomActionsInteractorTest { contentResolver = mock {}, eventLog = mock {}, packageManager = mock {}, - targetIntentRepo = - TargetIntentRepository( - initialIntent = Intent(), - initialActions = chooserActions, + chooserRequestInteractor = + ChooserRequestInteractor( + repository = chooserRequestRepository, ), ) val customActions: StateFlow> = 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 index 9317f798..08a667b9 100644 --- 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 @@ -121,7 +121,7 @@ class FetchPreviewsInteractorTest { @Test fun setsInitialPreviews() = runTestWithDeps { deps -> - backgroundScope.launch { deps.underTest.launch() } + backgroundScope.launch { deps.underTest.activate() } runCurrent() assertThat(deps.previewsRepo.previewsModel.value) @@ -147,7 +147,7 @@ class FetchPreviewsInteractorTest { @Test fun lookupCursorFromContentResolver() = runTestWithDeps { deps -> - backgroundScope.launch { deps.underTest.launch() } + backgroundScope.launch { deps.underTest.activate() } deps.cursorResolver.complete() runCurrent() @@ -173,7 +173,7 @@ class FetchPreviewsInteractorTest { pageSize = 16, maxLoadedPages = 1, ) { deps -> - backgroundScope.launch { deps.underTest.launch() } + backgroundScope.launch { deps.underTest.activate() } deps.cursorResolver.complete() runCurrent() @@ -205,7 +205,7 @@ class FetchPreviewsInteractorTest { pageSize = 16, maxLoadedPages = 2, ) { deps -> - backgroundScope.launch { deps.underTest.launch() } + backgroundScope.launch { deps.underTest.activate() } deps.cursorResolver.complete() runCurrent() @@ -237,7 +237,7 @@ class FetchPreviewsInteractorTest { pageSize = 16, maxLoadedPages = 1, ) { deps -> - backgroundScope.launch { deps.underTest.launch() } + backgroundScope.launch { deps.underTest.activate() } deps.cursorResolver.complete() runCurrent() @@ -268,7 +268,7 @@ class FetchPreviewsInteractorTest { pageSize = 16, maxLoadedPages = 2, ) { deps -> - backgroundScope.launch { deps.underTest.launch() } + backgroundScope.launch { deps.underTest.activate() } deps.cursorResolver.complete() runCurrent() @@ -299,7 +299,7 @@ class FetchPreviewsInteractorTest { pageSize = 16, maxLoadedPages = 2, ) { deps -> - backgroundScope.launch { deps.underTest.launch() } + backgroundScope.launch { deps.underTest.activate() } deps.cursorResolver.complete() runCurrent() @@ -320,7 +320,7 @@ class FetchPreviewsInteractorTest { pageSize = 16, maxLoadedPages = 2, ) { deps -> - backgroundScope.launch { deps.underTest.launch() } + backgroundScope.launch { deps.underTest.activate() } deps.cursorResolver.complete() runCurrent() 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 index 3dba5329..ff22f37b 100644 --- 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 @@ -18,11 +18,13 @@ package com.android.intentresolver.contentpreview.payloadtoggle.domain.interactor +import android.content.Intent 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 @@ -34,74 +36,113 @@ class SelectablePreviewInteractorTest { @Test fun reflectPreviewRepo_initState() = 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() + val chooserRequestRepo = + ChooserRequestRepository( + initialRequest = fakeChooserRequest(), + initialActions = emptyList(), + ) + val pendingSelectionCallbackRepo = PendingSelectionCallbackRepository() val underTest = SelectablePreviewInteractor( key = PreviewModel(Uri.fromParts("scheme", "ssp", "fragment"), null), - selectionRepo = selectionRepo, + selectionInteractor = + SelectionInteractor( + selectionsRepo = selectionRepo, + targetIntentModifier = { error("unexpected invocation") }, + updateTargetIntentInteractor = + UpdateTargetIntentInteractor( + repository = pendingSelectionCallbackRepo, + chooserRequestInteractor = + UpdateChooserRequestInteractor( + repository = chooserRequestRepo, + pendingIntentSender = { error("unexpected invocation") }, + ) + ) + ), ) - selectionRepo.setSelection(emptySet()) - testScheduler.runCurrent() + runCurrent() assertThat(underTest.isSelected.first()).isFalse() } @Test fun reflectPreviewRepo_updatedState() = runTest { - val repo = CursorPreviewsRepository() - val selectionRepository = PreviewSelectionsRepository() + 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"), - selectionRepo = selectionRepository, + selectionInteractor = + SelectionInteractor( + selectionsRepo = selectionRepo, + targetIntentModifier = { error("unexpected invocation") }, + updateTargetIntentInteractor = + UpdateTargetIntentInteractor( + repository = pendingSelectionCallbackRepo, + chooserRequestInteractor = + UpdateChooserRequestInteractor( + repository = chooserRequestRepo, + pendingIntentSender = { error("unexpected invocation") }, + ) + ) + ), ) - selectionRepository.setSelection(emptySet()) assertThat(underTest.isSelected.first()).isFalse() - repo.previewsModel.value = - PreviewsModel( - previewModels = - setOf( - PreviewModel( - Uri.fromParts("scheme", "ssp", "fragment"), - "image/bitmap", - ), - PreviewModel( - Uri.fromParts("scheme2", "ssp2", "fragment2"), - "image/bitmap", - ), + 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") }, + ) + ) ), - startIdx = 0, - loadMoreLeft = null, - loadMoreRight = null, ) - selectionRepository.setSelection( - setOf(PreviewModel(Uri.fromParts("scheme", "ssp", "fragment"), "image/bitmap")) - ) + underTest.setSelected(true) runCurrent() - assertThat(underTest.isSelected.first()).isTrue() + 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 index a5d09f56..3f02c0cd 100644 --- 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 @@ -20,9 +20,12 @@ package com.android.intentresolver.contentpreview.payloadtoggle.domain.interacto 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 @@ -57,16 +60,33 @@ class SelectablePreviewsInteractorTest { } val selectionRepo = PreviewSelectionsRepository().apply { - setSelection( + selections.value = setOf( PreviewModel(Uri.fromParts("scheme", "ssp", "fragment"), null), ) - ) } + val chooserRequestRepo = + ChooserRequestRepository( + initialRequest = fakeChooserRequest(), + initialActions = emptyList(), + ) val underTest = SelectablePreviewsInteractor( previewsRepo = repo, - selectionRepo = selectionRepo, + 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) @@ -95,9 +115,35 @@ class SelectablePreviewsInteractorTest { val previewsRepo = CursorPreviewsRepository() val selectionRepo = PreviewSelectionsRepository().apply { - setSelection(setOf(PreviewModel(Uri.fromParts("scheme", "ssp", "fragment"), null))) + selections.value = + setOf( + PreviewModel(Uri.fromParts("scheme", "ssp", "fragment"), null), + ) } - val underTest = SelectablePreviewsInteractor(previewsRepo, selectionRepo) + 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)) @@ -124,7 +170,7 @@ class SelectablePreviewsInteractorTest { loadMoreLeft = null, loadMoreRight = { loadRequested = true }, ) - selectionRepo.setSelection(emptySet()) + selectionRepo.selections.value = emptySet() runCurrent() assertThat(previews.value).isNotNull() 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/payloadtoggle/domain/interactor/UpdateTargetIntentInteractorTest.kt b/tests/unit/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/interactor/UpdateTargetIntentInteractorTest.kt deleted file mode 100644 index 3f437b22..00000000 --- a/tests/unit/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/interactor/UpdateTargetIntentInteractorTest.kt +++ /dev/null @@ -1,98 +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. - */ - -@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.ChooserParamsUpdateRepository -import com.android.intentresolver.contentpreview.payloadtoggle.data.repository.PreviewSelectionsRepository -import com.android.intentresolver.contentpreview.payloadtoggle.data.repository.TargetIntentRepository -import com.android.intentresolver.contentpreview.payloadtoggle.domain.model.ShareouselUpdate -import com.android.intentresolver.contentpreview.payloadtoggle.shared.model.PreviewModel -import com.google.common.truth.Truth.assertThat -import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.flow.filterNotNull -import kotlinx.coroutines.flow.first -import kotlinx.coroutines.launch -import kotlinx.coroutines.test.runCurrent -import kotlinx.coroutines.test.runTest -import org.junit.Test - -class UpdateTargetIntentInteractorTest { - @Test - fun updateTargetIntentWithSelection() = runTest { - val initialIntent = Intent() - val intentRepository = TargetIntentRepository(initialIntent, emptyList()) - val selectionRepository = PreviewSelectionsRepository() - val chooserParamsUpdateRepository = ChooserParamsUpdateRepository() - val selectionCallbackResult = ShareouselUpdate() - val underTest = - UpdateTargetIntentInteractor( - intentRepository = intentRepository, - chooserParamsUpdateRepository = chooserParamsUpdateRepository, - selectionCallback = { selectionCallbackResult }, - selectionRepo = selectionRepository, - targetIntentModifier = { selection -> - Intent() - .putParcelableArrayListExtra( - "selection", - selection.mapTo(ArrayList()) { it.uri }, - ) - }, - pendingIntentSender = {}, - ) - - backgroundScope.launch { underTest.launch() } - selectionRepository.setSelection( - setOf( - PreviewModel(Uri.fromParts("scheme0", "ssp0", "fragment0"), null), - PreviewModel(Uri.fromParts("scheme1", "ssp1", "fragment1"), null), - ) - ) - runCurrent() - - // only changes in selection should trigger intent updates - assertThat( - intentRepository.targetIntent.value.intent.getParcelableArrayListExtra( - "selection", - Uri::class.java, - ) - ) - .isNull() - - selectionRepository.select( - PreviewModel(Uri.fromParts("scheme2", "ssp2", "fragment2"), null), - ) - runCurrent() - - assertThat( - intentRepository.targetIntent.value.intent.getParcelableArrayListExtra( - "selection", - Uri::class.java, - ) - ) - .containsExactly( - Uri.fromParts("scheme0", "ssp0", "fragment0"), - Uri.fromParts("scheme1", "ssp1", "fragment1"), - Uri.fromParts("scheme2", "ssp2", "fragment2"), - ) - assertThat(chooserParamsUpdateRepository.updates.filterNotNull().first()) - .isEqualTo(selectionCallbackResult) - } -} 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 index 24035c54..5d95df04 100644 --- 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 @@ -30,17 +30,24 @@ 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.data.repository.TargetIntentRepository +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 @@ -54,30 +61,75 @@ import org.junit.Test class ShareouselViewModelTest { - class Dependencies { + class Dependencies( + val pendingIntentSender: PendingIntentSender, + val targetIntentModifier: TargetIntentModifier, + ) { val testDispatcher = StandardTestDispatcher() val testScope = TestScope(testDispatcher) val previewsRepository = CursorPreviewsRepository() val selectionRepository = PreviewSelectionsRepository().apply { - setSelection(setOf(PreviewModel(Uri.fromParts("scheme", "ssp", "fragment"), null))) + selections.value = + setOf(PreviewModel(Uri.fromParts("scheme", "ssp", "fragment"), null)) } val activityResultRepository = ActivityResultRepository() val contentResolver = mock {} val packageManager = mock {} val eventLog = FakeEventLog(instanceId = InstanceId.fakeInstanceId(1)) - val targetIntentRepo = - TargetIntentRepository( - initialIntent = Intent(), - initialActions = listOf(), + 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 = - SelectablePreviewsInteractor( - previewsRepo = previewsRepository, - selectionRepo = selectionRepository - ), + interactor = previewsInteractor, imageLoader = FakeImageLoader( initialBitmaps = @@ -86,15 +138,7 @@ class ShareouselViewModelTest { Bitmap.createBitmap(100, 100, Bitmap.Config.ALPHA_8) ) ), - actionsInteractor = - CustomActionsInteractor( - activityResultRepo = activityResultRepository, - bgDispatcher = testDispatcher, - contentResolver = contentResolver, - eventLog = eventLog, - packageManager = packageManager, - targetIntentRepo = targetIntentRepo, - ), + actionsInteractor = actionsInteractor, headlineGenerator = object : HeadlineGenerator { override fun getImagesHeadline(count: Int): String = "IMAGES: $count" @@ -123,18 +167,19 @@ class ShareouselViewModelTest { override fun getFilesHeadline(count: Int): String = error("not supported") }, - selectionInteractor = - SelectionInteractor( - selectionRepo = selectionRepository, - ), + selectionInteractor = selectionInteractor, scope = testScope.backgroundScope, ) } private inline fun runTestWithDeps( + pendingIntentSender: PendingIntentSender = PendingIntentSender {}, + targetIntentModifier: TargetIntentModifier = TargetIntentModifier { + error("unexpected invocation") + }, crossinline block: suspend TestScope.(Dependencies) -> Unit, ): Unit = - Dependencies().run { + Dependencies(pendingIntentSender, targetIntentModifier).run { testScope.runTest { runCurrent() block(this@run) @@ -145,7 +190,7 @@ class ShareouselViewModelTest { fun headline() = runTestWithDeps { deps -> with(deps) { assertThat(underTest.headline.first()).isEqualTo("IMAGES: 1") - selectionRepository.setSelection( + selectionRepository.selections.value = setOf( PreviewModel( Uri.fromParts("scheme", "ssp", "fragment"), @@ -156,88 +201,105 @@ class ShareouselViewModelTest { null, ) ) - ) runCurrent() assertThat(underTest.headline.first()).isEqualTo("IMAGES: 2") } } @Test - fun previews() = runTestWithDeps { deps -> - with(deps) { - previewsRepository.previewsModel.value = - PreviewsModel( - previewModels = - setOf( - PreviewModel( - Uri.fromParts("scheme", "ssp", "fragment"), - null, + 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, + ) ), - PreviewModel( - Uri.fromParts("scheme1", "ssp1", "fragment1"), - null, - ) - ), - startIdx = 1, - loadMoreLeft = null, - loadMoreRight = null, - ) - runCurrent() + 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() + 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)) + 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() + assertWithMessage("preview bitmap is null") + .that(previewVm.bitmap.first()) + .isNotNull() + assertThat(previewVm.isSelected.first()).isFalse() - previewVm.setSelected(true) + previewVm.setSelected(true) - assertThat(selectionRepository.selections.first().selection) - .comparingElementsUsingTransform("has uri of") { model: PreviewModel -> model.uri } - .contains(Uri.fromParts("scheme1", "ssp1", "fragment1")) + 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() + 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 - targetIntentRepo.customActions.value = - listOf(CustomActionModel("label1", icon) { actionSent = true }) - runCurrent() + 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() - 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) + 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/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/domain/interactor/ChooserRequestUpdateInteractorTest.kt b/tests/unit/src/com/android/intentresolver/v2/domain/interactor/ChooserRequestUpdateInteractorTest.kt deleted file mode 100644 index d05a0a91..00000000 --- a/tests/unit/src/com/android/intentresolver/v2/domain/interactor/ChooserRequestUpdateInteractorTest.kt +++ /dev/null @@ -1,204 +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.domain.interactor - -import android.content.Intent -import android.content.Intent.ACTION_SEND -import android.content.Intent.ACTION_SEND_MULTIPLE -import android.content.Intent.EXTRA_STREAM -import android.content.IntentSender -import android.net.Uri -import com.android.intentresolver.contentpreview.payloadtoggle.data.repository.ChooserParamsUpdateRepository -import com.android.intentresolver.contentpreview.payloadtoggle.data.repository.TargetIntentRepository -import com.android.intentresolver.contentpreview.payloadtoggle.domain.model.ShareouselUpdate -import com.android.intentresolver.contentpreview.payloadtoggle.domain.model.ValueUpdate -import com.android.intentresolver.mock -import com.android.intentresolver.v2.ui.model.ChooserRequest -import com.google.common.truth.Truth.assertThat -import com.google.common.truth.Truth.assertWithMessage -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.launch -import kotlinx.coroutines.test.TestScope -import kotlinx.coroutines.test.runTest -import org.junit.Test - -class ChooserRequestUpdateInteractorTest { - private val targetIntent = - Intent(ACTION_SEND_MULTIPLE).apply { - putExtra( - EXTRA_STREAM, - ArrayList().apply { - add(createUri(1)) - add(createUri(2)) - } - ) - type = "image/png" - } - val initialRequest = createSomeChooserRequest(targetIntent) - private val targetIntentRepository = - TargetIntentRepository( - targetIntent, - emptyList(), - ) - private val chooserParamsUpdateRepository = ChooserParamsUpdateRepository() - private val testScope = TestScope() - - @Test - fun testInitialIntentOnly_noUpdates() = - testScope.runTest { - val requestFlow = MutableStateFlow(initialRequest) - val testSubject = - ChooserRequestUpdateInteractor( - targetIntentRepository, - chooserParamsUpdateRepository, - requestFlow, - ) - backgroundScope.launch { testSubject.launch() } - testScheduler.runCurrent() - - assertWithMessage("No updates expected") - .that(requestFlow.value) - .isSameInstanceAs(initialRequest) - } - - @Test - fun testIntentUpdate_newRequestPublished() = - testScope.runTest { - val requestFlow = MutableStateFlow(initialRequest) - val testSubject = - ChooserRequestUpdateInteractor( - targetIntentRepository, - chooserParamsUpdateRepository, - requestFlow, - ) - backgroundScope.launch { testSubject.launch() } - targetIntentRepository.updateTargetIntent( - Intent(targetIntent).apply { - action = ACTION_SEND - putExtra(EXTRA_STREAM, createUri(2)) - } - ) - testScheduler.runCurrent() - - assertWithMessage("Another chooser request is expected") - .that(requestFlow.value) - .isNotEqualTo(initialRequest) - } - - @Test - fun testChooserParamsUpdate_newRequestPublished() = - testScope.runTest { - val requestFlow = MutableStateFlow(initialRequest) - val testSubject = - ChooserRequestUpdateInteractor( - targetIntentRepository, - chooserParamsUpdateRepository, - requestFlow, - ) - backgroundScope.launch { testSubject.launch() } - val newResultSender = mock() - chooserParamsUpdateRepository.setUpdates( - ShareouselUpdate( - resultIntentSender = ValueUpdate.Value(newResultSender), - ) - ) - testScheduler.runCurrent() - - assertWithMessage("Another chooser request is expected") - .that(requestFlow.value) - .isNotEqualTo(initialRequest) - - assertWithMessage("Another chooser request is expected") - .that(requestFlow.value.chosenComponentSender) - .isSameInstanceAs(newResultSender) - } - - @Test - fun testTargetIntentUpdateDoesNotOverrideOtherParameters() = - testScope.runTest { - val requestFlow = MutableStateFlow(initialRequest) - val testSubject = - ChooserRequestUpdateInteractor( - targetIntentRepository, - chooserParamsUpdateRepository, - requestFlow, - ) - backgroundScope.launch { testSubject.launch() } - - val newResultSender = mock() - val newTargetIntent = Intent(Intent.ACTION_VIEW) - chooserParamsUpdateRepository.setUpdates( - ShareouselUpdate( - resultIntentSender = ValueUpdate.Value(newResultSender), - ) - ) - testScheduler.runCurrent() - targetIntentRepository.updateTargetIntent(newTargetIntent) - testScheduler.runCurrent() - - assertThat(requestFlow.value.targetIntent).isSameInstanceAs(newTargetIntent) - - assertThat(requestFlow.value.chosenComponentSender).isSameInstanceAs(newResultSender) - } - - @Test - fun testUpdateWithNullValues() = - testScope.runTest { - val initialRequest = - ChooserRequest( - targetIntent = targetIntent, - targetAction = targetIntent.action, - isSendActionTarget = true, - targetType = null, - launchedFromPackage = "", - referrer = null, - refinementIntentSender = mock(), - chosenComponentSender = mock(), - ) - val requestFlow = MutableStateFlow(initialRequest) - val testSubject = - ChooserRequestUpdateInteractor( - targetIntentRepository, - chooserParamsUpdateRepository, - requestFlow, - ) - backgroundScope.launch { testSubject.launch() } - - chooserParamsUpdateRepository.setUpdates( - ShareouselUpdate( - resultIntentSender = ValueUpdate.Value(null), - refinementIntentSender = ValueUpdate.Value(null), - ) - ) - testScheduler.runCurrent() - - assertThat(requestFlow.value.chosenComponentSender).isNull() - assertThat(requestFlow.value.refinementIntentSender).isNull() - } -} - -private fun createSomeChooserRequest(targetIntent: Intent) = - ChooserRequest( - targetIntent = targetIntent, - targetAction = targetIntent.action, - isSendActionTarget = true, - targetType = null, - launchedFromPackage = "", - referrer = null, - ) - -private fun createUri(id: Int) = Uri.parse("content://org.pkg.app/image-$id.png") 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 -- cgit v1.2.3-59-g8ed1b From 7f2fc4418c68dbdcf8f6766bde2180749150d549 Mon Sep 17 00:00:00 2001 From: Mark Renouf Date: Mon, 1 Apr 2024 17:31:07 -0400 Subject: Add @Broadcast Handler, broadcastFlow() -> BroadcastSubscriber Provides a background handler (thread) for Broadcast delivery. Currently, broadcasts are flowed on a @Background CoroutineDispatcher but before that, they are delivered to the app via the Main thread Handler. This means broadcasts being processed during startup will get deferred until Main thread idle (generally, right after onResume) This change improves on broadcastFlow, providing a broadcast flow factory (BroadcastSubscriber), which registers Receivers with a singleton @Broadcast Handler. This makes broadcast delivery much more reliable and predictable. Test: manually. Observe broadcasts received while the process is frozen are now handled immediately after the unfreeze, and no longer blocked until after onResume. Bug: 330561320 Flag: ACONFIG intentresolver/com.android.intentresolver.enable_private_profile Change-Id: I8ecf151d3bf7627d9e3917fb9fecd78c1e201521 --- .../intentresolver/inject/ConcurrencyModule.kt | 29 +++++++++ .../android/intentresolver/inject/Qualifiers.kt | 2 + .../intentresolver/v2/data/BroadcastFlow.kt | 46 -------------- .../intentresolver/v2/data/BroadcastSubscriber.kt | 73 ++++++++++++++++++++++ .../v2/data/repository/UserRepository.kt | 60 +++++++++--------- 5 files changed, 134 insertions(+), 76 deletions(-) delete mode 100644 java/src/com/android/intentresolver/v2/data/BroadcastFlow.kt create mode 100644 java/src/com/android/intentresolver/v2/data/BroadcastSubscriber.kt (limited to 'java/src') diff --git a/java/src/com/android/intentresolver/inject/ConcurrencyModule.kt b/java/src/com/android/intentresolver/inject/ConcurrencyModule.kt index e0f8e88b..5fbdf090 100644 --- a/java/src/com/android/intentresolver/inject/ConcurrencyModule.kt +++ b/java/src/com/android/intentresolver/inject/ConcurrencyModule.kt @@ -16,6 +16,10 @@ package com.android.intentresolver.inject +import android.os.Handler +import android.os.HandlerThread +import android.os.Looper +import android.os.Process import dagger.Module import dagger.Provides import dagger.hilt.InstallIn @@ -26,6 +30,10 @@ import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob +// thread +private const val BROADCAST_SLOW_DISPATCH_THRESHOLD = 1000L +private const val BROADCAST_SLOW_DELIVERY_THRESHOLD = 1000L + @Module @InstallIn(SingletonComponent::class) object ConcurrencyModule { @@ -40,4 +48,25 @@ object ConcurrencyModule { CoroutineScope(SupervisorJob() + mainDispatcher) @Provides @Background fun backgroundDispatcher(): CoroutineDispatcher = Dispatchers.IO + + @Provides + @Singleton + @Broadcast + fun provideBroadcastLooper(): Looper { + val thread = HandlerThread("BroadcastReceiver", Process.THREAD_PRIORITY_BACKGROUND) + thread.start() + thread.looper.setSlowLogThresholdMs( + BROADCAST_SLOW_DISPATCH_THRESHOLD, + BROADCAST_SLOW_DELIVERY_THRESHOLD + ) + return thread.looper + } + + /** Provide a BroadcastReceiver Executor (for sending and receiving broadcasts). */ + @Provides + @Singleton + @Broadcast + fun provideBroadcastHandler(@Broadcast looper: Looper): Handler { + return Handler(looper) + } } diff --git a/java/src/com/android/intentresolver/inject/Qualifiers.kt b/java/src/com/android/intentresolver/inject/Qualifiers.kt index f267328b..77315cac 100644 --- a/java/src/com/android/intentresolver/inject/Qualifiers.kt +++ b/java/src/com/android/intentresolver/inject/Qualifiers.kt @@ -35,6 +35,8 @@ annotation class ApplicationOwned @Retention(AnnotationRetention.RUNTIME) annotation class ApplicationUser +@Qualifier @MustBeDocumented @Retention(AnnotationRetention.RUNTIME) annotation class Broadcast + @Qualifier @MustBeDocumented @Retention(AnnotationRetention.RUNTIME) annotation class ProfileParent @Qualifier @MustBeDocumented @Retention(AnnotationRetention.RUNTIME) annotation class Background diff --git a/java/src/com/android/intentresolver/v2/data/BroadcastFlow.kt b/java/src/com/android/intentresolver/v2/data/BroadcastFlow.kt deleted file mode 100644 index 1a58afcb..00000000 --- a/java/src/com/android/intentresolver/v2/data/BroadcastFlow.kt +++ /dev/null @@ -1,46 +0,0 @@ -package com.android.intentresolver.v2.data - -import android.content.BroadcastReceiver -import android.content.Context -import android.content.Intent -import android.content.IntentFilter -import android.os.UserHandle -import android.util.Log -import kotlinx.coroutines.channels.awaitClose -import kotlinx.coroutines.channels.onFailure -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.callbackFlow - -private const val TAG = "BroadcastFlow" - -/** - * Returns a [callbackFlow] that, when collected, registers a broadcast receiver and emits a new - * value whenever broadcast matching _filter_ is received. The result value will be computed using - * [transform] and emitted if non-null. - */ -internal fun broadcastFlow( - context: Context, - filter: IntentFilter, - user: UserHandle, - transform: (Intent) -> T? -): Flow = callbackFlow { - val receiver = - object : BroadcastReceiver() { - override fun onReceive(context: Context, intent: Intent) { - transform(intent)?.also { result -> - trySend(result).onFailure { Log.e(TAG, "Failed to send $result", it) } - } - ?: Log.w(TAG, "Ignored broadcast $intent") - } - } - - context.registerReceiverAsUser( - receiver, - user, - IntentFilter(filter), - null, - null, - Context.RECEIVER_NOT_EXPORTED - ) - awaitClose { context.unregisterReceiver(receiver) } -} diff --git a/java/src/com/android/intentresolver/v2/data/BroadcastSubscriber.kt b/java/src/com/android/intentresolver/v2/data/BroadcastSubscriber.kt new file mode 100644 index 00000000..f3013246 --- /dev/null +++ b/java/src/com/android/intentresolver/v2/data/BroadcastSubscriber.kt @@ -0,0 +1,73 @@ +/* + * Copyright (C) 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.intentresolver.v2.data + +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.content.IntentFilter +import android.os.Handler +import android.os.UserHandle +import android.util.Log +import com.android.intentresolver.inject.Broadcast +import dagger.hilt.android.qualifiers.ApplicationContext +import javax.inject.Inject +import kotlinx.coroutines.channels.awaitClose +import kotlinx.coroutines.channels.onFailure +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.callbackFlow + +private const val TAG = "BroadcastSubscriber" + +class BroadcastSubscriber +@Inject +constructor( + @ApplicationContext private val context: Context, + @Broadcast private val handler: Handler +) { + /** + * Returns a [callbackFlow] that, when collected, registers a broadcast receiver and emits a new + * value whenever broadcast matching _filter_ is received. The result value will be computed + * using [transform] and emitted if non-null. + */ + fun createFlow( + filter: IntentFilter, + user: UserHandle, + transform: (Intent) -> T?, + ): Flow = callbackFlow { + val receiver = + object : BroadcastReceiver() { + override fun onReceive(context: Context, intent: Intent) { + transform(intent)?.also { result -> + trySend(result).onFailure { Log.e(TAG, "Failed to send $result", it) } + } + ?: Log.w(TAG, "Ignored broadcast $intent") + } + } + + @Suppress("MissingPermission") + context.registerReceiverAsUser( + receiver, + user, + IntentFilter(filter), + null, + handler, + Context.RECEIVER_NOT_EXPORTED + ) + awaitClose { context.unregisterReceiver(receiver) } + } +} diff --git a/java/src/com/android/intentresolver/v2/data/repository/UserRepository.kt b/java/src/com/android/intentresolver/v2/data/repository/UserRepository.kt index 40672249..d196d3e6 100644 --- a/java/src/com/android/intentresolver/v2/data/repository/UserRepository.kt +++ b/java/src/com/android/intentresolver/v2/data/repository/UserRepository.kt @@ -16,7 +16,6 @@ package com.android.intentresolver.v2.data.repository -import android.content.Context import android.content.Intent import android.content.Intent.ACTION_MANAGED_PROFILE_AVAILABLE import android.content.Intent.ACTION_MANAGED_PROFILE_UNAVAILABLE @@ -36,9 +35,8 @@ import androidx.annotation.VisibleForTesting 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.BroadcastSubscriber import com.android.intentresolver.v2.shared.model.User -import dagger.hilt.android.qualifiers.ApplicationContext import javax.inject.Inject import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineScope @@ -88,6 +86,23 @@ internal data class UserWithState(val user: User, val available: Boolean) internal typealias UserStates = List +internal val userBroadcastActions = + setOf( + ACTION_PROFILE_ADDED, + ACTION_PROFILE_REMOVED, + + // Quiet mode enabled/disabled for managed + // From: UserController.broadcastProfileAvailabilityChanges + // In response to setQuietModeEnabled + ACTION_MANAGED_PROFILE_AVAILABLE, // quiet mode, sent for manage profiles only + ACTION_MANAGED_PROFILE_UNAVAILABLE, // quiet mode, sent for manage profiles only + + // Quiet mode toggled for profile type, requires flag 'android.os.allow_private_profile + // true' + ACTION_PROFILE_AVAILABLE, // quiet mode, + ACTION_PROFILE_UNAVAILABLE, // quiet mode, sent for any profile type + ) + /** Tracks and publishes state for the parent user and associated profiles. */ class UserRepositoryImpl @VisibleForTesting @@ -97,21 +112,26 @@ constructor( /** A flow of events which represent user-state changes from [UserManager]. */ private val userEvents: Flow, scope: CoroutineScope, - private val backgroundDispatcher: CoroutineDispatcher + private val backgroundDispatcher: CoroutineDispatcher, ) : UserRepository { @Inject constructor( - @ApplicationContext context: Context, @ProfileParent profileParent: UserHandle, userManager: UserManager, @Main scope: CoroutineScope, - @Background background: CoroutineDispatcher + @Background background: CoroutineDispatcher, + broadcastSubscriber: BroadcastSubscriber, ) : this( profileParent, userManager, - userEvents = userBroadcastFlow(context, profileParent), + userEvents = + broadcastSubscriber.createFlow( + createFilter(userBroadcastActions), + profileParent, + Intent::toUserEvent + ), scope, - background + background, ) private fun debugLog(msg: () -> String) { @@ -264,7 +284,7 @@ data class UnknownEvent( ) : UserEvent /** Used with [broadcastFlow] to transform a UserManager broadcast action into a [UserEvent]. */ -private fun Intent.toUserEvent(): UserEvent { +internal fun Intent.toUserEvent(): UserEvent { val action = action val user = extras?.getParcelable(EXTRA_USER, UserHandle::class.java) val quietMode = extras?.getBoolean(EXTRA_QUIET_MODE, false) @@ -280,30 +300,10 @@ private fun Intent.toUserEvent(): UserEvent { } } -private fun createFilter(actions: Iterable): IntentFilter { +internal fun createFilter(actions: Iterable): IntentFilter { return IntentFilter().apply { actions.forEach(::addAction) } } internal fun UserInfo?.isAvailable(): Boolean { return this?.isQuietModeEnabled != true } - -internal fun userBroadcastFlow(context: Context, profileParent: UserHandle): Flow { - val userActions = - setOf( - ACTION_PROFILE_ADDED, - ACTION_PROFILE_REMOVED, - - // Quiet mode enabled/disabled for managed - // From: UserController.broadcastProfileAvailabilityChanges - // In response to setQuietModeEnabled - ACTION_MANAGED_PROFILE_AVAILABLE, // quiet mode, sent for manage profiles only - ACTION_MANAGED_PROFILE_UNAVAILABLE, // quiet mode, sent for manage profiles only - - // Quiet mode toggled for profile type, requires flag 'android.os.allow_private_profile - // true' - ACTION_PROFILE_AVAILABLE, // quiet mode, - ACTION_PROFILE_UNAVAILABLE, // quiet mode, sent for any profile type - ) - return broadcastFlow(context, createFilter(userActions), profileParent, Intent::toUserEvent) -} -- cgit v1.2.3-59-g8ed1b From 8e7e81138fc72d5df530cb77032f9aae1f18cb2c Mon Sep 17 00:00:00 2001 From: Mark Renouf Date: Tue, 2 Apr 2024 10:54:45 -0400 Subject: Simplify startup and Profile flow handling Instead of blocking to collect a set of intermediate values in ChooserHelper, simply allow the @JavaInterop classes to do this work instead. This greatly simplifies the startup code run from onCreate but does not affect latency, as the flows are still collected within the same call stack (onCreate) when needed. This change also removes an unneccesary intermediate StateFlow from ProfileAvailability. The flow was only used internally to collect the current value. Since ProfileAvailability is a @JavaInterop class (bridging blocking/suspending code), used from the Main thread only, we can simplify to `runBlocking { flow.first() }` where an immediate value is needed. Bug: 330561320 Test: atest IntentResolver-tests-activity Flag: ACONFIG intentresolver/com.android.intentresolver.enable_private_profile Change-Id: I88c5a96a57db32bb5eee90f5d94e1d56b224aa63 --- .../android/intentresolver/v2/ChooserActivity.java | 19 +- .../com/android/intentresolver/v2/ChooserHelper.kt | 40 +---- .../intentresolver/v2/ProfileAvailability.kt | 23 ++- .../com/android/intentresolver/v2/ProfileHelper.kt | 19 +- .../intentresolver/v2/ProfileAvailabilityTest.kt | 8 +- .../android/intentresolver/v2/ProfileHelperTest.kt | 197 ++++++++++----------- 6 files changed, 140 insertions(+), 166 deletions(-) (limited to 'java/src') diff --git a/java/src/com/android/intentresolver/v2/ChooserActivity.java b/java/src/com/android/intentresolver/v2/ChooserActivity.java index d624c9e4..5f3129f8 100644 --- a/java/src/com/android/intentresolver/v2/ChooserActivity.java +++ b/java/src/com/android/intentresolver/v2/ChooserActivity.java @@ -124,6 +124,7 @@ import com.android.intentresolver.emptystate.EmptyState; import com.android.intentresolver.emptystate.EmptyStateProvider; import com.android.intentresolver.grid.ChooserGridAdapter; import com.android.intentresolver.icons.TargetDataLoader; +import com.android.intentresolver.inject.Background; import com.android.intentresolver.logging.EventLog; import com.android.intentresolver.measurements.Tracer; import com.android.intentresolver.model.AbstractResolverComparator; @@ -185,6 +186,8 @@ import java.util.function.Supplier; import javax.inject.Inject; +import kotlinx.coroutines.CoroutineDispatcher; + /** * The Chooser Activity handles intent resolution specifically for sharing intents - * for example, as generated by {@see android.content.Intent#createChooser(Intent, CharSequence)}. @@ -265,6 +268,7 @@ public class ChooserActivity extends Hilt_ChooserActivity implements private static final int SCROLL_STATUS_SCROLLING_HORIZONTAL = 2; @Inject public UserInteractor mUserInteractor; + @Inject @Background public CoroutineDispatcher mBackgroundDispatcher; @Inject public ChooserHelper mChooserHelper; @Inject public FeatureFlags mFeatureFlags; @Inject public android.service.chooser.FeatureFlags mChooserServiceFeatureFlags; @@ -352,7 +356,7 @@ public class ChooserActivity extends Hilt_ChooserActivity implements setTheme(R.style.Theme_DeviceDefault_Chooser); // Initializer is invoked when this function returns, via Lifecycle. - mChooserHelper.setInitializer(this::initializeWith); + mChooserHelper.setInitializer(this::initialize); if (mChooserServiceFeatureFlags.chooserPayloadToggling()) { mChooserHelper.setOnChooserRequestChanged(this::onChooserRequestChanged); } @@ -467,8 +471,7 @@ public class ChooserActivity extends Hilt_ChooserActivity implements } /** DO NOT CALL. Only for use from ChooserHelper as a callback. */ - private void initializeWith(InitialState initialState) { - Log.d(TAG, "initializeWith: " + initialState); + private void initialize() { mViewModel = new ViewModelProvider(this).get(ChooserViewModel.class); mRequest = mViewModel.getRequest().getValue(); @@ -476,14 +479,14 @@ public class ChooserActivity extends Hilt_ChooserActivity implements mProfiles = new ProfileHelper( mUserInteractor, - mFeatureFlags, - initialState.getProfiles(), - initialState.getLaunchedAs()); + getCoroutineScope(getLifecycle()), + mBackgroundDispatcher, + mFeatureFlags); mProfileAvailability = new ProfileAvailability( - getCoroutineScope(getLifecycle()), mUserInteractor, - initialState.getAvailability()); + getCoroutineScope(getLifecycle()), + mBackgroundDispatcher); mProfileAvailability.setOnProfileStatusChange(this::onWorkProfileStatusUpdated); diff --git a/java/src/com/android/intentresolver/v2/ChooserHelper.kt b/java/src/com/android/intentresolver/v2/ChooserHelper.kt index 503e46d8..9da0d605 100644 --- a/java/src/com/android/intentresolver/v2/ChooserHelper.kt +++ b/java/src/com/android/intentresolver/v2/ChooserHelper.kt @@ -31,7 +31,6 @@ 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 @@ -43,34 +42,9 @@ 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, - val availability: Map, - val launchedAs: Profile -) - /** * __Purpose__ * @@ -113,7 +87,7 @@ constructor( private val activity: ComponentActivity = hostActivity as ComponentActivity private val viewModel by activity.viewModels() - private lateinit var activityInitializer: ChooserInitializer + private lateinit var activityInitializer: Runnable var onChooserRequestChanged: Consumer = Consumer {} @@ -126,7 +100,7 @@ constructor( * * This _must_ be called from [ChooserActivity.onCreate]. */ - fun setInitializer(initializer: ChooserInitializer) { + fun setInitializer(initializer: Runnable) { check(activity.lifecycle.currentState == Lifecycle.State.INITIALIZED) { "setInitializer must be called before onCreate returns" } @@ -189,14 +163,6 @@ constructor( private fun initializeActivity(request: Valid) { 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) + activityInitializer.run() } } diff --git a/java/src/com/android/intentresolver/v2/ProfileAvailability.kt b/java/src/com/android/intentresolver/v2/ProfileAvailability.kt index ddb57991..27d8c6bb 100644 --- a/java/src/com/android/intentresolver/v2/ProfileAvailability.kt +++ b/java/src/com/android/intentresolver/v2/ProfileAvailability.kt @@ -16,27 +16,26 @@ package com.android.intentresolver.v2 +import androidx.annotation.MainThread import com.android.intentresolver.v2.annotation.JavaInterop import com.android.intentresolver.v2.domain.interactor.UserInteractor import com.android.intentresolver.v2.shared.model.Profile +import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Job -import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.filter import kotlinx.coroutines.flow.first -import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.flow.map import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking /** Provides availability status for profiles */ @JavaInterop class ProfileAvailability( - private val scope: CoroutineScope, private val userInteractor: UserInteractor, - initialState: Map + private val scope: CoroutineScope, + private val background: CoroutineDispatcher, ) { - private val availability = - userInteractor.availability.stateIn(scope, SharingStarted.Eagerly, initialState) - /** Used by WorkProfilePausedEmptyStateProvider */ var waitingToEnableProfile = false private set @@ -45,8 +44,14 @@ class ProfileAvailability( 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 + @MainThread + fun isAvailable(profile: Profile): Boolean { + return runBlocking(background) { + userInteractor.availability.map { it[profile] == true }.first() + } + } /** Used by WorkProfilePausedEmptyStateProvider */ fun requestQuietModeState(profile: Profile, quietMode: Boolean) { @@ -65,7 +70,7 @@ class ProfileAvailability( val job = scope.launch { // Wait for the profile to become available - availability.filter { it[profile] == true }.first() + userInteractor.availability.filter { it[profile] == true }.first() } job.invokeOnCompletion { waitingToEnableProfile = false diff --git a/java/src/com/android/intentresolver/v2/ProfileHelper.kt b/java/src/com/android/intentresolver/v2/ProfileHelper.kt index 8a8e6b54..87948150 100644 --- a/java/src/com/android/intentresolver/v2/ProfileHelper.kt +++ b/java/src/com/android/intentresolver/v2/ProfileHelper.kt @@ -17,29 +17,40 @@ package com.android.intentresolver.v2 import android.os.UserHandle +import androidx.annotation.MainThread 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 +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.runBlocking @JavaInterop +@MainThread class ProfileHelper @Inject constructor( interactor: UserInteractor, + private val scope: CoroutineScope, + private val background: CoroutineDispatcher, private val flags: IntentResolverFlags, - val profiles: List, - val launchedAsProfile: Profile, ) { private val launchedByHandle: UserHandle = interactor.launchedAs + val launchedAsProfile by lazy { + runBlocking(background) { interactor.launchedAsProfile.first() } + } + val profiles by lazy { runBlocking(background) { interactor.profiles.first() } } + // Map UserHandle back to a user within launchedByProfile - private val launchedByUser = + private val launchedByUser: User = when (launchedByHandle) { launchedAsProfile.primary.handle -> launchedAsProfile.primary - launchedAsProfile.clone?.handle -> launchedAsProfile.clone + launchedAsProfile.clone?.handle -> requireNotNull(launchedAsProfile.clone) else -> error("launchedByUser must be a member of launchedByProfile") } val launchedAsProfileType: Profile.Type = launchedAsProfile.type diff --git a/tests/unit/src/com/android/intentresolver/v2/ProfileAvailabilityTest.kt b/tests/unit/src/com/android/intentresolver/v2/ProfileAvailabilityTest.kt index 9f2b3e0f..c0d5ed4e 100644 --- a/tests/unit/src/com/android/intentresolver/v2/ProfileAvailabilityTest.kt +++ b/tests/unit/src/com/android/intentresolver/v2/ProfileAvailabilityTest.kt @@ -22,6 +22,7 @@ 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 com.google.common.truth.Truth.assertThat +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.test.runCurrent import kotlinx.coroutines.test.runTest @@ -40,8 +41,7 @@ class ProfileAvailabilityTest { @Test fun testProfileAvailable() = runTest { - val availability = ProfileAvailability(backgroundScope, interactor, mapOf()) - runCurrent() + val availability = ProfileAvailability(interactor, this, Dispatchers.IO) assertThat(availability.isAvailable(personalProfile)).isTrue() assertThat(availability.isAvailable(workProfile)).isTrue() @@ -59,8 +59,7 @@ class ProfileAvailabilityTest { @Test fun waitingToEnableProfile() = runTest { - val availability = ProfileAvailability(backgroundScope, interactor, mapOf()) - runCurrent() + val availability = ProfileAvailability(interactor, this, Dispatchers.IO) availability.requestQuietModeState(workProfile, true) assertThat(availability.waitingToEnableProfile).isFalse() @@ -68,7 +67,6 @@ class ProfileAvailabilityTest { availability.requestQuietModeState(workProfile, false) assertThat(availability.waitingToEnableProfile).isTrue() - runCurrent() assertThat(availability.waitingToEnableProfile).isFalse() diff --git a/tests/unit/src/com/android/intentresolver/v2/ProfileHelperTest.kt b/tests/unit/src/com/android/intentresolver/v2/ProfileHelperTest.kt index cb4b1d0a..06d795fe 100644 --- a/tests/unit/src/com/android/intentresolver/v2/ProfileHelperTest.kt +++ b/tests/unit/src/com/android/intentresolver/v2/ProfileHelperTest.kt @@ -17,20 +17,15 @@ package com.android.intentresolver.v2 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 import com.android.intentresolver.v2.shared.model.User -import com.google.common.truth.Truth import com.google.common.truth.Truth.assertThat -import kotlinx.coroutines.flow.first +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.test.runTest -import org.junit.Assert.* - import org.junit.Test @OptIn(JavaInterop::class) @@ -48,67 +43,69 @@ class ProfileHelperTest { private val privateUser = User(12, User.Role.PRIVATE) private val privateProfile = Profile(Profile.Type.PRIVATE, privateUser) - private val flags = FakeIntentResolverFlags().apply { - setFlag(FLAG_ENABLE_PRIVATE_PROFILE, true) - } + private val flags = + FakeIntentResolverFlags().apply { setFlag(FLAG_ENABLE_PRIVATE_PROFILE, true) } private fun assertProfiles( helper: ProfileHelper, personalProfile: Profile, workProfile: Profile? = null, - privateProfile: Profile? = null) { - + privateProfile: Profile? = null + ) { assertThat(helper.personalProfile).isEqualTo(personalProfile) assertThat(helper.personalHandle).isEqualTo(personalProfile.primary.handle) personalProfile.clone?.also { assertThat(helper.cloneUserPresent).isTrue() assertThat(helper.cloneHandle).isEqualTo(it.handle) - } ?: { - assertThat(helper.cloneUserPresent).isFalse() - assertThat(helper.cloneHandle).isNull() } + ?: { + assertThat(helper.cloneUserPresent).isFalse() + assertThat(helper.cloneHandle).isNull() + } workProfile?.also { assertThat(helper.workProfilePresent).isTrue() assertThat(helper.workProfile).isEqualTo(it) assertThat(helper.workHandle).isEqualTo(it.primary.handle) - } ?: { - assertThat(helper.workProfilePresent).isFalse() - assertThat(helper.workProfile).isNull() - assertThat(helper.workHandle).isNull() } + ?: { + assertThat(helper.workProfilePresent).isFalse() + assertThat(helper.workProfile).isNull() + assertThat(helper.workHandle).isNull() + } privateProfile?.also { assertThat(helper.privateProfilePresent).isTrue() assertThat(helper.privateProfile).isEqualTo(it) assertThat(helper.privateHandle).isEqualTo(it.primary.handle) - } ?: { - assertThat(helper.privateProfilePresent).isFalse() - assertThat(helper.privateProfile).isNull() - assertThat(helper.privateHandle).isNull() } + ?: { + assertThat(helper.privateProfilePresent).isFalse() + assertThat(helper.privateProfile).isNull() + assertThat(helper.privateHandle).isNull() + } } - @Test fun launchedByPersonal() = runTest { val repository = FakeUserRepository(listOf(personalUser)) val interactor = UserInteractor(repository, launchedAs = personalUser.handle) - val launchedBy = interactor.launchedAsProfile.first() - val helper = ProfileHelper( - interactor = interactor, - flags = flags, - profiles = interactor.profiles.first(), - launchedAsProfile = launchedBy) + val helper = + ProfileHelper( + interactor = interactor, + scope = this, + background = Dispatchers.Unconfined, + flags = flags + ) assertProfiles(helper, personalProfile) assertThat(helper.isLaunchedAsCloneProfile).isFalse() assertThat(helper.launchedAsProfileType).isEqualTo(Profile.Type.PERSONAL) assertThat(helper.getQueryIntentsHandle(personalUser.handle)) - .isEqualTo(personalProfile.primary.handle) + .isEqualTo(personalProfile.primary.handle) assertThat(helper.tabOwnerUserHandleForLaunch).isEqualTo(personalProfile.primary.handle) } @@ -116,13 +113,14 @@ class ProfileHelperTest { fun launchedByPersonal_withClone() = runTest { val repository = FakeUserRepository(listOf(personalUser, cloneUser)) val interactor = UserInteractor(repository, launchedAs = personalUser.handle) - val launchedBy = interactor.launchedAsProfile.first() - val helper = ProfileHelper( - interactor = interactor, - flags = flags, - profiles = interactor.profiles.first(), - launchedAsProfile = launchedBy) + val helper = + ProfileHelper( + interactor = interactor, + scope = this, + background = Dispatchers.Unconfined, + flags = flags + ) assertProfiles(helper, personalWithCloneProfile) @@ -136,47 +134,46 @@ class ProfileHelperTest { fun launchedByClone() = runTest { val repository = FakeUserRepository(listOf(personalUser, cloneUser)) val interactor = UserInteractor(repository, launchedAs = cloneUser.handle) - val launchedBy = interactor.launchedAsProfile.first() - val helper = ProfileHelper( - interactor = interactor, - flags = flags, - profiles = interactor.profiles.first(), - launchedAsProfile = launchedBy) + val helper = + ProfileHelper( + interactor = interactor, + scope = this, + background = Dispatchers.Unconfined, + flags = flags + ) assertProfiles(helper, personalWithCloneProfile) assertThat(helper.isLaunchedAsCloneProfile).isTrue() assertThat(helper.launchedAsProfileType).isEqualTo(Profile.Type.PERSONAL) assertThat(helper.getQueryIntentsHandle(personalWithCloneProfile.primary.handle)) - .isEqualTo(personalWithCloneProfile.clone?.handle) + .isEqualTo(personalWithCloneProfile.clone?.handle) assertThat(helper.tabOwnerUserHandleForLaunch) - .isEqualTo(personalWithCloneProfile.primary.handle) + .isEqualTo(personalWithCloneProfile.primary.handle) } @Test fun launchedByPersonal_withWork() = runTest { val repository = FakeUserRepository(listOf(personalUser, workUser)) val interactor = UserInteractor(repository, launchedAs = personalUser.handle) - val launchedBy = interactor.launchedAsProfile.first() - val helper = ProfileHelper( - interactor = interactor, - flags = flags, - profiles = interactor.profiles.first(), - launchedAsProfile = launchedBy) + val helper = + ProfileHelper( + interactor = interactor, + scope = this, + background = Dispatchers.Unconfined, + flags = flags + ) - - assertProfiles(helper, - personalProfile = personalProfile, - workProfile = workProfile) + assertProfiles(helper, personalProfile = personalProfile, workProfile = workProfile) assertThat(helper.launchedAsProfileType).isEqualTo(Profile.Type.PERSONAL) assertThat(helper.isLaunchedAsCloneProfile).isFalse() assertThat(helper.getQueryIntentsHandle(personalUser.handle)) - .isEqualTo(personalProfile.primary.handle) + .isEqualTo(personalProfile.primary.handle) assertThat(helper.getQueryIntentsHandle(workUser.handle)) - .isEqualTo(workProfile.primary.handle) + .isEqualTo(workProfile.primary.handle) assertThat(helper.tabOwnerUserHandleForLaunch).isEqualTo(personalProfile.primary.handle) } @@ -184,50 +181,47 @@ class ProfileHelperTest { fun launchedByWork() = runTest { val repository = FakeUserRepository(listOf(personalUser, workUser)) val interactor = UserInteractor(repository, launchedAs = workUser.handle) - val launchedBy = interactor.launchedAsProfile.first() - val helper = ProfileHelper( - interactor = interactor, - flags = flags, - profiles = interactor.profiles.first(), - launchedAsProfile = launchedBy) + val helper = + ProfileHelper( + interactor = interactor, + scope = this, + background = Dispatchers.Unconfined, + flags = flags + ) - assertProfiles(helper, - personalProfile = personalProfile, - workProfile = workProfile) + assertProfiles(helper, personalProfile = personalProfile, workProfile = workProfile) assertThat(helper.isLaunchedAsCloneProfile).isFalse() assertThat(helper.launchedAsProfileType).isEqualTo(Profile.Type.WORK) assertThat(helper.getQueryIntentsHandle(personalProfile.primary.handle)) - .isEqualTo(personalProfile.primary.handle) + .isEqualTo(personalProfile.primary.handle) assertThat(helper.getQueryIntentsHandle(workProfile.primary.handle)) - .isEqualTo(workProfile.primary.handle) - assertThat(helper.tabOwnerUserHandleForLaunch) - .isEqualTo(workProfile.primary.handle) + .isEqualTo(workProfile.primary.handle) + assertThat(helper.tabOwnerUserHandleForLaunch).isEqualTo(workProfile.primary.handle) } @Test fun launchedByPersonal_withPrivate() = runTest { val repository = FakeUserRepository(listOf(personalUser, privateUser)) val interactor = UserInteractor(repository, launchedAs = personalUser.handle) - val launchedBy = interactor.launchedAsProfile.first() - val helper = ProfileHelper( - interactor = interactor, - flags = flags, - profiles = interactor.profiles.first(), - launchedAsProfile = launchedBy) + val helper = + ProfileHelper( + interactor = interactor, + scope = this, + background = Dispatchers.Unconfined, + flags = flags + ) - assertProfiles(helper, - personalProfile = personalProfile, - privateProfile = privateProfile) + assertProfiles(helper, personalProfile = personalProfile, privateProfile = privateProfile) assertThat(helper.isLaunchedAsCloneProfile).isFalse() assertThat(helper.launchedAsProfileType).isEqualTo(Profile.Type.PERSONAL) assertThat(helper.getQueryIntentsHandle(personalProfile.primary.handle)) - .isEqualTo(personalProfile.primary.handle) + .isEqualTo(personalProfile.primary.handle) assertThat(helper.getQueryIntentsHandle(privateProfile.primary.handle)) - .isEqualTo(privateProfile.primary.handle) + .isEqualTo(privateProfile.primary.handle) assertThat(helper.tabOwnerUserHandleForLaunch).isEqualTo(personalProfile.primary.handle) } @@ -235,25 +229,23 @@ class ProfileHelperTest { fun launchedByPrivate() = runTest { val repository = FakeUserRepository(listOf(personalUser, privateUser)) val interactor = UserInteractor(repository, launchedAs = privateUser.handle) - val launchedBy = interactor.launchedAsProfile.first() - - val helper = ProfileHelper( - interactor = interactor, - flags = flags, - profiles = interactor.profiles.first(), - launchedAsProfile = launchedBy) + val helper = + ProfileHelper( + interactor = interactor, + scope = this, + background = Dispatchers.Unconfined, + flags = flags + ) - assertProfiles(helper, - personalProfile = personalProfile, - privateProfile = privateProfile) + assertProfiles(helper, personalProfile = personalProfile, privateProfile = privateProfile) assertThat(helper.isLaunchedAsCloneProfile).isFalse() assertThat(helper.launchedAsProfileType).isEqualTo(Profile.Type.PRIVATE) assertThat(helper.getQueryIntentsHandle(personalProfile.primary.handle)) - .isEqualTo(personalProfile.primary.handle) + .isEqualTo(personalProfile.primary.handle) assertThat(helper.getQueryIntentsHandle(privateProfile.primary.handle)) - .isEqualTo(privateProfile.primary.handle) + .isEqualTo(privateProfile.primary.handle) assertThat(helper.tabOwnerUserHandleForLaunch).isEqualTo(privateProfile.primary.handle) } @@ -263,22 +255,21 @@ class ProfileHelperTest { val repository = FakeUserRepository(listOf(personalUser, privateUser)) val interactor = UserInteractor(repository, launchedAs = personalUser.handle) - val launchedBy = interactor.launchedAsProfile.first() - val helper = ProfileHelper( - interactor = interactor, - flags = flags, - profiles = interactor.profiles.first(), - launchedAsProfile = launchedBy) + val helper = + ProfileHelper( + interactor = interactor, + scope = this, + background = Dispatchers.Unconfined, + flags = flags + ) - assertProfiles(helper, - personalProfile = personalProfile, - privateProfile = null) + assertProfiles(helper, personalProfile = personalProfile, privateProfile = null) assertThat(helper.isLaunchedAsCloneProfile).isFalse() assertThat(helper.launchedAsProfileType).isEqualTo(Profile.Type.PERSONAL) assertThat(helper.getQueryIntentsHandle(personalProfile.primary.handle)) - .isEqualTo(personalProfile.primary.handle) + .isEqualTo(personalProfile.primary.handle) assertThat(helper.tabOwnerUserHandleForLaunch).isEqualTo(personalProfile.primary.handle) } -} \ No newline at end of file +} -- cgit v1.2.3-59-g8ed1b From 4e5232e93dd5b1d389bea38da3ffdac9c27ed6a0 Mon Sep 17 00:00:00 2001 From: Mark Renouf Date: Tue, 2 Apr 2024 11:10:53 -0400 Subject: Switch UserRepository to started = WhileSubscribed Changes the StateFlow serving User info to remain active only `WhileSubscribed`, with a timeout to minimize repeated restarts while ensuring the flow is cancelled before the being frozen. Background: IntentResolver does not have a persistent background process. Leaving a hot flow started while in the background does not operate as expected. Once the process is frozen, several seconds after moving to the cached process state, no threads execute. The flow does not cancel or restart, broadcast receivers are not removed and sent broadcasts sent are queued and processed at once when execution resumes. Test: atest IntentResolver-tests-activity Bug: 330561320 Flag: ACONFIG intentresolver/com.android.intentresolver.enable_private_profile Change-Id: I1ebc8405807788e72da887829f9c43663bf10446 --- .../v2/data/repository/UserRepository.kt | 27 ++++++++++++++++++---- 1 file changed, 23 insertions(+), 4 deletions(-) (limited to 'java/src') 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 d196d3e6..56c84fcf 100644 --- a/java/src/com/android/intentresolver/v2/data/repository/UserRepository.kt +++ b/java/src/com/android/intentresolver/v2/data/repository/UserRepository.kt @@ -38,10 +38,12 @@ import com.android.intentresolver.inject.ProfileParent import com.android.intentresolver.v2.data.BroadcastSubscriber import com.android.intentresolver.v2.shared.model.User import javax.inject.Inject +import kotlin.time.Duration +import kotlin.time.Duration.Companion.seconds import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.SharingStarted.Companion.WhileSubscribed import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.filterNot import kotlinx.coroutines.flow.map @@ -82,6 +84,15 @@ interface UserRepository { private const val TAG = "UserRepository" +/** The delay between entering the cached process state and entering the frozen cgroup */ +private val cachedProcessFreezeDelay: Duration = 10.seconds + +/** How long to continue listening for user state broadcasts while unsubscribed */ +private val stateFlowTimeout = cachedProcessFreezeDelay - 2.seconds + +/** How long to retain the previous user state after the state flow stops. */ +private val stateCacheTimeout = 2.seconds + internal data class UserWithState(val user: User, val available: Boolean) internal typealias UserStates = List @@ -151,7 +162,7 @@ constructor( private class UserStateException( override val message: String, val event: UserEvent, - override val cause: Throwable? = null + override val cause: Throwable? = null, ) : RuntimeException("$message: event=$event", cause) private val sharingScope = CoroutineScope(scope.coroutineContext + backgroundDispatcher) @@ -162,7 +173,15 @@ constructor( .runningFold(emptyList(), ::handleEvent) .distinctUntilChanged() .onEach { debugLog { "userStateList: $it" } } - .stateIn(sharingScope, SharingStarted.Eagerly, emptyList()) + .stateIn( + sharingScope, + started = + WhileSubscribed( + stopTimeoutMillis = stateFlowTimeout.inWholeMilliseconds, + replayExpirationMillis = 0 /** Immediately on stop */ + ), + listOf() + ) .filterNot { it.isEmpty() } private suspend fun handleEvent(users: UserStates, event: UserEvent): UserStates { @@ -186,7 +205,7 @@ constructor( } override val users: Flow> = - usersWithState.map { userStateMap -> userStateMap.map { it.user } }.distinctUntilChanged() + usersWithState.map { userStates -> userStates.map { it.user } }.distinctUntilChanged() override val availability: Flow> = usersWithState -- cgit v1.2.3-59-g8ed1b From 9f4e2ece250f07e214231a89c9aa74ab19d35d30 Mon Sep 17 00:00:00 2001 From: Mark Renouf Date: Fri, 1 Mar 2024 12:37:23 -0500 Subject: ResolverActivity Profile integration * connects ResolverActivity with UserInteractor * replaces all existing references as the source of UserHandles * app continues to explicity use the same profile types as previous * updates Activity tests to use FakeUserRepository * removes ResolverWorkProfilePausedEmptyStateProvider * removes ResolverNoCrossProfileEmptyStateProvider * removes ActivityLogic * removes ResolverActivityLogic * removes TestResolverActivityLogic Bug: 300157408 Bug: 311348033 Test: atest IntentResolver-tests-activity:com.android.intentresolver.v2 Change-Id: Ia4a8bf458ebad12af06b1c6c1f0d8586be452d43 --- .../com/android/intentresolver/v2/ActivityLogic.kt | 79 ---- .../intentresolver/v2/ResolverActivity.java | 459 ++++++++++----------- .../intentresolver/v2/ResolverActivityLogic.kt | 18 - .../android/intentresolver/v2/ResolverHelper.kt | 129 ++++++ .../ResolverNoCrossProfileEmptyStateProvider.java | 138 ------- ...esolverWorkProfilePausedEmptyStateProvider.java | 116 ------ .../v2/ui/viewmodel/ResolverViewModel.kt | 70 ++++ .../intentresolver/ResolverActivityTest.java | 2 +- .../intentresolver/v2/ResolverActivityTest.java | 81 ++-- .../intentresolver/v2/ResolverWrapperActivity.java | 63 --- .../intentresolver/v2/TestResolverActivityLogic.kt | 22 - 11 files changed, 458 insertions(+), 719 deletions(-) delete mode 100644 java/src/com/android/intentresolver/v2/ActivityLogic.kt delete mode 100644 java/src/com/android/intentresolver/v2/ResolverActivityLogic.kt create mode 100644 java/src/com/android/intentresolver/v2/ResolverHelper.kt delete mode 100644 java/src/com/android/intentresolver/v2/emptystate/ResolverNoCrossProfileEmptyStateProvider.java delete mode 100644 java/src/com/android/intentresolver/v2/emptystate/ResolverWorkProfilePausedEmptyStateProvider.java create mode 100644 java/src/com/android/intentresolver/v2/ui/viewmodel/ResolverViewModel.kt delete mode 100644 tests/activity/src/com/android/intentresolver/v2/TestResolverActivityLogic.kt (limited to 'java/src') diff --git a/java/src/com/android/intentresolver/v2/ActivityLogic.kt b/java/src/com/android/intentresolver/v2/ActivityLogic.kt deleted file mode 100644 index 62ace0da..00000000 --- a/java/src/com/android/intentresolver/v2/ActivityLogic.kt +++ /dev/null @@ -1,79 +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.v2 - -import android.os.UserHandle -import android.os.UserManager -import android.util.Log -import androidx.activity.ComponentActivity -import androidx.core.content.getSystemService -import com.android.intentresolver.AnnotatedUserHandles -import com.android.intentresolver.WorkProfileAvailabilityManager - -/** - * Logic for IntentResolver Activities. Anything that is not the same across activities (including - * test activities) should be in this interface. Expect there to be one implementation for each - * activity, including test activities, but all implementations should delegate to a - * CommonActivityLogic implementation. - */ -interface ActivityLogic : CommonActivityLogic - -/** - * Logic that is common to all IntentResolver activities. Anything that is the same across - * activities (including test activities), should live here. - */ -interface CommonActivityLogic { - /** The tag to use when logging. */ - val tag: String - - /** A reference to the activity owning, and used by, this logic. */ - val activity: ComponentActivity - - /** Current [UserHandle]s retrievable by type. */ - val annotatedUserHandles: AnnotatedUserHandles? - - /** Monitors for changes to work profile availability. */ - val workProfileAvailabilityManager: WorkProfileAvailabilityManager -} - -/** - * Concrete implementation of the [CommonActivityLogic] interface meant to be delegated to by - * [ActivityLogic] implementations. Test implementations of [ActivityLogic] may need to create their - * own [CommonActivityLogic] implementation. - */ -class CommonActivityLogicImpl( - override val tag: String, - override val activity: ComponentActivity, - onWorkProfileStatusUpdated: () -> Unit, -) : CommonActivityLogic { - - private val userManager: UserManager = activity.getSystemService()!! - - override val annotatedUserHandles: AnnotatedUserHandles? = - try { - AnnotatedUserHandles.forShareActivity(activity) - } catch (e: SecurityException) { - Log.e(tag, "Request from UID without necessary permissions", e) - null - } - - override val workProfileAvailabilityManager = - WorkProfileAvailabilityManager( - userManager, - annotatedUserHandles?.workProfileUserHandle, - onWorkProfileStatusUpdated, - ) -} diff --git a/java/src/com/android/intentresolver/v2/ResolverActivity.java b/java/src/com/android/intentresolver/v2/ResolverActivity.java index 4e694c3a..86f32864 100644 --- a/java/src/com/android/intentresolver/v2/ResolverActivity.java +++ b/java/src/com/android/intentresolver/v2/ResolverActivity.java @@ -24,8 +24,9 @@ import static android.stats.devicepolicy.nano.DevicePolicyEnums.RESOLVER_EMPTY_S import static android.stats.devicepolicy.nano.DevicePolicyEnums.RESOLVER_EMPTY_STATE_NO_SHARING_TO_WORK; import static android.view.WindowManager.LayoutParams.SYSTEM_FLAG_HIDE_NON_SYSTEM_OVERLAY_WINDOWS; +import static androidx.lifecycle.LifecycleKt.getCoroutineScope; + import static com.android.intentresolver.v2.ext.CreationExtrasExtKt.addDefaultArgs; -import static com.android.intentresolver.v2.ui.viewmodel.ResolverRequestReaderKt.readResolverRequest; import static com.android.internal.annotations.VisibleForTesting.Visibility.PROTECTED; import static java.util.Objects.requireNonNull; @@ -83,14 +84,14 @@ import android.widget.Toast; import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.fragment.app.FragmentActivity; +import androidx.lifecycle.ViewModelProvider; import androidx.lifecycle.viewmodel.CreationExtras; import androidx.viewpager.widget.ViewPager; -import com.android.intentresolver.AnnotatedUserHandles; +import com.android.intentresolver.FeatureFlags; import com.android.intentresolver.R; import com.android.intentresolver.ResolverListAdapter; import com.android.intentresolver.ResolverListController; -import com.android.intentresolver.WorkProfileAvailabilityManager; import com.android.intentresolver.chooser.DisplayResolveInfo; import com.android.intentresolver.chooser.TargetInfo; import com.android.intentresolver.emptystate.CompositeEmptyStateProvider; @@ -99,12 +100,14 @@ import com.android.intentresolver.emptystate.EmptyState; import com.android.intentresolver.emptystate.EmptyStateProvider; import com.android.intentresolver.icons.DefaultTargetDataLoader; import com.android.intentresolver.icons.TargetDataLoader; +import com.android.intentresolver.inject.Background; import com.android.intentresolver.model.ResolverRankerServiceResolverComparator; import com.android.intentresolver.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; -import com.android.intentresolver.v2.emptystate.ResolverNoCrossProfileEmptyStateProvider; -import com.android.intentresolver.v2.emptystate.ResolverWorkProfilePausedEmptyStateProvider; +import com.android.intentresolver.v2.emptystate.WorkProfilePausedEmptyStateProvider; import com.android.intentresolver.v2.profiles.MultiProfilePagerAdapter; import com.android.intentresolver.v2.profiles.MultiProfilePagerAdapter.ProfileType; import com.android.intentresolver.v2.profiles.OnProfileSelectedListener; @@ -115,11 +118,7 @@ import com.android.intentresolver.v2.shared.model.Profile; import com.android.intentresolver.v2.ui.ActionTitle; import com.android.intentresolver.v2.ui.model.ActivityModel; import com.android.intentresolver.v2.ui.model.ResolverRequest; -import com.android.intentresolver.v2.validation.Finding; -import com.android.intentresolver.v2.validation.FindingsKt; -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.ui.viewmodel.ResolverViewModel; import com.android.intentresolver.widget.ResolverDrawerLayout; import com.android.internal.annotations.VisibleForTesting; import com.android.internal.content.PackageMonitor; @@ -131,7 +130,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; @@ -139,10 +137,11 @@ import java.util.Iterator; import java.util.List; import java.util.Objects; import java.util.Set; -import java.util.function.Consumer; import javax.inject.Inject; +import kotlinx.coroutines.CoroutineDispatcher; + /** * This is a copy of ResolverActivity to support IntentResolver's ChooserActivity. This code is * *not* the resolver that is actually triggered by the system right now (you want @@ -153,12 +152,18 @@ import javax.inject.Inject; public class ResolverActivity extends Hilt_ResolverActivity implements ResolverListAdapter.ResolverListCommunicator { + @Inject @Background public CoroutineDispatcher mBackgroundDispatcher; + @Inject public UserInteractor mUserInteractor; + @Inject public ResolverHelper mResolverHelper; @Inject public PackageManager mPackageManager; @Inject public DevicePolicyResources mDevicePolicyResources; @Inject public IntentForwarding mIntentForwarding; - private ResolverRequest mResolverRequest; - private ActivityModel mActivityModel; - protected ActivityLogic mLogic; + @Inject public FeatureFlags mFeatureFlags; + + private ResolverViewModel mViewModel; + private ResolverRequest mRequest; + private ProfileHelper mProfiles; + private ProfileAvailability mProfileAvailability; protected TargetDataLoader mTargetDataLoader; private boolean mResolvingHome; @@ -217,63 +222,133 @@ public class ResolverActivity extends Hilt_ResolverActivity implements } }; } + protected ActivityModel createActivityModel() { return ActivityModel.createFrom(this); } - @VisibleForTesting - protected ActivityLogic createActivityLogic() { - return new ResolverActivityLogic( - TAG, - /* activity = */ this, - this::onWorkProfileStatusUpdated); - } - @NonNull @Override public CreationExtras getDefaultViewModelCreationExtras() { return addDefaultArgs( super.getDefaultViewModelCreationExtras(), - new Pair<>(ActivityModel.ACTIVITY_MODEL_KEY, ActivityModel.createFrom(this))); + new Pair<>(ActivityModel.ACTIVITY_MODEL_KEY, createActivityModel())); } @Override protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); + Log.i(TAG, "onCreate"); setTheme(R.style.Theme_DeviceDefault_Resolver); - mActivityModel = createActivityModel(); + mResolverHelper.setInitializer(this::initialize); + } - Log.i(TAG, "onCreate"); - Log.i(TAG, "activityModel=" + mActivityModel.toString()); - int callerUid = mActivityModel.getLaunchedFromUid(); - if (callerUid < 0 || UserHandle.isIsolated(callerUid)) { - Log.e(TAG, "Can't start a resolver from uid " + callerUid); - finish(); + @Override + protected final void onStart() { + super.onStart(); + this.getWindow().addSystemFlags(SYSTEM_FLAG_HIDE_NON_SYSTEM_OVERLAY_WINDOWS); + } + + @Override + protected void onStop() { + super.onStop(); + + final Window window = this.getWindow(); + final WindowManager.LayoutParams attrs = window.getAttributes(); + attrs.privateFlags &= ~SYSTEM_FLAG_HIDE_NON_SYSTEM_OVERLAY_WINDOWS; + window.setAttributes(attrs); + + if (mRegistered) { + mPersonalPackageMonitor.unregister(); + if (mWorkPackageMonitor != null) { + mWorkPackageMonitor.unregister(); + } + mRegistered = false; + } + final Intent intent = getIntent(); + if ((intent.getFlags() & FLAG_ACTIVITY_NEW_TASK) != 0 && !isVoiceInteraction() + && !mResolvingHome) { + // This resolver is in the unusual situation where it has been + // launched at the top of a new task. We don't let it be added + // to the recent tasks shown to the user, and we need to make sure + // that each time we are launched we get the correct launching + // uid (not re-using the same resolver from an old launching uid), + // so we will now finish ourself since being no longer visible, + // the user probably can't get back to us. + if (!isChangingConfigurations()) { + finish(); + } } + } - ValidationResult result = readResolverRequest(mActivityModel); - if (result instanceof Invalid) { - ((Invalid) result).getErrors().forEach(new Consumer() { - @Override - public void accept(Finding finding) { - FindingsKt.log(finding, TAG); + @Override + protected final void onSaveInstanceState(@NonNull Bundle outState) { + super.onSaveInstanceState(outState); + ViewPager viewPager = findViewById(com.android.internal.R.id.profile_pager); + if (viewPager != null) { + outState.putInt(LAST_SHOWN_TAB_KEY, viewPager.getCurrentItem()); + } + } + + @Override + protected final void onRestart() { + super.onRestart(); + if (!mRegistered) { + mPersonalPackageMonitor.register( + this, + getMainLooper(), + mProfiles.getPersonalHandle(), + false); + if (mProfiles.getWorkProfilePresent()) { + if (mWorkPackageMonitor == null) { + mWorkPackageMonitor = createPackageMonitor( + mMultiProfilePagerAdapter.getWorkListAdapter()); } - }); - finish(); + mWorkPackageMonitor.register( + this, + getMainLooper(), + mProfiles.getWorkHandle(), + false); + } + mRegistered = true; + } + mMultiProfilePagerAdapter.getActiveListAdapter().handlePackagesChanged(); + } + + @Override + protected void onDestroy() { + super.onDestroy(); + if (!isChangingConfigurations() && mPickOptionRequest != null) { + mPickOptionRequest.cancel(); + } + if (mMultiProfilePagerAdapter != null + && mMultiProfilePagerAdapter.getActiveListAdapter() != null) { + mMultiProfilePagerAdapter.getActiveListAdapter().onDestroy(); } - mResolverRequest = ((Valid) result).getValue(); - mLogic = createActivityLogic(); - mResolvingHome = mResolverRequest.isResolvingHome(); + } + + private void initialize() { + mViewModel = new ViewModelProvider(this).get(ResolverViewModel.class); + mRequest = mViewModel.getRequest().getValue(); + + mProfiles = new ProfileHelper( + mUserInteractor, + getCoroutineScope(getLifecycle()), + mBackgroundDispatcher, + mFeatureFlags); + + mProfileAvailability = new ProfileAvailability( + mUserInteractor, + getCoroutineScope(getLifecycle()), + mBackgroundDispatcher); + + mProfileAvailability.setOnProfileStatusChange(this::onWorkProfileStatusUpdated); + + mResolvingHome = mRequest.isResolvingHome(); mTargetDataLoader = new DefaultTargetDataLoader( this, getLifecycle(), - mResolverRequest.isAudioCaptureDevice()); - init(); - restore(savedInstanceState); - } - - private void init() { - Intent intent = mResolverRequest.getIntent(); + mRequest.isAudioCaptureDevice()); // The last argument of createResolverListAdapter is whether to do special handling // of the last used choice to highlight it in the list. We need to always @@ -284,10 +359,10 @@ public class ResolverActivity extends Hilt_ResolverActivity implements // different "last chosen" activities in the different profiles, and PackageManager doesn't // provide any more information to help us select between them. boolean filterLastUsed = !isVoiceInteraction() - && !hasWorkProfile() && !hasCloneProfile(); + && !mProfiles.getWorkProfilePresent() && !mProfiles.getCloneUserPresent(); mMultiProfilePagerAdapter = createMultiProfilePagerAdapter( new Intent[0], - /* resolutionList = */ mResolverRequest.getResolutionList(), + /* resolutionList = */ mRequest.getResolutionList(), filterLastUsed ); if (configureContentView(mTargetDataLoader)) { @@ -299,16 +374,16 @@ public class ResolverActivity extends Hilt_ResolverActivity implements mPersonalPackageMonitor.register( this, getMainLooper(), - requireAnnotatedUserHandles().personalProfileUserHandle, + mProfiles.getPersonalHandle(), false ); - if (hasWorkProfile()) { + if (mProfiles.getWorkProfilePresent()) { mWorkPackageMonitor = createPackageMonitor( mMultiProfilePagerAdapter.getWorkListAdapter()); mWorkPackageMonitor.register( this, getMainLooper(), - requireAnnotatedUserHandles().workProfileUserHandle, + mProfiles.getWorkHandle(), false ); } @@ -337,7 +412,7 @@ public class ResolverActivity extends Hilt_ResolverActivity implements mResolverDrawerLayout = rdl; } - + Intent intent = mViewModel.getRequest().getValue().getIntent(); final Set categories = intent.getCategories(); MetricsLogger.action(this, mMultiProfilePagerAdapter.getActiveListAdapter().hasFilteredItem() ? MetricsProto.MetricsEvent.ACTION_SHOW_APP_DISAMBIG_APP_FEATURED @@ -364,7 +439,7 @@ public class ResolverActivity extends Hilt_ResolverActivity implements List resolutionList, boolean filterLastUsed) { ResolverMultiProfilePagerAdapter resolverMultiProfilePagerAdapter = null; - if (hasWorkProfile()) { + if (mProfiles.getWorkProfilePresent()) { resolverMultiProfilePagerAdapter = createResolverMultiProfilePagerAdapterForTwoProfiles( initialIntents, resolutionList, filterLastUsed); @@ -407,12 +482,11 @@ public class ResolverActivity extends Hilt_ResolverActivity implements /* devicePolicyEventCategory= */ ResolverActivity.METRICS_CATEGORY_RESOLVER); - return new ResolverNoCrossProfileEmptyStateProvider( - requireAnnotatedUserHandles().personalProfileUserHandle, + return new NoCrossProfileEmptyStateProvider( + mProfiles, noWorkToPersonalEmptyState, noPersonalToWorkEmptyState, - createCrossProfileIntentsChecker(), - requireAnnotatedUserHandles().tabOwnerUserHandleForLaunch); + createCrossProfileIntentsChecker()); } /** @@ -432,12 +506,12 @@ public class ResolverActivity extends Hilt_ResolverActivity implements mFooterSpacer = new Space(getApplicationContext()); } else { ((ResolverMultiProfilePagerAdapter) mMultiProfilePagerAdapter) - .getActiveAdapterView().removeFooterView(mFooterSpacer); + .getActiveAdapterView().removeFooterView(mFooterSpacer); } mFooterSpacer.setLayoutParams(new AbsListView.LayoutParams(LayoutParams.MATCH_PARENT, - mSystemWindowInsets.bottom)); + mSystemWindowInsets.bottom)); ((ResolverMultiProfilePagerAdapter) mMultiProfilePagerAdapter) - .getActiveAdapterView().addFooterView(mFooterSpacer); + .getActiveAdapterView().addFooterView(mFooterSpacer); } protected WindowInsets onApplyWindowInsets(View v, WindowInsets insets) { @@ -466,7 +540,7 @@ public class ResolverActivity extends Hilt_ResolverActivity implements public void onConfigurationChanged(Configuration newConfig) { super.onConfigurationChanged(newConfig); mMultiProfilePagerAdapter.getActiveListAdapter().handlePackagesChanged(); - if (hasWorkProfile() && !useLayoutWithDefault() + if (mProfiles.getWorkProfilePresent() && !useLayoutWithDefault() && !shouldUseMiniResolver()) { updateIntentPickerPaddings(); } @@ -481,52 +555,6 @@ public class ResolverActivity extends Hilt_ResolverActivity implements return R.layout.resolver_list; } - @Override - protected void onStop() { - super.onStop(); - - final Window window = this.getWindow(); - final WindowManager.LayoutParams attrs = window.getAttributes(); - attrs.privateFlags &= ~SYSTEM_FLAG_HIDE_NON_SYSTEM_OVERLAY_WINDOWS; - window.setAttributes(attrs); - - if (mRegistered) { - mPersonalPackageMonitor.unregister(); - if (mWorkPackageMonitor != null) { - mWorkPackageMonitor.unregister(); - } - mRegistered = false; - } - final Intent intent = getIntent(); - if ((intent.getFlags() & FLAG_ACTIVITY_NEW_TASK) != 0 && !isVoiceInteraction() - && !mResolvingHome) { - // This resolver is in the unusual situation where it has been - // launched at the top of a new task. We don't let it be added - // to the recent tasks shown to the user, and we need to make sure - // that each time we are launched we get the correct launching - // uid (not re-using the same resolver from an old launching uid), - // so we will now finish ourself since being no longer visible, - // the user probably can't get back to us. - if (!isChangingConfigurations()) { - finish(); - } - } - // TODO: should we clean up the work-profile manager before we potentially finish() above? - mLogic.getWorkProfileAvailabilityManager().unregisterWorkProfileStateReceiver(this); - } - - @Override - protected void onDestroy() { - super.onDestroy(); - if (!isChangingConfigurations() && mPickOptionRequest != null) { - mPickOptionRequest.cancel(); - } - if (mMultiProfilePagerAdapter != null - && mMultiProfilePagerAdapter.getActiveListAdapter() != null) { - mMultiProfilePagerAdapter.getActiveListAdapter().onDestroy(); - } - } - // referenced by layout XML: android:onClick="onButtonClick" public void onButtonClick(View v) { final int id = v.getId(); @@ -582,7 +610,7 @@ public class ResolverActivity extends Hilt_ResolverActivity implements protected void onListRebuilt(ResolverListAdapter listAdapter, boolean rebuildCompleted) { final ItemClickListener listener = new ItemClickListener(); setupAdapterListView((ListView) mMultiProfilePagerAdapter.getActiveAdapterView(), listener); - if (hasWorkProfile()) { + if (mProfiles.getWorkProfilePresent()) { final ResolverDrawerLayout rdl = findViewById(com.android.internal.R.id.contentPanel); if (rdl != null) { rdl.setMaxCollapsedHeight(getResources() @@ -598,7 +626,8 @@ public class ResolverActivity extends Hilt_ResolverActivity implements final Intent intent = target != null ? target.getResolvedIntent() : null; if (intent != null /*&& mMultiProfilePagerAdapter.getActiveListAdapter().hasFilteredItem()*/ - && mMultiProfilePagerAdapter.getActiveListAdapter().getUnfilteredResolveList() != null) { + && mMultiProfilePagerAdapter.getActiveListAdapter().getUnfilteredResolveList() + != null) { // Build a reasonable intent filter, based on what matched. IntentFilter filter = new IntentFilter(); Intent filterIntent; @@ -761,8 +790,8 @@ public class ResolverActivity extends Hilt_ResolverActivity implements ResolverRankerServiceResolverComparator resolverComparator = new ResolverRankerServiceResolverComparator( this, - mResolverRequest.getIntent(), - mActivityModel.getReferrerPackage(), + mRequest.getIntent(), + mViewModel.getActivityModel().getReferrerPackage(), null, null, getResolverRankerServiceUserHandleList(userHandle), @@ -770,17 +799,17 @@ public class ResolverActivity extends Hilt_ResolverActivity implements return new ResolverListController( this, mPackageManager, - mActivityModel.getIntent(), - mActivityModel.getReferrerPackage(), - mActivityModel.getLaunchedFromUid(), + mRequest.getIntent(), + mViewModel.getActivityModel().getReferrerPackage(), + mViewModel.getActivityModel().getLaunchedFromUid(), resolverComparator, - getQueryIntentsUser(userHandle)); + mProfiles.getQueryIntentsHandle(userHandle)); } /** * Finishing procedures to be performed after the list has been rebuilt. *

Subclasses must call postRebuildListInternal at the end of postRebuildList. - * @param rebuildCompleted + * * @return true if the activity is finishing and creation should halt. */ protected boolean postRebuildList(boolean rebuildCompleted) { @@ -802,7 +831,7 @@ public class ResolverActivity extends Hilt_ResolverActivity 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) @@ -814,6 +843,7 @@ public class ResolverActivity extends Hilt_ResolverActivity implements /** * Add a label to signify that the user can pick a different app. + * * @param adapter The adapter used to provide data to item views. */ public void addUseDifferentAppLabelIfNecessary(ResolverListAdapter adapter) { @@ -823,7 +853,7 @@ public class ResolverActivity extends Hilt_ResolverActivity 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); @@ -875,7 +905,7 @@ public class ResolverActivity extends Hilt_ResolverActivity implements public final void onHandlePackagesChanged(ResolverListAdapter listAdapter) { if (!mMultiProfilePagerAdapter.onHandlePackagesChanged( listAdapter, - mLogic.getWorkProfileAvailabilityManager().isWaitingToEnableWorkProfile())) { + mProfileAvailability.getWaitingToEnableProfile())) { // We no longer have any items... just finish the activity. finish(); } @@ -888,13 +918,12 @@ public class ResolverActivity extends Hilt_ResolverActivity implements return new CrossProfileIntentsChecker(getContentResolver()); } - protected Unit onWorkProfileStatusUpdated() { + private void onWorkProfileStatusUpdated() { if (mMultiProfilePagerAdapter.getActiveProfile() == PROFILE_WORK) { mMultiProfilePagerAdapter.rebuildActiveTab(true); } else { mMultiProfilePagerAdapter.clearInactiveProfileCache(); } - return Unit.INSTANCE; } // @NonFinalForTesting @@ -906,9 +935,7 @@ public class ResolverActivity extends Hilt_ResolverActivity implements List resolutionList, boolean filterLastUsed, UserHandle userHandle) { - UserHandle initialIntentsUserSpace = isLaunchedAsCloneProfile() - && userHandle.equals(requireAnnotatedUserHandles().personalProfileUserHandle) - ? requireAnnotatedUserHandles().cloneProfileUserHandle : userHandle; + UserHandle initialIntentsUserSpace = mProfiles.getQueryIntentsHandle(userHandle); return new ResolverListAdapter( context, payloadIntents, @@ -917,7 +944,7 @@ public class ResolverActivity extends Hilt_ResolverActivity implements filterLastUsed, createListController(userHandle), userHandle, - mResolverRequest.getIntent(), + mRequest.getIntent(), this, initialIntentsUserSpace, mTargetDataLoader); @@ -928,8 +955,10 @@ public class ResolverActivity extends Hilt_ResolverActivity implements final EmptyStateProvider blockerEmptyStateProvider = createBlockerEmptyStateProvider(); final EmptyStateProvider workProfileOffEmptyStateProvider = - new ResolverWorkProfilePausedEmptyStateProvider(this, workProfileUserHandle, - mLogic.getWorkProfileAvailabilityManager(), + new WorkProfilePausedEmptyStateProvider( + this, + mProfiles, + mProfileAvailability, /* onSwitchOnWorkSelectedListener= */ () -> { if (mOnSwitchOnWorkSelectedListener != null) { @@ -941,9 +970,9 @@ public class ResolverActivity extends Hilt_ResolverActivity implements final EmptyStateProvider noAppsEmptyStateProvider = new NoAppsAvailableEmptyStateProvider( this, workProfileUserHandle, - requireAnnotatedUserHandles().personalProfileUserHandle, + mProfiles.getPersonalHandle(), getMetricsCategory(), - requireAnnotatedUserHandles().tabOwnerUserHandleForLaunch + mProfiles.getTabOwnerUserHandleForLaunch() ); // Return composite provider, the order matters (the higher, the more priority) @@ -954,18 +983,17 @@ public class ResolverActivity extends Hilt_ResolverActivity implements ); } - private ResolverMultiProfilePagerAdapter - createResolverMultiProfilePagerAdapterForOneProfile( - Intent[] initialIntents, - List resolutionList, - boolean filterLastUsed) { + private ResolverMultiProfilePagerAdapter createResolverMultiProfilePagerAdapterForOneProfile( + Intent[] initialIntents, + List resolutionList, + boolean filterLastUsed) { ResolverListAdapter personalAdapter = createResolverListAdapter( /* context */ this, - mResolverRequest.getPayloadIntents(), + mRequest.getPayloadIntents(), initialIntents, resolutionList, filterLastUsed, - /* userHandle */ requireAnnotatedUserHandles().personalProfileUserHandle + /* userHandle */ mProfiles.getPersonalHandle() ); return new ResolverMultiProfilePagerAdapter( /* context */ this, @@ -980,12 +1008,12 @@ public class ResolverActivity extends Hilt_ResolverActivity implements /* workProfileQuietModeChecker= */ () -> false, /* defaultProfile= */ PROFILE_PERSONAL, /* workProfileUserHandle= */ null, - requireAnnotatedUserHandles().cloneProfileUserHandle); + mProfiles.getCloneHandle()); } private UserHandle getIntentUser() { - return Objects.requireNonNullElse(mResolverRequest.getCallingUser(), - requireAnnotatedUserHandles().tabOwnerUserHandleForLaunch); + return Objects.requireNonNullElse(mRequest.getCallingUser(), + mProfiles.getTabOwnerUserHandleForLaunch()); } private ResolverMultiProfilePagerAdapter createResolverMultiProfilePagerAdapterForTwoProfiles( @@ -997,10 +1025,10 @@ public class ResolverActivity extends Hilt_ResolverActivity implements // this happens, we check for it here and set the current profile's tab. int selectedProfile = getCurrentProfile(); UserHandle intentUser = getIntentUser(); - if (!requireAnnotatedUserHandles().tabOwnerUserHandleForLaunch.equals(intentUser)) { - if (requireAnnotatedUserHandles().personalProfileUserHandle.equals(intentUser)) { + if (!mProfiles.getTabOwnerUserHandleForLaunch().equals(intentUser)) { + if (mProfiles.getPersonalHandle().equals(intentUser)) { selectedProfile = PROFILE_PERSONAL; - } else if (requireAnnotatedUserHandles().workProfileUserHandle.equals(intentUser)) { + } else if (mProfiles.getWorkHandle().equals(intentUser)) { selectedProfile = PROFILE_WORK; } } else { @@ -1014,17 +1042,17 @@ public class ResolverActivity extends Hilt_ResolverActivity implements // resolver list. So filterLastUsed should be false for the other profile. ResolverListAdapter personalAdapter = createResolverListAdapter( /* context */ this, - mResolverRequest.getPayloadIntents(), + mRequest.getPayloadIntents(), selectedProfile == PROFILE_PERSONAL ? initialIntents : null, resolutionList, (filterLastUsed && UserHandle.myUserId() - == requireAnnotatedUserHandles().personalProfileUserHandle.getIdentifier()), - /* userHandle */ requireAnnotatedUserHandles().personalProfileUserHandle + == mProfiles.getPersonalHandle().getIdentifier()), + /* userHandle */ mProfiles.getPersonalHandle() ); - UserHandle workProfileUserHandle = requireAnnotatedUserHandles().workProfileUserHandle; + UserHandle workProfileUserHandle = mProfiles.getWorkHandle(); ResolverListAdapter workAdapter = createResolverListAdapter( /* context */ this, - mResolverRequest.getPayloadIntents(), + mRequest.getPayloadIntents(), selectedProfile == PROFILE_WORK ? initialIntents : null, resolutionList, (filterLastUsed && UserHandle.myUserId() @@ -1047,10 +1075,13 @@ public class ResolverActivity extends Hilt_ResolverActivity implements TAB_TAG_WORK, workAdapter)), createEmptyStateProvider(workProfileUserHandle), - () -> mLogic.getWorkProfileAvailabilityManager().isQuietModeEnabled(), + /* Supplier (QuietMode enabled) == !(available) */ + () -> !(mProfiles.getWorkProfilePresent() + && mProfileAvailability.isAvailable( + requireNonNull(mProfiles.getWorkProfile()))), selectedProfile, workProfileUserHandle, - requireAnnotatedUserHandles().cloneProfileUserHandle); + mProfiles.getCloneHandle()); } /** @@ -1058,7 +1089,7 @@ public class ResolverActivity extends Hilt_ResolverActivity implements * #EXTRA_SELECTED_PROFILE} extra was supplied, or {@code -1} if no extra was supplied. */ final int getSelectedProfileExtra() { - Profile.Type selected = mResolverRequest.getSelectedProfile(); + Profile.Type selected = mRequest.getSelectedProfile(); if (selected == null) { return -1; } @@ -1070,29 +1101,11 @@ public class ResolverActivity extends Hilt_ResolverActivity implements } protected final @ProfileType int getCurrentProfile() { - UserHandle launchUser = requireAnnotatedUserHandles().tabOwnerUserHandleForLaunch; - UserHandle personalUser = requireAnnotatedUserHandles().personalProfileUserHandle; + UserHandle launchUser = mProfiles.getTabOwnerUserHandleForLaunch(); + UserHandle personalUser = mProfiles.getPersonalHandle(); return launchUser.equals(personalUser) ? PROFILE_PERSONAL : PROFILE_WORK; } - private AnnotatedUserHandles requireAnnotatedUserHandles() { - return requireNonNull(mLogic.getAnnotatedUserHandles()); - } - - private boolean hasWorkProfile() { - return requireAnnotatedUserHandles().workProfileUserHandle != null; - } - - private boolean hasCloneProfile() { - return requireAnnotatedUserHandles().cloneProfileUserHandle != null; - } - - protected final boolean isLaunchedAsCloneProfile() { - UserHandle launchUser = requireAnnotatedUserHandles().userHandleSharesheetLaunchedAs; - UserHandle cloneUser = requireAnnotatedUserHandles().cloneProfileUserHandle; - return hasCloneProfile() && launchUser.equals(cloneUser); - } - private void updateIntentPickerPaddings() { View titleCont = findViewById(com.android.internal.R.id.title_container); titleCont.setPadding( @@ -1109,14 +1122,15 @@ public class ResolverActivity extends Hilt_ResolverActivity implements } private void maybeLogCrossProfileTargetLaunch(TargetInfo cti, UserHandle currentUserHandle) { - if (!hasWorkProfile() || currentUserHandle.equals(getUser())) { + // TODO: Test isolation bug, referencing getUser() will break tests with faked profiles + if (!mProfiles.getWorkProfilePresent() || currentUserHandle.equals(getUser())) { return; } DevicePolicyEventLogger .createEvent(DevicePolicyEnums.RESOLVER_CROSS_PROFILE_TARGET_OPENED) .setBoolean( currentUserHandle.equals( - requireAnnotatedUserHandles().personalProfileUserHandle)) + mProfiles.getPersonalHandle())) .setStrings(getMetricsCategory(), cti.isInDirectShareMetricsCategory() ? "direct_share" : "other_target") .write(); @@ -1172,56 +1186,6 @@ public class ResolverActivity extends Hilt_ResolverActivity implements } } - @Override - protected final void onRestart() { - super.onRestart(); - if (!mRegistered) { - mPersonalPackageMonitor.register( - this, - getMainLooper(), - requireAnnotatedUserHandles().personalProfileUserHandle, - false); - if (hasWorkProfile()) { - if (mWorkPackageMonitor == null) { - mWorkPackageMonitor = createPackageMonitor( - mMultiProfilePagerAdapter.getWorkListAdapter()); - } - mWorkPackageMonitor.register( - this, - getMainLooper(), - requireAnnotatedUserHandles().workProfileUserHandle, - false); - } - mRegistered = true; - } - WorkProfileAvailabilityManager workProfileAvailabilityManager = - mLogic.getWorkProfileAvailabilityManager(); - if (hasWorkProfile() && workProfileAvailabilityManager.isWaitingToEnableWorkProfile()) { - if (workProfileAvailabilityManager.isQuietModeEnabled()) { - workProfileAvailabilityManager.markWorkProfileEnabledBroadcastReceived(); - } - } - mMultiProfilePagerAdapter.getActiveListAdapter().handlePackagesChanged(); - } - - @Override - protected final void onSaveInstanceState(@NonNull Bundle outState) { - super.onSaveInstanceState(outState); - ViewPager viewPager = findViewById(com.android.internal.R.id.profile_pager); - if (viewPager != null) { - outState.putInt(LAST_SHOWN_TAB_KEY, viewPager.getCurrentItem()); - } - } - - @Override - protected final void onStart() { - super.onStart(); - this.getWindow().addSystemFlags(SYSTEM_FLAG_HIDE_NON_SYSTEM_OVERLAY_WINDOWS); - if (hasWorkProfile()) { - mLogic.getWorkProfileAvailabilityManager().registerWorkProfileStateReceiver(this); - } - } - private boolean hasManagedProfile() { UserManager userManager = (UserManager) getSystemService(Context.USER_SERVICE); if (userManager == null) { @@ -1261,7 +1225,7 @@ public class ResolverActivity extends Hilt_ResolverActivity implements // In case of clonedProfile being active, we do not allow the 'Always' option in the // disambiguation dialog of Personal Profile as the package manager cannot distinguish // between cross-profile preferred activities. - if (hasCloneProfile() + if (mProfiles.getCloneUserPresent() && (mMultiProfilePagerAdapter.getActiveProfile() == PROFILE_PERSONAL)) { mAlwaysButton.setEnabled(false); return; @@ -1295,7 +1259,7 @@ public class ResolverActivity extends Hilt_ResolverActivity implements if (!hasRecordPermission) { // OK, we know the record permission, is this a capture device - boolean hasAudioCapture = mResolverRequest.isAudioCaptureDevice(); + boolean hasAudioCapture = mViewModel.getRequest().getValue().isAudioCaptureDevice(); enabled = !hasAudioCapture; } } @@ -1378,7 +1342,8 @@ public class ResolverActivity extends Hilt_ResolverActivity implements // 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. // To date, we really only care about "partially rebuilding" tabs for work and/or personal. - boolean rebuildCompleted = mMultiProfilePagerAdapter.rebuildTabs(hasWorkProfile()); + boolean rebuildCompleted = + mMultiProfilePagerAdapter.rebuildTabs(mProfiles.getWorkProfilePresent()); if (shouldUseMiniResolver()) { configureMiniResolverContent(targetDataLoader); @@ -1392,7 +1357,8 @@ public class ResolverActivity extends Hilt_ResolverActivity implements mLayoutId = getLayoutResource(); } setContentView(mLayoutId); - mMultiProfilePagerAdapter.setupViewPager(findViewById(com.android.internal.R.id.profile_pager)); + mMultiProfilePagerAdapter.setupViewPager( + findViewById(com.android.internal.R.id.profile_pager)); boolean result = postRebuildList(rebuildCompleted); Trace.endSection(); return result; @@ -1483,7 +1449,7 @@ public class ResolverActivity extends Hilt_ResolverActivity implements // If needed, show that intent is forwarded // from managed profile to owner or other way around. String profileSwitchMessage = - mIntentForwarding.forwardMessageFor(mResolverRequest.getIntent()); + mIntentForwarding.forwardMessageFor(mRequest.getIntent()); if (profileSwitchMessage != null) { Toast.makeText(this, profileSwitchMessage, Toast.LENGTH_LONG).show(); } @@ -1493,9 +1459,10 @@ public class ResolverActivity extends Hilt_ResolverActivity implements } } catch (RuntimeException e) { Slog.wtf(TAG, - "Unable to launch as uid " + mActivityModel.getLaunchedFromUid() - + " package " + getLaunchedFromPackage() + ", while running in " - + ActivityThread.currentProcessName(), e); + "Unable to launch as uid " + + mViewModel.getActivityModel().getLaunchedFromUid() + + " package " + mViewModel.getActivityModel().getLaunchedFromPackage() + + ", while running in " + ActivityThread.currentProcessName(), e); } } @@ -1515,7 +1482,7 @@ public class ResolverActivity extends Hilt_ResolverActivity implements setupViewVisibilities(); - if (hasWorkProfile()) { + if (mProfiles.getWorkProfilePresent()) { setupProfileTabs(); } @@ -1638,7 +1605,7 @@ public class ResolverActivity extends Hilt_ResolverActivity implements DevicePolicyEventLogger .createEvent(DevicePolicyEnums.RESOLVER_AUTOLAUNCH_CROSS_PROFILE_TARGET) .setBoolean(activeListAdapter.getUserHandle() - .equals(requireAnnotatedUserHandles().personalProfileUserHandle)) + .equals(mProfiles.getPersonalHandle())) .setStrings(getMetricsCategory()) .write(); safelyStartActivity(activeProfileTarget); @@ -1697,7 +1664,7 @@ public class ResolverActivity extends Hilt_ResolverActivity implements DevicePolicyEventLogger .createEvent(DevicePolicyEnums.RESOLVER_AUTOLAUNCH_CROSS_PROFILE_TARGET) .setBoolean(activeListAdapter.getUserHandle() - .equals(requireAnnotatedUserHandles().personalProfileUserHandle)) + .equals(mProfiles.getPersonalHandle())) .setStrings(getMetricsCategory()) .write(); safelyStartActivity(activeProfileTarget); @@ -1755,17 +1722,17 @@ public class ResolverActivity extends Hilt_ResolverActivity 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) { titleView.setVisibility(View.GONE); } } - - CharSequence title = mResolverRequest.getTitle() != null - ? mResolverRequest.getTitle() - : getTitleForAction(mResolverRequest.getIntent(), 0); + ResolverRequest request = mViewModel.getRequest().getValue(); + CharSequence title = mViewModel.getRequest().getValue().getTitle() != null + ? request.getTitle() + : getTitleForAction(request.getIntent(), 0); if (!TextUtils.isEmpty(title)) { final TextView titleView = findViewById(com.android.internal.R.id.title); @@ -1811,7 +1778,7 @@ public class ResolverActivity extends Hilt_ResolverActivity implements // We only use the default app layout when the profile of the active user has a // filtered item. We always show the same default app even in the inactive user profile. return mMultiProfilePagerAdapter.getListAdapterForUserHandle( - requireAnnotatedUserHandles().tabOwnerUserHandleForLaunch + mProfiles.getTabOwnerUserHandleForLaunch() ).hasFilteredItem(); } @@ -1943,7 +1910,7 @@ public class ResolverActivity extends Hilt_ResolverActivity implements * {@link ResolverListController} configured for the provided {@code userHandle}. */ protected final UserHandle getQueryIntentsUser(UserHandle userHandle) { - return requireAnnotatedUserHandles().getQueryIntentsUser(userHandle); + return mProfiles.getQueryIntentsHandle(userHandle); } /** @@ -1963,9 +1930,9 @@ public class ResolverActivity extends Hilt_ResolverActivity implements // 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; } diff --git a/java/src/com/android/intentresolver/v2/ResolverActivityLogic.kt b/java/src/com/android/intentresolver/v2/ResolverActivityLogic.kt deleted file mode 100644 index 7eb63ab3..00000000 --- a/java/src/com/android/intentresolver/v2/ResolverActivityLogic.kt +++ /dev/null @@ -1,18 +0,0 @@ -package com.android.intentresolver.v2 - -import androidx.activity.ComponentActivity -import androidx.annotation.OpenForTesting - -/** Activity logic for [ResolverActivity]. */ -@OpenForTesting -open class ResolverActivityLogic( - tag: String, - activity: ComponentActivity, - onWorkProfileStatusUpdated: () -> Unit, -) : - ActivityLogic, - CommonActivityLogic by CommonActivityLogicImpl( - tag, - activity, - onWorkProfileStatusUpdated, - ) diff --git a/java/src/com/android/intentresolver/v2/ResolverHelper.kt b/java/src/com/android/intentresolver/v2/ResolverHelper.kt new file mode 100644 index 00000000..388b30a7 --- /dev/null +++ b/java/src/com/android/intentresolver/v2/ResolverHelper.kt @@ -0,0 +1,129 @@ +/* + * Copyright (C) 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.intentresolver.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 com.android.intentresolver.inject.Background +import com.android.intentresolver.v2.annotation.JavaInterop +import com.android.intentresolver.v2.domain.interactor.UserInteractor +import com.android.intentresolver.v2.ui.model.ResolverRequest +import com.android.intentresolver.v2.ui.viewmodel.ResolverViewModel +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 javax.inject.Inject +import kotlinx.coroutines.CoroutineDispatcher + +private const val TAG: String = "ResolverHelper" + +/** + * __Purpose__ + * + * Cleanup aid. Provides a pathway to cleaner code. + * + * __Incoming References__ + * + * ResolverHelper must not expose any properties or functions directly back to ResolverActivity. If + * a value or operation is required by ResolverActivity, then it must be added to + * ResolverInitializer (or a new interface as appropriate) with ResolverActivity supplying a + * callback to receive it at the appropriate point. This enforces unidirectional control flow. + * + * __Outgoing References__ + * + * _ResolverActivity_ + * + * This class must only reference it's host as Activity/ComponentActivity; no down-cast to + * [ResolverActivity]. Other components should be created here or supplied via Injection, and not + * referenced directly from the activity. This prevents circular dependencies from forming. If + * necessary, during cleanup the dependency can be supplied back to ChooserActivity as described + * above in 'Incoming References', see [ResolverInitializer]. + * + * _Elsewhere_ + * + * Where possible, Singleton and ActivityScoped dependencies should be injected here instead of + * referenced from an existing location. If not available for injection, the value should be + * constructed here, then provided to where it is needed. + */ +@ActivityScoped +@JavaInterop +class ResolverHelper +@Inject +constructor( + hostActivity: Activity, + private val userInteractor: UserInteractor, + @Background private val background: CoroutineDispatcher, +) : DefaultLifecycleObserver { + // This is guaranteed by Hilt, since only a ComponentActivity is injectable. + private val activity: ComponentActivity = hostActivity as ComponentActivity + private val viewModel by activity.viewModels() + + private lateinit var activityInitializer: Runnable + + init { + activity.lifecycle.addObserver(this) + } + + /** + * Set the initialization hook for the host activity. + * + * This _must_ be called from [ResolverActivity.onCreate]. + */ + fun setInitializer(initializer: Runnable) { + if (activity.lifecycle.currentState != Lifecycle.State.INITIALIZED) { + error("setInitializer must be called before onCreate returns") + } + activityInitializer = initializer + } + + /** Invoked by Lifecycle, after Activity.onCreate() _returns_. */ + override fun onCreate(owner: LifecycleOwner) { + Log.i(TAG, "CREATE") + Log.i(TAG, "${viewModel.activityModel}") + + val callerUid: Int = viewModel.activityModel.launchedFromUid + if (callerUid < 0 || UserHandle.isIsolated(callerUid)) { + Log.e(TAG, "Can't start a resolver from uid $callerUid") + activity.finish() + return + } + + when (val request = viewModel.initialRequest) { + is Valid -> initializeActivity(request) + is Invalid -> reportErrorsAndFinish(request) + } + } + + private fun reportErrorsAndFinish(request: Invalid) { + request.errors.forEach { it.log(TAG) } + activity.finish() + } + + private fun initializeActivity(request: Valid) { + Log.d(TAG, "initializeActivity") + request.warnings.forEach { it.log(TAG) } + + activityInitializer.run() + } +} diff --git a/java/src/com/android/intentresolver/v2/emptystate/ResolverNoCrossProfileEmptyStateProvider.java b/java/src/com/android/intentresolver/v2/emptystate/ResolverNoCrossProfileEmptyStateProvider.java deleted file mode 100644 index f133c31d..00000000 --- a/java/src/com/android/intentresolver/v2/emptystate/ResolverNoCrossProfileEmptyStateProvider.java +++ /dev/null @@ -1,138 +0,0 @@ -/* - * Copyright (C) 2022 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.android.intentresolver.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/ResolverWorkProfilePausedEmptyStateProvider.java b/java/src/com/android/intentresolver/v2/emptystate/ResolverWorkProfilePausedEmptyStateProvider.java deleted file mode 100644 index eaed35a7..00000000 --- a/java/src/com/android/intentresolver/v2/emptystate/ResolverWorkProfilePausedEmptyStateProvider.java +++ /dev/null @@ -1,116 +0,0 @@ -/* - * Copyright (C) 2022 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.android.intentresolver.v2.emptystate; - -import static android.app.admin.DevicePolicyResources.Strings.Core.RESOLVER_WORK_PAUSED_TITLE; - -import android.app.admin.DevicePolicyEventLogger; -import android.app.admin.DevicePolicyManager; -import android.content.Context; -import android.os.UserHandle; -import android.stats.devicepolicy.nano.DevicePolicyEnums; - -import androidx.annotation.NonNull; -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; - -/** - * ResolverActivity empty state provider that returns empty state which is shown when - * work profile is paused and we need to show a button to enable it. - */ -public class ResolverWorkProfilePausedEmptyStateProvider implements EmptyStateProvider { - - private final UserHandle mWorkProfileUserHandle; - private final WorkProfileAvailabilityManager mWorkProfileAvailability; - private final String mMetricsCategory; - private final OnSwitchOnWorkSelectedListener mOnSwitchOnWorkSelectedListener; - private final Context mContext; - - public ResolverWorkProfilePausedEmptyStateProvider(@NonNull Context context, - @Nullable UserHandle workProfileUserHandle, - @NonNull WorkProfileAvailabilityManager workProfileAvailability, - @Nullable OnSwitchOnWorkSelectedListener onSwitchOnWorkSelectedListener, - @NonNull String metricsCategory) { - mContext = context; - mWorkProfileUserHandle = workProfileUserHandle; - mWorkProfileAvailability = workProfileAvailability; - mMetricsCategory = metricsCategory; - mOnSwitchOnWorkSelectedListener = onSwitchOnWorkSelectedListener; - } - - @Nullable - @Override - public EmptyState getEmptyState(ResolverListAdapter resolverListAdapter) { - if (!resolverListAdapter.getUserHandle().equals(mWorkProfileUserHandle) - || !mWorkProfileAvailability.isQuietModeEnabled() - || resolverListAdapter.getCount() == 0) { - return null; - } - - final String title = mContext.getSystemService(DevicePolicyManager.class) - .getResources().getString(RESOLVER_WORK_PAUSED_TITLE, - () -> mContext.getString(R.string.resolver_turn_on_work_apps)); - - return new WorkProfileOffEmptyState(title, (tab) -> { - tab.showSpinner(); - if (mOnSwitchOnWorkSelectedListener != null) { - mOnSwitchOnWorkSelectedListener.onSwitchOnWorkSelected(); - } - mWorkProfileAvailability.requestQuietModeEnabled(false); - }, mMetricsCategory); - } - - public static class WorkProfileOffEmptyState implements EmptyState { - - private final String mTitle; - private final ClickListener mOnClick; - private final String mMetricsCategory; - - public WorkProfileOffEmptyState(String title, @NonNull ClickListener onClick, - @NonNull String metricsCategory) { - mTitle = title; - mOnClick = onClick; - mMetricsCategory = metricsCategory; - } - - @Nullable - @Override - public String getTitle() { - return mTitle; - } - - @Nullable - @Override - public ClickListener getButtonClickListener() { - return mOnClick; - } - - @Override - public void onEmptyStateShown() { - DevicePolicyEventLogger - .createEvent(DevicePolicyEnums.RESOLVER_EMPTY_STATE_WORK_APPS_DISABLED) - .setStrings(mMetricsCategory) - .write(); - } - } -} diff --git a/java/src/com/android/intentresolver/v2/ui/viewmodel/ResolverViewModel.kt b/java/src/com/android/intentresolver/v2/ui/viewmodel/ResolverViewModel.kt new file mode 100644 index 00000000..eb6a1b96 --- /dev/null +++ b/java/src/com/android/intentresolver/v2/ui/viewmodel/ResolverViewModel.kt @@ -0,0 +1,70 @@ +/* + * Copyright (C) 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.intentresolver.v2.ui.viewmodel + +import android.util.Log +import androidx.lifecycle.SavedStateHandle +import androidx.lifecycle.ViewModel +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.ResolverRequest +import com.android.intentresolver.v2.validation.Invalid +import com.android.intentresolver.v2.validation.Valid +import dagger.hilt.android.lifecycle.HiltViewModel +import javax.inject.Inject +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow + +private const val TAG = "ResolverViewModel" + +@HiltViewModel +class ResolverViewModel @Inject constructor(args: SavedStateHandle) : ViewModel() { + + /** Parcelable-only references provided from the creating Activity */ + val activityModel: ActivityModel = + requireNotNull(args[ACTIVITY_MODEL_KEY]) { + "ActivityModel missing in SavedStateHandle! ($ACTIVITY_MODEL_KEY)" + } + + /** + * Provided only for the express purpose of early exit in the event of an invalid request. + * + * Note: [request] can only be safely accessed after checking if this value is [Valid]. + */ + internal val initialRequest = readResolverRequest(activityModel) + + private lateinit var _request: MutableStateFlow + + /** + * A [StateFlow] of [ResolverRequest]. + * + * Note: Only safe to access after checking if [initialRequest] is [Valid]. + */ + lateinit var request: StateFlow + private set + + init { + when (initialRequest) { + is Valid -> { + _request = MutableStateFlow(initialRequest.value) + request = _request.asStateFlow() + } + is Invalid -> Log.w(TAG, "initialRequest is Invalid, initialization failed") + } + } +} diff --git a/tests/activity/src/com/android/intentresolver/ResolverActivityTest.java b/tests/activity/src/com/android/intentresolver/ResolverActivityTest.java index 05d397a2..81f6f5a6 100644 --- a/tests/activity/src/com/android/intentresolver/ResolverActivityTest.java +++ b/tests/activity/src/com/android/intentresolver/ResolverActivityTest.java @@ -49,9 +49,9 @@ import android.view.View; import android.widget.RelativeLayout; import android.widget.TextView; -import androidx.test.platform.app.InstrumentationRegistry; import androidx.test.espresso.Espresso; import androidx.test.espresso.NoMatchingViewException; +import androidx.test.platform.app.InstrumentationRegistry; import androidx.test.rule.ActivityTestRule; import androidx.test.runner.AndroidJUnit4; diff --git a/tests/activity/src/com/android/intentresolver/v2/ResolverActivityTest.java b/tests/activity/src/com/android/intentresolver/v2/ResolverActivityTest.java index 21fe2904..220a12cc 100644 --- a/tests/activity/src/com/android/intentresolver/v2/ResolverActivityTest.java +++ b/tests/activity/src/com/android/intentresolver/v2/ResolverActivityTest.java @@ -25,6 +25,7 @@ import static androidx.test.espresso.matcher.ViewMatchers.isDisplayed; import static androidx.test.espresso.matcher.ViewMatchers.isEnabled; import static androidx.test.espresso.matcher.ViewMatchers.withId; import static androidx.test.espresso.matcher.ViewMatchers.withText; +import static androidx.test.platform.app.InstrumentationRegistry.getInstrumentation; import static com.android.intentresolver.MatcherUtils.first; import static com.android.intentresolver.v2.ResolverWrapperActivity.sOverrides; @@ -55,16 +56,23 @@ import androidx.test.espresso.NoMatchingViewException; import androidx.test.rule.ActivityTestRule; import androidx.test.runner.AndroidJUnit4; -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.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 com.android.intentresolver.widget.ResolverDrawerLayout; import com.google.android.collect.Lists; +import dagger.hilt.android.testing.BindValue; import dagger.hilt.android.testing.HiltAndroidRule; import dagger.hilt.android.testing.HiltAndroidTest; +import dagger.hilt.android.testing.UninstallModules; import org.junit.Before; import org.junit.Ignore; @@ -81,19 +89,15 @@ import java.util.List; */ @RunWith(AndroidJUnit4.class) @HiltAndroidTest +@UninstallModules(UserRepositoryModule.class) public class ResolverActivityTest { - private static final UserHandle PERSONAL_USER_HANDLE = androidx.test.platform.app - .InstrumentationRegistry.getInstrumentation().getTargetContext().getUser(); + private static final UserHandle PERSONAL_USER_HANDLE = + getInstrumentation().getTargetContext().getUser(); private static final UserHandle WORK_PROFILE_USER_HANDLE = UserHandle.of(10); private static final UserHandle CLONE_PROFILE_USER_HANDLE = UserHandle.of(11); - - protected Intent getConcreteIntentForLaunch(Intent clientIntent) { - clientIntent.setClass( - androidx.test.platform.app.InstrumentationRegistry.getInstrumentation().getTargetContext(), - ResolverWrapperActivity.class); - return clientIntent; - } + private static final User WORK_PROFILE_USER = + new User(WORK_PROFILE_USER_HANDLE.getIdentifier(), User.Role.WORK); @Rule(order = 0) public HiltAndroidRule mHiltAndroidRule = new HiltAndroidRule(this); @@ -106,14 +110,30 @@ public class ResolverActivityTest { public void setup() { // TODO: use the other form of `adoptShellPermissionIdentity()` where we explicitly list the // permissions we require (which we'll read from the manifest at runtime). - androidx.test.platform.app.InstrumentationRegistry - .getInstrumentation() + getInstrumentation() .getUiAutomation() .adoptShellPermissionIdentity(); sOverrides.reset(); } + @BindValue + @ApplicationUser + public final UserHandle mApplicationUser = PERSONAL_USER_HANDLE; + + @BindValue + @ProfileParent + public final UserHandle mProfileParent = PERSONAL_USER_HANDLE; + + /** For setup of test state, a mutable reference of mUserRepository */ + private final FakeUserRepository mFakeUserRepo = + new FakeUserRepository(List.of( + new User(PERSONAL_USER_HANDLE.getIdentifier(), User.Role.PERSONAL) + )); + + @BindValue + public final UserRepository mUserRepository = mFakeUserRepo; + @Test public void twoOptionsAndUserSelectsOne() throws InterruptedException { Intent sendIntent = createSendImageIntent(); @@ -404,15 +424,14 @@ public class ResolverActivityTest { @Test public void testWorkTab_workTabUsesExpectedAdapter() { + markOtherProfileAvailability(/* workAvailable= */ true, /* cloneAvailable= */ false); List personalResolvedComponentInfos = createResolvedComponentsForTestWithOtherProfile(3, /* userId */ 10, PERSONAL_USER_HANDLE); - markOtherProfileAvailability(/* workAvailable= */ true, /* cloneAvailable= */ false); List workResolvedComponentInfos = createResolvedComponentsForTest(4, WORK_PROFILE_USER_HANDLE); setupResolverControllers(personalResolvedComponentInfos, workResolvedComponentInfos); Intent sendIntent = createSendImageIntent(); - markOtherProfileAvailability(/* workAvailable= */ true, /* cloneAvailable= */ false); final ResolverWrapperActivity activity = mActivityRule.launchActivity(sendIntent); waitForIdle(); @@ -424,9 +443,9 @@ public class ResolverActivityTest { @Test public void testWorkTab_personalTabUsesExpectedAdapter() { + markOtherProfileAvailability(/* workAvailable= */ true, /* cloneAvailable= */ false); List personalResolvedComponentInfos = createResolvedComponentsForTestWithOtherProfile(3, PERSONAL_USER_HANDLE); - markOtherProfileAvailability(/* workAvailable= */ true, /* cloneAvailable= */ false); List workResolvedComponentInfos = createResolvedComponentsForTest(4, WORK_PROFILE_USER_HANDLE); setupResolverControllers(personalResolvedComponentInfos, workResolvedComponentInfos); @@ -464,7 +483,8 @@ public class ResolverActivityTest { public void testWorkTab_selectingWorkTabAppOpensAppInWorkProfile() throws InterruptedException { markOtherProfileAvailability(/* workAvailable= */ true, /* cloneAvailable= */ false); List personalResolvedComponentInfos = - createResolvedComponentsForTestWithOtherProfile(3, /* userId */ 10, + createResolvedComponentsForTestWithOtherProfile(3, + /* userId */ WORK_PROFILE_USER_HANDLE.getIdentifier(), PERSONAL_USER_HANDLE); List workResolvedComponentInfos = createResolvedComponentsForTest(4, WORK_PROFILE_USER_HANDLE); @@ -622,7 +642,7 @@ public class ResolverActivityTest { PERSONAL_USER_HANDLE); List workResolvedComponentInfos = createResolvedComponentsForTest(workProfileTargets, WORK_PROFILE_USER_HANDLE); - sOverrides.isQuietModeEnabled = true; + mFakeUserRepo.updateState(WORK_PROFILE_USER, false); setupResolverControllers(personalResolvedComponentInfos, workResolvedComponentInfos); Intent sendIntent = createSendImageIntent(); sendIntent.setType("TestType"); @@ -670,7 +690,7 @@ public class ResolverActivityTest { setupResolverControllers(personalResolvedComponentInfos, workResolvedComponentInfos); Intent sendIntent = createSendImageIntent(); sendIntent.setType("TestType"); - sOverrides.isQuietModeEnabled = true; + mFakeUserRepo.updateState(WORK_PROFILE_USER, false); sOverrides.hasCrossProfileIntents = false; mActivityRule.launchActivity(sendIntent); @@ -740,7 +760,7 @@ public class ResolverActivityTest { setupResolverControllers(personalResolvedComponentInfos, workResolvedComponentInfos); Intent sendIntent = createSendImageIntent(); sendIntent.setType("TestType"); - sOverrides.isQuietModeEnabled = true; + mFakeUserRepo.updateState(WORK_PROFILE_USER, false); mActivityRule.launchActivity(sendIntent); waitForIdle(); @@ -1068,18 +1088,14 @@ public class ResolverActivityTest { } 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( + new User(WORK_PROFILE_USER_HANDLE.getIdentifier(), User.Role.WORK), true); } if (cloneAvailable) { - handles.setCloneProfileUserHandle(CLONE_PROFILE_USER_HANDLE); + mFakeUserRepo.addUser( + new User(CLONE_PROFILE_USER_HANDLE.getIdentifier(), User.Role.CLONE), true); } - sOverrides.annotatedUserHandles = handles.build(); } private void setupResolverControllers( @@ -1095,21 +1111,14 @@ public class ResolverActivityTest { Mockito.anyBoolean(), Mockito.anyBoolean(), Mockito.isA(List.class), - eq(UserHandle.SYSTEM))) - .thenReturn(new ArrayList<>(personalResolvedComponentInfos)); - when(sOverrides.workResolverListController.getResolversForIntentAsUser( - Mockito.anyBoolean(), - Mockito.anyBoolean(), - Mockito.anyBoolean(), - Mockito.isA(List.class), - eq(UserHandle.SYSTEM))) + eq(PERSONAL_USER_HANDLE))) .thenReturn(new ArrayList<>(personalResolvedComponentInfos)); when(sOverrides.workResolverListController.getResolversForIntentAsUser( Mockito.anyBoolean(), Mockito.anyBoolean(), Mockito.anyBoolean(), Mockito.isA(List.class), - eq(UserHandle.of(10)))) + eq(WORK_PROFILE_USER_HANDLE))) .thenReturn(new ArrayList<>(workResolvedComponentInfos)); } } diff --git a/tests/activity/src/com/android/intentresolver/v2/ResolverWrapperActivity.java b/tests/activity/src/com/android/intentresolver/v2/ResolverWrapperActivity.java index 2e29be11..e3d2edbb 100644 --- a/tests/activity/src/com/android/intentresolver/v2/ResolverWrapperActivity.java +++ b/tests/activity/src/com/android/intentresolver/v2/ResolverWrapperActivity.java @@ -24,7 +24,6 @@ import static org.mockito.Mockito.when; import android.annotation.Nullable; import android.content.Context; import android.content.Intent; -import android.content.pm.PackageManager; import android.content.pm.ResolveInfo; import android.graphics.drawable.Drawable; import android.os.Bundle; @@ -34,10 +33,8 @@ import android.util.Pair; import androidx.annotation.NonNull; import androidx.test.espresso.idling.CountingIdlingResource; -import com.android.intentresolver.AnnotatedUserHandles; import com.android.intentresolver.ResolverListAdapter; import com.android.intentresolver.ResolverListController; -import com.android.intentresolver.WorkProfileAvailabilityManager; import com.android.intentresolver.chooser.DisplayResolveInfo; import com.android.intentresolver.chooser.SelectableTargetInfo; import com.android.intentresolver.chooser.TargetInfo; @@ -45,8 +42,6 @@ import com.android.intentresolver.emptystate.CrossProfileIntentsChecker; import com.android.intentresolver.icons.LabelInfo; import com.android.intentresolver.icons.TargetDataLoader; -import kotlin.Unit; - import java.util.List; import java.util.function.Consumer; import java.util.function.Function; @@ -60,19 +55,6 @@ public class ResolverWrapperActivity extends ResolverActivity { private final CountingIdlingResource mLabelIdlingResource = new CountingIdlingResource("LoadLabelTask"); - @Override - protected final ResolverActivityLogic createActivityLogic() { - return new TestResolverActivityLogic( - "ResolverWrapper", - this, - () -> { - onWorkProfileStatusUpdated(); - return Unit.INSTANCE; - }, - sOverrides - ); - } - public CountingIdlingResource getLabelIdlingResource() { return mLabelIdlingResource; } @@ -154,12 +136,6 @@ public class ResolverWrapperActivity extends ResolverActivity { super.startActivityAsUser(intent, options, user); } - @Override - protected List getResolverRankerServiceUserHandleListInternal(UserHandle - userHandle) { - return super.getResolverRankerServiceUserHandleListInternal(userHandle); - } - /** * We cannot directly mock the activity created since instrumentation creates it. *

@@ -167,58 +143,19 @@ public class ResolverWrapperActivity extends ResolverActivity { */ public static class OverrideData { @SuppressWarnings("Since15") - public Function createPackageManager; public Function, Boolean> onSafelyStartInternalCallback; public ResolverListController resolverListController; public ResolverListController workResolverListController; public Boolean isVoiceInteraction; - public AnnotatedUserHandles annotatedUserHandles; - public Integer myUserId; public boolean hasCrossProfileIntents; - public boolean isQuietModeEnabled; - public WorkProfileAvailabilityManager mWorkProfileAvailability; public CrossProfileIntentsChecker mCrossProfileIntentsChecker; public void reset() { onSafelyStartInternalCallback = null; isVoiceInteraction = null; - createPackageManager = null; resolverListController = mock(ResolverListController.class); workResolverListController = mock(ResolverListController.class); - annotatedUserHandles = AnnotatedUserHandles.newBuilder() - .setUserIdOfCallingApp(1234) // Must be non-negative. - .setUserHandleSharesheetLaunchedAs(UserHandle.SYSTEM) - .setPersonalProfileUserHandle(UserHandle.SYSTEM) - .build(); - myUserId = null; hasCrossProfileIntents = true; - isQuietModeEnabled = false; - - mWorkProfileAvailability = new WorkProfileAvailabilityManager(null, null, null) { - @Override - public boolean isQuietModeEnabled() { - return isQuietModeEnabled; - } - - @Override - public boolean isWorkProfileUserUnlocked() { - return true; - } - - @Override - public void requestQuietModeEnabled(boolean enabled) { - isQuietModeEnabled = enabled; - } - - @Override - public void markWorkProfileEnabledBroadcastReceived() {} - - @Override - public boolean isWaitingToEnableWorkProfile() { - return false; - } - }; - mCrossProfileIntentsChecker = mock(CrossProfileIntentsChecker.class); when(mCrossProfileIntentsChecker.hasCrossProfileIntents(any(), anyInt(), anyInt())) .thenAnswer(invocation -> hasCrossProfileIntents); diff --git a/tests/activity/src/com/android/intentresolver/v2/TestResolverActivityLogic.kt b/tests/activity/src/com/android/intentresolver/v2/TestResolverActivityLogic.kt deleted file mode 100644 index 6826f23d..00000000 --- a/tests/activity/src/com/android/intentresolver/v2/TestResolverActivityLogic.kt +++ /dev/null @@ -1,22 +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 [ResolverActivity]. */ -class TestResolverActivityLogic( - tag: String, - activity: ComponentActivity, - onWorkProfileStatusUpdated: () -> Unit, - private val overrideData: ResolverWrapperActivity.OverrideData, -) : ResolverActivityLogic(tag, activity, onWorkProfileStatusUpdated) { - - override val annotatedUserHandles: AnnotatedUserHandles? by lazy { - overrideData.annotatedUserHandles - } - - override val workProfileAvailabilityManager: WorkProfileAvailabilityManager by lazy { - overrideData.mWorkProfileAvailability ?: super.workProfileAvailabilityManager - } -} -- cgit v1.2.3-59-g8ed1b From 6f0791d450bfcbfbb2424912531338de354644f7 Mon Sep 17 00:00:00 2001 From: Mark Renouf Date: Wed, 3 Apr 2024 15:37:12 -0400 Subject: Collapse v2 fork This is an internal merge of v2.* code back into a single set of code. The ChooserSelector mechanism is removed and usage of the 'modular_framework' AConfig flag is also removed. Test: atest --test-mapping packages/modules/IntentResolver Bug: NA Flag: None Change-Id: I8bb34613e5a042cfbcd8fe2654b8121560a47b03 --- AndroidManifest-app.xml | 41 +- .../intentresolver/AnnotatedUserHandles.java | 217 -- .../intentresolver/ChooserActionFactory.java | 112 +- .../android/intentresolver/ChooserActivity.java | 1784 +++++++---- .../com/android/intentresolver/ChooserHelper.kt | 168 ++ .../ChooserIntegratedDeviceComponents.java | 85 - .../android/intentresolver/ChooserListAdapter.java | 9 +- .../intentresolver/ChooserListController.java | 65 + .../ChooserMultiProfilePagerAdapter.java | 214 -- .../com/android/intentresolver/ChooserSelector.kt | 36 + .../intentresolver/IntentForwarderActivity.java | 9 +- .../com/android/intentresolver/IntentForwarding.kt | 111 + .../com/android/intentresolver/JavaFlowHelper.kt | 30 + .../intentresolver/MultiProfilePagerAdapter.java | 583 ---- .../android/intentresolver/ProfileAvailability.kt | 85 + .../com/android/intentresolver/ProfileHelper.kt | 97 + .../android/intentresolver/ResolverActivity.java | 1594 ++++------ .../com/android/intentresolver/ResolverHelper.kt | 129 + .../ResolverMultiProfilePagerAdapter.java | 120 - .../intentresolver/ShortcutSelectionLogic.java | 4 +- .../intentresolver/annotation/JavaInterop.kt | 28 + .../DisplayResolveInfoAzInfoComparator.java | 44 + .../contentpreview/ShareouselContentPreviewUi.kt | 2 +- .../domain/interactor/ChooserRequestInteractor.kt | 2 +- .../interactor/UpdateChooserRequestInteractor.kt | 2 +- .../domain/update/SelectionChangeCallback.kt | 18 +- .../intentresolver/data/BroadcastSubscriber.kt | 73 + .../intentresolver/data/model/ChooserRequest.kt | 195 ++ .../data/repository/ChooserRequestRepository.kt | 39 + .../data/repository/DevicePolicyResources.kt | 100 + .../intentresolver/data/repository/UserInfoExt.kt | 45 + .../data/repository/UserRepository.kt | 329 ++ .../data/repository/UserRepositoryModule.kt | 53 + .../data/repository/UserScopedService.kt | 67 + .../domain/interactor/UserInteractor.kt | 92 + .../emptystate/EmptyStateUiHelper.java | 117 +- .../NoAppsAvailableEmptyStateProvider.java | 19 +- .../NoCrossProfileEmptyStateProvider.java | 71 +- .../WorkProfilePausedEmptyStateProvider.java | 43 +- .../intentresolver/ext/CreationExtrasExt.kt | 34 + .../com/android/intentresolver/ext/IntentExt.kt | 45 + .../com/android/intentresolver/ext/ParcelExt.kt | 27 + .../intentresolver/icons/TargetDataLoaderModule.kt | 38 + .../intentresolver/inject/ActivityModelModule.kt | 10 +- .../intentresolver/inject/SystemServices.kt | 4 +- .../model/AbstractResolverComparator.java | 35 +- .../model/ResolveInfoAzInfoComparator.java | 44 + .../intentresolver/platform/AppPredictionModule.kt | 42 + .../intentresolver/platform/ImageEditorModule.kt | 35 + .../intentresolver/platform/NearbyShareModule.kt | 32 + .../platform/PlatformSecureSettings.kt | 30 + .../intentresolver/platform/SecureSettings.kt | 25 + .../platform/SecureSettingsModule.kt | 14 + .../intentresolver/profiles/AdapterBinder.java | 31 + .../profiles/ChooserMultiProfilePagerAdapter.java | 212 ++ .../profiles/MultiProfilePagerAdapter.java | 694 +++++ .../profiles/OnProfileSelectedListener.java | 46 + .../profiles/OnSwitchOnWorkSelectedListener.java | 27 + .../intentresolver/profiles/ProfileDescriptor.java | 82 + .../profiles/ResolverMultiProfilePagerAdapter.java | 112 + .../android/intentresolver/profiles/TabConfig.java | 38 + .../android/intentresolver/shared/model/Profile.kt | 52 + .../android/intentresolver/shared/model/User.kt | 52 + .../com/android/intentresolver/ui/ActionTitle.java | 88 + .../intentresolver/ui/ProfilePagerResources.kt | 53 + .../android/intentresolver/ui/ShareResultSender.kt | 163 + .../intentresolver/ui/ShortcutPolicyModule.kt | 94 + .../intentresolver/ui/model/ActivityModel.kt | 81 + .../intentresolver/ui/model/ResolverRequest.kt | 68 + .../android/intentresolver/ui/model/ShareAction.kt | 23 + .../ui/viewmodel/ChooserRequestReader.kt | 198 ++ .../ui/viewmodel/ChooserViewModel.kt | 94 + .../ui/viewmodel/ResolverRequestReader.kt | 59 + .../ui/viewmodel/ResolverViewModel.kt | 70 + .../intentresolver/v2/ChooserActionFactory.java | 400 --- .../android/intentresolver/v2/ChooserActivity.java | 2612 ---------------- .../com/android/intentresolver/v2/ChooserHelper.kt | 168 -- .../intentresolver/v2/ChooserListController.java | 66 - .../android/intentresolver/v2/ChooserSelector.kt | 36 - .../android/intentresolver/v2/IntentForwarding.kt | 111 - .../android/intentresolver/v2/JavaFlowHelper.kt | 30 - .../intentresolver/v2/ProfileAvailability.kt | 85 - .../com/android/intentresolver/v2/ProfileHelper.kt | 97 - .../intentresolver/v2/ResolverActivity.java | 1947 ------------ .../android/intentresolver/v2/ResolverHelper.kt | 129 - .../intentresolver/v2/annotation/JavaInterop.kt | 28 - .../intentresolver/v2/data/BroadcastSubscriber.kt | 73 - .../intentresolver/v2/data/model/ChooserRequest.kt | 209 -- .../v2/data/repository/ChooserRequestRepository.kt | 39 - .../v2/data/repository/DevicePolicyResources.kt | 100 - .../v2/data/repository/UserInfoExt.kt | 45 - .../v2/data/repository/UserRepository.kt | 328 -- .../v2/data/repository/UserRepositoryModule.kt | 53 - .../v2/data/repository/UserScopedService.kt | 67 - .../v2/domain/interactor/UserInteractor.kt | 92 - .../v2/emptystate/EmptyStateUiHelper.java | 141 - .../NoAppsAvailableEmptyStateProvider.java | 157 - .../NoCrossProfileEmptyStateProvider.java | 155 - .../WorkProfilePausedEmptyStateProvider.java | 131 - .../intentresolver/v2/ext/CreationExtrasExt.kt | 34 - .../com/android/intentresolver/v2/ext/IntentExt.kt | 45 - .../com/android/intentresolver/v2/ext/ParcelExt.kt | 27 - .../v2/icons/TargetDataLoaderModule.kt | 40 - .../v2/listcontroller/FilterableComponents.kt | 39 - .../v2/listcontroller/IntentResolver.kt | 70 - .../v2/listcontroller/LastChosenManager.kt | 77 - .../v2/listcontroller/ListController.kt | 21 - .../v2/listcontroller/PermissionChecker.kt | 34 - .../v2/listcontroller/PinnableComponents.kt | 39 - .../v2/listcontroller/ResolveListDeduper.kt | 69 - .../listcontroller/ResolvedComponentFiltering.kt | 121 - .../v2/listcontroller/ResolvedComponentSorting.kt | 108 - .../v2/platform/AppPredictionModule.kt | 42 - .../v2/platform/ImageEditorModule.kt | 35 - .../v2/platform/NearbyShareModule.kt | 32 - .../v2/platform/PlatformSecureSettings.kt | 30 - .../intentresolver/v2/platform/SecureSettings.kt | 25 - .../v2/platform/SecureSettingsModule.kt | 14 - .../intentresolver/v2/profiles/AdapterBinder.java | 31 - .../profiles/ChooserMultiProfilePagerAdapter.java | 212 -- .../v2/profiles/MultiProfilePagerAdapter.java | 694 ----- .../v2/profiles/OnProfileSelectedListener.java | 46 - .../profiles/OnSwitchOnWorkSelectedListener.java | 27 - .../v2/profiles/ProfileDescriptor.java | 82 - .../profiles/ResolverMultiProfilePagerAdapter.java | 112 - .../intentresolver/v2/profiles/TabConfig.java | 38 - .../intentresolver/v2/shared/model/Profile.kt | 52 - .../android/intentresolver/v2/shared/model/User.kt | 52 - .../android/intentresolver/v2/ui/ActionTitle.java | 88 - .../intentresolver/v2/ui/ProfilePagerResources.kt | 53 - .../intentresolver/v2/ui/ShareResultSender.kt | 163 - .../intentresolver/v2/ui/ShortcutPolicyModule.kt | 94 - .../intentresolver/v2/ui/model/ActivityModel.kt | 81 - .../intentresolver/v2/ui/model/ResolverRequest.kt | 68 - .../intentresolver/v2/ui/model/ShareAction.kt | 23 - .../v2/ui/viewmodel/ChooserRequestReader.kt | 198 -- .../v2/ui/viewmodel/ChooserViewModel.kt | 94 - .../v2/ui/viewmodel/ResolverRequestReader.kt | 59 - .../v2/ui/viewmodel/ResolverViewModel.kt | 70 - .../android/intentresolver/v2/util/MutableLazy.kt | 36 - .../intentresolver/v2/validation/Findings.kt | 120 - .../intentresolver/v2/validation/Validation.kt | 137 - .../v2/validation/ValidationResult.kt | 26 - .../v2/validation/types/IntentOrUri.kt | 62 - .../v2/validation/types/ParceledArray.kt | 84 - .../v2/validation/types/SimpleValue.kt | 60 - .../v2/validation/types/Validators.kt | 26 - .../android/intentresolver/validation/Findings.kt | 120 + .../intentresolver/validation/Validation.kt | 137 + .../intentresolver/validation/ValidationResult.kt | 26 + .../intentresolver/validation/types/IntentOrUri.kt | 62 + .../validation/types/ParceledArray.kt | 84 + .../intentresolver/validation/types/SimpleValue.kt | 60 + .../intentresolver/validation/types/Validators.kt | 26 + tests/activity/AndroidManifest.xml | 4 +- .../ChooserActivityOverrideData.java | 53 +- .../intentresolver/ChooserActivityTest.java | 3135 +++++++++++++++++++ .../ChooserActivityWorkProfileTest.java | 504 ++++ .../intentresolver/ChooserWrapperActivity.java | 78 +- .../intentresolver/ResolverActivityTest.java | 82 +- .../intentresolver/ResolverWrapperActivity.java | 92 +- .../UnbundledChooserActivityTest.java | 3130 ------------------- .../UnbundledChooserActivityWorkProfileTest.java | 480 --- .../v2/ChooserActivityOverrideData.java | 88 - .../intentresolver/v2/ChooserWrapperActivity.java | 219 -- .../intentresolver/v2/ResolverActivityTest.java | 1124 ------- .../intentresolver/v2/ResolverWrapperActivity.java | 209 -- .../v2/UnbundledChooserActivityTest.java | 3143 -------------------- .../UnbundledChooserActivityWorkProfileTest.java | 507 ---- .../interactor/PayloadToggleInteractorKosmos.kt | 2 +- .../data/repository/FakeUserRepository.kt | 65 + .../data/repository/V2RepositoryKosmos.kt | 29 + .../android/intentresolver/ext/ParcelableExt.kt | 45 + .../intentresolver/platform/FakeSecureSettings.kt | 44 + .../intentresolver/platform/FakeUserManager.kt | 222 ++ .../v2/data/model/FakeChooserRequest.kt | 26 - .../v2/data/repository/FakeUserRepository.kt | 65 - .../v2/data/repository/V2RepositoryKosmos.kt | 25 - .../android/intentresolver/v2/ext/ParcelableExt.kt | 45 - .../v2/platform/FakeSecureSettings.kt | 44 - .../intentresolver/v2/platform/FakeUserManager.kt | 222 -- .../intentresolver/AnnotatedUserHandlesTest.kt | 79 - .../intentresolver/ChooserActionFactoryTest.kt | 153 +- .../ChooserIntegratedDeviceComponentsTest.kt | 71 - .../intentresolver/ChooserRequestParametersTest.kt | 86 - .../intentresolver/MultiProfilePagerAdapterTest.kt | 277 -- .../intentresolver/ProfileAvailabilityTest.kt | 74 + .../android/intentresolver/ProfileHelperTest.kt | 275 ++ .../interactor/CustomActionsInteractorTest.kt | 13 +- .../interactor/SelectablePreviewInteractorTest.kt | 2 +- .../UpdateChooserRequestInteractorTest.kt | 2 +- .../ui/viewmodel/ShareouselViewModelTest.kt | 2 +- .../com/android/intentresolver/coroutines/Flow.kt | 89 + .../data/repository/FakeUserRepositoryTest.kt | 108 + .../data/repository/UserRepositoryImplTest.kt | 211 ++ .../domain/interactor/UserInteractorTest.kt | 206 ++ .../emptystate/EmptyStateUiHelperTest.kt | 140 +- .../intentresolver/ext/CreationExtrasExtTest.kt | 54 + .../android/intentresolver/ext/IntentExtTest.kt | 85 + .../platform/FakeSecureSettingsTest.kt | 61 + .../intentresolver/platform/FakeUserManagerTest.kt | 128 + .../platform/NearbyShareModuleTest.kt | 79 + .../profiles/MultiProfilePagerAdapterTest.kt | 342 +++ .../intentresolver/ui/ShareResultSenderImplTest.kt | 190 ++ .../intentresolver/ui/model/ActivityModelTest.kt | 107 + .../ui/viewmodel/ChooserRequestTest.kt | 297 ++ .../ui/viewmodel/ResolverRequestTest.kt | 128 + .../intentresolver/v2/ChooserActionFactoryTest.kt | 225 -- .../intentresolver/v2/ProfileAvailabilityTest.kt | 74 - .../android/intentresolver/v2/ProfileHelperTest.kt | 275 -- .../android/intentresolver/v2/coroutines/Flow.kt | 89 - .../v2/data/repository/FakeUserRepositoryTest.kt | 108 - .../v2/data/repository/UserRepositoryImplTest.kt | 211 -- .../v2/domain/interactor/UserInteractorTest.kt | 208 -- .../v2/emptystate/EmptyStateUiHelperTest.kt | 228 -- .../intentresolver/v2/ext/CreationExtrasExtTest.kt | 54 - .../android/intentresolver/v2/ext/IntentExtTest.kt | 85 - .../ChooserRequestFilteredComponentsTest.kt | 61 - .../v2/listcontroller/FakeResolverComparator.kt | 83 - .../v2/listcontroller/FilterableComponentsTest.kt | 77 - .../v2/listcontroller/IntentResolverTest.kt | 499 ---- .../v2/listcontroller/LastChosenManagerTest.kt | 111 - .../v2/listcontroller/PinnableComponentsTest.kt | 74 - .../v2/listcontroller/ResolveListDeduperTest.kt | 125 - .../ResolvedComponentFilteringTest.kt | 309 -- .../listcontroller/ResolvedComponentSortingTest.kt | 197 -- .../SharedPreferencesPinnedComponentsTest.kt | 63 - .../v2/platform/FakeSecureSettingsTest.kt | 61 - .../v2/platform/FakeUserManagerTest.kt | 128 - .../v2/platform/NearbyShareModuleTest.kt | 83 - .../v2/profiles/MultiProfilePagerAdapterTest.kt | 342 --- .../v2/ui/ShareResultSenderImplTest.kt | 190 -- .../v2/ui/model/ActivityModelTest.kt | 107 - .../v2/ui/viewmodel/ChooserRequestTest.kt | 297 -- .../v2/ui/viewmodel/ResolverRequestTest.kt | 128 - .../intentresolver/v2/validation/ValidationTest.kt | 122 - .../v2/validation/types/IntentOrUriTest.kt | 118 - .../v2/validation/types/ParceledArrayTest.kt | 101 - .../v2/validation/types/SimpleValueTest.kt | 77 - .../intentresolver/validation/ValidationTest.kt | 116 + .../validation/types/IntentOrUriTest.kt | 115 + .../validation/types/ParceledArrayTest.kt | 101 + .../validation/types/SimpleValueTest.kt | 76 + 243 files changed, 14278 insertions(+), 29169 deletions(-) delete mode 100644 java/src/com/android/intentresolver/AnnotatedUserHandles.java create mode 100644 java/src/com/android/intentresolver/ChooserHelper.kt delete mode 100644 java/src/com/android/intentresolver/ChooserIntegratedDeviceComponents.java create mode 100644 java/src/com/android/intentresolver/ChooserListController.java delete mode 100644 java/src/com/android/intentresolver/ChooserMultiProfilePagerAdapter.java create mode 100644 java/src/com/android/intentresolver/ChooserSelector.kt create mode 100644 java/src/com/android/intentresolver/IntentForwarding.kt create mode 100644 java/src/com/android/intentresolver/JavaFlowHelper.kt delete mode 100644 java/src/com/android/intentresolver/MultiProfilePagerAdapter.java create mode 100644 java/src/com/android/intentresolver/ProfileAvailability.kt create mode 100644 java/src/com/android/intentresolver/ProfileHelper.kt create mode 100644 java/src/com/android/intentresolver/ResolverHelper.kt delete mode 100644 java/src/com/android/intentresolver/ResolverMultiProfilePagerAdapter.java create mode 100644 java/src/com/android/intentresolver/annotation/JavaInterop.kt create mode 100644 java/src/com/android/intentresolver/chooser/DisplayResolveInfoAzInfoComparator.java create mode 100644 java/src/com/android/intentresolver/data/BroadcastSubscriber.kt create mode 100644 java/src/com/android/intentresolver/data/model/ChooserRequest.kt create mode 100644 java/src/com/android/intentresolver/data/repository/ChooserRequestRepository.kt create mode 100644 java/src/com/android/intentresolver/data/repository/DevicePolicyResources.kt create mode 100644 java/src/com/android/intentresolver/data/repository/UserInfoExt.kt create mode 100644 java/src/com/android/intentresolver/data/repository/UserRepository.kt create mode 100644 java/src/com/android/intentresolver/data/repository/UserRepositoryModule.kt create mode 100644 java/src/com/android/intentresolver/data/repository/UserScopedService.kt create mode 100644 java/src/com/android/intentresolver/domain/interactor/UserInteractor.kt create mode 100644 java/src/com/android/intentresolver/ext/CreationExtrasExt.kt create mode 100644 java/src/com/android/intentresolver/ext/IntentExt.kt create mode 100644 java/src/com/android/intentresolver/ext/ParcelExt.kt create mode 100644 java/src/com/android/intentresolver/icons/TargetDataLoaderModule.kt create mode 100644 java/src/com/android/intentresolver/model/ResolveInfoAzInfoComparator.java create mode 100644 java/src/com/android/intentresolver/platform/AppPredictionModule.kt create mode 100644 java/src/com/android/intentresolver/platform/ImageEditorModule.kt create mode 100644 java/src/com/android/intentresolver/platform/NearbyShareModule.kt create mode 100644 java/src/com/android/intentresolver/platform/PlatformSecureSettings.kt create mode 100644 java/src/com/android/intentresolver/platform/SecureSettings.kt create mode 100644 java/src/com/android/intentresolver/platform/SecureSettingsModule.kt create mode 100644 java/src/com/android/intentresolver/profiles/AdapterBinder.java create mode 100644 java/src/com/android/intentresolver/profiles/ChooserMultiProfilePagerAdapter.java create mode 100644 java/src/com/android/intentresolver/profiles/MultiProfilePagerAdapter.java create mode 100644 java/src/com/android/intentresolver/profiles/OnProfileSelectedListener.java create mode 100644 java/src/com/android/intentresolver/profiles/OnSwitchOnWorkSelectedListener.java create mode 100644 java/src/com/android/intentresolver/profiles/ProfileDescriptor.java create mode 100644 java/src/com/android/intentresolver/profiles/ResolverMultiProfilePagerAdapter.java create mode 100644 java/src/com/android/intentresolver/profiles/TabConfig.java create mode 100644 java/src/com/android/intentresolver/shared/model/Profile.kt create mode 100644 java/src/com/android/intentresolver/shared/model/User.kt create mode 100644 java/src/com/android/intentresolver/ui/ActionTitle.java create mode 100644 java/src/com/android/intentresolver/ui/ProfilePagerResources.kt create mode 100644 java/src/com/android/intentresolver/ui/ShareResultSender.kt create mode 100644 java/src/com/android/intentresolver/ui/ShortcutPolicyModule.kt create mode 100644 java/src/com/android/intentresolver/ui/model/ActivityModel.kt create mode 100644 java/src/com/android/intentresolver/ui/model/ResolverRequest.kt create mode 100644 java/src/com/android/intentresolver/ui/model/ShareAction.kt create mode 100644 java/src/com/android/intentresolver/ui/viewmodel/ChooserRequestReader.kt create mode 100644 java/src/com/android/intentresolver/ui/viewmodel/ChooserViewModel.kt create mode 100644 java/src/com/android/intentresolver/ui/viewmodel/ResolverRequestReader.kt create mode 100644 java/src/com/android/intentresolver/ui/viewmodel/ResolverViewModel.kt delete mode 100644 java/src/com/android/intentresolver/v2/ChooserActionFactory.java delete mode 100644 java/src/com/android/intentresolver/v2/ChooserActivity.java delete mode 100644 java/src/com/android/intentresolver/v2/ChooserHelper.kt delete mode 100644 java/src/com/android/intentresolver/v2/ChooserListController.java delete mode 100644 java/src/com/android/intentresolver/v2/ChooserSelector.kt delete mode 100644 java/src/com/android/intentresolver/v2/IntentForwarding.kt delete mode 100644 java/src/com/android/intentresolver/v2/JavaFlowHelper.kt delete mode 100644 java/src/com/android/intentresolver/v2/ProfileAvailability.kt delete mode 100644 java/src/com/android/intentresolver/v2/ProfileHelper.kt delete mode 100644 java/src/com/android/intentresolver/v2/ResolverActivity.java delete mode 100644 java/src/com/android/intentresolver/v2/ResolverHelper.kt delete mode 100644 java/src/com/android/intentresolver/v2/annotation/JavaInterop.kt delete mode 100644 java/src/com/android/intentresolver/v2/data/BroadcastSubscriber.kt delete mode 100644 java/src/com/android/intentresolver/v2/data/model/ChooserRequest.kt delete mode 100644 java/src/com/android/intentresolver/v2/data/repository/ChooserRequestRepository.kt delete mode 100644 java/src/com/android/intentresolver/v2/data/repository/DevicePolicyResources.kt delete mode 100644 java/src/com/android/intentresolver/v2/data/repository/UserInfoExt.kt delete mode 100644 java/src/com/android/intentresolver/v2/data/repository/UserRepository.kt delete mode 100644 java/src/com/android/intentresolver/v2/data/repository/UserRepositoryModule.kt delete mode 100644 java/src/com/android/intentresolver/v2/data/repository/UserScopedService.kt delete mode 100644 java/src/com/android/intentresolver/v2/domain/interactor/UserInteractor.kt delete mode 100644 java/src/com/android/intentresolver/v2/emptystate/EmptyStateUiHelper.java delete mode 100644 java/src/com/android/intentresolver/v2/emptystate/NoAppsAvailableEmptyStateProvider.java delete mode 100644 java/src/com/android/intentresolver/v2/emptystate/NoCrossProfileEmptyStateProvider.java delete mode 100644 java/src/com/android/intentresolver/v2/emptystate/WorkProfilePausedEmptyStateProvider.java delete mode 100644 java/src/com/android/intentresolver/v2/ext/CreationExtrasExt.kt delete mode 100644 java/src/com/android/intentresolver/v2/ext/IntentExt.kt delete mode 100644 java/src/com/android/intentresolver/v2/ext/ParcelExt.kt delete mode 100644 java/src/com/android/intentresolver/v2/icons/TargetDataLoaderModule.kt delete mode 100644 java/src/com/android/intentresolver/v2/listcontroller/FilterableComponents.kt delete mode 100644 java/src/com/android/intentresolver/v2/listcontroller/IntentResolver.kt delete mode 100644 java/src/com/android/intentresolver/v2/listcontroller/LastChosenManager.kt delete mode 100644 java/src/com/android/intentresolver/v2/listcontroller/ListController.kt delete mode 100644 java/src/com/android/intentresolver/v2/listcontroller/PermissionChecker.kt delete mode 100644 java/src/com/android/intentresolver/v2/listcontroller/PinnableComponents.kt delete mode 100644 java/src/com/android/intentresolver/v2/listcontroller/ResolveListDeduper.kt delete mode 100644 java/src/com/android/intentresolver/v2/listcontroller/ResolvedComponentFiltering.kt delete mode 100644 java/src/com/android/intentresolver/v2/listcontroller/ResolvedComponentSorting.kt delete mode 100644 java/src/com/android/intentresolver/v2/platform/AppPredictionModule.kt delete mode 100644 java/src/com/android/intentresolver/v2/platform/ImageEditorModule.kt delete mode 100644 java/src/com/android/intentresolver/v2/platform/NearbyShareModule.kt delete mode 100644 java/src/com/android/intentresolver/v2/platform/PlatformSecureSettings.kt delete mode 100644 java/src/com/android/intentresolver/v2/platform/SecureSettings.kt delete mode 100644 java/src/com/android/intentresolver/v2/platform/SecureSettingsModule.kt delete mode 100644 java/src/com/android/intentresolver/v2/profiles/AdapterBinder.java delete mode 100644 java/src/com/android/intentresolver/v2/profiles/ChooserMultiProfilePagerAdapter.java delete mode 100644 java/src/com/android/intentresolver/v2/profiles/MultiProfilePagerAdapter.java delete mode 100644 java/src/com/android/intentresolver/v2/profiles/OnProfileSelectedListener.java delete mode 100644 java/src/com/android/intentresolver/v2/profiles/OnSwitchOnWorkSelectedListener.java delete mode 100644 java/src/com/android/intentresolver/v2/profiles/ProfileDescriptor.java delete mode 100644 java/src/com/android/intentresolver/v2/profiles/ResolverMultiProfilePagerAdapter.java delete mode 100644 java/src/com/android/intentresolver/v2/profiles/TabConfig.java delete mode 100644 java/src/com/android/intentresolver/v2/shared/model/Profile.kt delete mode 100644 java/src/com/android/intentresolver/v2/shared/model/User.kt delete mode 100644 java/src/com/android/intentresolver/v2/ui/ActionTitle.java delete mode 100644 java/src/com/android/intentresolver/v2/ui/ProfilePagerResources.kt delete mode 100644 java/src/com/android/intentresolver/v2/ui/ShareResultSender.kt delete mode 100644 java/src/com/android/intentresolver/v2/ui/ShortcutPolicyModule.kt delete mode 100644 java/src/com/android/intentresolver/v2/ui/model/ActivityModel.kt delete mode 100644 java/src/com/android/intentresolver/v2/ui/model/ResolverRequest.kt delete mode 100644 java/src/com/android/intentresolver/v2/ui/model/ShareAction.kt delete mode 100644 java/src/com/android/intentresolver/v2/ui/viewmodel/ChooserRequestReader.kt delete mode 100644 java/src/com/android/intentresolver/v2/ui/viewmodel/ChooserViewModel.kt delete mode 100644 java/src/com/android/intentresolver/v2/ui/viewmodel/ResolverRequestReader.kt delete mode 100644 java/src/com/android/intentresolver/v2/ui/viewmodel/ResolverViewModel.kt delete mode 100644 java/src/com/android/intentresolver/v2/util/MutableLazy.kt delete mode 100644 java/src/com/android/intentresolver/v2/validation/Findings.kt delete mode 100644 java/src/com/android/intentresolver/v2/validation/Validation.kt delete mode 100644 java/src/com/android/intentresolver/v2/validation/ValidationResult.kt delete mode 100644 java/src/com/android/intentresolver/v2/validation/types/IntentOrUri.kt delete mode 100644 java/src/com/android/intentresolver/v2/validation/types/ParceledArray.kt delete mode 100644 java/src/com/android/intentresolver/v2/validation/types/SimpleValue.kt delete mode 100644 java/src/com/android/intentresolver/v2/validation/types/Validators.kt create mode 100644 java/src/com/android/intentresolver/validation/Findings.kt create mode 100644 java/src/com/android/intentresolver/validation/Validation.kt create mode 100644 java/src/com/android/intentresolver/validation/ValidationResult.kt create mode 100644 java/src/com/android/intentresolver/validation/types/IntentOrUri.kt create mode 100644 java/src/com/android/intentresolver/validation/types/ParceledArray.kt create mode 100644 java/src/com/android/intentresolver/validation/types/SimpleValue.kt create mode 100644 java/src/com/android/intentresolver/validation/types/Validators.kt create mode 100644 tests/activity/src/com/android/intentresolver/ChooserActivityTest.java create mode 100644 tests/activity/src/com/android/intentresolver/ChooserActivityWorkProfileTest.java delete mode 100644 tests/activity/src/com/android/intentresolver/UnbundledChooserActivityTest.java delete mode 100644 tests/activity/src/com/android/intentresolver/UnbundledChooserActivityWorkProfileTest.java delete mode 100644 tests/activity/src/com/android/intentresolver/v2/ChooserActivityOverrideData.java delete mode 100644 tests/activity/src/com/android/intentresolver/v2/ChooserWrapperActivity.java delete mode 100644 tests/activity/src/com/android/intentresolver/v2/ResolverActivityTest.java delete mode 100644 tests/activity/src/com/android/intentresolver/v2/ResolverWrapperActivity.java delete mode 100644 tests/activity/src/com/android/intentresolver/v2/UnbundledChooserActivityTest.java delete mode 100644 tests/activity/src/com/android/intentresolver/v2/UnbundledChooserActivityWorkProfileTest.java create mode 100644 tests/shared/src/com/android/intentresolver/data/repository/FakeUserRepository.kt create mode 100644 tests/shared/src/com/android/intentresolver/data/repository/V2RepositoryKosmos.kt create mode 100644 tests/shared/src/com/android/intentresolver/ext/ParcelableExt.kt create mode 100644 tests/shared/src/com/android/intentresolver/platform/FakeSecureSettings.kt create mode 100644 tests/shared/src/com/android/intentresolver/platform/FakeUserManager.kt delete mode 100644 tests/shared/src/com/android/intentresolver/v2/data/model/FakeChooserRequest.kt delete mode 100644 tests/shared/src/com/android/intentresolver/v2/data/repository/FakeUserRepository.kt delete mode 100644 tests/shared/src/com/android/intentresolver/v2/data/repository/V2RepositoryKosmos.kt delete mode 100644 tests/shared/src/com/android/intentresolver/v2/ext/ParcelableExt.kt delete mode 100644 tests/shared/src/com/android/intentresolver/v2/platform/FakeSecureSettings.kt delete mode 100644 tests/shared/src/com/android/intentresolver/v2/platform/FakeUserManager.kt delete mode 100644 tests/unit/src/com/android/intentresolver/AnnotatedUserHandlesTest.kt delete mode 100644 tests/unit/src/com/android/intentresolver/ChooserIntegratedDeviceComponentsTest.kt delete mode 100644 tests/unit/src/com/android/intentresolver/ChooserRequestParametersTest.kt delete mode 100644 tests/unit/src/com/android/intentresolver/MultiProfilePagerAdapterTest.kt create mode 100644 tests/unit/src/com/android/intentresolver/ProfileAvailabilityTest.kt create mode 100644 tests/unit/src/com/android/intentresolver/ProfileHelperTest.kt create mode 100644 tests/unit/src/com/android/intentresolver/coroutines/Flow.kt create mode 100644 tests/unit/src/com/android/intentresolver/data/repository/FakeUserRepositoryTest.kt create mode 100644 tests/unit/src/com/android/intentresolver/data/repository/UserRepositoryImplTest.kt create mode 100644 tests/unit/src/com/android/intentresolver/domain/interactor/UserInteractorTest.kt create mode 100644 tests/unit/src/com/android/intentresolver/ext/CreationExtrasExtTest.kt create mode 100644 tests/unit/src/com/android/intentresolver/ext/IntentExtTest.kt create mode 100644 tests/unit/src/com/android/intentresolver/platform/FakeSecureSettingsTest.kt create mode 100644 tests/unit/src/com/android/intentresolver/platform/FakeUserManagerTest.kt create mode 100644 tests/unit/src/com/android/intentresolver/platform/NearbyShareModuleTest.kt create mode 100644 tests/unit/src/com/android/intentresolver/profiles/MultiProfilePagerAdapterTest.kt create mode 100644 tests/unit/src/com/android/intentresolver/ui/ShareResultSenderImplTest.kt create mode 100644 tests/unit/src/com/android/intentresolver/ui/model/ActivityModelTest.kt create mode 100644 tests/unit/src/com/android/intentresolver/ui/viewmodel/ChooserRequestTest.kt create mode 100644 tests/unit/src/com/android/intentresolver/ui/viewmodel/ResolverRequestTest.kt delete mode 100644 tests/unit/src/com/android/intentresolver/v2/ChooserActionFactoryTest.kt delete mode 100644 tests/unit/src/com/android/intentresolver/v2/ProfileAvailabilityTest.kt delete mode 100644 tests/unit/src/com/android/intentresolver/v2/ProfileHelperTest.kt delete mode 100644 tests/unit/src/com/android/intentresolver/v2/coroutines/Flow.kt delete mode 100644 tests/unit/src/com/android/intentresolver/v2/data/repository/FakeUserRepositoryTest.kt delete mode 100644 tests/unit/src/com/android/intentresolver/v2/data/repository/UserRepositoryImplTest.kt delete mode 100644 tests/unit/src/com/android/intentresolver/v2/domain/interactor/UserInteractorTest.kt delete mode 100644 tests/unit/src/com/android/intentresolver/v2/emptystate/EmptyStateUiHelperTest.kt delete mode 100644 tests/unit/src/com/android/intentresolver/v2/ext/CreationExtrasExtTest.kt delete mode 100644 tests/unit/src/com/android/intentresolver/v2/ext/IntentExtTest.kt delete mode 100644 tests/unit/src/com/android/intentresolver/v2/listcontroller/ChooserRequestFilteredComponentsTest.kt delete mode 100644 tests/unit/src/com/android/intentresolver/v2/listcontroller/FakeResolverComparator.kt delete mode 100644 tests/unit/src/com/android/intentresolver/v2/listcontroller/FilterableComponentsTest.kt delete mode 100644 tests/unit/src/com/android/intentresolver/v2/listcontroller/IntentResolverTest.kt delete mode 100644 tests/unit/src/com/android/intentresolver/v2/listcontroller/LastChosenManagerTest.kt delete mode 100644 tests/unit/src/com/android/intentresolver/v2/listcontroller/PinnableComponentsTest.kt delete mode 100644 tests/unit/src/com/android/intentresolver/v2/listcontroller/ResolveListDeduperTest.kt delete mode 100644 tests/unit/src/com/android/intentresolver/v2/listcontroller/ResolvedComponentFilteringTest.kt delete mode 100644 tests/unit/src/com/android/intentresolver/v2/listcontroller/ResolvedComponentSortingTest.kt delete mode 100644 tests/unit/src/com/android/intentresolver/v2/listcontroller/SharedPreferencesPinnedComponentsTest.kt delete mode 100644 tests/unit/src/com/android/intentresolver/v2/platform/FakeSecureSettingsTest.kt delete mode 100644 tests/unit/src/com/android/intentresolver/v2/platform/FakeUserManagerTest.kt delete mode 100644 tests/unit/src/com/android/intentresolver/v2/platform/NearbyShareModuleTest.kt delete mode 100644 tests/unit/src/com/android/intentresolver/v2/profiles/MultiProfilePagerAdapterTest.kt delete mode 100644 tests/unit/src/com/android/intentresolver/v2/ui/ShareResultSenderImplTest.kt delete mode 100644 tests/unit/src/com/android/intentresolver/v2/ui/model/ActivityModelTest.kt delete mode 100644 tests/unit/src/com/android/intentresolver/v2/ui/viewmodel/ChooserRequestTest.kt delete mode 100644 tests/unit/src/com/android/intentresolver/v2/ui/viewmodel/ResolverRequestTest.kt delete mode 100644 tests/unit/src/com/android/intentresolver/v2/validation/ValidationTest.kt delete mode 100644 tests/unit/src/com/android/intentresolver/v2/validation/types/IntentOrUriTest.kt delete mode 100644 tests/unit/src/com/android/intentresolver/v2/validation/types/ParceledArrayTest.kt delete mode 100644 tests/unit/src/com/android/intentresolver/v2/validation/types/SimpleValueTest.kt create mode 100644 tests/unit/src/com/android/intentresolver/validation/ValidationTest.kt create mode 100644 tests/unit/src/com/android/intentresolver/validation/types/IntentOrUriTest.kt create mode 100644 tests/unit/src/com/android/intentresolver/validation/types/ParceledArrayTest.kt create mode 100644 tests/unit/src/com/android/intentresolver/validation/types/SimpleValueTest.kt (limited to 'java/src') diff --git a/AndroidManifest-app.xml b/AndroidManifest-app.xml index ec4fec85..d45a13e0 100644 --- a/AndroidManifest-app.xml +++ b/AndroidManifest-app.xml @@ -32,42 +32,7 @@ android:requiredForAllUsers="true" android:supportsRtl="true"> - - - - - - - - - - - - - - - - - - - - - + diff --git a/java/src/com/android/intentresolver/AnnotatedUserHandles.java b/java/src/com/android/intentresolver/AnnotatedUserHandles.java deleted file mode 100644 index 3565e757..00000000 --- a/java/src/com/android/intentresolver/AnnotatedUserHandles.java +++ /dev/null @@ -1,217 +0,0 @@ -/* - * Copyright (C) 2022 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.android.intentresolver; - -import android.app.Activity; -import android.app.ActivityManager; -import android.os.UserHandle; -import android.os.UserManager; - -import androidx.annotation.Nullable; -import androidx.annotation.VisibleForTesting; - -/** - * Helper class to precompute the (immutable) designations of various user handles in the system - * that may contribute to the current Sharesheet session. - */ -public final class AnnotatedUserHandles { - /** The user id of the app that started the share activity. */ - public final int userIdOfCallingApp; - - /** - * The {@link UserHandle} that launched Sharesheet. - * TODO: I believe this would always be the handle corresponding to {@code userIdOfCallingApp} - * except possibly if the caller used {@link Activity#startActivityAsUser} to launch - * Sharesheet as a different user than they themselves were running as. Verify and document. - */ - public final UserHandle userHandleSharesheetLaunchedAs; - - /** - * The {@link UserHandle} that owns the "personal tab" in a tabbed share UI (or the *only* 'tab' - * in a non-tabbed UI). - * - * This is never a work or clone user, but may either be the root user (0) or a "secondary" - * multi-user profile (i.e., one that's not root, work, nor clone). This is a "secondary" - * profile only when that user is the active "foreground" user. - * - * In the current implementation, we can assert that this is the root user (0) any time we - * display a tabbed UI (i.e., any time `workProfileUserHandle` is non-null), or any time that we - * have a clone profile. This note is only provided for informational purposes; clients should - * avoid making any reliances on that assumption. - */ - public final UserHandle personalProfileUserHandle; - - /** - * The {@link UserHandle} that owns the "work tab" in a tabbed share UI. This is (an arbitrary) - * one of the "managed" profiles associated with {@link #personalProfileUserHandle}. - */ - @Nullable - public final UserHandle workProfileUserHandle; - - /** - * The {@link UserHandle} of the clone profile belonging to {@link #personalProfileUserHandle}. - */ - @Nullable - public final UserHandle cloneProfileUserHandle; - - /** - * The "tab owner" user handle (i.e., either {@link #personalProfileUserHandle} or - * {@link #workProfileUserHandle}) that either matches or owns the profile of the - * {@link #userHandleSharesheetLaunchedAs}. - * - * In the current implementation, we can assert that this is the same as - * `userHandleSharesheetLaunchedAs` except when the latter is the clone profile; then this is - * the "personal" profile owning that clone profile (which we currently know must belong to - * user 0, but clients should avoid making any reliances on that assumption). - */ - public final UserHandle tabOwnerUserHandleForLaunch; - - /** Compute all handle designations for a new Sharesheet session in the specified activity. */ - public static AnnotatedUserHandles forShareActivity(Activity shareActivity) { - // TODO: consider integrating logic for `ResolverActivity.EXTRA_CALLING_USER`? - UserHandle userHandleSharesheetLaunchedAs = UserHandle.of(UserHandle.myUserId()); - - // ActivityManager.getCurrentUser() refers to the current Foreground user. When clone/work - // profile is active, we always make the personal tab from the foreground user. - // Outside profiles, current foreground user is potentially the same as the sharesheet - // process's user (UserHandle.myUserId()), so we continue to create personal tab with the - // current foreground user. - UserHandle personalProfileUserHandle = UserHandle.of(ActivityManager.getCurrentUser()); - - UserManager userManager = shareActivity.getSystemService(UserManager.class); - - return newBuilder() - .setUserIdOfCallingApp(shareActivity.getLaunchedFromUid()) - .setUserHandleSharesheetLaunchedAs(userHandleSharesheetLaunchedAs) - .setPersonalProfileUserHandle(personalProfileUserHandle) - .setWorkProfileUserHandle( - getWorkProfileForUser(userManager, personalProfileUserHandle)) - .setCloneProfileUserHandle( - getCloneProfileForUser(userManager, personalProfileUserHandle)) - .build(); - } - - @VisibleForTesting public static Builder newBuilder() { - return new Builder(); - } - - /** - * Returns the {@link UserHandle} to use when querying resolutions for intents in a - * {@link ResolverListController} configured for the provided {@code userHandle}. - */ - public UserHandle getQueryIntentsUser(UserHandle userHandle) { - // In case launching app is in clonedProfile, and we are building the personal tab, intent - // resolution will be attempted as clonedUser instead of user 0. This is because intent - // resolution from user 0 and clonedUser is not guaranteed to return same results. - // We do not care about the case when personal adapter is started with non-root user - // (secondary user case), as clone profile is guaranteed to be non-active in that case. - UserHandle queryIntentsUser = userHandle; - if (isLaunchedAsCloneProfile() && userHandle.equals(personalProfileUserHandle)) { - queryIntentsUser = cloneProfileUserHandle; - } - return queryIntentsUser; - } - - private Boolean isLaunchedAsCloneProfile() { - return userHandleSharesheetLaunchedAs.equals(cloneProfileUserHandle); - } - - private AnnotatedUserHandles( - int userIdOfCallingApp, - UserHandle userHandleSharesheetLaunchedAs, - UserHandle personalProfileUserHandle, - @Nullable UserHandle workProfileUserHandle, - @Nullable UserHandle cloneProfileUserHandle) { - if ((userIdOfCallingApp < 0) || UserHandle.isIsolated(userIdOfCallingApp)) { - throw new SecurityException("Can't start a resolver from uid " + userIdOfCallingApp); - } - - this.userIdOfCallingApp = userIdOfCallingApp; - this.userHandleSharesheetLaunchedAs = userHandleSharesheetLaunchedAs; - this.personalProfileUserHandle = personalProfileUserHandle; - this.workProfileUserHandle = workProfileUserHandle; - this.cloneProfileUserHandle = cloneProfileUserHandle; - this.tabOwnerUserHandleForLaunch = - (userHandleSharesheetLaunchedAs == workProfileUserHandle) - ? workProfileUserHandle : personalProfileUserHandle; - } - - @Nullable - private static UserHandle getWorkProfileForUser( - UserManager userManager, UserHandle profileOwnerUserHandle) { - return userManager.getProfiles(profileOwnerUserHandle.getIdentifier()) - .stream() - .filter(info -> info.isManagedProfile()) - .findFirst() - .map(info -> info.getUserHandle()) - .orElse(null); - } - - @Nullable - private static UserHandle getCloneProfileForUser( - UserManager userManager, UserHandle profileOwnerUserHandle) { - return userManager.getProfiles(profileOwnerUserHandle.getIdentifier()) - .stream() - .filter(info -> info.isCloneProfile()) - .findFirst() - .map(info -> info.getUserHandle()) - .orElse(null); - } - - @VisibleForTesting - public static class Builder { - private int mUserIdOfCallingApp; - private UserHandle mUserHandleSharesheetLaunchedAs; - private UserHandle mPersonalProfileUserHandle; - private UserHandle mWorkProfileUserHandle; - private UserHandle mCloneProfileUserHandle; - - public Builder setUserIdOfCallingApp(int id) { - mUserIdOfCallingApp = id; - return this; - } - - public Builder setUserHandleSharesheetLaunchedAs(UserHandle user) { - mUserHandleSharesheetLaunchedAs = user; - return this; - } - - public Builder setPersonalProfileUserHandle(UserHandle user) { - mPersonalProfileUserHandle = user; - return this; - } - - public Builder setWorkProfileUserHandle(UserHandle user) { - mWorkProfileUserHandle = user; - return this; - } - - public Builder setCloneProfileUserHandle(UserHandle user) { - mCloneProfileUserHandle = user; - return this; - } - - public AnnotatedUserHandles build() { - return new AnnotatedUserHandles( - mUserIdOfCallingApp, - mUserHandleSharesheetLaunchedAs, - mPersonalProfileUserHandle, - mWorkProfileUserHandle, - mCloneProfileUserHandle); - } - } -} diff --git a/java/src/com/android/intentresolver/ChooserActionFactory.java b/java/src/com/android/intentresolver/ChooserActionFactory.java index 310fcc27..ffe83fa6 100644 --- a/java/src/com/android/intentresolver/ChooserActionFactory.java +++ b/java/src/com/android/intentresolver/ChooserActionFactory.java @@ -39,6 +39,8 @@ import com.android.intentresolver.chooser.DisplayResolveInfo; import com.android.intentresolver.chooser.TargetInfo; import com.android.intentresolver.contentpreview.ChooserContentPreviewUi; import com.android.intentresolver.logging.EventLog; +import com.android.intentresolver.ui.ShareResultSender; +import com.android.intentresolver.ui.model.ShareAction; import com.android.intentresolver.widget.ActionRow; import com.android.internal.annotations.VisibleForTesting; @@ -46,6 +48,7 @@ import com.google.common.collect.ImmutableList; import java.util.ArrayList; import java.util.List; +import java.util.Optional; import java.util.concurrent.Callable; import java.util.function.Consumer; @@ -53,8 +56,11 @@ import java.util.function.Consumer; * Implementation of {@link ChooserContentPreviewUi.ActionFactory} specialized to the application * requirements of Sharesheet / {@link ChooserActivity}. */ +@SuppressWarnings("OptionalUsedAsFieldOrParameterType") public final class ChooserActionFactory implements ChooserContentPreviewUi.ActionFactory { - /** Delegate interface to launch activities when the actions are selected. */ + /** + * Delegate interface to launch activities when the actions are selected. + */ public interface ActionActivityStarter { /** * Request an activity launch for the provided target. Implementations may choose to exit @@ -92,19 +98,17 @@ public final class ChooserActionFactory implements ChooserContentPreviewUi.Actio private final Context mContext; - @Nullable - private final Runnable mCopyButtonRunnable; - private final Runnable mEditButtonRunnable; + @Nullable private Runnable mCopyButtonRunnable; + private Runnable mEditButtonRunnable; private final ImmutableList mCustomActions; - private final @Nullable ChooserAction mModifyShareAction; private final Consumer mExcludeSharedTextAction; + @Nullable private final ShareResultSender mShareResultSender; private final Consumer mFinishCallback; private final EventLog mLog; /** * @param context - * @param chooserRequest data about the invocation of the current Sharesheet session. - * device to implement the supported action types. + * @param imageEditor an explicit Activity to launch for editing images * @param onUpdateSharedTextIsExcluded a delegate to be invoked when the "exclude shared text" * setting is updated. The argument is whether the shared text is to be excluded. * @param firstVisibleImageQuery a delegate that provides a reference to the first visible image @@ -115,34 +119,39 @@ public final class ChooserActionFactory implements ChooserContentPreviewUi.Actio */ public ChooserActionFactory( Context context, - ChooserRequestParameters chooserRequest, - ChooserIntegratedDeviceComponents integratedDeviceComponents, + Intent targetIntent, + String referrerPackageName, + List chooserActions, + Optional imageEditor, EventLog log, Consumer onUpdateSharedTextIsExcluded, Callable firstVisibleImageQuery, ActionActivityStarter activityStarter, - Consumer finishCallback) { + @Nullable ShareResultSender shareResultSender, + Consumer finishCallback, + ClipboardManager clipboardManager) { this( context, makeCopyButtonRunnable( - context, - chooserRequest.getTargetIntent(), - chooserRequest.getReferrerPackageName(), + clipboardManager, + targetIntent, + referrerPackageName, finishCallback, log), makeEditButtonRunnable( getEditSharingTarget( context, - chooserRequest.getTargetIntent(), - integratedDeviceComponents), + targetIntent, + imageEditor), firstVisibleImageQuery, activityStarter, log), - chooserRequest.getChooserActions(), - chooserRequest.getModifyShareAction(), + chooserActions, onUpdateSharedTextIsExcluded, log, + shareResultSender, finishCallback); + } @VisibleForTesting @@ -151,18 +160,31 @@ public final class ChooserActionFactory implements ChooserContentPreviewUi.Actio @Nullable Runnable copyButtonRunnable, Runnable editButtonRunnable, List customActions, - @Nullable ChooserAction modifyShareAction, Consumer onUpdateSharedTextIsExcluded, EventLog log, + @Nullable ShareResultSender shareResultSender, Consumer finishCallback) { mContext = context; mCopyButtonRunnable = copyButtonRunnable; mEditButtonRunnable = editButtonRunnable; mCustomActions = ImmutableList.copyOf(customActions); - mModifyShareAction = modifyShareAction; mExcludeSharedTextAction = onUpdateSharedTextIsExcluded; mLog = log; + mShareResultSender = shareResultSender; mFinishCallback = finishCallback; + + if (mShareResultSender != null) { + mEditButtonRunnable = () -> { + mShareResultSender.onActionSelected(ShareAction.SYSTEM_EDIT); + editButtonRunnable.run(); + }; + if (mCopyButtonRunnable != null) { + mCopyButtonRunnable = () -> { + mShareResultSender.onActionSelected(ShareAction.SYSTEM_COPY); + copyButtonRunnable.run(); + }; + } + } } @Override @@ -186,11 +208,9 @@ public final class ChooserActionFactory implements ChooserContentPreviewUi.Actio ActionRow.Action actionRow = createCustomAction( mContext, mCustomActions.get(i), - mFinishCallback, - () -> { - mLog.logCustomActionSelected(position); - } - ); + () -> logCustomAction(position), + mShareResultSender, + mFinishCallback); if (actionRow != null) { actions.add(actionRow); } @@ -198,21 +218,6 @@ public final class ChooserActionFactory implements ChooserContentPreviewUi.Actio return actions; } - /** - * Provides a share modification action, if any. - */ - @Override - @Nullable - public ActionRow.Action getModifyShareAction() { - return createCustomAction( - mContext, - mModifyShareAction, - mFinishCallback, - () -> { - mLog.logActionSelected(EventLog.SELECTION_TYPE_MODIFY_SHARE); - }); - } - /** *

* Creates an exclude-text action that can be called when the user changes shared text @@ -229,7 +234,7 @@ public final class ChooserActionFactory implements ChooserContentPreviewUi.Actio @Nullable private static Runnable makeCopyButtonRunnable( - Context context, + ClipboardManager clipboardManager, Intent targetIntent, String referrerPackageName, Consumer finishCallback, @@ -245,8 +250,6 @@ public final class ChooserActionFactory implements ChooserContentPreviewUi.Actio return null; } return () -> { - ClipboardManager clipboardManager = (ClipboardManager) context.getSystemService( - Context.CLIPBOARD_SERVICE); clipboardManager.setPrimaryClipAsPackage(clipData, referrerPackageName); log.logActionSelected(EventLog.SELECTION_TYPE_COPY); @@ -281,15 +284,14 @@ public final class ChooserActionFactory implements ChooserContentPreviewUi.Actio private static TargetInfo getEditSharingTarget( Context context, Intent originalIntent, - ChooserIntegratedDeviceComponents integratedComponents) { - final ComponentName editorComponent = integratedComponents.getEditSharingComponent(); + Optional imageEditor) { final Intent resolveIntent = new Intent(originalIntent); // Retain only URI permission grant flags if present. Other flags may prevent the scene // transition animation from running (i.e FLAG_ACTIVITY_NO_ANIMATION, // FLAG_ACTIVITY_NEW_TASK, FLAG_ACTIVITY_NEW_DOCUMENT) but also not needed. resolveIntent.setFlags(originalIntent.getFlags() & URI_PERMISSION_INTENT_FLAGS); - resolveIntent.setComponent(editorComponent); + imageEditor.ifPresent(resolveIntent::setComponent); resolveIntent.setAction(Intent.ACTION_EDIT); resolveIntent.putExtra(EDIT_SOURCE, EDIT_SOURCE_SHARESHEET); String originalAction = originalIntent.getAction(); @@ -308,7 +310,7 @@ public final class ChooserActionFactory implements ChooserContentPreviewUi.Actio final ResolveInfo ri = context.getPackageManager().resolveActivity( resolveIntent, PackageManager.GET_META_DATA); if (ri == null || ri.activityInfo == null) { - Log.e(TAG, "Device-specified editor (" + editorComponent + ") not available"); + Log.e(TAG, "Device-specified editor (" + imageEditor + ") not available"); return null; } @@ -347,12 +349,13 @@ public final class ChooserActionFactory implements ChooserContentPreviewUi.Actio } @Nullable - private static ActionRow.Action createCustomAction( + static ActionRow.Action createCustomAction( Context context, - ChooserAction action, - Consumer finishCallback, - Runnable loggingRunnable) { - if (action == null || action.getAction() == null) { + @Nullable ChooserAction action, + Runnable loggingRunnable, + ShareResultSender shareResultSender, + Consumer finishCallback) { + if (action == null) { return null; } Drawable icon = action.getIcon().loadDrawable(context); @@ -382,8 +385,15 @@ public final class ChooserActionFactory implements ChooserContentPreviewUi.Actio if (loggingRunnable != null) { loggingRunnable.run(); } + if (shareResultSender != null) { + shareResultSender.onActionSelected(ShareAction.APPLICATION_DEFINED); + } finishCallback.accept(Activity.RESULT_OK); } ); } + + void logCustomAction(int position) { + mLog.logCustomActionSelected(position); + } } diff --git a/java/src/com/android/intentresolver/ChooserActivity.java b/java/src/com/android/intentresolver/ChooserActivity.java index 9557b25b..a2bde24c 100644 --- a/java/src/com/android/intentresolver/ChooserActivity.java +++ b/java/src/com/android/intentresolver/ChooserActivity.java @@ -1,5 +1,5 @@ /* - * Copyright (C) 2008 The Android Open Source Project + * Copyright (C) 2024 The Android Open Source Project * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -16,25 +16,37 @@ package com.android.intentresolver; +import static android.app.VoiceInteractor.PickOptionRequest.Option; import static android.app.admin.DevicePolicyResources.Strings.Core.RESOLVER_CANT_ACCESS_PERSONAL; import static android.app.admin.DevicePolicyResources.Strings.Core.RESOLVER_CANT_ACCESS_WORK; import static android.app.admin.DevicePolicyResources.Strings.Core.RESOLVER_CANT_SHARE_WITH_PERSONAL; import static android.app.admin.DevicePolicyResources.Strings.Core.RESOLVER_CANT_SHARE_WITH_WORK; import static android.app.admin.DevicePolicyResources.Strings.Core.RESOLVER_CROSS_PROFILE_BLOCKED_TITLE; +import static android.content.Intent.FLAG_ACTIVITY_NEW_TASK; import static android.stats.devicepolicy.nano.DevicePolicyEnums.RESOLVER_EMPTY_STATE_NO_SHARING_TO_PERSONAL; import static android.stats.devicepolicy.nano.DevicePolicyEnums.RESOLVER_EMPTY_STATE_NO_SHARING_TO_WORK; +import static android.view.WindowManager.LayoutParams.SYSTEM_FLAG_HIDE_NON_SYSTEM_OVERLAY_WINDOWS; import static androidx.lifecycle.LifecycleKt.getCoroutineScope; +import static com.android.intentresolver.ext.CreationExtrasExtKt.addDefaultArgs; +import static com.android.intentresolver.profiles.MultiProfilePagerAdapter.PROFILE_PERSONAL; +import static com.android.intentresolver.profiles.MultiProfilePagerAdapter.PROFILE_WORK; +import static com.android.intentresolver.ui.model.ActivityModel.ACTIVITY_MODEL_KEY; import static com.android.internal.util.LatencyTracker.ACTION_LOAD_SHARE_SHEET; -import android.app.Activity; +import static java.util.Objects.requireNonNull; + import android.app.ActivityManager; import android.app.ActivityOptions; +import android.app.ActivityThread; +import android.app.VoiceInteractor; +import android.app.admin.DevicePolicyEventLogger; import android.app.prediction.AppPredictor; import android.app.prediction.AppTarget; import android.app.prediction.AppTargetEvent; import android.app.prediction.AppTargetId; +import android.content.ClipboardManager; import android.content.ComponentName; import android.content.ContentResolver; import android.content.Context; @@ -51,25 +63,36 @@ import android.database.Cursor; import android.graphics.Insets; import android.net.Uri; 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; import android.util.Log; import android.util.Slog; -import android.util.SparseArray; +import android.view.Gravity; +import android.view.LayoutInflater; import android.view.View; import android.view.ViewGroup; import android.view.ViewGroup.LayoutParams; import android.view.ViewTreeObserver; +import android.view.Window; import android.view.WindowInsets; +import android.view.WindowManager; +import android.widget.FrameLayout; +import android.widget.ImageView; +import android.widget.TabHost; import android.widget.TextView; +import android.widget.Toast; -import androidx.annotation.IntDef; import androidx.annotation.MainThread; import androidx.annotation.NonNull; import androidx.annotation.Nullable; +import androidx.fragment.app.FragmentActivity; import androidx.lifecycle.ViewModelProvider; +import androidx.lifecycle.viewmodel.CreationExtras; import androidx.recyclerview.widget.GridLayoutManager; import androidx.recyclerview.widget.RecyclerView; import androidx.viewpager.widget.ViewPager; @@ -81,49 +104,83 @@ import com.android.intentresolver.contentpreview.BasePreviewViewModel; import com.android.intentresolver.contentpreview.ChooserContentPreviewUi; import com.android.intentresolver.contentpreview.HeadlineGeneratorImpl; import com.android.intentresolver.contentpreview.PreviewViewModel; +import com.android.intentresolver.data.model.ChooserRequest; +import com.android.intentresolver.data.repository.DevicePolicyResources; +import com.android.intentresolver.domain.interactor.UserInteractor; +import com.android.intentresolver.emptystate.CompositeEmptyStateProvider; +import com.android.intentresolver.emptystate.CrossProfileIntentsChecker; import com.android.intentresolver.emptystate.EmptyState; import com.android.intentresolver.emptystate.EmptyStateProvider; +import com.android.intentresolver.emptystate.NoAppsAvailableEmptyStateProvider; import com.android.intentresolver.emptystate.NoCrossProfileEmptyStateProvider; import com.android.intentresolver.emptystate.NoCrossProfileEmptyStateProvider.DevicePolicyBlockerEmptyState; +import com.android.intentresolver.emptystate.WorkProfilePausedEmptyStateProvider; import com.android.intentresolver.grid.ChooserGridAdapter; -import com.android.intentresolver.icons.DefaultTargetDataLoader; import com.android.intentresolver.icons.TargetDataLoader; +import com.android.intentresolver.inject.Background; import com.android.intentresolver.logging.EventLog; import com.android.intentresolver.measurements.Tracer; import com.android.intentresolver.model.AbstractResolverComparator; import com.android.intentresolver.model.AppPredictionServiceResolverComparator; import com.android.intentresolver.model.ResolverRankerServiceResolverComparator; +import com.android.intentresolver.platform.AppPredictionAvailable; +import com.android.intentresolver.platform.ImageEditor; +import com.android.intentresolver.platform.NearbyShare; +import com.android.intentresolver.profiles.ChooserMultiProfilePagerAdapter; +import com.android.intentresolver.profiles.MultiProfilePagerAdapter.ProfileType; +import com.android.intentresolver.profiles.OnProfileSelectedListener; +import com.android.intentresolver.profiles.OnSwitchOnWorkSelectedListener; +import com.android.intentresolver.profiles.TabConfig; +import com.android.intentresolver.shared.model.Profile; import com.android.intentresolver.shortcuts.AppPredictorFactory; import com.android.intentresolver.shortcuts.ShortcutLoader; +import com.android.intentresolver.ui.ActionTitle; +import com.android.intentresolver.ui.ProfilePagerResources; +import com.android.intentresolver.ui.ShareResultSender; +import com.android.intentresolver.ui.ShareResultSenderFactory; +import com.android.intentresolver.ui.model.ActivityModel; +import com.android.intentresolver.ui.viewmodel.ChooserViewModel; +import com.android.intentresolver.widget.ActionRow; import com.android.intentresolver.widget.ImagePreviewView; +import com.android.intentresolver.widget.ResolverDrawerLayout; import com.android.internal.annotations.VisibleForTesting; import com.android.internal.content.PackageMonitor; +import com.android.internal.logging.MetricsLogger; import com.android.internal.logging.nano.MetricsProto.MetricsEvent; +import com.android.internal.util.LatencyTracker; + +import com.google.common.collect.ImmutableList; import dagger.hilt.android.AndroidEntryPoint; -import java.lang.annotation.Retention; -import java.lang.annotation.RetentionPolicy; -import java.text.Collator; +import kotlin.Pair; + import java.util.ArrayList; +import java.util.Arrays; import java.util.Collections; -import java.util.Comparator; import java.util.HashMap; import java.util.List; import java.util.Map; import java.util.Objects; +import java.util.Optional; +import java.util.Set; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; +import java.util.concurrent.atomic.AtomicLong; import java.util.function.Consumer; +import java.util.function.Supplier; import javax.inject.Inject; +import kotlinx.coroutines.CoroutineDispatcher; + /** * The Chooser Activity handles intent resolution specifically for sharing intents - * for example, as generated by {@see android.content.Intent#createChooser(Intent, CharSequence)}. * */ -@AndroidEntryPoint(ResolverActivity.class) +@SuppressWarnings("OptionalUsedAsFieldOrParameterType") +@AndroidEntryPoint(FragmentActivity.class) public class ChooserActivity extends Hilt_ChooserActivity implements ResolverListAdapter.ResolverListCommunicator, PackagesChangedListener, StartsSelectedItem { private static final String TAG = "ChooserActivity"; @@ -139,7 +196,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"; @@ -148,6 +204,38 @@ public class ChooserActivity extends Hilt_ChooserActivity implements public static final String LAUNCH_LOCATION_DIRECT_SHARE = "direct_share"; private static final String SHORTCUT_TARGET = "shortcut_target"; + ////////////////////////////////////////////////////////////////////////////////////////////// + // Inherited properties. + ////////////////////////////////////////////////////////////////////////////////////////////// + private static final String TAB_TAG_PERSONAL = "personal"; + private static final String TAB_TAG_WORK = "work"; + + private static final String LAST_SHOWN_TAB_KEY = "last_shown_tab_key"; + protected static final String METRICS_CATEGORY_CHOOSER = "intent_chooser"; + + private int mLayoutId; + private UserHandle mHeaderCreatorUser; + private boolean mRegistered; + private PackageMonitor mPersonalPackageMonitor; + private PackageMonitor mWorkPackageMonitor; + protected View mProfileView; + + protected ResolverDrawerLayout mResolverDrawerLayout; + protected ChooserMultiProfilePagerAdapter mChooserMultiProfilePagerAdapter; + protected final LatencyTracker mLatencyTracker = getLatencyTracker(); + + /** See {@link #setRetainInOnStop}. */ + private boolean mRetainInOnStop; + protected Insets mSystemWindowInsets = null; + private ResolverActivity.PickTargetOptionRequest mPickOptionRequest; + + @Nullable + private OnSwitchOnWorkSelectedListener mOnSwitchOnWorkSelectedListener; + + ////////////////////////////////////////////////////////////////////////////////////////////// + ////////////////////////////////////////////////////////////////////////////////////////////// + + // TODO: these data structures are for one-time use in shuttling data from where they're // populated in `ShortcutToChooserTargetConverter` to where they're consumed in // `ShortcutSelectionLogic` which packs the appropriate elements into the final `TargetInfo`. @@ -156,37 +244,37 @@ public class ChooserActivity extends Hilt_ChooserActivity implements private final Map mDirectShareAppTargetCache = new HashMap<>(); private final Map mDirectShareShortcutInfoCache = new HashMap<>(); - public static final int TARGET_TYPE_DEFAULT = 0; - public static final int TARGET_TYPE_CHOOSER_TARGET = 1; - public static final int TARGET_TYPE_SHORTCUTS_FROM_SHORTCUT_MANAGER = 2; - public static final int TARGET_TYPE_SHORTCUTS_FROM_PREDICTION_SERVICE = 3; + static final int TARGET_TYPE_DEFAULT = 0; + static final int TARGET_TYPE_CHOOSER_TARGET = 1; + static final int TARGET_TYPE_SHORTCUTS_FROM_SHORTCUT_MANAGER = 2; + static final int TARGET_TYPE_SHORTCUTS_FROM_PREDICTION_SERVICE = 3; private static final int SCROLL_STATUS_IDLE = 0; private static final int SCROLL_STATUS_SCROLLING_VERTICAL = 1; private static final int SCROLL_STATUS_SCROLLING_HORIZONTAL = 2; - @IntDef({ - TARGET_TYPE_DEFAULT, - TARGET_TYPE_CHOOSER_TARGET, - TARGET_TYPE_SHORTCUTS_FROM_SHORTCUT_MANAGER, - TARGET_TYPE_SHORTCUTS_FROM_PREDICTION_SERVICE - }) - @Retention(RetentionPolicy.SOURCE) - public @interface ShareTargetType {} - + @Inject public UserInteractor mUserInteractor; + @Inject @Background public CoroutineDispatcher mBackgroundDispatcher; + @Inject public ChooserHelper mChooserHelper; @Inject public FeatureFlags mFeatureFlags; + @Inject public android.service.chooser.FeatureFlags mChooserServiceFeatureFlags; @Inject public EventLog mEventLog; - - private ChooserIntegratedDeviceComponents mIntegratedDeviceComponents; - - /* TODO: this is `nullable` because we have to defer the assignment til onCreate(). We make the - * only assignment there, and expect it to be ready by the time we ever use it -- - * someday if we move all the usage to a component with a narrower lifecycle (something that - * matches our Activity's create/destroy lifecycle, not its Java object lifecycle) then we - * should be able to make this assignment as "final." - */ - @Nullable - private ChooserRequestParameters mChooserRequest; + @Inject @AppPredictionAvailable public boolean mAppPredictionAvailable; + @Inject @ImageEditor public Optional mImageEditor; + @Inject @NearbyShare public Optional 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; + + private ActivityModel mActivityModel; + private ChooserRequest mRequest; + private ProfileHelper mProfiles; + private ProfileAvailability mProfileAvailability; + @Nullable private ShareResultSender mShareResultSender; private ChooserRefinementManager mRefinementManager; @@ -214,14 +302,12 @@ public class ChooserActivity extends Hilt_ChooserActivity implements private int mScrollStatus = SCROLL_STATUS_IDLE; - @VisibleForTesting - protected ChooserMultiProfilePagerAdapter mChooserMultiProfilePagerAdapter; private final EnterTransitionAnimationDelegate mEnterTransitionAnimationDelegate = new EnterTransitionAnimationDelegate(this, () -> mResolverDrawerLayout); - private View mContentView = null; + private final View mContentView = null; - private final SparseArray mProfileRecords = new SparseArray<>(); + private final Map mProfileRecords = new HashMap<>(); private boolean mExcludeSharedText = false; /** @@ -232,56 +318,254 @@ public class ChooserActivity extends Hilt_ChooserActivity implements */ private boolean mFinishWhenStopped = false; + private final AtomicLong mIntentReceivedTime = new AtomicLong(-1); + + protected ActivityModel createActivityModel() { + return ActivityModel.createFrom(this); + } + + private ChooserViewModel mViewModel; + + @NonNull + @Override + public CreationExtras getDefaultViewModelCreationExtras() { + return addDefaultArgs( + super.getDefaultViewModelCreationExtras(), + new Pair<>(ACTIVITY_MODEL_KEY, createActivityModel())); + } + @Override protected void onCreate(Bundle savedInstanceState) { - Tracer.INSTANCE.markLaunched(); - final long intentReceivedTime = System.currentTimeMillis(); - mLatencyTracker.onActionStart(ACTION_LOAD_SHARE_SHEET); + super.onCreate(savedInstanceState); + Log.i(TAG, "onCreate"); - try { - mChooserRequest = new ChooserRequestParameters( - getIntent(), - getReferrerPackageName(), - getReferrer()); - } catch (IllegalArgumentException e) { - Log.e(TAG, "Caller provided invalid Chooser request parameters", e); + setTheme(R.style.Theme_DeviceDefault_Chooser); + + // Initializer is invoked when this function returns, via Lifecycle. + mChooserHelper.setInitializer(this::initialize); + if (mChooserServiceFeatureFlags.chooserPayloadToggling()) { + mChooserHelper.setOnChooserRequestChanged(this::onChooserRequestChanged); + } + } + + @Override + protected final void onStart() { + super.onStart(); + this.getWindow().addSystemFlags(SYSTEM_FLAG_HIDE_NON_SYSTEM_OVERLAY_WINDOWS); + } + + @Override + protected final void onResume() { + super.onResume(); + Log.d(TAG, "onResume: " + getComponentName().flattenToShortString()); + mFinishWhenStopped = false; + mRefinementManager.onActivityResume(); + } + + @Override + protected final void onStop() { + super.onStop(); + + final Window window = this.getWindow(); + final WindowManager.LayoutParams attrs = window.getAttributes(); + attrs.privateFlags &= ~SYSTEM_FLAG_HIDE_NON_SYSTEM_OVERLAY_WINDOWS; + window.setAttributes(attrs); + + if (mRegistered) { + mPersonalPackageMonitor.unregister(); + if (mWorkPackageMonitor != null) { + mWorkPackageMonitor.unregister(); + } + mRegistered = false; + } + final Intent intent = getIntent(); + if ((intent.getFlags() & FLAG_ACTIVITY_NEW_TASK) != 0 && !isVoiceInteraction() + && !mRetainInOnStop) { + // This resolver is in the unusual situation where it has been + // launched at the top of a new task. We don't let it be added + // to the recent tasks shown to the user, and we need to make sure + // that each time we are launched we get the correct launching + // uid (not re-using the same resolver from an old launching uid), + // so we will now finish ourself since being no longer visible, + // the user probably can't get back to us. + if (!isChangingConfigurations()) { + finish(); + } + } + + if (mRefinementManager != null) { + mRefinementManager.onActivityStop(isChangingConfigurations()); + } + + if (mFinishWhenStopped) { + mFinishWhenStopped = false; finish(); - super_onCreate(null); - return; } + } + + @Override + protected final void onSaveInstanceState(Bundle outState) { + super.onSaveInstanceState(outState); + ViewPager viewPager = findViewById(com.android.internal.R.id.profile_pager); + if (viewPager != null) { + outState.putInt(LAST_SHOWN_TAB_KEY, viewPager.getCurrentItem()); + } + } + + @Override + protected final void onRestart() { + super.onRestart(); + if (!mRegistered) { + mPersonalPackageMonitor.register( + this, + getMainLooper(), + mProfiles.getPersonalHandle(), + false); + if (mProfiles.getWorkProfilePresent()) { + if (mWorkPackageMonitor == null) { + mWorkPackageMonitor = createPackageMonitor( + mChooserMultiProfilePagerAdapter.getWorkListAdapter()); + } + mWorkPackageMonitor.register( + this, + getMainLooper(), + mProfiles.getWorkHandle(), + false); + } + mRegistered = true; + } + mChooserMultiProfilePagerAdapter.getActiveListAdapter().handlePackagesChanged(); + } + + @Override + protected void onDestroy() { + super.onDestroy(); + if (!isChangingConfigurations() && mPickOptionRequest != null) { + mPickOptionRequest.cancel(); + } + if (mChooserMultiProfilePagerAdapter != null) { + mChooserMultiProfilePagerAdapter.destroy(); + } + + if (isFinishing()) { + mLatencyTracker.onActionCancel(ACTION_LOAD_SHARE_SHEET); + } + + mBackgroundThreadPoolExecutor.shutdownNow(); + + destroyProfileRecords(); + } + + /** DO NOT CALL. Only for use from ChooserHelper as a callback. */ + private void initialize() { + + mViewModel = new ViewModelProvider(this).get(ChooserViewModel.class); + mRequest = mViewModel.getRequest().getValue(); + mActivityModel = mViewModel.getActivityModel(); + + mProfiles = new ProfileHelper( + mUserInteractor, + getCoroutineScope(getLifecycle()), + mBackgroundDispatcher, + mFeatureFlags); + + mProfileAvailability = new ProfileAvailability( + mUserInteractor, + getCoroutineScope(getLifecycle()), + mBackgroundDispatcher); + + mProfileAvailability.setOnProfileStatusChange(this::onWorkProfileStatusUpdated); + + mIntentReceivedTime.set(System.currentTimeMillis()); + mLatencyTracker.onActionStart(ACTION_LOAD_SHARE_SHEET); + mPinnedSharedPrefs = getPinnedSharedPrefs(this); - mMaxTargetsPerRow = getResources().getInteger(R.integer.config_chooser_max_targets_per_row); + updateShareResultSender(); + + mMaxTargetsPerRow = + getResources().getInteger(R.integer.config_chooser_max_targets_per_row); mShouldDisplayLandscape = shouldDisplayLandscape(getResources().getConfiguration().orientation); - setRetainInOnStop(mChooserRequest.shouldRetainInOnStop()); + setRetainInOnStop(mRequest.shouldRetainInOnStop()); createProfileRecords( new AppPredictorFactory( this, - mChooserRequest.getSharedText(), - mChooserRequest.getTargetIntentFilter(), - getPackageManager().getAppPredictionServicePackageName() != null), - mChooserRequest.getTargetIntentFilter()); - - - super.onCreate( - savedInstanceState, - mChooserRequest.getTargetIntent(), - mChooserRequest.getAdditionalTargets(), - mChooserRequest.getTitle(), - mChooserRequest.getDefaultTitleResource(), - mChooserRequest.getInitialIntents(), - /* resolutionList= */ null, - /* supportsAlwaysUseOption= */ false, - new DefaultTargetDataLoader(this, getLifecycle(), false), - /* safeForwardingMode= */ true); + Objects.toString(mRequest.getSharedText(), null), + mRequest.getShareTargetFilter(), + mAppPredictionAvailable + ), + mRequest.getShareTargetFilter() + ); - getEventLog().logSharesheetTriggered(); - mIntegratedDeviceComponents = getIntegratedDeviceComponents(); + mChooserMultiProfilePagerAdapter = createMultiProfilePagerAdapter( + /* context = */ this, + mProfilePagerResources, + mRequest, + mProfiles, + mProfileAvailability, + mRequest.getInitialIntents(), + mMaxTargetsPerRow, + mFeatureFlags); - mRefinementManager = new ViewModelProvider(this).get(ChooserRefinementManager.class); + if (!configureContentView(mTargetDataLoader)) { + mPersonalPackageMonitor = createPackageMonitor( + mChooserMultiProfilePagerAdapter.getPersonalListAdapter()); + mPersonalPackageMonitor.register( + this, + getMainLooper(), + mProfiles.getPersonalHandle(), + false + ); + if (mProfiles.getWorkProfilePresent()) { + mWorkPackageMonitor = createPackageMonitor( + mChooserMultiProfilePagerAdapter.getWorkListAdapter()); + mWorkPackageMonitor.register( + this, + getMainLooper(), + mProfiles.getWorkHandle(), + false + ); + } + mRegistered = true; + final ResolverDrawerLayout rdl = findViewById( + com.android.internal.R.id.contentPanel); + if (rdl != null) { + rdl.setOnDismissedListener(new ResolverDrawerLayout.OnDismissedListener() { + @Override + public void onDismissed() { + finish(); + } + }); + + boolean hasTouchScreen = mPackageManager + .hasSystemFeature(PackageManager.FEATURE_TOUCHSCREEN); + + if (isVoiceInteraction() || !hasTouchScreen) { + rdl.setCollapsed(false); + } + + rdl.setSystemUiVisibility(View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION + | View.SYSTEM_UI_FLAG_LAYOUT_STABLE); + rdl.setOnApplyWindowInsetsListener(this::onApplyWindowInsets); + mResolverDrawerLayout = rdl; + } + + Intent intent = mRequest.getTargetIntent(); + final Set categories = intent.getCategories(); + MetricsLogger.action(this, + mChooserMultiProfilePagerAdapter.getActiveListAdapter().hasFilteredItem() + ? MetricsEvent.ACTION_SHOW_APP_DISAMBIG_APP_FEATURED + : MetricsEvent.ACTION_SHOW_APP_DISAMBIG_NONE_FEATURED, + intent.getAction() + ":" + intent.getType() + ":" + + (categories != null ? Arrays.toString(categories.toArray()) + : "")); + } + + getEventLog().logSharesheetTriggered(); + mRefinementManager = new ViewModelProvider(this).get(ChooserRefinementManager.class); mRefinementManager.getRefinementCompletion().observe(this, completion -> { if (completion.consume()) { TargetInfo targetInfo = completion.getTargetInfo(); @@ -293,100 +577,714 @@ public class ChooserActivity extends Hilt_ChooserActivity implements // can't recover a Chooser session if that's the reason the refined target fails // to launch now. Fire-and-forget the refined launch; ignore the return value // and just make sure the Sharesheet session gets cleaned up regardless. - ChooserActivity.super.onTargetSelected(targetInfo, false); + final ResolveInfo ri = targetInfo.getResolveInfo(); + final Intent intent1 = targetInfo.getResolvedIntent(); + + safelyStartActivity(targetInfo); + + // Rely on the ActivityManager to pop up a dialog regarding app suspension + // and return false + targetInfo.isSuspended(); } finish(); } - }); + }); + BasePreviewViewModel previewViewModel = + new ViewModelProvider(this, createPreviewViewModelFactory()) + .get(BasePreviewViewModel.class); + previewViewModel.init( + mRequest.getTargetIntent(), + mRequest.getAdditionalContentUri(), + mChooserServiceFeatureFlags.chooserPayloadToggling()); + mChooserContentPreviewUi = new ChooserContentPreviewUi( + getCoroutineScope(getLifecycle()), + previewViewModel.getPreviewDataProvider(), + mRequest.getTargetIntent(), + previewViewModel.getImageLoader(), + createChooserActionFactory(), + createModifyShareActionFactory(), + mEnterTransitionAnimationDelegate, + new HeadlineGeneratorImpl(this), + mRequest.getContentTypeHint(), + mRequest.getMetadataText(), + mChooserServiceFeatureFlags.chooserPayloadToggling()); + updateStickyContentPreview(); + if (shouldShowStickyContentPreview() + || mChooserMultiProfilePagerAdapter + .getCurrentRootAdapter().getSystemRowCount() != 0) { + getEventLog().logActionShareWithPreview( + mChooserContentPreviewUi.getPreferredContentPreview()); + } + mChooserShownTime = System.currentTimeMillis(); + final long systemCost = mChooserShownTime - mIntentReceivedTime.get(); + getEventLog().logChooserActivityShown( + isWorkProfile(), mRequest.getTargetType(), systemCost); + if (mResolverDrawerLayout != null) { + mResolverDrawerLayout.addOnLayoutChangeListener(this::handleLayoutChange); + + mResolverDrawerLayout.setOnCollapsedChangedListener( + isCollapsed -> { + mChooserMultiProfilePagerAdapter.setIsCollapsed(isCollapsed); + getEventLog().logSharesheetExpansionChanged(isCollapsed); + }); + } + if (DEBUG) { + Log.d(TAG, "System Time Cost is " + systemCost); + } + getEventLog().logShareStarted( + mRequest.getReferrerPackage(), + mRequest.getTargetType(), + mRequest.getCallerChooserTargets().size(), + mRequest.getInitialIntents().size(), + isWorkProfile(), + mChooserContentPreviewUi.getPreferredContentPreview(), + 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 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 oldAltIntents = oldChooserRequest.getAdditionalTargets(); + List 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(); + } + + ////////////////////////////////////////////////////////////////////////////////////////////// + // Inherited methods + ////////////////////////////////////////////////////////////////////////////////////////////// + + private boolean isAutolaunching() { + return !mRegistered && isFinishing(); + } + + private boolean maybeAutolaunchIfSingleTarget() { + int count = mChooserMultiProfilePagerAdapter.getActiveListAdapter().getUnfilteredCount(); + if (count != 1) { + return false; + } + + if (mChooserMultiProfilePagerAdapter.getActiveListAdapter().getOtherProfile() != null) { + return false; + } + + // Only one target, so we're a candidate to auto-launch! + final TargetInfo target = mChooserMultiProfilePagerAdapter.getActiveListAdapter() + .targetInfoForPosition(0, false); + if (shouldAutoLaunchSingleChoice(target)) { + safelyStartActivity(target); + finish(); + return true; + } + return false; + } + + private boolean isTwoPagePersonalAndWorkConfiguration() { + return (mChooserMultiProfilePagerAdapter.getCount() == 2) + && mChooserMultiProfilePagerAdapter.hasPageForProfile(PROFILE_PERSONAL) + && mChooserMultiProfilePagerAdapter.hasPageForProfile(PROFILE_WORK); + } + + /** + * When we have a personal and a work profile, we auto launch in the following scenario: + * - There is 1 resolved target on each profile + * - That target is the same app on both profiles + * - The target app has permission to communicate cross profiles + * - The target app has declared it supports cross-profile communication via manifest metadata + */ + private boolean maybeAutolaunchIfCrossProfileSupported() { + if (!isTwoPagePersonalAndWorkConfiguration()) { + return false; + } + + ResolverListAdapter activeListAdapter = + (mChooserMultiProfilePagerAdapter.getActiveProfile() == PROFILE_PERSONAL) + ? mChooserMultiProfilePagerAdapter.getPersonalListAdapter() + : mChooserMultiProfilePagerAdapter.getWorkListAdapter(); + + ResolverListAdapter inactiveListAdapter = + (mChooserMultiProfilePagerAdapter.getActiveProfile() == PROFILE_PERSONAL) + ? mChooserMultiProfilePagerAdapter.getWorkListAdapter() + : mChooserMultiProfilePagerAdapter.getPersonalListAdapter(); + + if (!activeListAdapter.isTabLoaded() || !inactiveListAdapter.isTabLoaded()) { + return false; + } + + if ((activeListAdapter.getUnfilteredCount() != 1) + || (inactiveListAdapter.getUnfilteredCount() != 1)) { + return false; + } + + TargetInfo activeProfileTarget = activeListAdapter.targetInfoForPosition(0, false); + TargetInfo inactiveProfileTarget = inactiveListAdapter.targetInfoForPosition(0, false); + if (!Objects.equals( + activeProfileTarget.getResolvedComponentName(), + inactiveProfileTarget.getResolvedComponentName())) { + return false; + } + + if (!shouldAutoLaunchSingleChoice(activeProfileTarget)) { + return false; + } + + String packageName = activeProfileTarget.getResolvedComponentName().getPackageName(); + if (!mIntentForwarding.canAppInteractAcrossProfiles(this, packageName)) { + return false; + } + + DevicePolicyEventLogger + .createEvent(DevicePolicyEnums.RESOLVER_AUTOLAUNCH_CROSS_PROFILE_TARGET) + .setBoolean(activeListAdapter.getUserHandle() + .equals(mProfiles.getPersonalHandle())) + .setStrings(getMetricsCategory()) + .write(); + safelyStartActivity(activeProfileTarget); + finish(); + return true; + } + + /** + * @return {@code true} if a resolved target is autolaunched, otherwise {@code false} + */ + private boolean maybeAutolaunchActivity() { + int numberOfProfiles = mChooserMultiProfilePagerAdapter.getItemCount(); + // TODO(b/280988288): If the ChooserActivity is shown we should consider showing the + // correct intent-picker UIs (e.g., mini-resolver) if it was launched without + // ACTION_SEND. + if (numberOfProfiles == 1 && maybeAutolaunchIfSingleTarget()) { + return true; + } else if (maybeAutolaunchIfCrossProfileSupported()) { + return true; + } + return false; + } + + @Override // ResolverListCommunicator + public final void onPostListReady(ResolverListAdapter listAdapter, boolean doPostProcessing, + boolean rebuildCompleted) { + if (isAutolaunching()) { + return; + } + if (mChooserMultiProfilePagerAdapter + .shouldShowEmptyStateScreen((ChooserListAdapter) listAdapter)) { + mChooserMultiProfilePagerAdapter + .showEmptyResolverListEmptyState((ChooserListAdapter) listAdapter); + } else { + mChooserMultiProfilePagerAdapter.showListView((ChooserListAdapter) listAdapter); + } + // showEmptyResolverListEmptyState can mark the tab as loaded, + // which is a precondition for auto launching + if (rebuildCompleted && maybeAutolaunchActivity()) { + return; + } + if (doPostProcessing) { + maybeCreateHeader(listAdapter); + onListRebuilt(listAdapter, rebuildCompleted); + } + } + + private CharSequence getOrLoadDisplayLabel(TargetInfo info) { + if (info.isDisplayResolveInfo()) { + mTargetDataLoader.getOrLoadLabel((DisplayResolveInfo) info); + } + CharSequence displayLabel = info.getDisplayLabel(); + return displayLabel == null ? "" : displayLabel; + } + + protected final CharSequence getTitleForAction(Intent intent, int defaultTitleRes) { + final ActionTitle title = ActionTitle.forAction(intent.getAction()); + + // While there may already be a filtered item, we can only use it in the title if the list + // is already sorted and all information relevant to it is already in the list. + final boolean named = + mChooserMultiProfilePagerAdapter.getActiveListAdapter().getFilteredPosition() >= 0; + if (title == ActionTitle.DEFAULT && defaultTitleRes != 0) { + return getString(defaultTitleRes); + } else { + return named + ? getString( + title.namedTitleRes, + getOrLoadDisplayLabel( + mChooserMultiProfilePagerAdapter + .getActiveListAdapter().getFilteredItem())) + : getString(title.titleRes); + } + } + + /** + * Configure the area above the app selection list (title, content preview, etc). + */ + private void maybeCreateHeader(ResolverListAdapter listAdapter) { + if (mHeaderCreatorUser != null + && !listAdapter.getUserHandle().equals(mHeaderCreatorUser)) { + return; + } + if (!mProfiles.getWorkProfilePresent() + && listAdapter.getCount() == 0 && listAdapter.getPlaceholderCount() == 0) { + final TextView titleView = findViewById(com.android.internal.R.id.title); + if (titleView != null) { + titleView.setVisibility(View.GONE); + } + } + + CharSequence title = mRequest.getTitle() != null + ? mRequest.getTitle() + : getTitleForAction(mRequest.getTargetIntent(), + mRequest.getDefaultTitleResource()); + + if (!TextUtils.isEmpty(title)) { + final TextView titleView = findViewById(com.android.internal.R.id.title); + if (titleView != null) { + titleView.setText(title); + } + setTitle(title); + } + + final ImageView iconView = findViewById(com.android.internal.R.id.icon); + if (iconView != null) { + listAdapter.loadFilteredItemIconTaskAsync(iconView); + } + mHeaderCreatorUser = listAdapter.getUserHandle(); + } + + /** Start the activity specified by the {@link TargetInfo}.*/ + public final void safelyStartActivity(TargetInfo cti) { + // In case cloned apps are present, we would want to start those apps in cloned user + // space, which will not be same as the adapter's userHandle. resolveInfo.userHandle + // identifies the correct user space in such cases. + UserHandle activityUserHandle = cti.getResolveInfo().userHandle; + safelyStartActivityAsUser(cti, activityUserHandle, null); + } + + protected final void safelyStartActivityAsUser( + TargetInfo cti, UserHandle user, @Nullable Bundle options) { + // We're dispatching intents that might be coming from legacy apps, so + // don't kill ourselves. + StrictMode.disableDeathOnFileUriExposure(); + try { + safelyStartActivityInternal(cti, user, options); + } finally { + StrictMode.enableDeathOnFileUriExposure(); + } + } + + @VisibleForTesting + protected void safelyStartActivityInternal( + TargetInfo cti, UserHandle user, @Nullable Bundle options) { + // If the target is suspended, the activity will not be successfully launched. + // Do not unregister from package manager updates in this case + if (!cti.isSuspended() && mRegistered) { + if (mPersonalPackageMonitor != null) { + mPersonalPackageMonitor.unregister(); + } + if (mWorkPackageMonitor != null) { + mWorkPackageMonitor.unregister(); + } + mRegistered = false; + } + // If needed, show that intent is forwarded + // from managed profile to owner or other way around. + String profileSwitchMessage = mIntentForwarding.forwardMessageFor( + mRequest.getTargetIntent()); + if (profileSwitchMessage != null) { + Toast.makeText(this, profileSwitchMessage, Toast.LENGTH_LONG).show(); + } + try { + if (cti.startAsCaller(this, options, user.getIdentifier())) { + maybeSendShareResult(cti); + maybeLogCrossProfileTargetLaunch(cti, user); + } + } catch (RuntimeException e) { + Slog.wtf(TAG, + "Unable to launch as uid " + mActivityModel.getLaunchedFromUid() + + " package " + mActivityModel.getLaunchedFromPackage() + + ", while running in " + ActivityThread.currentProcessName(), e); + } + } + + private void maybeLogCrossProfileTargetLaunch(TargetInfo cti, UserHandle currentUserHandle) { + if (!mProfiles.getWorkProfilePresent() || currentUserHandle.equals(getUser())) { + return; + } + DevicePolicyEventLogger + .createEvent(DevicePolicyEnums.RESOLVER_CROSS_PROFILE_TARGET_OPENED) + .setBoolean(currentUserHandle.equals(mProfiles.getPersonalHandle())) + .setStrings(getMetricsCategory(), + cti.isInDirectShareMetricsCategory() ? "direct_share" : "other_target") + .write(); + } + + private LatencyTracker getLatencyTracker() { + return LatencyTracker.getInstance(this); + } + + /** + * If {@code retainInOnStop} is set to true, we will not finish ourselves when onStop gets + * called and we are launched in a new task. + */ + protected final void setRetainInOnStop(boolean retainInOnStop) { + mRetainInOnStop = retainInOnStop; + } + + // @NonFinalForTesting + @VisibleForTesting + protected CrossProfileIntentsChecker createCrossProfileIntentsChecker() { + return new CrossProfileIntentsChecker(getContentResolver()); + } + + protected final EmptyStateProvider createEmptyStateProvider( + ProfileHelper profileHelper, + ProfileAvailability profileAvailability) { + EmptyStateProvider blockerEmptyStateProvider = createBlockerEmptyStateProvider(); + + EmptyStateProvider workProfileOffEmptyStateProvider = + new WorkProfilePausedEmptyStateProvider( + this, + profileHelper, + profileAvailability, + /* onSwitchOnWorkSelectedListener = */ + () -> { + if (mOnSwitchOnWorkSelectedListener != null) { + mOnSwitchOnWorkSelectedListener.onSwitchOnWorkSelected(); + } + }, + getMetricsCategory()); + + EmptyStateProvider noAppsEmptyStateProvider = new NoAppsAvailableEmptyStateProvider( + this, + profileHelper.getWorkHandle(), + profileHelper.getPersonalHandle(), + getMetricsCategory(), + profileHelper.getTabOwnerUserHandleForLaunch() + ); + + // Return composite provider, the order matters (the higher, the more priority) + return new CompositeEmptyStateProvider( + blockerEmptyStateProvider, + workProfileOffEmptyStateProvider, + noAppsEmptyStateProvider + ); + } + + /** + * Returns the {@link List} of {@link UserHandle} to pass on to the + * {@link ResolverRankerServiceResolverComparator} as per the provided {@code userHandle}. + */ + private List getResolverRankerServiceUserHandleList(UserHandle userHandle) { + return getResolverRankerServiceUserHandleListInternal(userHandle); + } + + private List getResolverRankerServiceUserHandleListInternal(UserHandle userHandle) { + List userList = new ArrayList<>(); + userList.add(userHandle); + // Add clonedProfileUserHandle to the list only if we are: + // a. Building the Personal Tab. + // b. CloneProfile exists on the device. + if (userHandle.equals(mProfiles.getPersonalHandle()) + && mProfiles.getCloneUserPresent()) { + userList.add(mProfiles.getCloneHandle()); + } + return userList; + } + + /** + * Start activity as a fixed user handle. + * @param cti TargetInfo to be launched. + * @param user User to launch this activity as. + */ + @VisibleForTesting(visibility = VisibleForTesting.Visibility.PROTECTED) + public final void safelyStartActivityAsUser(TargetInfo cti, UserHandle user) { + safelyStartActivityAsUser(cti, user, null); + } + + protected WindowInsets super_onApplyWindowInsets(View v, WindowInsets insets) { + mSystemWindowInsets = insets.getSystemWindowInsets(); + + mResolverDrawerLayout.setPadding(mSystemWindowInsets.left, mSystemWindowInsets.top, + mSystemWindowInsets.right, 0); + + // Need extra padding so the list can fully scroll up + // To accommodate for window insets + applyFooterView(mSystemWindowInsets.bottom); + + return insets.consumeSystemWindowInsets(); + } + + @Override // ResolverListCommunicator + public final void onHandlePackagesChanged(ResolverListAdapter listAdapter) { + if (!mChooserMultiProfilePagerAdapter.onHandlePackagesChanged( + (ChooserListAdapter) listAdapter, + mProfileAvailability.getWaitingToEnableProfile())) { + // We no longer have any items... just finish the activity. + finish(); + } + } + + final Option optionForChooserTarget(TargetInfo target, int index) { + return new Option(getOrLoadDisplayLabel(target), index); + } + + @Override // ResolverListCommunicator + public final void sendVoiceChoicesIfNeeded() { + if (!isVoiceInteraction()) { + // Clearly not needed. + return; + } + + int count = mChooserMultiProfilePagerAdapter.getActiveListAdapter().getCount(); + final Option[] options = new Option[count]; + for (int i = 0; i < options.length; i++) { + TargetInfo target = mChooserMultiProfilePagerAdapter.getActiveListAdapter().getItem(i); + if (target == null) { + // If this occurs, a new set of targets is being loaded. Let that complete, + // and have the next call to send voice choices proceed instead. + return; + } + options[i] = optionForChooserTarget(target, i); + } - BasePreviewViewModel previewViewModel = - new ViewModelProvider(this, createPreviewViewModelFactory()) - .get(BasePreviewViewModel.class); - previewViewModel.init( - mChooserRequest.getTargetIntent(), - /*additionalContentUri = */ null, - /*isPayloadTogglingEnabled = */ false); - final ChooserActionFactory chooserActionFactory = createChooserActionFactory(); - mChooserContentPreviewUi = new ChooserContentPreviewUi( - getCoroutineScope(getLifecycle()), - previewViewModel.getPreviewDataProvider(), - mChooserRequest.getTargetIntent(), - previewViewModel.getImageLoader(), - chooserActionFactory, - chooserActionFactory::getModifyShareAction, - mEnterTransitionAnimationDelegate, - new HeadlineGeneratorImpl(this), - ContentTypeHint.NONE, - mChooserRequest.getMetadataText(), - /*isPayloadTogglingEnabled =*/ false - ); + mPickOptionRequest = new ResolverActivity.PickTargetOptionRequest( + new VoiceInteractor.Prompt(getTitle()), options, null); + getVoiceInteractor().submitRequest(mPickOptionRequest); + } - updateStickyContentPreview(); - if (shouldShowStickyContentPreview() - || mChooserMultiProfilePagerAdapter - .getCurrentRootAdapter().getSystemRowCount() != 0) { - getEventLog().logActionShareWithPreview( - mChooserContentPreviewUi.getPreferredContentPreview()); - } + /** + * Sets up the content view. + * @return true if the activity is finishing and creation should halt. + */ + private boolean configureContentView(TargetDataLoader targetDataLoader) { + if (mChooserMultiProfilePagerAdapter.getActiveListAdapter() == null) { + throw new IllegalStateException("mMultiProfilePagerAdapter.getCurrentListAdapter() " + + "cannot be null."); + } + Trace.beginSection("configureContentView"); + // We partially rebuild the inactive adapter to determine if we should auto launch + // isTabLoaded will be true here if the empty state screen is shown instead of the list. + boolean rebuildCompleted = mChooserMultiProfilePagerAdapter.rebuildTabs( + mProfiles.getWorkProfilePresent()); + + mLayoutId = mFeatureFlags.scrollablePreview() + ? R.layout.chooser_grid_scrollable_preview + : R.layout.chooser_grid; - mChooserShownTime = System.currentTimeMillis(); - final long systemCost = mChooserShownTime - intentReceivedTime; - getEventLog().logChooserActivityShown( - isWorkProfile(), mChooserRequest.getTargetType(), systemCost); + setContentView(mLayoutId); + mChooserMultiProfilePagerAdapter.setupViewPager( + requireViewById(com.android.internal.R.id.profile_pager)); + boolean result = postRebuildList(rebuildCompleted); + Trace.endSection(); + return result; + } - if (mResolverDrawerLayout != null) { - mResolverDrawerLayout.addOnLayoutChangeListener(this::handleLayoutChange); + /** + * Finishing procedures to be performed after the list has been rebuilt. + *

Subclasses must call postRebuildListInternal at the end of postRebuildList. + * @param rebuildCompleted + * @return true if the activity is finishing and creation should halt. + */ + protected boolean postRebuildList(boolean rebuildCompleted) { + return postRebuildListInternal(rebuildCompleted); + } - mResolverDrawerLayout.setOnCollapsedChangedListener( - isCollapsed -> { - mChooserMultiProfilePagerAdapter.setIsCollapsed(isCollapsed); - getEventLog().logSharesheetExpansionChanged(isCollapsed); - }); + /** + * Add a label to signify that the user can pick a different app. + * @param adapter The adapter used to provide data to item views. + */ + public void addUseDifferentAppLabelIfNecessary(ResolverListAdapter adapter) { + final boolean useHeader = adapter.hasFilteredItem(); + if (useHeader) { + FrameLayout stub = findViewById(com.android.internal.R.id.stub); + stub.setVisibility(View.VISIBLE); + TextView textView = (TextView) LayoutInflater.from(this).inflate( + R.layout.resolver_different_item_header, null, false); + if (mProfiles.getWorkProfilePresent()) { + textView.setGravity(Gravity.CENTER); + } + stub.addView(textView); + } + } + private void setupViewVisibilities() { + ChooserListAdapter activeListAdapter = + mChooserMultiProfilePagerAdapter.getActiveListAdapter(); + if (!mChooserMultiProfilePagerAdapter.shouldShowEmptyStateScreen(activeListAdapter)) { + addUseDifferentAppLabelIfNecessary(activeListAdapter); } + } + /** + * Finishing procedures to be performed after the list has been rebuilt. + * @param rebuildCompleted + * @return true if the activity is finishing and creation should halt. + */ + final boolean postRebuildListInternal(boolean rebuildCompleted) { + int count = mChooserMultiProfilePagerAdapter.getActiveListAdapter().getUnfilteredCount(); - if (DEBUG) { - Log.d(TAG, "System Time Cost is " + systemCost); + // We only rebuild asynchronously when we have multiple elements to sort. In the case where + // we're already done, we can check if we should auto-launch immediately. + if (rebuildCompleted && maybeAutolaunchActivity()) { + return true; } - getEventLog().logShareStarted( - getReferrerPackageName(), - mChooserRequest.getTargetType(), - mChooserRequest.getCallerChooserTargets().size(), - (mChooserRequest.getInitialIntents() == null) - ? 0 : mChooserRequest.getInitialIntents().length, - isWorkProfile(), - mChooserContentPreviewUi.getPreferredContentPreview(), - mChooserRequest.getTargetAction(), - mChooserRequest.getChooserActions().size(), - mChooserRequest.getModifyShareAction() != null - ); + setupViewVisibilities(); - mEnterTransitionAnimationDelegate.postponeTransition(); - } + if (mProfiles.getWorkProfilePresent() + || (mProfiles.getPrivateProfilePresent() + && mProfileAvailability.isAvailable( + requireNonNull(mProfiles.getPrivateProfile())))) { + setupProfileTabs(); + } - @VisibleForTesting - protected ChooserIntegratedDeviceComponents getIntegratedDeviceComponents() { - return ChooserIntegratedDeviceComponents.get(this, new SecureSettings()); + return false; } - @Override - protected int appliedThemeResId() { - return R.style.Theme_DeviceDefault_Chooser; + private void setupProfileTabs() { + TabHost tabHost = findViewById(com.android.internal.R.id.profile_tabhost); + ViewPager viewPager = findViewById(com.android.internal.R.id.profile_pager); + + mChooserMultiProfilePagerAdapter.setupProfileTabs( + getLayoutInflater(), + tabHost, + viewPager, + R.layout.resolver_profile_tab_button, + com.android.internal.R.id.profile_pager, + () -> onProfileTabSelected(viewPager.getCurrentItem()), + new OnProfileSelectedListener() { + @Override + public void onProfilePageSelected(@ProfileType int profileId, int pageNumber) {} + + @Override + public void onProfilePageStateChanged(int state) { + onHorizontalSwipeStateChanged(state); + } + }); + mOnSwitchOnWorkSelectedListener = () -> { + View workTab = tabHost.getTabWidget().getChildAt( + mChooserMultiProfilePagerAdapter.getPageNumberForProfile(PROFILE_WORK)); + workTab.setFocusable(true); + workTab.setFocusableInTouchMode(true); + workTab.requestFocus(); + }; } + ////////////////////////////////////////////////////////////////////////////////////////////// + ////////////////////////////////////////////////////////////////////////////////////////////// + private void createProfileRecords( AppPredictorFactory factory, IntentFilter targetIntentFilter) { - UserHandle mainUserHandle = getAnnotatedUserHandles().personalProfileUserHandle; + UserHandle mainUserHandle = mProfiles.getPersonalHandle(); ProfileRecord record = createProfileRecord(mainUserHandle, targetIntentFilter, factory); if (record.shortcutLoader == null) { Tracer.INSTANCE.endLaunchToShortcutTrace(); } - UserHandle workUserHandle = getAnnotatedUserHandles().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( @@ -407,7 +1305,7 @@ public class ChooserActivity extends Hilt_ChooserActivity implements @Nullable private ProfileRecord getProfileRecord(UserHandle userHandle) { - return mProfileRecords.get(userHandle.getIdentifier(), null); + return mProfileRecords.get(userHandle.getIdentifier()); } @VisibleForTesting @@ -430,25 +1328,76 @@ public class ChooserActivity extends Hilt_ChooserActivity implements return context.getSharedPreferences(PINNED_SHARED_PREFS_NAME, MODE_PRIVATE); } - @Override - protected ChooserMultiProfilePagerAdapter createMultiProfilePagerAdapter( - Intent[] initialIntents, - List rList, - boolean filterLastUsed, - TargetDataLoader targetDataLoader) { - if (shouldShowTabs()) { - mChooserMultiProfilePagerAdapter = createChooserMultiProfilePagerAdapterForTwoProfiles( - initialIntents, rList, filterLastUsed, targetDataLoader); - } else { - mChooserMultiProfilePagerAdapter = createChooserMultiProfilePagerAdapterForOneProfile( - initialIntents, rList, filterLastUsed, targetDataLoader); - } - return mChooserMultiProfilePagerAdapter; + 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 initialIntents, + int maxTargetsPerRow, + FeatureFlags featureFlags) { + Log.d(TAG, "createMultiProfilePagerAdapter"); + + Profile launchedAs = profileHelper.getLaunchedAsProfile(); + + Intent[] initialIntentArray = initialIntents.toArray(new Intent[0]); + List payloadIntents = request.getPayloadIntents(); + + List> tabs = new ArrayList<>(); + for (Profile profile : profileHelper.getProfiles()) { + if (profile.getType() == Profile.Type.PRIVATE + && !profileAvailability.isAvailable(profile)) { + continue; + } + ChooserGridAdapter adapter = createChooserGridAdapter( + context, + payloadIntents, + profile.equals(launchedAs) ? initialIntentArray : null, + profile.getPrimary().getHandle() + ); + tabs.add(new TabConfig<>( + /* profile = */ profile.getType().ordinal(), + profilePagerResources.profileTabLabel(profile.getType()), + profilePagerResources.profileTabAccessibilityLabel(profile.getType()), + /* tabTag = */ profile.getType().name(), + adapter)); + } + + EmptyStateProvider emptyStateProvider = + createEmptyStateProvider(profileHelper, profileAvailability); + + Supplier 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); } - @Override protected EmptyStateProvider createBlockerEmptyStateProvider() { - final boolean isSendAction = mChooserRequest.isSendActionTarget(); + final boolean isSendAction = mRequest.isSendActionTarget(); final EmptyState noWorkToPersonalEmptyState = new DevicePolicyBlockerEmptyState( @@ -477,79 +1426,14 @@ public class ChooserActivity extends Hilt_ChooserActivity implements /* devicePolicyEventCategory= */ ResolverActivity.METRICS_CATEGORY_CHOOSER); return new NoCrossProfileEmptyStateProvider( - getAnnotatedUserHandles().personalProfileUserHandle, + mProfiles, noWorkToPersonalEmptyState, noPersonalToWorkEmptyState, - createCrossProfileIntentsChecker(), - getAnnotatedUserHandles().tabOwnerUserHandleForLaunch); - } - - private ChooserMultiProfilePagerAdapter createChooserMultiProfilePagerAdapterForOneProfile( - Intent[] initialIntents, - List rList, - boolean filterLastUsed, - TargetDataLoader targetDataLoader) { - ChooserGridAdapter adapter = createChooserGridAdapter( - /* context */ this, - /* payloadIntents */ mIntents, - initialIntents, - rList, - filterLastUsed, - /* userHandle */ getAnnotatedUserHandles().personalProfileUserHandle, - targetDataLoader); - return new ChooserMultiProfilePagerAdapter( - /* context */ this, - adapter, - createEmptyStateProvider(/* workProfileUserHandle= */ null), - /* workProfileQuietModeChecker= */ () -> false, - /* workProfileUserHandle= */ null, - getAnnotatedUserHandles().cloneProfileUserHandle, - mMaxTargetsPerRow, - mFeatureFlags); - } - - private ChooserMultiProfilePagerAdapter createChooserMultiProfilePagerAdapterForTwoProfiles( - Intent[] initialIntents, - List rList, - boolean filterLastUsed, - TargetDataLoader targetDataLoader) { - int selectedProfile = findSelectedProfile(); - ChooserGridAdapter personalAdapter = createChooserGridAdapter( - /* context */ this, - /* payloadIntents */ mIntents, - selectedProfile == PROFILE_PERSONAL ? initialIntents : null, - rList, - filterLastUsed, - /* userHandle */ getAnnotatedUserHandles().personalProfileUserHandle, - targetDataLoader); - ChooserGridAdapter workAdapter = createChooserGridAdapter( - /* context */ this, - /* payloadIntents */ mIntents, - selectedProfile == PROFILE_WORK ? initialIntents : null, - rList, - filterLastUsed, - /* userHandle */ getAnnotatedUserHandles().workProfileUserHandle, - targetDataLoader); - return new ChooserMultiProfilePagerAdapter( - /* context */ this, - personalAdapter, - workAdapter, - createEmptyStateProvider(getAnnotatedUserHandles().workProfileUserHandle), - () -> mWorkProfileAvailability.isQuietModeEnabled(), - selectedProfile, - getAnnotatedUserHandles().workProfileUserHandle, - getAnnotatedUserHandles().cloneProfileUserHandle, - mMaxTargetsPerRow, - mFeatureFlags); + createCrossProfileIntentsChecker()); } private int findSelectedProfile() { - int selectedProfile = getSelectedProfileExtra(); - if (selectedProfile == -1) { - selectedProfile = getProfileForUser( - getAnnotatedUserHandles().tabOwnerUserHandleForLaunch); - } - return selectedProfile; + return mProfiles.getLaunchedAsProfileType().ordinal(); } /** @@ -557,12 +1441,11 @@ 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 + //@Override protected PackageMonitor createPackageMonitor(ResolverListAdapter listAdapter) { return new PackageMonitor() { @Override @@ -589,39 +1472,24 @@ public class ChooserActivity extends Hilt_ChooserActivity implements // Refresh pinned items mPinnedSharedPrefs = getPinnedSharedPrefs(this); if (listAdapter == null) { - handlePackageChangePerProfile(mChooserMultiProfilePagerAdapter.getActiveListAdapter()); - if (mChooserMultiProfilePagerAdapter.getCount() > 1) { - handlePackageChangePerProfile( - mChooserMultiProfilePagerAdapter.getInactiveListAdapter()); - } + mChooserMultiProfilePagerAdapter.refreshPackagesInAllTabs(); } else { - handlePackageChangePerProfile(listAdapter); - } - updateProfileViewButton(); - } - - private void handlePackageChangePerProfile(ResolverListAdapter adapter) { - ProfileRecord record = getProfileRecord(adapter.getUserHandle()); - if (record != null && record.shortcutLoader != null) { - record.shortcutLoader.reset(); + listAdapter.handlePackagesChanged(); } - adapter.handlePackagesChanged(); - } - - @Override - protected void onResume() { - super.onResume(); - Log.d(TAG, "onResume: " + getComponentName().flattenToShortString()); - mFinishWhenStopped = false; - mRefinementManager.onActivityResume(); } @Override - public void onConfigurationChanged(@NonNull Configuration newConfig) { + public void onConfigurationChanged(Configuration 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()) { - mMultiProfilePagerAdapter.setupViewPager(viewPager); + mChooserMultiProfilePagerAdapter.setupViewPager(viewPager); } mShouldDisplayLandscape = shouldDisplayLandscape(newConfig.orientation); @@ -651,7 +1519,7 @@ public class ChooserActivity extends Hilt_ChooserActivity implements } private void updateTabPadding() { - if (shouldShowTabs()) { + if (mProfiles.getWorkProfilePresent()) { View tabs = findViewById(com.android.internal.R.id.tabs); float iconSize = getResources().getDimension(R.dimen.chooser_icon_size); // The entire width consists of icons or padding. Divide the item padding in half to get @@ -711,47 +1579,17 @@ public class ChooserActivity extends Hilt_ChooserActivity implements return resolver.query(uri, null, null, null, null); } - @Override - protected void onStop() { - super.onStop(); - mRefinementManager.onActivityStop(isChangingConfigurations()); - - if (mFinishWhenStopped) { - mFinishWhenStopped = false; - finish(); - } - } - - @Override - protected void onDestroy() { - super.onDestroy(); - - if (isFinishing()) { - mLatencyTracker.onActionCancel(ACTION_LOAD_SHARE_SHEET); - } - - mBackgroundThreadPoolExecutor.shutdownNow(); - - destroyProfileRecords(); - } - private void destroyProfileRecords() { - for (int i = 0; i < mProfileRecords.size(); ++i) { - mProfileRecords.valueAt(i).destroy(); - } + mProfileRecords.values().forEach(ProfileRecord::destroy); mProfileRecords.clear(); } @Override // ResolverListCommunicator public Intent getReplacementIntent(ActivityInfo aInfo, Intent defIntent) { - if (mChooserRequest == null) { - return defIntent; - } - Intent result = defIntent; - if (mChooserRequest.getReplacementExtras() != null) { + if (mRequest.getReplacementExtras() != null) { final Bundle replExtras = - mChooserRequest.getReplacementExtras().getBundle(aInfo.packageName); + mRequest.getReplacementExtras().getBundle(aInfo.packageName); if (replExtras != null) { result = new Intent(defIntent); result.putExtras(replExtras); @@ -770,33 +1608,22 @@ public class ChooserActivity extends Hilt_ChooserActivity implements return result; } - @Override - public void onActivityStarted(TargetInfo cti) { - if (mChooserRequest.getChosenComponentSender() != null) { + private void maybeSendShareResult(TargetInfo cti) { + if (mShareResultSender != null) { final ComponentName target = cti.getResolvedComponentName(); if (target != null) { - final Intent fillIn = new Intent().putExtra(Intent.EXTRA_CHOSEN_COMPONENT, target); - try { - mChooserRequest.getChosenComponentSender().sendIntent( - this, Activity.RESULT_OK, fillIn, null, null); - } catch (IntentSender.SendIntentException e) { - Slog.e(TAG, "Unable to launch supplied IntentSender to report " - + "the chosen component: " + e); - } + mShareResultSender.onComponentSelected(target, cti.isChooserTargetInfo()); } } } private void addCallerChooserTargets() { - if (!mChooserRequest.getCallerChooserTargets().isEmpty()) { + if (!mRequest.getCallerChooserTargets().isEmpty()) { // Send the caller's chooser targets only to the default profile. - UserHandle defaultUser = (findSelectedProfile() == PROFILE_WORK) - ? getAnnotatedUserHandles().workProfileUserHandle - : getAnnotatedUserHandles().personalProfileUserHandle; - if (mChooserMultiProfilePagerAdapter.getCurrentUserHandle() == defaultUser) { + if (mChooserMultiProfilePagerAdapter.getActiveProfile() == findSelectedProfile()) { mChooserMultiProfilePagerAdapter.getActiveListAdapter().addServiceResults( /* origTarget */ null, - new ArrayList<>(mChooserRequest.getCallerChooserTargets()), + new ArrayList<>(mRequest.getCallerChooserTargets()), TARGET_TYPE_DEFAULT, /* directShareShortcutInfoCache */ Collections.emptyMap(), /* directShareAppTargetCache */ Collections.emptyMap()); @@ -804,28 +1631,19 @@ public class ChooserActivity extends Hilt_ChooserActivity implements } } - @Override - public int getLayoutResource() { - return mFeatureFlags.scrollablePreview() - ? R.layout.chooser_grid_scrollable_preview - : R.layout.chooser_grid; - } - @Override // ResolverListCommunicator public boolean shouldGetActivityMetadata() { return true; } - @Override public boolean shouldAutoLaunchSingleChoice(TargetInfo target) { - // Note that this is only safe because the Intent handled by the ChooserActivity is - // guaranteed to contain no extras unknown to the local ClassLoader. That is why this - // method can not be replaced in the ResolverActivity whole hog. - if (!super.shouldAutoLaunchSingleChoice(target)) { + if (target.isSuspended()) { return false; } - return getIntent().getBooleanExtra(Intent.EXTRA_AUTO_LAUNCH_SINGLE_CHOICE, true); + // TODO: migrate to ChooserRequest + return mViewModel.getActivityModel().getIntent() + .getBooleanExtra(Intent.EXTRA_AUTO_LAUNCH_SINGLE_CHOICE, true); } private void showTargetDetails(TargetInfo targetInfo) { @@ -840,8 +1658,9 @@ public class ChooserActivity extends Hilt_ChooserActivity implements // TODO: implement these type-conditioned behaviors polymorphically, and consider moving // the logic into `ChooserTargetActionsDialogFragment.show()`. boolean isShortcutPinned = targetInfo.isSelectableTargetInfo() && targetInfo.isPinned(); - IntentFilter intentFilter = targetInfo.isSelectableTargetInfo() - ? mChooserRequest.getTargetIntentFilter() : null; + IntentFilter intentFilter; + intentFilter = targetInfo.isSelectableTargetInfo() + ? mRequest.getShareTargetFilter() : null; String shortcutTitle = targetInfo.isSelectableTargetInfo() ? targetInfo.getDisplayLabel().toString() : null; String shortcutIdKey = targetInfo.getDirectShareShortcutId(); @@ -858,22 +1677,25 @@ public class ChooserActivity extends Hilt_ChooserActivity implements intentFilter); } - @Override - protected boolean onTargetSelected(TargetInfo target, boolean alwaysCheck) { + protected boolean onTargetSelected(TargetInfo target) { if (mRefinementManager.maybeHandleSelection( target, - mChooserRequest.getRefinementIntentSender(), + mRequest.getRefinementIntentSender(), getApplication(), getMainThreadHandler())) { return false; } updateModelAndChooserCounts(target); maybeRemoveSharedText(target); - return super.onTargetSelected(target, alwaysCheck); + safelyStartActivity(target); + + // Rely on the ActivityManager to pop up a dialog regarding app suspension + // and return false + return !target.isSuspended(); } @Override - public void startSelected(int which, boolean always, boolean filtered) { + public void startSelected(int which, /* unused */ boolean always, boolean filtered) { ChooserListAdapter currentListAdapter = mChooserMultiProfilePagerAdapter.getActiveListAdapter(); TargetInfo targetInfo = currentListAdapter @@ -896,8 +1718,23 @@ public class ChooserActivity extends Hilt_ChooserActivity implements return; } } + if (isFinishing()) { + return; + } - super.startSelected(which, always, filtered); + TargetInfo target = mChooserMultiProfilePagerAdapter.getActiveListAdapter() + .targetInfoForPosition(which, filtered); + if (target != null) { + if (onTargetSelected(target)) { + MetricsLogger.action( + this, MetricsEvent.ACTION_APP_DISAMBIG_TAP); + MetricsLogger.action(this, + mChooserMultiProfilePagerAdapter.getActiveListAdapter().hasFilteredItem() + ? MetricsEvent.ACTION_HIDE_APP_DISAMBIG_APP_FEATURED + : MetricsEvent.ACTION_HIDE_APP_DISAMBIG_NONE_FEATURED); + finish(); + } + } // TODO: both of the conditions around this switch logic *should* be redundant, and // can be removed if certain invariants can be guaranteed. In particular, it seems @@ -917,7 +1754,7 @@ public class ChooserActivity extends Hilt_ChooserActivity implements targetInfo.getResolveInfo().activityInfo.processName, which, /* directTargetAlsoRanked= */ getRankedPosition(targetInfo), - mChooserRequest.getCallerChooserTargets().size(), + mRequest.getCallerChooserTargets().size(), targetInfo.getHashedTargetIdForMetrics(this), targetInfo.isPinned(), mIsSuccessfullySelected, @@ -954,7 +1791,6 @@ public class ChooserActivity extends Hilt_ChooserActivity implements mIsSuccessfullySelected, selectionCost ); - return; } } } @@ -976,19 +1812,8 @@ public class ChooserActivity extends Hilt_ChooserActivity implements return -1; } - @Override - protected boolean shouldAddFooterView() { - // To accommodate for window insets - return true; - } - - @Override protected void applyFooterView(int height) { - int count = mChooserMultiProfilePagerAdapter.getItemCount(); - - for (int i = 0; i < count; i++) { - mChooserMultiProfilePagerAdapter.getAdapterForIndex(i).setFooterHeight(height); - } + mChooserMultiProfilePagerAdapter.setFooterHeightInEveryAdapter(height); } private void logDirectShareTargetReceived(UserHandle forUser) { @@ -1008,7 +1833,7 @@ public class ChooserActivity extends Hilt_ChooserActivity implements if (info != null) { sendClickToAppPredictor(info); final ResolveInfo ri = info.getResolveInfo(); - Intent targetIntent = getTargetIntent(); + Intent targetIntent = mRequest.getTargetIntent(); if (ri != null && ri.activityInfo != null && targetIntent != null) { ChooserListAdapter currentListAdapter = mChooserMultiProfilePagerAdapter.getActiveListAdapter(); @@ -1036,7 +1861,7 @@ public class ChooserActivity extends Hilt_ChooserActivity implements if (targetIntent == null) { return; } - Intent originalTargetIntent = new Intent(mChooserRequest.getTargetIntent()); + Intent originalTargetIntent = new Intent(mRequest.getTargetIntent()); // Our TargetInfo implementations add associated component to the intent, let's do the same // for the sake of the comparison below. if (targetIntent.getComponent() != null) { @@ -1106,93 +1931,38 @@ 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) || (getAnnotatedUserHandles().cloneProfileUserHandle != null)) + return ((record == null) || (mProfiles.getCloneUserPresent())) ? null : record.appPredictor; } - /** - * Sort intents alphabetically based on display label. - */ - static class AzInfoComparator implements Comparator { - Comparator mComparator; - AzInfoComparator(Context context) { - Collator collator = Collator - .getInstance(context.getResources().getConfiguration().locale); - // Adding two stage comparator, first stage compares using displayLabel, next stage - // compares using resolveInfo.userHandle - mComparator = Comparator.comparing(DisplayResolveInfo::getDisplayLabel, collator) - .thenComparingInt(target -> target.getResolveInfo().userHandle.getIdentifier()); - } - - @Override - public int compare( - DisplayResolveInfo lhsp, DisplayResolveInfo rhsp) { - return mComparator.compare(lhsp, rhsp); - } - } - protected EventLog getEventLog() { return mEventLog; } - public class ChooserListController extends ResolverListController { - public ChooserListController( - Context context, - PackageManager pm, - Intent targetIntent, - String referrerPackageName, - int launchedFromUid, - AbstractResolverComparator resolverComparator, - UserHandle queryIntentsAsUser) { - super( - context, - pm, - targetIntent, - referrerPackageName, - launchedFromUid, - resolverComparator, - queryIntentsAsUser); - } - - @Override - public boolean isComponentFiltered(ComponentName name) { - return mChooserRequest.getFilteredComponentNames().contains(name); - } - - @Override - public boolean isComponentPinned(ComponentName name) { - return mPinnedSharedPrefs.getBoolean(name.flattenToString(), false); - } - } - - @VisibleForTesting - public ChooserGridAdapter createChooserGridAdapter( + private ChooserGridAdapter createChooserGridAdapter( Context context, List payloadIntents, Intent[] initialIntents, - List rList, - boolean filterLastUsed, - UserHandle userHandle, - TargetDataLoader targetDataLoader) { + UserHandle userHandle) { ChooserListAdapter chooserListAdapter = createChooserListAdapter( context, payloadIntents, initialIntents, - rList, - filterLastUsed, + /* TODO: not used, remove. rList= */ null, + /* TODO: not used, remove. filterLastUsed= */ false, createListController(userHandle), userHandle, - getTargetIntent(), - mChooserRequest.getReferrerFillInIntent(), - mMaxTargetsPerRow, - targetDataLoader); + mRequest.getTargetIntent(), + mRequest.getReferrerFillInIntent(), + mMaxTargetsPerRow + ); return new ChooserGridAdapter( context, new ChooserGridAdapter.ChooserActivityDelegate() { @Override public boolean shouldShowTabs() { - return ChooserActivity.this.shouldShowTabs(); + return mProfiles.getWorkProfilePresent(); } @Override @@ -1236,11 +2006,8 @@ public class ChooserActivity extends Hilt_ChooserActivity implements UserHandle userHandle, Intent targetIntent, Intent referrerFillInIntent, - int maxTargetsPerRow, - TargetDataLoader targetDataLoader) { - UserHandle initialIntentsUserSpace = isLaunchedAsCloneProfile() - && userHandle.equals(getAnnotatedUserHandles().personalProfileUserHandle) - ? getAnnotatedUserHandles().cloneProfileUserHandle : userHandle; + int maxTargetsPerRow) { + UserHandle initialIntentsUserSpace = mProfiles.getQueryIntentsHandle(userHandle); return new ChooserListAdapter( context, payloadIntents, @@ -1252,54 +2019,70 @@ public class ChooserActivity extends Hilt_ChooserActivity implements targetIntent, referrerFillInIntent, this, - context.getPackageManager(), + mPackageManager, getEventLog(), maxTargetsPerRow, initialIntentsUserSpace, - targetDataLoader, - null, + mTargetDataLoader, + () -> { + ProfileRecord record = getProfileRecord(userHandle); + if (record != null && record.shortcutLoader != null) { + record.shortcutLoader.reset(); + } + }, mFeatureFlags); } - @Override - protected void onWorkProfileStatusUpdated() { - UserHandle workUser = getAnnotatedUserHandles().workProfileUserHandle; + private void onWorkProfileStatusUpdated() { + UserHandle workUser = mProfiles.getWorkHandle(); ProfileRecord record = workUser == null ? null : getProfileRecord(workUser); if (record != null && record.shortcutLoader != null) { record.shortcutLoader.reset(); } - super.onWorkProfileStatusUpdated(); + if (mChooserMultiProfilePagerAdapter.getCurrentUserHandle().equals( + mProfiles.getWorkHandle())) { + mChooserMultiProfilePagerAdapter.rebuildActiveTab(true); + } else { + mChooserMultiProfilePagerAdapter.clearInactiveProfileCache(); + } } - @Override @VisibleForTesting protected ChooserListController createListController(UserHandle userHandle) { AppPredictor appPredictor = getAppPredictor(userHandle); AbstractResolverComparator resolverComparator; if (appPredictor != null) { - resolverComparator = new AppPredictionServiceResolverComparator(this, getTargetIntent(), - getReferrerPackageName(), appPredictor, userHandle, getEventLog(), - getIntegratedDeviceComponents().getNearbySharingComponent()); + resolverComparator = new AppPredictionServiceResolverComparator( + this, + mRequest.getTargetIntent(), + mRequest.getLaunchedFromPackage(), + appPredictor, + userHandle, + getEventLog(), + mNearbyShare.orElse(null) + ); } else { resolverComparator = new ResolverRankerServiceResolverComparator( this, - getTargetIntent(), - getReferrerPackageName(), + mRequest.getTargetIntent(), + mRequest.getReferrerPackage(), null, getEventLog(), getResolverRankerServiceUserHandleList(userHandle), - getIntegratedDeviceComponents().getNearbySharingComponent()); + mNearbyShare.orElse(null)); } return new ChooserListController( this, - mPm, - getTargetIntent(), - getReferrerPackageName(), - getAnnotatedUserHandles().userIdOfCallingApp, + mPackageManager, + mRequest.getTargetIntent(), + mRequest.getReferrerPackage(), + mViewModel.getActivityModel().getLaunchedFromUid(), resolverComparator, - getQueryIntentsUser(userHandle)); + mProfiles.getQueryIntentsHandle(userHandle), + mRequest.getFilteredComponentNames(), + mPinnedSharedPrefs); } @VisibleForTesting @@ -1310,8 +2093,10 @@ public class ChooserActivity extends Hilt_ChooserActivity implements private ChooserActionFactory createChooserActionFactory() { return new ChooserActionFactory( this, - mChooserRequest, - mIntegratedDeviceComponents, + mRequest.getTargetIntent(), + mRequest.getLaunchedFromPackage(), + mRequest.getChooserActions(), + mImageEditor, getEventLog(), (isExcluded) -> mExcludeSharedText = isExcluded, this::getFirstVisibleImgPreviewView, @@ -1319,7 +2104,9 @@ public class ChooserActivity extends Hilt_ChooserActivity implements @Override public void safelyStartActivityAsPersonalProfileUser(TargetInfo targetInfo) { safelyStartActivityAsUser( - targetInfo, getAnnotatedUserHandles().personalProfileUserHandle); + targetInfo, + mProfiles.getPersonalHandle() + ); finish(); } @@ -1330,19 +2117,32 @@ public class ChooserActivity extends Hilt_ChooserActivity implements ChooserActivity.this, sharedElement, sharedElementName); safelyStartActivityAsUser( targetInfo, - getAnnotatedUserHandles().personalProfileUserHandle, + mProfiles.getPersonalHandle(), options.toBundle()); // Can't finish right away because the shared element transition may not // be ready to start. mFinishWhenStopped = true; } }, - (status) -> { - if (status != null) { - setResult(status); - } - finish(); - }); + mShareResultSender, + this::finishWithStatus, + mClipboardManager); + } + + private Supplier 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(); } /* @@ -1387,8 +2187,7 @@ public class ChooserActivity extends Hilt_ChooserActivity implements updateTabPadding(); } - UserHandle currentUserHandle = mChooserMultiProfilePagerAdapter.getCurrentUserHandle(); - int currentProfile = getProfileForUser(currentUserHandle); + int currentProfile = mChooserMultiProfilePagerAdapter.getActiveProfile(); int initialProfile = findSelectedProfile(); if (currentProfile != initialProfile) { return; @@ -1437,7 +2236,7 @@ public class ChooserActivity extends Hilt_ChooserActivity implements offset += stickyContentPreview.getHeight(); } - if (shouldShowTabs()) { + if (mProfiles.getWorkProfilePresent()) { offset += findViewById(com.android.internal.R.id.tabs).getHeight(); } @@ -1460,7 +2259,8 @@ public class ChooserActivity extends Hilt_ChooserActivity implements rowsToShow--; } } else { - ViewGroup currentEmptyStateView = getActiveEmptyStateView(); + ViewGroup currentEmptyStateView = + mChooserMultiProfilePagerAdapter.getActiveEmptyStateView(); if (currentEmptyStateView.getVisibility() == View.VISIBLE) { offset += currentEmptyStateView.getHeight(); } @@ -1471,41 +2271,15 @@ public class ChooserActivity extends Hilt_ChooserActivity implements /** * If we have a tabbed view and are showing 1 row in the current profile and an empty - * state screen in the other profile, to prevent cropping of the empty state screen we show + * state screen in another profile, to prevent cropping of the empty state screen we show * a second row in the current profile. */ private boolean shouldShowExtraRow(int rowsToShow) { - return shouldShowTabs() - && rowsToShow == 1 - && mChooserMultiProfilePagerAdapter.shouldShowEmptyStateScreen( - mChooserMultiProfilePagerAdapter.getInactiveListAdapter()); - } - - /** - * Returns {@link #PROFILE_WORK}, if the given user handle matches work user handle. - * Returns {@link #PROFILE_PERSONAL}, otherwise. - **/ - private int getProfileForUser(UserHandle currentUserHandle) { - if (currentUserHandle.equals(getAnnotatedUserHandles().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; + return rowsToShow == 1 + && mChooserMultiProfilePagerAdapter + .shouldShowEmptyStateScreenInAnyInactiveAdapter(); } - private ViewGroup getActiveEmptyStateView() { - int currentPage = mChooserMultiProfilePagerAdapter.getCurrentPage(); - return mChooserMultiProfilePagerAdapter.getEmptyStateView(currentPage); - } - - @Override // ResolverListCommunicator - public void onHandlePackagesChanged(ResolverListAdapter listAdapter) { - mChooserMultiProfilePagerAdapter.getActiveListAdapter().notifyDataSetChanged(); - super.onHandlePackagesChanged(listAdapter); - } - - @Override protected void onListRebuilt(ResolverListAdapter listAdapter, boolean rebuildComplete) { setupScrollListener(); maybeSetupGlobalLayoutListener(); @@ -1575,7 +2349,7 @@ public class ChooserActivity extends Hilt_ChooserActivity implements adapter.completeServiceTargetLoading(); } - if (mMultiProfilePagerAdapter.getActiveListAdapter() == adapter) { + if (mChooserMultiProfilePagerAdapter.getActiveListAdapter() == adapter) { long duration = Tracer.INSTANCE.endLaunchToShortcutTrace(); if (duration >= 0) { Log.d(TAG, "stat to first shortcut time: " + duration + " ms"); @@ -1590,7 +2364,8 @@ public class ChooserActivity extends Hilt_ChooserActivity implements if (mResolverDrawerLayout == null) { return; } - int elevatedViewResId = shouldShowTabs() ? com.android.internal.R.id.tabs : com.android.internal.R.id.chooser_header; + int elevatedViewResId = mProfiles.getWorkProfilePresent() + ? com.android.internal.R.id.tabs : com.android.internal.R.id.chooser_header; final View elevatedView = mResolverDrawerLayout.findViewById(elevatedViewResId); final float defaultElevation = elevatedView.getElevation(); final float chooserHeaderScrollElevation = @@ -1598,7 +2373,7 @@ public class ChooserActivity extends Hilt_ChooserActivity implements mChooserMultiProfilePagerAdapter.getActiveAdapterView().addOnScrollListener( new RecyclerView.OnScrollListener() { @Override - public void onScrollStateChanged(@NonNull RecyclerView view, int scrollState) { + public void onScrollStateChanged(RecyclerView view, int scrollState) { if (scrollState == RecyclerView.SCROLL_STATE_IDLE) { if (mScrollStatus == SCROLL_STATUS_SCROLLING_VERTICAL) { mScrollStatus = SCROLL_STATUS_IDLE; @@ -1613,7 +2388,7 @@ public class ChooserActivity extends Hilt_ChooserActivity implements } @Override - public void onScrolled(@NonNull RecyclerView view, int dx, int dy) { + public void onScrolled(RecyclerView view, int dx, int dy) { if (view.getChildCount() > 0) { View child = view.getLayoutManager().findViewByPosition(0); if (child == null || child.getTop() < 0) { @@ -1628,7 +2403,7 @@ public class ChooserActivity extends Hilt_ChooserActivity implements } private void maybeSetupGlobalLayoutListener() { - if (shouldShowTabs()) { + if (mProfiles.getWorkProfilePresent()) { return; } final View recyclerView = mChooserMultiProfilePagerAdapter.getActiveAdapterView(); @@ -1662,10 +2437,10 @@ public class ChooserActivity extends Hilt_ChooserActivity implements if (!shouldShowContentPreview()) { return false; } - ResolverListAdapter adapter = mMultiProfilePagerAdapter.getListAdapterForUserHandle( + ResolverListAdapter adapter = mChooserMultiProfilePagerAdapter.getListAdapterForUserHandle( UserHandle.of(UserHandle.myUserId())); boolean isEmpty = adapter == null || adapter.getCount() == 0; - return (mFeatureFlags.scrollablePreview() || shouldShowTabs()) + return (mFeatureFlags.scrollablePreview() || mProfiles.getWorkProfilePresent()) && (!isEmpty || shouldShowContentPreviewWhenEmpty()); } @@ -1684,7 +2459,7 @@ public class ChooserActivity extends Hilt_ChooserActivity implements * @return true if we want to show the content preview area */ protected boolean shouldShowContentPreview() { - return (mChooserRequest != null) && mChooserRequest.isSendActionTarget(); + return mRequest.isSendActionTarget(); } private void updateStickyContentPreview() { @@ -1728,34 +2503,22 @@ public class ChooserActivity extends Hilt_ChooserActivity implements contentPreviewContainer.setVisibility(View.GONE); } - private View findRootView() { - if (mContentView == null) { - mContentView = findViewById(android.R.id.content); - } - return mContentView; - } - - /** - * Intentionally override the {@link ResolverActivity} implementation as we only need that - * implementation for the intent resolver case. - */ - @Override - public void onButtonClick(View v) {} - - /** - * Intentionally override the {@link ResolverActivity} implementation as we only need that - * implementation for the intent resolver case. - */ - @Override - protected void resetButtonBar() {} - - @Override protected String getMetricsCategory() { return METRICS_CATEGORY_CHOOSER; } - @Override - protected void onProfileTabSelected() { + protected void onProfileTabSelected(int currentPage) { + setupViewVisibilities(); + maybeLogProfileChange(); + if (mProfiles.getWorkProfilePresent()) { + // The device policy logger is only concerned with sessions that include a work profile. + DevicePolicyEventLogger + .createEvent(DevicePolicyEnums.RESOLVER_SWITCH_TABS) + .setInt(currentPage) + .setStrings(getMetricsCategory()) + .write(); + } + // This fixes an edge case where after performing a variety of gestures, vertical scrolling // ends up disabled. That's because at some point the old tab's vertical scrolling is // disabled and the new tab's is enabled. For context, see b/159997845 @@ -1765,16 +2528,13 @@ public class ChooserActivity extends Hilt_ChooserActivity implements } } - @Override protected WindowInsets onApplyWindowInsets(View v, WindowInsets insets) { - if (shouldShowTabs()) { + if (mProfiles.getWorkProfilePresent()) { mChooserMultiProfilePagerAdapter .setEmptyStateBottomOffset(insets.getSystemWindowInsetBottom()); - mChooserMultiProfilePagerAdapter.setupContainerPadding( - getActiveEmptyStateView().findViewById(com.android.internal.R.id.resolver_empty_state_container)); } - WindowInsets result = super.onApplyWindowInsets(v, insets); + WindowInsets result = super_onApplyWindowInsets(v, insets); if (mResolverDrawerLayout != null) { mResolverDrawerLayout.requestLayout(); } @@ -1793,7 +2553,6 @@ public class ChooserActivity extends Hilt_ChooserActivity implements layoutManager.setVerticalScrollEnabled(enabled); } - @Override void onHorizontalSwipeStateChanged(int state) { if (state == ViewPager.SCROLL_STATE_DRAGGING) { if (mScrollStatus == SCROLL_STATUS_IDLE) { @@ -1808,7 +2567,6 @@ public class ChooserActivity extends Hilt_ChooserActivity implements } } - @Override protected void maybeLogProfileChange() { getEventLog().logSharesheetProfileChanged(); } diff --git a/java/src/com/android/intentresolver/ChooserHelper.kt b/java/src/com/android/intentresolver/ChooserHelper.kt new file mode 100644 index 00000000..25c2b40f --- /dev/null +++ b/java/src/com/android/intentresolver/ChooserHelper.kt @@ -0,0 +1,168 @@ +/* + * Copyright (C) 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.intentresolver + +import android.app.Activity +import android.os.UserHandle +import android.util.Log +import androidx.activity.ComponentActivity +import androidx.activity.viewModels +import androidx.lifecycle.DefaultLifecycleObserver +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleOwner +import androidx.lifecycle.lifecycleScope +import androidx.lifecycle.repeatOnLifecycle +import com.android.intentresolver.annotation.JavaInterop +import com.android.intentresolver.contentpreview.payloadtoggle.data.repository.ActivityResultRepository +import com.android.intentresolver.data.model.ChooserRequest +import com.android.intentresolver.domain.interactor.UserInteractor +import com.android.intentresolver.inject.Background +import com.android.intentresolver.ui.viewmodel.ChooserViewModel +import com.android.intentresolver.validation.Invalid +import com.android.intentresolver.validation.Valid +import com.android.intentresolver.validation.log +import dagger.hilt.android.scopes.ActivityScoped +import java.util.function.Consumer +import javax.inject.Inject +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.flow.filterNotNull +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.launch + +private const val TAG: String = "ChooserHelper" + +/** + * __Purpose__ + * + * Cleanup aid. Provides a pathway to cleaner code. + * + * __Incoming References__ + * + * ChooserHelper must not expose any properties or functions directly back to ChooserActivity. If a + * value or operation is required by ChooserActivity, then it must be added to ChooserInitializer + * (or a new interface as appropriate) with ChooserActivity supplying a callback to receive it at + * the appropriate point. This enforces unidirectional control flow. + * + * __Outgoing References__ + * + * _ChooserActivity_ + * + * This class must only reference it's host as Activity/ComponentActivity; no down-cast to + * [ChooserActivity]. Other components should be created here or supplied via Injection, and not + * referenced directly within ChooserActivity. This prevents circular dependencies from forming. If + * necessary, during cleanup the dependency can be supplied back to ChooserActivity as described + * above in 'Incoming References', see [ChooserInitializer]. + * + * _Elsewhere_ + * + * Where possible, Singleton and ActivityScoped dependencies should be injected here instead of + * referenced from an existing location. If not available for injection, the value should be + * constructed here, then provided to where it is needed. + */ +@ActivityScoped +@JavaInterop +class ChooserHelper +@Inject +constructor( + hostActivity: Activity, + private val 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() + + private lateinit var activityInitializer: Runnable + + var onChooserRequestChanged: Consumer = Consumer {} + + init { + activity.lifecycle.addObserver(this) + } + + /** + * Set the initialization hook for the host activity. + * + * This _must_ be called from [ChooserActivity.onCreate]. + */ + fun setInitializer(initializer: Runnable) { + check(activity.lifecycle.currentState == Lifecycle.State.INITIALIZED) { + "setInitializer must be called before onCreate returns" + } + activityInitializer = initializer + } + + /** Invoked by Lifecycle, after [ChooserActivity.onCreate] _returns_. */ + override fun onCreate(owner: LifecycleOwner) { + Log.i(TAG, "CREATE") + Log.i(TAG, "${viewModel.activityModel}") + + val callerUid: Int = viewModel.activityModel.launchedFromUid + if (callerUid < 0 || UserHandle.isIsolated(callerUid)) { + Log.e(TAG, "Can't start a chooser from uid $callerUid") + activity.finish() + return + } + + 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) { + request.errors.forEach { it.log(TAG) } + activity.finish() + } + + private fun initializeActivity(request: Valid) { + request.warnings.forEach { it.log(TAG) } + activityInitializer.run() + } +} diff --git a/java/src/com/android/intentresolver/ChooserIntegratedDeviceComponents.java b/java/src/com/android/intentresolver/ChooserIntegratedDeviceComponents.java deleted file mode 100644 index 7cd86bf4..00000000 --- a/java/src/com/android/intentresolver/ChooserIntegratedDeviceComponents.java +++ /dev/null @@ -1,85 +0,0 @@ -/* - * Copyright (C) 2023 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.android.intentresolver; - -import android.content.ComponentName; -import android.content.Context; -import android.provider.Settings; -import android.text.TextUtils; - -import androidx.annotation.Nullable; - -import com.android.internal.annotations.VisibleForTesting; - -/** - * Helper to look up the components available on this device to handle assorted built-in actions - * like "Edit" that may be displayed for certain content/preview types. The components are queried - * when this record is instantiated, and are then immutable for a given instance. - * - * Because this describes the app's external execution environment, test methods may prefer to - * provide explicit values to override the default lookup logic. - */ -public class ChooserIntegratedDeviceComponents { - @Nullable - private final ComponentName mEditSharingComponent; - - @Nullable - private final ComponentName mNearbySharingComponent; - - /** Look up the integrated components available on this device. */ - public static ChooserIntegratedDeviceComponents get( - Context context, - SecureSettings secureSettings) { - return new ChooserIntegratedDeviceComponents( - getEditSharingComponent(context), - getNearbySharingComponent(context, secureSettings)); - } - - @VisibleForTesting - ChooserIntegratedDeviceComponents( - @Nullable ComponentName editSharingComponent, - @Nullable ComponentName nearbySharingComponent) { - mEditSharingComponent = editSharingComponent; - mNearbySharingComponent = nearbySharingComponent; - } - - public ComponentName getEditSharingComponent() { - return mEditSharingComponent; - } - - public ComponentName getNearbySharingComponent() { - return mNearbySharingComponent; - } - - private static ComponentName getEditSharingComponent(Context context) { - String editorComponent = context.getApplicationContext().getString( - R.string.config_systemImageEditor); - return TextUtils.isEmpty(editorComponent) - ? null : ComponentName.unflattenFromString(editorComponent); - } - - private static ComponentName getNearbySharingComponent(Context context, - SecureSettings secureSettings) { - String nearbyComponent = secureSettings.getString( - context.getContentResolver(), Settings.Secure.NEARBY_SHARING_COMPONENT); - if (TextUtils.isEmpty(nearbyComponent)) { - nearbyComponent = context.getString(R.string.config_defaultNearbySharingComponent); - } - return TextUtils.isEmpty(nearbyComponent) - ? null : ComponentName.unflattenFromString(nearbyComponent); - } -} diff --git a/java/src/com/android/intentresolver/ChooserListAdapter.java b/java/src/com/android/intentresolver/ChooserListAdapter.java index 5060f4f1..e8d4fdde 100644 --- a/java/src/com/android/intentresolver/ChooserListAdapter.java +++ b/java/src/com/android/intentresolver/ChooserListAdapter.java @@ -48,6 +48,7 @@ import androidx.annotation.Nullable; import androidx.annotation.WorkerThread; import com.android.intentresolver.chooser.DisplayResolveInfo; +import com.android.intentresolver.chooser.DisplayResolveInfoAzInfoComparator; import com.android.intentresolver.chooser.MultiDisplayResolveInfo; import com.android.intentresolver.chooser.NotSelectableTargetInfo; import com.android.intentresolver.chooser.SelectableTargetInfo; @@ -478,8 +479,8 @@ public class ChooserListAdapter extends ResolverListAdapter { } public void updateAlphabeticalList() { - final ChooserActivity.AzInfoComparator comparator = - new ChooserActivity.AzInfoComparator(mContext); + final DisplayResolveInfoAzInfoComparator + comparator = new DisplayResolveInfoAzInfoComparator(mContext); final List allTargets = new ArrayList<>(); allTargets.addAll(getTargetsInCurrentDisplayList()); allTargets.addAll(mCallerTargets); @@ -711,7 +712,7 @@ public class ChooserListAdapter extends ResolverListAdapter { public void addServiceResults( @Nullable DisplayResolveInfo origTarget, List targets, - @ChooserActivity.ShareTargetType int targetType, + int targetType, Map directShareToShortcutInfos, Map directShareToAppTargets) { // Avoid inserting any potentially late results. @@ -748,7 +749,7 @@ public class ChooserListAdapter extends ResolverListAdapter { */ public float getBaseScore( DisplayResolveInfo target, - @ChooserActivity.ShareTargetType int targetType) { + int targetType) { if (target == null) { return CALLER_TARGET_SCORE_BOOST; } diff --git a/java/src/com/android/intentresolver/ChooserListController.java b/java/src/com/android/intentresolver/ChooserListController.java new file mode 100644 index 00000000..48aa8be1 --- /dev/null +++ b/java/src/com/android/intentresolver/ChooserListController.java @@ -0,0 +1,65 @@ +/* + * Copyright (C) 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.intentresolver; + +import android.content.ComponentName; +import android.content.Context; +import android.content.Intent; +import android.content.SharedPreferences; +import android.content.pm.PackageManager; +import android.os.UserHandle; + +import com.android.intentresolver.model.AbstractResolverComparator; + +import java.util.List; + +public class ChooserListController extends ResolverListController { + private final List mFilteredComponents; + private final SharedPreferences mPinnedComponents; + + public ChooserListController( + Context context, + PackageManager pm, + Intent targetIntent, + String referrerPackageName, + int launchedFromUid, + AbstractResolverComparator resolverComparator, + UserHandle queryIntentsAsUser, + List filteredComponents, + SharedPreferences pinnedComponents) { + super( + context, + pm, + targetIntent, + referrerPackageName, + launchedFromUid, + resolverComparator, + queryIntentsAsUser); + mFilteredComponents = filteredComponents; + mPinnedComponents = pinnedComponents; + } + + @Override + public boolean isComponentFiltered(ComponentName name) { + return mFilteredComponents.contains(name); + } + + @Override + public boolean isComponentPinned(ComponentName name) { + return mPinnedComponents.getBoolean(name.flattenToString(), false); + } +} diff --git a/java/src/com/android/intentresolver/ChooserMultiProfilePagerAdapter.java b/java/src/com/android/intentresolver/ChooserMultiProfilePagerAdapter.java deleted file mode 100644 index 080f9d24..00000000 --- a/java/src/com/android/intentresolver/ChooserMultiProfilePagerAdapter.java +++ /dev/null @@ -1,214 +0,0 @@ -/* - * Copyright (C) 2019 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.android.intentresolver; - -import android.content.Context; -import android.os.UserHandle; -import android.view.LayoutInflater; -import android.view.ViewGroup; - -import androidx.recyclerview.widget.GridLayoutManager; -import androidx.recyclerview.widget.RecyclerView; -import androidx.viewpager.widget.PagerAdapter; - -import com.android.intentresolver.emptystate.EmptyStateProvider; -import com.android.intentresolver.grid.ChooserGridAdapter; -import com.android.intentresolver.measurements.Tracer; -import com.android.internal.annotations.VisibleForTesting; - -import com.google.common.collect.ImmutableList; - -import java.util.Optional; -import java.util.function.Supplier; - -/** - * A {@link PagerAdapter} which describes the work and personal profile share sheet screens. - */ -@VisibleForTesting -public class ChooserMultiProfilePagerAdapter extends MultiProfilePagerAdapter< - RecyclerView, ChooserGridAdapter, ChooserListAdapter> { - private static final int SINGLE_CELL_SPAN_SIZE = 1; - - private final ChooserProfileAdapterBinder mAdapterBinder; - private final BottomPaddingOverrideSupplier mBottomPaddingOverrideSupplier; - - public ChooserMultiProfilePagerAdapter( - Context context, - ChooserGridAdapter adapter, - EmptyStateProvider emptyStateProvider, - Supplier workProfileQuietModeChecker, - UserHandle workProfileUserHandle, - UserHandle cloneProfileUserHandle, - int maxTargetsPerRow, - FeatureFlags featureFlags) { - this( - context, - new ChooserProfileAdapterBinder(maxTargetsPerRow), - ImmutableList.of(adapter), - emptyStateProvider, - workProfileQuietModeChecker, - /* defaultProfile= */ 0, - workProfileUserHandle, - cloneProfileUserHandle, - new BottomPaddingOverrideSupplier(context), - featureFlags); - } - - public ChooserMultiProfilePagerAdapter( - Context context, - ChooserGridAdapter personalAdapter, - ChooserGridAdapter workAdapter, - EmptyStateProvider emptyStateProvider, - Supplier workProfileQuietModeChecker, - @Profile int defaultProfile, - UserHandle workProfileUserHandle, - UserHandle cloneProfileUserHandle, - int maxTargetsPerRow, - FeatureFlags featureFlags) { - this( - context, - new ChooserProfileAdapterBinder(maxTargetsPerRow), - ImmutableList.of(personalAdapter, workAdapter), - emptyStateProvider, - workProfileQuietModeChecker, - defaultProfile, - workProfileUserHandle, - cloneProfileUserHandle, - new BottomPaddingOverrideSupplier(context), - featureFlags); - } - - private ChooserMultiProfilePagerAdapter( - Context context, - ChooserProfileAdapterBinder adapterBinder, - ImmutableList gridAdapters, - EmptyStateProvider emptyStateProvider, - Supplier workProfileQuietModeChecker, - @Profile int defaultProfile, - UserHandle workProfileUserHandle, - UserHandle cloneProfileUserHandle, - BottomPaddingOverrideSupplier bottomPaddingOverrideSupplier, - FeatureFlags featureFlags) { - super( - gridAdapter -> gridAdapter.getListAdapter(), - adapterBinder, - gridAdapters, - emptyStateProvider, - workProfileQuietModeChecker, - defaultProfile, - workProfileUserHandle, - cloneProfileUserHandle, - () -> makeProfileView(context, featureFlags), - bottomPaddingOverrideSupplier); - mAdapterBinder = adapterBinder; - mBottomPaddingOverrideSupplier = bottomPaddingOverrideSupplier; - } - - public void setMaxTargetsPerRow(int maxTargetsPerRow) { - mAdapterBinder.setMaxTargetsPerRow(maxTargetsPerRow); - } - - public void setEmptyStateBottomOffset(int bottomOffset) { - mBottomPaddingOverrideSupplier.setEmptyStateBottomOffset(bottomOffset); - } - - /** - * Notify adapter about the drawer's collapse state. This will affect the app divider's - * visibility. - */ - public void setIsCollapsed(boolean isCollapsed) { - for (int i = 0, size = getItemCount(); i < size; i++) { - getAdapterForIndex(i).setAzLabelVisibility(!isCollapsed); - } - } - - private static ViewGroup makeProfileView( - Context context, FeatureFlags featureFlags) { - LayoutInflater inflater = LayoutInflater.from(context); - ViewGroup rootView = featureFlags.scrollablePreview() - ? (ViewGroup) inflater.inflate(R.layout.chooser_list_per_profile_wrap, null, false) - : (ViewGroup) inflater.inflate(R.layout.chooser_list_per_profile, null, false); - RecyclerView recyclerView = rootView.findViewById(com.android.internal.R.id.resolver_list); - recyclerView.setAccessibilityDelegateCompat( - new ChooserRecyclerViewAccessibilityDelegate(recyclerView)); - return rootView; - } - - @Override - public boolean rebuildActiveTab(boolean doPostProcessing) { - if (doPostProcessing) { - Tracer.INSTANCE.beginAppTargetLoadingSection(getActiveListAdapter().getUserHandle()); - } - return super.rebuildActiveTab(doPostProcessing); - } - - @Override - public boolean rebuildInactiveTab(boolean doPostProcessing) { - if (getItemCount() != 1 && doPostProcessing) { - Tracer.INSTANCE.beginAppTargetLoadingSection(getInactiveListAdapter().getUserHandle()); - } - return super.rebuildInactiveTab(doPostProcessing); - } - - private static class BottomPaddingOverrideSupplier implements Supplier> { - private final Context mContext; - private int mBottomOffset; - - BottomPaddingOverrideSupplier(Context context) { - mContext = context; - } - - public void setEmptyStateBottomOffset(int bottomOffset) { - mBottomOffset = bottomOffset; - } - - public Optional get() { - int initialBottomPadding = mContext.getResources().getDimensionPixelSize( - R.dimen.resolver_empty_state_container_padding_bottom); - return Optional.of(initialBottomPadding + mBottomOffset); - } - } - - private static class ChooserProfileAdapterBinder implements - AdapterBinder { - private int mMaxTargetsPerRow; - - ChooserProfileAdapterBinder(int maxTargetsPerRow) { - mMaxTargetsPerRow = maxTargetsPerRow; - } - - public void setMaxTargetsPerRow(int maxTargetsPerRow) { - mMaxTargetsPerRow = maxTargetsPerRow; - } - - @Override - public void bind( - RecyclerView recyclerView, ChooserGridAdapter chooserGridAdapter) { - GridLayoutManager glm = (GridLayoutManager) recyclerView.getLayoutManager(); - glm.setSpanCount(mMaxTargetsPerRow); - glm.setSpanSizeLookup( - new GridLayoutManager.SpanSizeLookup() { - @Override - public int getSpanSize(int position) { - return chooserGridAdapter.shouldCellSpan(position) - ? SINGLE_CELL_SPAN_SIZE - : glm.getSpanCount(); - } - }); - } - } -} diff --git a/java/src/com/android/intentresolver/ChooserSelector.kt b/java/src/com/android/intentresolver/ChooserSelector.kt new file mode 100644 index 00000000..378bc06c --- /dev/null +++ b/java/src/com/android/intentresolver/ChooserSelector.kt @@ -0,0 +1,36 @@ +package com.android.intentresolver.v2 + +import android.content.BroadcastReceiver +import android.content.ComponentName +import android.content.Context +import android.content.Intent +import android.content.pm.PackageManager +import com.android.intentresolver.FeatureFlags +import dagger.hilt.android.AndroidEntryPoint +import javax.inject.Inject + +@AndroidEntryPoint(BroadcastReceiver::class) +class ChooserSelector : Hilt_ChooserSelector() { + + @Inject lateinit var featureFlags: FeatureFlags + + override fun onReceive(context: Context, intent: Intent) { + super.onReceive(context, intent) + if (intent.action == Intent.ACTION_BOOT_COMPLETED) { + context.packageManager.setComponentEnabledSetting( + ComponentName(CHOOSER_PACKAGE, CHOOSER_PACKAGE + CHOOSER_CLASS), + if (featureFlags.modularFramework()) { + PackageManager.COMPONENT_ENABLED_STATE_ENABLED + } else { + PackageManager.COMPONENT_ENABLED_STATE_DEFAULT + }, + /* flags = */ 0, + ) + } + } + + companion object { + private const val CHOOSER_PACKAGE = "com.android.intentresolver" + private const val CHOOSER_CLASS = ".v2.ChooserActivity" + } +} diff --git a/java/src/com/android/intentresolver/IntentForwarderActivity.java b/java/src/com/android/intentresolver/IntentForwarderActivity.java index 15996d00..db94c918 100644 --- a/java/src/com/android/intentresolver/IntentForwarderActivity.java +++ b/java/src/com/android/intentresolver/IntentForwarderActivity.java @@ -20,8 +20,8 @@ import static android.app.admin.DevicePolicyResources.Strings.Core.FORWARD_INTEN import static android.app.admin.DevicePolicyResources.Strings.Core.FORWARD_INTENT_TO_WORK; import static android.content.pm.PackageManager.MATCH_DEFAULT_ONLY; -import static com.android.intentresolver.ResolverActivity.EXTRA_CALLING_USER; -import static com.android.intentresolver.ResolverActivity.EXTRA_SELECTED_PROFILE; +import static com.android.intentresolver.ui.viewmodel.ResolverRequestReaderKt.EXTRA_CALLING_USER; +import static com.android.intentresolver.ui.viewmodel.ResolverRequestReaderKt.EXTRA_SELECTED_PROFILE; import android.app.Activity; import android.app.ActivityThread; @@ -46,6 +46,7 @@ import android.widget.Toast; import androidx.annotation.Nullable; +import com.android.intentresolver.profiles.MultiProfilePagerAdapter; import com.android.internal.annotations.VisibleForTesting; import com.android.internal.logging.MetricsLogger; import com.android.internal.logging.nano.MetricsProto.MetricsEvent; @@ -254,9 +255,9 @@ public class IntentForwarderActivity extends Activity { private int findSelectedProfile(String className) { if (className.equals(FORWARD_INTENT_TO_PARENT)) { - return ChooserActivity.PROFILE_PERSONAL; + return MultiProfilePagerAdapter.PROFILE_PERSONAL; } else if (className.equals(FORWARD_INTENT_TO_MANAGED_PROFILE)) { - return ChooserActivity.PROFILE_WORK; + return MultiProfilePagerAdapter.PROFILE_WORK; } return -1; } diff --git a/java/src/com/android/intentresolver/IntentForwarding.kt b/java/src/com/android/intentresolver/IntentForwarding.kt new file mode 100644 index 00000000..c8f6cf41 --- /dev/null +++ b/java/src/com/android/intentresolver/IntentForwarding.kt @@ -0,0 +1,111 @@ +/* + * Copyright (C) 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.android.intentresolver + +import android.Manifest +import android.Manifest.permission.INTERACT_ACROSS_USERS +import android.Manifest.permission.INTERACT_ACROSS_USERS_FULL +import android.app.ActivityManager +import android.content.Context +import android.content.Intent +import android.content.PermissionChecker +import android.content.pm.ApplicationInfo +import android.content.pm.PackageManager +import android.content.pm.PackageManager.PERMISSION_GRANTED +import android.os.UserHandle +import android.os.UserManager +import android.util.Log +import com.android.intentresolver.data.repository.DevicePolicyResources +import javax.inject.Inject +import javax.inject.Singleton + +private const val TAG: String = "IntentForwarding" + +@Singleton +class IntentForwarding +@Inject +constructor( + private val resources: DevicePolicyResources, + private val userManager: UserManager, + private val packageManager: PackageManager +) { + + fun forwardMessageFor(intent: Intent): String? { + val contentUserHint = intent.contentUserHint + if ( + contentUserHint != UserHandle.USER_CURRENT && contentUserHint != UserHandle.myUserId() + ) { + val originUserInfo = userManager.getUserInfo(contentUserHint) + val originIsManaged = originUserInfo?.isManagedProfile ?: false + val targetIsManaged = userManager.isManagedProfile + return when { + originIsManaged && !targetIsManaged -> resources.forwardToPersonalMessage + !originIsManaged && targetIsManaged -> resources.forwardToWorkMessage + else -> null + } + } + return null + } + + private fun isPermissionGranted(permission: String, uid: Int) = + ActivityManager.checkComponentPermission( + /* permission = */ permission, + /* uid = */ uid, + /* owningUid= */ -1, + /* exported= */ true + ) + + /** + * Returns whether the package has the necessary permissions to interact across profiles on + * behalf of a given user. + * + * This means meeting the following condition: + * * The app's [ApplicationInfo.crossProfile] flag must be true, and at least one of the + * following conditions must be fulfilled + * * `Manifest.permission.INTERACT_ACROSS_USERS_FULL` granted. + * * `Manifest.permission.INTERACT_ACROSS_USERS` granted. + * * `Manifest.permission.INTERACT_ACROSS_PROFILES` granted, or the corresponding AppOps + * `android:interact_across_profiles` is set to "allow". + */ + fun canAppInteractAcrossProfiles(context: Context, packageName: String): Boolean { + val applicationInfo: ApplicationInfo + try { + applicationInfo = packageManager.getApplicationInfo(packageName, 0) + } catch (e: PackageManager.NameNotFoundException) { + Log.e(TAG, "Package $packageName does not exist on current user.") + return false + } + if (!applicationInfo.crossProfile) { + return false + } + + val packageUid = applicationInfo.uid + + if (isPermissionGranted(INTERACT_ACROSS_USERS_FULL, packageUid) == PERMISSION_GRANTED) { + return true + } + if (isPermissionGranted(INTERACT_ACROSS_USERS, packageUid) == PERMISSION_GRANTED) { + return true + } + return PermissionChecker.checkPermissionForPreflight( + context, + Manifest.permission.INTERACT_ACROSS_PROFILES, + PermissionChecker.PID_UNKNOWN, + packageUid, + packageName + ) == PERMISSION_GRANTED + } +} diff --git a/java/src/com/android/intentresolver/JavaFlowHelper.kt b/java/src/com/android/intentresolver/JavaFlowHelper.kt new file mode 100644 index 00000000..231cb809 --- /dev/null +++ b/java/src/com/android/intentresolver/JavaFlowHelper.kt @@ -0,0 +1,30 @@ +/* + * Copyright 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +@file:JvmName("JavaFlowHelper") + +package com.android.intentresolver + +import com.android.intentresolver.annotation.JavaInterop +import java.util.function.Consumer +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.launch + +@JavaInterop +fun collect(scope: CoroutineScope, flow: Flow, collector: Consumer): Job = + scope.launch { flow.collect { collector.accept(it) } } diff --git a/java/src/com/android/intentresolver/MultiProfilePagerAdapter.java b/java/src/com/android/intentresolver/MultiProfilePagerAdapter.java deleted file mode 100644 index 42a29e55..00000000 --- a/java/src/com/android/intentresolver/MultiProfilePagerAdapter.java +++ /dev/null @@ -1,583 +0,0 @@ -/* - * Copyright (C) 2019 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package com.android.intentresolver; - -import android.os.Trace; -import android.os.UserHandle; -import android.view.View; -import android.view.ViewGroup; -import android.widget.Button; -import android.widget.TextView; - -import androidx.annotation.IntDef; -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; -import androidx.viewpager.widget.PagerAdapter; -import androidx.viewpager.widget.ViewPager; - -import com.android.intentresolver.emptystate.EmptyState; -import com.android.intentresolver.emptystate.EmptyStateProvider; -import com.android.intentresolver.emptystate.EmptyStateUiHelper; -import com.android.internal.annotations.VisibleForTesting; - -import com.google.common.collect.ImmutableList; - -import java.util.HashSet; -import java.util.Optional; -import java.util.Set; -import java.util.function.Function; -import java.util.function.Supplier; - -/** - * Skeletal {@link PagerAdapter} implementation for a UI with per-profile tabs (as in Sharesheet). - * - * TODO: attempt to further restrict visibility/improve encapsulation in the methods we expose. - * TODO: deprecate and audit/fix usages of any methods that refer to the "active" or "inactive" - * adapters; these were marked {@link VisibleForTesting} and their usage seems like an accident - * waiting to happen since clients seem to make assumptions about which adapter will be "active" in - * a particular context, and more explicit APIs would make sure those were valid. - * TODO: consider renaming legacy methods (e.g. why do we know it's a "list", not just a "page"?) - * - * @param the type of the widget that represents the contents of a page in this adapter - * @param the type of a "root" adapter class to be instantiated and included in - * the per-profile records. - * @param the concrete type of a {@link ResolverListAdapter} implementation to - * control the contents of a given per-profile list. This is provided for convenience, since it must - * be possible to get the list adapter from the page adapter via our {@link mListAdapterExtractor}. - * - * TODO: this is part of an in-progress refactor to merge with `GenericMultiProfilePagerAdapter`. - * As originally noted there, we've reduced explicit references to the `ResolverListAdapter` base - * type and may be able to drop the type constraint. - */ -public class MultiProfilePagerAdapter< - PageViewT extends ViewGroup, - SinglePageAdapterT, - ListAdapterT extends ResolverListAdapter> extends PagerAdapter { - - /** - * Delegate to set up a given adapter and page view to be used together. - * @param (as in {@link MultiProfilePagerAdapter}). - * @param (as in {@link MultiProfilePagerAdapter}). - */ - public interface AdapterBinder { - /** - * The given {@code view} will be associated with the given {@code adapter}. Do any work - * necessary to configure them compatibly, introduce them to each other, etc. - */ - void bind(PageViewT view, SinglePageAdapterT adapter); - } - - public static final int PROFILE_PERSONAL = 0; - public static final int PROFILE_WORK = 1; - - @IntDef({PROFILE_PERSONAL, PROFILE_WORK}) - public @interface Profile {} - - private final Function mListAdapterExtractor; - private final AdapterBinder mAdapterBinder; - private final Supplier mPageViewInflater; - private final Supplier> mContainerBottomPaddingOverrideSupplier; - - private final ImmutableList> mItems; - - private final EmptyStateProvider mEmptyStateProvider; - private final UserHandle mWorkProfileUserHandle; - private final UserHandle mCloneProfileUserHandle; - private final Supplier mWorkProfileQuietModeChecker; // True when work is quiet. - - private Set mLoadedPages; - private int mCurrentPage; - private OnProfileSelectedListener mOnProfileSelectedListener; - - protected MultiProfilePagerAdapter( - Function listAdapterExtractor, - AdapterBinder adapterBinder, - ImmutableList adapters, - EmptyStateProvider emptyStateProvider, - Supplier workProfileQuietModeChecker, - @Profile int defaultProfile, - UserHandle workProfileUserHandle, - UserHandle cloneProfileUserHandle, - Supplier pageViewInflater, - Supplier> containerBottomPaddingOverrideSupplier) { - mCurrentPage = defaultProfile; - mLoadedPages = new HashSet<>(); - mWorkProfileUserHandle = workProfileUserHandle; - mCloneProfileUserHandle = cloneProfileUserHandle; - mEmptyStateProvider = emptyStateProvider; - mWorkProfileQuietModeChecker = workProfileQuietModeChecker; - - mListAdapterExtractor = listAdapterExtractor; - mAdapterBinder = adapterBinder; - mPageViewInflater = pageViewInflater; - mContainerBottomPaddingOverrideSupplier = containerBottomPaddingOverrideSupplier; - - ImmutableList.Builder> items = - new ImmutableList.Builder<>(); - for (SinglePageAdapterT adapter : adapters) { - items.add(createProfileDescriptor(adapter)); - } - mItems = items.build(); - } - - private ProfileDescriptor createProfileDescriptor( - SinglePageAdapterT adapter) { - return new ProfileDescriptor<>(mPageViewInflater.get(), adapter); - } - - public void setOnProfileSelectedListener(OnProfileSelectedListener listener) { - mOnProfileSelectedListener = listener; - } - - /** - * Sets this instance of this class as {@link ViewPager}'s {@link PagerAdapter} and sets - * an {@link ViewPager.OnPageChangeListener} where it keeps track of the currently displayed - * page and rebuilds the list. - */ - public void setupViewPager(ViewPager viewPager) { - viewPager.setOnPageChangeListener(new ViewPager.SimpleOnPageChangeListener() { - @Override - public void onPageSelected(int position) { - mCurrentPage = position; - if (!mLoadedPages.contains(position)) { - rebuildActiveTab(true); - mLoadedPages.add(position); - } - if (mOnProfileSelectedListener != null) { - mOnProfileSelectedListener.onProfileSelected(position); - } - } - - @Override - public void onPageScrollStateChanged(int state) { - if (mOnProfileSelectedListener != null) { - mOnProfileSelectedListener.onProfilePageStateChanged(state); - } - } - }); - viewPager.setAdapter(this); - viewPager.setCurrentItem(mCurrentPage); - mLoadedPages.add(mCurrentPage); - } - - public void clearInactiveProfileCache() { - if (mLoadedPages.size() == 1) { - return; - } - mLoadedPages.remove(1 - mCurrentPage); - } - - @NonNull - @Override - public final ViewGroup instantiateItem(ViewGroup container, int position) { - setupListAdapter(position); - final ProfileDescriptor descriptor = getItem(position); - container.addView(descriptor.mRootView); - return descriptor.mRootView; - } - - @Override - public void destroyItem(ViewGroup container, int position, @NonNull Object view) { - container.removeView((View) view); - } - - @Override - public int getCount() { - return getItemCount(); - } - - public int getCurrentPage() { - return mCurrentPage; - } - - @VisibleForTesting - public UserHandle getCurrentUserHandle() { - return getActiveListAdapter().getUserHandle(); - } - - @Override - public boolean isViewFromObject(@NonNull View view, @NonNull Object object) { - return view == object; - } - - @Override - public CharSequence getPageTitle(int position) { - return null; - } - - public UserHandle getCloneUserHandle() { - return mCloneProfileUserHandle; - } - - /** - * Returns the {@link ProfileDescriptor} relevant to the given pageIndex. - *
    - *
  • For a device with only one user, pageIndex value of - * 0 would return the personal profile {@link ProfileDescriptor}.
  • - *
  • For a device with a work profile, pageIndex value of 0 would - * return the personal profile {@link ProfileDescriptor}, and pageIndex value of - * 1 would return the work profile {@link ProfileDescriptor}.
  • - *
- */ - private ProfileDescriptor getItem(int pageIndex) { - return mItems.get(pageIndex); - } - - public ViewGroup getEmptyStateView(int pageIndex) { - return getItem(pageIndex).getEmptyStateView(); - } - - /** - * Returns the number of {@link ProfileDescriptor} objects. - *

For a normal consumer device with only one user returns 1. - *

For a device with a work profile returns 2. - */ - public final int getItemCount() { - return mItems.size(); - } - - public final PageViewT getListViewForIndex(int index) { - return getItem(index).mView; - } - - /** - * Returns the adapter of the list view for the relevant page specified by - * pageIndex. - *

This method is meant to be implemented with an implementation-specific return type - * depending on the adapter type. - */ - @VisibleForTesting - public final SinglePageAdapterT getAdapterForIndex(int index) { - return getItem(index).mAdapter; - } - - /** - * Performs view-related initialization procedures for the adapter specified - * by pageIndex. - */ - public final void setupListAdapter(int pageIndex) { - mAdapterBinder.bind(getListViewForIndex(pageIndex), getAdapterForIndex(pageIndex)); - } - - /** - * Returns the {@link ListAdapterT} instance of the profile that represents - * userHandle. If there is no such adapter for the specified - * userHandle, returns {@code null}. - *

For example, if there is a work profile on the device with user id 10, calling this method - * with UserHandle.of(10) returns the work profile {@link ListAdapterT}. - */ - @Nullable - public final ListAdapterT getListAdapterForUserHandle(UserHandle userHandle) { - if (getPersonalListAdapter().getUserHandle().equals(userHandle) - || userHandle.equals(getCloneUserHandle())) { - return getPersonalListAdapter(); - } else if ((getWorkListAdapter() != null) - && getWorkListAdapter().getUserHandle().equals(userHandle)) { - return getWorkListAdapter(); - } - return null; - } - - /** - * Returns the {@link ListAdapterT} instance of the profile that is currently visible - * to the user. - *

For example, if the user is viewing the work tab in the share sheet, this method returns - * the work profile {@link ListAdapterT}. - * @see #getInactiveListAdapter() - */ - @VisibleForTesting - public final ListAdapterT getActiveListAdapter() { - return mListAdapterExtractor.apply(getAdapterForIndex(getCurrentPage())); - } - - /** - * If this is a device with a work profile, returns the {@link ListAdapterT} instance - * of the profile that is not currently visible to the user. Otherwise returns - * {@code null}. - *

For example, if the user is viewing the work tab in the share sheet, this method returns - * the personal profile {@link ListAdapterT}. - * @see #getActiveListAdapter() - */ - @VisibleForTesting - @Nullable - public final ListAdapterT getInactiveListAdapter() { - if (getCount() < 2) { - return null; - } - return mListAdapterExtractor.apply(getAdapterForIndex(1 - getCurrentPage())); - } - - public final ListAdapterT getPersonalListAdapter() { - return mListAdapterExtractor.apply(getAdapterForIndex(PROFILE_PERSONAL)); - } - - @Nullable - public final ListAdapterT getWorkListAdapter() { - if (!hasAdapterForIndex(PROFILE_WORK)) { - return null; - } - return mListAdapterExtractor.apply(getAdapterForIndex(PROFILE_WORK)); - } - - public final SinglePageAdapterT getCurrentRootAdapter() { - return getAdapterForIndex(getCurrentPage()); - } - - public final PageViewT getActiveAdapterView() { - return getListViewForIndex(getCurrentPage()); - } - - @Nullable - public final PageViewT getInactiveAdapterView() { - if (getCount() < 2) { - return null; - } - return getListViewForIndex(1 - getCurrentPage()); - } - - /** - * Rebuilds the tab that is currently visible to the user. - *

Returns {@code true} if rebuild has completed. - */ - public boolean rebuildActiveTab(boolean doPostProcessing) { - Trace.beginSection("MultiProfilePagerAdapter#rebuildActiveTab"); - boolean result = rebuildTab(getActiveListAdapter(), doPostProcessing); - Trace.endSection(); - return result; - } - - /** - * Rebuilds the tab that is not currently visible to the user, if such one exists. - *

Returns {@code true} if rebuild has completed. - */ - public boolean rebuildInactiveTab(boolean doPostProcessing) { - Trace.beginSection("MultiProfilePagerAdapter#rebuildInactiveTab"); - if (getItemCount() == 1) { - Trace.endSection(); - return false; - } - boolean result = rebuildTab(getInactiveListAdapter(), doPostProcessing); - Trace.endSection(); - return result; - } - - private int userHandleToPageIndex(UserHandle userHandle) { - if (userHandle.equals(getPersonalListAdapter().getUserHandle())) { - return PROFILE_PERSONAL; - } else { - return PROFILE_WORK; - } - } - - private boolean rebuildTab(ListAdapterT activeListAdapter, boolean doPostProcessing) { - if (shouldSkipRebuild(activeListAdapter)) { - activeListAdapter.postListReadyRunnable(doPostProcessing, /* rebuildCompleted */ true); - return false; - } - return activeListAdapter.rebuildList(doPostProcessing); - } - - private boolean shouldSkipRebuild(ListAdapterT activeListAdapter) { - EmptyState emptyState = mEmptyStateProvider.getEmptyState(activeListAdapter); - return emptyState != null && emptyState.shouldSkipDataRebuild(); - } - - private boolean hasAdapterForIndex(int pageIndex) { - return (pageIndex < getCount()); - } - - /** - * The empty state screens are shown according to their priority: - *

    - *
  1. (highest priority) cross-profile disabled by policy (handled in - * {@link #rebuildTab(ListAdapterT, boolean)})
  2. - *
  3. no apps available
  4. - *
  5. (least priority) work is off
  6. - *
- * - * The intention is to prevent the user from having to turn - * the work profile on if there will not be any apps resolved - * anyway. - */ - public void showEmptyResolverListEmptyState(ListAdapterT listAdapter) { - final EmptyState emptyState = mEmptyStateProvider.getEmptyState(listAdapter); - - if (emptyState == null) { - return; - } - - emptyState.onEmptyStateShown(); - - View.OnClickListener clickListener = null; - - if (emptyState.getButtonClickListener() != null) { - clickListener = v -> emptyState.getButtonClickListener().onClick(() -> { - ProfileDescriptor descriptor = getItem( - userHandleToPageIndex(listAdapter.getUserHandle())); - descriptor.mEmptyStateUi.showSpinner(); - }); - } - - showEmptyState(listAdapter, emptyState, clickListener); - } - - /** - * Class to get user id of the current process - */ - public static class MyUserIdProvider { - /** - * @return user id of the current process - */ - public int getMyUserId() { - return UserHandle.myUserId(); - } - } - - protected void showEmptyState( - ListAdapterT activeListAdapter, - EmptyState emptyState, - View.OnClickListener buttonOnClick) { - ProfileDescriptor descriptor = getItem( - userHandleToPageIndex(activeListAdapter.getUserHandle())); - descriptor.mRootView.findViewById( - com.android.internal.R.id.resolver_list).setVisibility(View.GONE); - descriptor.mEmptyStateUi.resetViewVisibilities(); - - ViewGroup emptyStateView = descriptor.getEmptyStateView(); - - View container = emptyStateView.findViewById( - com.android.internal.R.id.resolver_empty_state_container); - setupContainerPadding(container); - - TextView titleView = emptyStateView.findViewById( - com.android.internal.R.id.resolver_empty_state_title); - String title = emptyState.getTitle(); - if (title != null) { - titleView.setVisibility(View.VISIBLE); - titleView.setText(title); - } else { - titleView.setVisibility(View.GONE); - } - - TextView subtitleView = emptyStateView.findViewById( - com.android.internal.R.id.resolver_empty_state_subtitle); - String subtitle = emptyState.getSubtitle(); - if (subtitle != null) { - subtitleView.setVisibility(View.VISIBLE); - subtitleView.setText(subtitle); - } else { - subtitleView.setVisibility(View.GONE); - } - - View defaultEmptyText = emptyStateView.findViewById(com.android.internal.R.id.empty); - defaultEmptyText.setVisibility(emptyState.useDefaultEmptyView() ? View.VISIBLE : View.GONE); - - Button button = emptyStateView.findViewById( - com.android.internal.R.id.resolver_empty_state_button); - button.setVisibility(buttonOnClick != null ? View.VISIBLE : View.GONE); - button.setOnClickListener(buttonOnClick); - - activeListAdapter.markTabLoaded(); - } - - /** - * Sets up the padding of the view containing the empty state screens. - *

This method is meant to be overridden so that subclasses can customize the padding. - */ - public void setupContainerPadding(View container) { - Optional bottomPaddingOverride = mContainerBottomPaddingOverrideSupplier.get(); - bottomPaddingOverride.ifPresent(paddingBottom -> - container.setPadding( - container.getPaddingLeft(), - container.getPaddingTop(), - container.getPaddingRight(), - paddingBottom)); - } - - public void showListView(ListAdapterT activeListAdapter) { - ProfileDescriptor descriptor = getItem( - userHandleToPageIndex(activeListAdapter.getUserHandle())); - descriptor.mRootView.findViewById( - com.android.internal.R.id.resolver_list).setVisibility(View.VISIBLE); - descriptor.mEmptyStateUi.hide(); - } - - public boolean shouldShowEmptyStateScreen(ListAdapterT listAdapter) { - int count = listAdapter.getUnfilteredCount(); - return (count == 0 && listAdapter.getPlaceholderCount() == 0) - || (listAdapter.getUserHandle().equals(mWorkProfileUserHandle) - && mWorkProfileQuietModeChecker.get()); - } - - // TODO: `ChooserActivity` also has a per-profile record type. Maybe the "multi-profile pager" - // should be the owner of all per-profile data (especially now that the API is generic)? - private static class ProfileDescriptor { - final ViewGroup mRootView; - final EmptyStateUiHelper mEmptyStateUi; - - // TODO: post-refactoring, we may not need to retain these ivars directly (since they may - // be encapsulated within the `EmptyStateUiHelper`?). - private final ViewGroup mEmptyStateView; - - private final SinglePageAdapterT mAdapter; - private final PageViewT mView; - - ProfileDescriptor(ViewGroup rootView, SinglePageAdapterT adapter) { - mRootView = rootView; - mAdapter = adapter; - mEmptyStateView = rootView.findViewById(com.android.internal.R.id.resolver_empty_state); - mView = (PageViewT) rootView.findViewById(com.android.internal.R.id.resolver_list); - mEmptyStateUi = new EmptyStateUiHelper(rootView); - } - - protected ViewGroup getEmptyStateView() { - return mEmptyStateView; - } - } - - /** Listener interface for changes between the per-profile UI tabs. */ - public interface OnProfileSelectedListener { - /** - * Callback for when the user changes the active tab from personal to work or vice versa. - *

This callback is only called when the intent resolver or share sheet shows - * the work and personal profiles. - * @param profileIndex {@link #PROFILE_PERSONAL} if the personal profile was selected or - * {@link #PROFILE_WORK} if the work profile was selected. - */ - void onProfileSelected(int profileIndex); - - - /** - * Callback for when the scroll state changes. Useful for discovering when the user begins - * dragging, when the pager is automatically settling to the current page, or when it is - * fully stopped/idle. - * @param state {@link ViewPager#SCROLL_STATE_IDLE}, {@link ViewPager#SCROLL_STATE_DRAGGING} - * or {@link ViewPager#SCROLL_STATE_SETTLING} - * @see ViewPager.OnPageChangeListener#onPageScrollStateChanged - */ - void onProfilePageStateChanged(int state); - } - - /** - * Listener for when the user switches on the work profile from the work tab. - */ - public interface OnSwitchOnWorkSelectedListener { - /** - * Callback for when the user switches on the work profile from the work tab. - */ - void onSwitchOnWorkSelected(); - } -} diff --git a/java/src/com/android/intentresolver/ProfileAvailability.kt b/java/src/com/android/intentresolver/ProfileAvailability.kt new file mode 100644 index 00000000..cf3e566e --- /dev/null +++ b/java/src/com/android/intentresolver/ProfileAvailability.kt @@ -0,0 +1,85 @@ +/* + * Copyright (C) 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.intentresolver + +import androidx.annotation.MainThread +import com.android.intentresolver.annotation.JavaInterop +import com.android.intentresolver.domain.interactor.UserInteractor +import com.android.intentresolver.shared.model.Profile +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.filter +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking + +/** Provides availability status for profiles */ +@JavaInterop +class ProfileAvailability( + private val userInteractor: UserInteractor, + private val scope: CoroutineScope, + private val background: CoroutineDispatcher, +) { + /** Used by WorkProfilePausedEmptyStateProvider */ + var waitingToEnableProfile = false + private set + + /** Set by ChooserActivity to call onWorkProfileStatusUpdated */ + var onProfileStatusChange: Runnable? = null + + private var waitJob: Job? = null + + /** Query current profile availability. An unavailable profile is one which is not active. */ + @MainThread + fun isAvailable(profile: Profile): Boolean { + return runBlocking(background) { + userInteractor.availability.map { it[profile] == true }.first() + } + } + + /** Used by WorkProfilePausedEmptyStateProvider */ + fun requestQuietModeState(profile: Profile, quietMode: Boolean) { + val enableProfile = !quietMode + + // Check if the profile is already in the correct state + if (isAvailable(profile) == enableProfile) { + return // No-op + } + + // Support existing code + if (enableProfile) { + waitingToEnableProfile = true + waitJob?.cancel() + + val job = + scope.launch { + // Wait for the profile to become available + userInteractor.availability.filter { it[profile] == true }.first() + } + job.invokeOnCompletion { + waitingToEnableProfile = false + onProfileStatusChange?.run() + } + waitJob = job + } + + // Apply the change + scope.launch { userInteractor.updateState(profile, enableProfile) } + } +} diff --git a/java/src/com/android/intentresolver/ProfileHelper.kt b/java/src/com/android/intentresolver/ProfileHelper.kt new file mode 100644 index 00000000..e1d912c3 --- /dev/null +++ b/java/src/com/android/intentresolver/ProfileHelper.kt @@ -0,0 +1,97 @@ +/* + * Copyright (C) 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.intentresolver + +import android.os.UserHandle +import androidx.annotation.MainThread +import com.android.intentresolver.annotation.JavaInterop +import com.android.intentresolver.domain.interactor.UserInteractor +import com.android.intentresolver.inject.IntentResolverFlags +import com.android.intentresolver.shared.model.Profile +import com.android.intentresolver.shared.model.User +import javax.inject.Inject +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.runBlocking + +@JavaInterop +@MainThread +class ProfileHelper +@Inject +constructor( + interactor: UserInteractor, + private val scope: CoroutineScope, + private val background: CoroutineDispatcher, + private val flags: IntentResolverFlags, +) { + private val launchedByHandle: UserHandle = interactor.launchedAs + + val launchedAsProfile by lazy { + runBlocking(background) { interactor.launchedAsProfile.first() } + } + val profiles by lazy { runBlocking(background) { interactor.profiles.first() } } + + // Map UserHandle back to a user within launchedByProfile + private val launchedByUser: User = + when (launchedByHandle) { + launchedAsProfile.primary.handle -> launchedAsProfile.primary + launchedAsProfile.clone?.handle -> requireNotNull(launchedAsProfile.clone) + else -> error("launchedByUser must be a member of launchedByProfile") + } + val launchedAsProfileType: Profile.Type = launchedAsProfile.type + + val personalProfile = profiles.single { it.type == Profile.Type.PERSONAL } + val workProfile = profiles.singleOrNull { it.type == Profile.Type.WORK } + val privateProfile = profiles.singleOrNull { it.type == Profile.Type.PRIVATE } + + val personalHandle = personalProfile.primary.handle + val workHandle = workProfile?.primary?.handle + val privateHandle = privateProfile?.primary?.handle + val cloneHandle = personalProfile.clone?.handle + + val isLaunchedAsCloneProfile = launchedByUser == launchedAsProfile.clone + + val cloneUserPresent = personalProfile.clone != null + val workProfilePresent = workProfile != null + val privateProfilePresent = privateProfile != null + + // Name retained for ease of review, to be renamed later + val tabOwnerUserHandleForLaunch = + if (launchedByUser.role == User.Role.CLONE) { + // When started by clone user, return the profile owner instead + launchedAsProfile.primary.handle + } else { + // Otherwise the launched user is used + launchedByUser.handle + } + + fun 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 + fun getQueryIntentsHandle(handle: UserHandle): UserHandle? { + return if (isLaunchedAsCloneProfile && handle == personalHandle) { + cloneHandle + } else { + handle + } + } +} diff --git a/java/src/com/android/intentresolver/ResolverActivity.java b/java/src/com/android/intentresolver/ResolverActivity.java index 0331c33e..17e957ae 100644 --- a/java/src/com/android/intentresolver/ResolverActivity.java +++ b/java/src/com/android/intentresolver/ResolverActivity.java @@ -16,39 +16,30 @@ package com.android.intentresolver; -import static android.Manifest.permission.INTERACT_ACROSS_PROFILES; -import static android.app.admin.DevicePolicyResources.Strings.Core.FORWARD_INTENT_TO_PERSONAL; -import static android.app.admin.DevicePolicyResources.Strings.Core.FORWARD_INTENT_TO_WORK; import static android.app.admin.DevicePolicyResources.Strings.Core.RESOLVER_CANT_ACCESS_PERSONAL; import static android.app.admin.DevicePolicyResources.Strings.Core.RESOLVER_CANT_ACCESS_WORK; import static android.app.admin.DevicePolicyResources.Strings.Core.RESOLVER_CROSS_PROFILE_BLOCKED_TITLE; -import static android.app.admin.DevicePolicyResources.Strings.Core.RESOLVER_PERSONAL_TAB; -import static android.app.admin.DevicePolicyResources.Strings.Core.RESOLVER_PERSONAL_TAB_ACCESSIBILITY; -import static android.app.admin.DevicePolicyResources.Strings.Core.RESOLVER_WORK_PROFILE_NOT_SUPPORTED; -import static android.app.admin.DevicePolicyResources.Strings.Core.RESOLVER_WORK_TAB; -import static android.app.admin.DevicePolicyResources.Strings.Core.RESOLVER_WORK_TAB_ACCESSIBILITY; -import static android.content.Intent.FLAG_ACTIVITY_LAUNCH_ADJACENT; import static android.content.Intent.FLAG_ACTIVITY_NEW_TASK; -import static android.content.PermissionChecker.PID_UNKNOWN; import static android.stats.devicepolicy.nano.DevicePolicyEnums.RESOLVER_EMPTY_STATE_NO_SHARING_TO_PERSONAL; import static android.stats.devicepolicy.nano.DevicePolicyEnums.RESOLVER_EMPTY_STATE_NO_SHARING_TO_WORK; import static android.view.WindowManager.LayoutParams.SYSTEM_FLAG_HIDE_NON_SYSTEM_OVERLAY_WINDOWS; +import static androidx.lifecycle.LifecycleKt.getCoroutineScope; + +import static com.android.intentresolver.ext.CreationExtrasExtKt.addDefaultArgs; import static com.android.internal.annotations.VisibleForTesting.Visibility.PROTECTED; -import android.app.Activity; -import android.app.ActivityManager; +import static java.util.Objects.requireNonNull; + import android.app.ActivityThread; import android.app.VoiceInteractor.PickOptionRequest; import android.app.VoiceInteractor.PickOptionRequest.Option; import android.app.VoiceInteractor.Prompt; import android.app.admin.DevicePolicyEventLogger; -import android.app.admin.DevicePolicyManager; import android.content.ComponentName; import android.content.Context; import android.content.Intent; import android.content.IntentFilter; -import android.content.PermissionChecker; import android.content.pm.ActivityInfo; import android.content.pm.ApplicationInfo; import android.content.pm.PackageManager; @@ -56,7 +47,6 @@ import android.content.pm.PackageManager.NameNotFoundException; import android.content.pm.ResolveInfo; import android.content.pm.UserInfo; import android.content.res.Configuration; -import android.content.res.TypedArray; import android.graphics.Insets; import android.net.Uri; import android.os.Build; @@ -67,7 +57,6 @@ import android.os.StrictMode; import android.os.Trace; import android.os.UserHandle; import android.os.UserManager; -import android.provider.MediaStore; import android.provider.Settings; import android.stats.devicepolicy.DevicePolicyEnums; import android.text.TextUtils; @@ -89,22 +78,20 @@ import android.widget.ImageView; import android.widget.ListView; import android.widget.Space; import android.widget.TabHost; -import android.widget.TabWidget; import android.widget.TextView; import android.widget.Toast; import androidx.annotation.NonNull; import androidx.annotation.Nullable; -import androidx.annotation.StringRes; -import androidx.annotation.UiThread; import androidx.fragment.app.FragmentActivity; +import androidx.lifecycle.ViewModelProvider; +import androidx.lifecycle.viewmodel.CreationExtras; import androidx.viewpager.widget.ViewPager; -import com.android.intentresolver.MultiProfilePagerAdapter.MyUserIdProvider; -import com.android.intentresolver.MultiProfilePagerAdapter.OnSwitchOnWorkSelectedListener; -import com.android.intentresolver.MultiProfilePagerAdapter.Profile; import com.android.intentresolver.chooser.DisplayResolveInfo; import com.android.intentresolver.chooser.TargetInfo; +import com.android.intentresolver.data.repository.DevicePolicyResources; +import com.android.intentresolver.domain.interactor.UserInteractor; import com.android.intentresolver.emptystate.CompositeEmptyStateProvider; import com.android.intentresolver.emptystate.CrossProfileIntentsChecker; import com.android.intentresolver.emptystate.EmptyState; @@ -115,22 +102,41 @@ import com.android.intentresolver.emptystate.NoCrossProfileEmptyStateProvider.De import com.android.intentresolver.emptystate.WorkProfilePausedEmptyStateProvider; import com.android.intentresolver.icons.DefaultTargetDataLoader; import com.android.intentresolver.icons.TargetDataLoader; +import com.android.intentresolver.inject.Background; import com.android.intentresolver.model.ResolverRankerServiceResolverComparator; +import com.android.intentresolver.profiles.MultiProfilePagerAdapter; +import com.android.intentresolver.profiles.MultiProfilePagerAdapter.ProfileType; +import com.android.intentresolver.profiles.OnProfileSelectedListener; +import com.android.intentresolver.profiles.OnSwitchOnWorkSelectedListener; +import com.android.intentresolver.profiles.ResolverMultiProfilePagerAdapter; +import com.android.intentresolver.profiles.TabConfig; +import com.android.intentresolver.shared.model.Profile; +import com.android.intentresolver.ui.ActionTitle; +import com.android.intentresolver.ui.model.ActivityModel; +import com.android.intentresolver.ui.model.ResolverRequest; +import com.android.intentresolver.ui.viewmodel.ResolverViewModel; import com.android.intentresolver.widget.ResolverDrawerLayout; import com.android.internal.annotations.VisibleForTesting; import com.android.internal.content.PackageMonitor; import com.android.internal.logging.MetricsLogger; import com.android.internal.logging.nano.MetricsProto; -import com.android.internal.util.LatencyTracker; + +import com.google.common.collect.ImmutableList; + +import dagger.hilt.android.AndroidEntryPoint; + +import kotlin.Pair; import java.util.ArrayList; import java.util.Arrays; -import java.util.Collections; import java.util.Iterator; import java.util.List; import java.util.Objects; import java.util.Set; -import java.util.function.Supplier; + +import javax.inject.Inject; + +import kotlinx.coroutines.CoroutineDispatcher; /** * This is a copy of ResolverActivity to support IntentResolver's ChooserActivity. This code is @@ -138,47 +144,33 @@ import java.util.function.Supplier; * frameworks/base/core/java/com/android/internal/app/ResolverActivity.java for that), the full * migration is not complete. */ -@UiThread -public class ResolverActivity extends FragmentActivity implements +@AndroidEntryPoint(FragmentActivity.class) +public class ResolverActivity extends Hilt_ResolverActivity implements ResolverListAdapter.ResolverListCommunicator { - public ResolverActivity() { - mIsIntentPicker = getClass().equals(ResolverActivity.class); - } - - protected ResolverActivity(boolean isIntentPicker) { - mIsIntentPicker = isIntentPicker; - } - - /** - * Whether to enable a launch mode that is safe to use when forwarding intents received from - * applications and running in system processes. This mode uses Activity.startActivityAsCaller - * instead of the normal Activity.startActivity for launching the activity selected - * by the user. - */ - private boolean mSafeForwardingMode; + @Inject @Background public CoroutineDispatcher mBackgroundDispatcher; + @Inject public UserInteractor mUserInteractor; + @Inject public ResolverHelper mResolverHelper; + @Inject public PackageManager mPackageManager; + @Inject public DevicePolicyResources mDevicePolicyResources; + @Inject public IntentForwarding mIntentForwarding; + @Inject public FeatureFlags mFeatureFlags; + + private ResolverViewModel mViewModel; + private ResolverRequest mRequest; + private ProfileHelper mProfiles; + private ProfileAvailability mProfileAvailability; + protected TargetDataLoader mTargetDataLoader; + private boolean mResolvingHome; private Button mAlwaysButton; private Button mOnceButton; protected View mProfileView; private int mLastSelected = AbsListView.INVALID_POSITION; - private boolean mResolvingHome = false; - private String mProfileSwitchMessage; private int mLayoutId; - @VisibleForTesting - protected final ArrayList mIntents = new ArrayList<>(); private PickTargetOptionRequest mPickOptionRequest; - private String mReferrerPackage; - private CharSequence mTitle; - private int mDefaultTitleResId; // Expected to be true if this object is ResolverActivity or is ResolverWrapperActivity. - private final boolean mIsIntentPicker; - - // Whether or not this activity supports choosing a default handler for the intent. - @VisibleForTesting - protected boolean mSupportsAlwaysUseOption; protected ResolverDrawerLayout mResolverDrawerLayout; - protected PackageManager mPm; private static final String TAG = "ResolverActivity"; private static final boolean DEBUG = false; @@ -189,150 +181,33 @@ public class ResolverActivity extends FragmentActivity implements protected Insets mSystemWindowInsets = null; private Space mFooterSpacer = null; - /** See {@link #setRetainInOnStop}. */ - private boolean mRetainInOnStop; - protected static final String METRICS_CATEGORY_RESOLVER = "intent_resolver"; protected static final String METRICS_CATEGORY_CHOOSER = "intent_chooser"; /** Tracks if we should ignore future broadcasts telling us the work profile is enabled */ - private boolean mWorkProfileHasBeenEnabled = false; + private final boolean mWorkProfileHasBeenEnabled = false; - private static final String TAB_TAG_PERSONAL = "personal"; - private static final String TAB_TAG_WORK = "work"; + protected static final String TAB_TAG_PERSONAL = "personal"; + protected static final String TAB_TAG_WORK = "work"; private PackageMonitor mPersonalPackageMonitor; private PackageMonitor mWorkPackageMonitor; - private TargetDataLoader mTargetDataLoader; - - @VisibleForTesting - protected MultiProfilePagerAdapter mMultiProfilePagerAdapter; - - protected WorkProfileAvailabilityManager mWorkProfileAvailability; - - // Intent extra for connected audio devices - public static final String EXTRA_IS_AUDIO_CAPTURE_DEVICE = "is_audio_capture_device"; - - /** - * Integer extra to indicate which profile should be automatically selected. - *

Can only be used if there is a work profile. - *

Possible values can be either {@link #PROFILE_PERSONAL} or {@link #PROFILE_WORK}. - */ - protected static final String EXTRA_SELECTED_PROFILE = - "com.android.internal.app.ResolverActivity.EXTRA_SELECTED_PROFILE"; - - /** - * {@link UserHandle} extra to indicate the user of the user that the starting intent - * originated from. - *

This is not necessarily the same as {@link #getUserId()} or {@link UserHandle#myUserId()}, - * as there are edge cases when the intent resolver is launched in the other profile. - * For example, when we have 0 resolved apps in current profile and multiple resolved - * apps in the other profile, opening a link from the current profile launches the intent - * resolver in the other one. b/148536209 for more info. - */ - static final String EXTRA_CALLING_USER = - "com.android.internal.app.ResolverActivity.EXTRA_CALLING_USER"; + protected ResolverMultiProfilePagerAdapter mMultiProfilePagerAdapter; - protected static final int PROFILE_PERSONAL = MultiProfilePagerAdapter.PROFILE_PERSONAL; - protected static final int PROFILE_WORK = MultiProfilePagerAdapter.PROFILE_WORK; + public static final int PROFILE_PERSONAL = MultiProfilePagerAdapter.PROFILE_PERSONAL; + public static final int PROFILE_WORK = MultiProfilePagerAdapter.PROFILE_WORK; private UserHandle mHeaderCreatorUser; - // User handle annotations are lazy-initialized to ensure that they're computed exactly once - // (even though they can't be computed prior to activity creation). - // TODO: use a less ad-hoc pattern for lazy initialization (by switching to Dagger or - // introducing a common `LazySingletonSupplier` API, etc), and/or migrate all dependents to a - // new component whose lifecycle is limited to the "created" Activity (so that we can just hold - // the annotations as a `final` ivar, which is a better way to show immutability). - private Supplier mLazyAnnotatedUserHandles = () -> { - final AnnotatedUserHandles result = computeAnnotatedUserHandles(); - mLazyAnnotatedUserHandles = () -> result; - return result; - }; - - // This method is called exactly once during creation to compute the immutable annotations - // accessible through the lazy supplier {@link mLazyAnnotatedUserHandles}. - // TODO: this is only defined so that tests can provide an override that injects fake - // annotations. Dagger could provide a cleaner model for our testing/injection requirements. - @VisibleForTesting(visibility = VisibleForTesting.Visibility.PRIVATE) - protected AnnotatedUserHandles computeAnnotatedUserHandles() { - return AnnotatedUserHandles.forShareActivity(this); - } - @Nullable private OnSwitchOnWorkSelectedListener mOnSwitchOnWorkSelectedListener; - protected final LatencyTracker mLatencyTracker = getLatencyTracker(); - - private enum ActionTitle { - VIEW(Intent.ACTION_VIEW, - R.string.whichViewApplication, - R.string.whichViewApplicationNamed, - R.string.whichViewApplicationLabel), - EDIT(Intent.ACTION_EDIT, - R.string.whichEditApplication, - R.string.whichEditApplicationNamed, - R.string.whichEditApplicationLabel), - SEND(Intent.ACTION_SEND, - R.string.whichSendApplication, - R.string.whichSendApplicationNamed, - R.string.whichSendApplicationLabel), - SENDTO(Intent.ACTION_SENDTO, - R.string.whichSendToApplication, - R.string.whichSendToApplicationNamed, - R.string.whichSendToApplicationLabel), - SEND_MULTIPLE(Intent.ACTION_SEND_MULTIPLE, - R.string.whichSendApplication, - R.string.whichSendApplicationNamed, - R.string.whichSendApplicationLabel), - CAPTURE_IMAGE(MediaStore.ACTION_IMAGE_CAPTURE, - R.string.whichImageCaptureApplication, - R.string.whichImageCaptureApplicationNamed, - R.string.whichImageCaptureApplicationLabel), - DEFAULT(null, - R.string.whichApplication, - R.string.whichApplicationNamed, - R.string.whichApplicationLabel), - HOME(Intent.ACTION_MAIN, - R.string.whichHomeApplication, - R.string.whichHomeApplicationNamed, - R.string.whichHomeApplicationLabel); - - // titles for layout that deals with http(s) intents - public static final int BROWSABLE_TITLE_RES = R.string.whichOpenLinksWith; - public static final int BROWSABLE_HOST_TITLE_RES = R.string.whichOpenHostLinksWith; - public static final int BROWSABLE_HOST_APP_TITLE_RES = R.string.whichOpenHostLinksWithApp; - public static final int BROWSABLE_APP_TITLE_RES = R.string.whichOpenLinksWithApp; - - public final String action; - public final int titleRes; - public final int namedTitleRes; - public final @StringRes int labelRes; - - ActionTitle(String action, int titleRes, int namedTitleRes, @StringRes int labelRes) { - this.action = action; - this.titleRes = titleRes; - this.namedTitleRes = namedTitleRes; - this.labelRes = labelRes; - } - - public static ActionTitle forAction(String action) { - for (ActionTitle title : values()) { - if (title != HOME && action != null && action.equals(title.action)) { - return title; - } - } - return DEFAULT; - } - } - protected PackageMonitor createPackageMonitor(ResolverListAdapter listAdapter) { return new PackageMonitor() { @Override public void onSomePackagesChanged() { listAdapter.handlePackagesChanged(); - updateProfileViewButton(); } @Override @@ -344,123 +219,169 @@ public class ResolverActivity extends FragmentActivity implements }; } - @Override - protected void onCreate(Bundle savedInstanceState) { - // Use a specialized prompt when we're handling the 'Home' app startActivity() - final Intent intent = makeMyIntent(); - final Set categories = intent.getCategories(); - if (Intent.ACTION_MAIN.equals(intent.getAction()) - && categories != null - && categories.size() == 1 - && categories.contains(Intent.CATEGORY_HOME)) { - // Note: this field is not set to true in the compatibility version. - mResolvingHome = true; - } - - onCreate( - savedInstanceState, - intent, - /* additionalTargets= */ null, - /* title= */ null, - /* defaultTitleRes= */ 0, - /* initialIntents= */ null, - /* resolutionList= */ null, - /* supportsAlwaysUseOption= */ true, - createIconLoader(), - /* safeForwardingMode= */ true); + protected ActivityModel createActivityModel() { + return ActivityModel.createFrom(this); } - /** - * Compatibility version for other bundled services that use this overload without - * a default title resource - */ - protected void onCreate( - Bundle savedInstanceState, - Intent intent, - CharSequence title, - Intent[] initialIntents, - List resolutionList, - boolean supportsAlwaysUseOption, - boolean safeForwardingMode) { - onCreate( - savedInstanceState, - intent, - null, - title, - 0, - initialIntents, - resolutionList, - supportsAlwaysUseOption, - createIconLoader(), - safeForwardingMode); + @NonNull + @Override + public CreationExtras getDefaultViewModelCreationExtras() { + return addDefaultArgs( + super.getDefaultViewModelCreationExtras(), + new Pair<>(ActivityModel.ACTIVITY_MODEL_KEY, createActivityModel())); } - protected void onCreate( - Bundle savedInstanceState, - Intent intent, - Intent[] additionalTargets, - CharSequence title, - int defaultTitleRes, - Intent[] initialIntents, - List resolutionList, - boolean supportsAlwaysUseOption, - TargetDataLoader targetDataLoader, - boolean safeForwardingMode) { - setTheme(appliedThemeResId()); + @Override + protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); + Log.i(TAG, "onCreate"); + setTheme(R.style.Theme_DeviceDefault_Resolver); + mResolverHelper.setInitializer(this::initialize); + } - // Determine whether we should show that intent is forwarded - // from managed profile to owner or other way around. - setProfileSwitchMessage(intent.getContentUserHint()); + @Override + protected final void onStart() { + super.onStart(); + this.getWindow().addSystemFlags(SYSTEM_FLAG_HIDE_NON_SYSTEM_OVERLAY_WINDOWS); + } + + @Override + protected void onStop() { + super.onStop(); - // Force computation of user handle annotations in order to validate the caller ID. (See the - // associated TODO comment to explain why this is structured as a lazy computation.) - AnnotatedUserHandles unusedReferenceToHandles = mLazyAnnotatedUserHandles.get(); + final Window window = this.getWindow(); + final WindowManager.LayoutParams attrs = window.getAttributes(); + attrs.privateFlags &= ~SYSTEM_FLAG_HIDE_NON_SYSTEM_OVERLAY_WINDOWS; + window.setAttributes(attrs); - mWorkProfileAvailability = createWorkProfileAvailabilityManager(); + if (mRegistered) { + mPersonalPackageMonitor.unregister(); + if (mWorkPackageMonitor != null) { + mWorkPackageMonitor.unregister(); + } + mRegistered = false; + } + final Intent intent = getIntent(); + if ((intent.getFlags() & FLAG_ACTIVITY_NEW_TASK) != 0 && !isVoiceInteraction() + && !mResolvingHome) { + // This resolver is in the unusual situation where it has been + // launched at the top of a new task. We don't let it be added + // to the recent tasks shown to the user, and we need to make sure + // that each time we are launched we get the correct launching + // uid (not re-using the same resolver from an old launching uid), + // so we will now finish ourself since being no longer visible, + // the user probably can't get back to us. + if (!isChangingConfigurations()) { + finish(); + } + } + } - mPm = getPackageManager(); + @Override + protected final void onSaveInstanceState(@NonNull Bundle outState) { + super.onSaveInstanceState(outState); + ViewPager viewPager = findViewById(com.android.internal.R.id.profile_pager); + if (viewPager != null) { + outState.putInt(LAST_SHOWN_TAB_KEY, viewPager.getCurrentItem()); + } + } - mReferrerPackage = getReferrerPackageName(); + @Override + protected final void onRestart() { + super.onRestart(); + if (!mRegistered) { + mPersonalPackageMonitor.register( + this, + getMainLooper(), + mProfiles.getPersonalHandle(), + false); + if (mProfiles.getWorkProfilePresent()) { + if (mWorkPackageMonitor == null) { + mWorkPackageMonitor = createPackageMonitor( + mMultiProfilePagerAdapter.getWorkListAdapter()); + } + mWorkPackageMonitor.register( + this, + getMainLooper(), + mProfiles.getWorkHandle(), + false); + } + mRegistered = true; + } + mMultiProfilePagerAdapter.getActiveListAdapter().handlePackagesChanged(); + } - // The initial intent must come before any other targets that are to be added. - mIntents.add(0, new Intent(intent)); - if (additionalTargets != null) { - Collections.addAll(mIntents, additionalTargets); + @Override + protected void onDestroy() { + super.onDestroy(); + if (!isChangingConfigurations() && mPickOptionRequest != null) { + mPickOptionRequest.cancel(); } + if (mMultiProfilePagerAdapter != null + && mMultiProfilePagerAdapter.getActiveListAdapter() != null) { + mMultiProfilePagerAdapter.getActiveListAdapter().onDestroy(); + } + } + + private void initialize() { + mViewModel = new ViewModelProvider(this).get(ResolverViewModel.class); + mRequest = mViewModel.getRequest().getValue(); + + mProfiles = new ProfileHelper( + mUserInteractor, + getCoroutineScope(getLifecycle()), + mBackgroundDispatcher, + mFeatureFlags); + + mProfileAvailability = new ProfileAvailability( + mUserInteractor, + getCoroutineScope(getLifecycle()), + mBackgroundDispatcher); - mTitle = title; - mDefaultTitleResId = defaultTitleRes; + mProfileAvailability.setOnProfileStatusChange(this::onWorkProfileStatusUpdated); - mSupportsAlwaysUseOption = supportsAlwaysUseOption; - mSafeForwardingMode = safeForwardingMode; - mTargetDataLoader = targetDataLoader; + mResolvingHome = mRequest.isResolvingHome(); + mTargetDataLoader = new DefaultTargetDataLoader( + this, + getLifecycle(), + mRequest.isAudioCaptureDevice()); // The last argument of createResolverListAdapter is whether to do special handling // of the last used choice to highlight it in the list. We need to always // turn this off when running under voice interaction, since it results in // a more complicated UI that the current voice interaction flow is not able - // to handle. We also turn it off when the work tab is shown to simplify the UX. + // to handle. We also turn it off when multiple tabs are shown to simplify the UX. // We also turn it off when clonedProfile is present on the device, because we might have // different "last chosen" activities in the different profiles, and PackageManager doesn't // provide any more information to help us select between them. - boolean filterLastUsed = mSupportsAlwaysUseOption && !isVoiceInteraction() - && !shouldShowTabs() && !hasCloneProfile(); + boolean filterLastUsed = !isVoiceInteraction() + && !mProfiles.getWorkProfilePresent() && !mProfiles.getCloneUserPresent(); mMultiProfilePagerAdapter = createMultiProfilePagerAdapter( - initialIntents, resolutionList, filterLastUsed, targetDataLoader); - if (configureContentView(targetDataLoader)) { + new Intent[0], + /* resolutionList = */ mRequest.getResolutionList(), + filterLastUsed + ); + if (configureContentView(mTargetDataLoader)) { return; } mPersonalPackageMonitor = createPackageMonitor( mMultiProfilePagerAdapter.getPersonalListAdapter()); mPersonalPackageMonitor.register( - this, getMainLooper(), getAnnotatedUserHandles().personalProfileUserHandle, false); - if (shouldShowTabs()) { + this, + getMainLooper(), + mProfiles.getPersonalHandle(), + false + ); + if (mProfiles.getWorkProfilePresent()) { mWorkPackageMonitor = createPackageMonitor( mMultiProfilePagerAdapter.getWorkListAdapter()); mWorkPackageMonitor.register( - this, getMainLooper(), getAnnotatedUserHandles().workProfileUserHandle, false); + this, + getMainLooper(), + mProfiles.getWorkHandle(), + false + ); } mRegistered = true; @@ -474,7 +395,7 @@ public class ResolverActivity extends FragmentActivity implements } }); - boolean hasTouchScreen = getPackageManager() + boolean hasTouchScreen = mPackageManager .hasSystemFeature(PackageManager.FEATURE_TOUCHSCREEN); if (isVoiceInteraction() || !hasTouchScreen) { @@ -487,13 +408,7 @@ public class ResolverActivity extends FragmentActivity implements mResolverDrawerLayout = rdl; } - - mProfileView = findViewById(com.android.internal.R.id.profile_button); - if (mProfileView != null) { - mProfileView.setOnClickListener(this::onProfileClick); - updateProfileViewButton(); - } - + Intent intent = mViewModel.getRequest().getValue().getIntent(); final Set categories = intent.getCategories(); MetricsLogger.action(this, mMultiProfilePagerAdapter.getActiveListAdapter().hasFilteredItem() ? MetricsProto.MetricsEvent.ACTION_SHOW_APP_DISAMBIG_APP_FEATURED @@ -502,19 +417,31 @@ public class ResolverActivity extends FragmentActivity implements + (categories != null ? Arrays.toString(categories.toArray()) : "")); } - protected MultiProfilePagerAdapter createMultiProfilePagerAdapter( + private void restore(@Nullable Bundle savedInstanceState) { + if (savedInstanceState != null) { + // onRestoreInstanceState + resetButtonBar(); + ViewPager viewPager = findViewById(com.android.internal.R.id.profile_pager); + if (viewPager != null) { + viewPager.setCurrentItem(savedInstanceState.getInt(LAST_SHOWN_TAB_KEY)); + } + } + + mMultiProfilePagerAdapter.clearInactiveProfileCache(); + } + + protected ResolverMultiProfilePagerAdapter createMultiProfilePagerAdapter( Intent[] initialIntents, List resolutionList, - boolean filterLastUsed, - TargetDataLoader targetDataLoader) { - MultiProfilePagerAdapter resolverMultiProfilePagerAdapter = null; - if (shouldShowTabs()) { + boolean filterLastUsed) { + ResolverMultiProfilePagerAdapter resolverMultiProfilePagerAdapter = null; + if (mProfiles.getWorkProfilePresent()) { resolverMultiProfilePagerAdapter = createResolverMultiProfilePagerAdapterForTwoProfiles( - initialIntents, resolutionList, filterLastUsed, targetDataLoader); + initialIntents, resolutionList, filterLastUsed); } else { resolverMultiProfilePagerAdapter = createResolverMultiProfilePagerAdapterForOneProfile( - initialIntents, resolutionList, filterLastUsed, targetDataLoader); + initialIntents, resolutionList, filterLastUsed); } return resolverMultiProfilePagerAdapter; } @@ -552,15 +479,10 @@ public class ResolverActivity extends FragmentActivity implements ResolverActivity.METRICS_CATEGORY_RESOLVER); return new NoCrossProfileEmptyStateProvider( - getAnnotatedUserHandles().personalProfileUserHandle, + mProfiles, noWorkToPersonalEmptyState, noPersonalToWorkEmptyState, - createCrossProfileIntentsChecker(), - getAnnotatedUserHandles().tabOwnerUserHandleForLaunch); - } - - protected int appliedThemeResId() { - return R.style.Theme_DeviceDefault_Resolver; + createCrossProfileIntentsChecker()); } /** @@ -572,9 +494,7 @@ public class ResolverActivity extends FragmentActivity implements if (useLayoutWithDefault()) return true; View buttonBar = findViewById(com.android.internal.R.id.button_bar); - if (buttonBar == null || buttonBar.getVisibility() == View.GONE) return true; - - return false; + return buttonBar == null || buttonBar.getVisibility() == View.GONE; } protected void applyFooterView(int height) { @@ -582,12 +502,12 @@ public class ResolverActivity extends FragmentActivity implements mFooterSpacer = new Space(getApplicationContext()); } else { ((ResolverMultiProfilePagerAdapter) mMultiProfilePagerAdapter) - .getActiveAdapterView().removeFooterView(mFooterSpacer); + .getActiveAdapterView().removeFooterView(mFooterSpacer); } mFooterSpacer.setLayoutParams(new AbsListView.LayoutParams(LayoutParams.MATCH_PARENT, - mSystemWindowInsets.bottom)); + mSystemWindowInsets.bottom)); ((ResolverMultiProfilePagerAdapter) mMultiProfilePagerAdapter) - .getActiveAdapterView().addFooterView(mFooterSpacer); + .getActiveAdapterView().addFooterView(mFooterSpacer); } protected WindowInsets onApplyWindowInsets(View v, WindowInsets insets) { @@ -613,10 +533,10 @@ public class ResolverActivity extends FragmentActivity implements } @Override - public void onConfigurationChanged(@NonNull Configuration newConfig) { + public void onConfigurationChanged(Configuration newConfig) { super.onConfigurationChanged(newConfig); mMultiProfilePagerAdapter.getActiveListAdapter().handlePackagesChanged(); - if (mIsIntentPicker && shouldShowTabs() && !useLayoutWithDefault() + if (mProfiles.getWorkProfilePresent() && !useLayoutWithDefault() && !shouldUseMiniResolver()) { updateIntentPickerPaddings(); } @@ -631,52 +551,7 @@ public class ResolverActivity extends FragmentActivity implements return R.layout.resolver_list; } - @Override - protected void onStop() { - super.onStop(); - - final Window window = this.getWindow(); - final WindowManager.LayoutParams attrs = window.getAttributes(); - attrs.privateFlags &= ~SYSTEM_FLAG_HIDE_NON_SYSTEM_OVERLAY_WINDOWS; - window.setAttributes(attrs); - - if (mRegistered) { - mPersonalPackageMonitor.unregister(); - if (mWorkPackageMonitor != null) { - mWorkPackageMonitor.unregister(); - } - mRegistered = false; - } - final Intent intent = getIntent(); - if ((intent.getFlags() & FLAG_ACTIVITY_NEW_TASK) != 0 && !isVoiceInteraction() - && !mResolvingHome && !mRetainInOnStop) { - // This resolver is in the unusual situation where it has been - // launched at the top of a new task. We don't let it be added - // to the recent tasks shown to the user, and we need to make sure - // that each time we are launched we get the correct launching - // uid (not re-using the same resolver from an old launching uid), - // so we will now finish ourself since being no longer visible, - // the user probably can't get back to us. - if (!isChangingConfigurations()) { - finish(); - } - } - // TODO: should we clean up the work-profile manager before we potentially finish() above? - mWorkProfileAvailability.unregisterWorkProfileStateReceiver(this); - } - - @Override - protected void onDestroy() { - super.onDestroy(); - if (!isChangingConfigurations() && mPickOptionRequest != null) { - mPickOptionRequest.cancel(); - } - if (mMultiProfilePagerAdapter != null - && mMultiProfilePagerAdapter.getActiveListAdapter() != null) { - mMultiProfilePagerAdapter.getActiveListAdapter().onDestroy(); - } - } - + // referenced by layout XML: android:onClick="onButtonClick" public void onButtonClick(View v) { final int id = v.getId(); ListView listView = (ListView) mMultiProfilePagerAdapter.getActiveAdapterView(); @@ -695,9 +570,9 @@ public class ResolverActivity extends FragmentActivity implements ResolveInfo ri = mMultiProfilePagerAdapter.getActiveListAdapter() .resolveInfoForPosition(which, hasIndexBeenFiltered); if (mResolvingHome && hasManagedProfile() && !supportsManagedProfiles(ri)) { + String launcherName = ri.activityInfo.loadLabel(mPackageManager).toString(); Toast.makeText(this, - getWorkProfileNotSupportedMsg( - ri.activityInfo.loadLabel(getPackageManager()).toString()), + mDevicePolicyResources.getWorkProfileNotSupportedMessage(launcherName), Toast.LENGTH_LONG).show(); return; } @@ -708,15 +583,12 @@ public class ResolverActivity extends FragmentActivity implements return; } if (onTargetSelected(target, always)) { - if (always && mSupportsAlwaysUseOption) { + if (always) { MetricsLogger.action( this, MetricsProto.MetricsEvent.ACTION_APP_DISAMBIG_ALWAYS); - } else if (mSupportsAlwaysUseOption) { - MetricsLogger.action( - this, MetricsProto.MetricsEvent.ACTION_APP_DISAMBIG_JUST_ONCE); } else { MetricsLogger.action( - this, MetricsProto.MetricsEvent.ACTION_APP_DISAMBIG_TAP); + this, MetricsProto.MetricsEvent.ACTION_APP_DISAMBIG_JUST_ONCE); } MetricsLogger.action(this, mMultiProfilePagerAdapter.getActiveListAdapter().hasFilteredItem() @@ -726,9 +598,6 @@ public class ResolverActivity extends FragmentActivity implements } } - /** - * Replace me in subclasses! - */ @Override // ResolverListCommunicator public Intent getReplacementIntent(ActivityInfo aInfo, Intent defIntent) { return defIntent; @@ -737,7 +606,7 @@ public class ResolverActivity extends FragmentActivity implements protected void onListRebuilt(ResolverListAdapter listAdapter, boolean rebuildCompleted) { final ItemClickListener listener = new ItemClickListener(); setupAdapterListView((ListView) mMultiProfilePagerAdapter.getActiveAdapterView(), listener); - if (shouldShowTabs() && mIsIntentPicker) { + if (mProfiles.getWorkProfilePresent()) { final ResolverDrawerLayout rdl = findViewById(com.android.internal.R.id.contentPanel); if (rdl != null) { rdl.setMaxCollapsedHeight(getResources() @@ -752,9 +621,9 @@ public class ResolverActivity extends FragmentActivity implements final ResolveInfo ri = target.getResolveInfo(); final Intent intent = target != null ? target.getResolvedIntent() : null; - if (intent != null && (mSupportsAlwaysUseOption - || mMultiProfilePagerAdapter.getActiveListAdapter().hasFilteredItem()) - && mMultiProfilePagerAdapter.getActiveListAdapter().getUnfilteredResolveList() != null) { + if (intent != null /*&& mMultiProfilePagerAdapter.getActiveListAdapter().hasFilteredItem()*/ + && mMultiProfilePagerAdapter.getActiveListAdapter().getUnfilteredResolveList() + != null) { // Build a reasonable intent filter, based on what matched. IntentFilter filter = new IntentFilter(); Intent filterIntent; @@ -796,7 +665,7 @@ public class ResolverActivity extends FragmentActivity implements // or "content:" schemes (see IntentFilter for the reason). if (cat != IntentFilter.MATCH_CATEGORY_TYPE || (!"file".equals(data.getScheme()) - && !"content".equals(data.getScheme()))) { + && !"content".equals(data.getScheme()))) { filter.addDataScheme(data.getScheme()); // Look through the resolved filter to determine which part @@ -854,7 +723,7 @@ public class ResolverActivity extends FragmentActivity implements } int bestMatch = 0; - for (int i=0; iSubclasses must call postRebuildListInternal at the end of postRebuildList. - * @param rebuildCompleted + * * @return true if the activity is finishing and creation should halt. */ protected boolean postRebuildList(boolean rebuildCompleted) { return postRebuildListInternal(rebuildCompleted); } - void onHorizontalSwipeStateChanged(int state) {} - /** * Callback called when user changes the profile tab. - *

This method is intended to be overridden by subclasses. */ - protected void onProfileTabSelected() { } + /* TODO: consider merging with the customized considerations of our implemented + * {@link MultiProfilePagerAdapter.OnProfileSelectedListener}. The only apparent distinctions + * between the respective listener callbacks would occur in the triggering patterns during init + * (when the `OnProfileSelectedListener` is registered after a possible tab-change), or possibly + * if there's some way to trigger an update in one model but not the other. If there's an + * initialization dependency, we can probably reason about it with confidence. If there's a + * discrepancy between the `TabHost` and pager-adapter data models, that inconsistency is + * likely to be a bug that would benefit from consolidation. + */ + protected void onProfileTabSelected(int currentPage) { + setupViewVisibilities(); + maybeLogProfileChange(); + if (mProfiles.getWorkProfilePresent()) { + // The device policy logger is only concerned with sessions that include a work profile. + DevicePolicyEventLogger + .createEvent(DevicePolicyEnums.RESOLVER_SWITCH_TABS) + .setInt(currentPage) + .setStrings(getMetricsCategory()) + .write(); + } + } /** * Add a label to signify that the user can pick a different app. + * * @param adapter The adapter used to provide data to item views. */ public void addUseDifferentAppLabelIfNecessary(ResolverListAdapter adapter) { @@ -982,7 +849,7 @@ public class ResolverActivity extends FragmentActivity implements stub.setVisibility(View.VISIBLE); TextView textView = (TextView) LayoutInflater.from(this).inflate( R.layout.resolver_different_item_header, null, false); - if (shouldShowTabs()) { + if (mProfiles.getWorkProfilePresent()) { textView.setGravity(Gravity.CENTER); } stub.addView(textView); @@ -990,9 +857,6 @@ public class ResolverActivity extends FragmentActivity implements } protected void resetButtonBar() { - if (!mSupportsAlwaysUseOption) { - return; - } final ViewGroup buttonLayout = findViewById(com.android.internal.R.id.button_bar); if (buttonLayout == null) { Log.e(TAG, "Layout unexpectedly does not have a button bar"); @@ -1034,55 +898,24 @@ public class ResolverActivity extends FragmentActivity implements } @Override // ResolverListCommunicator - public void onHandlePackagesChanged(ResolverListAdapter listAdapter) { - if (listAdapter == mMultiProfilePagerAdapter.getActiveListAdapter()) { - if (listAdapter.getUserHandle().equals(getAnnotatedUserHandles().workProfileUserHandle) - && mWorkProfileAvailability.isWaitingToEnableWorkProfile()) { - // We have just turned on the work profile and entered the pass code to start it, - // now we are waiting to receive the ACTION_USER_UNLOCKED broadcast. There is no - // point in reloading the list now, since the work profile user is still - // turning on. - return; - } - boolean listRebuilt = mMultiProfilePagerAdapter.rebuildActiveTab(true); - if (listRebuilt) { - ResolverListAdapter activeListAdapter = - mMultiProfilePagerAdapter.getActiveListAdapter(); - activeListAdapter.notifyDataSetChanged(); - if (activeListAdapter.getCount() == 0 && !inactiveListAdapterHasItems()) { - // We no longer have any items... just finish the activity. - finish(); - } - } - } else { - mMultiProfilePagerAdapter.clearInactiveProfileCache(); + public final void onHandlePackagesChanged(ResolverListAdapter listAdapter) { + if (!mMultiProfilePagerAdapter.onHandlePackagesChanged( + listAdapter, + mProfileAvailability.getWaitingToEnableProfile())) { + // We no longer have any items... just finish the activity. + finish(); } } protected void maybeLogProfileChange() {} - // @NonFinalForTesting - @VisibleForTesting - protected MyUserIdProvider createMyUserIdProvider() { - return new MyUserIdProvider(); - } - - // @NonFinalForTesting @VisibleForTesting protected CrossProfileIntentsChecker createCrossProfileIntentsChecker() { return new CrossProfileIntentsChecker(getContentResolver()); } - protected WorkProfileAvailabilityManager createWorkProfileAvailabilityManager() { - return new WorkProfileAvailabilityManager( - getSystemService(UserManager.class), - getAnnotatedUserHandles().workProfileUserHandle, - this::onWorkProfileStatusUpdated); - } - - protected void onWorkProfileStatusUpdated() { - if (mMultiProfilePagerAdapter.getCurrentUserHandle().equals( - getAnnotatedUserHandles().workProfileUserHandle)) { + private void onWorkProfileStatusUpdated() { + if (mMultiProfilePagerAdapter.getActiveProfile() == PROFILE_WORK) { mMultiProfilePagerAdapter.rebuildActiveTab(true); } else { mMultiProfilePagerAdapter.clearInactiveProfileCache(); @@ -1097,11 +930,8 @@ public class ResolverActivity extends FragmentActivity implements Intent[] initialIntents, List resolutionList, boolean filterLastUsed, - UserHandle userHandle, - TargetDataLoader targetDataLoader) { - UserHandle initialIntentsUserSpace = isLaunchedAsCloneProfile() - && userHandle.equals(getAnnotatedUserHandles().personalProfileUserHandle) - ? getAnnotatedUserHandles().cloneProfileUserHandle : userHandle; + UserHandle userHandle) { + UserHandle initialIntentsUserSpace = mProfiles.getQueryIntentsHandle(userHandle); return new ResolverListAdapter( context, payloadIntents, @@ -1110,33 +940,10 @@ public class ResolverActivity extends FragmentActivity implements filterLastUsed, createListController(userHandle), userHandle, - getTargetIntent(), + mRequest.getIntent(), this, initialIntentsUserSpace, - targetDataLoader); - } - - private TargetDataLoader createIconLoader() { - Intent startIntent = getIntent(); - boolean isAudioCaptureDevice = - startIntent.getBooleanExtra(EXTRA_IS_AUDIO_CAPTURE_DEVICE, false); - return new DefaultTargetDataLoader(this, getLifecycle(), isAudioCaptureDevice); - } - - private LatencyTracker getLatencyTracker() { - return LatencyTracker.getInstance(this); - } - - /** - * Get the string resource to be used as a label for the link to the resolver activity for an - * action. - * - * @param action The action to resolve - * - * @return The string resource to be used as a label - */ - public static @StringRes int getLabelRes(String action) { - return ActionTitle.forAction(action).labelRes; + mTargetDataLoader); } protected final EmptyStateProvider createEmptyStateProvider( @@ -1144,8 +951,10 @@ public class ResolverActivity extends FragmentActivity implements final EmptyStateProvider blockerEmptyStateProvider = createBlockerEmptyStateProvider(); final EmptyStateProvider workProfileOffEmptyStateProvider = - new WorkProfilePausedEmptyStateProvider(this, workProfileUserHandle, - mWorkProfileAvailability, + new WorkProfilePausedEmptyStateProvider( + this, + mProfiles, + mProfileAvailability, /* onSwitchOnWorkSelectedListener= */ () -> { if (mOnSwitchOnWorkSelectedListener != null) { @@ -1157,9 +966,9 @@ public class ResolverActivity extends FragmentActivity implements final EmptyStateProvider noAppsEmptyStateProvider = new NoAppsAvailableEmptyStateProvider( this, workProfileUserHandle, - getAnnotatedUserHandles().personalProfileUserHandle, + mProfiles.getPersonalHandle(), getMetricsCategory(), - getAnnotatedUserHandles().tabOwnerUserHandleForLaunch + mProfiles.getTabOwnerUserHandleForLaunch() ); // Return composite provider, the order matters (the higher, the more priority) @@ -1170,76 +979,52 @@ public class ResolverActivity extends FragmentActivity implements ); } - private Intent makeMyIntent() { - Intent intent = new Intent(getIntent()); - intent.setComponent(null); - // The resolver activity is set to be hidden from recent tasks. - // we don't want this attribute to be propagated to the next activity - // being launched. Note that if the original Intent also had this - // flag set, we are now losing it. That should be a very rare case - // and we can live with this. - intent.setFlags(intent.getFlags() & ~Intent.FLAG_ACTIVITY_EXCLUDE_FROM_RECENTS); - - // If FLAG_ACTIVITY_LAUNCH_ADJACENT was set, ResolverActivity was opened in the alternate - // side, which means we want to open the target app on the same side as ResolverActivity. - if ((intent.getFlags() & FLAG_ACTIVITY_LAUNCH_ADJACENT) != 0) { - intent.setFlags(intent.getFlags() & ~FLAG_ACTIVITY_LAUNCH_ADJACENT); - } - return intent; - } - - /** - * Call {@link Activity#onCreate} without initializing anything further. This should - * only be used when the activity is about to be immediately finished to avoid wasting - * initializing steps and leaking resources. - */ - protected final void super_onCreate(Bundle savedInstanceState) { - super.onCreate(savedInstanceState); - } - - private ResolverMultiProfilePagerAdapter - createResolverMultiProfilePagerAdapterForOneProfile( - Intent[] initialIntents, - List resolutionList, - boolean filterLastUsed, - TargetDataLoader targetDataLoader) { - ResolverListAdapter adapter = createResolverListAdapter( + private ResolverMultiProfilePagerAdapter createResolverMultiProfilePagerAdapterForOneProfile( + Intent[] initialIntents, + List resolutionList, + boolean filterLastUsed) { + ResolverListAdapter personalAdapter = createResolverListAdapter( /* context */ this, - /* payloadIntents */ mIntents, + mRequest.getPayloadIntents(), initialIntents, resolutionList, filterLastUsed, - /* userHandle */ getAnnotatedUserHandles().personalProfileUserHandle, - targetDataLoader); + /* userHandle */ mProfiles.getPersonalHandle() + ); return new ResolverMultiProfilePagerAdapter( /* context */ this, - adapter, + ImmutableList.of( + new TabConfig<>( + PROFILE_PERSONAL, + mDevicePolicyResources.getPersonalTabLabel(), + mDevicePolicyResources.getPersonalTabAccessibilityLabel(), + TAB_TAG_PERSONAL, + personalAdapter)), createEmptyStateProvider(/* workProfileUserHandle= */ null), /* workProfileQuietModeChecker= */ () -> false, + /* defaultProfile= */ PROFILE_PERSONAL, /* workProfileUserHandle= */ null, - getAnnotatedUserHandles().cloneProfileUserHandle); + mProfiles.getCloneHandle()); } private UserHandle getIntentUser() { - return getIntent().hasExtra(EXTRA_CALLING_USER) - ? getIntent().getParcelableExtra(EXTRA_CALLING_USER) - : getAnnotatedUserHandles().tabOwnerUserHandleForLaunch; + return Objects.requireNonNullElse(mRequest.getCallingUser(), + mProfiles.getTabOwnerUserHandleForLaunch()); } private ResolverMultiProfilePagerAdapter createResolverMultiProfilePagerAdapterForTwoProfiles( Intent[] initialIntents, List resolutionList, - boolean filterLastUsed, - TargetDataLoader targetDataLoader) { + boolean filterLastUsed) { // In the edge case when we have 0 apps in the current profile and >1 apps in the other, // the intent resolver is started in the other profile. Since this is the only case when // this happens, we check for it here and set the current profile's tab. int selectedProfile = getCurrentProfile(); UserHandle intentUser = getIntentUser(); - if (!getAnnotatedUserHandles().tabOwnerUserHandleForLaunch.equals(intentUser)) { - if (getAnnotatedUserHandles().personalProfileUserHandle.equals(intentUser)) { + if (!mProfiles.getTabOwnerUserHandleForLaunch().equals(intentUser)) { + if (mProfiles.getPersonalHandle().equals(intentUser)) { selectedProfile = PROFILE_PERSONAL; - } else if (getAnnotatedUserHandles().workProfileUserHandle.equals(intentUser)) { + } else if (mProfiles.getWorkHandle().equals(intentUser)) { selectedProfile = PROFILE_WORK; } } else { @@ -1253,95 +1038,70 @@ public class ResolverActivity extends FragmentActivity implements // resolver list. So filterLastUsed should be false for the other profile. ResolverListAdapter personalAdapter = createResolverListAdapter( /* context */ this, - /* payloadIntents */ mIntents, + mRequest.getPayloadIntents(), selectedProfile == PROFILE_PERSONAL ? initialIntents : null, resolutionList, (filterLastUsed && UserHandle.myUserId() - == getAnnotatedUserHandles().personalProfileUserHandle.getIdentifier()), - /* userHandle */ getAnnotatedUserHandles().personalProfileUserHandle, - targetDataLoader); - UserHandle workProfileUserHandle = getAnnotatedUserHandles().workProfileUserHandle; + == mProfiles.getPersonalHandle().getIdentifier()), + /* userHandle */ mProfiles.getPersonalHandle() + ); + UserHandle workProfileUserHandle = mProfiles.getWorkHandle(); ResolverListAdapter workAdapter = createResolverListAdapter( /* context */ this, - /* payloadIntents */ mIntents, + mRequest.getPayloadIntents(), selectedProfile == PROFILE_WORK ? initialIntents : null, resolutionList, (filterLastUsed && UserHandle.myUserId() == workProfileUserHandle.getIdentifier()), - /* userHandle */ workProfileUserHandle, - targetDataLoader); + /* userHandle */ workProfileUserHandle + ); return new ResolverMultiProfilePagerAdapter( /* context */ this, - personalAdapter, - workAdapter, + ImmutableList.of( + new TabConfig<>( + PROFILE_PERSONAL, + mDevicePolicyResources.getPersonalTabLabel(), + mDevicePolicyResources.getPersonalTabAccessibilityLabel(), + TAB_TAG_PERSONAL, + personalAdapter), + new TabConfig<>( + PROFILE_WORK, + mDevicePolicyResources.getWorkTabLabel(), + mDevicePolicyResources.getWorkTabAccessibilityLabel(), + TAB_TAG_WORK, + workAdapter)), createEmptyStateProvider(workProfileUserHandle), - () -> mWorkProfileAvailability.isQuietModeEnabled(), + /* Supplier (QuietMode enabled) == !(available) */ + () -> !(mProfiles.getWorkProfilePresent() + && mProfileAvailability.isAvailable( + requireNonNull(mProfiles.getWorkProfile()))), selectedProfile, workProfileUserHandle, - getAnnotatedUserHandles().cloneProfileUserHandle); + mProfiles.getCloneHandle()); } /** * Returns {@link #PROFILE_PERSONAL} or {@link #PROFILE_WORK} if the {@link * #EXTRA_SELECTED_PROFILE} extra was supplied, or {@code -1} if no extra was supplied. - * @throws IllegalArgumentException if the value passed to the {@link #EXTRA_SELECTED_PROFILE} - * extra is not {@link #PROFILE_PERSONAL} or {@link #PROFILE_WORK} */ final int getSelectedProfileExtra() { - int selectedProfile = -1; - if (getIntent().hasExtra(EXTRA_SELECTED_PROFILE)) { - selectedProfile = getIntent().getIntExtra(EXTRA_SELECTED_PROFILE, /* defValue = */ -1); - if (selectedProfile != PROFILE_PERSONAL && selectedProfile != PROFILE_WORK) { - throw new IllegalArgumentException(EXTRA_SELECTED_PROFILE + " has invalid value " - + selectedProfile + ". Must be either ResolverActivity.PROFILE_PERSONAL or " - + "ResolverActivity.PROFILE_WORK."); - } + Profile.Type selected = mRequest.getSelectedProfile(); + if (selected == null) { + return -1; + } + switch (selected) { + case PERSONAL: return PROFILE_PERSONAL; + case WORK: return PROFILE_WORK; + default: return -1; } - return selectedProfile; } - protected final @Profile int getCurrentProfile() { - UserHandle launchUser = getAnnotatedUserHandles().tabOwnerUserHandleForLaunch; - UserHandle personalUser = getAnnotatedUserHandles().personalProfileUserHandle; + protected final @ProfileType int getCurrentProfile() { + UserHandle launchUser = mProfiles.getTabOwnerUserHandleForLaunch(); + UserHandle personalUser = mProfiles.getPersonalHandle(); return launchUser.equals(personalUser) ? PROFILE_PERSONAL : PROFILE_WORK; } - protected final AnnotatedUserHandles getAnnotatedUserHandles() { - return mLazyAnnotatedUserHandles.get(); - } - - private boolean hasWorkProfile() { - return getAnnotatedUserHandles().workProfileUserHandle != null; - } - - private boolean hasCloneProfile() { - return getAnnotatedUserHandles().cloneProfileUserHandle != null; - } - - protected final boolean isLaunchedAsCloneProfile() { - UserHandle launchUser = getAnnotatedUserHandles().userHandleSharesheetLaunchedAs; - UserHandle cloneUser = getAnnotatedUserHandles().cloneProfileUserHandle; - return hasCloneProfile() && launchUser.equals(cloneUser); - } - - protected final boolean shouldShowTabs() { - return hasWorkProfile(); - } - - protected final void onProfileClick(View v) { - final DisplayResolveInfo dri = - mMultiProfilePagerAdapter.getActiveListAdapter().getOtherProfile(); - if (dri == null) { - return; - } - - // Do not show the profile switch message anymore. - mProfileSwitchMessage = null; - - onTargetSelected(dri, false); - finish(); - } - private void updateIntentPickerPaddings() { View titleCont = findViewById(com.android.internal.R.id.title_container); titleCont.setPadding( @@ -1358,14 +1118,15 @@ public class ResolverActivity extends FragmentActivity implements } private void maybeLogCrossProfileTargetLaunch(TargetInfo cti, UserHandle currentUserHandle) { - if (!hasWorkProfile() || currentUserHandle.equals(getUser())) { + // TODO: Test isolation bug, referencing getUser() will break tests with faked profiles + if (!mProfiles.getWorkProfilePresent() || currentUserHandle.equals(getUser())) { return; } DevicePolicyEventLogger .createEvent(DevicePolicyEnums.RESOLVER_CROSS_PROFILE_TARGET_OPENED) .setBoolean( currentUserHandle.equals( - getAnnotatedUserHandles().personalProfileUserHandle)) + mProfiles.getPersonalHandle())) .setStrings(getMetricsCategory(), cti.isInDirectShareMetricsCategory() ? "direct_share" : "other_target") .write(); @@ -1399,66 +1160,6 @@ public class ResolverActivity extends FragmentActivity implements return new Option(getOrLoadDisplayLabel(target), index); } - public final Intent getTargetIntent() { - return mIntents.isEmpty() ? null : mIntents.get(0); - } - - protected final String getReferrerPackageName() { - final Uri referrer = getReferrer(); - if (referrer != null && "android-app".equals(referrer.getScheme())) { - return referrer.getHost(); - } - return null; - } - - @Override // ResolverListCommunicator - public final void updateProfileViewButton() { - if (mProfileView == null) { - return; - } - - final DisplayResolveInfo dri = - mMultiProfilePagerAdapter.getActiveListAdapter().getOtherProfile(); - if (dri != null && !shouldShowTabs()) { - mProfileView.setVisibility(View.VISIBLE); - View text = mProfileView.findViewById(com.android.internal.R.id.profile_button); - if (!(text instanceof TextView)) { - text = mProfileView.findViewById(com.android.internal.R.id.text1); - } - ((TextView) text).setText(dri.getDisplayLabel()); - } else { - mProfileView.setVisibility(View.GONE); - } - } - - private void setProfileSwitchMessage(int contentUserHint) { - if ((contentUserHint != UserHandle.USER_CURRENT) - && (contentUserHint != UserHandle.myUserId())) { - UserManager userManager = (UserManager) getSystemService(Context.USER_SERVICE); - UserInfo originUserInfo = userManager.getUserInfo(contentUserHint); - boolean originIsManaged = originUserInfo != null ? originUserInfo.isManagedProfile() - : false; - boolean targetIsManaged = userManager.isManagedProfile(); - if (originIsManaged && !targetIsManaged) { - mProfileSwitchMessage = getForwardToPersonalMsg(); - } else if (!originIsManaged && targetIsManaged) { - mProfileSwitchMessage = getForwardToWorkMsg(); - } - } - } - - private String getForwardToPersonalMsg() { - return getSystemService(DevicePolicyManager.class).getResources().getString( - FORWARD_INTENT_TO_PERSONAL, - () -> getString(R.string.forward_intent_to_owner)); - } - - private String getForwardToWorkMsg() { - return getSystemService(DevicePolicyManager.class).getResources().getString( - FORWARD_INTENT_TO_WORK, - () -> getString(R.string.forward_intent_to_work)); - } - protected final CharSequence getTitleForAction(Intent intent, int defaultTitleRes) { final ActionTitle title = mResolvingHome ? ActionTitle.HOME @@ -1481,73 +1182,6 @@ public class ResolverActivity extends FragmentActivity implements } } - final void dismiss() { - if (!isFinishing()) { - finish(); - } - } - - @Override - protected final void onRestart() { - super.onRestart(); - if (!mRegistered) { - mPersonalPackageMonitor.register( - this, - getMainLooper(), - getAnnotatedUserHandles().personalProfileUserHandle, - false); - if (shouldShowTabs()) { - if (mWorkPackageMonitor == null) { - mWorkPackageMonitor = createPackageMonitor( - mMultiProfilePagerAdapter.getWorkListAdapter()); - } - mWorkPackageMonitor.register( - this, - getMainLooper(), - getAnnotatedUserHandles().workProfileUserHandle, - false); - } - mRegistered = true; - } - if (shouldShowTabs() && mWorkProfileAvailability.isWaitingToEnableWorkProfile()) { - if (mWorkProfileAvailability.isQuietModeEnabled()) { - mWorkProfileAvailability.markWorkProfileEnabledBroadcastReceived(); - } - } - mMultiProfilePagerAdapter.getActiveListAdapter().handlePackagesChanged(); - updateProfileViewButton(); - } - - @Override - protected final void onStart() { - super.onStart(); - - this.getWindow().addSystemFlags(SYSTEM_FLAG_HIDE_NON_SYSTEM_OVERLAY_WINDOWS); - if (shouldShowTabs()) { - mWorkProfileAvailability.registerWorkProfileStateReceiver(this); - } - } - - @Override - protected final void onSaveInstanceState(@NonNull Bundle outState) { - super.onSaveInstanceState(outState); - ViewPager viewPager = findViewById(com.android.internal.R.id.profile_pager); - if (viewPager != null) { - outState.putInt(LAST_SHOWN_TAB_KEY, viewPager.getCurrentItem()); - } - } - - @Override - protected final void onRestoreInstanceState(@NonNull Bundle savedInstanceState) { - super.onRestoreInstanceState(savedInstanceState); - resetButtonBar(); - ViewPager viewPager = findViewById(com.android.internal.R.id.profile_pager); - if (viewPager != null) { - viewPager.setCurrentItem(savedInstanceState.getInt(LAST_SHOWN_TAB_KEY)); - } - mMultiProfilePagerAdapter.clearInactiveProfileCache(); - } - private boolean hasManagedProfile() { UserManager userManager = (UserManager) getSystemService(Context.USER_SERVICE); if (userManager == null) { @@ -1569,7 +1203,7 @@ public class ResolverActivity extends FragmentActivity implements private boolean supportsManagedProfiles(ResolveInfo resolveInfo) { try { - ApplicationInfo appInfo = getPackageManager().getApplicationInfo( + ApplicationInfo appInfo = mPackageManager.getApplicationInfo( resolveInfo.activityInfo.packageName, 0 /* default flags */); return appInfo.targetSdkVersion >= Build.VERSION_CODES.LOLLIPOP; } catch (NameNotFoundException e) { @@ -1587,7 +1221,8 @@ public class ResolverActivity extends FragmentActivity implements // In case of clonedProfile being active, we do not allow the 'Always' option in the // disambiguation dialog of Personal Profile as the package manager cannot distinguish // between cross-profile preferred activities. - if (hasCloneProfile() && (mMultiProfilePagerAdapter.getCurrentPage() == PROFILE_PERSONAL)) { + if (mProfiles.getCloneUserPresent() + && (mMultiProfilePagerAdapter.getActiveProfile() == PROFILE_PERSONAL)) { mAlwaysButton.setEnabled(false); return; } @@ -1613,41 +1248,28 @@ public class ResolverActivity extends FragmentActivity implements if (ri != null) { ActivityInfo activityInfo = ri.activityInfo; - boolean hasRecordPermission = - mPm.checkPermission(android.Manifest.permission.RECORD_AUDIO, + boolean hasRecordPermission = mPackageManager + .checkPermission(android.Manifest.permission.RECORD_AUDIO, activityInfo.packageName) - == android.content.pm.PackageManager.PERMISSION_GRANTED; + == PackageManager.PERMISSION_GRANTED; if (!hasRecordPermission) { // OK, we know the record permission, is this a capture device - boolean hasAudioCapture = - getIntent().getBooleanExtra( - ResolverActivity.EXTRA_IS_AUDIO_CAPTURE_DEVICE, false); + boolean hasAudioCapture = mViewModel.getRequest().getValue().isAudioCaptureDevice(); enabled = !hasAudioCapture; } } mAlwaysButton.setEnabled(enabled); } - private String getWorkProfileNotSupportedMsg(String launcherName) { - return getSystemService(DevicePolicyManager.class).getResources().getString( - RESOLVER_WORK_PROFILE_NOT_SUPPORTED, - () -> getString( - R.string.activity_resolver_work_profiles_support, - launcherName), - launcherName); - } - @Override // ResolverListCommunicator public final void onPostListReady(ResolverListAdapter listAdapter, boolean doPostProcessing, boolean rebuildCompleted) { if (isAutolaunching()) { return; } - if (mIsIntentPicker) { - ((ResolverMultiProfilePagerAdapter) mMultiProfilePagerAdapter) - .setUseLayoutWithDefault(useLayoutWithDefault()); - } + mMultiProfilePagerAdapter.setUseLayoutWithDefault(useLayoutWithDefault()); + if (mMultiProfilePagerAdapter.shouldShowEmptyStateScreen(listAdapter)) { mMultiProfilePagerAdapter.showEmptyResolverListEmptyState(listAdapter); } else { @@ -1696,45 +1318,6 @@ public class ResolverActivity extends FragmentActivity implements } } - @VisibleForTesting - protected void safelyStartActivityInternal( - TargetInfo cti, UserHandle user, @Nullable Bundle options) { - // If the target is suspended, the activity will not be successfully launched. - // Do not unregister from package manager updates in this case - if (!cti.isSuspended() && mRegistered) { - if (mPersonalPackageMonitor != null) { - mPersonalPackageMonitor.unregister(); - } - if (mWorkPackageMonitor != null) { - mWorkPackageMonitor.unregister(); - } - mRegistered = false; - } - // If needed, show that intent is forwarded - // from managed profile to owner or other way around. - if (mProfileSwitchMessage != null) { - Toast.makeText(this, mProfileSwitchMessage, Toast.LENGTH_LONG).show(); - } - if (!mSafeForwardingMode) { - if (cti.startAsUser(this, options, user)) { - onActivityStarted(cti); - maybeLogCrossProfileTargetLaunch(cti, user); - } - return; - } - try { - if (cti.startAsCaller(this, options, user.getIdentifier())) { - onActivityStarted(cti); - maybeLogCrossProfileTargetLaunch(cti, user); - } - } catch (RuntimeException e) { - Slog.wtf(TAG, - "Unable to launch as uid " + getAnnotatedUserHandles().userIdOfCallingApp - + " package " + getLaunchedFromPackage() + ", while running in " - + ActivityThread.currentProcessName(), e); - } - } - final void showTargetDetails(ResolveInfo ri) { Intent in = new Intent().setAction(Settings.ACTION_APPLICATION_DETAILS_SETTINGS) .setData(Uri.fromParts("package", ri.activityInfo.packageName, null)) @@ -1754,13 +1337,9 @@ public class ResolverActivity extends FragmentActivity implements Trace.beginSection("configureContentView"); // We partially rebuild the inactive adapter to determine if we should auto launch // isTabLoaded will be true here if the empty state screen is shown instead of the list. - boolean rebuildCompleted = mMultiProfilePagerAdapter.rebuildActiveTab(true) - || mMultiProfilePagerAdapter.getActiveListAdapter().isTabLoaded(); - if (shouldShowTabs()) { - boolean rebuildInactiveCompleted = mMultiProfilePagerAdapter.rebuildInactiveTab(false) - || mMultiProfilePagerAdapter.getInactiveListAdapter().isTabLoaded(); - rebuildCompleted = rebuildCompleted && rebuildInactiveCompleted; - } + // To date, we really only care about "partially rebuilding" tabs for work and/or personal. + boolean rebuildCompleted = + mMultiProfilePagerAdapter.rebuildTabs(mProfiles.getWorkProfilePresent()); if (shouldUseMiniResolver()) { configureMiniResolverContent(targetDataLoader); @@ -1774,7 +1353,8 @@ public class ResolverActivity extends FragmentActivity implements mLayoutId = getLayoutResource(); } setContentView(mLayoutId); - mMultiProfilePagerAdapter.setupViewPager(findViewById(com.android.internal.R.id.profile_pager)); + mMultiProfilePagerAdapter.setupViewPager( + findViewById(com.android.internal.R.id.profile_pager)); boolean result = postRebuildList(rebuildCompleted); Trace.endSection(); return result; @@ -1790,12 +1370,20 @@ public class ResolverActivity extends FragmentActivity implements mLayoutId = R.layout.miniresolver; setContentView(mLayoutId); - DisplayResolveInfo sameProfileResolveInfo = - mMultiProfilePagerAdapter.getActiveListAdapter().getFirstDisplayResolveInfo(); boolean inWorkProfile = getCurrentProfile() == PROFILE_WORK; - final ResolverListAdapter inactiveAdapter = - mMultiProfilePagerAdapter.getInactiveListAdapter(); + ResolverListAdapter sameProfileAdapter = + (mMultiProfilePagerAdapter.getActiveProfile() == PROFILE_PERSONAL) + ? mMultiProfilePagerAdapter.getPersonalListAdapter() + : mMultiProfilePagerAdapter.getWorkListAdapter(); + + ResolverListAdapter inactiveAdapter = + (mMultiProfilePagerAdapter.getActiveProfile() == PROFILE_PERSONAL) + ? mMultiProfilePagerAdapter.getWorkListAdapter() + : mMultiProfilePagerAdapter.getPersonalListAdapter(); + + DisplayResolveInfo sameProfileResolveInfo = sameProfileAdapter.getFirstDisplayResolveInfo(); + final DisplayResolveInfo otherProfileResolveInfo = inactiveAdapter.getFirstDisplayResolveInfo(); @@ -1834,6 +1422,69 @@ public class ResolverActivity extends FragmentActivity implements }); } + private boolean isTwoPagePersonalAndWorkConfiguration() { + return (mMultiProfilePagerAdapter.getCount() == 2) + && mMultiProfilePagerAdapter.hasPageForProfile(PROFILE_PERSONAL) + && mMultiProfilePagerAdapter.hasPageForProfile(PROFILE_WORK); + } + + @VisibleForTesting + protected void safelyStartActivityInternal( + TargetInfo cti, UserHandle user, @Nullable Bundle options) { + // If the target is suspended, the activity will not be successfully launched. + // Do not unregister from package manager updates in this case + if (!cti.isSuspended() && mRegistered) { + if (mPersonalPackageMonitor != null) { + mPersonalPackageMonitor.unregister(); + } + if (mWorkPackageMonitor != null) { + mWorkPackageMonitor.unregister(); + } + mRegistered = false; + } + // If needed, show that intent is forwarded + // from managed profile to owner or other way around. + String profileSwitchMessage = + mIntentForwarding.forwardMessageFor(mRequest.getIntent()); + if (profileSwitchMessage != null) { + Toast.makeText(this, profileSwitchMessage, Toast.LENGTH_LONG).show(); + } + try { + if (cti.startAsCaller(this, options, user.getIdentifier())) { + maybeLogCrossProfileTargetLaunch(cti, user); + } + } catch (RuntimeException e) { + Slog.wtf(TAG, + "Unable to launch as uid " + + mViewModel.getActivityModel().getLaunchedFromUid() + + " package " + mViewModel.getActivityModel().getLaunchedFromPackage() + + ", while running in " + ActivityThread.currentProcessName(), e); + } + } + + /** + * Finishing procedures to be performed after the list has been rebuilt. + * @param rebuildCompleted + * @return true if the activity is finishing and creation should halt. + */ + final boolean postRebuildListInternal(boolean rebuildCompleted) { + int count = mMultiProfilePagerAdapter.getActiveListAdapter().getUnfilteredCount(); + + // We only rebuild asynchronously when we have multiple elements to sort. In the case where + // we're already done, we can check if we should auto-launch immediately. + if (rebuildCompleted && maybeAutolaunchActivity()) { + return true; + } + + setupViewVisibilities(); + + if (mProfiles.getWorkProfilePresent()) { + setupProfileTabs(); + } + + return false; + } + /** * Mini resolver should be used when all of the following are true: * 1. This is the intent picker (ResolverActivity). @@ -1841,17 +1492,19 @@ public class ResolverActivity extends FragmentActivity implements * 3. The other profile has a single non-browser match. */ private boolean shouldUseMiniResolver() { - if (!mIsIntentPicker) { - return false; - } - if (mMultiProfilePagerAdapter.getActiveListAdapter() == null - || mMultiProfilePagerAdapter.getInactiveListAdapter() == null) { + if (!isTwoPagePersonalAndWorkConfiguration()) { return false; } + ResolverListAdapter sameProfileAdapter = - mMultiProfilePagerAdapter.getActiveListAdapter(); + (mMultiProfilePagerAdapter.getActiveProfile() == PROFILE_PERSONAL) + ? mMultiProfilePagerAdapter.getPersonalListAdapter() + : mMultiProfilePagerAdapter.getWorkListAdapter(); + ResolverListAdapter otherProfileAdapter = - mMultiProfilePagerAdapter.getInactiveListAdapter(); + (mMultiProfilePagerAdapter.getActiveProfile() == PROFILE_PERSONAL) + ? mMultiProfilePagerAdapter.getWorkListAdapter() + : mMultiProfilePagerAdapter.getPersonalListAdapter(); if (sameProfileAdapter.getDisplayResolveInfoCount() == 0) { Log.d(TAG, "No targets in the current profile"); @@ -1876,53 +1529,6 @@ public class ResolverActivity extends FragmentActivity implements return true; } - /** - * Finishing procedures to be performed after the list has been rebuilt. - * @param rebuildCompleted - * @return true if the activity is finishing and creation should halt. - */ - final boolean postRebuildListInternal(boolean rebuildCompleted) { - int count = mMultiProfilePagerAdapter.getActiveListAdapter().getUnfilteredCount(); - - // We only rebuild asynchronously when we have multiple elements to sort. In the case where - // we're already done, we can check if we should auto-launch immediately. - if (rebuildCompleted && maybeAutolaunchActivity()) { - return true; - } - - setupViewVisibilities(); - - if (shouldShowTabs()) { - setupProfileTabs(); - } - - return false; - } - - private int isPermissionGranted(String permission, int uid) { - return ActivityManager.checkComponentPermission(permission, uid, - /* owningUid= */-1, /* exported= */ true); - } - - /** - * @return {@code true} if a resolved target is autolaunched, otherwise {@code false} - */ - private boolean maybeAutolaunchActivity() { - int numberOfProfiles = mMultiProfilePagerAdapter.getItemCount(); - if (numberOfProfiles == 1 && maybeAutolaunchIfSingleTarget()) { - return true; - } else if (numberOfProfiles == 2 - && mMultiProfilePagerAdapter.getActiveListAdapter().isTabLoaded() - && mMultiProfilePagerAdapter.getInactiveListAdapter().isTabLoaded() - && maybeAutolaunchIfCrossProfileSupported()) { - // TODO(b/280988288): If the ChooserActivity is shown we should consider showing the - // correct intent-picker UIs (e.g., mini-resolver) if it was launched without - // ACTION_SEND. - return true; - } - return false; - } - private boolean maybeAutolaunchIfSingleTarget() { int count = mMultiProfilePagerAdapter.getActiveListAdapter().getUnfilteredCount(); if (count != 1) { @@ -1945,42 +1551,57 @@ public class ResolverActivity extends FragmentActivity implements } /** - * When we have a personal and a work profile, we auto launch in the following scenario: + * When we have just a personal and a work profile, we auto launch in the following scenario: * - There is 1 resolved target on each profile * - That target is the same app on both profiles * - The target app has permission to communicate cross profiles * - The target app has declared it supports cross-profile communication via manifest metadata */ private boolean maybeAutolaunchIfCrossProfileSupported() { - ResolverListAdapter activeListAdapter = mMultiProfilePagerAdapter.getActiveListAdapter(); - int count = activeListAdapter.getUnfilteredCount(); - if (count != 1) { + if (!isTwoPagePersonalAndWorkConfiguration()) { return false; } + + ResolverListAdapter activeListAdapter = + (mMultiProfilePagerAdapter.getActiveProfile() == PROFILE_PERSONAL) + ? mMultiProfilePagerAdapter.getPersonalListAdapter() + : mMultiProfilePagerAdapter.getWorkListAdapter(); + ResolverListAdapter inactiveListAdapter = - mMultiProfilePagerAdapter.getInactiveListAdapter(); - if (inactiveListAdapter.getUnfilteredCount() != 1) { + (mMultiProfilePagerAdapter.getActiveProfile() == PROFILE_PERSONAL) + ? mMultiProfilePagerAdapter.getWorkListAdapter() + : mMultiProfilePagerAdapter.getPersonalListAdapter(); + + if (!activeListAdapter.isTabLoaded() || !inactiveListAdapter.isTabLoaded()) { return false; } - TargetInfo activeProfileTarget = activeListAdapter - .targetInfoForPosition(0, false); + + if ((activeListAdapter.getUnfilteredCount() != 1) + || (inactiveListAdapter.getUnfilteredCount() != 1)) { + return false; + } + + TargetInfo activeProfileTarget = activeListAdapter.targetInfoForPosition(0, false); TargetInfo inactiveProfileTarget = inactiveListAdapter.targetInfoForPosition(0, false); - if (!Objects.equals(activeProfileTarget.getResolvedComponentName(), + if (!Objects.equals( + activeProfileTarget.getResolvedComponentName(), inactiveProfileTarget.getResolvedComponentName())) { return false; } + if (!shouldAutoLaunchSingleChoice(activeProfileTarget)) { return false; } + String packageName = activeProfileTarget.getResolvedComponentName().getPackageName(); - if (!canAppInteractCrossProfiles(packageName)) { + if (!mIntentForwarding.canAppInteractAcrossProfiles(this, packageName)) { return false; } DevicePolicyEventLogger .createEvent(DevicePolicyEnums.RESOLVER_AUTOLAUNCH_CROSS_PROFILE_TARGET) .setBoolean(activeListAdapter.getUserHandle() - .equals(getAnnotatedUserHandles().personalProfileUserHandle)) + .equals(mProfiles.getPersonalHandle())) .setStrings(getMetricsCategory()) .write(); safelyStartActivity(activeProfileTarget); @@ -1988,140 +1609,66 @@ public class ResolverActivity extends FragmentActivity implements return true; } + private boolean isAutolaunching() { + return !mRegistered && isFinishing(); + } + /** - * Returns whether the package has the necessary permissions to interact across profiles on - * behalf of a given user. - * - *

This means meeting the following condition: - *

    - *
  • The app's {@link ApplicationInfo#crossProfile} flag must be true, and at least - * one of the following conditions must be fulfilled
  • - *
  • {@code Manifest.permission.INTERACT_ACROSS_USERS_FULL} granted.
  • - *
  • {@code Manifest.permission.INTERACT_ACROSS_USERS} granted.
  • - *
  • {@code Manifest.permission.INTERACT_ACROSS_PROFILES} granted, or the corresponding - * AppOps {@code android:interact_across_profiles} is set to "allow".
  • - *
- * + * @return {@code true} if a resolved target is autolaunched, otherwise {@code false} */ - private boolean canAppInteractCrossProfiles(String packageName) { - ApplicationInfo applicationInfo; - try { - applicationInfo = getPackageManager().getApplicationInfo(packageName, 0); - } catch (NameNotFoundException e) { - Log.e(TAG, "Package " + packageName + " does not exist on current user."); - return false; - } - if (!applicationInfo.crossProfile) { + private boolean maybeAutolaunchActivity() { + if (!isTwoPagePersonalAndWorkConfiguration()) { return false; } - int packageUid = applicationInfo.uid; + ResolverListAdapter activeListAdapter = + (mMultiProfilePagerAdapter.getActiveProfile() == PROFILE_PERSONAL) + ? mMultiProfilePagerAdapter.getPersonalListAdapter() + : mMultiProfilePagerAdapter.getWorkListAdapter(); - if (isPermissionGranted(android.Manifest.permission.INTERACT_ACROSS_USERS_FULL, - packageUid) == PackageManager.PERMISSION_GRANTED) { - return true; - } - if (isPermissionGranted(android.Manifest.permission.INTERACT_ACROSS_USERS, packageUid) - == PackageManager.PERMISSION_GRANTED) { - return true; - } - if (PermissionChecker.checkPermissionForPreflight(this, INTERACT_ACROSS_PROFILES, - PID_UNKNOWN, packageUid, packageName) == PackageManager.PERMISSION_GRANTED) { - return true; - } - return false; - } + ResolverListAdapter inactiveListAdapter = + (mMultiProfilePagerAdapter.getActiveProfile() == PROFILE_PERSONAL) + ? mMultiProfilePagerAdapter.getWorkListAdapter() + : mMultiProfilePagerAdapter.getPersonalListAdapter(); - private boolean isAutolaunching() { - return !mRegistered && isFinishing(); - } + if (!activeListAdapter.isTabLoaded() || !inactiveListAdapter.isTabLoaded()) { + return false; + } - private void setupProfileTabs() { - maybeHideDivider(); - TabHost tabHost = findViewById(com.android.internal.R.id.profile_tabhost); - tabHost.setup(); - ViewPager viewPager = findViewById(com.android.internal.R.id.profile_pager); - viewPager.setSaveEnabled(false); - - Button personalButton = (Button) getLayoutInflater().inflate( - R.layout.resolver_profile_tab_button, tabHost.getTabWidget(), false); - personalButton.setText(getPersonalTabLabel()); - personalButton.setContentDescription(getPersonalTabAccessibilityLabel()); - - TabHost.TabSpec tabSpec = tabHost.newTabSpec(TAB_TAG_PERSONAL) - .setContent(com.android.internal.R.id.profile_pager) - .setIndicator(personalButton); - tabHost.addTab(tabSpec); - - Button workButton = (Button) getLayoutInflater().inflate( - R.layout.resolver_profile_tab_button, tabHost.getTabWidget(), false); - workButton.setText(getWorkTabLabel()); - workButton.setContentDescription(getWorkTabAccessibilityLabel()); - - tabSpec = tabHost.newTabSpec(TAB_TAG_WORK) - .setContent(com.android.internal.R.id.profile_pager) - .setIndicator(workButton); - tabHost.addTab(tabSpec); - - TabWidget tabWidget = tabHost.getTabWidget(); - tabWidget.setVisibility(View.VISIBLE); - updateActiveTabStyle(tabHost); - - tabHost.setOnTabChangedListener(tabId -> { - updateActiveTabStyle(tabHost); - if (TAB_TAG_PERSONAL.equals(tabId)) { - viewPager.setCurrentItem(0); - } else { - viewPager.setCurrentItem(1); - } - setupViewVisibilities(); - maybeLogProfileChange(); - onProfileTabSelected(); - DevicePolicyEventLogger - .createEvent(DevicePolicyEnums.RESOLVER_SWITCH_TABS) - .setInt(viewPager.getCurrentItem()) - .setStrings(getMetricsCategory()) - .write(); - }); + if ((activeListAdapter.getUnfilteredCount() != 1) + || (inactiveListAdapter.getUnfilteredCount() != 1)) { + return false; + } - viewPager.setVisibility(View.VISIBLE); - tabHost.setCurrentTab(mMultiProfilePagerAdapter.getCurrentPage()); - mMultiProfilePagerAdapter.setOnProfileSelectedListener( - new MultiProfilePagerAdapter.OnProfileSelectedListener() { - @Override - public void onProfileSelected(int index) { - tabHost.setCurrentTab(index); - resetButtonBar(); - resetCheckedItem(); - } + TargetInfo activeProfileTarget = activeListAdapter.targetInfoForPosition(0, false); + TargetInfo inactiveProfileTarget = inactiveListAdapter.targetInfoForPosition(0, false); + if (!Objects.equals( + activeProfileTarget.getResolvedComponentName(), + inactiveProfileTarget.getResolvedComponentName())) { + return false; + } - @Override - public void onProfilePageStateChanged(int state) { - onHorizontalSwipeStateChanged(state); - } - }); - mOnSwitchOnWorkSelectedListener = () -> { - final View workTab = tabHost.getTabWidget().getChildAt(1); - workTab.setFocusable(true); - workTab.setFocusableInTouchMode(true); - workTab.requestFocus(); - }; - } + if (!shouldAutoLaunchSingleChoice(activeProfileTarget)) { + return false; + } - private String getPersonalTabLabel() { - return getSystemService(DevicePolicyManager.class).getResources().getString( - RESOLVER_PERSONAL_TAB, () -> getString(R.string.resolver_personal_tab)); - } + String packageName = activeProfileTarget.getResolvedComponentName().getPackageName(); + if (!mIntentForwarding.canAppInteractAcrossProfiles(this, packageName)) { + return false; + } - private String getWorkTabLabel() { - return getSystemService(DevicePolicyManager.class).getResources().getString( - RESOLVER_WORK_TAB, () -> getString(R.string.resolver_work_tab)); + DevicePolicyEventLogger + .createEvent(DevicePolicyEnums.RESOLVER_AUTOLAUNCH_CROSS_PROFILE_TARGET) + .setBoolean(activeListAdapter.getUserHandle() + .equals(mProfiles.getPersonalHandle())) + .setStrings(getMetricsCategory()) + .write(); + safelyStartActivity(activeProfileTarget); + finish(); + return true; } private void maybeHideDivider() { - if (!mIsIntentPicker) { - return; - } final View divider = findViewById(com.android.internal.R.id.divider); if (divider == null) { return; @@ -2130,41 +1677,9 @@ public class ResolverActivity extends FragmentActivity implements } private void resetCheckedItem() { - if (!mIsIntentPicker) { - return; - } mLastSelected = ListView.INVALID_POSITION; - ListView inactiveListView = (ListView) mMultiProfilePagerAdapter.getInactiveAdapterView(); - if (inactiveListView.getCheckedItemCount() > 0) { - inactiveListView.setItemChecked(inactiveListView.getCheckedItemPosition(), false); - } - } - - private String getPersonalTabAccessibilityLabel() { - return getSystemService(DevicePolicyManager.class).getResources().getString( - RESOLVER_PERSONAL_TAB_ACCESSIBILITY, - () -> getString(R.string.resolver_personal_tab_accessibility)); - } - - private String getWorkTabAccessibilityLabel() { - return getSystemService(DevicePolicyManager.class).getResources().getString( - RESOLVER_WORK_TAB_ACCESSIBILITY, - () -> getString(R.string.resolver_work_tab_accessibility)); - } - - private static int getAttrColor(Context context, int attr) { - TypedArray ta = context.obtainStyledAttributes(new int[]{attr}); - int colorAccent = ta.getColor(0, 0); - ta.recycle(); - return colorAccent; - } - - private void updateActiveTabStyle(TabHost tabHost) { - int currentTab = tabHost.getCurrentTab(); - TextView selected = (TextView) tabHost.getTabWidget().getChildAt(currentTab); - TextView unselected = (TextView) tabHost.getTabWidget().getChildAt(1 - currentTab); - selected.setSelected(true); - unselected.setSelected(false); + ((ResolverMultiProfilePagerAdapter) mMultiProfilePagerAdapter) + .clearCheckedItemsInInactiveProfiles(); } private void setupViewVisibilities() { @@ -2192,10 +1707,7 @@ public class ResolverActivity extends FragmentActivity implements private void setupAdapterListView(ListView listView, ItemClickListener listener) { listView.setOnItemClickListener(listener); listView.setOnItemLongClickListener(listener); - - if (mSupportsAlwaysUseOption) { - listView.setChoiceMode(AbsListView.CHOICE_MODE_SINGLE); - } + listView.setChoiceMode(AbsListView.CHOICE_MODE_SINGLE); } /** @@ -2206,17 +1718,17 @@ public class ResolverActivity extends FragmentActivity implements && !listAdapter.getUserHandle().equals(mHeaderCreatorUser)) { return; } - if (!shouldShowTabs() + if (!mProfiles.getWorkProfilePresent() && listAdapter.getCount() == 0 && listAdapter.getPlaceholderCount() == 0) { final TextView titleView = findViewById(com.android.internal.R.id.title); if (titleView != null) { titleView.setVisibility(View.GONE); } } - - CharSequence title = mTitle != null - ? mTitle - : getTitleForAction(getTargetIntent(), mDefaultTitleResId); + ResolverRequest request = mViewModel.getRequest().getValue(); + CharSequence title = mViewModel.getRequest().getValue().getTitle() != null + ? request.getTitle() + : getTitleForAction(request.getIntent(), 0); if (!TextUtils.isEmpty(title)) { final TextView titleView = findViewById(com.android.internal.R.id.title); @@ -2261,25 +1773,9 @@ public class ResolverActivity extends FragmentActivity implements public final boolean useLayoutWithDefault() { // We only use the default app layout when the profile of the active user has a // filtered item. We always show the same default app even in the inactive user profile. - boolean adapterForCurrentUserHasFilteredItem = - mMultiProfilePagerAdapter.getListAdapterForUserHandle( - getAnnotatedUserHandles().tabOwnerUserHandleForLaunch).hasFilteredItem(); - return mSupportsAlwaysUseOption && adapterForCurrentUserHasFilteredItem; - } - - /** - * If {@code retainInOnStop} is set to true, we will not finish ourselves when onStop gets - * called and we are launched in a new task. - */ - protected final void setRetainInOnStop(boolean retainInOnStop) { - mRetainInOnStop = retainInOnStop; - } - - private boolean inactiveListAdapterHasItems() { - if (!shouldShowTabs()) { - return false; - } - return mMultiProfilePagerAdapter.getInactiveListAdapter().getCount() > 0; + return mMultiProfilePagerAdapter.getListAdapterForUserHandle( + mProfiles.getTabOwnerUserHandleForLaunch() + ).hasFilteredItem(); } final class ItemClickListener implements AdapterView.OnItemClickListener, @@ -2336,11 +1832,37 @@ public class ResolverActivity extends FragmentActivity implements } - /** Determine whether a given match result is considered "specific" in our application. */ - public static final boolean isSpecificUriMatch(int match) { - match = (match & IntentFilter.MATCH_CATEGORY_MASK); - return match >= IntentFilter.MATCH_CATEGORY_HOST - && match <= IntentFilter.MATCH_CATEGORY_PATH; + private void setupProfileTabs() { + maybeHideDivider(); + + TabHost tabHost = findViewById(com.android.internal.R.id.profile_tabhost); + ViewPager viewPager = findViewById(com.android.internal.R.id.profile_pager); + + mMultiProfilePagerAdapter.setupProfileTabs( + getLayoutInflater(), + tabHost, + viewPager, + R.layout.resolver_profile_tab_button, + com.android.internal.R.id.profile_pager, + () -> onProfileTabSelected(viewPager.getCurrentItem()), + new OnProfileSelectedListener() { + @Override + public void onProfilePageSelected(@ProfileType int profileId, int pageNumber) { + resetButtonBar(); + resetCheckedItem(); + } + + @Override + public void onProfilePageStateChanged(int state) {} + }); + mOnSwitchOnWorkSelectedListener = () -> { + final View workTab = + tabHost.getTabWidget().getChildAt( + mMultiProfilePagerAdapter.getPageNumberForProfile(PROFILE_WORK)); + workTab.setFocusable(true); + workTab.setFocusableInTouchMode(true); + workTab.requestFocus(); + }; } static final class PickTargetOptionRequest extends PickOptionRequest { @@ -2384,7 +1906,7 @@ public class ResolverActivity extends FragmentActivity implements * {@link ResolverListController} configured for the provided {@code userHandle}. */ protected final UserHandle getQueryIntentsUser(UserHandle userHandle) { - return getAnnotatedUserHandles().getQueryIntentsUser(userHandle); + return mProfiles.getQueryIntentsHandle(userHandle); } /** @@ -2404,9 +1926,9 @@ public class ResolverActivity extends FragmentActivity implements // Add clonedProfileUserHandle to the list only if we are: // a. Building the Personal Tab. // b. CloneProfile exists on the device. - if (userHandle.equals(getAnnotatedUserHandles().personalProfileUserHandle) - && hasCloneProfile()) { - userList.add(getAnnotatedUserHandles().cloneProfileUserHandle); + if (userHandle.equals(mProfiles.getPersonalHandle()) + && mProfiles.getCloneUserPresent()) { + userList.add(mProfiles.getCloneHandle()); } return userList; } diff --git a/java/src/com/android/intentresolver/ResolverHelper.kt b/java/src/com/android/intentresolver/ResolverHelper.kt new file mode 100644 index 00000000..d12ba7d5 --- /dev/null +++ b/java/src/com/android/intentresolver/ResolverHelper.kt @@ -0,0 +1,129 @@ +/* + * Copyright (C) 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.intentresolver + +import android.app.Activity +import android.os.UserHandle +import android.util.Log +import androidx.activity.ComponentActivity +import androidx.activity.viewModels +import androidx.lifecycle.DefaultLifecycleObserver +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleOwner +import com.android.intentresolver.annotation.JavaInterop +import com.android.intentresolver.domain.interactor.UserInteractor +import com.android.intentresolver.inject.Background +import com.android.intentresolver.ui.model.ResolverRequest +import com.android.intentresolver.ui.viewmodel.ResolverViewModel +import com.android.intentresolver.validation.Invalid +import com.android.intentresolver.validation.Valid +import com.android.intentresolver.validation.log +import dagger.hilt.android.scopes.ActivityScoped +import javax.inject.Inject +import kotlinx.coroutines.CoroutineDispatcher + +private const val TAG: String = "ResolverHelper" + +/** + * __Purpose__ + * + * Cleanup aid. Provides a pathway to cleaner code. + * + * __Incoming References__ + * + * ResolverHelper must not expose any properties or functions directly back to ResolverActivity. If + * a value or operation is required by ResolverActivity, then it must be added to + * ResolverInitializer (or a new interface as appropriate) with ResolverActivity supplying a + * callback to receive it at the appropriate point. This enforces unidirectional control flow. + * + * __Outgoing References__ + * + * _ResolverActivity_ + * + * This class must only reference it's host as Activity/ComponentActivity; no down-cast to + * [ResolverActivity]. Other components should be created here or supplied via Injection, and not + * referenced directly from the activity. This prevents circular dependencies from forming. If + * necessary, during cleanup the dependency can be supplied back to ChooserActivity as described + * above in 'Incoming References', see [ResolverInitializer]. + * + * _Elsewhere_ + * + * Where possible, Singleton and ActivityScoped dependencies should be injected here instead of + * referenced from an existing location. If not available for injection, the value should be + * constructed here, then provided to where it is needed. + */ +@ActivityScoped +@JavaInterop +class ResolverHelper +@Inject +constructor( + hostActivity: Activity, + private val userInteractor: UserInteractor, + @Background private val background: CoroutineDispatcher, +) : DefaultLifecycleObserver { + // This is guaranteed by Hilt, since only a ComponentActivity is injectable. + private val activity: ComponentActivity = hostActivity as ComponentActivity + private val viewModel by activity.viewModels() + + private lateinit var activityInitializer: Runnable + + init { + activity.lifecycle.addObserver(this) + } + + /** + * Set the initialization hook for the host activity. + * + * This _must_ be called from [ResolverActivity.onCreate]. + */ + fun setInitializer(initializer: Runnable) { + if (activity.lifecycle.currentState != Lifecycle.State.INITIALIZED) { + error("setInitializer must be called before onCreate returns") + } + activityInitializer = initializer + } + + /** Invoked by Lifecycle, after Activity.onCreate() _returns_. */ + override fun onCreate(owner: LifecycleOwner) { + Log.i(TAG, "CREATE") + Log.i(TAG, "${viewModel.activityModel}") + + val callerUid: Int = viewModel.activityModel.launchedFromUid + if (callerUid < 0 || UserHandle.isIsolated(callerUid)) { + Log.e(TAG, "Can't start a resolver from uid $callerUid") + activity.finish() + return + } + + when (val request = viewModel.initialRequest) { + is Valid -> initializeActivity(request) + is Invalid -> reportErrorsAndFinish(request) + } + } + + private fun reportErrorsAndFinish(request: Invalid) { + request.errors.forEach { it.log(TAG) } + activity.finish() + } + + private fun initializeActivity(request: Valid) { + Log.d(TAG, "initializeActivity") + request.warnings.forEach { it.log(TAG) } + + activityInitializer.run() + } +} diff --git a/java/src/com/android/intentresolver/ResolverMultiProfilePagerAdapter.java b/java/src/com/android/intentresolver/ResolverMultiProfilePagerAdapter.java deleted file mode 100644 index 591c23b7..00000000 --- a/java/src/com/android/intentresolver/ResolverMultiProfilePagerAdapter.java +++ /dev/null @@ -1,120 +0,0 @@ -/* - * Copyright (C) 2019 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.android.intentresolver; - -import android.content.Context; -import android.os.UserHandle; -import android.view.LayoutInflater; -import android.view.ViewGroup; -import android.widget.ListView; - -import androidx.viewpager.widget.PagerAdapter; - -import com.android.intentresolver.emptystate.EmptyStateProvider; -import com.android.internal.annotations.VisibleForTesting; - -import com.google.common.collect.ImmutableList; - -import java.util.Optional; -import java.util.function.Supplier; - -/** - * A {@link PagerAdapter} which describes the work and personal profile intent resolver screens. - */ -@VisibleForTesting -public class ResolverMultiProfilePagerAdapter extends - MultiProfilePagerAdapter { - private final BottomPaddingOverrideSupplier mBottomPaddingOverrideSupplier; - - public ResolverMultiProfilePagerAdapter( - Context context, - ResolverListAdapter adapter, - EmptyStateProvider emptyStateProvider, - Supplier workProfileQuietModeChecker, - UserHandle workProfileUserHandle, - UserHandle cloneProfileUserHandle) { - this( - context, - ImmutableList.of(adapter), - emptyStateProvider, - workProfileQuietModeChecker, - /* defaultProfile= */ 0, - workProfileUserHandle, - cloneProfileUserHandle, - new BottomPaddingOverrideSupplier()); - } - - public ResolverMultiProfilePagerAdapter(Context context, - ResolverListAdapter personalAdapter, - ResolverListAdapter workAdapter, - EmptyStateProvider emptyStateProvider, - Supplier workProfileQuietModeChecker, - @Profile int defaultProfile, - UserHandle workProfileUserHandle, - UserHandle cloneProfileUserHandle) { - this( - context, - ImmutableList.of(personalAdapter, workAdapter), - emptyStateProvider, - workProfileQuietModeChecker, - defaultProfile, - workProfileUserHandle, - cloneProfileUserHandle, - new BottomPaddingOverrideSupplier()); - } - - private ResolverMultiProfilePagerAdapter( - Context context, - ImmutableList listAdapters, - EmptyStateProvider emptyStateProvider, - Supplier workProfileQuietModeChecker, - @Profile int defaultProfile, - UserHandle workProfileUserHandle, - UserHandle cloneProfileUserHandle, - BottomPaddingOverrideSupplier bottomPaddingOverrideSupplier) { - super( - listAdapter -> listAdapter, - (listView, bindAdapter) -> listView.setAdapter(bindAdapter), - listAdapters, - emptyStateProvider, - workProfileQuietModeChecker, - defaultProfile, - workProfileUserHandle, - cloneProfileUserHandle, - () -> (ViewGroup) LayoutInflater.from(context).inflate( - R.layout.resolver_list_per_profile, null, false), - bottomPaddingOverrideSupplier); - mBottomPaddingOverrideSupplier = bottomPaddingOverrideSupplier; - } - - public void setUseLayoutWithDefault(boolean useLayoutWithDefault) { - mBottomPaddingOverrideSupplier.setUseLayoutWithDefault(useLayoutWithDefault); - } - - private static class BottomPaddingOverrideSupplier implements Supplier> { - private boolean mUseLayoutWithDefault; - - public void setUseLayoutWithDefault(boolean useLayoutWithDefault) { - mUseLayoutWithDefault = useLayoutWithDefault; - } - - @Override - public Optional get() { - return mUseLayoutWithDefault ? Optional.empty() : Optional.of(0); - } - } -} diff --git a/java/src/com/android/intentresolver/ShortcutSelectionLogic.java b/java/src/com/android/intentresolver/ShortcutSelectionLogic.java index 12465184..2d5ec451 100644 --- a/java/src/com/android/intentresolver/ShortcutSelectionLogic.java +++ b/java/src/com/android/intentresolver/ShortcutSelectionLogic.java @@ -30,8 +30,8 @@ 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 com.android.intentresolver.ui.AppShortcutLimit; +import com.android.intentresolver.ui.EnforceShortcutLimit; import java.util.Collections; import java.util.Comparator; diff --git a/java/src/com/android/intentresolver/annotation/JavaInterop.kt b/java/src/com/android/intentresolver/annotation/JavaInterop.kt new file mode 100644 index 00000000..e268af98 --- /dev/null +++ b/java/src/com/android/intentresolver/annotation/JavaInterop.kt @@ -0,0 +1,28 @@ +/* + * Copyright (C) 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.intentresolver.annotation + +/** + * Apply to code which exists specifically to easy integration with existing Java and Java APIs. + * + * The goal is to prevent usage from Kotlin when a more idiomatic alternative is available. + */ +@RequiresOptIn( + "This is a a property, function or class specifically supporting Java " + + "interoperability. Usage from Kotlin should be limited to interactions with Java." +) +annotation class JavaInterop diff --git a/java/src/com/android/intentresolver/chooser/DisplayResolveInfoAzInfoComparator.java b/java/src/com/android/intentresolver/chooser/DisplayResolveInfoAzInfoComparator.java new file mode 100644 index 00000000..3462b726 --- /dev/null +++ b/java/src/com/android/intentresolver/chooser/DisplayResolveInfoAzInfoComparator.java @@ -0,0 +1,44 @@ +/* + * Copyright (C) 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.intentresolver.chooser; + + +import android.content.Context; + +import java.text.Collator; +import java.util.Comparator; + +/** + * Sort intents alphabetically based on display label. + */ +public class DisplayResolveInfoAzInfoComparator implements Comparator { + Comparator mComparator; + public DisplayResolveInfoAzInfoComparator(Context context) { + Collator collator = Collator + .getInstance(context.getResources().getConfiguration().locale); + // Adding two stage comparator, first stage compares using displayLabel, next stage + // compares using resolveInfo.userHandle + mComparator = Comparator.comparing(DisplayResolveInfo::getDisplayLabel, collator) + .thenComparingInt(target -> target.getResolveInfo().userHandle.getIdentifier()); + } + + @Override + public int compare( + DisplayResolveInfo lhsp, DisplayResolveInfo rhsp) { + return mComparator.compare(lhsp, rhsp); + } +} diff --git a/java/src/com/android/intentresolver/contentpreview/ShareouselContentPreviewUi.kt b/java/src/com/android/intentresolver/contentpreview/ShareouselContentPreviewUi.kt index 3530ede1..fa0859e0 100644 --- a/java/src/com/android/intentresolver/contentpreview/ShareouselContentPreviewUi.kt +++ b/java/src/com/android/intentresolver/contentpreview/ShareouselContentPreviewUi.kt @@ -32,7 +32,7 @@ import androidx.lifecycle.viewmodel.compose.viewModel import com.android.intentresolver.R import com.android.intentresolver.contentpreview.payloadtoggle.ui.composable.Shareousel import com.android.intentresolver.contentpreview.payloadtoggle.ui.viewmodel.ShareouselViewModel -import com.android.intentresolver.v2.ui.viewmodel.ChooserViewModel +import com.android.intentresolver.ui.viewmodel.ChooserViewModel @VisibleForTesting(otherwise = VisibleForTesting.PACKAGE_PRIVATE) class ShareouselContentPreviewUi : ContentPreviewUi() { 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 index 61c04ac1..c70fc83e 100644 --- a/java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/interactor/ChooserRequestInteractor.kt +++ b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/interactor/ChooserRequestInteractor.kt @@ -18,7 +18,7 @@ package com.android.intentresolver.contentpreview.payloadtoggle.domain.interacto import android.content.Intent import com.android.intentresolver.contentpreview.payloadtoggle.data.model.CustomActionModel -import com.android.intentresolver.v2.data.repository.ChooserRequestRepository +import com.android.intentresolver.data.repository.ChooserRequestRepository import javax.inject.Inject import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.asSharedFlow 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 index 9e48cd28..941dfca1 100644 --- a/java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/interactor/UpdateChooserRequestInteractor.kt +++ b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/interactor/UpdateChooserRequestInteractor.kt @@ -23,7 +23,7 @@ import com.android.intentresolver.contentpreview.payloadtoggle.domain.intent.toC 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 com.android.intentresolver.data.repository.ChooserRequestRepository import javax.inject.Inject import kotlinx.coroutines.flow.update 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 index 20af264a..1d34dc75 100644 --- a/java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/update/SelectionChangeCallback.kt +++ b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/update/SelectionChangeCallback.kt @@ -37,15 +37,15 @@ import com.android.intentresolver.contentpreview.payloadtoggle.domain.model.Valu 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 com.android.intentresolver.ui.viewmodel.readAlternateIntents +import com.android.intentresolver.ui.viewmodel.readChooserActions +import com.android.intentresolver.validation.Invalid +import com.android.intentresolver.validation.Valid +import com.android.intentresolver.validation.ValidationResult +import com.android.intentresolver.validation.log +import com.android.intentresolver.validation.types.array +import com.android.intentresolver.validation.types.value +import com.android.intentresolver.validation.validateFrom import dagger.Binds import dagger.Module import dagger.hilt.InstallIn diff --git a/java/src/com/android/intentresolver/data/BroadcastSubscriber.kt b/java/src/com/android/intentresolver/data/BroadcastSubscriber.kt new file mode 100644 index 00000000..cf31ea10 --- /dev/null +++ b/java/src/com/android/intentresolver/data/BroadcastSubscriber.kt @@ -0,0 +1,73 @@ +/* + * Copyright (C) 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.intentresolver.data + +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.content.IntentFilter +import android.os.Handler +import android.os.UserHandle +import android.util.Log +import com.android.intentresolver.inject.Broadcast +import dagger.hilt.android.qualifiers.ApplicationContext +import javax.inject.Inject +import kotlinx.coroutines.channels.awaitClose +import kotlinx.coroutines.channels.onFailure +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.callbackFlow + +private const val TAG = "BroadcastSubscriber" + +class BroadcastSubscriber +@Inject +constructor( + @ApplicationContext private val context: Context, + @Broadcast private val handler: Handler +) { + /** + * Returns a [callbackFlow] that, when collected, registers a broadcast receiver and emits a new + * value whenever broadcast matching _filter_ is received. The result value will be computed + * using [transform] and emitted if non-null. + */ + fun createFlow( + filter: IntentFilter, + user: UserHandle, + transform: (Intent) -> T?, + ): Flow = callbackFlow { + val receiver = + object : BroadcastReceiver() { + override fun onReceive(context: Context, intent: Intent) { + transform(intent)?.also { result -> + trySend(result).onFailure { Log.e(TAG, "Failed to send $result", it) } + } + ?: Log.w(TAG, "Ignored broadcast $intent") + } + } + + @Suppress("MissingPermission") + context.registerReceiverAsUser( + receiver, + user, + IntentFilter(filter), + null, + handler, + Context.RECEIVER_NOT_EXPORTED + ) + awaitClose { context.unregisterReceiver(receiver) } + } +} diff --git a/java/src/com/android/intentresolver/data/model/ChooserRequest.kt b/java/src/com/android/intentresolver/data/model/ChooserRequest.kt new file mode 100644 index 00000000..045a17f6 --- /dev/null +++ b/java/src/com/android/intentresolver/data/model/ChooserRequest.kt @@ -0,0 +1,195 @@ +/* + * Copyright (C) 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.android.intentresolver.data.model + +import android.content.ComponentName +import android.content.Intent +import android.content.Intent.ACTION_SEND +import android.content.Intent.ACTION_SEND_MULTIPLE +import android.content.Intent.EXTRA_REFERRER +import android.content.IntentFilter +import android.content.IntentSender +import android.net.Uri +import android.os.Bundle +import android.service.chooser.ChooserAction +import android.service.chooser.ChooserTarget +import androidx.annotation.StringRes +import com.android.intentresolver.ContentTypeHint +import com.android.intentresolver.ext.hasAction + +const val ANDROID_APP_SCHEME = "android-app" + +/** All of the things that are consumed from an incoming share Intent (+Extras). */ +data class ChooserRequest( + /** Required. Represents the content being sent. */ + val targetIntent: Intent, + + /** The action from [targetIntent] as retrieved with [Intent.getAction]. */ + val targetAction: String? = targetIntent.action, + + /** + * Whether [targetAction] is ACTION_SEND or ACTION_SEND_MULTIPLE. These are considered the + * canonical "Share" actions. When handling other actions, this flag controls behavioral and + * visual changes. + */ + val isSendActionTarget: Boolean = targetIntent.hasAction(ACTION_SEND, ACTION_SEND_MULTIPLE), + + /** The top-level content type as retrieved using [Intent.getType]. */ + val targetType: String? = targetIntent.type, + + /** The package name of the app which started the current activity instance. */ + val launchedFromPackage: String, + + /** A custom tile for the main UI. Ignored when the intent is ACTION_SEND(_MULTIPLE). */ + val title: CharSequence? = null, + + /** A String resource ID to load when [title] is null. */ + @get:StringRes val defaultTitleResource: Int = 0, + + /** + * The referrer value as received by the caller. It may have been supplied via [EXTRA_REFERRER] + * or synthesized from callerPackageName. This value is merged into outgoing intents. + */ + val referrer: Uri? = null, + + /** + * Choices to exclude from results. + * + * Any resolved intents with a component in this list will be omitted before presentation. + */ + val filteredComponentNames: List = emptyList(), + + /** + * App provided shortcut share intents (aka "direct share targets") + * + * Normally share shortcuts are published and consumed using + * [ShortcutManager][android.content.pm.ShortcutManager]. This is an alternate channel to allow + * apps to directly inject the same information. + * + * Historical note: This option was initially integrated with other results from the + * ChooserTargetService API (since deprecated and removed), hence the name and data format. + * These are more correctly called "Share Shortcuts" now. + */ + val callerChooserTargets: List = emptyList(), + + /** + * Actions the user may perform. These are presented as separate affordances from the main list + * of choices. Selecting a choice is a terminal action which results in finishing. The item + * limit is [MAX_CHOOSER_ACTIONS]. This may be further constrained as appropriate. + */ + val chooserActions: List = emptyList(), + + /** + * An action to start an Activity which for user updating of shared content. Selection is a + * terminal action, closing the current activity and launching the target of the action. + */ + val modifyShareAction: ChooserAction? = null, + + /** + * When false the host activity will be [finished][android.app.Activity.finish] when stopped. + */ + @get:JvmName("shouldRetainInOnStop") val shouldRetainInOnStop: Boolean = false, + + /** + * Intents which contain alternate representations of the content being shared. Any results from + * resolving these _alternate_ intents are included with the results of the primary intent as + * additional choices (e.g. share as image content vs. link to content). + */ + val additionalTargets: List = emptyList(), + + /** + * Alternate [extras][Intent.getExtras] to substitute when launching a selected app. + * + * For a given app (by package name), the Bundle describes what parameters to substitute when + * that app is selected. + * + * // TODO: Map + */ + val replacementExtras: Bundle? = null, + + /** + * App-supplied choices to be presented first in the list. + * + * Custom labels and icons may be supplied using + * [LabeledIntent][android.content.pm.LabeledIntent]. + * + * Limit 2. + */ + val initialIntents: List = emptyList(), + + /** + * Provides for callers to be notified when a component is selected. + * + * The selection is reported in the Intent as [Intent.EXTRA_CHOSEN_COMPONENT] with the + * [ComponentName] of the item. + */ + val chosenComponentSender: IntentSender? = null, + + /** + * Provides a mechanism for callers to post-process a target when a selection is made. + * + * The received intent will contain: + * * **EXTRA_INTENT** The chosen target + * * **EXTRA_ALTERNATE_INTENTS** Additional intents which also match the target + * * **EXTRA_RESULT_RECEIVER** A [ResultReceiver][android.os.ResultReceiver] providing a + * mechanism for the caller to return information. An updated intent to send must be included + * as [Intent.EXTRA_INTENT]. + */ + val refinementIntentSender: IntentSender? = null, + + /** + * Contains the text content to share supplied by the source app. + * + * TODO: Constrain length? + */ + val sharedText: CharSequence? = null, + + /** + * Supplied to + * [ShortcutManager.getShareTargets][android.content.pm.ShortcutManager.getShareTargets] to + * query for matching shortcuts. Specifically, only the [dataTypes][IntentFilter.hasDataType] + * are considered for matching share shortcuts currently. + */ + val shareTargetFilter: IntentFilter? = null, + + /** A URI for additional content */ + val additionalContentUri: Uri? = null, + + /** Focused item index (from target intent's STREAM_EXTRA) */ + val focusedItemPosition: Int = 0, + + /** Value for [Intent.EXTRA_CHOOSER_CONTENT_TYPE_HINT] on the incoming chooser intent. */ + val contentTypeHint: ContentTypeHint = ContentTypeHint.NONE, + + /** + * Metadata to be shown to the user as a part of the sharesheet window. + * + * Specified by the [Intent.EXTRA_METADATA_TEXT] + */ + val metadataText: CharSequence? = null, +) { + val referrerPackage = referrer?.takeIf { it.scheme == ANDROID_APP_SCHEME }?.authority + + fun getReferrerFillInIntent(): Intent { + return Intent().apply { + referrerPackage?.also { pkg -> + putExtra(EXTRA_REFERRER, Uri.parse("$ANDROID_APP_SCHEME://$pkg")) + } + } + } + + val payloadIntents = listOf(targetIntent) + additionalTargets +} diff --git a/java/src/com/android/intentresolver/data/repository/ChooserRequestRepository.kt b/java/src/com/android/intentresolver/data/repository/ChooserRequestRepository.kt new file mode 100644 index 00000000..14177b1b --- /dev/null +++ b/java/src/com/android/intentresolver/data/repository/ChooserRequestRepository.kt @@ -0,0 +1,39 @@ +/* + * Copyright (C) 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.intentresolver.data.repository + +import com.android.intentresolver.contentpreview.payloadtoggle.data.model.CustomActionModel +import com.android.intentresolver.data.model.ChooserRequest +import dagger.hilt.android.scopes.ViewModelScoped +import javax.inject.Inject +import kotlinx.coroutines.flow.MutableStateFlow + +@ViewModelScoped +class ChooserRequestRepository +@Inject +constructor( + initialRequest: ChooserRequest, + initialActions: List, +) { + /** All information from the sharing application pertaining to the chooser. */ + val chooserRequest: MutableStateFlow = 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> = MutableStateFlow(initialActions) +} diff --git a/java/src/com/android/intentresolver/data/repository/DevicePolicyResources.kt b/java/src/com/android/intentresolver/data/repository/DevicePolicyResources.kt new file mode 100644 index 00000000..c396b720 --- /dev/null +++ b/java/src/com/android/intentresolver/data/repository/DevicePolicyResources.kt @@ -0,0 +1,100 @@ +/* + * Copyright (C) 2023 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.android.intentresolver.data.repository + +import android.app.admin.DevicePolicyManager +import android.app.admin.DevicePolicyResources.Strings.Core.FORWARD_INTENT_TO_PERSONAL +import android.app.admin.DevicePolicyResources.Strings.Core.FORWARD_INTENT_TO_WORK +import android.app.admin.DevicePolicyResources.Strings.Core.RESOLVER_PERSONAL_TAB +import android.app.admin.DevicePolicyResources.Strings.Core.RESOLVER_PERSONAL_TAB_ACCESSIBILITY +import android.app.admin.DevicePolicyResources.Strings.Core.RESOLVER_WORK_PROFILE_NOT_SUPPORTED +import android.app.admin.DevicePolicyResources.Strings.Core.RESOLVER_WORK_TAB +import android.app.admin.DevicePolicyResources.Strings.Core.RESOLVER_WORK_TAB_ACCESSIBILITY +import android.content.res.Resources +import com.android.intentresolver.R +import com.android.intentresolver.inject.ApplicationOwned +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class DevicePolicyResources +@Inject +constructor( + @ApplicationOwned private val resources: Resources, + devicePolicyManager: DevicePolicyManager +) { + private val policyResources = devicePolicyManager.resources + + val personalTabLabel by lazy { + requireNotNull( + policyResources.getString(RESOLVER_PERSONAL_TAB) { + resources.getString(R.string.resolver_personal_tab) + } + ) + } + + val workTabLabel by lazy { + requireNotNull( + policyResources.getString(RESOLVER_WORK_TAB) { + resources.getString(R.string.resolver_work_tab) + } + ) + } + + val personalTabAccessibilityLabel by lazy { + requireNotNull( + policyResources.getString(RESOLVER_PERSONAL_TAB_ACCESSIBILITY) { + resources.getString(R.string.resolver_personal_tab_accessibility) + } + ) + } + + val workTabAccessibilityLabel by lazy { + requireNotNull( + policyResources.getString(RESOLVER_WORK_TAB_ACCESSIBILITY) { + resources.getString(R.string.resolver_work_tab_accessibility) + } + ) + } + + val forwardToPersonalMessage: String? = + devicePolicyManager.resources.getString(FORWARD_INTENT_TO_PERSONAL) { + resources.getString(R.string.forward_intent_to_owner) + } + + val forwardToWorkMessage by lazy { + requireNotNull( + policyResources.getString(FORWARD_INTENT_TO_WORK) { + resources.getString(R.string.forward_intent_to_work) + } + ) + } + + fun getWorkProfileNotSupportedMessage(launcherName: String): String { + return requireNotNull( + policyResources.getString( + RESOLVER_WORK_PROFILE_NOT_SUPPORTED, + { + resources.getString( + R.string.activity_resolver_work_profiles_support, + launcherName + ) + }, + launcherName + ) + ) + } +} diff --git a/java/src/com/android/intentresolver/data/repository/UserInfoExt.kt b/java/src/com/android/intentresolver/data/repository/UserInfoExt.kt new file mode 100644 index 00000000..753df93e --- /dev/null +++ b/java/src/com/android/intentresolver/data/repository/UserInfoExt.kt @@ -0,0 +1,45 @@ +/* + * Copyright (C) 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.intentresolver.data.repository + +import android.content.pm.UserInfo +import com.android.intentresolver.shared.model.User +import com.android.intentresolver.shared.model.User.Role + +/** Maps the UserInfo to one of the defined [Roles][User.Role], if possible. */ +fun UserInfo.getSupportedUserRole(): Role? = + when { + isFull -> Role.PERSONAL + isManagedProfile -> Role.WORK + isCloneProfile -> Role.CLONE + isPrivateProfile -> Role.PRIVATE + else -> null + } + +/** + * Creates a [User], based on values from a [UserInfo]. + * + * ``` + * val users: List = + * getEnabledProfiles(user).map(::toUser).filterNotNull() + * ``` + * + * @return a [User] if the [UserInfo] matched a supported [Role], otherwise null + */ +fun UserInfo.toUser(): User? { + return getSupportedUserRole()?.let { role -> User(userHandle.identifier, role) } +} diff --git a/java/src/com/android/intentresolver/data/repository/UserRepository.kt b/java/src/com/android/intentresolver/data/repository/UserRepository.kt new file mode 100644 index 00000000..6b5ff4ba --- /dev/null +++ b/java/src/com/android/intentresolver/data/repository/UserRepository.kt @@ -0,0 +1,329 @@ +/* + * Copyright (C) 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.intentresolver.data.repository + +import android.content.Intent +import android.content.Intent.ACTION_MANAGED_PROFILE_AVAILABLE +import android.content.Intent.ACTION_MANAGED_PROFILE_UNAVAILABLE +import android.content.Intent.ACTION_PROFILE_ADDED +import android.content.Intent.ACTION_PROFILE_AVAILABLE +import android.content.Intent.ACTION_PROFILE_REMOVED +import android.content.Intent.ACTION_PROFILE_UNAVAILABLE +import android.content.Intent.EXTRA_QUIET_MODE +import android.content.Intent.EXTRA_USER +import android.content.IntentFilter +import android.content.pm.UserInfo +import android.os.Build +import android.os.UserHandle +import android.os.UserManager +import android.util.Log +import androidx.annotation.VisibleForTesting +import com.android.intentresolver.data.BroadcastSubscriber +import com.android.intentresolver.inject.Background +import com.android.intentresolver.inject.Main +import com.android.intentresolver.inject.ProfileParent +import com.android.intentresolver.shared.model.User +import javax.inject.Inject +import kotlin.time.Duration +import kotlin.time.Duration.Companion.seconds +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.SharingStarted.Companion.WhileSubscribed +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.filterNot +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.flow.onStart +import kotlinx.coroutines.flow.runningFold +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.withContext + +interface UserRepository { + /** + * A [Flow] user profile groups. Each list contains the context user along with all members of + * the profile group. This includes the (Full) parent user, if the context user is a profile. + */ + val users: Flow> + + /** + * A [Flow] of availability. Only profile users may become unavailable. + * + * Availability is currently defined as not being in [quietMode][UserInfo.isQuietModeEnabled]. + */ + val availability: Flow> + + /** + * Request that availability be updated to the requested state. This currently includes toggling + * quiet mode as needed. This may involve additional background actions, such as starting or + * stopping a profile user (along with their many associated processes). + * + * If successful, the change will be applied after the call returns and can be observed using + * [UserRepository.availability] for the given user. + * + * No actions are taken if the user is already in requested state. + * + * @throws IllegalArgumentException if called for an unsupported user type + */ + suspend fun requestState(user: User, available: Boolean) +} + +private const val TAG = "UserRepository" + +/** The delay between entering the cached process state and entering the frozen cgroup */ +private val cachedProcessFreezeDelay: Duration = 10.seconds + +/** How long to continue listening for user state broadcasts while unsubscribed */ +private val stateFlowTimeout = cachedProcessFreezeDelay - 2.seconds + +/** How long to retain the previous user state after the state flow stops. */ +private val stateCacheTimeout = 2.seconds + +internal data class UserWithState(val user: User, val available: Boolean) + +internal typealias UserStates = List + +internal val userBroadcastActions = + setOf( + ACTION_PROFILE_ADDED, + ACTION_PROFILE_REMOVED, + + // Quiet mode enabled/disabled for managed + // From: UserController.broadcastProfileAvailabilityChanges + // In response to setQuietModeEnabled + ACTION_MANAGED_PROFILE_AVAILABLE, // quiet mode, sent for manage profiles only + ACTION_MANAGED_PROFILE_UNAVAILABLE, // quiet mode, sent for manage profiles only + + // Quiet mode toggled for profile type, requires flag 'android.os.allow_private_profile + // true' + ACTION_PROFILE_AVAILABLE, // quiet mode, + ACTION_PROFILE_UNAVAILABLE, // quiet mode, sent for any profile type + ) + +/** Tracks and publishes state for the parent user and associated profiles. */ +class UserRepositoryImpl +@VisibleForTesting +constructor( + private val profileParent: UserHandle, + private val userManager: UserManager, + /** A flow of events which represent user-state changes from [UserManager]. */ + private val userEvents: Flow, + scope: CoroutineScope, + private val backgroundDispatcher: CoroutineDispatcher, +) : UserRepository { + @Inject + constructor( + @ProfileParent profileParent: UserHandle, + userManager: UserManager, + @Main scope: CoroutineScope, + @Background background: CoroutineDispatcher, + broadcastSubscriber: BroadcastSubscriber, + ) : this( + profileParent, + userManager, + userEvents = + broadcastSubscriber.createFlow( + createFilter(userBroadcastActions), + profileParent, + Intent::toUserEvent + ), + scope, + background, + ) + + private fun debugLog(msg: () -> String) { + if (Build.IS_USERDEBUG || Build.IS_ENG) { + Log.d(TAG, msg()) + } + } + + private fun errorLog(msg: String, caught: Throwable? = null) { + Log.e(TAG, msg, caught) + } + + /** + * An exception which indicates that an inconsistency exists between the user state map and the + * rest of the system. + */ + private class UserStateException( + override val message: String, + val event: UserEvent, + override val cause: Throwable? = null, + ) : RuntimeException("$message: event=$event", cause) + + private val sharingScope = CoroutineScope(scope.coroutineContext + backgroundDispatcher) + private val usersWithState: Flow = + userEvents + .onStart { emit(Initialize) } + .onEach { debugLog { "userEvent: $it" } } + .runningFold(emptyList(), ::handleEvent) + .distinctUntilChanged() + .onEach { debugLog { "userStateList: $it" } } + .stateIn( + sharingScope, + started = + WhileSubscribed( + stopTimeoutMillis = stateFlowTimeout.inWholeMilliseconds, + replayExpirationMillis = 0 + /** Immediately on stop */ + ), + listOf() + ) + .filterNot { it.isEmpty() } + + private suspend fun handleEvent(users: UserStates, event: UserEvent): UserStates { + return try { + // Handle an action by performing some operation, then returning a new map + when (event) { + is Initialize -> createNewUserStates(profileParent) + is ProfileAdded -> handleProfileAdded(event, users) + is ProfileRemoved -> handleProfileRemoved(event, users) + is AvailabilityChange -> handleAvailability(event, users) + is UnknownEvent -> { + debugLog { "Unhandled event: $event)" } + users + } + } + } catch (e: UserStateException) { + errorLog("An error occurred handling an event: ${e.event}") + errorLog("Attempting to recover...", e) + createNewUserStates(profileParent) + } + } + + override val users: Flow> = + usersWithState.map { userStates -> userStates.map { it.user } }.distinctUntilChanged() + + override val availability: Flow> = + usersWithState + .map { list -> list.associate { it.user to it.available } } + .distinctUntilChanged() + + override suspend fun requestState(user: User, available: Boolean) { + return withContext(backgroundDispatcher) { + debugLog { "requestQuietModeEnabled: ${!available} for user $user" } + userManager.requestQuietModeEnabled(/* enableQuietMode = */ !available, user.handle) + } + } + + private fun List.update(handle: UserHandle, user: UserWithState) = + filter { it.user.id != handle.identifier } + user + + private fun handleAvailability(event: AvailabilityChange, current: UserStates): UserStates { + val userEntry = + current.firstOrNull { it.user.id == event.user.identifier } + ?: throw UserStateException("User was not present in the map", event) + return current.update(event.user, userEntry.copy(available = !event.quietMode)) + } + + private fun handleProfileRemoved(event: ProfileRemoved, current: UserStates): UserStates { + if (!current.any { it.user.id == event.user.identifier }) { + throw UserStateException("User was not present in the map", event) + } + return current.filter { it.user.id != event.user.identifier } + } + + private suspend fun handleProfileAdded(event: ProfileAdded, current: UserStates): UserStates { + val user = + try { + requireNotNull(readUser(event.user)) + } catch (e: Exception) { + throw UserStateException("Failed to read user from UserManager", event, e) + } + return current + UserWithState(user, true) + } + + private suspend fun createNewUserStates(user: UserHandle): UserStates { + val profiles = readProfileGroup(user) + return profiles.mapNotNull { userInfo -> + userInfo.toUser()?.let { user -> UserWithState(user, userInfo.isAvailable()) } + } + } + + private suspend fun readProfileGroup(member: UserHandle): List { + return withContext(backgroundDispatcher) { + @Suppress("DEPRECATION") userManager.getEnabledProfiles(member.identifier) + } + .toList() + } + + /** Read [UserInfo] from [UserManager], or null if not found or an unsupported type. */ + private suspend fun readUser(user: UserHandle): User? { + val userInfo = + withContext(backgroundDispatcher) { userManager.getUserInfo(user.identifier) } + return userInfo?.let { info -> + info.getSupportedUserRole()?.let { role -> User(info.id, role) } + } + } +} + +/** A Model representing changes to profiles and availability */ +sealed interface UserEvent + +/** Used as a an initial value to trigger a fetch of all profile data. */ +data object Initialize : UserEvent + +/** A profile was added to the profile group. */ +data class ProfileAdded( + /** The handle for the added profile. */ + val user: UserHandle, +) : UserEvent + +/** A profile was removed from the profile group. */ +data class ProfileRemoved( + /** The handle for the removed profile. */ + val user: UserHandle, +) : UserEvent + +/** A profile has changed availability. */ +data class AvailabilityChange( + /** THe handle for the profile with availability change. */ + val user: UserHandle, + /** The new quietMode state. */ + val quietMode: Boolean = false, +) : UserEvent + +/** An unhandled event, logged and ignored. */ +data class UnknownEvent( + /** The broadcast intent action received */ + val action: String?, +) : UserEvent + +/** Used with [broadcastFlow] to transform a UserManager broadcast action into a [UserEvent]. */ +internal fun Intent.toUserEvent(): UserEvent { + val action = action + val user = extras?.getParcelable(EXTRA_USER, UserHandle::class.java) + val quietMode = extras?.getBoolean(EXTRA_QUIET_MODE, false) + return when (action) { + ACTION_PROFILE_ADDED -> ProfileAdded(requireNotNull(user)) + ACTION_PROFILE_REMOVED -> ProfileRemoved(requireNotNull(user)) + ACTION_MANAGED_PROFILE_UNAVAILABLE, + ACTION_MANAGED_PROFILE_AVAILABLE, + ACTION_PROFILE_AVAILABLE, + ACTION_PROFILE_UNAVAILABLE -> + AvailabilityChange(requireNotNull(user), requireNotNull(quietMode)) + else -> UnknownEvent(action) + } +} + +internal fun createFilter(actions: Iterable): IntentFilter { + return IntentFilter().apply { actions.forEach(::addAction) } +} + +internal fun UserInfo?.isAvailable(): Boolean { + return this?.isQuietModeEnabled != true +} diff --git a/java/src/com/android/intentresolver/data/repository/UserRepositoryModule.kt b/java/src/com/android/intentresolver/data/repository/UserRepositoryModule.kt new file mode 100644 index 00000000..7109d6d4 --- /dev/null +++ b/java/src/com/android/intentresolver/data/repository/UserRepositoryModule.kt @@ -0,0 +1,53 @@ +/* + * Copyright (C) 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.intentresolver.data.repository + +import android.content.Context +import android.os.UserHandle +import android.os.UserManager +import com.android.intentresolver.inject.ApplicationUser +import com.android.intentresolver.inject.ProfileParent +import dagger.Binds +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.android.qualifiers.ApplicationContext +import dagger.hilt.components.SingletonComponent +import javax.inject.Singleton + +@Module +@InstallIn(SingletonComponent::class) +interface UserRepositoryModule { + companion object { + @Provides + @Singleton + @ApplicationUser + fun applicationUser(@ApplicationContext context: Context): UserHandle = context.user + + @Provides + @Singleton + @ProfileParent + fun profileParent( + @ApplicationContext context: Context, + userManager: UserManager + ): UserHandle { + return userManager.getProfileParent(context.user) ?: context.user + } + } + + @Binds @Singleton fun userRepository(impl: UserRepositoryImpl): UserRepository +} diff --git a/java/src/com/android/intentresolver/data/repository/UserScopedService.kt b/java/src/com/android/intentresolver/data/repository/UserScopedService.kt new file mode 100644 index 00000000..10a33eb1 --- /dev/null +++ b/java/src/com/android/intentresolver/data/repository/UserScopedService.kt @@ -0,0 +1,67 @@ +/* + * Copyright (C) 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.intentresolver.data.repository + +import android.content.Context +import android.os.UserHandle +import androidx.core.content.getSystemService +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlin.reflect.KClass + +/** + * Provides instances of a [system service][Context.getSystemService] created with + * [the context of a specified user][Context.createContextAsUser]. + * + * Some services which have only `@UserHandleAware` APIs operate on the user id available from + * [Context.getUser], the context used to retrieve the service. This utility helps adapt a per-user + * API model to work in multi-user manner. + * + * Example usage: + * ``` + * @Provides + * fun scopedUserManager(@ApplicationContext ctx: Context): UserScopedService { + * return UserScopedServiceImpl(ctx, UserManager::class) + * } + * + * class MyUserHelper @Inject constructor( + * private val userMgr: UserScopedService, + * ) { + * fun isPrivateProfile(user: UserHandle): UserManager { + * return userMgr.forUser(user).isPrivateProfile() + * } + * } + * ``` + */ +fun interface UserScopedService { + /** Create a service instance for the given user. */ + fun forUser(user: UserHandle): T +} + +class UserScopedServiceImpl( + @ApplicationContext private val context: Context, + private val serviceType: KClass, +) : UserScopedService { + override fun forUser(user: UserHandle): T { + val context = + if (context.user == user) { + context + } else { + context.createContextAsUser(user, 0) + } + return requireNotNull(context.getSystemService(serviceType.java)) + } +} diff --git a/java/src/com/android/intentresolver/domain/interactor/UserInteractor.kt b/java/src/com/android/intentresolver/domain/interactor/UserInteractor.kt new file mode 100644 index 00000000..2392a48d --- /dev/null +++ b/java/src/com/android/intentresolver/domain/interactor/UserInteractor.kt @@ -0,0 +1,92 @@ +/* + * Copyright (C) 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.intentresolver.domain.interactor + +import android.os.UserHandle +import com.android.intentresolver.data.repository.UserRepository +import com.android.intentresolver.inject.ApplicationUser +import com.android.intentresolver.shared.model.Profile +import com.android.intentresolver.shared.model.Profile.Type +import com.android.intentresolver.shared.model.User +import com.android.intentresolver.shared.model.User.Role +import javax.inject.Inject +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.map + +/** The high level User interface. */ +class UserInteractor +@Inject +constructor( + private val userRepository: UserRepository, + /** The specific [User] of the application which started this one. */ + @ApplicationUser val launchedAs: UserHandle, +) { + /** The profile group associated with the launching app user. */ + val profiles: Flow> = + userRepository.users.map { users -> + users.mapNotNull { user -> + when (user.role) { + // PERSONAL includes CLONE + Role.PERSONAL -> { + Profile(Type.PERSONAL, user, users.firstOrNull { it.role == Role.CLONE }) + } + Role.CLONE -> { + /* ignore, included above */ + null + } + // others map 1:1 + else -> Profile(profileFromRole(user.role), user) + } + } + } + + /** The [Profile] of the application which started this one. */ + val launchedAsProfile: Flow = + profiles.map { profiles -> + // The launching user profile is the one with a primary id or clone id + // matching the application user id. By definition there must always be exactly + // one matching profile for the current user. + profiles.single { + it.primary.id == launchedAs.identifier || it.clone?.id == launchedAs.identifier + } + } + /** + * Provides a flow to report on the availability of profile. An unavailable profile may be + * hidden or appear disabled within the app. + */ + val availability: Flow> = + combine(profiles, userRepository.availability) { profiles, availability -> + profiles.associateWith { availability.getOrDefault(it.primary, false) } + } + + /** + * Request the profile state be updated. In the case of enabling, the operation could take + * significant time and/or require user input. + */ + suspend fun updateState(profile: Profile, available: Boolean) { + userRepository.requestState(profile.primary, available) + } + + private fun profileFromRole(role: Role): Type = + when (role) { + Role.PERSONAL -> Type.PERSONAL + Role.CLONE -> Type.PERSONAL /* CLONE maps to PERSONAL */ + Role.PRIVATE -> Type.PRIVATE + Role.WORK -> Type.WORK + } +} diff --git a/java/src/com/android/intentresolver/emptystate/EmptyStateUiHelper.java b/java/src/com/android/intentresolver/emptystate/EmptyStateUiHelper.java index d7ef8c75..7524f343 100644 --- a/java/src/com/android/intentresolver/emptystate/EmptyStateUiHelper.java +++ b/java/src/com/android/intentresolver/emptystate/EmptyStateUiHelper.java @@ -17,47 +17,120 @@ package com.android.intentresolver.emptystate; import android.view.View; import android.view.ViewGroup; +import android.widget.Button; +import android.widget.TextView; + +import java.util.Optional; +import java.util.function.Supplier; /** * Helper for building `MultiProfilePagerAdapter` tab UIs for profile tabs that are "blocked" by * some empty-state status. */ public class EmptyStateUiHelper { + private final Supplier> mContainerBottomPaddingOverrideSupplier; private final View mEmptyStateView; + private final View mListView; + private final View mEmptyStateContainerView; + private final TextView mEmptyStateTitleView; + private final TextView mEmptyStateSubtitleView; + private final Button mEmptyStateButtonView; + private final View mEmptyStateProgressView; + private final View mEmptyStateEmptyView; - public EmptyStateUiHelper(ViewGroup rootView) { + public EmptyStateUiHelper( + ViewGroup rootView, + int listViewResourceId, + Supplier> containerBottomPaddingOverrideSupplier) { + mContainerBottomPaddingOverrideSupplier = containerBottomPaddingOverrideSupplier; mEmptyStateView = rootView.requireViewById(com.android.internal.R.id.resolver_empty_state); + mListView = rootView.requireViewById(listViewResourceId); + mEmptyStateContainerView = mEmptyStateView.requireViewById( + com.android.internal.R.id.resolver_empty_state_container); + mEmptyStateTitleView = mEmptyStateView.requireViewById( + com.android.internal.R.id.resolver_empty_state_title); + mEmptyStateSubtitleView = mEmptyStateView.requireViewById( + com.android.internal.R.id.resolver_empty_state_subtitle); + mEmptyStateButtonView = mEmptyStateView.requireViewById( + com.android.internal.R.id.resolver_empty_state_button); + mEmptyStateProgressView = mEmptyStateView.requireViewById( + com.android.internal.R.id.resolver_empty_state_progress); + mEmptyStateEmptyView = mEmptyStateView.requireViewById(com.android.internal.R.id.empty); } - public void resetViewVisibilities() { - mEmptyStateView.requireViewById(com.android.internal.R.id.resolver_empty_state_title) - .setVisibility(View.VISIBLE); - mEmptyStateView.requireViewById(com.android.internal.R.id.resolver_empty_state_subtitle) - .setVisibility(View.VISIBLE); - mEmptyStateView.requireViewById(com.android.internal.R.id.resolver_empty_state_button) - .setVisibility(View.INVISIBLE); - mEmptyStateView.requireViewById(com.android.internal.R.id.resolver_empty_state_progress) - .setVisibility(View.GONE); - mEmptyStateView.requireViewById(com.android.internal.R.id.empty) - .setVisibility(View.GONE); - mEmptyStateView.setVisibility(View.VISIBLE); + /** + * Display the described empty state. + * @param emptyState the data describing the cause of this empty-state condition. + * @param buttonOnClick handler for a button that the user might be able to use to circumvent + * the empty-state condition. If null, no button will be displayed. + */ + public void showEmptyState(EmptyState emptyState, View.OnClickListener buttonOnClick) { + resetViewVisibilities(); + setupContainerPadding(); + + String title = emptyState.getTitle(); + if (title != null) { + mEmptyStateTitleView.setVisibility(View.VISIBLE); + mEmptyStateTitleView.setText(title); + } else { + mEmptyStateTitleView.setVisibility(View.GONE); + } + + String subtitle = emptyState.getSubtitle(); + if (subtitle != null) { + mEmptyStateSubtitleView.setVisibility(View.VISIBLE); + mEmptyStateSubtitleView.setText(subtitle); + } else { + mEmptyStateSubtitleView.setVisibility(View.GONE); + } + + mEmptyStateEmptyView.setVisibility( + emptyState.useDefaultEmptyView() ? View.VISIBLE : View.GONE); + // TODO: The EmptyState API says that if `useDefaultEmptyView()` is true, we'll ignore the + // state's specified title/subtitle; where (if anywhere) is that implemented? + + mEmptyStateButtonView.setVisibility(buttonOnClick != null ? View.VISIBLE : View.GONE); + mEmptyStateButtonView.setOnClickListener(buttonOnClick); + + // Don't show the main list view when we're showing an empty state. + mListView.setVisibility(View.GONE); + } + + /** Sets up the padding of the view containing the empty state screens. */ + public void setupContainerPadding() { + Optional bottomPaddingOverride = mContainerBottomPaddingOverrideSupplier.get(); + bottomPaddingOverride.ifPresent(paddingBottom -> + mEmptyStateContainerView.setPadding( + mEmptyStateContainerView.getPaddingLeft(), + mEmptyStateContainerView.getPaddingTop(), + mEmptyStateContainerView.getPaddingRight(), + paddingBottom)); } public void showSpinner() { - mEmptyStateView.requireViewById(com.android.internal.R.id.resolver_empty_state_title) - .setVisibility(View.INVISIBLE); + mEmptyStateTitleView.setVisibility(View.INVISIBLE); // TODO: subtitle? - mEmptyStateView.requireViewById(com.android.internal.R.id.resolver_empty_state_button) - .setVisibility(View.INVISIBLE); - mEmptyStateView.requireViewById(com.android.internal.R.id.resolver_empty_state_progress) - .setVisibility(View.VISIBLE); - mEmptyStateView.requireViewById(com.android.internal.R.id.empty) - .setVisibility(View.GONE); + mEmptyStateButtonView.setVisibility(View.INVISIBLE); + mEmptyStateProgressView.setVisibility(View.VISIBLE); + mEmptyStateEmptyView.setVisibility(View.GONE); } public void hide() { mEmptyStateView.setVisibility(View.GONE); + mListView.setVisibility(View.VISIBLE); } -} + // TODO: this is exposed for testing so we can thoroughly prepare initial conditions that let us + // observe the resulting change. In reality it's only invoked as part of `showEmptyState()` and + // we could consider setting up narrower "realistic" preconditions to make assertions about the + // higher-level operation. + public void resetViewVisibilities() { + mEmptyStateTitleView.setVisibility(View.VISIBLE); + mEmptyStateSubtitleView.setVisibility(View.VISIBLE); + mEmptyStateButtonView.setVisibility(View.INVISIBLE); + mEmptyStateProgressView.setVisibility(View.GONE); + mEmptyStateEmptyView.setVisibility(View.GONE); + mEmptyStateView.setVisibility(View.VISIBLE); + } +} diff --git a/java/src/com/android/intentresolver/emptystate/NoAppsAvailableEmptyStateProvider.java b/java/src/com/android/intentresolver/emptystate/NoAppsAvailableEmptyStateProvider.java index 5f10cf32..7bfea4f8 100644 --- a/java/src/com/android/intentresolver/emptystate/NoAppsAvailableEmptyStateProvider.java +++ b/java/src/com/android/intentresolver/emptystate/NoAppsAvailableEmptyStateProvider.java @@ -52,11 +52,9 @@ public class NoAppsAvailableEmptyStateProvider implements EmptyStateProvider { @NonNull private final UserHandle mTabOwnerUserHandleForLaunch; - public NoAppsAvailableEmptyStateProvider( - @NonNull Context context, + public NoAppsAvailableEmptyStateProvider(@NonNull Context context, @Nullable UserHandle workProfileUserHandle, - @Nullable UserHandle personalProfileUserHandle, - @NonNull String metricsCategory, + @Nullable UserHandle personalProfileUserHandle, @NonNull String metricsCategory, @NonNull UserHandle tabOwnerUserHandleForLaunch) { mContext = context; mWorkProfileUserHandle = workProfileUserHandle; @@ -125,22 +123,21 @@ public class NoAppsAvailableEmptyStateProvider implements EmptyStateProvider { public static class NoAppsAvailableEmptyState implements EmptyState { @NonNull - private String mTitle; + private final String mTitle; @NonNull - private String mMetricsCategory; + private final String mMetricsCategory; - private boolean mIsPersonalProfile; + private final boolean mIsPersonalProfile; - public NoAppsAvailableEmptyState(@NonNull String title, - @NonNull String metricsCategory, - boolean isPersonalProfile) { + public NoAppsAvailableEmptyState(@NonNull String title, @NonNull String metricsCategory, + boolean isPersonalProfile) { mTitle = title; mMetricsCategory = metricsCategory; mIsPersonalProfile = isPersonalProfile; } - @Nullable + @NonNull @Override public String getTitle() { return mTitle; diff --git a/java/src/com/android/intentresolver/emptystate/NoCrossProfileEmptyStateProvider.java b/java/src/com/android/intentresolver/emptystate/NoCrossProfileEmptyStateProvider.java index ce7bd8d9..e6d5d1c4 100644 --- a/java/src/com/android/intentresolver/emptystate/NoCrossProfileEmptyStateProvider.java +++ b/java/src/com/android/intentresolver/emptystate/NoCrossProfileEmptyStateProvider.java @@ -19,13 +19,19 @@ package com.android.intentresolver.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; import androidx.annotation.Nullable; import androidx.annotation.StringRes; +import com.android.intentresolver.ProfileHelper; import com.android.intentresolver.ResolverListAdapter; +import com.android.intentresolver.shared.model.Profile; +import com.android.intentresolver.shared.model.User; + +import java.util.List; /** * Empty state provider that does not allow cross profile sharing, it will return a blocker @@ -33,45 +39,56 @@ import com.android.intentresolver.ResolverListAdapter; */ 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 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 @@ -91,14 +108,10 @@ public class NoCrossProfileEmptyStateProvider implements EmptyStateProvider { @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) { + 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; diff --git a/java/src/com/android/intentresolver/emptystate/WorkProfilePausedEmptyStateProvider.java b/java/src/com/android/intentresolver/emptystate/WorkProfilePausedEmptyStateProvider.java index 612828e0..cef88ce3 100644 --- a/java/src/com/android/intentresolver/emptystate/WorkProfilePausedEmptyStateProvider.java +++ b/java/src/com/android/intentresolver/emptystate/WorkProfilePausedEmptyStateProvider.java @@ -18,6 +18,8 @@ package com.android.intentresolver.emptystate; import static android.app.admin.DevicePolicyResources.Strings.Core.RESOLVER_WORK_PAUSED_TITLE; +import static java.util.Objects.requireNonNull; + import android.app.admin.DevicePolicyEventLogger; import android.app.admin.DevicePolicyManager; import android.content.Context; @@ -27,10 +29,12 @@ import android.stats.devicepolicy.nano.DevicePolicyEnums; import androidx.annotation.NonNull; import androidx.annotation.Nullable; -import com.android.intentresolver.MultiProfilePagerAdapter.OnSwitchOnWorkSelectedListener; +import com.android.intentresolver.ProfileAvailability; +import com.android.intentresolver.ProfileHelper; import com.android.intentresolver.R; import com.android.intentresolver.ResolverListAdapter; -import com.android.intentresolver.WorkProfileAvailabilityManager; +import com.android.intentresolver.profiles.OnSwitchOnWorkSelectedListener; +import com.android.intentresolver.shared.model.Profile; /** * Chooser/ResolverActivity empty state provider that returns empty state which is shown when @@ -38,20 +42,20 @@ import com.android.intentresolver.WorkProfileAvailabilityManager; */ 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; } @@ -59,22 +63,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/ext/CreationExtrasExt.kt b/java/src/com/android/intentresolver/ext/CreationExtrasExt.kt new file mode 100644 index 00000000..2ba08c90 --- /dev/null +++ b/java/src/com/android/intentresolver/ext/CreationExtrasExt.kt @@ -0,0 +1,34 @@ +/* + * Copyright (C) 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.intentresolver.ext + +import android.os.Bundle +import android.os.Parcelable +import androidx.core.os.bundleOf +import androidx.lifecycle.DEFAULT_ARGS_KEY +import androidx.lifecycle.viewmodel.CreationExtras +import androidx.lifecycle.viewmodel.MutableCreationExtras + +/** + * Returns a new instance with additional [values] added to the existing default args Bundle (if + * present), otherwise adds a new entry with a copy of this bundle. + */ +fun CreationExtras.addDefaultArgs(vararg values: Pair): CreationExtras { + val defaultArgs: Bundle = get(DEFAULT_ARGS_KEY) ?: Bundle() + defaultArgs.putAll(bundleOf(*values)) + return MutableCreationExtras(this).apply { set(DEFAULT_ARGS_KEY, defaultArgs) } +} diff --git a/java/src/com/android/intentresolver/ext/IntentExt.kt b/java/src/com/android/intentresolver/ext/IntentExt.kt new file mode 100644 index 00000000..127dbf86 --- /dev/null +++ b/java/src/com/android/intentresolver/ext/IntentExt.kt @@ -0,0 +1,45 @@ +/* + * Copyright (C) 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.android.intentresolver.ext + +import android.content.Intent +import java.util.function.Predicate + +/** Applies an operation on this Intent if matches the given filter. */ +inline fun Intent.ifMatch( + predicate: Predicate, + crossinline block: Intent.() -> Unit +): Intent { + if (predicate.test(this)) { + apply(block) + } + return this +} + +/** True if the Intent has one of the specified actions. */ +fun Intent.hasAction(vararg actions: String): Boolean = action in actions + +/** True if the Intent has a specific component target */ +fun Intent.hasComponent(): Boolean = (component != null) + +/** True if the Intent has a single matching category. */ +fun Intent.hasSingleCategory(category: String) = categories.singleOrNull() == category + +/** True if the Intent is a SEND or SEND_MULTIPLE action. */ +fun Intent.hasSendAction() = hasAction(Intent.ACTION_SEND, Intent.ACTION_SEND_MULTIPLE) + +/** True if the Intent resolves to the special Home (Launcher) component */ +fun Intent.isHomeIntent() = hasAction(Intent.ACTION_MAIN) && hasSingleCategory(Intent.CATEGORY_HOME) diff --git a/java/src/com/android/intentresolver/ext/ParcelExt.kt b/java/src/com/android/intentresolver/ext/ParcelExt.kt new file mode 100644 index 00000000..68ea600f --- /dev/null +++ b/java/src/com/android/intentresolver/ext/ParcelExt.kt @@ -0,0 +1,27 @@ +/* + * Copyright (C) 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.intentresolver.ext + +import android.os.Parcel + +inline fun Parcel.requireParcelable(): T { + return requireNotNull(readParcelable()) { "A non-value required from this parcel was null!" } +} + +inline fun Parcel.readParcelable(): T? { + return readParcelable(T::class.java.classLoader, T::class.java) +} diff --git a/java/src/com/android/intentresolver/icons/TargetDataLoaderModule.kt b/java/src/com/android/intentresolver/icons/TargetDataLoaderModule.kt new file mode 100644 index 00000000..32c040b8 --- /dev/null +++ b/java/src/com/android/intentresolver/icons/TargetDataLoaderModule.kt @@ -0,0 +1,38 @@ +/* + * Copyright (C) 2023 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.intentresolver.icons + +import android.content.Context +import androidx.lifecycle.Lifecycle +import com.android.intentresolver.inject.ActivityOwned +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.android.components.ActivityComponent +import dagger.hilt.android.qualifiers.ActivityContext +import dagger.hilt.android.scopes.ActivityScoped + +@Module +@InstallIn(ActivityComponent::class) +object TargetDataLoaderModule { + @Provides + @ActivityScoped + fun targetDataLoader( + @ActivityContext context: Context, + @ActivityOwned lifecycle: Lifecycle, + ): TargetDataLoader = DefaultTargetDataLoader(context, lifecycle, isAudioCaptureDevice = false) +} diff --git a/java/src/com/android/intentresolver/inject/ActivityModelModule.kt b/java/src/com/android/intentresolver/inject/ActivityModelModule.kt index ff2bb14b..bbd25eb7 100644 --- a/java/src/com/android/intentresolver/inject/ActivityModelModule.kt +++ b/java/src/com/android/intentresolver/inject/ActivityModelModule.kt @@ -20,12 +20,12 @@ import android.content.Intent import android.net.Uri import android.service.chooser.ChooserAction import androidx.lifecycle.SavedStateHandle +import com.android.intentresolver.data.model.ChooserRequest +import com.android.intentresolver.ui.model.ActivityModel +import com.android.intentresolver.ui.viewmodel.readChooserRequest import com.android.intentresolver.util.ownedByCurrentUser -import com.android.intentresolver.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 com.android.intentresolver.validation.Valid +import com.android.intentresolver.validation.ValidationResult import dagger.Module import dagger.Provides import dagger.hilt.InstallIn diff --git a/java/src/com/android/intentresolver/inject/SystemServices.kt b/java/src/com/android/intentresolver/inject/SystemServices.kt index c09598e0..2a123dc7 100644 --- a/java/src/com/android/intentresolver/inject/SystemServices.kt +++ b/java/src/com/android/intentresolver/inject/SystemServices.kt @@ -27,8 +27,8 @@ 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 com.android.intentresolver.data.repository.UserScopedService +import com.android.intentresolver.data.repository.UserScopedServiceImpl import dagger.Binds import dagger.Module import dagger.Provides diff --git a/java/src/com/android/intentresolver/model/AbstractResolverComparator.java b/java/src/com/android/intentresolver/model/AbstractResolverComparator.java index 724fa849..4871ef4d 100644 --- a/java/src/com/android/intentresolver/model/AbstractResolverComparator.java +++ b/java/src/com/android/intentresolver/model/AbstractResolverComparator.java @@ -20,6 +20,7 @@ import android.app.usage.UsageStatsManager; import android.content.ComponentName; import android.content.Context; import android.content.Intent; +import android.content.IntentFilter; import android.content.pm.PackageManager; import android.content.pm.ResolveInfo; import android.os.BadParcelableException; @@ -37,7 +38,6 @@ import com.android.intentresolver.ResolverListController; import com.android.intentresolver.chooser.TargetInfo; import com.android.intentresolver.logging.EventLog; -import java.text.Collator; import java.util.ArrayList; import java.util.Comparator; import java.util.HashMap; @@ -135,7 +135,7 @@ public abstract class AbstractResolverComparator implements Comparator= IntentFilter.MATCH_CATEGORY_HOST + && match <= IntentFilter.MATCH_CATEGORY_PATH; + } + /** * Delegated to when used as a {@link Comparator} if there is not a * special case. The {@link ResolveInfo ResolveInfos} are the first {@link ResolveInfo} in @@ -306,24 +313,4 @@ public abstract class AbstractResolverComparator implements Comparator { - Collator mCollator; - AzInfoComparator(Context context) { - mCollator = Collator.getInstance(context.getResources().getConfiguration().locale); - } - - @Override - public int compare(ResolveInfo lhsp, ResolveInfo rhsp) { - if (lhsp == null) { - return -1; - } else if (rhsp == null) { - return 1; - } - return mCollator.compare(lhsp.activityInfo.packageName, rhsp.activityInfo.packageName); - } - } - } diff --git a/java/src/com/android/intentresolver/model/ResolveInfoAzInfoComparator.java b/java/src/com/android/intentresolver/model/ResolveInfoAzInfoComparator.java new file mode 100644 index 00000000..411d0c6e --- /dev/null +++ b/java/src/com/android/intentresolver/model/ResolveInfoAzInfoComparator.java @@ -0,0 +1,44 @@ +/* + * Copyright (C) 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.intentresolver.model; + +import android.content.Context; +import android.content.pm.ResolveInfo; + +import java.text.Collator; +import java.util.Comparator; + +/** + * Sort intents alphabetically based on package name. + */ +public class ResolveInfoAzInfoComparator implements Comparator { + Collator mCollator; + + public ResolveInfoAzInfoComparator(Context context) { + mCollator = Collator.getInstance(context.getResources().getConfiguration().locale); + } + + @Override + public int compare(ResolveInfo lhsp, ResolveInfo rhsp) { + if (lhsp == null) { + return -1; + } else if (rhsp == null) { + return 1; + } + return mCollator.compare(lhsp.activityInfo.packageName, rhsp.activityInfo.packageName); + } +} diff --git a/java/src/com/android/intentresolver/platform/AppPredictionModule.kt b/java/src/com/android/intentresolver/platform/AppPredictionModule.kt new file mode 100644 index 00000000..415d5f7d --- /dev/null +++ b/java/src/com/android/intentresolver/platform/AppPredictionModule.kt @@ -0,0 +1,42 @@ +/* + * Copyright (C) 2023 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.android.intentresolver.platform + +import android.content.pm.PackageManager +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import javax.inject.Qualifier +import javax.inject.Singleton + +@Qualifier +@MustBeDocumented +@Retention(AnnotationRetention.RUNTIME) +annotation class AppPredictionAvailable + +@Module +@InstallIn(SingletonComponent::class) +object AppPredictionModule { + + /** Eventually replaced with: Optional, etc. */ + @Provides + @Singleton + @AppPredictionAvailable + fun isAppPredictionAvailable(packageManager: PackageManager): Boolean { + return packageManager.appPredictionServicePackageName != null + } +} diff --git a/java/src/com/android/intentresolver/platform/ImageEditorModule.kt b/java/src/com/android/intentresolver/platform/ImageEditorModule.kt new file mode 100644 index 00000000..54b93939 --- /dev/null +++ b/java/src/com/android/intentresolver/platform/ImageEditorModule.kt @@ -0,0 +1,35 @@ +package com.android.intentresolver.platform + +import android.content.ComponentName +import android.content.res.Resources +import androidx.annotation.StringRes +import com.android.intentresolver.R +import com.android.intentresolver.inject.ApplicationOwned +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import java.util.Optional +import javax.inject.Qualifier +import javax.inject.Singleton + +internal fun Resources.componentName(@StringRes resId: Int): ComponentName? { + check(getResourceTypeName(resId) == "string") { "resId must be a string" } + return ComponentName.unflattenFromString(getString(resId)) +} + +@Qualifier @MustBeDocumented @Retention(AnnotationRetention.RUNTIME) annotation class ImageEditor + +@Module +@InstallIn(SingletonComponent::class) +object ImageEditorModule { + /** + * The name of the preferred Activity to launch for editing images. This is added to Intents to + * edit images using Intent.ACTION_EDIT. + */ + @Provides + @Singleton + @ImageEditor + fun imageEditorComponent(@ApplicationOwned resources: Resources) = + Optional.ofNullable(resources.componentName(R.string.config_systemImageEditor)) +} diff --git a/java/src/com/android/intentresolver/platform/NearbyShareModule.kt b/java/src/com/android/intentresolver/platform/NearbyShareModule.kt new file mode 100644 index 00000000..4eaa24c0 --- /dev/null +++ b/java/src/com/android/intentresolver/platform/NearbyShareModule.kt @@ -0,0 +1,32 @@ +package com.android.intentresolver.platform + +import android.content.ComponentName +import android.content.res.Resources +import android.provider.Settings.Secure.NEARBY_SHARING_COMPONENT +import com.android.intentresolver.R +import com.android.intentresolver.inject.ApplicationOwned +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import java.util.Optional +import javax.inject.Qualifier +import javax.inject.Singleton + +@Qualifier @MustBeDocumented @Retention(AnnotationRetention.RUNTIME) annotation class NearbyShare + +@Module +@InstallIn(SingletonComponent::class) +object NearbyShareModule { + + @Provides + @Singleton + @NearbyShare + fun nearbyShareComponent(@ApplicationOwned resources: Resources, settings: SecureSettings) = + Optional.ofNullable( + ComponentName.unflattenFromString( + settings.getString(NEARBY_SHARING_COMPONENT)?.ifEmpty { null } + ?: resources.getString(R.string.config_defaultNearbySharingComponent), + ) + ) +} diff --git a/java/src/com/android/intentresolver/platform/PlatformSecureSettings.kt b/java/src/com/android/intentresolver/platform/PlatformSecureSettings.kt new file mode 100644 index 00000000..d2319873 --- /dev/null +++ b/java/src/com/android/intentresolver/platform/PlatformSecureSettings.kt @@ -0,0 +1,30 @@ +package com.android.intentresolver.platform + +import android.content.ContentResolver +import android.provider.Settings +import javax.inject.Inject + +/** + * Implements [SecureSettings] backed by Settings.Secure and a ContentResolver. + * + * These methods make Binder calls and may block, so use on the Main thread should be avoided. + */ +class PlatformSecureSettings @Inject constructor(private val resolver: ContentResolver) : + SecureSettings { + + override fun getString(name: String): String? { + return Settings.Secure.getString(resolver, name) + } + + override fun getInt(name: String): Int? { + return runCatching { Settings.Secure.getInt(resolver, name) }.getOrNull() + } + + override fun getLong(name: String): Long? { + return runCatching { Settings.Secure.getLong(resolver, name) }.getOrNull() + } + + override fun getFloat(name: String): Float? { + return runCatching { Settings.Secure.getFloat(resolver, name) }.getOrNull() + } +} diff --git a/java/src/com/android/intentresolver/platform/SecureSettings.kt b/java/src/com/android/intentresolver/platform/SecureSettings.kt new file mode 100644 index 00000000..86fc8e98 --- /dev/null +++ b/java/src/com/android/intentresolver/platform/SecureSettings.kt @@ -0,0 +1,25 @@ +package com.android.intentresolver.platform + +import android.provider.Settings.SettingNotFoundException + +/** + * A component which provides access to values from [android.provider.Settings.Secure]. + * + * All methods return nullable types instead of throwing [SettingNotFoundException] which yields + * cleaner, more idiomatic Kotlin code: + * + * // apply a default: val foo = settings.getInt(FOO) ?: DEFAULT_FOO + * + * // assert if missing: val required = settings.getInt(REQUIRED_VALUE) ?: error("required value + * missing") + */ +interface SecureSettings { + + fun getString(name: String): String? + + fun getInt(name: String): Int? + + fun getLong(name: String): Long? + + fun getFloat(name: String): Float? +} diff --git a/java/src/com/android/intentresolver/platform/SecureSettingsModule.kt b/java/src/com/android/intentresolver/platform/SecureSettingsModule.kt new file mode 100644 index 00000000..260e50a1 --- /dev/null +++ b/java/src/com/android/intentresolver/platform/SecureSettingsModule.kt @@ -0,0 +1,14 @@ +package com.android.intentresolver.platform + +import dagger.Binds +import dagger.Module +import dagger.Reusable +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent + +@Module +@InstallIn(SingletonComponent::class) +interface SecureSettingsModule { + + @Binds @Reusable fun secureSettings(settings: PlatformSecureSettings): SecureSettings +} diff --git a/java/src/com/android/intentresolver/profiles/AdapterBinder.java b/java/src/com/android/intentresolver/profiles/AdapterBinder.java new file mode 100644 index 00000000..f92a140f --- /dev/null +++ b/java/src/com/android/intentresolver/profiles/AdapterBinder.java @@ -0,0 +1,31 @@ +/* + * Copyright (C) 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.intentresolver.profiles; + +/** + * Delegate to set up a given adapter and page view to be used together. + * + * @param (as in {@link MultiProfilePagerAdapter}). + * @param (as in {@link MultiProfilePagerAdapter}). + */ +public interface AdapterBinder { + /** + * The given {@code view} will be associated with the given {@code adapter}. Do any work + * necessary to configure them compatibly, introduce them to each other, etc. + */ + void bind(PageViewT view, SinglePageAdapterT adapter); +} diff --git a/java/src/com/android/intentresolver/profiles/ChooserMultiProfilePagerAdapter.java b/java/src/com/android/intentresolver/profiles/ChooserMultiProfilePagerAdapter.java new file mode 100644 index 00000000..4d0f4a49 --- /dev/null +++ b/java/src/com/android/intentresolver/profiles/ChooserMultiProfilePagerAdapter.java @@ -0,0 +1,212 @@ +/* + * Copyright (C) 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.intentresolver.profiles; + +import android.content.Context; +import android.os.UserHandle; +import android.view.LayoutInflater; +import android.view.ViewGroup; + +import androidx.recyclerview.widget.GridLayoutManager; +import androidx.recyclerview.widget.RecyclerView; +import androidx.viewpager.widget.PagerAdapter; + +import com.android.intentresolver.ChooserListAdapter; +import com.android.intentresolver.ChooserRecyclerViewAccessibilityDelegate; +import com.android.intentresolver.FeatureFlags; +import com.android.intentresolver.R; +import com.android.intentresolver.emptystate.EmptyStateProvider; +import com.android.intentresolver.grid.ChooserGridAdapter; +import com.android.intentresolver.measurements.Tracer; + +import com.google.common.collect.ImmutableList; + +import java.util.Optional; +import java.util.function.Supplier; + +/** + * A {@link PagerAdapter} which describes the work and personal profile share sheet screens. + */ +public class ChooserMultiProfilePagerAdapter extends MultiProfilePagerAdapter< + RecyclerView, ChooserGridAdapter, ChooserListAdapter> { + private static final int SINGLE_CELL_SPAN_SIZE = 1; + + private final ChooserProfileAdapterBinder mAdapterBinder; + private final BottomPaddingOverrideSupplier mBottomPaddingOverrideSupplier; + + public ChooserMultiProfilePagerAdapter( + Context context, + ImmutableList> tabs, + EmptyStateProvider emptyStateProvider, + Supplier workProfileQuietModeChecker, + @ProfileType int defaultProfile, + UserHandle workProfileUserHandle, + UserHandle cloneProfileUserHandle, + int maxTargetsPerRow, + FeatureFlags featureFlags) { + this( + context, + new ChooserProfileAdapterBinder(maxTargetsPerRow), + tabs, + emptyStateProvider, + workProfileQuietModeChecker, + defaultProfile, + workProfileUserHandle, + cloneProfileUserHandle, + new BottomPaddingOverrideSupplier(context), + featureFlags); + } + + private ChooserMultiProfilePagerAdapter( + Context context, + ChooserProfileAdapterBinder adapterBinder, + ImmutableList> tabs, + EmptyStateProvider emptyStateProvider, + Supplier workProfileQuietModeChecker, + @ProfileType int defaultProfile, + UserHandle workProfileUserHandle, + UserHandle cloneProfileUserHandle, + BottomPaddingOverrideSupplier bottomPaddingOverrideSupplier, + FeatureFlags featureFlags) { + super( + gridAdapter -> gridAdapter.getListAdapter(), + adapterBinder, + tabs, + emptyStateProvider, + workProfileQuietModeChecker, + defaultProfile, + workProfileUserHandle, + cloneProfileUserHandle, + () -> makeProfileView(context, featureFlags), + bottomPaddingOverrideSupplier); + mAdapterBinder = adapterBinder; + mBottomPaddingOverrideSupplier = bottomPaddingOverrideSupplier; + } + + public void setMaxTargetsPerRow(int maxTargetsPerRow) { + mAdapterBinder.setMaxTargetsPerRow(maxTargetsPerRow); + } + + public void setEmptyStateBottomOffset(int bottomOffset) { + mBottomPaddingOverrideSupplier.setEmptyStateBottomOffset(bottomOffset); + setupContainerPadding(); + } + + /** + * Notify adapter about the drawer's collapse state. This will affect the app divider's + * visibility. + */ + public void setIsCollapsed(boolean isCollapsed) { + for (int i = 0, size = getItemCount(); i < size; i++) { + getPageAdapterForIndex(i).setAzLabelVisibility(!isCollapsed); + } + } + + private static ViewGroup makeProfileView( + Context context, FeatureFlags featureFlags) { + LayoutInflater inflater = LayoutInflater.from(context); + ViewGroup rootView = featureFlags.scrollablePreview() + ? (ViewGroup) inflater.inflate(R.layout.chooser_list_per_profile_wrap, null, false) + : (ViewGroup) inflater.inflate(R.layout.chooser_list_per_profile, null, false); + RecyclerView recyclerView = rootView.findViewById(com.android.internal.R.id.resolver_list); + recyclerView.setAccessibilityDelegateCompat( + new ChooserRecyclerViewAccessibilityDelegate(recyclerView)); + return rootView; + } + + @Override + public boolean onHandlePackagesChanged( + ChooserListAdapter listAdapter, boolean waitingToEnableWorkProfile) { + // TODO: why do we need to do the extra `notifyDataSetChanged()` in (only) the Chooser case? + getActiveListAdapter().notifyDataSetChanged(); + return super.onHandlePackagesChanged(listAdapter, waitingToEnableWorkProfile); + } + + @Override + protected final boolean rebuildTab(ChooserListAdapter listAdapter, boolean doPostProcessing) { + if (doPostProcessing) { + Tracer.INSTANCE.beginAppTargetLoadingSection(listAdapter.getUserHandle()); + } + return super.rebuildTab(listAdapter, doPostProcessing); + } + + /** Apply the specified {@code height} as the footer in each tab's adapter. */ + public void setFooterHeightInEveryAdapter(int height) { + for (int i = 0; i < getItemCount(); ++i) { + getPageAdapterForIndex(i).setFooterHeight(height); + } + } + + /** Cleanup system resources */ + public void destroy() { + for (int i = 0, count = getItemCount(); i < count; i++) { + ChooserGridAdapter adapter = getPageAdapterForIndex(i); + if (adapter != null) { + adapter.getListAdapter().onDestroy(); + } + } + } + + private static class BottomPaddingOverrideSupplier implements Supplier> { + private final Context mContext; + private int mBottomOffset; + + BottomPaddingOverrideSupplier(Context context) { + mContext = context; + } + + public void setEmptyStateBottomOffset(int bottomOffset) { + mBottomOffset = bottomOffset; + } + + @Override + public Optional get() { + int initialBottomPadding = mContext.getResources().getDimensionPixelSize( + R.dimen.resolver_empty_state_container_padding_bottom); + return Optional.of(initialBottomPadding + mBottomOffset); + } + } + + private static class ChooserProfileAdapterBinder implements + AdapterBinder { + private int mMaxTargetsPerRow; + + ChooserProfileAdapterBinder(int maxTargetsPerRow) { + mMaxTargetsPerRow = maxTargetsPerRow; + } + + public void setMaxTargetsPerRow(int maxTargetsPerRow) { + mMaxTargetsPerRow = maxTargetsPerRow; + } + + @Override + public void bind( + RecyclerView recyclerView, ChooserGridAdapter chooserGridAdapter) { + GridLayoutManager glm = (GridLayoutManager) recyclerView.getLayoutManager(); + glm.setSpanCount(mMaxTargetsPerRow); + glm.setSpanSizeLookup( + new GridLayoutManager.SpanSizeLookup() { + @Override + public int getSpanSize(int position) { + return chooserGridAdapter.shouldCellSpan(position) + ? SINGLE_CELL_SPAN_SIZE + : glm.getSpanCount(); + } + }); + } + } +} diff --git a/java/src/com/android/intentresolver/profiles/MultiProfilePagerAdapter.java b/java/src/com/android/intentresolver/profiles/MultiProfilePagerAdapter.java new file mode 100644 index 00000000..48de37de --- /dev/null +++ b/java/src/com/android/intentresolver/profiles/MultiProfilePagerAdapter.java @@ -0,0 +1,694 @@ +/* + * Copyright (C) 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.android.intentresolver.profiles; + +import android.annotation.Nullable; +import android.os.Trace; +import android.os.UserHandle; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.Button; +import android.widget.TabHost; +import android.widget.TextView; + +import androidx.viewpager.widget.PagerAdapter; +import androidx.viewpager.widget.ViewPager; + +import com.android.intentresolver.ResolverListAdapter; +import com.android.intentresolver.emptystate.EmptyState; +import com.android.intentresolver.emptystate.EmptyStateProvider; +import com.android.intentresolver.shared.model.Profile; +import com.android.internal.annotations.VisibleForTesting; + +import com.google.common.collect.ImmutableList; + +import java.util.HashSet; +import java.util.Objects; +import java.util.Optional; +import java.util.Set; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.function.Consumer; +import java.util.function.Function; +import java.util.function.Supplier; + +/** + * Skeletal {@link PagerAdapter} implementation for a UI with per-profile tabs (as in Sharesheet). + * + * @param the type of the widget that represents the contents of a page in this adapter + * @param the type of a "root" adapter class to be instantiated and included in + * the per-profile records. + * @param the concrete type of a {@link ResolverListAdapter} implementation to + * control the contents of a given per-profile list. This is provided for convenience, since it must + * be possible to get the list adapter from the page adapter via our + * mListAdapterExtractor. + */ +public class MultiProfilePagerAdapter< + PageViewT extends ViewGroup, + SinglePageAdapterT, + ListAdapterT extends ResolverListAdapter> extends PagerAdapter { + + public static final int PROFILE_PERSONAL = Profile.Type.PERSONAL.ordinal(); + public static final int PROFILE_WORK = Profile.Type.WORK.ordinal(); + + // Removed, must be constants. This is only used for linting anyway. + // @IntDef({PROFILE_PERSONAL, PROFILE_WORK}) + public @interface ProfileType {} + + private final Function mListAdapterExtractor; + private final AdapterBinder mAdapterBinder; + private final Supplier mPageViewInflater; + + private final ImmutableList> mItems; + + private final EmptyStateProvider mEmptyStateProvider; + private final UserHandle mWorkProfileUserHandle; + private final UserHandle mCloneProfileUserHandle; + private final Supplier mWorkProfileQuietModeChecker; // True when work is quiet. + + private final Set mLoadedPages; + private int mCurrentPage; + private OnProfileSelectedListener mOnProfileSelectedListener; + + protected MultiProfilePagerAdapter( + Function listAdapterExtractor, + AdapterBinder adapterBinder, + ImmutableList> tabs, + EmptyStateProvider emptyStateProvider, + Supplier workProfileQuietModeChecker, + @ProfileType int defaultProfile, + UserHandle workProfileUserHandle, + UserHandle cloneProfileUserHandle, + Supplier pageViewInflater, + Supplier> containerBottomPaddingOverrideSupplier) { + mLoadedPages = new HashSet<>(); + mWorkProfileUserHandle = workProfileUserHandle; + mCloneProfileUserHandle = cloneProfileUserHandle; + mEmptyStateProvider = emptyStateProvider; + mWorkProfileQuietModeChecker = workProfileQuietModeChecker; + + mListAdapterExtractor = listAdapterExtractor; + mAdapterBinder = adapterBinder; + mPageViewInflater = pageViewInflater; + + ImmutableList.Builder> items = + new ImmutableList.Builder<>(); + for (TabConfig tab : tabs) { + // TODO: consider representing tabConfig in a different data structure that can ensure + // uniqueness of their profile assignments (while still respecting the client's + // requested tab order). + items.add( + createProfileDescriptor( + tab.mProfile, + tab.mTabLabel, + tab.mTabAccessibilityLabel, + tab.mTabTag, + tab.mPageAdapter, + containerBottomPaddingOverrideSupplier)); + } + mItems = items.build(); + + mCurrentPage = + hasPageForProfile(defaultProfile) ? getPageNumberForProfile(defaultProfile) : 0; + } + + private ProfileDescriptor createProfileDescriptor( + @ProfileType int profile, + String tabLabel, + String tabAccessibilityLabel, + String tabTag, + SinglePageAdapterT adapter, + Supplier> containerBottomPaddingOverrideSupplier) { + return new ProfileDescriptor<>( + profile, + tabLabel, + tabAccessibilityLabel, + tabTag, + mPageViewInflater.get(), + adapter, + containerBottomPaddingOverrideSupplier); + } + + private boolean hasPageForIndex(int pageIndex) { + return (pageIndex >= 0) && (pageIndex < getCount()); + } + + public final boolean hasPageForProfile(@ProfileType int profile) { + return hasPageForIndex(getPageNumberForProfile(profile)); + } + + private @ProfileType int getProfileForPageNumber(int position) { + if (hasPageForIndex(position)) { + return mItems.get(position).mProfile; + } + return -1; + } + + public int getPageNumberForProfile(@ProfileType int profile) { + for (int i = 0; i < mItems.size(); ++i) { + if (profile == mItems.get(i).mProfile) { + return i; + } + } + return -1; + } + + private ListAdapterT getListAdapterForPageNumber(int pageNumber) { + SinglePageAdapterT pageAdapter = getPageAdapterForIndex(pageNumber); + if (pageAdapter == null) { + return null; + } + return mListAdapterExtractor.apply(pageAdapter); + } + + private @ProfileType int getProfileForUserHandle(UserHandle userHandle) { + if (userHandle.equals(getCloneUserHandle())) { + // TODO: can we push this special case elsewhere -- e.g., when we check against each + // list adapter's user handle in the loop below, could we instead ask the list adapter + // whether it "represents" the queried user handle, and have the personal list adapter + // return true because it knows it's also associated with the clone profile? Or if we + // don't want to make modifications to the list adapter, maybe we could at least specify + // it in our per-page configuration data that we use to build our tabs/pages, and then + // maintain the relevant bookkeeping in our own ProfileDescriptor? + return PROFILE_PERSONAL; + } + for (int i = 0; i < mItems.size(); ++i) { + ListAdapterT listAdapter = getListAdapterForPageNumber(i); + if (listAdapter.getUserHandle().equals(userHandle)) { + return mItems.get(i).mProfile; + } + } + return -1; + } + + private int getPageNumberForUserHandle(UserHandle userHandle) { + return getPageNumberForProfile(getProfileForUserHandle(userHandle)); + } + + /** + * Returns the {@link ListAdapterT} instance of the profile that represents + * userHandle. If there is no such adapter for the specified + * userHandle, returns {@code null}. + *

For example, if there is a work profile on the device with user id 10, calling this method + * with UserHandle.of(10) returns the work profile {@link ListAdapterT}. + */ + @Nullable + public final ListAdapterT getListAdapterForUserHandle(UserHandle userHandle) { + return getListAdapterForPageNumber(getPageNumberForUserHandle(userHandle)); + } + + @Nullable + private ProfileDescriptor getDescriptorForUserHandle( + UserHandle userHandle) { + return getItem(getPageNumberForUserHandle(userHandle)); + } + + private int getPageNumberForTabTag(String tag) { + for (int i = 0; i < mItems.size(); ++i) { + if (Objects.equals(mItems.get(i).mTabTag, tag)) { + return i; + } + } + return -1; + } + + private void updateActiveTabStyle(TabHost tabHost) { + int currentTab = tabHost.getCurrentTab(); + + for (int pageNumber = 0; pageNumber < getItemCount(); ++pageNumber) { + // TODO: can we avoid this downcast by pushing our knowledge of the intended view type + // somewhere else? + TextView tabText = (TextView) tabHost.getTabWidget().getChildAt(pageNumber); + tabText.setSelected(currentTab == pageNumber); + } + } + + public void setupProfileTabs( + LayoutInflater layoutInflater, + TabHost tabHost, + ViewPager viewPager, + int tabButtonLayoutResId, + int tabPageContentViewId, + Runnable onTabChangeListener, + OnProfileSelectedListener clientOnProfileSelectedListener) { + tabHost.setup(); + tabHost.getTabWidget().removeAllViews(); + viewPager.setSaveEnabled(false); + + for (int pageNumber = 0; pageNumber < getItemCount(); ++pageNumber) { + ProfileDescriptor descriptor = mItems.get(pageNumber); + Button profileButton = (Button) layoutInflater.inflate( + tabButtonLayoutResId, tabHost.getTabWidget(), false); + profileButton.setText(descriptor.mTabLabel); + profileButton.setContentDescription(descriptor.mTabAccessibilityLabel); + + TabHost.TabSpec profileTabSpec = tabHost.newTabSpec(descriptor.mTabTag) + .setContent(tabPageContentViewId) + .setIndicator(profileButton); + tabHost.addTab(profileTabSpec); + } + + tabHost.getTabWidget().setVisibility(View.VISIBLE); + + updateActiveTabStyle(tabHost); + + tabHost.setOnTabChangedListener(tabTag -> { + updateActiveTabStyle(tabHost); + + int pageNumber = getPageNumberForTabTag(tabTag); + if (pageNumber >= 0) { + viewPager.setCurrentItem(pageNumber); + } + onTabChangeListener.run(); + }); + + viewPager.setVisibility(View.VISIBLE); + tabHost.setCurrentTab(getCurrentPage()); + mOnProfileSelectedListener = + new OnProfileSelectedListener() { + @Override + public void onProfilePageSelected(@ProfileType int profileId, int pageNumber) { + tabHost.setCurrentTab(pageNumber); + clientOnProfileSelectedListener.onProfilePageSelected( + profileId, pageNumber); + } + + @Override + public void onProfilePageStateChanged(int state) { + clientOnProfileSelectedListener.onProfilePageStateChanged(state); + } + }; + } + + /** + * Sets this instance of this class as {@link ViewPager}'s {@link PagerAdapter} and sets + * an {@link ViewPager.OnPageChangeListener} where it keeps track of the currently displayed + * page and rebuilds the list. + */ + public void setupViewPager(ViewPager viewPager) { + viewPager.setOnPageChangeListener(new ViewPager.SimpleOnPageChangeListener() { + @Override + public void onPageSelected(int position) { + mCurrentPage = position; + if (!mLoadedPages.contains(position)) { + rebuildActiveTab(true); + mLoadedPages.add(position); + } + if (mOnProfileSelectedListener != null) { + mOnProfileSelectedListener.onProfilePageSelected( + getProfileForPageNumber(position), position); + } + } + + @Override + public void onPageScrollStateChanged(int state) { + if (mOnProfileSelectedListener != null) { + mOnProfileSelectedListener.onProfilePageStateChanged(state); + } + } + }); + viewPager.setAdapter(this); + viewPager.setCurrentItem(mCurrentPage); + mLoadedPages.add(mCurrentPage); + } + + public void clearInactiveProfileCache() { + forEachInactivePage(pageNumber -> mLoadedPages.remove(pageNumber)); + } + + @Override + public final ViewGroup instantiateItem(ViewGroup container, int position) { + setupListAdapter(position); + final ProfileDescriptor descriptor = getItem(position); + container.addView(descriptor.mRootView); + return descriptor.mRootView; + } + + @Override + public void destroyItem(ViewGroup container, int position, Object view) { + container.removeView((View) view); + } + + @Override + public int getCount() { + return getItemCount(); + } + + public int getCurrentPage() { + return mCurrentPage; + } + + public final @ProfileType int getActiveProfile() { + return getProfileForPageNumber(getCurrentPage()); + } + + @VisibleForTesting + public UserHandle getCurrentUserHandle() { + return getActiveListAdapter().getUserHandle(); + } + + @Override + public boolean isViewFromObject(View view, Object object) { + return view == object; + } + + @Override + public CharSequence getPageTitle(int position) { + return null; + } + + public UserHandle getCloneUserHandle() { + return mCloneProfileUserHandle; + } + + /** + * Returns the {@link ProfileDescriptor} relevant to the given pageIndex. + *

    + *
  • For a device with only one user, pageIndex value of + * 0 would return the personal profile {@link ProfileDescriptor}.
  • + *
  • For a device with a work profile, pageIndex value of 0 would + * return the personal profile {@link ProfileDescriptor}, and pageIndex value of + * 1 would return the work profile {@link ProfileDescriptor}.
  • + *
+ */ + @Nullable + private ProfileDescriptor getItem(int pageIndex) { + if (!hasPageForIndex(pageIndex)) { + return null; + } + return mItems.get(pageIndex); + } + + private ViewGroup getEmptyStateView(int pageIndex) { + return getItem(pageIndex).getEmptyStateView(); + } + + public ViewGroup getActiveEmptyStateView() { + return getEmptyStateView(getCurrentPage()); + } + + /** + * Returns the number of {@link ProfileDescriptor} objects. + *

For a normal consumer device with only one user returns 1. + *

For a device with a work profile returns 2. + */ + public final int getItemCount() { + return mItems.size(); + } + + public final PageViewT getListViewForIndex(int index) { + return getItem(index).getView(); + } + + /** + * Returns the adapter of the list view for the relevant page specified by + * pageIndex. + *

This method is meant to be implemented with an implementation-specific return type + * depending on the adapter type. + */ + @VisibleForTesting + public final SinglePageAdapterT getPageAdapterForIndex(int index) { + if (!hasPageForIndex(index)) { + return null; + } + return getItem(index).getAdapter(); + } + + /** + * Performs view-related initialization procedures for the adapter specified + * by pageIndex. + */ + public final void setupListAdapter(int pageIndex) { + mAdapterBinder.bind(getListViewForIndex(pageIndex), getPageAdapterForIndex(pageIndex)); + } + + /** + * Returns the {@link ListAdapterT} instance of the profile that is currently visible + * to the user. + *

For example, if the user is viewing the work tab in the share sheet, this method returns + * the work profile {@link ListAdapterT}. + */ + @VisibleForTesting + public final ListAdapterT getActiveListAdapter() { + return getListAdapterForPageNumber(getCurrentPage()); + } + + public final ListAdapterT getPersonalListAdapter() { + return getListAdapterForPageNumber(getPageNumberForProfile(PROFILE_PERSONAL)); + } + + @Nullable + public final ListAdapterT getWorkListAdapter() { + if (!hasPageForProfile(PROFILE_WORK)) { + return null; + } + return getListAdapterForPageNumber(getPageNumberForProfile(PROFILE_WORK)); + } + + public final SinglePageAdapterT getCurrentRootAdapter() { + return getPageAdapterForIndex(getCurrentPage()); + } + + public final PageViewT getActiveAdapterView() { + return getListViewForIndex(getCurrentPage()); + } + + private boolean anyAdapterHasItems() { + for (int i = 0; i < mItems.size(); ++i) { + ListAdapterT listAdapter = getListAdapterForPageNumber(i); + if (listAdapter.getCount() > 0) { + return true; + } + } + return false; + } + + public void refreshPackagesInAllTabs() { + // TODO: it's unclear if this legacy logic really requires the active tab to be rebuilt + // first, or if we could just iterate over the tabs in arbitrary order. + getActiveListAdapter().handlePackagesChanged(); + forEachInactivePage(page -> getListAdapterForPageNumber(page).handlePackagesChanged()); + } + + /** + * Notify that there has been a package change which could potentially modify the set of targets + * that should be shown in the specified {@code listAdapter}. This may result in + * "rebuilding" the target list for that adapter. + * + * @param listAdapter an adapter that may need to be updated after the package-change event. + * @param waitingToEnableWorkProfile whether we've turned on the work profile, but haven't yet + * seen an {@code ACTION_USER_UNLOCKED} broadcast. In this case we skip the rebuild of any + * work-profile adapter because we wouldn't expect meaningful results -- but another rebuild + * will be prompted when we eventually get the broadcast. + * + * @return whether we're able to proceed with a Sharesheet session after processing this + * package-change event. If false, we were able to rebuild the targets but determined that there + * aren't any we could present in the UI without the app looking broken, so we should just quit. + */ + public boolean onHandlePackagesChanged( + ListAdapterT listAdapter, boolean waitingToEnableWorkProfile) { + if (listAdapter == getActiveListAdapter()) { + if (listAdapter.getUserHandle().equals(mWorkProfileUserHandle) + && waitingToEnableWorkProfile) { + // We have just turned on the work profile and entered the passcode to start it, + // now we are waiting to receive the ACTION_USER_UNLOCKED broadcast. There is no + // point in reloading the list now, since the work profile user is still turning on. + return true; + } + + boolean listRebuilt = rebuildActiveTab(true); + if (listRebuilt) { + listAdapter.notifyDataSetChanged(); + } + + // TODO: shouldn't we check that the inactive tabs are built before declaring that we + // have to quit for lack of items? + return anyAdapterHasItems(); + } else { + clearInactiveProfileCache(); + return true; + } + } + + /** + * Fully-rebuild the active tab and, if specified, partially-rebuild any other inactive tabs. + */ + public boolean rebuildTabs(boolean includePartialRebuildOfInactiveTabs) { + // TODO: we may be able to determine `includePartialRebuildOfInactiveTabs` ourselves as + // a function of our own instance state. OTOH the purpose of this "partial rebuild" is to + // be able to evaluate the intermediate state of one particular profile tab (i.e. work + // profile) that may not generalize well when we have other "inactive tabs." I.e., either we + // rebuild *all* the inactive tabs just to evaluate some auto-launch conditions that only + // depend on personal and/or work tabs, or we have to explicitly specify the ones we care + // about. It's not the pager-adapter's business to know "which ones we care about," so maybe + // they should be rebuilt lazily when-and-if it comes up (e.g. during the evaluation of + // autolaunch conditions). + boolean rebuildCompleted = rebuildActiveTab(true) || getActiveListAdapter().isTabLoaded(); + if (includePartialRebuildOfInactiveTabs) { + // Per legacy logic, avoid short-circuiting (TODO: why? possibly so that we *start* + // loading the inactive tabs even if we're still waiting on the active tab to finish?). + boolean completedRebuildingInactiveTabs = rebuildInactiveTabs(false); + rebuildCompleted = rebuildCompleted && completedRebuildingInactiveTabs; + } + return rebuildCompleted; + } + + /** + * Rebuilds the tab that is currently visible to the user. + *

Returns {@code true} if rebuild has completed. + */ + public final boolean rebuildActiveTab(boolean doPostProcessing) { + Trace.beginSection("MultiProfilePagerAdapter#rebuildActiveTab"); + boolean result = rebuildTab(getActiveListAdapter(), doPostProcessing); + Trace.endSection(); + return result; + } + + /** + * Rebuilds any tabs that are not currently visible to the user. + *

Returns {@code true} if rebuild has completed in all inactive tabs. + */ + private boolean rebuildInactiveTabs(boolean doPostProcessing) { + Trace.beginSection("MultiProfilePagerAdapter#rebuildInactiveTab"); + AtomicBoolean allRebuildsComplete = new AtomicBoolean(true); + forEachInactivePage(pageNumber -> { + // Evaluate the rebuild for every inactive page, even if we've already seen some adapter + // return an "incomplete" status (i.e., even if `allRebuildsComplete` is already false) + // and so we already know we'll end up returning false for the batch. + // TODO: any particular reason the per-page legacy logic was set up in this order, or + // could we possibly short-circuit the rebuild if the tab is already "loaded"? + ListAdapterT inactiveAdapter = getListAdapterForPageNumber(pageNumber); + boolean rebuildInactivePageCompleted = + rebuildTab(inactiveAdapter, doPostProcessing) || inactiveAdapter.isTabLoaded(); + if (!rebuildInactivePageCompleted) { + allRebuildsComplete.set(false); + } + }); + Trace.endSection(); + return allRebuildsComplete.get(); + } + + protected void forEachPage(Consumer pageNumberHandler) { + for (int pageNumber = 0; pageNumber < getItemCount(); ++pageNumber) { + pageNumberHandler.accept(pageNumber); + } + } + + protected void forEachInactivePage(Consumer inactivePageNumberHandler) { + forEachPage(pageNumber -> { + if (pageNumber != getCurrentPage()) { + inactivePageNumberHandler.accept(pageNumber); + } + }); + } + + protected boolean rebuildTab(ListAdapterT activeListAdapter, boolean doPostProcessing) { + if (shouldSkipRebuild(activeListAdapter)) { + activeListAdapter.postListReadyRunnable(doPostProcessing, /* rebuildCompleted */ true); + return false; + } + return activeListAdapter.rebuildList(doPostProcessing); + } + + private boolean shouldSkipRebuild(ListAdapterT activeListAdapter) { + EmptyState emptyState = mEmptyStateProvider.getEmptyState(activeListAdapter); + return emptyState != null && emptyState.shouldSkipDataRebuild(); + } + + /** + * The empty state screens are shown according to their priority: + *

    + *
  1. (highest priority) cross-profile disabled by policy (handled in + * {@link #rebuildTab(ListAdapterT, boolean)})
  2. + *
  3. no apps available
  4. + *
  5. (least priority) work is off
  6. + *
+ * + * The intention is to prevent the user from having to turn + * the work profile on if there will not be any apps resolved + * anyway. + * + * TODO: move this comment to the place where we configure our composite provider. + */ + public void showEmptyResolverListEmptyState(ListAdapterT listAdapter) { + final EmptyState emptyState = mEmptyStateProvider.getEmptyState(listAdapter); + + if (emptyState == null) { + return; + } + + emptyState.onEmptyStateShown(); + + View.OnClickListener clickListener = null; + + if (emptyState.getButtonClickListener() != null) { + clickListener = v -> emptyState.getButtonClickListener().onClick(() -> { + ProfileDescriptor descriptor = + getDescriptorForUserHandle(listAdapter.getUserHandle()); + descriptor.mEmptyStateUi.showSpinner(); + }); + } + + showEmptyState(listAdapter, emptyState, clickListener); + } + + private void showEmptyState( + ListAdapterT activeListAdapter, + EmptyState emptyState, + View.OnClickListener buttonOnClick) { + ProfileDescriptor descriptor = + getDescriptorForUserHandle(activeListAdapter.getUserHandle()); + descriptor.mEmptyStateUi.showEmptyState(emptyState, buttonOnClick); + activeListAdapter.markTabLoaded(); + } + + /** + * Sets up the padding of the view containing the empty state screens for the current adapter + * view. + */ + protected final void setupContainerPadding() { + getItem(getCurrentPage()).setupContainerPadding(); + } + + public void showListView(ListAdapterT activeListAdapter) { + ProfileDescriptor descriptor = + getDescriptorForUserHandle(activeListAdapter.getUserHandle()); + descriptor.mEmptyStateUi.hide(); + } + + /** + * @return whether any "inactive" tab's adapter would show an empty-state screen in our current + * application state. + */ + public final boolean shouldShowEmptyStateScreenInAnyInactiveAdapter() { + AtomicBoolean anyEmpty = new AtomicBoolean(false); + // TODO: The "inactive" condition is legacy logic. Could we simplify and ask "any"? + forEachInactivePage(pageNumber -> { + if (shouldShowEmptyStateScreen(getListAdapterForPageNumber(pageNumber))) { + anyEmpty.set(true); + } + }); + return anyEmpty.get(); + } + + public boolean shouldShowEmptyStateScreen(ListAdapterT listAdapter) { + int count = listAdapter.getUnfilteredCount(); + return (count == 0 && listAdapter.getPlaceholderCount() == 0) + || (listAdapter.getUserHandle().equals(mWorkProfileUserHandle) + && mWorkProfileQuietModeChecker.get()); + } + +} diff --git a/java/src/com/android/intentresolver/profiles/OnProfileSelectedListener.java b/java/src/com/android/intentresolver/profiles/OnProfileSelectedListener.java new file mode 100644 index 00000000..e6299954 --- /dev/null +++ b/java/src/com/android/intentresolver/profiles/OnProfileSelectedListener.java @@ -0,0 +1,46 @@ +/* + * Copyright (C) 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.intentresolver.profiles; + +import androidx.viewpager.widget.ViewPager; + +/** Listener interface for changes between the per-profile UI tabs. */ +public interface OnProfileSelectedListener { + /** + * Callback for when the user changes the active tab. + *

This callback is only called when the intent resolver or share sheet shows + * more than one profile. + * + * @param profileId the ID of the newly-selected profile, e.g. {@link #PROFILE_PERSONAL} + * if the personal profile tab was selected or {@link #PROFILE_WORK} if the + * work profile tab + * was selected. + */ + void onProfilePageSelected(@MultiProfilePagerAdapter.ProfileType int profileId, int pageNumber); + + + /** + * Callback for when the scroll state changes. Useful for discovering when the user begins + * dragging, when the pager is automatically settling to the current page, or when it is + * fully stopped/idle. + * + * @param state {@link ViewPager#SCROLL_STATE_IDLE}, {@link ViewPager#SCROLL_STATE_DRAGGING} + * or {@link ViewPager#SCROLL_STATE_SETTLING} + * @see ViewPager.OnPageChangeListener#onPageScrollStateChanged + */ + void onProfilePageStateChanged(int state); +} diff --git a/java/src/com/android/intentresolver/profiles/OnSwitchOnWorkSelectedListener.java b/java/src/com/android/intentresolver/profiles/OnSwitchOnWorkSelectedListener.java new file mode 100644 index 00000000..7989551a --- /dev/null +++ b/java/src/com/android/intentresolver/profiles/OnSwitchOnWorkSelectedListener.java @@ -0,0 +1,27 @@ +/* + * Copyright (C) 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.intentresolver.profiles; + +/** + * Listener for when the user switches on the work profile from the work tab. + */ +public interface OnSwitchOnWorkSelectedListener { + /** + * Callback for when the user switches on the work profile from the work tab. + */ + void onSwitchOnWorkSelected(); +} diff --git a/java/src/com/android/intentresolver/profiles/ProfileDescriptor.java b/java/src/com/android/intentresolver/profiles/ProfileDescriptor.java new file mode 100644 index 00000000..61c7c670 --- /dev/null +++ b/java/src/com/android/intentresolver/profiles/ProfileDescriptor.java @@ -0,0 +1,82 @@ +/* + * Copyright (C) 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.intentresolver.profiles; + +import android.view.ViewGroup; + +import com.android.intentresolver.emptystate.EmptyStateUiHelper; + +import java.util.Optional; +import java.util.function.Supplier; + +// TODO: `ChooserActivity` also has a per-profile record type. Maybe the "multi-profile pager" +// should be the owner of all per-profile data (especially now that the API is generic)? +class ProfileDescriptor { + final @MultiProfilePagerAdapter.ProfileType int mProfile; + final String mTabLabel; + final String mTabAccessibilityLabel; + final String mTabTag; + + final ViewGroup mRootView; + final EmptyStateUiHelper mEmptyStateUi; + + // TODO: post-refactoring, we may not need to retain these ivars directly (since they may + // be encapsulated within the `EmptyStateUiHelper`?). + private final ViewGroup mEmptyStateView; + + private final SinglePageAdapterT mAdapter; + + public SinglePageAdapterT getAdapter() { + return mAdapter; + } + + public PageViewT getView() { + return mView; + } + + private final PageViewT mView; + + ProfileDescriptor( + @MultiProfilePagerAdapter.ProfileType int forProfile, + String tabLabel, + String tabAccessibilityLabel, + String tabTag, + ViewGroup rootView, + SinglePageAdapterT adapter, + Supplier> containerBottomPaddingOverrideSupplier) { + mProfile = forProfile; + mTabLabel = tabLabel; + mTabAccessibilityLabel = tabAccessibilityLabel; + mTabTag = tabTag; + mRootView = rootView; + mAdapter = adapter; + mEmptyStateView = rootView.findViewById(com.android.internal.R.id.resolver_empty_state); + mView = (PageViewT) rootView.findViewById(com.android.internal.R.id.resolver_list); + mEmptyStateUi = new EmptyStateUiHelper( + rootView, + com.android.internal.R.id.resolver_list, + containerBottomPaddingOverrideSupplier); + } + + protected ViewGroup getEmptyStateView() { + return mEmptyStateView; + } + + public void setupContainerPadding() { + mEmptyStateUi.setupContainerPadding(); + } +} diff --git a/java/src/com/android/intentresolver/profiles/ResolverMultiProfilePagerAdapter.java b/java/src/com/android/intentresolver/profiles/ResolverMultiProfilePagerAdapter.java new file mode 100644 index 00000000..0c669510 --- /dev/null +++ b/java/src/com/android/intentresolver/profiles/ResolverMultiProfilePagerAdapter.java @@ -0,0 +1,112 @@ +/* + * Copyright (C) 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.intentresolver.profiles; + +import android.content.Context; +import android.os.UserHandle; +import android.view.LayoutInflater; +import android.view.ViewGroup; +import android.widget.ListView; + +import androidx.viewpager.widget.PagerAdapter; + +import com.android.intentresolver.R; +import com.android.intentresolver.ResolverListAdapter; +import com.android.intentresolver.emptystate.EmptyStateProvider; + +import com.google.common.collect.ImmutableList; + +import java.util.Optional; +import java.util.function.Supplier; + +/** + * A {@link PagerAdapter} which describes the work and personal profile intent resolver screens. + */ +public class ResolverMultiProfilePagerAdapter extends + MultiProfilePagerAdapter { + private final BottomPaddingOverrideSupplier mBottomPaddingOverrideSupplier; + + public ResolverMultiProfilePagerAdapter(Context context, + ImmutableList> tabs, + EmptyStateProvider emptyStateProvider, + Supplier workProfileQuietModeChecker, + @ProfileType int defaultProfile, + UserHandle workProfileUserHandle, + UserHandle cloneProfileUserHandle) { + this( + context, + tabs, + emptyStateProvider, + workProfileQuietModeChecker, + defaultProfile, + workProfileUserHandle, + cloneProfileUserHandle, + new BottomPaddingOverrideSupplier()); + } + + private ResolverMultiProfilePagerAdapter( + Context context, + ImmutableList> tabs, + EmptyStateProvider emptyStateProvider, + Supplier workProfileQuietModeChecker, + @ProfileType int defaultProfile, + UserHandle workProfileUserHandle, + UserHandle cloneProfileUserHandle, + BottomPaddingOverrideSupplier bottomPaddingOverrideSupplier) { + super( + listAdapter -> listAdapter, + (listView, bindAdapter) -> listView.setAdapter(bindAdapter), + tabs, + emptyStateProvider, + workProfileQuietModeChecker, + defaultProfile, + workProfileUserHandle, + cloneProfileUserHandle, + () -> (ViewGroup) LayoutInflater.from(context).inflate( + R.layout.resolver_list_per_profile, null, false), + bottomPaddingOverrideSupplier); + mBottomPaddingOverrideSupplier = bottomPaddingOverrideSupplier; + } + + public void setUseLayoutWithDefault(boolean useLayoutWithDefault) { + mBottomPaddingOverrideSupplier.setUseLayoutWithDefault(useLayoutWithDefault); + } + + /** Un-check any item(s) that may be checked in any of our inactive adapter(s). */ + public void clearCheckedItemsInInactiveProfiles() { + // TODO: The "inactive" condition is legacy logic. Could we simplify and clear-all? + forEachInactivePage(pageNumber -> { + ListView inactiveListView = getListViewForIndex(pageNumber); + if (inactiveListView.getCheckedItemCount() > 0) { + inactiveListView.setItemChecked(inactiveListView.getCheckedItemPosition(), false); + } + }); + } + + private static class BottomPaddingOverrideSupplier implements Supplier> { + private boolean mUseLayoutWithDefault; + + public void setUseLayoutWithDefault(boolean useLayoutWithDefault) { + mUseLayoutWithDefault = useLayoutWithDefault; + } + + @Override + public Optional get() { + return mUseLayoutWithDefault ? Optional.empty() : Optional.of(0); + } + } +} diff --git a/java/src/com/android/intentresolver/profiles/TabConfig.java b/java/src/com/android/intentresolver/profiles/TabConfig.java new file mode 100644 index 00000000..320f069a --- /dev/null +++ b/java/src/com/android/intentresolver/profiles/TabConfig.java @@ -0,0 +1,38 @@ +/* + * Copyright (C) 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.intentresolver.profiles; + +public class TabConfig { + final @MultiProfilePagerAdapter.ProfileType int mProfile; + final String mTabLabel; + final String mTabAccessibilityLabel; + final String mTabTag; + final PageAdapterT mPageAdapter; + + public TabConfig( + @MultiProfilePagerAdapter.ProfileType int profile, + String tabLabel, + String tabAccessibilityLabel, + String tabTag, + PageAdapterT pageAdapter) { + mProfile = profile; + mTabLabel = tabLabel; + mTabAccessibilityLabel = tabAccessibilityLabel; + mTabTag = tabTag; + mPageAdapter = pageAdapter; + } +} diff --git a/java/src/com/android/intentresolver/shared/model/Profile.kt b/java/src/com/android/intentresolver/shared/model/Profile.kt new file mode 100644 index 00000000..c557c151 --- /dev/null +++ b/java/src/com/android/intentresolver/shared/model/Profile.kt @@ -0,0 +1,52 @@ +/* + * Copyright (C) 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.intentresolver.shared.model + +import com.android.intentresolver.shared.model.Profile.Type + +/** + * Associates [users][User] into a [Type] instance. + * + * This is a simple abstraction which combines a primary [user][User] with an optional + * [cloned apps][User.Role.CLONE] user. This encapsulates the cloned app user id, while still being + * available where needed. + */ +data class Profile( + val type: Type, + val primary: User, + /** + * An optional [User] of which contains second instances of some applications installed for the + * personal user. This value may only be supplied when creating the PERSONAL profile. + */ + val clone: User? = null +) { + + init { + clone?.apply { + require(primary.role == User.Role.PERSONAL) { + "clone is not supported for profile=${this@Profile.type} / primary=$primary" + } + require(role == User.Role.CLONE) { "clone is not a clone user ($this)" } + } + } + + enum class Type { + PERSONAL, + WORK, + PRIVATE + } +} diff --git a/java/src/com/android/intentresolver/shared/model/User.kt b/java/src/com/android/intentresolver/shared/model/User.kt new file mode 100644 index 00000000..b544a390 --- /dev/null +++ b/java/src/com/android/intentresolver/shared/model/User.kt @@ -0,0 +1,52 @@ +/* + * Copyright (C) 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.intentresolver.shared.model + +import android.annotation.UserIdInt +import android.os.UserHandle + +/** + * A User represents the owner of a distinct set of content. + * * maps 1:1 to a UserHandle or UserId (Int) value. + * * refers to either [Full][Type.FULL], or a [Profile][Type.PROFILE] user, as indicated by the + * [type] property. + * + * See + * [Users for system developers](https://android.googlesource.com/platform/frameworks/base/+/master/core/java/android/os/Users.md) + * + * ``` + * val users = listOf( + * User(id = 0, role = PERSONAL), + * User(id = 10, role = WORK), + * User(id = 11, role = CLONE), + * User(id = 12, role = PRIVATE), + * ) + * ``` + */ +data class User( + @UserIdInt val id: Int, + val role: Role, +) { + val handle: UserHandle = UserHandle.of(id) + + enum class Role { + PERSONAL, + PRIVATE, + WORK, + CLONE + } +} diff --git a/java/src/com/android/intentresolver/ui/ActionTitle.java b/java/src/com/android/intentresolver/ui/ActionTitle.java new file mode 100644 index 00000000..1cc96fa9 --- /dev/null +++ b/java/src/com/android/intentresolver/ui/ActionTitle.java @@ -0,0 +1,88 @@ +/* + * Copyright (C) 2023 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.android.intentresolver.ui; + +import android.content.Intent; +import android.provider.MediaStore; + +import androidx.annotation.StringRes; + +import com.android.intentresolver.R; + +/** + * Provides a set of related resources for different use cases. + */ +public enum ActionTitle { + VIEW(Intent.ACTION_VIEW, + R.string.whichViewApplication, + R.string.whichViewApplicationNamed, + R.string.whichViewApplicationLabel), + EDIT(Intent.ACTION_EDIT, + R.string.whichEditApplication, + R.string.whichEditApplicationNamed, + R.string.whichEditApplicationLabel), + SEND(Intent.ACTION_SEND, + R.string.whichSendApplication, + R.string.whichSendApplicationNamed, + R.string.whichSendApplicationLabel), + SENDTO(Intent.ACTION_SENDTO, + R.string.whichSendToApplication, + R.string.whichSendToApplicationNamed, + R.string.whichSendToApplicationLabel), + SEND_MULTIPLE(Intent.ACTION_SEND_MULTIPLE, + R.string.whichSendApplication, + R.string.whichSendApplicationNamed, + R.string.whichSendApplicationLabel), + CAPTURE_IMAGE(MediaStore.ACTION_IMAGE_CAPTURE, + R.string.whichImageCaptureApplication, + R.string.whichImageCaptureApplicationNamed, + R.string.whichImageCaptureApplicationLabel), + DEFAULT(null, + R.string.whichApplication, + R.string.whichApplicationNamed, + R.string.whichApplicationLabel), + HOME(Intent.ACTION_MAIN, + R.string.whichHomeApplication, + R.string.whichHomeApplicationNamed, + R.string.whichHomeApplicationLabel); + + // titles for layout that deals with http(s) intents + public static final int BROWSABLE_TITLE_RES = R.string.whichOpenLinksWith; + public static final int BROWSABLE_HOST_TITLE_RES = R.string.whichOpenHostLinksWith; + public static final int BROWSABLE_HOST_APP_TITLE_RES = R.string.whichOpenHostLinksWithApp; + public static final int BROWSABLE_APP_TITLE_RES = R.string.whichOpenLinksWithApp; + + public final String action; + public final int titleRes; + public final int namedTitleRes; + public final @StringRes int labelRes; + + ActionTitle(String action, int titleRes, int namedTitleRes, @StringRes int labelRes) { + this.action = action; + this.titleRes = titleRes; + this.namedTitleRes = namedTitleRes; + this.labelRes = labelRes; + } + + public static ActionTitle forAction(String action) { + for (ActionTitle title : values()) { + if (title != HOME && action != null && action.equals(title.action)) { + return title; + } + } + return DEFAULT; + } +} diff --git a/java/src/com/android/intentresolver/ui/ProfilePagerResources.kt b/java/src/com/android/intentresolver/ui/ProfilePagerResources.kt new file mode 100644 index 00000000..baab9a4c --- /dev/null +++ b/java/src/com/android/intentresolver/ui/ProfilePagerResources.kt @@ -0,0 +1,53 @@ +/* + * Copyright (C) 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.intentresolver.ui + +import android.content.res.Resources +import com.android.intentresolver.R +import com.android.intentresolver.data.repository.DevicePolicyResources +import com.android.intentresolver.inject.ApplicationOwned +import com.android.intentresolver.shared.model.Profile +import javax.inject.Inject + +class ProfilePagerResources +@Inject +constructor( + @ApplicationOwned private val resources: Resources, + private val devicePolicyResources: DevicePolicyResources +) { + private val privateTabLabel by lazy { resources.getString(R.string.resolver_private_tab) } + + private val privateTabAccessibilityLabel by lazy { + resources.getString(R.string.resolver_private_tab_accessibility) + } + + fun profileTabLabel(profile: Profile.Type): String { + return when (profile) { + Profile.Type.PERSONAL -> devicePolicyResources.personalTabLabel + Profile.Type.WORK -> devicePolicyResources.workTabLabel + Profile.Type.PRIVATE -> privateTabLabel + } + } + + fun profileTabAccessibilityLabel(type: Profile.Type): String { + return when (type) { + Profile.Type.PERSONAL -> devicePolicyResources.personalTabAccessibilityLabel + Profile.Type.WORK -> devicePolicyResources.workTabAccessibilityLabel + Profile.Type.PRIVATE -> privateTabAccessibilityLabel + } + } +} diff --git a/java/src/com/android/intentresolver/ui/ShareResultSender.kt b/java/src/com/android/intentresolver/ui/ShareResultSender.kt new file mode 100644 index 00000000..7be2076e --- /dev/null +++ b/java/src/com/android/intentresolver/ui/ShareResultSender.kt @@ -0,0 +1,163 @@ +/* + * Copyright (C) 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.intentresolver.ui + +import android.app.Activity +import android.app.compat.CompatChanges +import android.content.ComponentName +import android.content.Context +import android.content.Intent +import android.content.IntentSender +import android.service.chooser.ChooserResult +import android.service.chooser.ChooserResult.CHOOSER_RESULT_COPY +import android.service.chooser.ChooserResult.CHOOSER_RESULT_EDIT +import android.service.chooser.ChooserResult.CHOOSER_RESULT_SELECTED_COMPONENT +import android.service.chooser.ChooserResult.CHOOSER_RESULT_UNKNOWN +import android.service.chooser.ChooserResult.ResultType +import android.util.Log +import com.android.intentresolver.inject.Background +import com.android.intentresolver.inject.ChooserServiceFlags +import com.android.intentresolver.inject.Main +import com.android.intentresolver.ui.model.ShareAction +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject +import dagger.hilt.android.qualifiers.ActivityContext +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext + +private const val TAG = "ShareResultSender" + +/** Reports the result of a share to another process across binder, via an [IntentSender] */ +interface ShareResultSender { + /** Reports user selection of an activity to launch from the provided choices. */ + fun onComponentSelected(component: ComponentName, directShare: Boolean) + + /** Reports user invocation of a built-in system action. See [ShareAction]. */ + fun onActionSelected(action: ShareAction) +} + +@AssistedFactory +interface ShareResultSenderFactory { + fun create(callerUid: Int, chosenComponentSender: IntentSender): ShareResultSenderImpl +} + +/** Dispatches Intents via IntentSender */ +fun interface IntentSenderDispatcher { + fun dispatchIntent(intentSender: IntentSender, intent: Intent) +} + +class ShareResultSenderImpl( + private val flags: ChooserServiceFlags, + @Main private val scope: CoroutineScope, + @Background val backgroundDispatcher: CoroutineDispatcher, + private val callerUid: Int, + private val resultSender: IntentSender, + private val intentDispatcher: IntentSenderDispatcher +) : ShareResultSender { + @AssistedInject + constructor( + @ActivityContext context: Context, + flags: ChooserServiceFlags, + @Main scope: CoroutineScope, + @Background backgroundDispatcher: CoroutineDispatcher, + @Assisted callerUid: Int, + @Assisted chosenComponentSender: IntentSender, + ) : this( + flags, + scope, + backgroundDispatcher, + callerUid, + chosenComponentSender, + IntentSenderDispatcher { sender, intent -> sender.dispatchIntent(context, intent) } + ) + + override fun onComponentSelected(component: ComponentName, directShare: Boolean) { + Log.i(TAG, "onComponentSelected: $component directShare=$directShare") + scope.launch { + val intent = createChosenComponentIntent(component, directShare) + intentDispatcher.dispatchIntent(resultSender, intent) + } + } + + override fun onActionSelected(action: ShareAction) { + Log.i(TAG, "onActionSelected: $action") + scope.launch { + if (flags.enableChooserResult() && chooserResultSupported(callerUid)) { + @ResultType val chosenAction = shareActionToChooserResult(action) + val intent: Intent = createSelectedActionIntent(chosenAction) + intentDispatcher.dispatchIntent(resultSender, intent) + } else { + Log.i(TAG, "Not sending SelectedAction") + } + } + } + + private suspend fun createChosenComponentIntent( + component: ComponentName, + direct: Boolean, + ): Intent { + // Add extra with component name for backwards compatibility. + val intent: Intent = Intent().putExtra(Intent.EXTRA_CHOSEN_COMPONENT, component) + + // Add ChooserResult value for Android V+ + if (flags.enableChooserResult() && chooserResultSupported(callerUid)) { + intent.putExtra( + Intent.EXTRA_CHOOSER_RESULT, + ChooserResult(CHOOSER_RESULT_SELECTED_COMPONENT, component, direct) + ) + } else { + Log.i(TAG, "Not including ${Intent.EXTRA_CHOOSER_RESULT}") + } + return intent + } + + @ResultType + private fun shareActionToChooserResult(action: ShareAction) = + when (action) { + ShareAction.SYSTEM_COPY -> CHOOSER_RESULT_COPY + ShareAction.SYSTEM_EDIT -> CHOOSER_RESULT_EDIT + ShareAction.APPLICATION_DEFINED -> CHOOSER_RESULT_UNKNOWN + } + + private fun createSelectedActionIntent(@ResultType result: Int): Intent { + return Intent().putExtra(Intent.EXTRA_CHOOSER_RESULT, ChooserResult(result, null, false)) + } + + private suspend fun chooserResultSupported(uid: Int): Boolean { + return withContext(backgroundDispatcher) { + // background -> Binder call to system_server + CompatChanges.isChangeEnabled(ChooserResult.SEND_CHOOSER_RESULT, uid) + } + } +} + +private fun IntentSender.dispatchIntent(context: Context, intent: Intent) { + try { + sendIntent( + /* context = */ context, + /* code = */ Activity.RESULT_OK, + /* intent = */ intent, + /* onFinished = */ null, + /* handler = */ null + ) + } catch (e: IntentSender.SendIntentException) { + Log.e(TAG, "Failed to send intent to IntentSender", e) + } +} diff --git a/java/src/com/android/intentresolver/ui/ShortcutPolicyModule.kt b/java/src/com/android/intentresolver/ui/ShortcutPolicyModule.kt new file mode 100644 index 00000000..7239198e --- /dev/null +++ b/java/src/com/android/intentresolver/ui/ShortcutPolicyModule.kt @@ -0,0 +1,94 @@ +/* + * Copyright (C) 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.intentresolver.ui + +import android.content.res.Resources +import android.provider.DeviceConfig +import com.android.intentresolver.R +import com.android.intentresolver.inject.ApplicationOwned +import com.android.internal.config.sysui.SystemUiDeviceConfigFlags +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import javax.inject.Qualifier +import javax.inject.Singleton + +@Qualifier +@MustBeDocumented +@Retention(AnnotationRetention.RUNTIME) +annotation class AppShortcutLimit + +@Qualifier +@MustBeDocumented +@Retention(AnnotationRetention.RUNTIME) +annotation class EnforceShortcutLimit + +@Qualifier +@MustBeDocumented +@Retention(AnnotationRetention.RUNTIME) +annotation class ShortcutRowLimit + +@Module +@InstallIn(SingletonComponent::class) +object ShortcutPolicyModule { + /** + * Defines the limit for the number of shortcut targets provided for any single app. + * + * This value applies to both results from Shortcut-service and app-provided targets on a + * per-package basis. + */ + @Provides + @Singleton + @AppShortcutLimit + fun appShortcutLimit(@ApplicationOwned resources: Resources): Int { + return resources.getInteger(R.integer.config_maxShortcutTargetsPerApp) + } + + /** + * Once this value is no longer necessary it should be replaced in tests with simply replacing + * [AppShortcutLimit]: + * ``` + * @BindValue + * @AppShortcutLimit + * var shortcutLimit = Int.MAX_VALUE + * ``` + */ + @Provides + @Singleton + @EnforceShortcutLimit + fun applyShortcutLimit(): Boolean { + return DeviceConfig.getBoolean( + DeviceConfig.NAMESPACE_SYSTEMUI, + SystemUiDeviceConfigFlags.APPLY_SHARING_APP_LIMITS_IN_SYSUI, + true + ) + } + + /** + * Defines the limit for the number of shortcuts presented within the direct share row. + * + * This value applies to all displayed direct share targets, including those from Shortcut + * service as well as app-provided targets. + */ + @Provides + @Singleton + @ShortcutRowLimit + fun shortcutRowLimit(@ApplicationOwned resources: Resources): Int { + return resources.getInteger(R.integer.config_chooser_max_targets_per_row) + } +} diff --git a/java/src/com/android/intentresolver/ui/model/ActivityModel.kt b/java/src/com/android/intentresolver/ui/model/ActivityModel.kt new file mode 100644 index 00000000..4bcdd69b --- /dev/null +++ b/java/src/com/android/intentresolver/ui/model/ActivityModel.kt @@ -0,0 +1,81 @@ +/* + * Copyright (C) 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.android.intentresolver.ui.model + +import android.app.Activity +import android.content.Intent +import android.net.Uri +import android.os.Parcel +import android.os.Parcelable +import com.android.intentresolver.data.model.ANDROID_APP_SCHEME +import com.android.intentresolver.ext.readParcelable +import com.android.intentresolver.ext.requireParcelable +import java.util.Objects + +/** Contains Activity-scope information about the state when started. */ +data class ActivityModel( + /** The [Intent] received by the app */ + val intent: Intent, + /** The identifier for the sending app and user */ + val launchedFromUid: Int, + /** The package of the sending app */ + val launchedFromPackage: String, + /** The referrer as supplied to the activity. */ + val referrer: Uri? +) : Parcelable { + constructor( + source: Parcel + ) : this( + intent = source.requireParcelable(), + launchedFromUid = source.readInt(), + launchedFromPackage = requireNotNull(source.readString()), + referrer = source.readParcelable() + ) + + /** A package name from referrer, if it is an android-app URI */ + val referrerPackage = referrer?.takeIf { it.scheme == ANDROID_APP_SCHEME }?.authority + + override fun describeContents() = 0 /* flags */ + + override fun writeToParcel(dest: Parcel, flags: Int) { + dest.writeParcelable(intent, flags) + dest.writeInt(launchedFromUid) + dest.writeString(launchedFromPackage) + dest.writeParcelable(referrer, flags) + } + + companion object { + const val ACTIVITY_MODEL_KEY = "com.android.intentresolver.ACTIVITY_MODEL" + + @JvmField + @Suppress("unused") + val CREATOR = + object : Parcelable.Creator { + override fun newArray(size: Int) = arrayOfNulls(size) + override fun createFromParcel(source: Parcel) = ActivityModel(source) + } + + @JvmStatic + fun createFrom(activity: Activity): ActivityModel { + return ActivityModel( + activity.intent, + activity.launchedFromUid, + Objects.requireNonNull(activity.launchedFromPackage), + activity.referrer + ) + } + } +} diff --git a/java/src/com/android/intentresolver/ui/model/ResolverRequest.kt b/java/src/com/android/intentresolver/ui/model/ResolverRequest.kt new file mode 100644 index 00000000..363c413d --- /dev/null +++ b/java/src/com/android/intentresolver/ui/model/ResolverRequest.kt @@ -0,0 +1,68 @@ +/* + * Copyright (C) 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.intentresolver.ui.model + +import android.content.Intent +import android.content.pm.ResolveInfo +import android.os.UserHandle +import com.android.intentresolver.ext.isHomeIntent +import com.android.intentresolver.shared.model.Profile + +/** All of the things that are consumed from an incoming Intent Resolution request (+Extras). */ +data class ResolverRequest( + /** The intent to be resolved to a target. */ + val intent: Intent, + + /** + * Supplied by the system to indicate which profile should be selected by default. This is + * required since ResolverActivity may be launched as either the originating OR target user when + * resolving a cross profile intent. + * + * Valid values are: [PERSONAL][Profile.Type.PERSONAL] and [WORK][Profile.Type.WORK] and null + * when the intent is not a forwarded cross-profile intent. + */ + val selectedProfile: Profile.Type?, + + /** + * When handing a cross profile forwarded intent, this is the user which started the original + * intent. This is required to allow ResolverActivity to be launched as the target user under + * some conditions. + */ + val callingUser: UserHandle?, + + /** + * Indicates if resolving actions for a connected device which has audio capture capability + * (e.g. is a USB Microphone). + * + * When used to handle a connected device, ResolverActivity uses this signal to present a + * warning when a resolved application does not hold the RECORD_AUDIO permission. (If selected + * the app would be able to capture audio directly via the device, bypassing audio API + * permissions.) + */ + val isAudioCaptureDevice: Boolean = false, + + /** A list of a resolved activity targets. This list overrides normal intent resolution. */ + val resolutionList: List? = null, + + /** A customized title for the resolver interface. */ + val title: String? = null, +) { + val isResolvingHome = intent.isHomeIntent() + + /** For compatibility with existing code shared between chooser/resolver. */ + val payloadIntents: List = listOf(intent) +} diff --git a/java/src/com/android/intentresolver/ui/model/ShareAction.kt b/java/src/com/android/intentresolver/ui/model/ShareAction.kt new file mode 100644 index 00000000..4d727b9a --- /dev/null +++ b/java/src/com/android/intentresolver/ui/model/ShareAction.kt @@ -0,0 +1,23 @@ +/* + * Copyright (C) 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.intentresolver.ui.model + +enum class ShareAction { + SYSTEM_COPY, + SYSTEM_EDIT, + APPLICATION_DEFINED +} diff --git a/java/src/com/android/intentresolver/ui/viewmodel/ChooserRequestReader.kt b/java/src/com/android/intentresolver/ui/viewmodel/ChooserRequestReader.kt new file mode 100644 index 00000000..a9b6de7e --- /dev/null +++ b/java/src/com/android/intentresolver/ui/viewmodel/ChooserRequestReader.kt @@ -0,0 +1,198 @@ +/* + * Copyright (C) 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.android.intentresolver.ui.viewmodel + +import android.content.ComponentName +import android.content.Intent +import android.content.Intent.EXTRA_ALTERNATE_INTENTS +import android.content.Intent.EXTRA_CHOOSER_CUSTOM_ACTIONS +import android.content.Intent.EXTRA_CHOOSER_MODIFY_SHARE_ACTION +import android.content.Intent.EXTRA_CHOOSER_REFINEMENT_INTENT_SENDER +import android.content.Intent.EXTRA_CHOOSER_RESULT_INTENT_SENDER +import android.content.Intent.EXTRA_CHOOSER_TARGETS +import android.content.Intent.EXTRA_CHOSEN_COMPONENT_INTENT_SENDER +import android.content.Intent.EXTRA_EXCLUDE_COMPONENTS +import android.content.Intent.EXTRA_INITIAL_INTENTS +import android.content.Intent.EXTRA_INTENT +import android.content.Intent.EXTRA_METADATA_TEXT +import android.content.Intent.EXTRA_REPLACEMENT_EXTRAS +import android.content.Intent.EXTRA_TEXT +import android.content.Intent.EXTRA_TITLE +import android.content.Intent.FLAG_ACTIVITY_MULTIPLE_TASK +import android.content.Intent.FLAG_ACTIVITY_NEW_DOCUMENT +import android.content.IntentFilter +import android.content.IntentSender +import android.net.Uri +import android.os.Bundle +import android.service.chooser.ChooserAction +import android.service.chooser.ChooserTarget +import com.android.intentresolver.ChooserActivity +import com.android.intentresolver.ContentTypeHint +import com.android.intentresolver.R +import com.android.intentresolver.data.model.ChooserRequest +import com.android.intentresolver.ext.hasSendAction +import com.android.intentresolver.ext.ifMatch +import com.android.intentresolver.inject.ChooserServiceFlags +import com.android.intentresolver.ui.model.ActivityModel +import com.android.intentresolver.util.hasValidIcon +import com.android.intentresolver.validation.Validation +import com.android.intentresolver.validation.ValidationResult +import com.android.intentresolver.validation.types.IntentOrUri +import com.android.intentresolver.validation.types.array +import com.android.intentresolver.validation.types.value +import com.android.intentresolver.validation.validateFrom + +private const val MAX_CHOOSER_ACTIONS = 5 +private const val MAX_INITIAL_INTENTS = 2 + +internal fun Intent.maybeAddSendActionFlags() = + ifMatch(Intent::hasSendAction) { + addFlags(FLAG_ACTIVITY_NEW_DOCUMENT) + addFlags(FLAG_ACTIVITY_MULTIPLE_TASK) + } + +fun readChooserRequest( + model: ActivityModel, + flags: ChooserServiceFlags +): ValidationResult { + val extras = model.intent.extras ?: Bundle() + @Suppress("DEPRECATION") + return validateFrom(extras::get) { + val targetIntent = required(IntentOrUri(EXTRA_INTENT)).maybeAddSendActionFlags() + + val isSendAction = targetIntent.hasSendAction() + + val additionalTargets = readAlternateIntents() ?: emptyList() + + val replacementExtras = optional(value(EXTRA_REPLACEMENT_EXTRAS)) + + val (customTitle, defaultTitleResource) = + if (isSendAction) { + ignored( + value(EXTRA_TITLE), + "deprecated in P. You may wish to set a preview title by using EXTRA_TITLE " + + "property of the wrapped EXTRA_INTENT." + ) + null to R.string.chooseActivity + } else { + val custom = optional(value(EXTRA_TITLE)) + custom to (custom?.let { 0 } ?: R.string.chooseActivity) + } + + val initialIntents = + optional(array(EXTRA_INITIAL_INTENTS))?.take(MAX_INITIAL_INTENTS)?.map { + it.maybeAddSendActionFlags() + } + ?: emptyList() + + val chosenComponentSender = + optional(value(EXTRA_CHOOSER_RESULT_INTENT_SENDER)) + ?: optional(value(EXTRA_CHOSEN_COMPONENT_INTENT_SENDER)) + + val refinementIntentSender = + optional(value(EXTRA_CHOOSER_REFINEMENT_INTENT_SENDER)) + + val filteredComponents = + optional(array(EXTRA_EXCLUDE_COMPONENTS)) ?: emptyList() + + @Suppress("DEPRECATION") + val callerChooserTargets = + optional(array(EXTRA_CHOOSER_TARGETS)) ?: emptyList() + + val retainInOnStop = + optional(value(ChooserActivity.EXTRA_PRIVATE_RETAIN_IN_ON_STOP)) ?: false + + val sharedText = optional(value(EXTRA_TEXT)) + + val chooserActions = readChooserActions() ?: emptyList() + + val modifyShareAction = optional(value(EXTRA_CHOOSER_MODIFY_SHARE_ACTION)) + + val additionalContentUri: Uri? + val focusedItemPos: Int + if (isSendAction && flags.chooserPayloadToggling()) { + additionalContentUri = optional(value(Intent.EXTRA_CHOOSER_ADDITIONAL_CONTENT_URI)) + focusedItemPos = optional(value(Intent.EXTRA_CHOOSER_FOCUSED_ITEM_POSITION)) ?: 0 + } else { + additionalContentUri = null + focusedItemPos = 0 + } + + val contentTypeHint = + if (flags.chooserAlbumText()) { + when (optional(value(Intent.EXTRA_CHOOSER_CONTENT_TYPE_HINT))) { + Intent.CHOOSER_CONTENT_TYPE_ALBUM -> ContentTypeHint.ALBUM + else -> ContentTypeHint.NONE + } + } else { + ContentTypeHint.NONE + } + + val metadataText = + if (flags.enableSharesheetMetadataExtra()) { + optional(value(EXTRA_METADATA_TEXT)) + } else { + null + } + + ChooserRequest( + targetIntent = targetIntent, + targetAction = targetIntent.action, + isSendActionTarget = isSendAction, + targetType = targetIntent.type, + launchedFromPackage = + requireNotNull(model.launchedFromPackage) { + "launch.fromPackage was null, See Activity.getLaunchedFromPackage()" + }, + title = customTitle, + defaultTitleResource = defaultTitleResource, + referrer = model.referrer, + filteredComponentNames = filteredComponents, + callerChooserTargets = callerChooserTargets, + chooserActions = chooserActions, + modifyShareAction = modifyShareAction, + shouldRetainInOnStop = retainInOnStop, + additionalTargets = additionalTargets, + replacementExtras = replacementExtras, + initialIntents = initialIntents, + chosenComponentSender = chosenComponentSender, + refinementIntentSender = refinementIntentSender, + sharedText = sharedText, + shareTargetFilter = targetIntent.toShareTargetFilter(), + additionalContentUri = additionalContentUri, + focusedItemPosition = focusedItemPos, + contentTypeHint = contentTypeHint, + metadataText = metadataText, + ) + } +} + +fun Validation.readAlternateIntents(): List? = + optional(array(EXTRA_ALTERNATE_INTENTS))?.map { it.maybeAddSendActionFlags() } + +fun Validation.readChooserActions(): List? = + optional(array(EXTRA_CHOOSER_CUSTOM_ACTIONS)) + ?.filter { hasValidIcon(it) } + ?.take(MAX_CHOOSER_ACTIONS) + +private fun Intent.toShareTargetFilter(): IntentFilter? { + return type?.let { + IntentFilter().apply { + action?.also { addAction(it) } + addDataType(it) + } + } +} diff --git a/java/src/com/android/intentresolver/ui/viewmodel/ChooserViewModel.kt b/java/src/com/android/intentresolver/ui/viewmodel/ChooserViewModel.kt new file mode 100644 index 00000000..c9cae3db --- /dev/null +++ b/java/src/com/android/intentresolver/ui/viewmodel/ChooserViewModel.kt @@ -0,0 +1,94 @@ +/* + * Copyright (C) 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.android.intentresolver.ui.viewmodel + +import android.util.Log +import androidx.lifecycle.SavedStateHandle +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.android.intentresolver.contentpreview.payloadtoggle.domain.interactor.FetchPreviewsInteractor +import com.android.intentresolver.contentpreview.payloadtoggle.domain.interactor.ProcessTargetIntentUpdatesInteractor +import com.android.intentresolver.contentpreview.payloadtoggle.ui.viewmodel.ShareouselViewModel +import com.android.intentresolver.data.model.ChooserRequest +import com.android.intentresolver.data.repository.ChooserRequestRepository +import com.android.intentresolver.inject.Background +import com.android.intentresolver.inject.ChooserServiceFlags +import com.android.intentresolver.ui.model.ActivityModel +import com.android.intentresolver.ui.model.ActivityModel.Companion.ACTIVITY_MODEL_KEY +import com.android.intentresolver.validation.Invalid +import com.android.intentresolver.validation.Valid +import com.android.intentresolver.validation.ValidationResult +import dagger.Lazy +import dagger.hilt.android.lifecycle.HiltViewModel +import javax.inject.Inject +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch + +private const val TAG = "ChooserViewModel" + +@HiltViewModel +class ChooserViewModel +@Inject +constructor( + args: SavedStateHandle, + private val shareouselViewModelProvider: Lazy, + private val processUpdatesInteractor: Lazy, + private val fetchPreviewsInteractor: Lazy, + @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, + private val chooserRequestRepository: Lazy, +) : ViewModel() { + + /** Parcelable-only references provided from the creating Activity */ + val activityModel: ActivityModel = + requireNotNull(args[ACTIVITY_MODEL_KEY]) { + "ActivityModel missing in SavedStateHandle! ($ACTIVITY_MODEL_KEY)" + } + + val shareouselViewModel: ShareouselViewModel by lazy { + // TODO: consolidate this logic, this would require a consolidated preview view model but + // for now just postpone starting the payload selection preview machinery until it's needed + assert(flags.chooserPayloadToggling()) { + "An attempt to use payload selection preview with the disabled flag" + } + + viewModelScope.launch(bgDispatcher) { processUpdatesInteractor.get().activate() } + viewModelScope.launch(bgDispatcher) { fetchPreviewsInteractor.get().activate() } + shareouselViewModelProvider.get() + } + + /** + * A [StateFlow] of [ChooserRequest]. + * + * Note: Only safe to access after checking if [initialRequest] is [Valid]. + */ + val request: StateFlow + get() = chooserRequestRepository.get().chooserRequest.asStateFlow() + + init { + if (initialRequest is Invalid) { + Log.w(TAG, "initialRequest is Invalid, initialization failed") + } + } +} diff --git a/java/src/com/android/intentresolver/ui/viewmodel/ResolverRequestReader.kt b/java/src/com/android/intentresolver/ui/viewmodel/ResolverRequestReader.kt new file mode 100644 index 00000000..856d9fdd --- /dev/null +++ b/java/src/com/android/intentresolver/ui/viewmodel/ResolverRequestReader.kt @@ -0,0 +1,59 @@ +/* + * Copyright (C) 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.intentresolver.ui.viewmodel + +import android.os.Bundle +import android.os.UserHandle +import com.android.intentresolver.ResolverActivity.PROFILE_PERSONAL +import com.android.intentresolver.ResolverActivity.PROFILE_WORK +import com.android.intentresolver.shared.model.Profile +import com.android.intentresolver.ui.model.ActivityModel +import com.android.intentresolver.ui.model.ResolverRequest +import com.android.intentresolver.validation.Validation +import com.android.intentresolver.validation.ValidationResult +import com.android.intentresolver.validation.types.value +import com.android.intentresolver.validation.validateFrom + +const val EXTRA_CALLING_USER = "com.android.internal.app.ResolverActivity.EXTRA_CALLING_USER" +const val EXTRA_SELECTED_PROFILE = + "com.android.internal.app.ResolverActivity.EXTRA_SELECTED_PROFILE" +const val EXTRA_IS_AUDIO_CAPTURE_DEVICE = "is_audio_capture_device" + +fun readResolverRequest(launch: ActivityModel): ValidationResult { + @Suppress("DEPRECATION") + return validateFrom((launch.intent.extras ?: Bundle())::get) { + val callingUser = optional(value(EXTRA_CALLING_USER)) + val selectedProfile = checkSelectedProfile() + val audioDevice = optional(value(EXTRA_IS_AUDIO_CAPTURE_DEVICE)) ?: false + ResolverRequest(launch.intent, selectedProfile, callingUser, audioDevice) + } +} + +private fun Validation.checkSelectedProfile(): Profile.Type? { + return when (val selected = optional(value(EXTRA_SELECTED_PROFILE))) { + null -> null + PROFILE_PERSONAL -> Profile.Type.PERSONAL + PROFILE_WORK -> Profile.Type.WORK + else -> + error( + EXTRA_SELECTED_PROFILE + + " has invalid value ($selected)." + + " Must be either ResolverActivity.PROFILE_PERSONAL ($PROFILE_PERSONAL)" + + " or ResolverActivity.PROFILE_WORK ($PROFILE_WORK)." + ) + } +} diff --git a/java/src/com/android/intentresolver/ui/viewmodel/ResolverViewModel.kt b/java/src/com/android/intentresolver/ui/viewmodel/ResolverViewModel.kt new file mode 100644 index 00000000..a3dc58a6 --- /dev/null +++ b/java/src/com/android/intentresolver/ui/viewmodel/ResolverViewModel.kt @@ -0,0 +1,70 @@ +/* + * Copyright (C) 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.intentresolver.ui.viewmodel + +import android.util.Log +import androidx.lifecycle.SavedStateHandle +import androidx.lifecycle.ViewModel +import com.android.intentresolver.ui.model.ActivityModel +import com.android.intentresolver.ui.model.ActivityModel.Companion.ACTIVITY_MODEL_KEY +import com.android.intentresolver.ui.model.ResolverRequest +import com.android.intentresolver.validation.Invalid +import com.android.intentresolver.validation.Valid +import dagger.hilt.android.lifecycle.HiltViewModel +import javax.inject.Inject +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow + +private const val TAG = "ResolverViewModel" + +@HiltViewModel +class ResolverViewModel @Inject constructor(args: SavedStateHandle) : ViewModel() { + + /** Parcelable-only references provided from the creating Activity */ + val activityModel: ActivityModel = + requireNotNull(args[ACTIVITY_MODEL_KEY]) { + "ActivityModel missing in SavedStateHandle! ($ACTIVITY_MODEL_KEY)" + } + + /** + * Provided only for the express purpose of early exit in the event of an invalid request. + * + * Note: [request] can only be safely accessed after checking if this value is [Valid]. + */ + internal val initialRequest = readResolverRequest(activityModel) + + private lateinit var _request: MutableStateFlow + + /** + * A [StateFlow] of [ResolverRequest]. + * + * Note: Only safe to access after checking if [initialRequest] is [Valid]. + */ + lateinit var request: StateFlow + private set + + init { + when (initialRequest) { + is Valid -> { + _request = MutableStateFlow(initialRequest.value) + request = _request.asStateFlow() + } + is Invalid -> Log.w(TAG, "initialRequest is Invalid, initialization failed") + } + } +} diff --git a/java/src/com/android/intentresolver/v2/ChooserActionFactory.java b/java/src/com/android/intentresolver/v2/ChooserActionFactory.java deleted file mode 100644 index efd5bfd1..00000000 --- a/java/src/com/android/intentresolver/v2/ChooserActionFactory.java +++ /dev/null @@ -1,400 +0,0 @@ -/* - * Copyright (C) 2023 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.android.intentresolver.v2; - -import android.app.Activity; -import android.app.ActivityOptions; -import android.app.PendingIntent; -import android.content.ClipData; -import android.content.ClipboardManager; -import android.content.ComponentName; -import android.content.Context; -import android.content.Intent; -import android.content.pm.PackageManager; -import android.content.pm.ResolveInfo; -import android.graphics.drawable.Drawable; -import android.net.Uri; -import android.service.chooser.ChooserAction; -import android.text.TextUtils; -import android.util.Log; -import android.view.View; - -import androidx.annotation.Nullable; - -import com.android.intentresolver.R; -import com.android.intentresolver.chooser.DisplayResolveInfo; -import com.android.intentresolver.chooser.TargetInfo; -import com.android.intentresolver.contentpreview.ChooserContentPreviewUi; -import com.android.intentresolver.logging.EventLog; -import com.android.intentresolver.v2.ui.ShareResultSender; -import com.android.intentresolver.v2.ui.model.ShareAction; -import com.android.intentresolver.widget.ActionRow; -import com.android.internal.annotations.VisibleForTesting; - -import com.google.common.collect.ImmutableList; - -import java.util.ArrayList; -import java.util.List; -import java.util.Optional; -import java.util.concurrent.Callable; -import java.util.function.Consumer; - -/** - * Implementation of {@link ChooserContentPreviewUi.ActionFactory} specialized to the application - * requirements of Sharesheet / {@link ChooserActivity}. - */ -@SuppressWarnings("OptionalUsedAsFieldOrParameterType") -public final class ChooserActionFactory implements ChooserContentPreviewUi.ActionFactory { - /** - * Delegate interface to launch activities when the actions are selected. - */ - public interface ActionActivityStarter { - /** - * Request an activity launch for the provided target. Implementations may choose to exit - * the current activity when the target is launched. - */ - void safelyStartActivityAsPersonalProfileUser(TargetInfo info); - - /** - * Request an activity launch for the provided target, optionally employing the specified - * shared element transition. Implementations may choose to exit the current activity when - * the target is launched. - */ - default void safelyStartActivityAsPersonalProfileUserWithSharedElementTransition( - TargetInfo info, View sharedElement, String sharedElementName) { - safelyStartActivityAsPersonalProfileUser(info); - } - } - - private static final String TAG = "ChooserActions"; - - private static final int URI_PERMISSION_INTENT_FLAGS = Intent.FLAG_GRANT_READ_URI_PERMISSION - | Intent.FLAG_GRANT_WRITE_URI_PERMISSION - | Intent.FLAG_GRANT_PERSISTABLE_URI_PERMISSION - | Intent.FLAG_GRANT_PREFIX_URI_PERMISSION; - - // Boolean extra used to inform the editor that it may want to customize the editing experience - // for the sharesheet editing flow. - private static final String EDIT_SOURCE = "edit_source"; - private static final String EDIT_SOURCE_SHARESHEET = "sharesheet"; - - private static final String CHIP_LABEL_METADATA_KEY = "android.service.chooser.chip_label"; - private static final String CHIP_ICON_METADATA_KEY = "android.service.chooser.chip_icon"; - - private static final String IMAGE_EDITOR_SHARED_ELEMENT = "screenshot_preview_image"; - - private final Context mContext; - - @Nullable private Runnable mCopyButtonRunnable; - private Runnable mEditButtonRunnable; - private final ImmutableList mCustomActions; - private final Consumer mExcludeSharedTextAction; - @Nullable private final ShareResultSender mShareResultSender; - private final Consumer mFinishCallback; - private final EventLog mLog; - - /** - * @param context - * @param imageEditor an explicit Activity to launch for editing images - * @param onUpdateSharedTextIsExcluded a delegate to be invoked when the "exclude shared text" - * setting is updated. The argument is whether the shared text is to be excluded. - * @param firstVisibleImageQuery a delegate that provides a reference to the first visible image - * View in the Sharesheet UI, if any, or null. - * @param activityStarter a delegate to launch activities when actions are selected. - * @param finishCallback a delegate to close the Sharesheet UI (e.g. because some action was - * completed). - */ - public ChooserActionFactory( - Context context, - Intent targetIntent, - String referrerPackageName, - List chooserActions, - Optional imageEditor, - EventLog log, - Consumer onUpdateSharedTextIsExcluded, - Callable firstVisibleImageQuery, - ActionActivityStarter activityStarter, - @Nullable ShareResultSender shareResultSender, - Consumer finishCallback, - ClipboardManager clipboardManager) { - this( - context, - makeCopyButtonRunnable( - clipboardManager, - targetIntent, - referrerPackageName, - finishCallback, - log), - makeEditButtonRunnable( - getEditSharingTarget( - context, - targetIntent, - imageEditor), - firstVisibleImageQuery, - activityStarter, - log), - chooserActions, - onUpdateSharedTextIsExcluded, - log, - shareResultSender, - finishCallback); - - } - - @VisibleForTesting - ChooserActionFactory( - Context context, - @Nullable Runnable copyButtonRunnable, - Runnable editButtonRunnable, - List customActions, - Consumer onUpdateSharedTextIsExcluded, - EventLog log, - @Nullable ShareResultSender shareResultSender, - Consumer finishCallback) { - mContext = context; - mCopyButtonRunnable = copyButtonRunnable; - mEditButtonRunnable = editButtonRunnable; - mCustomActions = ImmutableList.copyOf(customActions); - mExcludeSharedTextAction = onUpdateSharedTextIsExcluded; - mLog = log; - mShareResultSender = shareResultSender; - mFinishCallback = finishCallback; - - if (mShareResultSender != null) { - mEditButtonRunnable = () -> { - mShareResultSender.onActionSelected(ShareAction.SYSTEM_EDIT); - editButtonRunnable.run(); - }; - if (mCopyButtonRunnable != null) { - mCopyButtonRunnable = () -> { - mShareResultSender.onActionSelected(ShareAction.SYSTEM_COPY); - copyButtonRunnable.run(); - }; - } - } - } - - @Override - @Nullable - public Runnable getEditButtonRunnable() { - return mEditButtonRunnable; - } - - @Override - @Nullable - public Runnable getCopyButtonRunnable() { - return mCopyButtonRunnable; - } - - /** Create custom actions */ - @Override - public List createCustomActions() { - List actions = new ArrayList<>(); - for (int i = 0; i < mCustomActions.size(); i++) { - final int position = i; - ActionRow.Action actionRow = createCustomAction( - mContext, - mCustomActions.get(i), - () -> logCustomAction(position), - mShareResultSender, - mFinishCallback); - if (actionRow != null) { - actions.add(actionRow); - } - } - return actions; - } - - /** - *

- * Creates an exclude-text action that can be called when the user changes shared text - * status in the Media + Text preview. - *

- *

- * true argument value indicates that the text should be excluded. - *

- */ - @Override - public Consumer getExcludeSharedTextAction() { - return mExcludeSharedTextAction; - } - - @Nullable - private static Runnable makeCopyButtonRunnable( - ClipboardManager clipboardManager, - Intent targetIntent, - String referrerPackageName, - Consumer finishCallback, - EventLog log) { - final ClipData clipData; - try { - clipData = extractTextToCopy(targetIntent); - } catch (Throwable t) { - Log.e(TAG, "Failed to extract data to copy", t); - return null; - } - if (clipData == null) { - return null; - } - return () -> { - clipboardManager.setPrimaryClipAsPackage(clipData, referrerPackageName); - - log.logActionSelected(EventLog.SELECTION_TYPE_COPY); - finishCallback.accept(Activity.RESULT_OK); - }; - } - - @Nullable - private static ClipData extractTextToCopy(Intent targetIntent) { - if (targetIntent == null) { - return null; - } - - final String action = targetIntent.getAction(); - - ClipData clipData = null; - if (Intent.ACTION_SEND.equals(action)) { - String extraText = targetIntent.getStringExtra(Intent.EXTRA_TEXT); - - if (extraText != null) { - clipData = ClipData.newPlainText(null, extraText); - } else { - Log.w(TAG, "No data available to copy to clipboard"); - } - } else { - // expected to only be visible with ACTION_SEND (when a text is shared) - Log.d(TAG, "Action (" + action + ") not supported for copying to clipboard"); - } - return clipData; - } - - private static TargetInfo getEditSharingTarget( - Context context, - Intent originalIntent, - Optional imageEditor) { - - final Intent resolveIntent = new Intent(originalIntent); - // Retain only URI permission grant flags if present. Other flags may prevent the scene - // transition animation from running (i.e FLAG_ACTIVITY_NO_ANIMATION, - // FLAG_ACTIVITY_NEW_TASK, FLAG_ACTIVITY_NEW_DOCUMENT) but also not needed. - resolveIntent.setFlags(originalIntent.getFlags() & URI_PERMISSION_INTENT_FLAGS); - imageEditor.ifPresent(resolveIntent::setComponent); - resolveIntent.setAction(Intent.ACTION_EDIT); - resolveIntent.putExtra(EDIT_SOURCE, EDIT_SOURCE_SHARESHEET); - String originalAction = originalIntent.getAction(); - if (Intent.ACTION_SEND.equals(originalAction)) { - if (resolveIntent.getData() == null) { - Uri uri = resolveIntent.getParcelableExtra(Intent.EXTRA_STREAM); - if (uri != null) { - String mimeType = context.getContentResolver().getType(uri); - resolveIntent.setDataAndType(uri, mimeType); - } - } - } else { - Log.e(TAG, originalAction + " is not supported."); - return null; - } - final ResolveInfo ri = context.getPackageManager().resolveActivity( - resolveIntent, PackageManager.GET_META_DATA); - if (ri == null || ri.activityInfo == null) { - Log.e(TAG, "Device-specified editor (" + imageEditor + ") not available"); - return null; - } - - final DisplayResolveInfo dri = DisplayResolveInfo.newDisplayResolveInfo( - originalIntent, - ri, - context.getString(R.string.screenshot_edit), - "", - resolveIntent); - dri.getDisplayIconHolder().setDisplayIcon( - context.getDrawable(com.android.internal.R.drawable.ic_screenshot_edit)); - return dri; - } - - private static Runnable makeEditButtonRunnable( - TargetInfo editSharingTarget, - Callable firstVisibleImageQuery, - ActionActivityStarter activityStarter, - EventLog log) { - return () -> { - // Log share completion via edit. - log.logActionSelected(EventLog.SELECTION_TYPE_EDIT); - - View firstImageView = null; - try { - firstImageView = firstVisibleImageQuery.call(); - } catch (Exception e) { /* ignore */ } - // Action bar is user-independent; always start as primary. - if (firstImageView == null) { - activityStarter.safelyStartActivityAsPersonalProfileUser(editSharingTarget); - } else { - activityStarter.safelyStartActivityAsPersonalProfileUserWithSharedElementTransition( - editSharingTarget, firstImageView, IMAGE_EDITOR_SHARED_ELEMENT); - } - }; - } - - @Nullable - static ActionRow.Action createCustomAction( - Context context, - @Nullable ChooserAction action, - Runnable loggingRunnable, - ShareResultSender shareResultSender, - Consumer finishCallback) { - if (action == null) { - return null; - } - Drawable icon = action.getIcon().loadDrawable(context); - if (icon == null && TextUtils.isEmpty(action.getLabel())) { - return null; - } - return new ActionRow.Action( - action.getLabel(), - icon, - () -> { - try { - action.getAction().send( - null, - 0, - null, - null, - null, - null, - ActivityOptions.makeCustomAnimation( - context, - R.anim.slide_in_right, - R.anim.slide_out_left) - .toBundle()); - } catch (PendingIntent.CanceledException e) { - Log.d(TAG, "Custom action, " + action.getLabel() + ", has been cancelled"); - } - if (loggingRunnable != null) { - loggingRunnable.run(); - } - if (shareResultSender != null) { - shareResultSender.onActionSelected(ShareAction.APPLICATION_DEFINED); - } - finishCallback.accept(Activity.RESULT_OK); - } - ); - } - - void logCustomAction(int position) { - mLog.logCustomActionSelected(position); - } -} diff --git a/java/src/com/android/intentresolver/v2/ChooserActivity.java b/java/src/com/android/intentresolver/v2/ChooserActivity.java deleted file mode 100644 index 5f3129f8..00000000 --- a/java/src/com/android/intentresolver/v2/ChooserActivity.java +++ /dev/null @@ -1,2612 +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.v2; - -import static android.app.VoiceInteractor.PickOptionRequest.Option; -import static android.app.admin.DevicePolicyResources.Strings.Core.RESOLVER_CANT_ACCESS_PERSONAL; -import static android.app.admin.DevicePolicyResources.Strings.Core.RESOLVER_CANT_ACCESS_WORK; -import static android.app.admin.DevicePolicyResources.Strings.Core.RESOLVER_CANT_SHARE_WITH_PERSONAL; -import static android.app.admin.DevicePolicyResources.Strings.Core.RESOLVER_CANT_SHARE_WITH_WORK; -import static android.app.admin.DevicePolicyResources.Strings.Core.RESOLVER_CROSS_PROFILE_BLOCKED_TITLE; -import static android.content.Intent.FLAG_ACTIVITY_NEW_TASK; -import static android.stats.devicepolicy.nano.DevicePolicyEnums.RESOLVER_EMPTY_STATE_NO_SHARING_TO_PERSONAL; -import static android.stats.devicepolicy.nano.DevicePolicyEnums.RESOLVER_EMPTY_STATE_NO_SHARING_TO_WORK; -import static android.view.WindowManager.LayoutParams.SYSTEM_FLAG_HIDE_NON_SYSTEM_OVERLAY_WINDOWS; - -import static androidx.lifecycle.LifecycleKt.getCoroutineScope; - -import static com.android.intentresolver.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.util.LatencyTracker.ACTION_LOAD_SHARE_SHEET; - -import static java.util.Objects.requireNonNull; - -import android.app.ActivityManager; -import android.app.ActivityOptions; -import android.app.ActivityThread; -import android.app.VoiceInteractor; -import android.app.admin.DevicePolicyEventLogger; -import android.app.prediction.AppPredictor; -import android.app.prediction.AppTarget; -import android.app.prediction.AppTargetEvent; -import android.app.prediction.AppTargetId; -import android.content.ClipboardManager; -import android.content.ComponentName; -import android.content.ContentResolver; -import android.content.Context; -import android.content.Intent; -import android.content.IntentFilter; -import android.content.IntentSender; -import android.content.SharedPreferences; -import android.content.pm.ActivityInfo; -import android.content.pm.PackageManager; -import android.content.pm.ResolveInfo; -import android.content.pm.ShortcutInfo; -import android.content.res.Configuration; -import android.database.Cursor; -import android.graphics.Insets; -import android.net.Uri; -import android.os.Bundle; -import android.os.StrictMode; -import android.os.SystemClock; -import android.os.Trace; -import android.os.UserHandle; -import android.service.chooser.ChooserTarget; -import android.stats.devicepolicy.DevicePolicyEnums; -import android.text.TextUtils; -import android.util.Log; -import android.util.Slog; -import android.view.Gravity; -import android.view.LayoutInflater; -import android.view.View; -import android.view.ViewGroup; -import android.view.ViewGroup.LayoutParams; -import android.view.ViewTreeObserver; -import android.view.Window; -import android.view.WindowInsets; -import android.view.WindowManager; -import android.widget.FrameLayout; -import android.widget.ImageView; -import android.widget.TabHost; -import android.widget.TextView; -import android.widget.Toast; - -import androidx.annotation.MainThread; -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; -import androidx.fragment.app.FragmentActivity; -import androidx.lifecycle.ViewModelProvider; -import androidx.lifecycle.viewmodel.CreationExtras; -import androidx.recyclerview.widget.GridLayoutManager; -import androidx.recyclerview.widget.RecyclerView; -import androidx.viewpager.widget.ViewPager; - -import com.android.intentresolver.ChooserGridLayoutManager; -import com.android.intentresolver.ChooserListAdapter; -import com.android.intentresolver.ChooserRefinementManager; -import com.android.intentresolver.ChooserStackedAppDialogFragment; -import com.android.intentresolver.ChooserTargetActionsDialogFragment; -import com.android.intentresolver.EnterTransitionAnimationDelegate; -import com.android.intentresolver.FeatureFlags; -import com.android.intentresolver.IntentForwarderActivity; -import com.android.intentresolver.PackagesChangedListener; -import com.android.intentresolver.R; -import com.android.intentresolver.ResolverListAdapter; -import com.android.intentresolver.ResolverListController; -import com.android.intentresolver.ResolverViewPager; -import com.android.intentresolver.StartsSelectedItem; -import com.android.intentresolver.chooser.DisplayResolveInfo; -import com.android.intentresolver.chooser.MultiDisplayResolveInfo; -import com.android.intentresolver.chooser.TargetInfo; -import com.android.intentresolver.contentpreview.BasePreviewViewModel; -import com.android.intentresolver.contentpreview.ChooserContentPreviewUi; -import com.android.intentresolver.contentpreview.HeadlineGeneratorImpl; -import com.android.intentresolver.contentpreview.PreviewViewModel; -import com.android.intentresolver.emptystate.CompositeEmptyStateProvider; -import com.android.intentresolver.emptystate.CrossProfileIntentsChecker; -import com.android.intentresolver.emptystate.EmptyState; -import com.android.intentresolver.emptystate.EmptyStateProvider; -import com.android.intentresolver.grid.ChooserGridAdapter; -import com.android.intentresolver.icons.TargetDataLoader; -import com.android.intentresolver.inject.Background; -import com.android.intentresolver.logging.EventLog; -import com.android.intentresolver.measurements.Tracer; -import com.android.intentresolver.model.AbstractResolverComparator; -import com.android.intentresolver.model.AppPredictionServiceResolverComparator; -import com.android.intentresolver.model.ResolverRankerServiceResolverComparator; -import com.android.intentresolver.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; -import com.android.intentresolver.v2.emptystate.WorkProfilePausedEmptyStateProvider; -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.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.viewmodel.ChooserViewModel; -import com.android.intentresolver.widget.ActionRow; -import com.android.intentresolver.widget.ImagePreviewView; -import com.android.intentresolver.widget.ResolverDrawerLayout; -import com.android.internal.annotations.VisibleForTesting; -import com.android.internal.content.PackageMonitor; -import com.android.internal.logging.MetricsLogger; -import com.android.internal.logging.nano.MetricsProto.MetricsEvent; -import com.android.internal.util.LatencyTracker; - -import com.google.common.collect.ImmutableList; - -import dagger.hilt.android.AndroidEntryPoint; - -import kotlin.Pair; - -import java.util.ArrayList; -import java.util.Arrays; -import java.util.Collections; -import java.util.HashMap; -import java.util.List; -import java.util.Map; -import java.util.Objects; -import java.util.Optional; -import java.util.Set; -import java.util.concurrent.ExecutorService; -import java.util.concurrent.Executors; -import java.util.concurrent.atomic.AtomicLong; -import java.util.function.Consumer; -import java.util.function.Supplier; - -import javax.inject.Inject; - -import kotlinx.coroutines.CoroutineDispatcher; - -/** - * The Chooser Activity handles intent resolution specifically for sharing intents - - * for example, as generated by {@see android.content.Intent#createChooser(Intent, CharSequence)}. - * - */ -@SuppressWarnings("OptionalUsedAsFieldOrParameterType") -@AndroidEntryPoint(FragmentActivity.class) -public class ChooserActivity extends Hilt_ChooserActivity implements - ResolverListAdapter.ResolverListCommunicator, PackagesChangedListener, StartsSelectedItem { - private static final String TAG = "ChooserActivity"; - - /** - * Boolean extra to change the following behavior: Normally, ChooserActivity finishes itself - * in onStop when launched in a new task. If this extra is set to true, we do not finish - * ourselves when onStop gets called. - */ - public static final String EXTRA_PRIVATE_RETAIN_IN_ON_STOP - = "com.android.internal.app.ChooserActivity.EXTRA_PRIVATE_RETAIN_IN_ON_STOP"; - - /** - * Transition name for the first image preview. - * To be used for shared element transition into this activity. - */ - public static final String FIRST_IMAGE_PREVIEW_TRANSITION_NAME = "screenshot_preview_image"; - - private static final boolean DEBUG = true; - - public static final String LAUNCH_LOCATION_DIRECT_SHARE = "direct_share"; - private static final String SHORTCUT_TARGET = "shortcut_target"; - - ////////////////////////////////////////////////////////////////////////////////////////////// - // Inherited properties. - ////////////////////////////////////////////////////////////////////////////////////////////// - private static final String TAB_TAG_PERSONAL = "personal"; - private static final String TAB_TAG_WORK = "work"; - - private static final String LAST_SHOWN_TAB_KEY = "last_shown_tab_key"; - protected static final String METRICS_CATEGORY_CHOOSER = "intent_chooser"; - - private int mLayoutId; - private UserHandle mHeaderCreatorUser; - private boolean mRegistered; - private PackageMonitor mPersonalPackageMonitor; - private PackageMonitor mWorkPackageMonitor; - protected View mProfileView; - - protected ResolverDrawerLayout mResolverDrawerLayout; - protected ChooserMultiProfilePagerAdapter mChooserMultiProfilePagerAdapter; - protected final LatencyTracker mLatencyTracker = getLatencyTracker(); - - /** See {@link #setRetainInOnStop}. */ - private boolean mRetainInOnStop; - protected Insets mSystemWindowInsets = null; - private ResolverActivity.PickTargetOptionRequest mPickOptionRequest; - - @Nullable - private OnSwitchOnWorkSelectedListener mOnSwitchOnWorkSelectedListener; - - ////////////////////////////////////////////////////////////////////////////////////////////// - ////////////////////////////////////////////////////////////////////////////////////////////// - - - // TODO: these data structures are for one-time use in shuttling data from where they're - // populated in `ShortcutToChooserTargetConverter` to where they're consumed in - // `ShortcutSelectionLogic` which packs the appropriate elements into the final `TargetInfo`. - // That flow should be refactored so that `ChooserActivity` isn't responsible for holding their - // intermediate data, and then these members can be removed. - private final Map mDirectShareAppTargetCache = new HashMap<>(); - private final Map mDirectShareShortcutInfoCache = new HashMap<>(); - - private static final int TARGET_TYPE_DEFAULT = 0; - private static final int TARGET_TYPE_CHOOSER_TARGET = 1; - private static final int TARGET_TYPE_SHORTCUTS_FROM_SHORTCUT_MANAGER = 2; - private static final int TARGET_TYPE_SHORTCUTS_FROM_PREDICTION_SERVICE = 3; - - private static final int SCROLL_STATUS_IDLE = 0; - private static final int SCROLL_STATUS_SCROLLING_VERTICAL = 1; - private static final int SCROLL_STATUS_SCROLLING_HORIZONTAL = 2; - - @Inject public UserInteractor mUserInteractor; - @Inject @Background public CoroutineDispatcher mBackgroundDispatcher; - @Inject public ChooserHelper mChooserHelper; - @Inject public FeatureFlags mFeatureFlags; - @Inject public android.service.chooser.FeatureFlags mChooserServiceFeatureFlags; - @Inject public EventLog mEventLog; - @Inject @AppPredictionAvailable public boolean mAppPredictionAvailable; - @Inject @ImageEditor public Optional mImageEditor; - @Inject @NearbyShare public Optional 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; - - private ActivityModel mActivityModel; - private ChooserRequest mRequest; - private ProfileHelper mProfiles; - private ProfileAvailability mProfileAvailability; - @Nullable private ShareResultSender mShareResultSender; - - private ChooserRefinementManager mRefinementManager; - - private ChooserContentPreviewUi mChooserContentPreviewUi; - - private boolean mShouldDisplayLandscape; - private long mChooserShownTime; - protected boolean mIsSuccessfullySelected; - - private int mCurrAvailableWidth = 0; - private Insets mLastAppliedInsets = null; - private int mLastNumberOfChildren = -1; - private int mMaxTargetsPerRow = 1; - - private static final int MAX_LOG_RANK_POSITION = 12; - - // TODO: are these used anywhere? They should probably be migrated to ChooserRequestParameters. - private static final int MAX_EXTRA_INITIAL_INTENTS = 2; - private static final int MAX_EXTRA_CHOOSER_TARGETS = 2; - - private SharedPreferences mPinnedSharedPrefs; - private static final String PINNED_SHARED_PREFS_NAME = "chooser_pin_settings"; - - private final ExecutorService mBackgroundThreadPoolExecutor = Executors.newFixedThreadPool(5); - - private int mScrollStatus = SCROLL_STATUS_IDLE; - - private final EnterTransitionAnimationDelegate mEnterTransitionAnimationDelegate = - new EnterTransitionAnimationDelegate(this, () -> mResolverDrawerLayout); - - private final View mContentView = null; - - private final Map mProfileRecords = new HashMap<>(); - - private boolean mExcludeSharedText = false; - /** - * When we intend to finish the activity with a shared element transition, we can't immediately - * finish() when the transition is invoked, as the receiving end may not be able to start the - * animation and the UI breaks if this takes too long. Instead we defer finishing until onStop - * in order to wait for the transition to begin. - */ - private boolean mFinishWhenStopped = false; - - private final AtomicLong mIntentReceivedTime = new AtomicLong(-1); - - protected ActivityModel createActivityModel() { - return ActivityModel.createFrom(this); - } - - private ChooserViewModel mViewModel; - - @NonNull - @Override - public CreationExtras getDefaultViewModelCreationExtras() { - return addDefaultArgs( - super.getDefaultViewModelCreationExtras(), - new Pair<>(ACTIVITY_MODEL_KEY, createActivityModel())); - } - - @Override - protected void onCreate(Bundle savedInstanceState) { - super.onCreate(savedInstanceState); - Log.i(TAG, "onCreate"); - - setTheme(R.style.Theme_DeviceDefault_Chooser); - - // Initializer is invoked when this function returns, via Lifecycle. - mChooserHelper.setInitializer(this::initialize); - if (mChooserServiceFeatureFlags.chooserPayloadToggling()) { - mChooserHelper.setOnChooserRequestChanged(this::onChooserRequestChanged); - } - } - - @Override - protected final void onStart() { - super.onStart(); - this.getWindow().addSystemFlags(SYSTEM_FLAG_HIDE_NON_SYSTEM_OVERLAY_WINDOWS); - } - - @Override - protected final void onResume() { - super.onResume(); - Log.d(TAG, "onResume: " + getComponentName().flattenToShortString()); - mFinishWhenStopped = false; - mRefinementManager.onActivityResume(); - } - - @Override - protected final void onStop() { - super.onStop(); - - final Window window = this.getWindow(); - final WindowManager.LayoutParams attrs = window.getAttributes(); - attrs.privateFlags &= ~SYSTEM_FLAG_HIDE_NON_SYSTEM_OVERLAY_WINDOWS; - window.setAttributes(attrs); - - if (mRegistered) { - mPersonalPackageMonitor.unregister(); - if (mWorkPackageMonitor != null) { - mWorkPackageMonitor.unregister(); - } - mRegistered = false; - } - final Intent intent = getIntent(); - if ((intent.getFlags() & FLAG_ACTIVITY_NEW_TASK) != 0 && !isVoiceInteraction() - && !mRetainInOnStop) { - // This resolver is in the unusual situation where it has been - // launched at the top of a new task. We don't let it be added - // to the recent tasks shown to the user, and we need to make sure - // that each time we are launched we get the correct launching - // uid (not re-using the same resolver from an old launching uid), - // so we will now finish ourself since being no longer visible, - // the user probably can't get back to us. - if (!isChangingConfigurations()) { - finish(); - } - } - - if (mRefinementManager != null) { - mRefinementManager.onActivityStop(isChangingConfigurations()); - } - - if (mFinishWhenStopped) { - mFinishWhenStopped = false; - finish(); - } - } - - @Override - protected final void onSaveInstanceState(Bundle outState) { - super.onSaveInstanceState(outState); - ViewPager viewPager = findViewById(com.android.internal.R.id.profile_pager); - if (viewPager != null) { - outState.putInt(LAST_SHOWN_TAB_KEY, viewPager.getCurrentItem()); - } - } - - @Override - protected final void onRestart() { - super.onRestart(); - if (!mRegistered) { - mPersonalPackageMonitor.register( - this, - getMainLooper(), - mProfiles.getPersonalHandle(), - false); - if (mProfiles.getWorkProfilePresent()) { - if (mWorkPackageMonitor == null) { - mWorkPackageMonitor = createPackageMonitor( - mChooserMultiProfilePagerAdapter.getWorkListAdapter()); - } - mWorkPackageMonitor.register( - this, - getMainLooper(), - mProfiles.getWorkHandle(), - false); - } - mRegistered = true; - } - mChooserMultiProfilePagerAdapter.getActiveListAdapter().handlePackagesChanged(); - } - - @Override - protected void onDestroy() { - super.onDestroy(); - if (!isChangingConfigurations() && mPickOptionRequest != null) { - mPickOptionRequest.cancel(); - } - if (mChooserMultiProfilePagerAdapter != null) { - mChooserMultiProfilePagerAdapter.destroy(); - } - - if (isFinishing()) { - mLatencyTracker.onActionCancel(ACTION_LOAD_SHARE_SHEET); - } - - mBackgroundThreadPoolExecutor.shutdownNow(); - - destroyProfileRecords(); - } - - /** DO NOT CALL. Only for use from ChooserHelper as a callback. */ - private void initialize() { - - mViewModel = new ViewModelProvider(this).get(ChooserViewModel.class); - mRequest = mViewModel.getRequest().getValue(); - mActivityModel = mViewModel.getActivityModel(); - - mProfiles = new ProfileHelper( - mUserInteractor, - getCoroutineScope(getLifecycle()), - mBackgroundDispatcher, - mFeatureFlags); - - mProfileAvailability = new ProfileAvailability( - mUserInteractor, - getCoroutineScope(getLifecycle()), - mBackgroundDispatcher); - - mProfileAvailability.setOnProfileStatusChange(this::onWorkProfileStatusUpdated); - - mIntentReceivedTime.set(System.currentTimeMillis()); - mLatencyTracker.onActionStart(ACTION_LOAD_SHARE_SHEET); - - mPinnedSharedPrefs = getPinnedSharedPrefs(this); - updateShareResultSender(); - - mMaxTargetsPerRow = - getResources().getInteger(R.integer.config_chooser_max_targets_per_row); - mShouldDisplayLandscape = - shouldDisplayLandscape(getResources().getConfiguration().orientation); - - setRetainInOnStop(mRequest.shouldRetainInOnStop()); - createProfileRecords( - new AppPredictorFactory( - this, - Objects.toString(mRequest.getSharedText(), null), - mRequest.getShareTargetFilter(), - mAppPredictionAvailable - ), - mRequest.getShareTargetFilter() - ); - - - mChooserMultiProfilePagerAdapter = createMultiProfilePagerAdapter( - /* context = */ this, - mProfilePagerResources, - mRequest, - mProfiles, - mProfileAvailability, - mRequest.getInitialIntents(), - mMaxTargetsPerRow, - mFeatureFlags); - - if (!configureContentView(mTargetDataLoader)) { - mPersonalPackageMonitor = createPackageMonitor( - mChooserMultiProfilePagerAdapter.getPersonalListAdapter()); - mPersonalPackageMonitor.register( - this, - getMainLooper(), - mProfiles.getPersonalHandle(), - false - ); - if (mProfiles.getWorkProfilePresent()) { - mWorkPackageMonitor = createPackageMonitor( - mChooserMultiProfilePagerAdapter.getWorkListAdapter()); - mWorkPackageMonitor.register( - this, - getMainLooper(), - mProfiles.getWorkHandle(), - false - ); - } - mRegistered = true; - final ResolverDrawerLayout rdl = findViewById( - com.android.internal.R.id.contentPanel); - if (rdl != null) { - rdl.setOnDismissedListener(new ResolverDrawerLayout.OnDismissedListener() { - @Override - public void onDismissed() { - finish(); - } - }); - - boolean hasTouchScreen = mPackageManager - .hasSystemFeature(PackageManager.FEATURE_TOUCHSCREEN); - - if (isVoiceInteraction() || !hasTouchScreen) { - rdl.setCollapsed(false); - } - - rdl.setSystemUiVisibility(View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION - | View.SYSTEM_UI_FLAG_LAYOUT_STABLE); - rdl.setOnApplyWindowInsetsListener(this::onApplyWindowInsets); - - mResolverDrawerLayout = rdl; - } - - Intent intent = mRequest.getTargetIntent(); - final Set categories = intent.getCategories(); - MetricsLogger.action(this, - mChooserMultiProfilePagerAdapter.getActiveListAdapter().hasFilteredItem() - ? MetricsEvent.ACTION_SHOW_APP_DISAMBIG_APP_FEATURED - : MetricsEvent.ACTION_SHOW_APP_DISAMBIG_NONE_FEATURED, - intent.getAction() + ":" + intent.getType() + ":" - + (categories != null ? Arrays.toString(categories.toArray()) - : "")); - } - - getEventLog().logSharesheetTriggered(); - mRefinementManager = new ViewModelProvider(this).get(ChooserRefinementManager.class); - mRefinementManager.getRefinementCompletion().observe(this, completion -> { - if (completion.consume()) { - TargetInfo targetInfo = completion.getTargetInfo(); - // targetInfo is non-null if the refinement process was successful. - if (targetInfo != null) { - maybeRemoveSharedText(targetInfo); - - // We already block suspended targets from going to refinement, and we probably - // can't recover a Chooser session if that's the reason the refined target fails - // to launch now. Fire-and-forget the refined launch; ignore the return value - // and just make sure the Sharesheet session gets cleaned up regardless. - final ResolveInfo ri = targetInfo.getResolveInfo(); - final Intent intent1 = targetInfo.getResolvedIntent(); - - safelyStartActivity(targetInfo); - - // Rely on the ActivityManager to pop up a dialog regarding app suspension - // and return false - targetInfo.isSuspended(); - } - - finish(); - } - }); - BasePreviewViewModel previewViewModel = - new ViewModelProvider(this, createPreviewViewModelFactory()) - .get(BasePreviewViewModel.class); - previewViewModel.init( - mRequest.getTargetIntent(), - mRequest.getAdditionalContentUri(), - mChooserServiceFeatureFlags.chooserPayloadToggling()); - mChooserContentPreviewUi = new ChooserContentPreviewUi( - getCoroutineScope(getLifecycle()), - previewViewModel.getPreviewDataProvider(), - mRequest.getTargetIntent(), - previewViewModel.getImageLoader(), - createChooserActionFactory(), - createModifyShareActionFactory(), - mEnterTransitionAnimationDelegate, - new HeadlineGeneratorImpl(this), - mRequest.getContentTypeHint(), - mRequest.getMetadataText(), - mChooserServiceFeatureFlags.chooserPayloadToggling()); - updateStickyContentPreview(); - if (shouldShowStickyContentPreview() - || mChooserMultiProfilePagerAdapter - .getCurrentRootAdapter().getSystemRowCount() != 0) { - getEventLog().logActionShareWithPreview( - mChooserContentPreviewUi.getPreferredContentPreview()); - } - mChooserShownTime = System.currentTimeMillis(); - final long systemCost = mChooserShownTime - mIntentReceivedTime.get(); - getEventLog().logChooserActivityShown( - isWorkProfile(), mRequest.getTargetType(), systemCost); - if (mResolverDrawerLayout != null) { - mResolverDrawerLayout.addOnLayoutChangeListener(this::handleLayoutChange); - - mResolverDrawerLayout.setOnCollapsedChangedListener( - isCollapsed -> { - mChooserMultiProfilePagerAdapter.setIsCollapsed(isCollapsed); - getEventLog().logSharesheetExpansionChanged(isCollapsed); - }); - } - if (DEBUG) { - Log.d(TAG, "System Time Cost is " + systemCost); - } - getEventLog().logShareStarted( - mRequest.getReferrerPackage(), - mRequest.getTargetType(), - mRequest.getCallerChooserTargets().size(), - mRequest.getInitialIntents().size(), - isWorkProfile(), - mChooserContentPreviewUi.getPreferredContentPreview(), - 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 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 oldAltIntents = oldChooserRequest.getAdditionalTargets(); - List 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(); - } - - ////////////////////////////////////////////////////////////////////////////////////////////// - // Inherited methods - ////////////////////////////////////////////////////////////////////////////////////////////// - - private boolean isAutolaunching() { - return !mRegistered && isFinishing(); - } - - private boolean maybeAutolaunchIfSingleTarget() { - int count = mChooserMultiProfilePagerAdapter.getActiveListAdapter().getUnfilteredCount(); - if (count != 1) { - return false; - } - - if (mChooserMultiProfilePagerAdapter.getActiveListAdapter().getOtherProfile() != null) { - return false; - } - - // Only one target, so we're a candidate to auto-launch! - final TargetInfo target = mChooserMultiProfilePagerAdapter.getActiveListAdapter() - .targetInfoForPosition(0, false); - if (shouldAutoLaunchSingleChoice(target)) { - safelyStartActivity(target); - finish(); - return true; - } - return false; - } - - private boolean isTwoPagePersonalAndWorkConfiguration() { - return (mChooserMultiProfilePagerAdapter.getCount() == 2) - && mChooserMultiProfilePagerAdapter.hasPageForProfile(PROFILE_PERSONAL) - && mChooserMultiProfilePagerAdapter.hasPageForProfile(PROFILE_WORK); - } - - /** - * When we have a personal and a work profile, we auto launch in the following scenario: - * - There is 1 resolved target on each profile - * - That target is the same app on both profiles - * - The target app has permission to communicate cross profiles - * - The target app has declared it supports cross-profile communication via manifest metadata - */ - private boolean maybeAutolaunchIfCrossProfileSupported() { - if (!isTwoPagePersonalAndWorkConfiguration()) { - return false; - } - - ResolverListAdapter activeListAdapter = - (mChooserMultiProfilePagerAdapter.getActiveProfile() == PROFILE_PERSONAL) - ? mChooserMultiProfilePagerAdapter.getPersonalListAdapter() - : mChooserMultiProfilePagerAdapter.getWorkListAdapter(); - - ResolverListAdapter inactiveListAdapter = - (mChooserMultiProfilePagerAdapter.getActiveProfile() == PROFILE_PERSONAL) - ? mChooserMultiProfilePagerAdapter.getWorkListAdapter() - : mChooserMultiProfilePagerAdapter.getPersonalListAdapter(); - - if (!activeListAdapter.isTabLoaded() || !inactiveListAdapter.isTabLoaded()) { - return false; - } - - if ((activeListAdapter.getUnfilteredCount() != 1) - || (inactiveListAdapter.getUnfilteredCount() != 1)) { - return false; - } - - TargetInfo activeProfileTarget = activeListAdapter.targetInfoForPosition(0, false); - TargetInfo inactiveProfileTarget = inactiveListAdapter.targetInfoForPosition(0, false); - if (!Objects.equals( - activeProfileTarget.getResolvedComponentName(), - inactiveProfileTarget.getResolvedComponentName())) { - return false; - } - - if (!shouldAutoLaunchSingleChoice(activeProfileTarget)) { - return false; - } - - String packageName = activeProfileTarget.getResolvedComponentName().getPackageName(); - if (!mIntentForwarding.canAppInteractAcrossProfiles(this, packageName)) { - return false; - } - - DevicePolicyEventLogger - .createEvent(DevicePolicyEnums.RESOLVER_AUTOLAUNCH_CROSS_PROFILE_TARGET) - .setBoolean(activeListAdapter.getUserHandle() - .equals(mProfiles.getPersonalHandle())) - .setStrings(getMetricsCategory()) - .write(); - safelyStartActivity(activeProfileTarget); - finish(); - return true; - } - - /** - * @return {@code true} if a resolved target is autolaunched, otherwise {@code false} - */ - private boolean maybeAutolaunchActivity() { - int numberOfProfiles = mChooserMultiProfilePagerAdapter.getItemCount(); - // TODO(b/280988288): If the ChooserActivity is shown we should consider showing the - // correct intent-picker UIs (e.g., mini-resolver) if it was launched without - // ACTION_SEND. - if (numberOfProfiles == 1 && maybeAutolaunchIfSingleTarget()) { - return true; - } else if (maybeAutolaunchIfCrossProfileSupported()) { - return true; - } - return false; - } - - @Override // ResolverListCommunicator - public final void onPostListReady(ResolverListAdapter listAdapter, boolean doPostProcessing, - boolean rebuildCompleted) { - if (isAutolaunching()) { - return; - } - if (mChooserMultiProfilePagerAdapter - .shouldShowEmptyStateScreen((ChooserListAdapter) listAdapter)) { - mChooserMultiProfilePagerAdapter - .showEmptyResolverListEmptyState((ChooserListAdapter) listAdapter); - } else { - mChooserMultiProfilePagerAdapter.showListView((ChooserListAdapter) listAdapter); - } - // showEmptyResolverListEmptyState can mark the tab as loaded, - // which is a precondition for auto launching - if (rebuildCompleted && maybeAutolaunchActivity()) { - return; - } - if (doPostProcessing) { - maybeCreateHeader(listAdapter); - onListRebuilt(listAdapter, rebuildCompleted); - } - } - - private CharSequence getOrLoadDisplayLabel(TargetInfo info) { - if (info.isDisplayResolveInfo()) { - mTargetDataLoader.getOrLoadLabel((DisplayResolveInfo) info); - } - CharSequence displayLabel = info.getDisplayLabel(); - return displayLabel == null ? "" : displayLabel; - } - - protected final CharSequence getTitleForAction(Intent intent, int defaultTitleRes) { - final ActionTitle title = ActionTitle.forAction(intent.getAction()); - - // While there may already be a filtered item, we can only use it in the title if the list - // is already sorted and all information relevant to it is already in the list. - final boolean named = - mChooserMultiProfilePagerAdapter.getActiveListAdapter().getFilteredPosition() >= 0; - if (title == ActionTitle.DEFAULT && defaultTitleRes != 0) { - return getString(defaultTitleRes); - } else { - return named - ? getString( - title.namedTitleRes, - getOrLoadDisplayLabel( - mChooserMultiProfilePagerAdapter - .getActiveListAdapter().getFilteredItem())) - : getString(title.titleRes); - } - } - - /** - * Configure the area above the app selection list (title, content preview, etc). - */ - private void maybeCreateHeader(ResolverListAdapter listAdapter) { - if (mHeaderCreatorUser != null - && !listAdapter.getUserHandle().equals(mHeaderCreatorUser)) { - return; - } - if (!mProfiles.getWorkProfilePresent() - && listAdapter.getCount() == 0 && listAdapter.getPlaceholderCount() == 0) { - final TextView titleView = findViewById(com.android.internal.R.id.title); - if (titleView != null) { - titleView.setVisibility(View.GONE); - } - } - - CharSequence title = mRequest.getTitle() != null - ? mRequest.getTitle() - : getTitleForAction(mRequest.getTargetIntent(), - mRequest.getDefaultTitleResource()); - - if (!TextUtils.isEmpty(title)) { - final TextView titleView = findViewById(com.android.internal.R.id.title); - if (titleView != null) { - titleView.setText(title); - } - setTitle(title); - } - - final ImageView iconView = findViewById(com.android.internal.R.id.icon); - if (iconView != null) { - listAdapter.loadFilteredItemIconTaskAsync(iconView); - } - mHeaderCreatorUser = listAdapter.getUserHandle(); - } - - /** Start the activity specified by the {@link TargetInfo}.*/ - public final void safelyStartActivity(TargetInfo cti) { - // In case cloned apps are present, we would want to start those apps in cloned user - // space, which will not be same as the adapter's userHandle. resolveInfo.userHandle - // identifies the correct user space in such cases. - UserHandle activityUserHandle = cti.getResolveInfo().userHandle; - safelyStartActivityAsUser(cti, activityUserHandle, null); - } - - protected final void safelyStartActivityAsUser( - TargetInfo cti, UserHandle user, @Nullable Bundle options) { - // We're dispatching intents that might be coming from legacy apps, so - // don't kill ourselves. - StrictMode.disableDeathOnFileUriExposure(); - try { - safelyStartActivityInternal(cti, user, options); - } finally { - StrictMode.enableDeathOnFileUriExposure(); - } - } - - @VisibleForTesting - protected void safelyStartActivityInternal( - TargetInfo cti, UserHandle user, @Nullable Bundle options) { - // If the target is suspended, the activity will not be successfully launched. - // Do not unregister from package manager updates in this case - if (!cti.isSuspended() && mRegistered) { - if (mPersonalPackageMonitor != null) { - mPersonalPackageMonitor.unregister(); - } - if (mWorkPackageMonitor != null) { - mWorkPackageMonitor.unregister(); - } - mRegistered = false; - } - // If needed, show that intent is forwarded - // from managed profile to owner or other way around. - String profileSwitchMessage = mIntentForwarding.forwardMessageFor( - mRequest.getTargetIntent()); - if (profileSwitchMessage != null) { - Toast.makeText(this, profileSwitchMessage, Toast.LENGTH_LONG).show(); - } - try { - if (cti.startAsCaller(this, options, user.getIdentifier())) { - maybeSendShareResult(cti); - maybeLogCrossProfileTargetLaunch(cti, user); - } - } catch (RuntimeException e) { - Slog.wtf(TAG, - "Unable to launch as uid " + mActivityModel.getLaunchedFromUid() - + " package " + mActivityModel.getLaunchedFromPackage() + - ", while running in " + ActivityThread.currentProcessName(), e); - } - } - - private void maybeLogCrossProfileTargetLaunch(TargetInfo cti, UserHandle currentUserHandle) { - if (!mProfiles.getWorkProfilePresent() || currentUserHandle.equals(getUser())) { - return; - } - DevicePolicyEventLogger - .createEvent(DevicePolicyEnums.RESOLVER_CROSS_PROFILE_TARGET_OPENED) - .setBoolean(currentUserHandle.equals(mProfiles.getPersonalHandle())) - .setStrings(getMetricsCategory(), - cti.isInDirectShareMetricsCategory() ? "direct_share" : "other_target") - .write(); - } - - private LatencyTracker getLatencyTracker() { - return LatencyTracker.getInstance(this); - } - - /** - * If {@code retainInOnStop} is set to true, we will not finish ourselves when onStop gets - * called and we are launched in a new task. - */ - protected final void setRetainInOnStop(boolean retainInOnStop) { - mRetainInOnStop = retainInOnStop; - } - - // @NonFinalForTesting - @VisibleForTesting - protected CrossProfileIntentsChecker createCrossProfileIntentsChecker() { - return new CrossProfileIntentsChecker(getContentResolver()); - } - - protected final EmptyStateProvider createEmptyStateProvider( - ProfileHelper profileHelper, - ProfileAvailability profileAvailability) { - EmptyStateProvider blockerEmptyStateProvider = createBlockerEmptyStateProvider(); - - EmptyStateProvider workProfileOffEmptyStateProvider = - new WorkProfilePausedEmptyStateProvider( - this, - profileHelper, - profileAvailability, - /* onSwitchOnWorkSelectedListener = */ - () -> { - if (mOnSwitchOnWorkSelectedListener != null) { - mOnSwitchOnWorkSelectedListener.onSwitchOnWorkSelected(); - } - }, - getMetricsCategory()); - - EmptyStateProvider noAppsEmptyStateProvider = new NoAppsAvailableEmptyStateProvider( - this, - profileHelper.getWorkHandle(), - profileHelper.getPersonalHandle(), - getMetricsCategory(), - profileHelper.getTabOwnerUserHandleForLaunch() - ); - - // Return composite provider, the order matters (the higher, the more priority) - return new CompositeEmptyStateProvider( - blockerEmptyStateProvider, - workProfileOffEmptyStateProvider, - noAppsEmptyStateProvider - ); - } - - /** - * Returns the {@link List} of {@link UserHandle} to pass on to the - * {@link ResolverRankerServiceResolverComparator} as per the provided {@code userHandle}. - */ - private List getResolverRankerServiceUserHandleList(UserHandle userHandle) { - return getResolverRankerServiceUserHandleListInternal(userHandle); - } - - private List getResolverRankerServiceUserHandleListInternal(UserHandle userHandle) { - List userList = new ArrayList<>(); - userList.add(userHandle); - // Add clonedProfileUserHandle to the list only if we are: - // a. Building the Personal Tab. - // b. CloneProfile exists on the device. - if (userHandle.equals(mProfiles.getPersonalHandle()) - && mProfiles.getCloneUserPresent()) { - userList.add(mProfiles.getCloneHandle()); - } - return userList; - } - - /** - * Start activity as a fixed user handle. - * @param cti TargetInfo to be launched. - * @param user User to launch this activity as. - */ - @VisibleForTesting(visibility = VisibleForTesting.Visibility.PROTECTED) - public final void safelyStartActivityAsUser(TargetInfo cti, UserHandle user) { - safelyStartActivityAsUser(cti, user, null); - } - - protected WindowInsets super_onApplyWindowInsets(View v, WindowInsets insets) { - mSystemWindowInsets = insets.getSystemWindowInsets(); - - mResolverDrawerLayout.setPadding(mSystemWindowInsets.left, mSystemWindowInsets.top, - mSystemWindowInsets.right, 0); - - // Need extra padding so the list can fully scroll up - // To accommodate for window insets - applyFooterView(mSystemWindowInsets.bottom); - - return insets.consumeSystemWindowInsets(); - } - - @Override // ResolverListCommunicator - public final void onHandlePackagesChanged(ResolverListAdapter listAdapter) { - if (!mChooserMultiProfilePagerAdapter.onHandlePackagesChanged( - (ChooserListAdapter) listAdapter, - mProfileAvailability.getWaitingToEnableProfile())) { - // We no longer have any items... just finish the activity. - finish(); - } - } - - final Option optionForChooserTarget(TargetInfo target, int index) { - return new Option(getOrLoadDisplayLabel(target), index); - } - - @Override // ResolverListCommunicator - public final void sendVoiceChoicesIfNeeded() { - if (!isVoiceInteraction()) { - // Clearly not needed. - return; - } - - int count = mChooserMultiProfilePagerAdapter.getActiveListAdapter().getCount(); - final Option[] options = new Option[count]; - for (int i = 0; i < options.length; i++) { - TargetInfo target = mChooserMultiProfilePagerAdapter.getActiveListAdapter().getItem(i); - if (target == null) { - // If this occurs, a new set of targets is being loaded. Let that complete, - // and have the next call to send voice choices proceed instead. - return; - } - options[i] = optionForChooserTarget(target, i); - } - - mPickOptionRequest = new ResolverActivity.PickTargetOptionRequest( - new VoiceInteractor.Prompt(getTitle()), options, null); - getVoiceInteractor().submitRequest(mPickOptionRequest); - } - - /** - * Sets up the content view. - * @return true if the activity is finishing and creation should halt. - */ - private boolean configureContentView(TargetDataLoader targetDataLoader) { - if (mChooserMultiProfilePagerAdapter.getActiveListAdapter() == null) { - throw new IllegalStateException("mMultiProfilePagerAdapter.getCurrentListAdapter() " - + "cannot be null."); - } - Trace.beginSection("configureContentView"); - // We partially rebuild the inactive adapter to determine if we should auto launch - // isTabLoaded will be true here if the empty state screen is shown instead of the list. - boolean rebuildCompleted = mChooserMultiProfilePagerAdapter.rebuildTabs( - mProfiles.getWorkProfilePresent()); - - mLayoutId = mFeatureFlags.scrollablePreview() - ? R.layout.chooser_grid_scrollable_preview - : R.layout.chooser_grid; - - setContentView(mLayoutId); - mChooserMultiProfilePagerAdapter.setupViewPager( - requireViewById(com.android.internal.R.id.profile_pager)); - boolean result = postRebuildList(rebuildCompleted); - Trace.endSection(); - return result; - } - - /** - * Finishing procedures to be performed after the list has been rebuilt. - *

Subclasses must call postRebuildListInternal at the end of postRebuildList. - * @param rebuildCompleted - * @return true if the activity is finishing and creation should halt. - */ - protected boolean postRebuildList(boolean rebuildCompleted) { - return postRebuildListInternal(rebuildCompleted); - } - - /** - * Add a label to signify that the user can pick a different app. - * @param adapter The adapter used to provide data to item views. - */ - public void addUseDifferentAppLabelIfNecessary(ResolverListAdapter adapter) { - final boolean useHeader = adapter.hasFilteredItem(); - if (useHeader) { - FrameLayout stub = findViewById(com.android.internal.R.id.stub); - stub.setVisibility(View.VISIBLE); - TextView textView = (TextView) LayoutInflater.from(this).inflate( - R.layout.resolver_different_item_header, null, false); - if (mProfiles.getWorkProfilePresent()) { - textView.setGravity(Gravity.CENTER); - } - stub.addView(textView); - } - } - private void setupViewVisibilities() { - ChooserListAdapter activeListAdapter = - mChooserMultiProfilePagerAdapter.getActiveListAdapter(); - if (!mChooserMultiProfilePagerAdapter.shouldShowEmptyStateScreen(activeListAdapter)) { - addUseDifferentAppLabelIfNecessary(activeListAdapter); - } - } - /** - * Finishing procedures to be performed after the list has been rebuilt. - * @param rebuildCompleted - * @return true if the activity is finishing and creation should halt. - */ - final boolean postRebuildListInternal(boolean rebuildCompleted) { - int count = mChooserMultiProfilePagerAdapter.getActiveListAdapter().getUnfilteredCount(); - - // We only rebuild asynchronously when we have multiple elements to sort. In the case where - // we're already done, we can check if we should auto-launch immediately. - if (rebuildCompleted && maybeAutolaunchActivity()) { - return true; - } - - setupViewVisibilities(); - - if (mProfiles.getWorkProfilePresent() - || (mProfiles.getPrivateProfilePresent() - && mProfileAvailability.isAvailable( - requireNonNull(mProfiles.getPrivateProfile())))) { - setupProfileTabs(); - } - - return false; - } - - private void setupProfileTabs() { - TabHost tabHost = findViewById(com.android.internal.R.id.profile_tabhost); - ViewPager viewPager = findViewById(com.android.internal.R.id.profile_pager); - - mChooserMultiProfilePagerAdapter.setupProfileTabs( - getLayoutInflater(), - tabHost, - viewPager, - R.layout.resolver_profile_tab_button, - com.android.internal.R.id.profile_pager, - () -> onProfileTabSelected(viewPager.getCurrentItem()), - new OnProfileSelectedListener() { - @Override - public void onProfilePageSelected(@ProfileType int profileId, int pageNumber) {} - - @Override - public void onProfilePageStateChanged(int state) { - onHorizontalSwipeStateChanged(state); - } - }); - mOnSwitchOnWorkSelectedListener = () -> { - View workTab = tabHost.getTabWidget().getChildAt( - mChooserMultiProfilePagerAdapter.getPageNumberForProfile(PROFILE_WORK)); - workTab.setFocusable(true); - workTab.setFocusableInTouchMode(true); - workTab.requestFocus(); - }; - } - - ////////////////////////////////////////////////////////////////////////////////////////////// - ////////////////////////////////////////////////////////////////////////////////////////////// - - private void createProfileRecords( - AppPredictorFactory factory, IntentFilter targetIntentFilter) { - UserHandle mainUserHandle = mProfiles.getPersonalHandle(); - ProfileRecord record = createProfileRecord(mainUserHandle, targetIntentFilter, factory); - if (record.shortcutLoader == null) { - Tracer.INSTANCE.endLaunchToShortcutTrace(); - } - - 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( - UserHandle userHandle, IntentFilter targetIntentFilter, AppPredictorFactory factory) { - AppPredictor appPredictor = factory.create(userHandle); - ShortcutLoader shortcutLoader = ActivityManager.isLowRamDeviceStatic() - ? null - : createShortcutLoader( - this, - appPredictor, - userHandle, - targetIntentFilter, - shortcutsResult -> onShortcutsLoaded(userHandle, shortcutsResult)); - ProfileRecord record = new ProfileRecord(appPredictor, shortcutLoader); - mProfileRecords.put(userHandle.getIdentifier(), record); - return record; - } - - @Nullable - private ProfileRecord getProfileRecord(UserHandle userHandle) { - return mProfileRecords.get(userHandle.getIdentifier()); - } - - @VisibleForTesting - protected ShortcutLoader createShortcutLoader( - Context context, - AppPredictor appPredictor, - UserHandle userHandle, - IntentFilter targetIntentFilter, - Consumer callback) { - return new ShortcutLoader( - context, - getCoroutineScope(getLifecycle()), - appPredictor, - userHandle, - targetIntentFilter, - callback); - } - - private SharedPreferences getPinnedSharedPrefs(Context context) { - return context.getSharedPreferences(PINNED_SHARED_PREFS_NAME, MODE_PRIVATE); - } - - 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 initialIntents, - int maxTargetsPerRow, - FeatureFlags featureFlags) { - Log.d(TAG, "createMultiProfilePagerAdapter"); - - Profile launchedAs = profileHelper.getLaunchedAsProfile(); - - Intent[] initialIntentArray = initialIntents.toArray(new Intent[0]); - List payloadIntents = request.getPayloadIntents(); - - List> tabs = new ArrayList<>(); - for (Profile profile : profileHelper.getProfiles()) { - if (profile.getType() == Profile.Type.PRIVATE - && !profileAvailability.isAvailable(profile)) { - continue; - } - ChooserGridAdapter adapter = createChooserGridAdapter( - context, - payloadIntents, - profile.equals(launchedAs) ? initialIntentArray : null, - profile.getPrimary().getHandle() - ); - tabs.add(new TabConfig<>( - /* profile = */ profile.getType().ordinal(), - profilePagerResources.profileTabLabel(profile.getType()), - profilePagerResources.profileTabAccessibilityLabel(profile.getType()), - /* tabTag = */ profile.getType().name(), - adapter)); - } - - EmptyStateProvider emptyStateProvider = - createEmptyStateProvider(profileHelper, profileAvailability); - - Supplier 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 = mRequest.isSendActionTarget(); - - final EmptyState noWorkToPersonalEmptyState = - new DevicePolicyBlockerEmptyState( - /* context= */ this, - /* devicePolicyStringTitleId= */ RESOLVER_CROSS_PROFILE_BLOCKED_TITLE, - /* defaultTitleResource= */ R.string.resolver_cross_profile_blocked, - /* devicePolicyStringSubtitleId= */ - isSendAction ? RESOLVER_CANT_SHARE_WITH_PERSONAL : RESOLVER_CANT_ACCESS_PERSONAL, - /* defaultSubtitleResource= */ - isSendAction ? R.string.resolver_cant_share_with_personal_apps_explanation - : R.string.resolver_cant_access_personal_apps_explanation, - /* devicePolicyEventId= */ RESOLVER_EMPTY_STATE_NO_SHARING_TO_PERSONAL, - /* devicePolicyEventCategory= */ ResolverActivity.METRICS_CATEGORY_CHOOSER); - - final EmptyState noPersonalToWorkEmptyState = - new DevicePolicyBlockerEmptyState( - /* context= */ this, - /* devicePolicyStringTitleId= */ RESOLVER_CROSS_PROFILE_BLOCKED_TITLE, - /* defaultTitleResource= */ R.string.resolver_cross_profile_blocked, - /* devicePolicyStringSubtitleId= */ - isSendAction ? RESOLVER_CANT_SHARE_WITH_WORK : RESOLVER_CANT_ACCESS_WORK, - /* defaultSubtitleResource= */ - isSendAction ? R.string.resolver_cant_share_with_work_apps_explanation - : R.string.resolver_cant_access_work_apps_explanation, - /* devicePolicyEventId= */ RESOLVER_EMPTY_STATE_NO_SHARING_TO_WORK, - /* devicePolicyEventCategory= */ ResolverActivity.METRICS_CATEGORY_CHOOSER); - - return new NoCrossProfileEmptyStateProvider( - mProfiles, - noWorkToPersonalEmptyState, - noPersonalToWorkEmptyState, - createCrossProfileIntentsChecker()); - } - - private int findSelectedProfile() { - return mProfiles.getLaunchedAsProfileType().ordinal(); - } - - /** - * Check if the profile currently used is a work profile. - * @return true if it is work profile, false if it is parent profile (or no work profile is - * set up) - */ - private boolean isWorkProfile() { - return mProfiles.getLaunchedAsProfileType() == Profile.Type.WORK; - } - - //@Override - protected PackageMonitor createPackageMonitor(ResolverListAdapter listAdapter) { - return new PackageMonitor() { - @Override - public void onSomePackagesChanged() { - handlePackagesChanged(listAdapter); - } - }; - } - - /** - * Update UI to reflect changes in data. - */ - @Override - public void handlePackagesChanged() { - handlePackagesChanged(/* listAdapter */ null); - } - - /** - * Update UI to reflect changes in data. - *

If {@code listAdapter} is {@code null}, both profile list adapters are updated if - * available. - */ - private void handlePackagesChanged(@Nullable ResolverListAdapter listAdapter) { - // Refresh pinned items - mPinnedSharedPrefs = getPinnedSharedPrefs(this); - if (listAdapter == null) { - mChooserMultiProfilePagerAdapter.refreshPackagesInAllTabs(); - } else { - listAdapter.handlePackagesChanged(); - } - } - - @Override - public void onConfigurationChanged(Configuration 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); - } - - mShouldDisplayLandscape = shouldDisplayLandscape(newConfig.orientation); - mMaxTargetsPerRow = getResources().getInteger(R.integer.config_chooser_max_targets_per_row); - mChooserMultiProfilePagerAdapter.setMaxTargetsPerRow(mMaxTargetsPerRow); - adjustPreviewWidth(newConfig.orientation, null); - updateStickyContentPreview(); - updateTabPadding(); - } - - private boolean shouldDisplayLandscape(int orientation) { - // Sharesheet fixes the # of items per row and therefore can not correctly lay out - // when in the restricted size of multi-window mode. In the future, would be nice - // to use minimum dp size requirements instead - return orientation == Configuration.ORIENTATION_LANDSCAPE && !isInMultiWindowMode(); - } - - private void adjustPreviewWidth(int orientation, View parent) { - int width = -1; - if (mShouldDisplayLandscape) { - width = getResources().getDimensionPixelSize(R.dimen.chooser_preview_width); - } - - parent = parent == null ? getWindow().getDecorView() : parent; - - updateLayoutWidth(com.android.internal.R.id.content_preview_file_layout, width, parent); - } - - private void updateTabPadding() { - 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 - // paddingHorizontal. - float padding = (tabs.getWidth() - mMaxTargetsPerRow * iconSize) - / mMaxTargetsPerRow / 2; - // Subtract the margin the buttons already have. - padding -= getResources().getDimension(R.dimen.resolver_profile_tab_margin); - tabs.setPadding((int) padding, 0, (int) padding, 0); - } - } - - private void updateLayoutWidth(int layoutResourceId, int width, View parent) { - View view = parent.findViewById(layoutResourceId); - if (view != null && view.getLayoutParams() != null) { - LayoutParams params = view.getLayoutParams(); - params.width = width; - view.setLayoutParams(params); - } - } - - /** - * Create a view that will be shown in the content preview area - * @param parent reference to the parent container where the view should be attached to - * @return content preview view - */ - protected ViewGroup createContentPreviewView(ViewGroup parent) { - ViewGroup layout = mChooserContentPreviewUi.displayContentPreview( - getResources(), - getLayoutInflater(), - parent, - mFeatureFlags.scrollablePreview() - ? findViewById(R.id.chooser_headline_row_container) - : null); - - if (layout != null) { - adjustPreviewWidth(getResources().getConfiguration().orientation, layout); - } - - return layout; - } - - @Nullable - private View getFirstVisibleImgPreviewView() { - View imagePreview = findViewById(R.id.scrollable_image_preview); - return imagePreview instanceof ImagePreviewView - ? ((ImagePreviewView) imagePreview).getTransitionView() - : null; - } - - /** - * Wrapping the ContentResolver call to expose for easier mocking, - * and to avoid mocking Android core classes. - */ - @VisibleForTesting - public Cursor queryResolver(ContentResolver resolver, Uri uri) { - return resolver.query(uri, null, null, null, null); - } - - private void destroyProfileRecords() { - mProfileRecords.values().forEach(ProfileRecord::destroy); - mProfileRecords.clear(); - } - - @Override // ResolverListCommunicator - public Intent getReplacementIntent(ActivityInfo aInfo, Intent defIntent) { - Intent result = defIntent; - if (mRequest.getReplacementExtras() != null) { - final Bundle replExtras = - mRequest.getReplacementExtras().getBundle(aInfo.packageName); - if (replExtras != null) { - result = new Intent(defIntent); - result.putExtras(replExtras); - } - } - if (aInfo.name.equals(IntentForwarderActivity.FORWARD_INTENT_TO_PARENT) - || aInfo.name.equals(IntentForwarderActivity.FORWARD_INTENT_TO_MANAGED_PROFILE)) { - result = Intent.createChooser(result, - getIntent().getCharSequenceExtra(Intent.EXTRA_TITLE)); - - // Don't auto-launch single intents if the intent is being forwarded. This is done - // because automatically launching a resolving application as a response to the user - // action of switching accounts is pretty unexpected. - result.putExtra(Intent.EXTRA_AUTO_LAUNCH_SINGLE_CHOICE, false); - } - return result; - } - - private void maybeSendShareResult(TargetInfo cti) { - if (mShareResultSender != null) { - final ComponentName target = cti.getResolvedComponentName(); - if (target != null) { - mShareResultSender.onComponentSelected(target, cti.isChooserTargetInfo()); - } - } - } - - private void addCallerChooserTargets() { - 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<>(mRequest.getCallerChooserTargets()), - TARGET_TYPE_DEFAULT, - /* directShareShortcutInfoCache */ Collections.emptyMap(), - /* directShareAppTargetCache */ Collections.emptyMap()); - } - } - } - - @Override // ResolverListCommunicator - public boolean shouldGetActivityMetadata() { - return true; - } - - public boolean shouldAutoLaunchSingleChoice(TargetInfo target) { - if (target.isSuspended()) { - return false; - } - - // TODO: migrate to ChooserRequest - return mViewModel.getActivityModel().getIntent() - .getBooleanExtra(Intent.EXTRA_AUTO_LAUNCH_SINGLE_CHOICE, true); - } - - private void showTargetDetails(TargetInfo targetInfo) { - if (targetInfo == null) return; - - List targetList = targetInfo.getAllDisplayTargets(); - if (targetList.isEmpty()) { - Log.e(TAG, "No displayable data to show target details"); - return; - } - - // TODO: implement these type-conditioned behaviors polymorphically, and consider moving - // the logic into `ChooserTargetActionsDialogFragment.show()`. - boolean isShortcutPinned = targetInfo.isSelectableTargetInfo() && targetInfo.isPinned(); - IntentFilter intentFilter; - intentFilter = targetInfo.isSelectableTargetInfo() - ? mRequest.getShareTargetFilter() : null; - String shortcutTitle = targetInfo.isSelectableTargetInfo() - ? targetInfo.getDisplayLabel().toString() : null; - String shortcutIdKey = targetInfo.getDirectShareShortcutId(); - - ChooserTargetActionsDialogFragment.show( - getSupportFragmentManager(), - targetList, - // Adding userHandle from ResolveInfo allows the app icon in Dialog Box to be - // resolved correctly within the same tab. - targetInfo.getResolveInfo().userHandle, - shortcutIdKey, - shortcutTitle, - isShortcutPinned, - intentFilter); - } - - protected boolean onTargetSelected(TargetInfo target) { - if (mRefinementManager.maybeHandleSelection( - target, - mRequest.getRefinementIntentSender(), - getApplication(), - getMainThreadHandler())) { - return false; - } - updateModelAndChooserCounts(target); - maybeRemoveSharedText(target); - safelyStartActivity(target); - - // Rely on the ActivityManager to pop up a dialog regarding app suspension - // and return false - return !target.isSuspended(); - } - - @Override - public void startSelected(int which, /* unused */ boolean always, boolean filtered) { - ChooserListAdapter currentListAdapter = - mChooserMultiProfilePagerAdapter.getActiveListAdapter(); - TargetInfo targetInfo = currentListAdapter - .targetInfoForPosition(which, filtered); - if (targetInfo != null && targetInfo.isNotSelectableTargetInfo()) { - return; - } - - final long selectionCost = System.currentTimeMillis() - mChooserShownTime; - - if ((targetInfo != null) && targetInfo.isMultiDisplayResolveInfo()) { - MultiDisplayResolveInfo mti = (MultiDisplayResolveInfo) targetInfo; - if (!mti.hasSelected()) { - // Add userHandle based badge to the stackedAppDialogBox. - ChooserStackedAppDialogFragment.show( - getSupportFragmentManager(), - mti, - which, - targetInfo.getResolveInfo().userHandle); - return; - } - } - if (isFinishing()) { - return; - } - - TargetInfo target = mChooserMultiProfilePagerAdapter.getActiveListAdapter() - .targetInfoForPosition(which, filtered); - if (target != null) { - if (onTargetSelected(target)) { - MetricsLogger.action( - this, MetricsEvent.ACTION_APP_DISAMBIG_TAP); - MetricsLogger.action(this, - mChooserMultiProfilePagerAdapter.getActiveListAdapter().hasFilteredItem() - ? MetricsEvent.ACTION_HIDE_APP_DISAMBIG_APP_FEATURED - : MetricsEvent.ACTION_HIDE_APP_DISAMBIG_NONE_FEATURED); - finish(); - } - } - - // TODO: both of the conditions around this switch logic *should* be redundant, and - // can be removed if certain invariants can be guaranteed. In particular, it seems - // like targetInfo (from `ChooserListAdapter.targetInfoForPosition()`) is *probably* - // expected to be null only at out-of-bounds indexes where `getPositionTargetType()` - // returns TARGET_BAD; then the switch falls through to a default no-op, and we don't - // need to null-check targetInfo. We only need the null check if it's possible that - // the ChooserListAdapter contains null elements "in the middle" of its list data, - // such that they're classified as belonging to one of the real target types. That - // should probably never happen. But why would this method ever be invoked with a - // null target at all? Even an out-of-bounds index should never be "selected"... - if ((currentListAdapter.getCount() > 0) && (targetInfo != null)) { - switch (currentListAdapter.getPositionTargetType(which)) { - case ChooserListAdapter.TARGET_SERVICE: - getEventLog().logShareTargetSelected( - EventLog.SELECTION_TYPE_SERVICE, - targetInfo.getResolveInfo().activityInfo.processName, - which, - /* directTargetAlsoRanked= */ getRankedPosition(targetInfo), - mRequest.getCallerChooserTargets().size(), - targetInfo.getHashedTargetIdForMetrics(this), - targetInfo.isPinned(), - mIsSuccessfullySelected, - selectionCost - ); - return; - case ChooserListAdapter.TARGET_CALLER: - case ChooserListAdapter.TARGET_STANDARD: - getEventLog().logShareTargetSelected( - EventLog.SELECTION_TYPE_APP, - targetInfo.getResolveInfo().activityInfo.processName, - (which - currentListAdapter.getSurfacedTargetInfo().size()), - /* directTargetAlsoRanked= */ -1, - currentListAdapter.getCallerTargetCount(), - /* directTargetHashed= */ null, - targetInfo.isPinned(), - mIsSuccessfullySelected, - selectionCost - ); - return; - case ChooserListAdapter.TARGET_STANDARD_AZ: - // A-Z targets are unranked standard targets; we use a value of -1 to mark that - // they are from the alphabetical pool. - // TODO: why do we log a different selection type if the -1 value already - // designates the same condition? - getEventLog().logShareTargetSelected( - EventLog.SELECTION_TYPE_STANDARD, - targetInfo.getResolveInfo().activityInfo.processName, - /* value= */ -1, - /* directTargetAlsoRanked= */ -1, - /* numCallerProvided= */ 0, - /* directTargetHashed= */ null, - /* isPinned= */ false, - mIsSuccessfullySelected, - selectionCost - ); - } - } - } - - private int getRankedPosition(TargetInfo targetInfo) { - String targetPackageName = - targetInfo.getChooserTargetComponentName().getPackageName(); - ChooserListAdapter currentListAdapter = - mChooserMultiProfilePagerAdapter.getActiveListAdapter(); - int maxRankedResults = Math.min( - currentListAdapter.getDisplayResolveInfoCount(), MAX_LOG_RANK_POSITION); - - for (int i = 0; i < maxRankedResults; i++) { - if (currentListAdapter.getDisplayResolveInfo(i) - .getResolveInfo().activityInfo.packageName.equals(targetPackageName)) { - return i; - } - } - return -1; - } - - protected void applyFooterView(int height) { - mChooserMultiProfilePagerAdapter.setFooterHeightInEveryAdapter(height); - } - - private void logDirectShareTargetReceived(UserHandle forUser) { - ProfileRecord profileRecord = getProfileRecord(forUser); - if (profileRecord == null) { - return; - } - getEventLog().logDirectShareTargetReceived( - MetricsEvent.ACTION_DIRECT_SHARE_TARGETS_LOADED_SHORTCUT_MANAGER, - (int) (SystemClock.elapsedRealtime() - profileRecord.loadingStartTime)); - } - - void updateModelAndChooserCounts(TargetInfo info) { - if (info != null && info.isMultiDisplayResolveInfo()) { - info = ((MultiDisplayResolveInfo) info).getSelectedTarget(); - } - if (info != null) { - sendClickToAppPredictor(info); - final ResolveInfo ri = info.getResolveInfo(); - Intent targetIntent = mRequest.getTargetIntent(); - if (ri != null && ri.activityInfo != null && targetIntent != null) { - ChooserListAdapter currentListAdapter = - mChooserMultiProfilePagerAdapter.getActiveListAdapter(); - if (currentListAdapter != null) { - sendImpressionToAppPredictor(info, currentListAdapter); - currentListAdapter.updateModel(info); - currentListAdapter.updateChooserCounts( - ri.activityInfo.packageName, - targetIntent.getAction(), - ri.userHandle); - } - if (DEBUG) { - Log.d(TAG, "ResolveInfo Package is " + ri.activityInfo.packageName); - Log.d(TAG, "Action to be updated is " + targetIntent.getAction()); - } - } else if (DEBUG) { - Log.d(TAG, "Can not log Chooser Counts of null ResolveInfo"); - } - } - mIsSuccessfullySelected = true; - } - - private void maybeRemoveSharedText(@NonNull TargetInfo targetInfo) { - Intent targetIntent = targetInfo.getTargetIntent(); - if (targetIntent == null) { - return; - } - 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) { - originalTargetIntent.setComponent(targetIntent.getComponent()); - } - // Use filterEquals as a way to check that the primary intent is in use (and not an - // alternative one). For example, an app is sharing an image and a link with mime type - // "image/png" and provides an alternative intent to share only the link with mime type - // "text/uri". Should there be a target that accepts only the latter, the alternative intent - // will be used and we don't want to exclude the link from it. - if (mExcludeSharedText && originalTargetIntent.filterEquals(targetIntent)) { - targetIntent.removeExtra(Intent.EXTRA_TEXT); - } - } - - private void sendImpressionToAppPredictor(TargetInfo targetInfo, ChooserListAdapter adapter) { - // Send DS target impression info to AppPredictor, only when user chooses app share. - if (targetInfo.isChooserTargetInfo()) { - return; - } - - AppPredictor directShareAppPredictor = getAppPredictor( - mChooserMultiProfilePagerAdapter.getCurrentUserHandle()); - if (directShareAppPredictor == null) { - return; - } - List surfacedTargetInfo = adapter.getSurfacedTargetInfo(); - List targetIds = new ArrayList<>(); - for (TargetInfo chooserTargetInfo : surfacedTargetInfo) { - ShortcutInfo shortcutInfo = chooserTargetInfo.getDirectShareShortcutInfo(); - if (shortcutInfo != null) { - ComponentName componentName = - chooserTargetInfo.getChooserTargetComponentName(); - targetIds.add(new AppTargetId( - String.format( - "%s/%s/%s", - shortcutInfo.getId(), - componentName.flattenToString(), - SHORTCUT_TARGET))); - } - } - directShareAppPredictor.notifyLaunchLocationShown(LAUNCH_LOCATION_DIRECT_SHARE, targetIds); - } - - private void sendClickToAppPredictor(TargetInfo targetInfo) { - if (!targetInfo.isChooserTargetInfo()) { - return; - } - - AppPredictor directShareAppPredictor = getAppPredictor( - mChooserMultiProfilePagerAdapter.getCurrentUserHandle()); - if (directShareAppPredictor == null) { - return; - } - AppTarget appTarget = targetInfo.getDirectShareAppTarget(); - if (appTarget != null) { - // This is a direct share click that was provided by the APS - directShareAppPredictor.notifyAppTargetEvent( - new AppTargetEvent.Builder(appTarget, AppTargetEvent.ACTION_LAUNCH) - .setLaunchLocation(LAUNCH_LOCATION_DIRECT_SHARE) - .build()); - } - } - - @Nullable - private AppPredictor getAppPredictor(UserHandle userHandle) { - 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) || (mProfiles.getCloneUserPresent())) - ? null : record.appPredictor; - } - - protected EventLog getEventLog() { - return mEventLog; - } - - private ChooserGridAdapter createChooserGridAdapter( - Context context, - List payloadIntents, - Intent[] initialIntents, - UserHandle userHandle) { - ChooserListAdapter chooserListAdapter = createChooserListAdapter( - context, - payloadIntents, - initialIntents, - /* TODO: not used, remove. rList= */ null, - /* TODO: not used, remove. filterLastUsed= */ false, - createListController(userHandle), - userHandle, - mRequest.getTargetIntent(), - mRequest.getReferrerFillInIntent(), - mMaxTargetsPerRow - ); - - return new ChooserGridAdapter( - context, - new ChooserGridAdapter.ChooserActivityDelegate() { - @Override - public boolean shouldShowTabs() { - return mProfiles.getWorkProfilePresent(); - } - - @Override - public View buildContentPreview(ViewGroup parent) { - return createContentPreviewView(parent); - } - - @Override - public void onTargetSelected(int itemIndex) { - startSelected(itemIndex, false, true); - } - - @Override - public void onTargetLongPressed(int selectedPosition) { - final TargetInfo longPressedTargetInfo = - mChooserMultiProfilePagerAdapter - .getActiveListAdapter() - .targetInfoForPosition( - selectedPosition, /* filtered= */ true); - // Only a direct share target or an app target is expected - if (longPressedTargetInfo.isDisplayResolveInfo() - || longPressedTargetInfo.isSelectableTargetInfo()) { - showTargetDetails(longPressedTargetInfo); - } - } - }, - chooserListAdapter, - shouldShowContentPreview(), - mMaxTargetsPerRow, - mFeatureFlags); - } - - @VisibleForTesting - public ChooserListAdapter createChooserListAdapter( - Context context, - List payloadIntents, - Intent[] initialIntents, - List rList, - boolean filterLastUsed, - ResolverListController resolverListController, - UserHandle userHandle, - Intent targetIntent, - Intent referrerFillInIntent, - int maxTargetsPerRow) { - UserHandle initialIntentsUserSpace = mProfiles.getQueryIntentsHandle(userHandle); - return new ChooserListAdapter( - context, - payloadIntents, - initialIntents, - rList, - filterLastUsed, - createListController(userHandle), - userHandle, - targetIntent, - referrerFillInIntent, - this, - mPackageManager, - getEventLog(), - maxTargetsPerRow, - initialIntentsUserSpace, - mTargetDataLoader, - () -> { - ProfileRecord record = getProfileRecord(userHandle); - if (record != null && record.shortcutLoader != null) { - record.shortcutLoader.reset(); - } - }, - mFeatureFlags); - } - - 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( - mProfiles.getWorkHandle())) { - mChooserMultiProfilePagerAdapter.rebuildActiveTab(true); - } else { - mChooserMultiProfilePagerAdapter.clearInactiveProfileCache(); - } - } - - @VisibleForTesting - protected ChooserListController createListController(UserHandle userHandle) { - AppPredictor appPredictor = getAppPredictor(userHandle); - AbstractResolverComparator resolverComparator; - if (appPredictor != null) { - resolverComparator = new AppPredictionServiceResolverComparator( - this, - mRequest.getTargetIntent(), - mRequest.getLaunchedFromPackage(), - appPredictor, - userHandle, - getEventLog(), - mNearbyShare.orElse(null) - ); - } else { - resolverComparator = - new ResolverRankerServiceResolverComparator( - this, - mRequest.getTargetIntent(), - mRequest.getReferrerPackage(), - null, - getEventLog(), - getResolverRankerServiceUserHandleList(userHandle), - mNearbyShare.orElse(null)); - } - - return new ChooserListController( - this, - mPackageManager, - mRequest.getTargetIntent(), - mRequest.getReferrerPackage(), - mViewModel.getActivityModel().getLaunchedFromUid(), - resolverComparator, - mProfiles.getQueryIntentsHandle(userHandle), - mRequest.getFilteredComponentNames(), - mPinnedSharedPrefs); - } - - @VisibleForTesting - protected ViewModelProvider.Factory createPreviewViewModelFactory() { - return PreviewViewModel.Companion.getFactory(); - } - - private ChooserActionFactory createChooserActionFactory() { - return new ChooserActionFactory( - this, - mRequest.getTargetIntent(), - mRequest.getLaunchedFromPackage(), - mRequest.getChooserActions(), - mImageEditor, - getEventLog(), - (isExcluded) -> mExcludeSharedText = isExcluded, - this::getFirstVisibleImgPreviewView, - new ChooserActionFactory.ActionActivityStarter() { - @Override - public void safelyStartActivityAsPersonalProfileUser(TargetInfo targetInfo) { - safelyStartActivityAsUser( - targetInfo, - mProfiles.getPersonalHandle() - ); - finish(); - } - - @Override - public void safelyStartActivityAsPersonalProfileUserWithSharedElementTransition( - TargetInfo targetInfo, View sharedElement, String sharedElementName) { - ActivityOptions options = ActivityOptions.makeSceneTransitionAnimation( - ChooserActivity.this, sharedElement, sharedElementName); - safelyStartActivityAsUser( - targetInfo, - mProfiles.getPersonalHandle(), - options.toBundle()); - // Can't finish right away because the shared element transition may not - // be ready to start. - mFinishWhenStopped = true; - } - }, - mShareResultSender, - this::finishWithStatus, - mClipboardManager); - } - - private Supplier 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 - * preview area + 2 rows of targets - */ - private void handleLayoutChange(View v, int left, int top, int right, int bottom, int oldLeft, - int oldTop, int oldRight, int oldBottom) { - if (mChooserMultiProfilePagerAdapter == null) { - return; - } - RecyclerView recyclerView = mChooserMultiProfilePagerAdapter.getActiveAdapterView(); - ChooserGridAdapter gridAdapter = mChooserMultiProfilePagerAdapter.getCurrentRootAdapter(); - // Skip height calculation if recycler view was scrolled to prevent it inaccurately - // calculating the height, as the logic below does not account for the scrolled offset. - if (gridAdapter == null || recyclerView == null - || recyclerView.computeVerticalScrollOffset() != 0) { - return; - } - - final int availableWidth = right - left - v.getPaddingLeft() - v.getPaddingRight(); - boolean isLayoutUpdated = - gridAdapter.calculateChooserTargetWidth(availableWidth) - || recyclerView.getAdapter() == null - || availableWidth != mCurrAvailableWidth; - - boolean insetsChanged = !Objects.equals(mLastAppliedInsets, mSystemWindowInsets); - - if (isLayoutUpdated - || insetsChanged - || mLastNumberOfChildren != recyclerView.getChildCount()) { - mCurrAvailableWidth = availableWidth; - if (isLayoutUpdated) { - // It is very important we call setAdapter from here. Otherwise in some cases - // the resolver list doesn't get populated, such as b/150922090, b/150918223 - // and b/150936654 - recyclerView.setAdapter(gridAdapter); - ((GridLayoutManager) recyclerView.getLayoutManager()).setSpanCount( - mMaxTargetsPerRow); - - updateTabPadding(); - } - - int currentProfile = mChooserMultiProfilePagerAdapter.getActiveProfile(); - int initialProfile = findSelectedProfile(); - if (currentProfile != initialProfile) { - return; - } - - if (mLastNumberOfChildren == recyclerView.getChildCount() && !insetsChanged) { - return; - } - - getMainThreadHandler().post(() -> { - if (mResolverDrawerLayout == null || gridAdapter == null) { - return; - } - int offset = calculateDrawerOffset(top, bottom, recyclerView, gridAdapter); - mResolverDrawerLayout.setCollapsibleHeightReserved(offset); - mEnterTransitionAnimationDelegate.markOffsetCalculated(); - mLastAppliedInsets = mSystemWindowInsets; - }); - } - } - - private int calculateDrawerOffset( - int top, int bottom, RecyclerView recyclerView, ChooserGridAdapter gridAdapter) { - - int offset = mSystemWindowInsets != null ? mSystemWindowInsets.bottom : 0; - int rowsToShow = gridAdapter.getSystemRowCount() - + gridAdapter.getServiceTargetRowCount() - + gridAdapter.getCallerAndRankedTargetRowCount(); - - // then this is most likely not a SEND_* action, so check - // the app target count - if (rowsToShow == 0) { - rowsToShow = gridAdapter.getRowCount(); - } - - // still zero? then use a default height and leave, which - // can happen when there are no targets to show - if (rowsToShow == 0 && !shouldShowStickyContentPreview()) { - offset += getResources().getDimensionPixelSize( - R.dimen.chooser_max_collapsed_height); - return offset; - } - - View stickyContentPreview = findViewById(com.android.internal.R.id.content_preview_container); - if (shouldShowStickyContentPreview() && isStickyContentPreviewShowing()) { - offset += stickyContentPreview.getHeight(); - } - - if (mProfiles.getWorkProfilePresent()) { - offset += findViewById(com.android.internal.R.id.tabs).getHeight(); - } - - if (recyclerView.getVisibility() == View.VISIBLE) { - rowsToShow = Math.min(4, rowsToShow); - boolean shouldShowExtraRow = shouldShowExtraRow(rowsToShow); - mLastNumberOfChildren = recyclerView.getChildCount(); - for (int i = 0, childCount = recyclerView.getChildCount(); - i < childCount && rowsToShow > 0; i++) { - View child = recyclerView.getChildAt(i); - if (((GridLayoutManager.LayoutParams) - child.getLayoutParams()).getSpanIndex() != 0) { - continue; - } - int height = child.getHeight(); - offset += height; - if (shouldShowExtraRow) { - offset += height; - } - rowsToShow--; - } - } else { - ViewGroup currentEmptyStateView = - mChooserMultiProfilePagerAdapter.getActiveEmptyStateView(); - if (currentEmptyStateView.getVisibility() == View.VISIBLE) { - offset += currentEmptyStateView.getHeight(); - } - } - - return Math.min(offset, bottom - top); - } - - /** - * If we have a tabbed view and are showing 1 row in the current profile and an empty - * state screen in another profile, to prevent cropping of the empty state screen we show - * a second row in the current profile. - */ - private boolean shouldShowExtraRow(int rowsToShow) { - return rowsToShow == 1 - && mChooserMultiProfilePagerAdapter - .shouldShowEmptyStateScreenInAnyInactiveAdapter(); - } - - protected void onListRebuilt(ResolverListAdapter listAdapter, boolean rebuildComplete) { - setupScrollListener(); - maybeSetupGlobalLayoutListener(); - - ChooserListAdapter chooserListAdapter = (ChooserListAdapter) listAdapter; - UserHandle listProfileUserHandle = chooserListAdapter.getUserHandle(); - if (listProfileUserHandle.equals(mChooserMultiProfilePagerAdapter.getCurrentUserHandle())) { - mChooserMultiProfilePagerAdapter.getActiveAdapterView() - .setAdapter(mChooserMultiProfilePagerAdapter.getCurrentRootAdapter()); - mChooserMultiProfilePagerAdapter - .setupListAdapter(mChooserMultiProfilePagerAdapter.getCurrentPage()); - } - - //TODO: move this block inside ChooserListAdapter (should be called when - // ResolverListAdapter#mPostListReadyRunnable is executed. - if (chooserListAdapter.getDisplayResolveInfoCount() == 0) { - chooserListAdapter.notifyDataSetChanged(); - } else { - chooserListAdapter.updateAlphabeticalList(); - } - - if (rebuildComplete) { - long duration = Tracer.INSTANCE.endAppTargetLoadingSection(listProfileUserHandle); - if (duration >= 0) { - Log.d(TAG, "app target loading time " + duration + " ms"); - } - addCallerChooserTargets(); - getEventLog().logSharesheetAppLoadComplete(); - maybeQueryAdditionalPostProcessingTargets( - listProfileUserHandle, - chooserListAdapter.getDisplayResolveInfos()); - mLatencyTracker.onActionEnd(ACTION_LOAD_SHARE_SHEET); - } - } - - private void maybeQueryAdditionalPostProcessingTargets( - UserHandle userHandle, - DisplayResolveInfo[] displayResolveInfos) { - ProfileRecord record = getProfileRecord(userHandle); - if (record == null || record.shortcutLoader == null) { - return; - } - record.loadingStartTime = SystemClock.elapsedRealtime(); - record.shortcutLoader.updateAppTargets(displayResolveInfos); - } - - @MainThread - private void onShortcutsLoaded(UserHandle userHandle, ShortcutLoader.Result result) { - if (DEBUG) { - Log.d(TAG, "onShortcutsLoaded for user: " + userHandle); - } - mDirectShareShortcutInfoCache.putAll(result.getDirectShareShortcutInfoCache()); - mDirectShareAppTargetCache.putAll(result.getDirectShareAppTargetCache()); - ChooserListAdapter adapter = - mChooserMultiProfilePagerAdapter.getListAdapterForUserHandle(userHandle); - if (adapter != null) { - for (ShortcutLoader.ShortcutResultInfo resultInfo : result.getShortcutsByApp()) { - adapter.addServiceResults( - resultInfo.getAppTarget(), - resultInfo.getShortcuts(), - result.isFromAppPredictor() - ? TARGET_TYPE_SHORTCUTS_FROM_PREDICTION_SERVICE - : TARGET_TYPE_SHORTCUTS_FROM_SHORTCUT_MANAGER, - mDirectShareShortcutInfoCache, - mDirectShareAppTargetCache); - } - adapter.completeServiceTargetLoading(); - } - - if (mChooserMultiProfilePagerAdapter.getActiveListAdapter() == adapter) { - long duration = Tracer.INSTANCE.endLaunchToShortcutTrace(); - if (duration >= 0) { - Log.d(TAG, "stat to first shortcut time: " + duration + " ms"); - } - } - logDirectShareTargetReceived(userHandle); - sendVoiceChoicesIfNeeded(); - getEventLog().logSharesheetDirectLoadComplete(); - } - - private void setupScrollListener() { - if (mResolverDrawerLayout == null) { - return; - } - int elevatedViewResId = mProfiles.getWorkProfilePresent() - ? com.android.internal.R.id.tabs : com.android.internal.R.id.chooser_header; - final View elevatedView = mResolverDrawerLayout.findViewById(elevatedViewResId); - final float defaultElevation = elevatedView.getElevation(); - final float chooserHeaderScrollElevation = - getResources().getDimensionPixelSize(R.dimen.chooser_header_scroll_elevation); - mChooserMultiProfilePagerAdapter.getActiveAdapterView().addOnScrollListener( - new RecyclerView.OnScrollListener() { - @Override - public void onScrollStateChanged(RecyclerView view, int scrollState) { - if (scrollState == RecyclerView.SCROLL_STATE_IDLE) { - if (mScrollStatus == SCROLL_STATUS_SCROLLING_VERTICAL) { - mScrollStatus = SCROLL_STATUS_IDLE; - setHorizontalScrollingEnabled(true); - } - } else if (scrollState == RecyclerView.SCROLL_STATE_DRAGGING) { - if (mScrollStatus == SCROLL_STATUS_IDLE) { - mScrollStatus = SCROLL_STATUS_SCROLLING_VERTICAL; - setHorizontalScrollingEnabled(false); - } - } - } - - @Override - public void onScrolled(RecyclerView view, int dx, int dy) { - if (view.getChildCount() > 0) { - View child = view.getLayoutManager().findViewByPosition(0); - if (child == null || child.getTop() < 0) { - elevatedView.setElevation(chooserHeaderScrollElevation); - return; - } - } - - elevatedView.setElevation(defaultElevation); - } - }); - } - - private void maybeSetupGlobalLayoutListener() { - if (mProfiles.getWorkProfilePresent()) { - return; - } - final View recyclerView = mChooserMultiProfilePagerAdapter.getActiveAdapterView(); - recyclerView.getViewTreeObserver() - .addOnGlobalLayoutListener(new ViewTreeObserver.OnGlobalLayoutListener() { - @Override - public void onGlobalLayout() { - // Fixes an issue were the accessibility border disappears on list creation. - recyclerView.getViewTreeObserver().removeOnGlobalLayoutListener(this); - final TextView titleView = findViewById(com.android.internal.R.id.title); - if (titleView != null) { - titleView.setFocusable(true); - titleView.setFocusableInTouchMode(true); - titleView.requestFocus(); - titleView.requestAccessibilityFocus(); - } - } - }); - } - - /** - * The sticky content preview is shown only when we have a tabbed view. It's shown above - * the tabs so it is not part of the scrollable list. If we are not in tabbed view, - * we instead show the content preview as a regular list item. - */ - private boolean shouldShowStickyContentPreview() { - return shouldShowStickyContentPreviewNoOrientationCheck(); - } - - private boolean shouldShowStickyContentPreviewNoOrientationCheck() { - if (!shouldShowContentPreview()) { - return false; - } - ResolverListAdapter adapter = mChooserMultiProfilePagerAdapter.getListAdapterForUserHandle( - UserHandle.of(UserHandle.myUserId())); - boolean isEmpty = adapter == null || adapter.getCount() == 0; - return (mFeatureFlags.scrollablePreview() || mProfiles.getWorkProfilePresent()) - && (!isEmpty || shouldShowContentPreviewWhenEmpty()); - } - - /** - * This method could be used to override the default behavior when we hide the preview area - * when the current tab doesn't have any items. - * - * @return true if we want to show the content preview area even if the tab for the current - * user is empty - */ - protected boolean shouldShowContentPreviewWhenEmpty() { - return false; - } - - /** - * @return true if we want to show the content preview area - */ - protected boolean shouldShowContentPreview() { - return mRequest.isSendActionTarget(); - } - - private void updateStickyContentPreview() { - if (shouldShowStickyContentPreviewNoOrientationCheck()) { - // The sticky content preview is only shown when we show the work and personal tabs. - // We don't show it in landscape as otherwise there is no room for scrolling. - // If the sticky content preview will be shown at some point with orientation change, - // then always preload it to avoid subsequent resizing of the share sheet. - ViewGroup contentPreviewContainer = - findViewById(com.android.internal.R.id.content_preview_container); - if (contentPreviewContainer.getChildCount() == 0) { - ViewGroup contentPreviewView = createContentPreviewView(contentPreviewContainer); - contentPreviewContainer.addView(contentPreviewView); - } - } - if (shouldShowStickyContentPreview()) { - showStickyContentPreview(); - } else { - hideStickyContentPreview(); - } - } - - private void showStickyContentPreview() { - if (isStickyContentPreviewShowing()) { - return; - } - ViewGroup contentPreviewContainer = findViewById(com.android.internal.R.id.content_preview_container); - contentPreviewContainer.setVisibility(View.VISIBLE); - } - - private boolean isStickyContentPreviewShowing() { - ViewGroup contentPreviewContainer = findViewById(com.android.internal.R.id.content_preview_container); - return contentPreviewContainer.getVisibility() == View.VISIBLE; - } - - private void hideStickyContentPreview() { - if (!isStickyContentPreviewShowing()) { - return; - } - ViewGroup contentPreviewContainer = findViewById(com.android.internal.R.id.content_preview_container); - contentPreviewContainer.setVisibility(View.GONE); - } - - protected String getMetricsCategory() { - return METRICS_CATEGORY_CHOOSER; - } - - protected void onProfileTabSelected(int currentPage) { - setupViewVisibilities(); - maybeLogProfileChange(); - if (mProfiles.getWorkProfilePresent()) { - // The device policy logger is only concerned with sessions that include a work profile. - DevicePolicyEventLogger - .createEvent(DevicePolicyEnums.RESOLVER_SWITCH_TABS) - .setInt(currentPage) - .setStrings(getMetricsCategory()) - .write(); - } - - // This fixes an edge case where after performing a variety of gestures, vertical scrolling - // ends up disabled. That's because at some point the old tab's vertical scrolling is - // disabled and the new tab's is enabled. For context, see b/159997845 - setVerticalScrollEnabled(true); - if (mResolverDrawerLayout != null) { - mResolverDrawerLayout.scrollNestedScrollableChildBackToTop(); - } - } - - protected WindowInsets onApplyWindowInsets(View v, WindowInsets insets) { - if (mProfiles.getWorkProfilePresent()) { - mChooserMultiProfilePagerAdapter - .setEmptyStateBottomOffset(insets.getSystemWindowInsetBottom()); - } - - WindowInsets result = super_onApplyWindowInsets(v, insets); - if (mResolverDrawerLayout != null) { - mResolverDrawerLayout.requestLayout(); - } - return result; - } - - private void setHorizontalScrollingEnabled(boolean enabled) { - ResolverViewPager viewPager = findViewById(com.android.internal.R.id.profile_pager); - viewPager.setSwipingEnabled(enabled); - } - - private void setVerticalScrollEnabled(boolean enabled) { - ChooserGridLayoutManager layoutManager = - (ChooserGridLayoutManager) mChooserMultiProfilePagerAdapter.getActiveAdapterView() - .getLayoutManager(); - layoutManager.setVerticalScrollEnabled(enabled); - } - - void onHorizontalSwipeStateChanged(int state) { - if (state == ViewPager.SCROLL_STATE_DRAGGING) { - if (mScrollStatus == SCROLL_STATUS_IDLE) { - mScrollStatus = SCROLL_STATUS_SCROLLING_HORIZONTAL; - setVerticalScrollEnabled(false); - } - } else if (state == ViewPager.SCROLL_STATE_IDLE) { - if (mScrollStatus == SCROLL_STATUS_SCROLLING_HORIZONTAL) { - mScrollStatus = SCROLL_STATUS_IDLE; - setVerticalScrollEnabled(true); - } - } - } - - protected void maybeLogProfileChange() { - getEventLog().logSharesheetProfileChanged(); - } - - private static class ProfileRecord { - /** The {@link AppPredictor} for this profile, if any. */ - @Nullable - public final AppPredictor appPredictor; - /** - * null if we should not load shortcuts. - */ - @Nullable - public final ShortcutLoader shortcutLoader; - public long loadingStartTime; - - private ProfileRecord( - @Nullable AppPredictor appPredictor, - @Nullable ShortcutLoader shortcutLoader) { - this.appPredictor = appPredictor; - this.shortcutLoader = shortcutLoader; - } - - public void destroy() { - if (appPredictor != null) { - appPredictor.destroy(); - } - } - } -} diff --git a/java/src/com/android/intentresolver/v2/ChooserHelper.kt b/java/src/com/android/intentresolver/v2/ChooserHelper.kt deleted file mode 100644 index 9da0d605..00000000 --- a/java/src/com/android/intentresolver/v2/ChooserHelper.kt +++ /dev/null @@ -1,168 +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.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.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 - -private const val TAG: String = "ChooserHelper" - -/** - * __Purpose__ - * - * Cleanup aid. Provides a pathway to cleaner code. - * - * __Incoming References__ - * - * ChooserHelper must not expose any properties or functions directly back to ChooserActivity. If a - * value or operation is required by ChooserActivity, then it must be added to ChooserInitializer - * (or a new interface as appropriate) with ChooserActivity supplying a callback to receive it at - * the appropriate point. This enforces unidirectional control flow. - * - * __Outgoing References__ - * - * _ChooserActivity_ - * - * This class must only reference it's host as Activity/ComponentActivity; no down-cast to - * [ChooserActivity]. Other components should be created here or supplied via Injection, and not - * referenced directly within ChooserActivity. This prevents circular dependencies from forming. If - * necessary, during cleanup the dependency can be supplied back to ChooserActivity as described - * above in 'Incoming References', see [ChooserInitializer]. - * - * _Elsewhere_ - * - * Where possible, Singleton and ActivityScoped dependencies should be injected here instead of - * referenced from an existing location. If not available for injection, the value should be - * constructed here, then provided to where it is needed. - */ -@ActivityScoped -@JavaInterop -class ChooserHelper -@Inject -constructor( - hostActivity: Activity, - private val 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() - - private lateinit var activityInitializer: Runnable - - var onChooserRequestChanged: Consumer = Consumer {} - - init { - activity.lifecycle.addObserver(this) - } - - /** - * Set the initialization hook for the host activity. - * - * This _must_ be called from [ChooserActivity.onCreate]. - */ - fun setInitializer(initializer: Runnable) { - check(activity.lifecycle.currentState == Lifecycle.State.INITIALIZED) { - "setInitializer must be called before onCreate returns" - } - activityInitializer = initializer - } - - /** Invoked by Lifecycle, after [ChooserActivity.onCreate] _returns_. */ - override fun onCreate(owner: LifecycleOwner) { - Log.i(TAG, "CREATE") - Log.i(TAG, "${viewModel.activityModel}") - - val callerUid: Int = viewModel.activityModel.launchedFromUid - if (callerUid < 0 || UserHandle.isIsolated(callerUid)) { - Log.e(TAG, "Can't start a chooser from uid $callerUid") - activity.finish() - return - } - - 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) { - request.errors.forEach { it.log(TAG) } - activity.finish() - } - - private fun initializeActivity(request: Valid) { - request.warnings.forEach { it.log(TAG) } - activityInitializer.run() - } -} diff --git a/java/src/com/android/intentresolver/v2/ChooserListController.java b/java/src/com/android/intentresolver/v2/ChooserListController.java deleted file mode 100644 index 467f343b..00000000 --- a/java/src/com/android/intentresolver/v2/ChooserListController.java +++ /dev/null @@ -1,66 +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.v2; - -import android.content.ComponentName; -import android.content.Context; -import android.content.Intent; -import android.content.SharedPreferences; -import android.content.pm.PackageManager; -import android.os.UserHandle; - -import com.android.intentresolver.ResolverListController; -import com.android.intentresolver.model.AbstractResolverComparator; - -import java.util.List; - -public class ChooserListController extends ResolverListController { - private final List mFilteredComponents; - private final SharedPreferences mPinnedComponents; - - public ChooserListController( - Context context, - PackageManager pm, - Intent targetIntent, - String referrerPackageName, - int launchedFromUid, - AbstractResolverComparator resolverComparator, - UserHandle queryIntentsAsUser, - List filteredComponents, - SharedPreferences pinnedComponents) { - super( - context, - pm, - targetIntent, - referrerPackageName, - launchedFromUid, - resolverComparator, - queryIntentsAsUser); - mFilteredComponents = filteredComponents; - mPinnedComponents = pinnedComponents; - } - - @Override - public boolean isComponentFiltered(ComponentName name) { - return mFilteredComponents.contains(name); - } - - @Override - public boolean isComponentPinned(ComponentName name) { - return mPinnedComponents.getBoolean(name.flattenToString(), false); - } -} diff --git a/java/src/com/android/intentresolver/v2/ChooserSelector.kt b/java/src/com/android/intentresolver/v2/ChooserSelector.kt deleted file mode 100644 index 378bc06c..00000000 --- a/java/src/com/android/intentresolver/v2/ChooserSelector.kt +++ /dev/null @@ -1,36 +0,0 @@ -package com.android.intentresolver.v2 - -import android.content.BroadcastReceiver -import android.content.ComponentName -import android.content.Context -import android.content.Intent -import android.content.pm.PackageManager -import com.android.intentresolver.FeatureFlags -import dagger.hilt.android.AndroidEntryPoint -import javax.inject.Inject - -@AndroidEntryPoint(BroadcastReceiver::class) -class ChooserSelector : Hilt_ChooserSelector() { - - @Inject lateinit var featureFlags: FeatureFlags - - override fun onReceive(context: Context, intent: Intent) { - super.onReceive(context, intent) - if (intent.action == Intent.ACTION_BOOT_COMPLETED) { - context.packageManager.setComponentEnabledSetting( - ComponentName(CHOOSER_PACKAGE, CHOOSER_PACKAGE + CHOOSER_CLASS), - if (featureFlags.modularFramework()) { - PackageManager.COMPONENT_ENABLED_STATE_ENABLED - } else { - PackageManager.COMPONENT_ENABLED_STATE_DEFAULT - }, - /* flags = */ 0, - ) - } - } - - companion object { - private const val CHOOSER_PACKAGE = "com.android.intentresolver" - private const val CHOOSER_CLASS = ".v2.ChooserActivity" - } -} diff --git a/java/src/com/android/intentresolver/v2/IntentForwarding.kt b/java/src/com/android/intentresolver/v2/IntentForwarding.kt deleted file mode 100644 index 3d366d10..00000000 --- a/java/src/com/android/intentresolver/v2/IntentForwarding.kt +++ /dev/null @@ -1,111 +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.v2 - -import android.Manifest -import android.Manifest.permission.INTERACT_ACROSS_USERS -import android.Manifest.permission.INTERACT_ACROSS_USERS_FULL -import android.app.ActivityManager -import android.content.Context -import android.content.Intent -import android.content.PermissionChecker -import android.content.pm.ApplicationInfo -import android.content.pm.PackageManager -import android.content.pm.PackageManager.PERMISSION_GRANTED -import android.os.UserHandle -import android.os.UserManager -import android.util.Log -import com.android.intentresolver.v2.data.repository.DevicePolicyResources -import javax.inject.Inject -import javax.inject.Singleton - -private const val TAG: String = "IntentForwarding" - -@Singleton -class IntentForwarding -@Inject -constructor( - private val resources: DevicePolicyResources, - private val userManager: UserManager, - private val packageManager: PackageManager -) { - - fun forwardMessageFor(intent: Intent): String? { - val contentUserHint = intent.contentUserHint - if ( - contentUserHint != UserHandle.USER_CURRENT && contentUserHint != UserHandle.myUserId() - ) { - val originUserInfo = userManager.getUserInfo(contentUserHint) - val originIsManaged = originUserInfo?.isManagedProfile ?: false - val targetIsManaged = userManager.isManagedProfile - return when { - originIsManaged && !targetIsManaged -> resources.forwardToPersonalMessage - !originIsManaged && targetIsManaged -> resources.forwardToWorkMessage - else -> null - } - } - return null - } - - private fun isPermissionGranted(permission: String, uid: Int) = - ActivityManager.checkComponentPermission( - /* permission = */ permission, - /* uid = */ uid, - /* owningUid= */ -1, - /* exported= */ true - ) - - /** - * Returns whether the package has the necessary permissions to interact across profiles on - * behalf of a given user. - * - * This means meeting the following condition: - * * The app's [ApplicationInfo.crossProfile] flag must be true, and at least one of the - * following conditions must be fulfilled - * * `Manifest.permission.INTERACT_ACROSS_USERS_FULL` granted. - * * `Manifest.permission.INTERACT_ACROSS_USERS` granted. - * * `Manifest.permission.INTERACT_ACROSS_PROFILES` granted, or the corresponding AppOps - * `android:interact_across_profiles` is set to "allow". - */ - fun canAppInteractAcrossProfiles(context: Context, packageName: String): Boolean { - val applicationInfo: ApplicationInfo - try { - applicationInfo = packageManager.getApplicationInfo(packageName, 0) - } catch (e: PackageManager.NameNotFoundException) { - Log.e(TAG, "Package $packageName does not exist on current user.") - return false - } - if (!applicationInfo.crossProfile) { - return false - } - - val packageUid = applicationInfo.uid - - if (isPermissionGranted(INTERACT_ACROSS_USERS_FULL, packageUid) == PERMISSION_GRANTED) { - return true - } - if (isPermissionGranted(INTERACT_ACROSS_USERS, packageUid) == PERMISSION_GRANTED) { - return true - } - return PermissionChecker.checkPermissionForPreflight( - context, - Manifest.permission.INTERACT_ACROSS_PROFILES, - PermissionChecker.PID_UNKNOWN, - packageUid, - packageName - ) == PERMISSION_GRANTED - } -} diff --git a/java/src/com/android/intentresolver/v2/JavaFlowHelper.kt b/java/src/com/android/intentresolver/v2/JavaFlowHelper.kt deleted file mode 100644 index 3c4bddd1..00000000 --- a/java/src/com/android/intentresolver/v2/JavaFlowHelper.kt +++ /dev/null @@ -1,30 +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. - */ - -@file:JvmName("JavaFlowHelper") - -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 collect(scope: CoroutineScope, flow: Flow, collector: Consumer): 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 deleted file mode 100644 index 27d8c6bb..00000000 --- a/java/src/com/android/intentresolver/v2/ProfileAvailability.kt +++ /dev/null @@ -1,85 +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.v2 - -import androidx.annotation.MainThread -import com.android.intentresolver.v2.annotation.JavaInterop -import com.android.intentresolver.v2.domain.interactor.UserInteractor -import com.android.intentresolver.v2.shared.model.Profile -import kotlinx.coroutines.CoroutineDispatcher -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Job -import kotlinx.coroutines.flow.filter -import kotlinx.coroutines.flow.first -import kotlinx.coroutines.flow.map -import kotlinx.coroutines.launch -import kotlinx.coroutines.runBlocking - -/** Provides availability status for profiles */ -@JavaInterop -class ProfileAvailability( - private val userInteractor: UserInteractor, - private val scope: CoroutineScope, - private val background: CoroutineDispatcher, -) { - /** Used by WorkProfilePausedEmptyStateProvider */ - var waitingToEnableProfile = false - private set - - /** Set by ChooserActivity to call onWorkProfileStatusUpdated */ - var onProfileStatusChange: Runnable? = null - - private var waitJob: Job? = null - - /** Query current profile availability. An unavailable profile is one which is not active. */ - @MainThread - fun isAvailable(profile: Profile): Boolean { - return runBlocking(background) { - userInteractor.availability.map { it[profile] == true }.first() - } - } - - /** Used by WorkProfilePausedEmptyStateProvider */ - fun requestQuietModeState(profile: Profile, quietMode: Boolean) { - val enableProfile = !quietMode - - // Check if the profile is already in the correct state - if (isAvailable(profile) == enableProfile) { - return // No-op - } - - // Support existing code - if (enableProfile) { - waitingToEnableProfile = true - waitJob?.cancel() - - val job = - scope.launch { - // Wait for the profile to become available - userInteractor.availability.filter { it[profile] == true }.first() - } - job.invokeOnCompletion { - waitingToEnableProfile = false - onProfileStatusChange?.run() - } - waitJob = job - } - - // Apply the change - scope.launch { userInteractor.updateState(profile, enableProfile) } - } -} diff --git a/java/src/com/android/intentresolver/v2/ProfileHelper.kt b/java/src/com/android/intentresolver/v2/ProfileHelper.kt deleted file mode 100644 index 87948150..00000000 --- a/java/src/com/android/intentresolver/v2/ProfileHelper.kt +++ /dev/null @@ -1,97 +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.v2 - -import android.os.UserHandle -import androidx.annotation.MainThread -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 -import kotlinx.coroutines.CoroutineDispatcher -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.flow.first -import kotlinx.coroutines.runBlocking - -@JavaInterop -@MainThread -class ProfileHelper -@Inject -constructor( - interactor: UserInteractor, - private val scope: CoroutineScope, - private val background: CoroutineDispatcher, - private val flags: IntentResolverFlags, -) { - private val launchedByHandle: UserHandle = interactor.launchedAs - - val launchedAsProfile by lazy { - runBlocking(background) { interactor.launchedAsProfile.first() } - } - val profiles by lazy { runBlocking(background) { interactor.profiles.first() } } - - // Map UserHandle back to a user within launchedByProfile - private val launchedByUser: User = - when (launchedByHandle) { - launchedAsProfile.primary.handle -> launchedAsProfile.primary - launchedAsProfile.clone?.handle -> requireNotNull(launchedAsProfile.clone) - else -> error("launchedByUser must be a member of launchedByProfile") - } - val launchedAsProfileType: Profile.Type = launchedAsProfile.type - - val personalProfile = profiles.single { it.type == Profile.Type.PERSONAL } - val workProfile = profiles.singleOrNull { it.type == Profile.Type.WORK } - val privateProfile = profiles.singleOrNull { it.type == Profile.Type.PRIVATE } - - val personalHandle = personalProfile.primary.handle - val workHandle = workProfile?.primary?.handle - val privateHandle = privateProfile?.primary?.handle - val cloneHandle = personalProfile.clone?.handle - - val isLaunchedAsCloneProfile = launchedByUser == launchedAsProfile.clone - - val cloneUserPresent = personalProfile.clone != null - val workProfilePresent = workProfile != null - val privateProfilePresent = privateProfile != null - - // Name retained for ease of review, to be renamed later - val tabOwnerUserHandleForLaunch = - if (launchedByUser.role == User.Role.CLONE) { - // When started by clone user, return the profile owner instead - launchedAsProfile.primary.handle - } else { - // Otherwise the launched user is used - launchedByUser.handle - } - - fun 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 - fun getQueryIntentsHandle(handle: UserHandle): UserHandle? { - return if (isLaunchedAsCloneProfile && handle == personalHandle) { - cloneHandle - } else { - handle - } - } -} diff --git a/java/src/com/android/intentresolver/v2/ResolverActivity.java b/java/src/com/android/intentresolver/v2/ResolverActivity.java deleted file mode 100644 index 86f32864..00000000 --- a/java/src/com/android/intentresolver/v2/ResolverActivity.java +++ /dev/null @@ -1,1947 +0,0 @@ -/* - * Copyright (C) 2008 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.android.intentresolver.v2; - -import static android.app.admin.DevicePolicyResources.Strings.Core.RESOLVER_CANT_ACCESS_PERSONAL; -import static android.app.admin.DevicePolicyResources.Strings.Core.RESOLVER_CANT_ACCESS_WORK; -import static android.app.admin.DevicePolicyResources.Strings.Core.RESOLVER_CROSS_PROFILE_BLOCKED_TITLE; -import static android.content.Intent.FLAG_ACTIVITY_NEW_TASK; -import static android.stats.devicepolicy.nano.DevicePolicyEnums.RESOLVER_EMPTY_STATE_NO_SHARING_TO_PERSONAL; -import static android.stats.devicepolicy.nano.DevicePolicyEnums.RESOLVER_EMPTY_STATE_NO_SHARING_TO_WORK; -import static android.view.WindowManager.LayoutParams.SYSTEM_FLAG_HIDE_NON_SYSTEM_OVERLAY_WINDOWS; - -import static androidx.lifecycle.LifecycleKt.getCoroutineScope; - -import static com.android.intentresolver.v2.ext.CreationExtrasExtKt.addDefaultArgs; -import static com.android.internal.annotations.VisibleForTesting.Visibility.PROTECTED; - -import static java.util.Objects.requireNonNull; - -import android.app.ActivityThread; -import android.app.VoiceInteractor.PickOptionRequest; -import android.app.VoiceInteractor.PickOptionRequest.Option; -import android.app.VoiceInteractor.Prompt; -import android.app.admin.DevicePolicyEventLogger; -import android.content.ComponentName; -import android.content.Context; -import android.content.Intent; -import android.content.IntentFilter; -import android.content.pm.ActivityInfo; -import android.content.pm.ApplicationInfo; -import android.content.pm.PackageManager; -import android.content.pm.PackageManager.NameNotFoundException; -import android.content.pm.ResolveInfo; -import android.content.pm.UserInfo; -import android.content.res.Configuration; -import android.graphics.Insets; -import android.net.Uri; -import android.os.Build; -import android.os.Bundle; -import android.os.PatternMatcher; -import android.os.RemoteException; -import android.os.StrictMode; -import android.os.Trace; -import android.os.UserHandle; -import android.os.UserManager; -import android.provider.Settings; -import android.stats.devicepolicy.DevicePolicyEnums; -import android.text.TextUtils; -import android.util.Log; -import android.util.Slog; -import android.view.Gravity; -import android.view.LayoutInflater; -import android.view.View; -import android.view.ViewGroup; -import android.view.ViewGroup.LayoutParams; -import android.view.Window; -import android.view.WindowInsets; -import android.view.WindowManager; -import android.widget.AbsListView; -import android.widget.AdapterView; -import android.widget.Button; -import android.widget.FrameLayout; -import android.widget.ImageView; -import android.widget.ListView; -import android.widget.Space; -import android.widget.TabHost; -import android.widget.TextView; -import android.widget.Toast; - -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; -import androidx.fragment.app.FragmentActivity; -import androidx.lifecycle.ViewModelProvider; -import androidx.lifecycle.viewmodel.CreationExtras; -import androidx.viewpager.widget.ViewPager; - -import com.android.intentresolver.FeatureFlags; -import com.android.intentresolver.R; -import com.android.intentresolver.ResolverListAdapter; -import com.android.intentresolver.ResolverListController; -import com.android.intentresolver.chooser.DisplayResolveInfo; -import com.android.intentresolver.chooser.TargetInfo; -import com.android.intentresolver.emptystate.CompositeEmptyStateProvider; -import com.android.intentresolver.emptystate.CrossProfileIntentsChecker; -import com.android.intentresolver.emptystate.EmptyState; -import com.android.intentresolver.emptystate.EmptyStateProvider; -import com.android.intentresolver.icons.DefaultTargetDataLoader; -import com.android.intentresolver.icons.TargetDataLoader; -import com.android.intentresolver.inject.Background; -import com.android.intentresolver.model.ResolverRankerServiceResolverComparator; -import com.android.intentresolver.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; -import com.android.intentresolver.v2.emptystate.WorkProfilePausedEmptyStateProvider; -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.ResolverMultiProfilePagerAdapter; -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.model.ActivityModel; -import com.android.intentresolver.v2.ui.model.ResolverRequest; -import com.android.intentresolver.v2.ui.viewmodel.ResolverViewModel; -import com.android.intentresolver.widget.ResolverDrawerLayout; -import com.android.internal.annotations.VisibleForTesting; -import com.android.internal.content.PackageMonitor; -import com.android.internal.logging.MetricsLogger; -import com.android.internal.logging.nano.MetricsProto; - -import com.google.common.collect.ImmutableList; - -import dagger.hilt.android.AndroidEntryPoint; - -import kotlin.Pair; - -import java.util.ArrayList; -import java.util.Arrays; -import java.util.Iterator; -import java.util.List; -import java.util.Objects; -import java.util.Set; - -import javax.inject.Inject; - -import kotlinx.coroutines.CoroutineDispatcher; - -/** - * This is a copy of ResolverActivity to support IntentResolver's ChooserActivity. This code is - * *not* the resolver that is actually triggered by the system right now (you want - * frameworks/base/core/java/com/android/internal/app/ResolverActivity.java for that), the full - * migration is not complete. - */ -@AndroidEntryPoint(FragmentActivity.class) -public class ResolverActivity extends Hilt_ResolverActivity implements - ResolverListAdapter.ResolverListCommunicator { - - @Inject @Background public CoroutineDispatcher mBackgroundDispatcher; - @Inject public UserInteractor mUserInteractor; - @Inject public ResolverHelper mResolverHelper; - @Inject public PackageManager mPackageManager; - @Inject public DevicePolicyResources mDevicePolicyResources; - @Inject public IntentForwarding mIntentForwarding; - @Inject public FeatureFlags mFeatureFlags; - - private ResolverViewModel mViewModel; - private ResolverRequest mRequest; - private ProfileHelper mProfiles; - private ProfileAvailability mProfileAvailability; - protected TargetDataLoader mTargetDataLoader; - private boolean mResolvingHome; - - private Button mAlwaysButton; - private Button mOnceButton; - protected View mProfileView; - private int mLastSelected = AbsListView.INVALID_POSITION; - private int mLayoutId; - private PickTargetOptionRequest mPickOptionRequest; - // Expected to be true if this object is ResolverActivity or is ResolverWrapperActivity. - protected ResolverDrawerLayout mResolverDrawerLayout; - - private static final String TAG = "ResolverActivity"; - private static final boolean DEBUG = false; - private static final String LAST_SHOWN_TAB_KEY = "last_shown_tab_key"; - - private boolean mRegistered; - - protected Insets mSystemWindowInsets = null; - private Space mFooterSpacer = null; - - protected static final String METRICS_CATEGORY_RESOLVER = "intent_resolver"; - protected static final String METRICS_CATEGORY_CHOOSER = "intent_chooser"; - - /** Tracks if we should ignore future broadcasts telling us the work profile is enabled */ - private final boolean mWorkProfileHasBeenEnabled = false; - - protected static final String TAB_TAG_PERSONAL = "personal"; - protected static final String TAB_TAG_WORK = "work"; - - private PackageMonitor mPersonalPackageMonitor; - private PackageMonitor mWorkPackageMonitor; - - protected ResolverMultiProfilePagerAdapter mMultiProfilePagerAdapter; - - public static final int PROFILE_PERSONAL = MultiProfilePagerAdapter.PROFILE_PERSONAL; - public static final int PROFILE_WORK = MultiProfilePagerAdapter.PROFILE_WORK; - - private UserHandle mHeaderCreatorUser; - - @Nullable - private OnSwitchOnWorkSelectedListener mOnSwitchOnWorkSelectedListener; - - protected PackageMonitor createPackageMonitor(ResolverListAdapter listAdapter) { - return new PackageMonitor() { - @Override - public void onSomePackagesChanged() { - listAdapter.handlePackagesChanged(); - } - - @Override - public boolean onPackageChanged(String packageName, int uid, String[] components) { - // We care about all package changes, not just the whole package itself which is - // default behavior. - return true; - } - }; - } - - protected ActivityModel createActivityModel() { - return ActivityModel.createFrom(this); - } - - @NonNull - @Override - public CreationExtras getDefaultViewModelCreationExtras() { - return addDefaultArgs( - super.getDefaultViewModelCreationExtras(), - new Pair<>(ActivityModel.ACTIVITY_MODEL_KEY, createActivityModel())); - } - - @Override - protected void onCreate(Bundle savedInstanceState) { - super.onCreate(savedInstanceState); - Log.i(TAG, "onCreate"); - setTheme(R.style.Theme_DeviceDefault_Resolver); - mResolverHelper.setInitializer(this::initialize); - } - - @Override - protected final void onStart() { - super.onStart(); - this.getWindow().addSystemFlags(SYSTEM_FLAG_HIDE_NON_SYSTEM_OVERLAY_WINDOWS); - } - - @Override - protected void onStop() { - super.onStop(); - - final Window window = this.getWindow(); - final WindowManager.LayoutParams attrs = window.getAttributes(); - attrs.privateFlags &= ~SYSTEM_FLAG_HIDE_NON_SYSTEM_OVERLAY_WINDOWS; - window.setAttributes(attrs); - - if (mRegistered) { - mPersonalPackageMonitor.unregister(); - if (mWorkPackageMonitor != null) { - mWorkPackageMonitor.unregister(); - } - mRegistered = false; - } - final Intent intent = getIntent(); - if ((intent.getFlags() & FLAG_ACTIVITY_NEW_TASK) != 0 && !isVoiceInteraction() - && !mResolvingHome) { - // This resolver is in the unusual situation where it has been - // launched at the top of a new task. We don't let it be added - // to the recent tasks shown to the user, and we need to make sure - // that each time we are launched we get the correct launching - // uid (not re-using the same resolver from an old launching uid), - // so we will now finish ourself since being no longer visible, - // the user probably can't get back to us. - if (!isChangingConfigurations()) { - finish(); - } - } - } - - @Override - protected final void onSaveInstanceState(@NonNull Bundle outState) { - super.onSaveInstanceState(outState); - ViewPager viewPager = findViewById(com.android.internal.R.id.profile_pager); - if (viewPager != null) { - outState.putInt(LAST_SHOWN_TAB_KEY, viewPager.getCurrentItem()); - } - } - - @Override - protected final void onRestart() { - super.onRestart(); - if (!mRegistered) { - mPersonalPackageMonitor.register( - this, - getMainLooper(), - mProfiles.getPersonalHandle(), - false); - if (mProfiles.getWorkProfilePresent()) { - if (mWorkPackageMonitor == null) { - mWorkPackageMonitor = createPackageMonitor( - mMultiProfilePagerAdapter.getWorkListAdapter()); - } - mWorkPackageMonitor.register( - this, - getMainLooper(), - mProfiles.getWorkHandle(), - false); - } - mRegistered = true; - } - mMultiProfilePagerAdapter.getActiveListAdapter().handlePackagesChanged(); - } - - @Override - protected void onDestroy() { - super.onDestroy(); - if (!isChangingConfigurations() && mPickOptionRequest != null) { - mPickOptionRequest.cancel(); - } - if (mMultiProfilePagerAdapter != null - && mMultiProfilePagerAdapter.getActiveListAdapter() != null) { - mMultiProfilePagerAdapter.getActiveListAdapter().onDestroy(); - } - } - - private void initialize() { - mViewModel = new ViewModelProvider(this).get(ResolverViewModel.class); - mRequest = mViewModel.getRequest().getValue(); - - mProfiles = new ProfileHelper( - mUserInteractor, - getCoroutineScope(getLifecycle()), - mBackgroundDispatcher, - mFeatureFlags); - - mProfileAvailability = new ProfileAvailability( - mUserInteractor, - getCoroutineScope(getLifecycle()), - mBackgroundDispatcher); - - mProfileAvailability.setOnProfileStatusChange(this::onWorkProfileStatusUpdated); - - mResolvingHome = mRequest.isResolvingHome(); - mTargetDataLoader = new DefaultTargetDataLoader( - this, - getLifecycle(), - mRequest.isAudioCaptureDevice()); - - // The last argument of createResolverListAdapter is whether to do special handling - // of the last used choice to highlight it in the list. We need to always - // turn this off when running under voice interaction, since it results in - // a more complicated UI that the current voice interaction flow is not able - // to handle. We also turn it off when multiple tabs are shown to simplify the UX. - // We also turn it off when clonedProfile is present on the device, because we might have - // different "last chosen" activities in the different profiles, and PackageManager doesn't - // provide any more information to help us select between them. - boolean filterLastUsed = !isVoiceInteraction() - && !mProfiles.getWorkProfilePresent() && !mProfiles.getCloneUserPresent(); - mMultiProfilePagerAdapter = createMultiProfilePagerAdapter( - new Intent[0], - /* resolutionList = */ mRequest.getResolutionList(), - filterLastUsed - ); - if (configureContentView(mTargetDataLoader)) { - return; - } - - mPersonalPackageMonitor = createPackageMonitor( - mMultiProfilePagerAdapter.getPersonalListAdapter()); - mPersonalPackageMonitor.register( - this, - getMainLooper(), - mProfiles.getPersonalHandle(), - false - ); - if (mProfiles.getWorkProfilePresent()) { - mWorkPackageMonitor = createPackageMonitor( - mMultiProfilePagerAdapter.getWorkListAdapter()); - mWorkPackageMonitor.register( - this, - getMainLooper(), - mProfiles.getWorkHandle(), - false - ); - } - - mRegistered = true; - - final ResolverDrawerLayout rdl = findViewById(com.android.internal.R.id.contentPanel); - if (rdl != null) { - rdl.setOnDismissedListener(new ResolverDrawerLayout.OnDismissedListener() { - @Override - public void onDismissed() { - finish(); - } - }); - - boolean hasTouchScreen = mPackageManager - .hasSystemFeature(PackageManager.FEATURE_TOUCHSCREEN); - - if (isVoiceInteraction() || !hasTouchScreen) { - rdl.setCollapsed(false); - } - - rdl.setSystemUiVisibility(View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION - | View.SYSTEM_UI_FLAG_LAYOUT_STABLE); - rdl.setOnApplyWindowInsetsListener(this::onApplyWindowInsets); - - mResolverDrawerLayout = rdl; - } - Intent intent = mViewModel.getRequest().getValue().getIntent(); - final Set categories = intent.getCategories(); - MetricsLogger.action(this, mMultiProfilePagerAdapter.getActiveListAdapter().hasFilteredItem() - ? MetricsProto.MetricsEvent.ACTION_SHOW_APP_DISAMBIG_APP_FEATURED - : MetricsProto.MetricsEvent.ACTION_SHOW_APP_DISAMBIG_NONE_FEATURED, - intent.getAction() + ":" + intent.getType() + ":" - + (categories != null ? Arrays.toString(categories.toArray()) : "")); - } - - private void restore(@Nullable Bundle savedInstanceState) { - if (savedInstanceState != null) { - // onRestoreInstanceState - resetButtonBar(); - ViewPager viewPager = findViewById(com.android.internal.R.id.profile_pager); - if (viewPager != null) { - viewPager.setCurrentItem(savedInstanceState.getInt(LAST_SHOWN_TAB_KEY)); - } - } - - mMultiProfilePagerAdapter.clearInactiveProfileCache(); - } - - protected ResolverMultiProfilePagerAdapter createMultiProfilePagerAdapter( - Intent[] initialIntents, - List resolutionList, - boolean filterLastUsed) { - ResolverMultiProfilePagerAdapter resolverMultiProfilePagerAdapter = null; - if (mProfiles.getWorkProfilePresent()) { - resolverMultiProfilePagerAdapter = - createResolverMultiProfilePagerAdapterForTwoProfiles( - initialIntents, resolutionList, filterLastUsed); - } else { - resolverMultiProfilePagerAdapter = createResolverMultiProfilePagerAdapterForOneProfile( - initialIntents, resolutionList, filterLastUsed); - } - return resolverMultiProfilePagerAdapter; - } - - protected EmptyStateProvider createBlockerEmptyStateProvider() { - final boolean shouldShowNoCrossProfileIntentsEmptyState = getUser().equals(getIntentUser()); - - if (!shouldShowNoCrossProfileIntentsEmptyState) { - // Implementation that doesn't show any blockers - return new EmptyStateProvider() {}; - } - - final EmptyState noWorkToPersonalEmptyState = - new DevicePolicyBlockerEmptyState( - /* context= */ this, - /* devicePolicyStringTitleId= */ RESOLVER_CROSS_PROFILE_BLOCKED_TITLE, - /* defaultTitleResource= */ R.string.resolver_cross_profile_blocked, - /* devicePolicyStringSubtitleId= */ RESOLVER_CANT_ACCESS_PERSONAL, - /* defaultSubtitleResource= */ - R.string.resolver_cant_access_personal_apps_explanation, - /* devicePolicyEventId= */ RESOLVER_EMPTY_STATE_NO_SHARING_TO_PERSONAL, - /* devicePolicyEventCategory= */ - ResolverActivity.METRICS_CATEGORY_RESOLVER); - - final EmptyState noPersonalToWorkEmptyState = - new DevicePolicyBlockerEmptyState( - /* context= */ this, - /* devicePolicyStringTitleId= */ RESOLVER_CROSS_PROFILE_BLOCKED_TITLE, - /* defaultTitleResource= */ R.string.resolver_cross_profile_blocked, - /* devicePolicyStringSubtitleId= */ RESOLVER_CANT_ACCESS_WORK, - /* defaultSubtitleResource= */ - R.string.resolver_cant_access_work_apps_explanation, - /* devicePolicyEventId= */ RESOLVER_EMPTY_STATE_NO_SHARING_TO_WORK, - /* devicePolicyEventCategory= */ - ResolverActivity.METRICS_CATEGORY_RESOLVER); - - return new NoCrossProfileEmptyStateProvider( - mProfiles, - noWorkToPersonalEmptyState, - noPersonalToWorkEmptyState, - createCrossProfileIntentsChecker()); - } - - /** - * Numerous layouts are supported, each with optional ViewGroups. - * Make sure the inset gets added to the correct View, using - * a footer for Lists so it can properly scroll under the navbar. - */ - protected boolean shouldAddFooterView() { - if (useLayoutWithDefault()) return true; - - View buttonBar = findViewById(com.android.internal.R.id.button_bar); - return buttonBar == null || buttonBar.getVisibility() == View.GONE; - } - - protected void applyFooterView(int height) { - if (mFooterSpacer == null) { - mFooterSpacer = new Space(getApplicationContext()); - } else { - ((ResolverMultiProfilePagerAdapter) mMultiProfilePagerAdapter) - .getActiveAdapterView().removeFooterView(mFooterSpacer); - } - mFooterSpacer.setLayoutParams(new AbsListView.LayoutParams(LayoutParams.MATCH_PARENT, - mSystemWindowInsets.bottom)); - ((ResolverMultiProfilePagerAdapter) mMultiProfilePagerAdapter) - .getActiveAdapterView().addFooterView(mFooterSpacer); - } - - protected WindowInsets onApplyWindowInsets(View v, WindowInsets insets) { - mSystemWindowInsets = insets.getSystemWindowInsets(); - - mResolverDrawerLayout.setPadding(mSystemWindowInsets.left, mSystemWindowInsets.top, - mSystemWindowInsets.right, 0); - - resetButtonBar(); - - if (shouldUseMiniResolver()) { - View buttonContainer = findViewById(com.android.internal.R.id.button_bar_container); - buttonContainer.setPadding(0, 0, 0, mSystemWindowInsets.bottom - + getResources().getDimensionPixelOffset(R.dimen.resolver_button_bar_spacing)); - } - - // Need extra padding so the list can fully scroll up - if (shouldAddFooterView()) { - applyFooterView(mSystemWindowInsets.bottom); - } - - return insets.consumeSystemWindowInsets(); - } - - @Override - public void onConfigurationChanged(Configuration newConfig) { - super.onConfigurationChanged(newConfig); - mMultiProfilePagerAdapter.getActiveListAdapter().handlePackagesChanged(); - if (mProfiles.getWorkProfilePresent() && !useLayoutWithDefault() - && !shouldUseMiniResolver()) { - updateIntentPickerPaddings(); - } - - if (mSystemWindowInsets != null) { - mResolverDrawerLayout.setPadding(mSystemWindowInsets.left, mSystemWindowInsets.top, - mSystemWindowInsets.right, 0); - } - } - - public int getLayoutResource() { - return R.layout.resolver_list; - } - - // referenced by layout XML: android:onClick="onButtonClick" - public void onButtonClick(View v) { - final int id = v.getId(); - ListView listView = (ListView) mMultiProfilePagerAdapter.getActiveAdapterView(); - ResolverListAdapter currentListAdapter = mMultiProfilePagerAdapter.getActiveListAdapter(); - int which = currentListAdapter.hasFilteredItem() - ? currentListAdapter.getFilteredPosition() - : listView.getCheckedItemPosition(); - boolean hasIndexBeenFiltered = !currentListAdapter.hasFilteredItem(); - startSelected(which, id == com.android.internal.R.id.button_always, hasIndexBeenFiltered); - } - - public void startSelected(int which, boolean always, boolean hasIndexBeenFiltered) { - if (isFinishing()) { - return; - } - ResolveInfo ri = mMultiProfilePagerAdapter.getActiveListAdapter() - .resolveInfoForPosition(which, hasIndexBeenFiltered); - if (mResolvingHome && hasManagedProfile() && !supportsManagedProfiles(ri)) { - String launcherName = ri.activityInfo.loadLabel(mPackageManager).toString(); - Toast.makeText(this, - mDevicePolicyResources.getWorkProfileNotSupportedMessage(launcherName), - Toast.LENGTH_LONG).show(); - return; - } - - TargetInfo target = mMultiProfilePagerAdapter.getActiveListAdapter() - .targetInfoForPosition(which, hasIndexBeenFiltered); - if (target == null) { - return; - } - if (onTargetSelected(target, always)) { - if (always) { - MetricsLogger.action( - this, MetricsProto.MetricsEvent.ACTION_APP_DISAMBIG_ALWAYS); - } else { - MetricsLogger.action( - this, MetricsProto.MetricsEvent.ACTION_APP_DISAMBIG_JUST_ONCE); - } - MetricsLogger.action(this, - mMultiProfilePagerAdapter.getActiveListAdapter().hasFilteredItem() - ? MetricsProto.MetricsEvent.ACTION_HIDE_APP_DISAMBIG_APP_FEATURED - : MetricsProto.MetricsEvent.ACTION_HIDE_APP_DISAMBIG_NONE_FEATURED); - finish(); - } - } - - @Override // ResolverListCommunicator - public Intent getReplacementIntent(ActivityInfo aInfo, Intent defIntent) { - return defIntent; - } - - protected void onListRebuilt(ResolverListAdapter listAdapter, boolean rebuildCompleted) { - final ItemClickListener listener = new ItemClickListener(); - setupAdapterListView((ListView) mMultiProfilePagerAdapter.getActiveAdapterView(), listener); - if (mProfiles.getWorkProfilePresent()) { - final ResolverDrawerLayout rdl = findViewById(com.android.internal.R.id.contentPanel); - if (rdl != null) { - rdl.setMaxCollapsedHeight(getResources() - .getDimensionPixelSize(useLayoutWithDefault() - ? R.dimen.resolver_max_collapsed_height_with_default_with_tabs - : R.dimen.resolver_max_collapsed_height_with_tabs)); - } - } - } - - protected boolean onTargetSelected(TargetInfo target, boolean always) { - final ResolveInfo ri = target.getResolveInfo(); - final Intent intent = target != null ? target.getResolvedIntent() : null; - - if (intent != null /*&& mMultiProfilePagerAdapter.getActiveListAdapter().hasFilteredItem()*/ - && mMultiProfilePagerAdapter.getActiveListAdapter().getUnfilteredResolveList() - != null) { - // Build a reasonable intent filter, based on what matched. - IntentFilter filter = new IntentFilter(); - Intent filterIntent; - - if (intent.getSelector() != null) { - filterIntent = intent.getSelector(); - } else { - filterIntent = intent; - } - - String action = filterIntent.getAction(); - if (action != null) { - filter.addAction(action); - } - Set categories = filterIntent.getCategories(); - if (categories != null) { - for (String cat : categories) { - filter.addCategory(cat); - } - } - filter.addCategory(Intent.CATEGORY_DEFAULT); - - int cat = ri.match & IntentFilter.MATCH_CATEGORY_MASK; - Uri data = filterIntent.getData(); - if (cat == IntentFilter.MATCH_CATEGORY_TYPE) { - String mimeType = filterIntent.resolveType(this); - if (mimeType != null) { - try { - filter.addDataType(mimeType); - } catch (IntentFilter.MalformedMimeTypeException e) { - Log.w("ResolverActivity", e); - filter = null; - } - } - } - if (data != null && data.getScheme() != null) { - // We need the data specification if there was no type, - // OR if the scheme is not one of our magical "file:" - // or "content:" schemes (see IntentFilter for the reason). - if (cat != IntentFilter.MATCH_CATEGORY_TYPE - || (!"file".equals(data.getScheme()) - && !"content".equals(data.getScheme()))) { - filter.addDataScheme(data.getScheme()); - - // Look through the resolved filter to determine which part - // of it matched the original Intent. - Iterator pIt = ri.filter.schemeSpecificPartsIterator(); - if (pIt != null) { - String ssp = data.getSchemeSpecificPart(); - while (ssp != null && pIt.hasNext()) { - PatternMatcher p = pIt.next(); - if (p.match(ssp)) { - filter.addDataSchemeSpecificPart(p.getPath(), p.getType()); - break; - } - } - } - Iterator aIt = ri.filter.authoritiesIterator(); - if (aIt != null) { - while (aIt.hasNext()) { - IntentFilter.AuthorityEntry a = aIt.next(); - if (a.match(data) >= 0) { - int port = a.getPort(); - filter.addDataAuthority(a.getHost(), - port >= 0 ? Integer.toString(port) : null); - break; - } - } - } - pIt = ri.filter.pathsIterator(); - if (pIt != null) { - String path = data.getPath(); - while (path != null && pIt.hasNext()) { - PatternMatcher p = pIt.next(); - if (p.match(path)) { - filter.addDataPath(p.getPath(), p.getType()); - break; - } - } - } - } - } - - if (filter != null) { - final int N = mMultiProfilePagerAdapter.getActiveListAdapter() - .getUnfilteredResolveList().size(); - ComponentName[] set; - // If we don't add back in the component for forwarding the intent to a managed - // profile, the preferred activity may not be updated correctly (as the set of - // components we tell it we knew about will have changed). - final boolean needToAddBackProfileForwardingComponent = - mMultiProfilePagerAdapter.getActiveListAdapter().getOtherProfile() != null; - if (!needToAddBackProfileForwardingComponent) { - set = new ComponentName[N]; - } else { - set = new ComponentName[N + 1]; - } - - int bestMatch = 0; - for (int i = 0; i < N; i++) { - ResolveInfo r = mMultiProfilePagerAdapter.getActiveListAdapter() - .getUnfilteredResolveList().get(i).getResolveInfoAt(0); - set[i] = new ComponentName(r.activityInfo.packageName, - r.activityInfo.name); - if (r.match > bestMatch) bestMatch = r.match; - } - - if (needToAddBackProfileForwardingComponent) { - set[N] = mMultiProfilePagerAdapter.getActiveListAdapter() - .getOtherProfile().getResolvedComponentName(); - final int otherProfileMatch = mMultiProfilePagerAdapter.getActiveListAdapter() - .getOtherProfile().getResolveInfo().match; - if (otherProfileMatch > bestMatch) bestMatch = otherProfileMatch; - } - - if (always) { - final int userId = getUserId(); - final PackageManager pm = mPackageManager; - - // Set the preferred Activity - pm.addUniquePreferredActivity(filter, bestMatch, set, intent.getComponent()); - - if (ri.handleAllWebDataURI) { - // Set default Browser if needed - final String packageName = pm.getDefaultBrowserPackageNameAsUser(userId); - if (TextUtils.isEmpty(packageName)) { - pm.setDefaultBrowserPackageNameAsUser(ri.activityInfo.packageName, - userId); - } - } - } else { - try { - mMultiProfilePagerAdapter.getActiveListAdapter() - .mResolverListController.setLastChosen(intent, filter, bestMatch); - } catch (RemoteException re) { - Log.d(TAG, "Error calling setLastChosenActivity\n" + re); - } - } - } - } - - safelyStartActivity(target); - - // Rely on the ActivityManager to pop up a dialog regarding app suspension - // and return false - return !target.isSuspended(); - } - - @Override // ResolverListCommunicator - public boolean shouldGetActivityMetadata() { - return false; - } - - public boolean shouldAutoLaunchSingleChoice(TargetInfo target) { - return !target.isSuspended(); - } - - @VisibleForTesting - protected ResolverListController createListController(UserHandle userHandle) { - ResolverRankerServiceResolverComparator resolverComparator = - new ResolverRankerServiceResolverComparator( - this, - mRequest.getIntent(), - mViewModel.getActivityModel().getReferrerPackage(), - null, - null, - getResolverRankerServiceUserHandleList(userHandle), - null); - return new ResolverListController( - this, - mPackageManager, - mRequest.getIntent(), - mViewModel.getActivityModel().getReferrerPackage(), - mViewModel.getActivityModel().getLaunchedFromUid(), - resolverComparator, - mProfiles.getQueryIntentsHandle(userHandle)); - } - - /** - * Finishing procedures to be performed after the list has been rebuilt. - *

Subclasses must call postRebuildListInternal at the end of postRebuildList. - * - * @return true if the activity is finishing and creation should halt. - */ - protected boolean postRebuildList(boolean rebuildCompleted) { - return postRebuildListInternal(rebuildCompleted); - } - - /** - * Callback called when user changes the profile tab. - */ - /* TODO: consider merging with the customized considerations of our implemented - * {@link MultiProfilePagerAdapter.OnProfileSelectedListener}. The only apparent distinctions - * between the respective listener callbacks would occur in the triggering patterns during init - * (when the `OnProfileSelectedListener` is registered after a possible tab-change), or possibly - * if there's some way to trigger an update in one model but not the other. If there's an - * initialization dependency, we can probably reason about it with confidence. If there's a - * discrepancy between the `TabHost` and pager-adapter data models, that inconsistency is - * likely to be a bug that would benefit from consolidation. - */ - protected void onProfileTabSelected(int currentPage) { - setupViewVisibilities(); - maybeLogProfileChange(); - if (mProfiles.getWorkProfilePresent()) { - // The device policy logger is only concerned with sessions that include a work profile. - DevicePolicyEventLogger - .createEvent(DevicePolicyEnums.RESOLVER_SWITCH_TABS) - .setInt(currentPage) - .setStrings(getMetricsCategory()) - .write(); - } - } - - /** - * Add a label to signify that the user can pick a different app. - * - * @param adapter The adapter used to provide data to item views. - */ - public void addUseDifferentAppLabelIfNecessary(ResolverListAdapter adapter) { - final boolean useHeader = adapter.hasFilteredItem(); - if (useHeader) { - FrameLayout stub = findViewById(com.android.internal.R.id.stub); - stub.setVisibility(View.VISIBLE); - TextView textView = (TextView) LayoutInflater.from(this).inflate( - R.layout.resolver_different_item_header, null, false); - if (mProfiles.getWorkProfilePresent()) { - textView.setGravity(Gravity.CENTER); - } - stub.addView(textView); - } - } - - protected void resetButtonBar() { - final ViewGroup buttonLayout = findViewById(com.android.internal.R.id.button_bar); - if (buttonLayout == null) { - Log.e(TAG, "Layout unexpectedly does not have a button bar"); - return; - } - ResolverListAdapter activeListAdapter = - mMultiProfilePagerAdapter.getActiveListAdapter(); - View buttonBarDivider = findViewById(com.android.internal.R.id.resolver_button_bar_divider); - if (!useLayoutWithDefault()) { - int inset = mSystemWindowInsets != null ? mSystemWindowInsets.bottom : 0; - buttonLayout.setPadding(buttonLayout.getPaddingLeft(), buttonLayout.getPaddingTop(), - buttonLayout.getPaddingRight(), getResources().getDimensionPixelSize( - R.dimen.resolver_button_bar_spacing) + inset); - } - if (activeListAdapter.isTabLoaded() - && mMultiProfilePagerAdapter.shouldShowEmptyStateScreen(activeListAdapter) - && !useLayoutWithDefault()) { - buttonLayout.setVisibility(View.INVISIBLE); - if (buttonBarDivider != null) { - buttonBarDivider.setVisibility(View.INVISIBLE); - } - setButtonBarIgnoreOffset(/* ignoreOffset */ false); - return; - } - if (buttonBarDivider != null) { - buttonBarDivider.setVisibility(View.VISIBLE); - } - buttonLayout.setVisibility(View.VISIBLE); - setButtonBarIgnoreOffset(/* ignoreOffset */ true); - - mOnceButton = (Button) buttonLayout.findViewById(com.android.internal.R.id.button_once); - mAlwaysButton = (Button) buttonLayout.findViewById(com.android.internal.R.id.button_always); - - resetAlwaysOrOnceButtonBar(); - } - - protected String getMetricsCategory() { - return METRICS_CATEGORY_RESOLVER; - } - - @Override // ResolverListCommunicator - public final void onHandlePackagesChanged(ResolverListAdapter listAdapter) { - if (!mMultiProfilePagerAdapter.onHandlePackagesChanged( - listAdapter, - mProfileAvailability.getWaitingToEnableProfile())) { - // We no longer have any items... just finish the activity. - finish(); - } - } - - protected void maybeLogProfileChange() {} - - @VisibleForTesting - protected CrossProfileIntentsChecker createCrossProfileIntentsChecker() { - return new CrossProfileIntentsChecker(getContentResolver()); - } - - private void onWorkProfileStatusUpdated() { - if (mMultiProfilePagerAdapter.getActiveProfile() == PROFILE_WORK) { - mMultiProfilePagerAdapter.rebuildActiveTab(true); - } else { - mMultiProfilePagerAdapter.clearInactiveProfileCache(); - } - } - - // @NonFinalForTesting - @VisibleForTesting - protected ResolverListAdapter createResolverListAdapter( - Context context, - List payloadIntents, - Intent[] initialIntents, - List resolutionList, - boolean filterLastUsed, - UserHandle userHandle) { - UserHandle initialIntentsUserSpace = mProfiles.getQueryIntentsHandle(userHandle); - return new ResolverListAdapter( - context, - payloadIntents, - initialIntents, - resolutionList, - filterLastUsed, - createListController(userHandle), - userHandle, - mRequest.getIntent(), - this, - initialIntentsUserSpace, - mTargetDataLoader); - } - - protected final EmptyStateProvider createEmptyStateProvider( - @Nullable UserHandle workProfileUserHandle) { - final EmptyStateProvider blockerEmptyStateProvider = createBlockerEmptyStateProvider(); - - final EmptyStateProvider workProfileOffEmptyStateProvider = - new WorkProfilePausedEmptyStateProvider( - this, - mProfiles, - mProfileAvailability, - /* onSwitchOnWorkSelectedListener= */ - () -> { - if (mOnSwitchOnWorkSelectedListener != null) { - mOnSwitchOnWorkSelectedListener.onSwitchOnWorkSelected(); - } - }, - getMetricsCategory()); - - final EmptyStateProvider noAppsEmptyStateProvider = new NoAppsAvailableEmptyStateProvider( - this, - workProfileUserHandle, - mProfiles.getPersonalHandle(), - getMetricsCategory(), - mProfiles.getTabOwnerUserHandleForLaunch() - ); - - // Return composite provider, the order matters (the higher, the more priority) - return new CompositeEmptyStateProvider( - blockerEmptyStateProvider, - workProfileOffEmptyStateProvider, - noAppsEmptyStateProvider - ); - } - - private ResolverMultiProfilePagerAdapter createResolverMultiProfilePagerAdapterForOneProfile( - Intent[] initialIntents, - List resolutionList, - boolean filterLastUsed) { - ResolverListAdapter personalAdapter = createResolverListAdapter( - /* context */ this, - mRequest.getPayloadIntents(), - initialIntents, - resolutionList, - filterLastUsed, - /* userHandle */ mProfiles.getPersonalHandle() - ); - return new ResolverMultiProfilePagerAdapter( - /* context */ this, - ImmutableList.of( - new TabConfig<>( - PROFILE_PERSONAL, - mDevicePolicyResources.getPersonalTabLabel(), - mDevicePolicyResources.getPersonalTabAccessibilityLabel(), - TAB_TAG_PERSONAL, - personalAdapter)), - createEmptyStateProvider(/* workProfileUserHandle= */ null), - /* workProfileQuietModeChecker= */ () -> false, - /* defaultProfile= */ PROFILE_PERSONAL, - /* workProfileUserHandle= */ null, - mProfiles.getCloneHandle()); - } - - private UserHandle getIntentUser() { - return Objects.requireNonNullElse(mRequest.getCallingUser(), - mProfiles.getTabOwnerUserHandleForLaunch()); - } - - private ResolverMultiProfilePagerAdapter createResolverMultiProfilePagerAdapterForTwoProfiles( - Intent[] initialIntents, - List resolutionList, - boolean filterLastUsed) { - // In the edge case when we have 0 apps in the current profile and >1 apps in the other, - // the intent resolver is started in the other profile. Since this is the only case when - // this happens, we check for it here and set the current profile's tab. - int selectedProfile = getCurrentProfile(); - UserHandle intentUser = getIntentUser(); - if (!mProfiles.getTabOwnerUserHandleForLaunch().equals(intentUser)) { - if (mProfiles.getPersonalHandle().equals(intentUser)) { - selectedProfile = PROFILE_PERSONAL; - } else if (mProfiles.getWorkHandle().equals(intentUser)) { - selectedProfile = PROFILE_WORK; - } - } else { - int selectedProfileExtra = getSelectedProfileExtra(); - if (selectedProfileExtra != -1) { - selectedProfile = selectedProfileExtra; - } - } - // We only show the default app for the profile of the current user. The filterLastUsed - // flag determines whether to show a default app and that app is not shown in the - // resolver list. So filterLastUsed should be false for the other profile. - ResolverListAdapter personalAdapter = createResolverListAdapter( - /* context */ this, - mRequest.getPayloadIntents(), - selectedProfile == PROFILE_PERSONAL ? initialIntents : null, - resolutionList, - (filterLastUsed && UserHandle.myUserId() - == mProfiles.getPersonalHandle().getIdentifier()), - /* userHandle */ mProfiles.getPersonalHandle() - ); - UserHandle workProfileUserHandle = mProfiles.getWorkHandle(); - ResolverListAdapter workAdapter = createResolverListAdapter( - /* context */ this, - mRequest.getPayloadIntents(), - selectedProfile == PROFILE_WORK ? initialIntents : null, - resolutionList, - (filterLastUsed && UserHandle.myUserId() - == workProfileUserHandle.getIdentifier()), - /* userHandle */ workProfileUserHandle - ); - return new ResolverMultiProfilePagerAdapter( - /* 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(workProfileUserHandle), - /* Supplier (QuietMode enabled) == !(available) */ - () -> !(mProfiles.getWorkProfilePresent() - && mProfileAvailability.isAvailable( - requireNonNull(mProfiles.getWorkProfile()))), - selectedProfile, - workProfileUserHandle, - mProfiles.getCloneHandle()); - } - - /** - * Returns {@link #PROFILE_PERSONAL} or {@link #PROFILE_WORK} if the {@link - * #EXTRA_SELECTED_PROFILE} extra was supplied, or {@code -1} if no extra was supplied. - */ - final int getSelectedProfileExtra() { - Profile.Type selected = mRequest.getSelectedProfile(); - if (selected == null) { - return -1; - } - switch (selected) { - case PERSONAL: return PROFILE_PERSONAL; - case WORK: return PROFILE_WORK; - default: return -1; - } - } - - protected final @ProfileType int getCurrentProfile() { - UserHandle launchUser = mProfiles.getTabOwnerUserHandleForLaunch(); - UserHandle personalUser = mProfiles.getPersonalHandle(); - return launchUser.equals(personalUser) ? PROFILE_PERSONAL : PROFILE_WORK; - } - - private void updateIntentPickerPaddings() { - View titleCont = findViewById(com.android.internal.R.id.title_container); - titleCont.setPadding( - titleCont.getPaddingLeft(), - titleCont.getPaddingTop(), - titleCont.getPaddingRight(), - getResources().getDimensionPixelSize(R.dimen.resolver_title_padding_bottom)); - View buttonBar = findViewById(com.android.internal.R.id.button_bar); - buttonBar.setPadding( - buttonBar.getPaddingLeft(), - getResources().getDimensionPixelSize(R.dimen.resolver_button_bar_spacing), - buttonBar.getPaddingRight(), - getResources().getDimensionPixelSize(R.dimen.resolver_button_bar_spacing)); - } - - private void maybeLogCrossProfileTargetLaunch(TargetInfo cti, UserHandle currentUserHandle) { - // TODO: Test isolation bug, referencing getUser() will break tests with faked profiles - if (!mProfiles.getWorkProfilePresent() || currentUserHandle.equals(getUser())) { - return; - } - DevicePolicyEventLogger - .createEvent(DevicePolicyEnums.RESOLVER_CROSS_PROFILE_TARGET_OPENED) - .setBoolean( - currentUserHandle.equals( - mProfiles.getPersonalHandle())) - .setStrings(getMetricsCategory(), - cti.isInDirectShareMetricsCategory() ? "direct_share" : "other_target") - .write(); - } - - @Override // ResolverListCommunicator - public final void sendVoiceChoicesIfNeeded() { - if (!isVoiceInteraction()) { - // Clearly not needed. - return; - } - - int count = mMultiProfilePagerAdapter.getActiveListAdapter().getCount(); - final Option[] options = new Option[count]; - for (int i = 0; i < options.length; i++) { - TargetInfo target = mMultiProfilePagerAdapter.getActiveListAdapter().getItem(i); - if (target == null) { - // If this occurs, a new set of targets is being loaded. Let that complete, - // and have the next call to send voice choices proceed instead. - return; - } - options[i] = optionForChooserTarget(target, i); - } - - mPickOptionRequest = new PickTargetOptionRequest( - new Prompt(getTitle()), options, null); - getVoiceInteractor().submitRequest(mPickOptionRequest); - } - - final Option optionForChooserTarget(TargetInfo target, int index) { - return new Option(getOrLoadDisplayLabel(target), index); - } - - protected final CharSequence getTitleForAction(Intent intent, int defaultTitleRes) { - final ActionTitle title = mResolvingHome - ? ActionTitle.HOME - : ActionTitle.forAction(intent.getAction()); - - // While there may already be a filtered item, we can only use it in the title if the list - // is already sorted and all information relevant to it is already in the list. - final boolean named = - mMultiProfilePagerAdapter.getActiveListAdapter().getFilteredPosition() >= 0; - if (title == ActionTitle.DEFAULT && defaultTitleRes != 0) { - return getString(defaultTitleRes); - } else { - return named - ? getString( - title.namedTitleRes, - getOrLoadDisplayLabel( - mMultiProfilePagerAdapter - .getActiveListAdapter().getFilteredItem())) - : getString(title.titleRes); - } - } - - private boolean hasManagedProfile() { - UserManager userManager = (UserManager) getSystemService(Context.USER_SERVICE); - if (userManager == null) { - return false; - } - - try { - List profiles = userManager.getProfiles(getUserId()); - for (UserInfo userInfo : profiles) { - if (userInfo != null && userInfo.isManagedProfile()) { - return true; - } - } - } catch (SecurityException e) { - return false; - } - return false; - } - - private boolean supportsManagedProfiles(ResolveInfo resolveInfo) { - try { - ApplicationInfo appInfo = mPackageManager.getApplicationInfo( - resolveInfo.activityInfo.packageName, 0 /* default flags */); - return appInfo.targetSdkVersion >= Build.VERSION_CODES.LOLLIPOP; - } catch (NameNotFoundException e) { - return false; - } - } - - private void setAlwaysButtonEnabled(boolean hasValidSelection, int checkedPos, - boolean filtered) { - if (!mMultiProfilePagerAdapter.getCurrentUserHandle().equals(getUser())) { - // Never allow the inactive profile to always open an app. - mAlwaysButton.setEnabled(false); - return; - } - // In case of clonedProfile being active, we do not allow the 'Always' option in the - // disambiguation dialog of Personal Profile as the package manager cannot distinguish - // between cross-profile preferred activities. - if (mProfiles.getCloneUserPresent() - && (mMultiProfilePagerAdapter.getActiveProfile() == PROFILE_PERSONAL)) { - mAlwaysButton.setEnabled(false); - return; - } - boolean enabled = false; - ResolveInfo ri = null; - if (hasValidSelection) { - ri = mMultiProfilePagerAdapter.getActiveListAdapter() - .resolveInfoForPosition(checkedPos, filtered); - if (ri == null) { - Log.e(TAG, "Invalid position supplied to setAlwaysButtonEnabled"); - return; - } else if (ri.targetUserId != UserHandle.USER_CURRENT) { - Log.e(TAG, "Attempted to set selection to resolve info for another user"); - return; - } else { - enabled = true; - } - - mAlwaysButton.setText(getResources() - .getString(R.string.activity_resolver_use_always)); - } - - if (ri != null) { - ActivityInfo activityInfo = ri.activityInfo; - - boolean hasRecordPermission = mPackageManager - .checkPermission(android.Manifest.permission.RECORD_AUDIO, - activityInfo.packageName) - == PackageManager.PERMISSION_GRANTED; - - if (!hasRecordPermission) { - // OK, we know the record permission, is this a capture device - boolean hasAudioCapture = mViewModel.getRequest().getValue().isAudioCaptureDevice(); - enabled = !hasAudioCapture; - } - } - mAlwaysButton.setEnabled(enabled); - } - - @Override // ResolverListCommunicator - public final void onPostListReady(ResolverListAdapter listAdapter, boolean doPostProcessing, - boolean rebuildCompleted) { - if (isAutolaunching()) { - return; - } - mMultiProfilePagerAdapter.setUseLayoutWithDefault(useLayoutWithDefault()); - - if (mMultiProfilePagerAdapter.shouldShowEmptyStateScreen(listAdapter)) { - mMultiProfilePagerAdapter.showEmptyResolverListEmptyState(listAdapter); - } else { - mMultiProfilePagerAdapter.showListView(listAdapter); - } - // showEmptyResolverListEmptyState can mark the tab as loaded, - // which is a precondition for auto launching - if (rebuildCompleted && maybeAutolaunchActivity()) { - return; - } - if (doPostProcessing) { - maybeCreateHeader(listAdapter); - resetButtonBar(); - onListRebuilt(listAdapter, rebuildCompleted); - } - } - - /** Start the activity specified by the {@link TargetInfo}.*/ - public final void safelyStartActivity(TargetInfo cti) { - // In case cloned apps are present, we would want to start those apps in cloned user - // space, which will not be same as the adapter's userHandle. resolveInfo.userHandle - // identifies the correct user space in such cases. - UserHandle activityUserHandle = cti.getResolveInfo().userHandle; - safelyStartActivityAsUser(cti, activityUserHandle, null); - } - - /** - * Start activity as a fixed user handle. - * @param cti TargetInfo to be launched. - * @param user User to launch this activity as. - */ - @VisibleForTesting(visibility = VisibleForTesting.Visibility.PROTECTED) - public final void safelyStartActivityAsUser(TargetInfo cti, UserHandle user) { - safelyStartActivityAsUser(cti, user, null); - } - - protected final void safelyStartActivityAsUser( - TargetInfo cti, UserHandle user, @Nullable Bundle options) { - // We're dispatching intents that might be coming from legacy apps, so - // don't kill ourselves. - StrictMode.disableDeathOnFileUriExposure(); - try { - safelyStartActivityInternal(cti, user, options); - } finally { - StrictMode.enableDeathOnFileUriExposure(); - } - } - - final void showTargetDetails(ResolveInfo ri) { - Intent in = new Intent().setAction(Settings.ACTION_APPLICATION_DETAILS_SETTINGS) - .setData(Uri.fromParts("package", ri.activityInfo.packageName, null)) - .addFlags(Intent.FLAG_ACTIVITY_NEW_DOCUMENT); - startActivityAsUser(in, mMultiProfilePagerAdapter.getCurrentUserHandle()); - } - - /** - * Sets up the content view. - * @return true if the activity is finishing and creation should halt. - */ - private boolean configureContentView(TargetDataLoader targetDataLoader) { - if (mMultiProfilePagerAdapter.getActiveListAdapter() == null) { - throw new IllegalStateException("mMultiProfilePagerAdapter.getCurrentListAdapter() " - + "cannot be null."); - } - Trace.beginSection("configureContentView"); - // We partially rebuild the inactive adapter to determine if we should auto launch - // isTabLoaded will be true here if the empty state screen is shown instead of the list. - // To date, we really only care about "partially rebuilding" tabs for work and/or personal. - boolean rebuildCompleted = - mMultiProfilePagerAdapter.rebuildTabs(mProfiles.getWorkProfilePresent()); - - if (shouldUseMiniResolver()) { - configureMiniResolverContent(targetDataLoader); - Trace.endSection(); - return false; - } - - if (useLayoutWithDefault()) { - mLayoutId = R.layout.resolver_list_with_default; - } else { - mLayoutId = getLayoutResource(); - } - setContentView(mLayoutId); - mMultiProfilePagerAdapter.setupViewPager( - findViewById(com.android.internal.R.id.profile_pager)); - boolean result = postRebuildList(rebuildCompleted); - Trace.endSection(); - return result; - } - - /** - * Mini resolver is shown when the user is choosing between browser[s] in this profile and a - * single app in the other profile (see shouldUseMiniResolver()). It shows the single app icon - * and asks the user if they'd like to open that cross-profile app or use the in-profile - * browser. - */ - private void configureMiniResolverContent(TargetDataLoader targetDataLoader) { - mLayoutId = R.layout.miniresolver; - setContentView(mLayoutId); - - boolean inWorkProfile = getCurrentProfile() == PROFILE_WORK; - - ResolverListAdapter sameProfileAdapter = - (mMultiProfilePagerAdapter.getActiveProfile() == PROFILE_PERSONAL) - ? mMultiProfilePagerAdapter.getPersonalListAdapter() - : mMultiProfilePagerAdapter.getWorkListAdapter(); - - ResolverListAdapter inactiveAdapter = - (mMultiProfilePagerAdapter.getActiveProfile() == PROFILE_PERSONAL) - ? mMultiProfilePagerAdapter.getWorkListAdapter() - : mMultiProfilePagerAdapter.getPersonalListAdapter(); - - DisplayResolveInfo sameProfileResolveInfo = sameProfileAdapter.getFirstDisplayResolveInfo(); - - final DisplayResolveInfo otherProfileResolveInfo = - inactiveAdapter.getFirstDisplayResolveInfo(); - - // Load the icon asynchronously - ImageView icon = findViewById(com.android.internal.R.id.icon); - targetDataLoader.loadAppTargetIcon( - otherProfileResolveInfo, - inactiveAdapter.getUserHandle(), - (drawable) -> { - if (!isDestroyed()) { - otherProfileResolveInfo.getDisplayIconHolder().setDisplayIcon(drawable); - new ResolverListAdapter.ViewHolder(icon).bindIcon(otherProfileResolveInfo); - } - }); - - ((TextView) findViewById(com.android.internal.R.id.open_cross_profile)).setText( - getResources().getString( - inWorkProfile - ? R.string.miniresolver_open_in_personal - : R.string.miniresolver_open_in_work, - getOrLoadDisplayLabel(otherProfileResolveInfo))); - ((Button) findViewById(com.android.internal.R.id.use_same_profile_browser)).setText( - inWorkProfile ? R.string.miniresolver_use_work_browser - : R.string.miniresolver_use_personal_browser); - - findViewById(com.android.internal.R.id.use_same_profile_browser).setOnClickListener( - v -> { - safelyStartActivity(sameProfileResolveInfo); - finish(); - }); - - findViewById(com.android.internal.R.id.button_open).setOnClickListener(v -> { - Intent intent = otherProfileResolveInfo.getResolvedIntent(); - safelyStartActivityAsUser(otherProfileResolveInfo, inactiveAdapter.getUserHandle()); - finish(); - }); - } - - private boolean isTwoPagePersonalAndWorkConfiguration() { - return (mMultiProfilePagerAdapter.getCount() == 2) - && mMultiProfilePagerAdapter.hasPageForProfile(PROFILE_PERSONAL) - && mMultiProfilePagerAdapter.hasPageForProfile(PROFILE_WORK); - } - - @VisibleForTesting - protected void safelyStartActivityInternal( - TargetInfo cti, UserHandle user, @Nullable Bundle options) { - // If the target is suspended, the activity will not be successfully launched. - // Do not unregister from package manager updates in this case - if (!cti.isSuspended() && mRegistered) { - if (mPersonalPackageMonitor != null) { - mPersonalPackageMonitor.unregister(); - } - if (mWorkPackageMonitor != null) { - mWorkPackageMonitor.unregister(); - } - mRegistered = false; - } - // If needed, show that intent is forwarded - // from managed profile to owner or other way around. - String profileSwitchMessage = - mIntentForwarding.forwardMessageFor(mRequest.getIntent()); - if (profileSwitchMessage != null) { - Toast.makeText(this, profileSwitchMessage, Toast.LENGTH_LONG).show(); - } - try { - if (cti.startAsCaller(this, options, user.getIdentifier())) { - maybeLogCrossProfileTargetLaunch(cti, user); - } - } catch (RuntimeException e) { - Slog.wtf(TAG, - "Unable to launch as uid " - + mViewModel.getActivityModel().getLaunchedFromUid() - + " package " + mViewModel.getActivityModel().getLaunchedFromPackage() - + ", while running in " + ActivityThread.currentProcessName(), e); - } - } - - /** - * Finishing procedures to be performed after the list has been rebuilt. - * @param rebuildCompleted - * @return true if the activity is finishing and creation should halt. - */ - final boolean postRebuildListInternal(boolean rebuildCompleted) { - int count = mMultiProfilePagerAdapter.getActiveListAdapter().getUnfilteredCount(); - - // We only rebuild asynchronously when we have multiple elements to sort. In the case where - // we're already done, we can check if we should auto-launch immediately. - if (rebuildCompleted && maybeAutolaunchActivity()) { - return true; - } - - setupViewVisibilities(); - - if (mProfiles.getWorkProfilePresent()) { - setupProfileTabs(); - } - - return false; - } - - /** - * Mini resolver should be used when all of the following are true: - * 1. This is the intent picker (ResolverActivity). - * 2. This profile only has web browser matches. - * 3. The other profile has a single non-browser match. - */ - private boolean shouldUseMiniResolver() { - if (!isTwoPagePersonalAndWorkConfiguration()) { - return false; - } - - ResolverListAdapter sameProfileAdapter = - (mMultiProfilePagerAdapter.getActiveProfile() == PROFILE_PERSONAL) - ? mMultiProfilePagerAdapter.getPersonalListAdapter() - : mMultiProfilePagerAdapter.getWorkListAdapter(); - - ResolverListAdapter otherProfileAdapter = - (mMultiProfilePagerAdapter.getActiveProfile() == PROFILE_PERSONAL) - ? mMultiProfilePagerAdapter.getWorkListAdapter() - : mMultiProfilePagerAdapter.getPersonalListAdapter(); - - if (sameProfileAdapter.getDisplayResolveInfoCount() == 0) { - Log.d(TAG, "No targets in the current profile"); - return false; - } - - if (otherProfileAdapter.getDisplayResolveInfoCount() != 1) { - Log.d(TAG, "Other-profile count: " + otherProfileAdapter.getDisplayResolveInfoCount()); - return false; - } - - if (otherProfileAdapter.allResolveInfosHandleAllWebDataUri()) { - Log.d(TAG, "Other profile is a web browser"); - return false; - } - - if (!sameProfileAdapter.allResolveInfosHandleAllWebDataUri()) { - Log.d(TAG, "Non-browser found in this profile"); - return false; - } - - return true; - } - - private boolean maybeAutolaunchIfSingleTarget() { - int count = mMultiProfilePagerAdapter.getActiveListAdapter().getUnfilteredCount(); - if (count != 1) { - return false; - } - - if (mMultiProfilePagerAdapter.getActiveListAdapter().getOtherProfile() != null) { - return false; - } - - // Only one target, so we're a candidate to auto-launch! - final TargetInfo target = mMultiProfilePagerAdapter.getActiveListAdapter() - .targetInfoForPosition(0, false); - if (shouldAutoLaunchSingleChoice(target)) { - safelyStartActivity(target); - finish(); - return true; - } - return false; - } - - /** - * When we have just a personal and a work profile, we auto launch in the following scenario: - * - There is 1 resolved target on each profile - * - That target is the same app on both profiles - * - The target app has permission to communicate cross profiles - * - The target app has declared it supports cross-profile communication via manifest metadata - */ - private boolean maybeAutolaunchIfCrossProfileSupported() { - if (!isTwoPagePersonalAndWorkConfiguration()) { - return false; - } - - ResolverListAdapter activeListAdapter = - (mMultiProfilePagerAdapter.getActiveProfile() == PROFILE_PERSONAL) - ? mMultiProfilePagerAdapter.getPersonalListAdapter() - : mMultiProfilePagerAdapter.getWorkListAdapter(); - - ResolverListAdapter inactiveListAdapter = - (mMultiProfilePagerAdapter.getActiveProfile() == PROFILE_PERSONAL) - ? mMultiProfilePagerAdapter.getWorkListAdapter() - : mMultiProfilePagerAdapter.getPersonalListAdapter(); - - if (!activeListAdapter.isTabLoaded() || !inactiveListAdapter.isTabLoaded()) { - return false; - } - - if ((activeListAdapter.getUnfilteredCount() != 1) - || (inactiveListAdapter.getUnfilteredCount() != 1)) { - return false; - } - - TargetInfo activeProfileTarget = activeListAdapter.targetInfoForPosition(0, false); - TargetInfo inactiveProfileTarget = inactiveListAdapter.targetInfoForPosition(0, false); - if (!Objects.equals( - activeProfileTarget.getResolvedComponentName(), - inactiveProfileTarget.getResolvedComponentName())) { - return false; - } - - if (!shouldAutoLaunchSingleChoice(activeProfileTarget)) { - return false; - } - - String packageName = activeProfileTarget.getResolvedComponentName().getPackageName(); - if (!mIntentForwarding.canAppInteractAcrossProfiles(this, packageName)) { - return false; - } - - DevicePolicyEventLogger - .createEvent(DevicePolicyEnums.RESOLVER_AUTOLAUNCH_CROSS_PROFILE_TARGET) - .setBoolean(activeListAdapter.getUserHandle() - .equals(mProfiles.getPersonalHandle())) - .setStrings(getMetricsCategory()) - .write(); - safelyStartActivity(activeProfileTarget); - finish(); - return true; - } - - private boolean isAutolaunching() { - return !mRegistered && isFinishing(); - } - - /** - * @return {@code true} if a resolved target is autolaunched, otherwise {@code false} - */ - private boolean maybeAutolaunchActivity() { - if (!isTwoPagePersonalAndWorkConfiguration()) { - return false; - } - - ResolverListAdapter activeListAdapter = - (mMultiProfilePagerAdapter.getActiveProfile() == PROFILE_PERSONAL) - ? mMultiProfilePagerAdapter.getPersonalListAdapter() - : mMultiProfilePagerAdapter.getWorkListAdapter(); - - ResolverListAdapter inactiveListAdapter = - (mMultiProfilePagerAdapter.getActiveProfile() == PROFILE_PERSONAL) - ? mMultiProfilePagerAdapter.getWorkListAdapter() - : mMultiProfilePagerAdapter.getPersonalListAdapter(); - - if (!activeListAdapter.isTabLoaded() || !inactiveListAdapter.isTabLoaded()) { - return false; - } - - if ((activeListAdapter.getUnfilteredCount() != 1) - || (inactiveListAdapter.getUnfilteredCount() != 1)) { - return false; - } - - TargetInfo activeProfileTarget = activeListAdapter.targetInfoForPosition(0, false); - TargetInfo inactiveProfileTarget = inactiveListAdapter.targetInfoForPosition(0, false); - if (!Objects.equals( - activeProfileTarget.getResolvedComponentName(), - inactiveProfileTarget.getResolvedComponentName())) { - return false; - } - - if (!shouldAutoLaunchSingleChoice(activeProfileTarget)) { - return false; - } - - String packageName = activeProfileTarget.getResolvedComponentName().getPackageName(); - if (!mIntentForwarding.canAppInteractAcrossProfiles(this, packageName)) { - return false; - } - - DevicePolicyEventLogger - .createEvent(DevicePolicyEnums.RESOLVER_AUTOLAUNCH_CROSS_PROFILE_TARGET) - .setBoolean(activeListAdapter.getUserHandle() - .equals(mProfiles.getPersonalHandle())) - .setStrings(getMetricsCategory()) - .write(); - safelyStartActivity(activeProfileTarget); - finish(); - return true; - } - - private void maybeHideDivider() { - final View divider = findViewById(com.android.internal.R.id.divider); - if (divider == null) { - return; - } - divider.setVisibility(View.GONE); - } - - private void resetCheckedItem() { - mLastSelected = ListView.INVALID_POSITION; - ((ResolverMultiProfilePagerAdapter) mMultiProfilePagerAdapter) - .clearCheckedItemsInInactiveProfiles(); - } - - private void setupViewVisibilities() { - ResolverListAdapter activeListAdapter = mMultiProfilePagerAdapter.getActiveListAdapter(); - if (!mMultiProfilePagerAdapter.shouldShowEmptyStateScreen(activeListAdapter)) { - addUseDifferentAppLabelIfNecessary(activeListAdapter); - } - } - - /** - * Updates the button bar container {@code ignoreOffset} layout param. - *

Setting this to {@code true} means that the button bar will be glued to the bottom of - * the screen. - */ - private void setButtonBarIgnoreOffset(boolean ignoreOffset) { - View buttonBarContainer = findViewById(com.android.internal.R.id.button_bar_container); - if (buttonBarContainer != null) { - ResolverDrawerLayout.LayoutParams layoutParams = - (ResolverDrawerLayout.LayoutParams) buttonBarContainer.getLayoutParams(); - layoutParams.ignoreOffset = ignoreOffset; - buttonBarContainer.setLayoutParams(layoutParams); - } - } - - private void setupAdapterListView(ListView listView, ItemClickListener listener) { - listView.setOnItemClickListener(listener); - listView.setOnItemLongClickListener(listener); - listView.setChoiceMode(AbsListView.CHOICE_MODE_SINGLE); - } - - /** - * Configure the area above the app selection list (title, content preview, etc). - */ - private void maybeCreateHeader(ResolverListAdapter listAdapter) { - if (mHeaderCreatorUser != null - && !listAdapter.getUserHandle().equals(mHeaderCreatorUser)) { - return; - } - if (!mProfiles.getWorkProfilePresent() - && listAdapter.getCount() == 0 && listAdapter.getPlaceholderCount() == 0) { - final TextView titleView = findViewById(com.android.internal.R.id.title); - if (titleView != null) { - titleView.setVisibility(View.GONE); - } - } - ResolverRequest request = mViewModel.getRequest().getValue(); - CharSequence title = mViewModel.getRequest().getValue().getTitle() != null - ? request.getTitle() - : getTitleForAction(request.getIntent(), 0); - - if (!TextUtils.isEmpty(title)) { - final TextView titleView = findViewById(com.android.internal.R.id.title); - if (titleView != null) { - titleView.setText(title); - } - setTitle(title); - } - - final ImageView iconView = findViewById(com.android.internal.R.id.icon); - if (iconView != null) { - listAdapter.loadFilteredItemIconTaskAsync(iconView); - } - mHeaderCreatorUser = listAdapter.getUserHandle(); - } - - private void resetAlwaysOrOnceButtonBar() { - // Disable both buttons initially - setAlwaysButtonEnabled(false, ListView.INVALID_POSITION, false); - mOnceButton.setEnabled(false); - - int filteredPosition = mMultiProfilePagerAdapter.getActiveListAdapter() - .getFilteredPosition(); - if (useLayoutWithDefault() && filteredPosition != ListView.INVALID_POSITION) { - setAlwaysButtonEnabled(true, filteredPosition, false); - mOnceButton.setEnabled(true); - // Focus the button if we already have the default option - mOnceButton.requestFocus(); - return; - } - - // When the items load in, if an item was already selected, enable the buttons - ListView currentAdapterView = (ListView) mMultiProfilePagerAdapter.getActiveAdapterView(); - if (currentAdapterView != null - && currentAdapterView.getCheckedItemPosition() != ListView.INVALID_POSITION) { - setAlwaysButtonEnabled(true, currentAdapterView.getCheckedItemPosition(), true); - mOnceButton.setEnabled(true); - } - } - - @Override // ResolverListCommunicator - public final boolean useLayoutWithDefault() { - // We only use the default app layout when the profile of the active user has a - // filtered item. We always show the same default app even in the inactive user profile. - return mMultiProfilePagerAdapter.getListAdapterForUserHandle( - mProfiles.getTabOwnerUserHandleForLaunch() - ).hasFilteredItem(); - } - - final class ItemClickListener implements AdapterView.OnItemClickListener, - AdapterView.OnItemLongClickListener { - @Override - public void onItemClick(AdapterView parent, View view, int position, long id) { - final ListView listView = parent instanceof ListView ? (ListView) parent : null; - if (listView != null) { - position -= listView.getHeaderViewsCount(); - } - if (position < 0) { - // Header views don't count. - return; - } - // If we're still loading, we can't yet enable the buttons. - if (mMultiProfilePagerAdapter.getActiveListAdapter() - .resolveInfoForPosition(position, true) == null) { - return; - } - ListView currentAdapterView = - (ListView) mMultiProfilePagerAdapter.getActiveAdapterView(); - final int checkedPos = currentAdapterView.getCheckedItemPosition(); - final boolean hasValidSelection = checkedPos != ListView.INVALID_POSITION; - if (!useLayoutWithDefault() - && (!hasValidSelection || mLastSelected != checkedPos) - && mAlwaysButton != null) { - setAlwaysButtonEnabled(hasValidSelection, checkedPos, true); - mOnceButton.setEnabled(hasValidSelection); - if (hasValidSelection) { - currentAdapterView.smoothScrollToPosition(checkedPos); - mOnceButton.requestFocus(); - } - mLastSelected = checkedPos; - } else { - startSelected(position, false, true); - } - } - - @Override - public boolean onItemLongClick(AdapterView parent, View view, int position, long id) { - final ListView listView = parent instanceof ListView ? (ListView) parent : null; - if (listView != null) { - position -= listView.getHeaderViewsCount(); - } - if (position < 0) { - // Header views don't count. - return false; - } - ResolveInfo ri = mMultiProfilePagerAdapter.getActiveListAdapter() - .resolveInfoForPosition(position, true); - showTargetDetails(ri); - return true; - } - - } - - private void setupProfileTabs() { - maybeHideDivider(); - - TabHost tabHost = findViewById(com.android.internal.R.id.profile_tabhost); - ViewPager viewPager = findViewById(com.android.internal.R.id.profile_pager); - - mMultiProfilePagerAdapter.setupProfileTabs( - getLayoutInflater(), - tabHost, - viewPager, - R.layout.resolver_profile_tab_button, - com.android.internal.R.id.profile_pager, - () -> onProfileTabSelected(viewPager.getCurrentItem()), - new OnProfileSelectedListener() { - @Override - public void onProfilePageSelected(@ProfileType int profileId, int pageNumber) { - resetButtonBar(); - resetCheckedItem(); - } - - @Override - public void onProfilePageStateChanged(int state) {} - }); - mOnSwitchOnWorkSelectedListener = () -> { - final View workTab = - tabHost.getTabWidget().getChildAt( - mMultiProfilePagerAdapter.getPageNumberForProfile(PROFILE_WORK)); - workTab.setFocusable(true); - workTab.setFocusableInTouchMode(true); - workTab.requestFocus(); - }; - } - - static final class PickTargetOptionRequest extends PickOptionRequest { - public PickTargetOptionRequest(@Nullable Prompt prompt, Option[] options, - @Nullable Bundle extras) { - super(prompt, options, extras); - } - - @Override - public void onCancel() { - super.onCancel(); - final ResolverActivity ra = (ResolverActivity) getActivity(); - if (ra != null) { - ra.mPickOptionRequest = null; - ra.finish(); - } - } - - @Override - public void onPickOptionResult(boolean finished, Option[] selections, Bundle result) { - super.onPickOptionResult(finished, selections, result); - if (selections.length != 1) { - // TODO In a better world we would filter the UI presented here and let the - // user refine. Maybe later. - return; - } - - final ResolverActivity ra = (ResolverActivity) getActivity(); - if (ra != null) { - final TargetInfo ti = ra.mMultiProfilePagerAdapter.getActiveListAdapter() - .getItem(selections[0].getIndex()); - if (ra.onTargetSelected(ti, false)) { - ra.mPickOptionRequest = null; - ra.finish(); - } - } - } - } - /** - * 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 mProfiles.getQueryIntentsHandle(userHandle); - } - - /** - * 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 getResolverRankerServiceUserHandleList(UserHandle userHandle) { - return getResolverRankerServiceUserHandleListInternal(userHandle); - } - - @VisibleForTesting - protected List getResolverRankerServiceUserHandleListInternal( - UserHandle userHandle) { - List userList = new ArrayList<>(); - userList.add(userHandle); - // Add clonedProfileUserHandle to the list only if we are: - // a. Building the Personal Tab. - // b. CloneProfile exists on the device. - if (userHandle.equals(mProfiles.getPersonalHandle()) - && mProfiles.getCloneUserPresent()) { - userList.add(mProfiles.getCloneHandle()); - } - return userList; - } - - private CharSequence getOrLoadDisplayLabel(TargetInfo info) { - if (info.isDisplayResolveInfo()) { - mTargetDataLoader.getOrLoadLabel((DisplayResolveInfo) info); - } - CharSequence displayLabel = info.getDisplayLabel(); - return displayLabel == null ? "" : displayLabel; - } -} diff --git a/java/src/com/android/intentresolver/v2/ResolverHelper.kt b/java/src/com/android/intentresolver/v2/ResolverHelper.kt deleted file mode 100644 index 388b30a7..00000000 --- a/java/src/com/android/intentresolver/v2/ResolverHelper.kt +++ /dev/null @@ -1,129 +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.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 com.android.intentresolver.inject.Background -import com.android.intentresolver.v2.annotation.JavaInterop -import com.android.intentresolver.v2.domain.interactor.UserInteractor -import com.android.intentresolver.v2.ui.model.ResolverRequest -import com.android.intentresolver.v2.ui.viewmodel.ResolverViewModel -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 javax.inject.Inject -import kotlinx.coroutines.CoroutineDispatcher - -private const val TAG: String = "ResolverHelper" - -/** - * __Purpose__ - * - * Cleanup aid. Provides a pathway to cleaner code. - * - * __Incoming References__ - * - * ResolverHelper must not expose any properties or functions directly back to ResolverActivity. If - * a value or operation is required by ResolverActivity, then it must be added to - * ResolverInitializer (or a new interface as appropriate) with ResolverActivity supplying a - * callback to receive it at the appropriate point. This enforces unidirectional control flow. - * - * __Outgoing References__ - * - * _ResolverActivity_ - * - * This class must only reference it's host as Activity/ComponentActivity; no down-cast to - * [ResolverActivity]. Other components should be created here or supplied via Injection, and not - * referenced directly from the activity. This prevents circular dependencies from forming. If - * necessary, during cleanup the dependency can be supplied back to ChooserActivity as described - * above in 'Incoming References', see [ResolverInitializer]. - * - * _Elsewhere_ - * - * Where possible, Singleton and ActivityScoped dependencies should be injected here instead of - * referenced from an existing location. If not available for injection, the value should be - * constructed here, then provided to where it is needed. - */ -@ActivityScoped -@JavaInterop -class ResolverHelper -@Inject -constructor( - hostActivity: Activity, - private val userInteractor: UserInteractor, - @Background private val background: CoroutineDispatcher, -) : DefaultLifecycleObserver { - // This is guaranteed by Hilt, since only a ComponentActivity is injectable. - private val activity: ComponentActivity = hostActivity as ComponentActivity - private val viewModel by activity.viewModels() - - private lateinit var activityInitializer: Runnable - - init { - activity.lifecycle.addObserver(this) - } - - /** - * Set the initialization hook for the host activity. - * - * This _must_ be called from [ResolverActivity.onCreate]. - */ - fun setInitializer(initializer: Runnable) { - if (activity.lifecycle.currentState != Lifecycle.State.INITIALIZED) { - error("setInitializer must be called before onCreate returns") - } - activityInitializer = initializer - } - - /** Invoked by Lifecycle, after Activity.onCreate() _returns_. */ - override fun onCreate(owner: LifecycleOwner) { - Log.i(TAG, "CREATE") - Log.i(TAG, "${viewModel.activityModel}") - - val callerUid: Int = viewModel.activityModel.launchedFromUid - if (callerUid < 0 || UserHandle.isIsolated(callerUid)) { - Log.e(TAG, "Can't start a resolver from uid $callerUid") - activity.finish() - return - } - - when (val request = viewModel.initialRequest) { - is Valid -> initializeActivity(request) - is Invalid -> reportErrorsAndFinish(request) - } - } - - private fun reportErrorsAndFinish(request: Invalid) { - request.errors.forEach { it.log(TAG) } - activity.finish() - } - - private fun initializeActivity(request: Valid) { - Log.d(TAG, "initializeActivity") - request.warnings.forEach { it.log(TAG) } - - activityInitializer.run() - } -} diff --git a/java/src/com/android/intentresolver/v2/annotation/JavaInterop.kt b/java/src/com/android/intentresolver/v2/annotation/JavaInterop.kt deleted file mode 100644 index a813358e..00000000 --- a/java/src/com/android/intentresolver/v2/annotation/JavaInterop.kt +++ /dev/null @@ -1,28 +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.v2.annotation - -/** - * Apply to code which exists specifically to easy integration with existing Java and Java APIs. - * - * The goal is to prevent usage from Kotlin when a more idiomatic alternative is available. - */ -@RequiresOptIn( - "This is a a property, function or class specifically supporting Java " + - "interoperability. Usage from Kotlin should be limited to interactions with Java." -) -annotation class JavaInterop diff --git a/java/src/com/android/intentresolver/v2/data/BroadcastSubscriber.kt b/java/src/com/android/intentresolver/v2/data/BroadcastSubscriber.kt deleted file mode 100644 index f3013246..00000000 --- a/java/src/com/android/intentresolver/v2/data/BroadcastSubscriber.kt +++ /dev/null @@ -1,73 +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.v2.data - -import android.content.BroadcastReceiver -import android.content.Context -import android.content.Intent -import android.content.IntentFilter -import android.os.Handler -import android.os.UserHandle -import android.util.Log -import com.android.intentresolver.inject.Broadcast -import dagger.hilt.android.qualifiers.ApplicationContext -import javax.inject.Inject -import kotlinx.coroutines.channels.awaitClose -import kotlinx.coroutines.channels.onFailure -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.callbackFlow - -private const val TAG = "BroadcastSubscriber" - -class BroadcastSubscriber -@Inject -constructor( - @ApplicationContext private val context: Context, - @Broadcast private val handler: Handler -) { - /** - * Returns a [callbackFlow] that, when collected, registers a broadcast receiver and emits a new - * value whenever broadcast matching _filter_ is received. The result value will be computed - * using [transform] and emitted if non-null. - */ - fun createFlow( - filter: IntentFilter, - user: UserHandle, - transform: (Intent) -> T?, - ): Flow = callbackFlow { - val receiver = - object : BroadcastReceiver() { - override fun onReceive(context: Context, intent: Intent) { - transform(intent)?.also { result -> - trySend(result).onFailure { Log.e(TAG, "Failed to send $result", it) } - } - ?: Log.w(TAG, "Ignored broadcast $intent") - } - } - - @Suppress("MissingPermission") - context.registerReceiverAsUser( - receiver, - user, - IntentFilter(filter), - null, - handler, - Context.RECEIVER_NOT_EXPORTED - ) - awaitClose { context.unregisterReceiver(receiver) } - } -} diff --git a/java/src/com/android/intentresolver/v2/data/model/ChooserRequest.kt b/java/src/com/android/intentresolver/v2/data/model/ChooserRequest.kt deleted file mode 100644 index 7c9c8613..00000000 --- a/java/src/com/android/intentresolver/v2/data/model/ChooserRequest.kt +++ /dev/null @@ -1,209 +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.v2.data.model - -import android.content.ComponentName -import android.content.Intent -import android.content.Intent.ACTION_SEND -import android.content.Intent.ACTION_SEND_MULTIPLE -import android.content.Intent.EXTRA_REFERRER -import android.content.IntentFilter -import android.content.IntentSender -import android.net.Uri -import android.os.Bundle -import android.service.chooser.ChooserAction -import android.service.chooser.ChooserTarget -import androidx.annotation.StringRes -import com.android.intentresolver.ContentTypeHint -import com.android.intentresolver.v2.ext.hasAction - -const val ANDROID_APP_SCHEME = "android-app" - -/** All of the things that are consumed from an incoming share Intent (+Extras). */ -data class ChooserRequest( - /** Required. Represents the content being sent. */ - val targetIntent: Intent, - - /** The action from [targetIntent] as retrieved with [Intent.getAction]. */ - val targetAction: String?, - - /** - * Whether [targetAction] is ACTION_SEND or ACTION_SEND_MULTIPLE. These are considered the - * canonical "Share" actions. When handling other actions, this flag controls behavioral and - * visual changes. - */ - val isSendActionTarget: Boolean, - - /** The top-level content type as retrieved using [Intent.getType]. */ - val targetType: String?, - - /** The package name of the app which started the current activity instance. */ - val launchedFromPackage: String, - - /** A custom tile for the main UI. Ignored when the intent is ACTION_SEND(_MULTIPLE). */ - val title: CharSequence? = null, - - /** A String resource ID to load when [title] is null. */ - @get:StringRes val defaultTitleResource: Int = 0, - - /** - * The referrer value as received by the caller. It may have been supplied via [EXTRA_REFERRER] - * or synthesized from callerPackageName. This value is merged into outgoing intents. - */ - val referrer: Uri?, - - /** - * Choices to exclude from results. - * - * Any resolved intents with a component in this list will be omitted before presentation. - */ - val filteredComponentNames: List = emptyList(), - - /** - * App provided shortcut share intents (aka "direct share targets") - * - * Normally share shortcuts are published and consumed using - * [ShortcutManager][android.content.pm.ShortcutManager]. This is an alternate channel to allow - * apps to directly inject the same information. - * - * Historical note: This option was initially integrated with other results from the - * ChooserTargetService API (since deprecated and removed), hence the name and data format. - * These are more correctly called "Share Shortcuts" now. - */ - val callerChooserTargets: List = emptyList(), - - /** - * Actions the user may perform. These are presented as separate affordances from the main list - * of choices. Selecting a choice is a terminal action which results in finishing. The item - * limit is [MAX_CHOOSER_ACTIONS]. This may be further constrained as appropriate. - */ - val chooserActions: List = emptyList(), - - /** - * An action to start an Activity which for user updating of shared content. Selection is a - * terminal action, closing the current activity and launching the target of the action. - */ - val modifyShareAction: ChooserAction? = null, - - /** - * When false the host activity will be [finished][android.app.Activity.finish] when stopped. - */ - @get:JvmName("shouldRetainInOnStop") val shouldRetainInOnStop: Boolean = false, - - /** - * Intents which contain alternate representations of the content being shared. Any results from - * resolving these _alternate_ intents are included with the results of the primary intent as - * additional choices (e.g. share as image content vs. link to content). - */ - val additionalTargets: List = emptyList(), - - /** - * Alternate [extras][Intent.getExtras] to substitute when launching a selected app. - * - * For a given app (by package name), the Bundle describes what parameters to substitute when - * that app is selected. - * - * // TODO: Map - */ - val replacementExtras: Bundle? = null, - - /** - * App-supplied choices to be presented first in the list. - * - * Custom labels and icons may be supplied using - * [LabeledIntent][android.content.pm.LabeledIntent]. - * - * Limit 2. - */ - val initialIntents: List = emptyList(), - - /** - * Provides for callers to be notified when a component is selected. - * - * The selection is reported in the Intent as [Intent.EXTRA_CHOSEN_COMPONENT] with the - * [ComponentName] of the item. - */ - val chosenComponentSender: IntentSender? = null, - - /** - * Provides a mechanism for callers to post-process a target when a selection is made. - * - * The received intent will contain: - * * **EXTRA_INTENT** The chosen target - * * **EXTRA_ALTERNATE_INTENTS** Additional intents which also match the target - * * **EXTRA_RESULT_RECEIVER** A [ResultReceiver][android.os.ResultReceiver] providing a - * mechanism for the caller to return information. An updated intent to send must be included - * as [Intent.EXTRA_INTENT]. - */ - val refinementIntentSender: IntentSender? = null, - - /** - * Contains the text content to share supplied by the source app. - * - * TODO: Constrain length? - */ - val sharedText: CharSequence? = null, - - /** - * Supplied to - * [ShortcutManager.getShareTargets][android.content.pm.ShortcutManager.getShareTargets] to - * query for matching shortcuts. Specifically, only the [dataTypes][IntentFilter.hasDataType] - * are considered for matching share shortcuts currently. - */ - val shareTargetFilter: IntentFilter? = null, - - /** A URI for additional content */ - val additionalContentUri: Uri? = null, - - /** Focused item index (from target intent's STREAM_EXTRA) */ - val focusedItemPosition: Int = 0, - - /** Value for [Intent.EXTRA_CHOOSER_CONTENT_TYPE_HINT] on the incoming chooser intent. */ - val contentTypeHint: ContentTypeHint = ContentTypeHint.NONE, - - /** - * Metadata to be shown to the user as a part of the sharesheet window. - * - * Specified by the [Intent.EXTRA_METADATA_TEXT] - */ - val metadataText: CharSequence? = null, -) { - val referrerPackage = referrer?.takeIf { it.scheme == ANDROID_APP_SCHEME }?.authority - - fun getReferrerFillInIntent(): Intent { - return Intent().apply { - referrerPackage?.also { pkg -> - putExtra(EXTRA_REFERRER, Uri.parse("$ANDROID_APP_SCHEME://$pkg")) - } - } - } - - val payloadIntents = listOf(targetIntent) + additionalTargets - - /** Constructs an instance from only the required values. */ - constructor( - targetIntent: Intent, - launchedFromPackage: String, - referrer: Uri? - ) : this( - targetIntent = targetIntent, - targetAction = targetIntent.action, - isSendActionTarget = targetIntent.hasAction(ACTION_SEND, ACTION_SEND_MULTIPLE), - targetType = targetIntent.type, - launchedFromPackage = launchedFromPackage, - referrer = referrer - ) -} diff --git a/java/src/com/android/intentresolver/v2/data/repository/ChooserRequestRepository.kt b/java/src/com/android/intentresolver/v2/data/repository/ChooserRequestRepository.kt deleted file mode 100644 index d23e07ee..00000000 --- a/java/src/com/android/intentresolver/v2/data/repository/ChooserRequestRepository.kt +++ /dev/null @@ -1,39 +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.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, -) { - /** All information from the sharing application pertaining to the chooser. */ - val chooserRequest: MutableStateFlow = 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> = MutableStateFlow(initialActions) -} diff --git a/java/src/com/android/intentresolver/v2/data/repository/DevicePolicyResources.kt b/java/src/com/android/intentresolver/v2/data/repository/DevicePolicyResources.kt deleted file mode 100644 index 5719ff08..00000000 --- a/java/src/com/android/intentresolver/v2/data/repository/DevicePolicyResources.kt +++ /dev/null @@ -1,100 +0,0 @@ -/* - * Copyright (C) 2023 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package com.android.intentresolver.v2.data.repository - -import android.app.admin.DevicePolicyManager -import android.app.admin.DevicePolicyResources.Strings.Core.FORWARD_INTENT_TO_PERSONAL -import android.app.admin.DevicePolicyResources.Strings.Core.FORWARD_INTENT_TO_WORK -import android.app.admin.DevicePolicyResources.Strings.Core.RESOLVER_PERSONAL_TAB -import android.app.admin.DevicePolicyResources.Strings.Core.RESOLVER_PERSONAL_TAB_ACCESSIBILITY -import android.app.admin.DevicePolicyResources.Strings.Core.RESOLVER_WORK_PROFILE_NOT_SUPPORTED -import android.app.admin.DevicePolicyResources.Strings.Core.RESOLVER_WORK_TAB -import android.app.admin.DevicePolicyResources.Strings.Core.RESOLVER_WORK_TAB_ACCESSIBILITY -import android.content.res.Resources -import com.android.intentresolver.R -import com.android.intentresolver.inject.ApplicationOwned -import javax.inject.Inject -import javax.inject.Singleton - -@Singleton -class DevicePolicyResources -@Inject -constructor( - @ApplicationOwned private val resources: Resources, - devicePolicyManager: DevicePolicyManager -) { - private val policyResources = devicePolicyManager.resources - - val personalTabLabel by lazy { - requireNotNull( - policyResources.getString(RESOLVER_PERSONAL_TAB) { - resources.getString(R.string.resolver_personal_tab) - } - ) - } - - val workTabLabel by lazy { - requireNotNull( - policyResources.getString(RESOLVER_WORK_TAB) { - resources.getString(R.string.resolver_work_tab) - } - ) - } - - val personalTabAccessibilityLabel by lazy { - requireNotNull( - policyResources.getString(RESOLVER_PERSONAL_TAB_ACCESSIBILITY) { - resources.getString(R.string.resolver_personal_tab_accessibility) - } - ) - } - - val workTabAccessibilityLabel by lazy { - requireNotNull( - policyResources.getString(RESOLVER_WORK_TAB_ACCESSIBILITY) { - resources.getString(R.string.resolver_work_tab_accessibility) - } - ) - } - - val forwardToPersonalMessage: String? = - devicePolicyManager.resources.getString(FORWARD_INTENT_TO_PERSONAL) { - resources.getString(R.string.forward_intent_to_owner) - } - - val forwardToWorkMessage by lazy { - requireNotNull( - policyResources.getString(FORWARD_INTENT_TO_WORK) { - resources.getString(R.string.forward_intent_to_work) - } - ) - } - - fun getWorkProfileNotSupportedMessage(launcherName: String): String { - return requireNotNull( - policyResources.getString( - RESOLVER_WORK_PROFILE_NOT_SUPPORTED, - { - resources.getString( - R.string.activity_resolver_work_profiles_support, - launcherName - ) - }, - launcherName - ) - ) - } -} diff --git a/java/src/com/android/intentresolver/v2/data/repository/UserInfoExt.kt b/java/src/com/android/intentresolver/v2/data/repository/UserInfoExt.kt deleted file mode 100644 index a61d6d0d..00000000 --- a/java/src/com/android/intentresolver/v2/data/repository/UserInfoExt.kt +++ /dev/null @@ -1,45 +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.v2.data.repository - -import android.content.pm.UserInfo -import com.android.intentresolver.v2.shared.model.User -import com.android.intentresolver.v2.shared.model.User.Role - -/** Maps the UserInfo to one of the defined [Roles][User.Role], if possible. */ -fun UserInfo.getSupportedUserRole(): Role? = - when { - isFull -> Role.PERSONAL - isManagedProfile -> Role.WORK - isCloneProfile -> Role.CLONE - isPrivateProfile -> Role.PRIVATE - else -> null - } - -/** - * Creates a [User], based on values from a [UserInfo]. - * - * ``` - * val users: List = - * getEnabledProfiles(user).map(::toUser).filterNotNull() - * ``` - * - * @return a [User] if the [UserInfo] matched a supported [Role], otherwise null - */ -fun UserInfo.toUser(): User? { - return getSupportedUserRole()?.let { role -> User(userHandle.identifier, role) } -} diff --git a/java/src/com/android/intentresolver/v2/data/repository/UserRepository.kt b/java/src/com/android/intentresolver/v2/data/repository/UserRepository.kt deleted file mode 100644 index 56c84fcf..00000000 --- a/java/src/com/android/intentresolver/v2/data/repository/UserRepository.kt +++ /dev/null @@ -1,328 +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.v2.data.repository - -import android.content.Intent -import android.content.Intent.ACTION_MANAGED_PROFILE_AVAILABLE -import android.content.Intent.ACTION_MANAGED_PROFILE_UNAVAILABLE -import android.content.Intent.ACTION_PROFILE_ADDED -import android.content.Intent.ACTION_PROFILE_AVAILABLE -import android.content.Intent.ACTION_PROFILE_REMOVED -import android.content.Intent.ACTION_PROFILE_UNAVAILABLE -import android.content.Intent.EXTRA_QUIET_MODE -import android.content.Intent.EXTRA_USER -import android.content.IntentFilter -import android.content.pm.UserInfo -import android.os.Build -import android.os.UserHandle -import android.os.UserManager -import android.util.Log -import androidx.annotation.VisibleForTesting -import com.android.intentresolver.inject.Background -import com.android.intentresolver.inject.Main -import com.android.intentresolver.inject.ProfileParent -import com.android.intentresolver.v2.data.BroadcastSubscriber -import com.android.intentresolver.v2.shared.model.User -import javax.inject.Inject -import kotlin.time.Duration -import kotlin.time.Duration.Companion.seconds -import kotlinx.coroutines.CoroutineDispatcher -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.SharingStarted.Companion.WhileSubscribed -import kotlinx.coroutines.flow.distinctUntilChanged -import kotlinx.coroutines.flow.filterNot -import kotlinx.coroutines.flow.map -import kotlinx.coroutines.flow.onEach -import kotlinx.coroutines.flow.onStart -import kotlinx.coroutines.flow.runningFold -import kotlinx.coroutines.flow.stateIn -import kotlinx.coroutines.withContext - -interface UserRepository { - /** - * A [Flow] user profile groups. Each list contains the context user along with all members of - * the profile group. This includes the (Full) parent user, if the context user is a profile. - */ - val users: Flow> - - /** - * A [Flow] of availability. Only profile users may become unavailable. - * - * Availability is currently defined as not being in [quietMode][UserInfo.isQuietModeEnabled]. - */ - val availability: Flow> - - /** - * Request that availability be updated to the requested state. This currently includes toggling - * quiet mode as needed. This may involve additional background actions, such as starting or - * stopping a profile user (along with their many associated processes). - * - * If successful, the change will be applied after the call returns and can be observed using - * [UserRepository.availability] for the given user. - * - * No actions are taken if the user is already in requested state. - * - * @throws IllegalArgumentException if called for an unsupported user type - */ - suspend fun requestState(user: User, available: Boolean) -} - -private const val TAG = "UserRepository" - -/** The delay between entering the cached process state and entering the frozen cgroup */ -private val cachedProcessFreezeDelay: Duration = 10.seconds - -/** How long to continue listening for user state broadcasts while unsubscribed */ -private val stateFlowTimeout = cachedProcessFreezeDelay - 2.seconds - -/** How long to retain the previous user state after the state flow stops. */ -private val stateCacheTimeout = 2.seconds - -internal data class UserWithState(val user: User, val available: Boolean) - -internal typealias UserStates = List - -internal val userBroadcastActions = - setOf( - ACTION_PROFILE_ADDED, - ACTION_PROFILE_REMOVED, - - // Quiet mode enabled/disabled for managed - // From: UserController.broadcastProfileAvailabilityChanges - // In response to setQuietModeEnabled - ACTION_MANAGED_PROFILE_AVAILABLE, // quiet mode, sent for manage profiles only - ACTION_MANAGED_PROFILE_UNAVAILABLE, // quiet mode, sent for manage profiles only - - // Quiet mode toggled for profile type, requires flag 'android.os.allow_private_profile - // true' - ACTION_PROFILE_AVAILABLE, // quiet mode, - ACTION_PROFILE_UNAVAILABLE, // quiet mode, sent for any profile type - ) - -/** Tracks and publishes state for the parent user and associated profiles. */ -class UserRepositoryImpl -@VisibleForTesting -constructor( - private val profileParent: UserHandle, - private val userManager: UserManager, - /** A flow of events which represent user-state changes from [UserManager]. */ - private val userEvents: Flow, - scope: CoroutineScope, - private val backgroundDispatcher: CoroutineDispatcher, -) : UserRepository { - @Inject - constructor( - @ProfileParent profileParent: UserHandle, - userManager: UserManager, - @Main scope: CoroutineScope, - @Background background: CoroutineDispatcher, - broadcastSubscriber: BroadcastSubscriber, - ) : this( - profileParent, - userManager, - userEvents = - broadcastSubscriber.createFlow( - createFilter(userBroadcastActions), - profileParent, - Intent::toUserEvent - ), - scope, - background, - ) - - private fun debugLog(msg: () -> String) { - if (Build.IS_USERDEBUG || Build.IS_ENG) { - Log.d(TAG, msg()) - } - } - - private fun errorLog(msg: String, caught: Throwable? = null) { - Log.e(TAG, msg, caught) - } - - /** - * An exception which indicates that an inconsistency exists between the user state map and the - * rest of the system. - */ - private class UserStateException( - override val message: String, - val event: UserEvent, - override val cause: Throwable? = null, - ) : RuntimeException("$message: event=$event", cause) - - private val sharingScope = CoroutineScope(scope.coroutineContext + backgroundDispatcher) - private val usersWithState: Flow = - userEvents - .onStart { emit(Initialize) } - .onEach { debugLog { "userEvent: $it" } } - .runningFold(emptyList(), ::handleEvent) - .distinctUntilChanged() - .onEach { debugLog { "userStateList: $it" } } - .stateIn( - sharingScope, - started = - WhileSubscribed( - stopTimeoutMillis = stateFlowTimeout.inWholeMilliseconds, - replayExpirationMillis = 0 /** Immediately on stop */ - ), - listOf() - ) - .filterNot { it.isEmpty() } - - private suspend fun handleEvent(users: UserStates, event: UserEvent): UserStates { - return try { - // Handle an action by performing some operation, then returning a new map - when (event) { - is Initialize -> createNewUserStates(profileParent) - is ProfileAdded -> handleProfileAdded(event, users) - is ProfileRemoved -> handleProfileRemoved(event, users) - is AvailabilityChange -> handleAvailability(event, users) - is UnknownEvent -> { - debugLog { "Unhandled event: $event)" } - users - } - } - } catch (e: UserStateException) { - errorLog("An error occurred handling an event: ${e.event}") - errorLog("Attempting to recover...", e) - createNewUserStates(profileParent) - } - } - - override val users: Flow> = - usersWithState.map { userStates -> userStates.map { it.user } }.distinctUntilChanged() - - override val availability: Flow> = - usersWithState - .map { list -> list.associate { it.user to it.available } } - .distinctUntilChanged() - - override suspend fun requestState(user: User, available: Boolean) { - return withContext(backgroundDispatcher) { - debugLog { "requestQuietModeEnabled: ${!available} for user $user" } - userManager.requestQuietModeEnabled(/* enableQuietMode = */ !available, user.handle) - } - } - - private fun List.update(handle: UserHandle, user: UserWithState) = - filter { it.user.id != handle.identifier } + user - - private fun handleAvailability(event: AvailabilityChange, current: UserStates): UserStates { - val userEntry = - current.firstOrNull { it.user.id == event.user.identifier } - ?: throw UserStateException("User was not present in the map", event) - return current.update(event.user, userEntry.copy(available = !event.quietMode)) - } - - private fun handleProfileRemoved(event: ProfileRemoved, current: UserStates): UserStates { - if (!current.any { it.user.id == event.user.identifier }) { - throw UserStateException("User was not present in the map", event) - } - return current.filter { it.user.id != event.user.identifier } - } - - private suspend fun handleProfileAdded(event: ProfileAdded, current: UserStates): UserStates { - val user = - try { - requireNotNull(readUser(event.user)) - } catch (e: Exception) { - throw UserStateException("Failed to read user from UserManager", event, e) - } - return current + UserWithState(user, true) - } - - private suspend fun createNewUserStates(user: UserHandle): UserStates { - val profiles = readProfileGroup(user) - return profiles.mapNotNull { userInfo -> - userInfo.toUser()?.let { user -> UserWithState(user, userInfo.isAvailable()) } - } - } - - private suspend fun readProfileGroup(member: UserHandle): List { - return withContext(backgroundDispatcher) { - @Suppress("DEPRECATION") userManager.getEnabledProfiles(member.identifier) - } - .toList() - } - - /** Read [UserInfo] from [UserManager], or null if not found or an unsupported type. */ - private suspend fun readUser(user: UserHandle): User? { - val userInfo = - withContext(backgroundDispatcher) { userManager.getUserInfo(user.identifier) } - return userInfo?.let { info -> - info.getSupportedUserRole()?.let { role -> User(info.id, role) } - } - } -} - -/** A Model representing changes to profiles and availability */ -sealed interface UserEvent - -/** Used as a an initial value to trigger a fetch of all profile data. */ -data object Initialize : UserEvent - -/** A profile was added to the profile group. */ -data class ProfileAdded( - /** The handle for the added profile. */ - val user: UserHandle, -) : UserEvent - -/** A profile was removed from the profile group. */ -data class ProfileRemoved( - /** The handle for the removed profile. */ - val user: UserHandle, -) : UserEvent - -/** A profile has changed availability. */ -data class AvailabilityChange( - /** THe handle for the profile with availability change. */ - val user: UserHandle, - /** The new quietMode state. */ - val quietMode: Boolean = false, -) : UserEvent - -/** An unhandled event, logged and ignored. */ -data class UnknownEvent( - /** The broadcast intent action received */ - val action: String?, -) : UserEvent - -/** Used with [broadcastFlow] to transform a UserManager broadcast action into a [UserEvent]. */ -internal fun Intent.toUserEvent(): UserEvent { - val action = action - val user = extras?.getParcelable(EXTRA_USER, UserHandle::class.java) - val quietMode = extras?.getBoolean(EXTRA_QUIET_MODE, false) - return when (action) { - ACTION_PROFILE_ADDED -> ProfileAdded(requireNotNull(user)) - ACTION_PROFILE_REMOVED -> ProfileRemoved(requireNotNull(user)) - ACTION_MANAGED_PROFILE_UNAVAILABLE, - ACTION_MANAGED_PROFILE_AVAILABLE, - ACTION_PROFILE_AVAILABLE, - ACTION_PROFILE_UNAVAILABLE -> - AvailabilityChange(requireNotNull(user), requireNotNull(quietMode)) - else -> UnknownEvent(action) - } -} - -internal fun createFilter(actions: Iterable): IntentFilter { - return IntentFilter().apply { actions.forEach(::addAction) } -} - -internal fun UserInfo?.isAvailable(): Boolean { - return this?.isQuietModeEnabled != true -} diff --git a/java/src/com/android/intentresolver/v2/data/repository/UserRepositoryModule.kt b/java/src/com/android/intentresolver/v2/data/repository/UserRepositoryModule.kt deleted file mode 100644 index ad4faa17..00000000 --- a/java/src/com/android/intentresolver/v2/data/repository/UserRepositoryModule.kt +++ /dev/null @@ -1,53 +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.v2.data.repository - -import android.content.Context -import android.os.UserHandle -import android.os.UserManager -import com.android.intentresolver.inject.ApplicationUser -import com.android.intentresolver.inject.ProfileParent -import dagger.Binds -import dagger.Module -import dagger.Provides -import dagger.hilt.InstallIn -import dagger.hilt.android.qualifiers.ApplicationContext -import dagger.hilt.components.SingletonComponent -import javax.inject.Singleton - -@Module -@InstallIn(SingletonComponent::class) -interface UserRepositoryModule { - companion object { - @Provides - @Singleton - @ApplicationUser - fun applicationUser(@ApplicationContext context: Context): UserHandle = context.user - - @Provides - @Singleton - @ProfileParent - fun profileParent( - @ApplicationContext context: Context, - userManager: UserManager - ): UserHandle { - return userManager.getProfileParent(context.user) ?: context.user - } - } - - @Binds @Singleton fun userRepository(impl: UserRepositoryImpl): UserRepository -} diff --git a/java/src/com/android/intentresolver/v2/data/repository/UserScopedService.kt b/java/src/com/android/intentresolver/v2/data/repository/UserScopedService.kt deleted file mode 100644 index 65a48a55..00000000 --- a/java/src/com/android/intentresolver/v2/data/repository/UserScopedService.kt +++ /dev/null @@ -1,67 +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.v2.data.repository - -import android.content.Context -import android.os.UserHandle -import androidx.core.content.getSystemService -import dagger.hilt.android.qualifiers.ApplicationContext -import kotlin.reflect.KClass - -/** - * Provides instances of a [system service][Context.getSystemService] created with - * [the context of a specified user][Context.createContextAsUser]. - * - * Some services which have only `@UserHandleAware` APIs operate on the user id available from - * [Context.getUser], the context used to retrieve the service. This utility helps adapt a per-user - * API model to work in multi-user manner. - * - * Example usage: - * ``` - * @Provides - * fun scopedUserManager(@ApplicationContext ctx: Context): UserScopedService { - * return UserScopedServiceImpl(ctx, UserManager::class) - * } - * - * class MyUserHelper @Inject constructor( - * private val userMgr: UserScopedService, - * ) { - * fun isPrivateProfile(user: UserHandle): UserManager { - * return userMgr.forUser(user).isPrivateProfile() - * } - * } - * ``` - */ -fun interface UserScopedService { - /** Create a service instance for the given user. */ - fun forUser(user: UserHandle): T -} - -class UserScopedServiceImpl( - @ApplicationContext private val context: Context, - private val serviceType: KClass, -) : UserScopedService { - 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 deleted file mode 100644 index 69374f88..00000000 --- a/java/src/com/android/intentresolver/v2/domain/interactor/UserInteractor.kt +++ /dev/null @@ -1,92 +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.v2.domain.interactor - -import android.os.UserHandle -import com.android.intentresolver.inject.ApplicationUser -import com.android.intentresolver.v2.data.repository.UserRepository -import com.android.intentresolver.v2.shared.model.Profile -import com.android.intentresolver.v2.shared.model.Profile.Type -import com.android.intentresolver.v2.shared.model.User -import com.android.intentresolver.v2.shared.model.User.Role -import javax.inject.Inject -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.combine -import kotlinx.coroutines.flow.map - -/** The high level User interface. */ -class UserInteractor -@Inject -constructor( - private val userRepository: UserRepository, - /** The specific [User] of the application which started this one. */ - @ApplicationUser val launchedAs: UserHandle, -) { - /** The profile group associated with the launching app user. */ - val profiles: Flow> = - userRepository.users.map { users -> - users.mapNotNull { user -> - when (user.role) { - // PERSONAL includes CLONE - Role.PERSONAL -> { - Profile(Type.PERSONAL, user, users.firstOrNull { it.role == Role.CLONE }) - } - Role.CLONE -> { - /* ignore, included above */ - null - } - // others map 1:1 - else -> Profile(profileFromRole(user.role), user) - } - } - } - - /** The [Profile] of the application which started this one. */ - val launchedAsProfile: Flow = - profiles.map { profiles -> - // The launching user profile is the one with a primary id or clone id - // matching the application user id. By definition there must always be exactly - // one matching profile for the current user. - profiles.single { - it.primary.id == launchedAs.identifier || it.clone?.id == launchedAs.identifier - } - } - /** - * Provides a flow to report on the availability of profile. An unavailable profile may be - * hidden or appear disabled within the app. - */ - val availability: Flow> = - combine(profiles, userRepository.availability) { profiles, availability -> - profiles.associateWith { availability.getOrDefault(it.primary, false) } - } - - /** - * Request the profile state be updated. In the case of enabling, the operation could take - * significant time and/or require user input. - */ - suspend fun updateState(profile: Profile, available: Boolean) { - userRepository.requestState(profile.primary, available) - } - - private fun profileFromRole(role: Role): Type = - when (role) { - Role.PERSONAL -> Type.PERSONAL - Role.CLONE -> Type.PERSONAL /* CLONE maps to PERSONAL */ - Role.PRIVATE -> Type.PRIVATE - Role.WORK -> Type.WORK - } -} diff --git a/java/src/com/android/intentresolver/v2/emptystate/EmptyStateUiHelper.java b/java/src/com/android/intentresolver/v2/emptystate/EmptyStateUiHelper.java deleted file mode 100644 index 2f1e1b59..00000000 --- a/java/src/com/android/intentresolver/v2/emptystate/EmptyStateUiHelper.java +++ /dev/null @@ -1,141 +0,0 @@ -/* - * Copyright (C) 2023 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package com.android.intentresolver.v2.emptystate; - -import android.view.View; -import android.view.ViewGroup; -import android.widget.Button; -import android.widget.TextView; - -import com.android.intentresolver.emptystate.EmptyState; -import com.android.internal.annotations.VisibleForTesting; - -import java.util.Optional; -import java.util.function.Supplier; - -/** - * Helper for building `MultiProfilePagerAdapter` tab UIs for profile tabs that are "blocked" by - * some empty-state status. - */ -public class EmptyStateUiHelper { - private final Supplier> mContainerBottomPaddingOverrideSupplier; - private final View mEmptyStateView; - private final View mListView; - private final View mEmptyStateContainerView; - private final TextView mEmptyStateTitleView; - private final TextView mEmptyStateSubtitleView; - private final Button mEmptyStateButtonView; - private final View mEmptyStateProgressView; - private final View mEmptyStateEmptyView; - - public EmptyStateUiHelper( - ViewGroup rootView, - int listViewResourceId, - Supplier> containerBottomPaddingOverrideSupplier) { - mContainerBottomPaddingOverrideSupplier = containerBottomPaddingOverrideSupplier; - mEmptyStateView = - rootView.requireViewById(com.android.internal.R.id.resolver_empty_state); - mListView = rootView.requireViewById(listViewResourceId); - mEmptyStateContainerView = mEmptyStateView.requireViewById( - com.android.internal.R.id.resolver_empty_state_container); - mEmptyStateTitleView = mEmptyStateView.requireViewById( - com.android.internal.R.id.resolver_empty_state_title); - mEmptyStateSubtitleView = mEmptyStateView.requireViewById( - com.android.internal.R.id.resolver_empty_state_subtitle); - mEmptyStateButtonView = mEmptyStateView.requireViewById( - com.android.internal.R.id.resolver_empty_state_button); - mEmptyStateProgressView = mEmptyStateView.requireViewById( - com.android.internal.R.id.resolver_empty_state_progress); - mEmptyStateEmptyView = mEmptyStateView.requireViewById(com.android.internal.R.id.empty); - } - - /** - * Display the described empty state. - * @param emptyState the data describing the cause of this empty-state condition. - * @param buttonOnClick handler for a button that the user might be able to use to circumvent - * the empty-state condition. If null, no button will be displayed. - */ - public void showEmptyState(EmptyState emptyState, View.OnClickListener buttonOnClick) { - resetViewVisibilities(); - setupContainerPadding(); - - String title = emptyState.getTitle(); - if (title != null) { - mEmptyStateTitleView.setVisibility(View.VISIBLE); - mEmptyStateTitleView.setText(title); - } else { - mEmptyStateTitleView.setVisibility(View.GONE); - } - - String subtitle = emptyState.getSubtitle(); - if (subtitle != null) { - mEmptyStateSubtitleView.setVisibility(View.VISIBLE); - mEmptyStateSubtitleView.setText(subtitle); - } else { - mEmptyStateSubtitleView.setVisibility(View.GONE); - } - - mEmptyStateEmptyView.setVisibility( - emptyState.useDefaultEmptyView() ? View.VISIBLE : View.GONE); - // TODO: The EmptyState API says that if `useDefaultEmptyView()` is true, we'll ignore the - // state's specified title/subtitle; where (if anywhere) is that implemented? - - mEmptyStateButtonView.setVisibility(buttonOnClick != null ? View.VISIBLE : View.GONE); - mEmptyStateButtonView.setOnClickListener(buttonOnClick); - - // Don't show the main list view when we're showing an empty state. - mListView.setVisibility(View.GONE); - } - - /** Sets up the padding of the view containing the empty state screens. */ - public void setupContainerPadding() { - Optional bottomPaddingOverride = mContainerBottomPaddingOverrideSupplier.get(); - bottomPaddingOverride.ifPresent(paddingBottom -> - mEmptyStateContainerView.setPadding( - mEmptyStateContainerView.getPaddingLeft(), - mEmptyStateContainerView.getPaddingTop(), - mEmptyStateContainerView.getPaddingRight(), - paddingBottom)); - } - - public void showSpinner() { - mEmptyStateTitleView.setVisibility(View.INVISIBLE); - // TODO: subtitle? - mEmptyStateButtonView.setVisibility(View.INVISIBLE); - mEmptyStateProgressView.setVisibility(View.VISIBLE); - mEmptyStateEmptyView.setVisibility(View.GONE); - } - - public void hide() { - mEmptyStateView.setVisibility(View.GONE); - mListView.setVisibility(View.VISIBLE); - } - - // TODO: this is exposed for testing so we can thoroughly prepare initial conditions that let us - // observe the resulting change. In reality it's only invoked as part of `showEmptyState()` and - // we could consider setting up narrower "realistic" preconditions to make assertions about the - // higher-level operation. - @VisibleForTesting - void resetViewVisibilities() { - mEmptyStateTitleView.setVisibility(View.VISIBLE); - mEmptyStateSubtitleView.setVisibility(View.VISIBLE); - mEmptyStateButtonView.setVisibility(View.INVISIBLE); - mEmptyStateProgressView.setVisibility(View.GONE); - mEmptyStateEmptyView.setVisibility(View.GONE); - mEmptyStateView.setVisibility(View.VISIBLE); - } -} - diff --git a/java/src/com/android/intentresolver/v2/emptystate/NoAppsAvailableEmptyStateProvider.java b/java/src/com/android/intentresolver/v2/emptystate/NoAppsAvailableEmptyStateProvider.java deleted file mode 100644 index dfc46697..00000000 --- a/java/src/com/android/intentresolver/v2/emptystate/NoAppsAvailableEmptyStateProvider.java +++ /dev/null @@ -1,157 +0,0 @@ -/* - * Copyright (C) 2022 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.android.intentresolver.v2.emptystate; - -import static android.app.admin.DevicePolicyResources.Strings.Core.RESOLVER_NO_PERSONAL_APPS; -import static android.app.admin.DevicePolicyResources.Strings.Core.RESOLVER_NO_WORK_APPS; - -import android.app.admin.DevicePolicyEventLogger; -import android.app.admin.DevicePolicyManager; -import android.content.Context; -import android.content.pm.ResolveInfo; -import android.os.UserHandle; -import android.stats.devicepolicy.nano.DevicePolicyEnums; - -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; - -import com.android.intentresolver.R; -import com.android.intentresolver.ResolvedComponentInfo; -import com.android.intentresolver.ResolverListAdapter; -import com.android.intentresolver.emptystate.EmptyState; -import com.android.intentresolver.emptystate.EmptyStateProvider; - -import java.util.List; - -/** - * Chooser/ResolverActivity empty state provider that returns empty state which is shown when - * there are no apps available. - */ -public class NoAppsAvailableEmptyStateProvider implements EmptyStateProvider { - - @NonNull - private final Context mContext; - @Nullable - private final UserHandle mWorkProfileUserHandle; - @Nullable - private final UserHandle mPersonalProfileUserHandle; - @NonNull - private final String mMetricsCategory; - @NonNull - private final UserHandle mTabOwnerUserHandleForLaunch; - - public NoAppsAvailableEmptyStateProvider(@NonNull Context context, - @Nullable UserHandle workProfileUserHandle, - @Nullable UserHandle personalProfileUserHandle, @NonNull String metricsCategory, - @NonNull UserHandle tabOwnerUserHandleForLaunch) { - mContext = context; - mWorkProfileUserHandle = workProfileUserHandle; - mPersonalProfileUserHandle = personalProfileUserHandle; - mMetricsCategory = metricsCategory; - mTabOwnerUserHandleForLaunch = tabOwnerUserHandleForLaunch; - } - - @Nullable - @Override - @SuppressWarnings("ReferenceEquality") - public EmptyState getEmptyState(ResolverListAdapter resolverListAdapter) { - UserHandle listUserHandle = resolverListAdapter.getUserHandle(); - - if (mWorkProfileUserHandle != null - && (mTabOwnerUserHandleForLaunch.equals(listUserHandle) - || !hasAppsInOtherProfile(resolverListAdapter))) { - - String title; - if (listUserHandle == mPersonalProfileUserHandle) { - title = mContext.getSystemService( - DevicePolicyManager.class).getResources().getString( - RESOLVER_NO_PERSONAL_APPS, - () -> mContext.getString(R.string.resolver_no_personal_apps_available)); - } else { - title = mContext.getSystemService( - DevicePolicyManager.class).getResources().getString( - RESOLVER_NO_WORK_APPS, - () -> mContext.getString(R.string.resolver_no_work_apps_available)); - } - - return new NoAppsAvailableEmptyState( - title, mMetricsCategory, - /* isPersonalProfile= */ listUserHandle == mPersonalProfileUserHandle - ); - } else if (mWorkProfileUserHandle == null) { - // Return default empty state without tracking - return new DefaultEmptyState(); - } - - return null; - } - - private boolean hasAppsInOtherProfile(ResolverListAdapter adapter) { - if (mWorkProfileUserHandle == null) { - return false; - } - List resolversForIntent = - adapter.getResolversForUser(mTabOwnerUserHandleForLaunch); - for (ResolvedComponentInfo info : resolversForIntent) { - ResolveInfo resolveInfo = info.getResolveInfoAt(0); - if (resolveInfo.targetUserId != UserHandle.USER_CURRENT) { - return true; - } - } - return false; - } - - public static class DefaultEmptyState implements EmptyState { - @Override - public boolean useDefaultEmptyView() { - return true; - } - } - - public static class NoAppsAvailableEmptyState implements EmptyState { - - @NonNull - private final String mTitle; - - @NonNull - private final String mMetricsCategory; - - private final boolean mIsPersonalProfile; - - public NoAppsAvailableEmptyState(@NonNull String title, @NonNull String metricsCategory, - boolean isPersonalProfile) { - mTitle = title; - mMetricsCategory = metricsCategory; - mIsPersonalProfile = isPersonalProfile; - } - - @NonNull - @Override - public String getTitle() { - return mTitle; - } - - @Override - public void onEmptyStateShown() { - DevicePolicyEventLogger.createEvent( - DevicePolicyEnums.RESOLVER_EMPTY_STATE_NO_APPS_RESOLVED) - .setStrings(mMetricsCategory) - .setBoolean(/*isPersonalProfile*/ mIsPersonalProfile) - .write(); - } - } -} diff --git a/java/src/com/android/intentresolver/v2/emptystate/NoCrossProfileEmptyStateProvider.java b/java/src/com/android/intentresolver/v2/emptystate/NoCrossProfileEmptyStateProvider.java deleted file mode 100644 index d52015bf..00000000 --- a/java/src/com/android/intentresolver/v2/emptystate/NoCrossProfileEmptyStateProvider.java +++ /dev/null @@ -1,155 +0,0 @@ -/* - * Copyright (C) 2022 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.android.intentresolver.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; -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; -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 - * in case if the profile of the current tab is not the same as the profile of the calling app. - */ -public class NoCrossProfileEmptyStateProvider implements EmptyStateProvider { - - private final ProfileHelper mProfileHelper; - private final EmptyState mNoWorkToPersonalEmptyState; - private final EmptyState mNoPersonalToWorkEmptyState; - private final CrossProfileIntentsChecker mCrossProfileIntentsChecker; - - public NoCrossProfileEmptyStateProvider( - ProfileHelper profileHelper, - EmptyState noWorkToPersonalEmptyState, - EmptyState noPersonalToWorkEmptyState, - CrossProfileIntentsChecker crossProfileIntentsChecker) { - mProfileHelper = profileHelper; - mNoWorkToPersonalEmptyState = noWorkToPersonalEmptyState; - mNoPersonalToWorkEmptyState = noPersonalToWorkEmptyState; - mCrossProfileIntentsChecker = crossProfileIntentsChecker; - } - - private boolean anyCrossProfileAllowedIntents(ResolverListAdapter selected, UserHandle source) { - List intents = selected.getIntents(); - UserHandle target = selected.getUserHandle(); - return mCrossProfileIntentsChecker.hasCrossProfileIntents(intents, - source.getIdentifier(), target.getIdentifier()); - } - - @Nullable - @Override - 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; - } - - // 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 - * 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 deleted file mode 100644 index af13f8fe..00000000 --- a/java/src/com/android/intentresolver/v2/emptystate/WorkProfilePausedEmptyStateProvider.java +++ /dev/null @@ -1,131 +0,0 @@ -/* - * Copyright (C) 2022 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.android.intentresolver.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; -import android.os.UserHandle; -import android.stats.devicepolicy.nano.DevicePolicyEnums; - -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; - -import com.android.intentresolver.MultiProfilePagerAdapter.OnSwitchOnWorkSelectedListener; -import com.android.intentresolver.R; -import com.android.intentresolver.ResolverListAdapter; -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 - * work profile is paused and we need to show a button to enable it. - */ -public class WorkProfilePausedEmptyStateProvider implements EmptyStateProvider { - - private final ProfileHelper mProfileHelper; - private final ProfileAvailability mProfileAvailability; - private final String mMetricsCategory; - private final OnSwitchOnWorkSelectedListener mOnSwitchOnWorkSelectedListener; - private final Context mContext; - - public WorkProfilePausedEmptyStateProvider(@NonNull Context context, - ProfileHelper profileHelper, - ProfileAvailability profileAvailability, - @Nullable OnSwitchOnWorkSelectedListener onSwitchOnWorkSelectedListener, - @NonNull String metricsCategory) { - mContext = context; - mProfileHelper = profileHelper; - mProfileAvailability = profileAvailability; - mMetricsCategory = metricsCategory; - mOnSwitchOnWorkSelectedListener = onSwitchOnWorkSelectedListener; - } - - @Nullable - @Override - public EmptyState getEmptyState(ResolverListAdapter resolverListAdapter) { - UserHandle userHandle = resolverListAdapter.getUserHandle(); - if (!mProfileHelper.getWorkProfilePresent()) { - return null; - } - Profile workProfile = requireNonNull(mProfileHelper.getWorkProfile()); - - // Policy: only show the "Work profile paused" state when: - // * provided list adapter is from the work profile - // * the list adapter is not empty - // * work profile quiet mode is _enabled_ (unavailable) - - if (!userHandle.equals(workProfile.getPrimary().getHandle()) - || resolverListAdapter.getCount() == 0 - || mProfileAvailability.isAvailable(workProfile)) { - return null; - } - - String title = mContext.getSystemService(DevicePolicyManager.class) - .getResources().getString(RESOLVER_WORK_PAUSED_TITLE, - () -> mContext.getString(R.string.resolver_turn_on_work_apps)); - - return new WorkProfileOffEmptyState(title, /* EmptyState.ClickListener */ (tab) -> { - tab.showSpinner(); - if (mOnSwitchOnWorkSelectedListener != null) { - mOnSwitchOnWorkSelectedListener.onSwitchOnWorkSelected(); - } - mProfileAvailability.requestQuietModeState(workProfile, false); - }, mMetricsCategory); - } - - public static class WorkProfileOffEmptyState implements EmptyState { - - private final String mTitle; - private final ClickListener mOnClick; - private final String mMetricsCategory; - - public WorkProfileOffEmptyState(String title, @NonNull ClickListener onClick, - @NonNull String metricsCategory) { - mTitle = title; - mOnClick = onClick; - mMetricsCategory = metricsCategory; - } - - @Nullable - @Override - public String getTitle() { - return mTitle; - } - - @Nullable - @Override - public ClickListener getButtonClickListener() { - return mOnClick; - } - - @Override - public void onEmptyStateShown() { - DevicePolicyEventLogger - .createEvent(DevicePolicyEnums.RESOLVER_EMPTY_STATE_WORK_APPS_DISABLED) - .setStrings(mMetricsCategory) - .write(); - } - } -} diff --git a/java/src/com/android/intentresolver/v2/ext/CreationExtrasExt.kt b/java/src/com/android/intentresolver/v2/ext/CreationExtrasExt.kt deleted file mode 100644 index 6c36e6aa..00000000 --- a/java/src/com/android/intentresolver/v2/ext/CreationExtrasExt.kt +++ /dev/null @@ -1,34 +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.v2.ext - -import android.os.Bundle -import android.os.Parcelable -import androidx.core.os.bundleOf -import androidx.lifecycle.DEFAULT_ARGS_KEY -import androidx.lifecycle.viewmodel.CreationExtras -import androidx.lifecycle.viewmodel.MutableCreationExtras - -/** - * Returns a new instance with additional [values] added to the existing default args Bundle (if - * present), otherwise adds a new entry with a copy of this bundle. - */ -fun CreationExtras.addDefaultArgs(vararg values: Pair): CreationExtras { - val defaultArgs: Bundle = get(DEFAULT_ARGS_KEY) ?: Bundle() - defaultArgs.putAll(bundleOf(*values)) - return MutableCreationExtras(this).apply { set(DEFAULT_ARGS_KEY, defaultArgs) } -} diff --git a/java/src/com/android/intentresolver/v2/ext/IntentExt.kt b/java/src/com/android/intentresolver/v2/ext/IntentExt.kt deleted file mode 100644 index 8c2d7277..00000000 --- a/java/src/com/android/intentresolver/v2/ext/IntentExt.kt +++ /dev/null @@ -1,45 +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.v2.ext - -import android.content.Intent -import java.util.function.Predicate - -/** Applies an operation on this Intent if matches the given filter. */ -inline fun Intent.ifMatch( - predicate: Predicate, - crossinline block: Intent.() -> Unit -): Intent { - if (predicate.test(this)) { - apply(block) - } - return this -} - -/** True if the Intent has one of the specified actions. */ -fun Intent.hasAction(vararg actions: String): Boolean = action in actions - -/** True if the Intent has a specific component target */ -fun Intent.hasComponent(): Boolean = (component != null) - -/** True if the Intent has a single matching category. */ -fun Intent.hasSingleCategory(category: String) = categories.singleOrNull() == category - -/** True if the Intent is a SEND or SEND_MULTIPLE action. */ -fun Intent.hasSendAction() = hasAction(Intent.ACTION_SEND, Intent.ACTION_SEND_MULTIPLE) - -/** True if the Intent resolves to the special Home (Launcher) component */ -fun Intent.isHomeIntent() = hasAction(Intent.ACTION_MAIN) && hasSingleCategory(Intent.CATEGORY_HOME) diff --git a/java/src/com/android/intentresolver/v2/ext/ParcelExt.kt b/java/src/com/android/intentresolver/v2/ext/ParcelExt.kt deleted file mode 100644 index b0ec97f4..00000000 --- a/java/src/com/android/intentresolver/v2/ext/ParcelExt.kt +++ /dev/null @@ -1,27 +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.v2.ext - -import android.os.Parcel - -inline fun Parcel.requireParcelable(): T { - return requireNotNull(readParcelable()) { "A non-value required from this parcel was null!" } -} - -inline fun Parcel.readParcelable(): T? { - return readParcelable(T::class.java.classLoader, T::class.java) -} diff --git a/java/src/com/android/intentresolver/v2/icons/TargetDataLoaderModule.kt b/java/src/com/android/intentresolver/v2/icons/TargetDataLoaderModule.kt deleted file mode 100644 index 4e8783f8..00000000 --- a/java/src/com/android/intentresolver/v2/icons/TargetDataLoaderModule.kt +++ /dev/null @@ -1,40 +0,0 @@ -/* - * Copyright (C) 2023 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.android.intentresolver.v2.icons - -import android.content.Context -import androidx.lifecycle.Lifecycle -import com.android.intentresolver.icons.DefaultTargetDataLoader -import com.android.intentresolver.icons.TargetDataLoader -import com.android.intentresolver.inject.ActivityOwned -import dagger.Module -import dagger.Provides -import dagger.hilt.InstallIn -import dagger.hilt.android.components.ActivityComponent -import dagger.hilt.android.qualifiers.ActivityContext -import dagger.hilt.android.scopes.ActivityScoped - -@Module -@InstallIn(ActivityComponent::class) -object TargetDataLoaderModule { - @Provides - @ActivityScoped - fun targetDataLoader( - @ActivityContext context: Context, - @ActivityOwned lifecycle: Lifecycle, - ): TargetDataLoader = DefaultTargetDataLoader(context, lifecycle, isAudioCaptureDevice = false) -} diff --git a/java/src/com/android/intentresolver/v2/listcontroller/FilterableComponents.kt b/java/src/com/android/intentresolver/v2/listcontroller/FilterableComponents.kt deleted file mode 100644 index 5855e2fc..00000000 --- a/java/src/com/android/intentresolver/v2/listcontroller/FilterableComponents.kt +++ /dev/null @@ -1,39 +0,0 @@ -/* - * Copyright (C) 2023 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.android.intentresolver.v2.listcontroller - -import android.content.ComponentName -import com.android.intentresolver.ChooserRequestParameters - -/** A class that is able to identify components that should be hidden from the user. */ -interface FilterableComponents { - /** Whether this component should hidden from the user. */ - fun isComponentFiltered(name: ComponentName): Boolean -} - -/** A class that never filters components. */ -class NoComponentFiltering : FilterableComponents { - override fun isComponentFiltered(name: ComponentName): Boolean = false -} - -/** A class that filters components by chooser request filter. */ -class ChooserRequestFilteredComponents( - private val chooserRequestParameters: ChooserRequestParameters, -) : FilterableComponents { - override fun isComponentFiltered(name: ComponentName): Boolean = - chooserRequestParameters.filteredComponentNames.contains(name) -} diff --git a/java/src/com/android/intentresolver/v2/listcontroller/IntentResolver.kt b/java/src/com/android/intentresolver/v2/listcontroller/IntentResolver.kt deleted file mode 100644 index bb9394b4..00000000 --- a/java/src/com/android/intentresolver/v2/listcontroller/IntentResolver.kt +++ /dev/null @@ -1,70 +0,0 @@ -package com.android.intentresolver.v2.listcontroller - -import android.content.Intent -import android.content.pm.PackageManager -import android.os.UserHandle -import com.android.intentresolver.ResolvedComponentInfo - -/** A class for translating [Intent]s to [ResolvedComponentInfo]s. */ -interface IntentResolver { - /** - * Get data about all the ways the user with the specified handle can resolve any of the - * provided `intents`. - */ - fun getResolversForIntentAsUser( - shouldGetResolvedFilter: Boolean, - shouldGetActivityMetadata: Boolean, - shouldGetOnlyDefaultActivities: Boolean, - intents: List, - userHandle: UserHandle, - ): List -} - -/** Resolves [Intent]s using the [packageManager], deduping using the given [ResolveListDeduper]. */ -class IntentResolverImpl( - private val packageManager: PackageManager, - resolveListDeduper: ResolveListDeduper, -) : IntentResolver, ResolveListDeduper by resolveListDeduper { - override fun getResolversForIntentAsUser( - shouldGetResolvedFilter: Boolean, - shouldGetActivityMetadata: Boolean, - shouldGetOnlyDefaultActivities: Boolean, - intents: List, - userHandle: UserHandle, - ): List { - val baseFlags = - ((if (shouldGetOnlyDefaultActivities) PackageManager.MATCH_DEFAULT_ONLY else 0) or - PackageManager.MATCH_DIRECT_BOOT_AWARE or - PackageManager.MATCH_DIRECT_BOOT_UNAWARE or - (if (shouldGetResolvedFilter) PackageManager.GET_RESOLVED_FILTER else 0) or - (if (shouldGetActivityMetadata) PackageManager.GET_META_DATA else 0) or - PackageManager.MATCH_CLONE_PROFILE) - return getResolversForIntentAsUserInternal( - intents, - userHandle, - baseFlags, - ) - } - - private fun getResolversForIntentAsUserInternal( - intents: List, - userHandle: UserHandle, - baseFlags: Int, - ): List = buildList { - for (intent in intents) { - var flags = baseFlags - if (intent.isWebIntent || intent.flags and Intent.FLAG_ACTIVITY_MATCH_EXTERNAL != 0) { - flags = flags or PackageManager.MATCH_INSTANT - } - // Because of AIDL bug, queryIntentActivitiesAsUser can't accept subclasses of Intent. - val fixedIntent = - if (intent.javaClass != Intent::class.java) { - Intent(intent) - } else { - intent - } - val infos = packageManager.queryIntentActivitiesAsUser(fixedIntent, flags, userHandle) - addToResolveListWithDedupe(this, fixedIntent, infos) - } - } -} diff --git a/java/src/com/android/intentresolver/v2/listcontroller/LastChosenManager.kt b/java/src/com/android/intentresolver/v2/listcontroller/LastChosenManager.kt deleted file mode 100644 index b2856526..00000000 --- a/java/src/com/android/intentresolver/v2/listcontroller/LastChosenManager.kt +++ /dev/null @@ -1,77 +0,0 @@ -/* - * Copyright (C) 2023 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.android.intentresolver.v2.listcontroller - -import android.app.AppGlobals -import android.content.ContentResolver -import android.content.Intent -import android.content.IntentFilter -import android.content.pm.IPackageManager -import android.content.pm.PackageManager -import android.content.pm.ResolveInfo -import android.os.RemoteException -import kotlinx.coroutines.CoroutineDispatcher -import kotlinx.coroutines.withContext - -/** Class that stores and retrieves the most recently chosen resolutions. */ -interface LastChosenManager { - - /** Returns the most recently chosen resolution. */ - suspend fun getLastChosen(): ResolveInfo - - /** Sets the most recently chosen resolution. */ - suspend fun setLastChosen(intent: Intent, filter: IntentFilter, match: Int) -} - -/** - * Stores and retrieves the most recently chosen resolutions using the [PackageManager] provided by - * the [packageManagerProvider]. - */ -class PackageManagerLastChosenManager( - private val contentResolver: ContentResolver, - private val bgDispatcher: CoroutineDispatcher, - private val targetIntent: Intent, - private val packageManagerProvider: () -> IPackageManager = AppGlobals::getPackageManager, -) : LastChosenManager { - - @Throws(RemoteException::class) - override suspend fun getLastChosen(): ResolveInfo { - return withContext(bgDispatcher) { - packageManagerProvider() - .getLastChosenActivity( - targetIntent, - targetIntent.resolveTypeIfNeeded(contentResolver), - PackageManager.MATCH_DEFAULT_ONLY, - ) - } - } - - @Throws(RemoteException::class) - override suspend fun setLastChosen(intent: Intent, filter: IntentFilter, match: Int) { - return withContext(bgDispatcher) { - packageManagerProvider() - .setLastChosenActivity( - intent, - intent.resolveType(contentResolver), - PackageManager.MATCH_DEFAULT_ONLY, - filter, - match, - intent.component, - ) - } - } -} diff --git a/java/src/com/android/intentresolver/v2/listcontroller/ListController.kt b/java/src/com/android/intentresolver/v2/listcontroller/ListController.kt deleted file mode 100644 index 4ddab755..00000000 --- a/java/src/com/android/intentresolver/v2/listcontroller/ListController.kt +++ /dev/null @@ -1,21 +0,0 @@ -/* - * Copyright (C) 2023 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.android.intentresolver.v2.listcontroller - -/** Controller for managing lists of [com.android.intentresolver.ResolvedComponentInfo]s. */ -interface ListController : - LastChosenManager, IntentResolver, ResolvedComponentFiltering, ResolvedComponentSorting diff --git a/java/src/com/android/intentresolver/v2/listcontroller/PermissionChecker.kt b/java/src/com/android/intentresolver/v2/listcontroller/PermissionChecker.kt deleted file mode 100644 index cae2af95..00000000 --- a/java/src/com/android/intentresolver/v2/listcontroller/PermissionChecker.kt +++ /dev/null @@ -1,34 +0,0 @@ -package com.android.intentresolver.v2.listcontroller - -import android.app.ActivityManager -import kotlinx.coroutines.CoroutineDispatcher -import kotlinx.coroutines.withContext - -/** Class for checking if a permission has been granted. */ -interface PermissionChecker { - /** Checks if the given [permission] has been granted. */ - suspend fun checkComponentPermission( - permission: String, - uid: Int, - owningUid: Int, - exported: Boolean, - ): Int -} - -/** - * Class for checking if a permission has been granted using the static - * [ActivityManager.checkComponentPermission]. - */ -class ActivityManagerPermissionChecker( - private val bgDispatcher: CoroutineDispatcher, -) : PermissionChecker { - override suspend fun checkComponentPermission( - permission: String, - uid: Int, - owningUid: Int, - exported: Boolean, - ): Int = - withContext(bgDispatcher) { - ActivityManager.checkComponentPermission(permission, uid, owningUid, exported) - } -} diff --git a/java/src/com/android/intentresolver/v2/listcontroller/PinnableComponents.kt b/java/src/com/android/intentresolver/v2/listcontroller/PinnableComponents.kt deleted file mode 100644 index 8be45ba2..00000000 --- a/java/src/com/android/intentresolver/v2/listcontroller/PinnableComponents.kt +++ /dev/null @@ -1,39 +0,0 @@ -/* - * Copyright (C) 2023 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.android.intentresolver.v2.listcontroller - -import android.content.ComponentName -import android.content.SharedPreferences - -/** A class that is able to identify components that should be pinned for the user. */ -interface PinnableComponents { - /** Whether this component is pinned by the user. */ - fun isComponentPinned(name: ComponentName): Boolean -} - -/** A class that never pins components. */ -class NoComponentPinning : PinnableComponents { - override fun isComponentPinned(name: ComponentName): Boolean = false -} - -/** A class that determines pinnable components by user preferences. */ -class SharedPreferencesPinnedComponents( - private val pinnedSharedPreferences: SharedPreferences, -) : PinnableComponents { - override fun isComponentPinned(name: ComponentName): Boolean = - pinnedSharedPreferences.getBoolean(name.flattenToString(), false) -} diff --git a/java/src/com/android/intentresolver/v2/listcontroller/ResolveListDeduper.kt b/java/src/com/android/intentresolver/v2/listcontroller/ResolveListDeduper.kt deleted file mode 100644 index f0b4bf3f..00000000 --- a/java/src/com/android/intentresolver/v2/listcontroller/ResolveListDeduper.kt +++ /dev/null @@ -1,69 +0,0 @@ -package com.android.intentresolver.v2.listcontroller - -import android.content.ComponentName -import android.content.Intent -import android.content.pm.ResolveInfo -import android.util.Log -import com.android.intentresolver.ResolvedComponentInfo - -/** A class for adding [ResolveInfo]s to a list of [ResolvedComponentInfo]s without duplicates. */ -interface ResolveListDeduper { - /** - * Adds [ResolveInfo]s in [from] to [ResolvedComponentInfo]s in [into], creating new - * [ResolvedComponentInfo]s when there is not already a corresponding one. - * - * This method may be destructive to both the given [into] list and the underlying - * [ResolvedComponentInfo]s. - */ - fun addToResolveListWithDedupe( - into: MutableList, - intent: Intent, - from: List, - ) -} - -/** - * Default implementation for adding [ResolveInfo]s to a list of [ResolvedComponentInfo]s without - * duplicates. Uses the given [PinnableComponents] to determine the pinning state of newly created - * [ResolvedComponentInfo]s. - */ -class ResolveListDeduperImpl(pinnableComponents: PinnableComponents) : - ResolveListDeduper, PinnableComponents by pinnableComponents { - override fun addToResolveListWithDedupe( - into: MutableList, - intent: Intent, - from: List, - ) { - from.forEach { newInfo -> - if (newInfo.userHandle == null) { - Log.w(TAG, "Skipping ResolveInfo with no userHandle: $newInfo") - return@forEach - } - val oldInfo = into.firstOrNull { isSameResolvedComponent(newInfo, it) } - // If existing resolution found, add to existing and filter out - if (oldInfo != null) { - oldInfo.add(intent, newInfo) - } else { - with(newInfo.activityInfo) { - into.add( - ResolvedComponentInfo( - ComponentName(packageName, name), - intent, - newInfo, - ) - .apply { isPinned = isComponentPinned(name) }, - ) - } - } - } - } - - private fun isSameResolvedComponent(a: ResolveInfo, b: ResolvedComponentInfo): Boolean { - val ai = a.activityInfo - return ai.packageName == b.name.packageName && ai.name == b.name.className - } - - companion object { - const val TAG = "ResolveListDeduper" - } -} diff --git a/java/src/com/android/intentresolver/v2/listcontroller/ResolvedComponentFiltering.kt b/java/src/com/android/intentresolver/v2/listcontroller/ResolvedComponentFiltering.kt deleted file mode 100644 index e78bff00..00000000 --- a/java/src/com/android/intentresolver/v2/listcontroller/ResolvedComponentFiltering.kt +++ /dev/null @@ -1,121 +0,0 @@ -package com.android.intentresolver.v2.listcontroller - -import android.content.pm.PackageManager -import android.util.Log -import com.android.intentresolver.ResolvedComponentInfo -import kotlinx.coroutines.CompletableDeferred -import kotlinx.coroutines.CoroutineDispatcher -import kotlinx.coroutines.async -import kotlinx.coroutines.awaitAll -import kotlinx.coroutines.coroutineScope - -/** Provides filtering methods for lists of [ResolvedComponentInfo]. */ -interface ResolvedComponentFiltering { - /** - * Returns a list with all the [ResolvedComponentInfo] in [inputList], less the ones that are - * not eligible. - */ - suspend fun filterIneligibleActivities( - inputList: List, - ): List - - /** Filter out any low priority items. */ - fun filterLowPriority(inputList: List): List -} - -/** - * Default instantiation of the filtering methods for lists of [ResolvedComponentInfo]. - * - * Binder calls are performed on the given [bgDispatcher] and permissions are checked as if launched - * from the given [launchedFromUid] UID. Component filtering is handled by the given - * [FilterableComponents] and permission checking is handled by the given [PermissionChecker]. - */ -class ResolvedComponentFilteringImpl( - private val launchedFromUid: Int, - filterableComponents: FilterableComponents, - permissionChecker: PermissionChecker, -) : - ResolvedComponentFiltering, - PermissionChecker by permissionChecker, - FilterableComponents by filterableComponents { - constructor( - bgDispatcher: CoroutineDispatcher, - launchedFromUid: Int, - filterableComponents: FilterableComponents, - ) : this( - launchedFromUid = launchedFromUid, - filterableComponents = filterableComponents, - permissionChecker = ActivityManagerPermissionChecker(bgDispatcher), - ) - - /** - * Filter out items that are filtered by [FilterableComponents] or do not have the necessary - * permissions. - */ - override suspend fun filterIneligibleActivities( - inputList: List, - ): List = coroutineScope { - inputList - .map { - val activityInfo = it.getResolveInfoAt(0).activityInfo - if (isComponentFiltered(activityInfo.componentName)) { - CompletableDeferred(value = null) - } else { - // Do all permission checks in parallel - async { - val granted = - checkComponentPermission( - activityInfo.permission, - launchedFromUid, - activityInfo.applicationInfo.uid, - activityInfo.exported, - ) == PackageManager.PERMISSION_GRANTED - if (granted) it else null - } - } - } - .awaitAll() - .filterNotNull() - } - - /** - * Filters out all elements starting with the first elements with a different priority or - * default status than the first element. - */ - override fun filterLowPriority( - inputList: List, - ): List { - val firstResolveInfo = inputList[0].getResolveInfoAt(0) - // Only display the first matches that are either of equal - // priority or have asked to be default options. - val firstDiffIndex = - inputList.indexOfFirst { resolvedComponentInfo -> - val resolveInfo = resolvedComponentInfo.getResolveInfoAt(0) - if (firstResolveInfo == resolveInfo) { - false - } else { - if (DEBUG) { - Log.v( - TAG, - "${firstResolveInfo?.activityInfo?.name}=" + - "${firstResolveInfo?.priority}/${firstResolveInfo?.isDefault}" + - " vs ${resolveInfo?.activityInfo?.name}=" + - "${resolveInfo?.priority}/${resolveInfo?.isDefault}" - ) - } - firstResolveInfo!!.priority != resolveInfo!!.priority || - firstResolveInfo.isDefault != resolveInfo.isDefault - } - } - return if (firstDiffIndex == -1) { - inputList - } else { - inputList.subList(0, firstDiffIndex) - } - } - - companion object { - private const val TAG = "ResolvedComponentFilter" - private const val DEBUG = false - } -} diff --git a/java/src/com/android/intentresolver/v2/listcontroller/ResolvedComponentSorting.kt b/java/src/com/android/intentresolver/v2/listcontroller/ResolvedComponentSorting.kt deleted file mode 100644 index 8ab41ef0..00000000 --- a/java/src/com/android/intentresolver/v2/listcontroller/ResolvedComponentSorting.kt +++ /dev/null @@ -1,108 +0,0 @@ -package com.android.intentresolver.v2.listcontroller - -import android.os.UserHandle -import android.util.Log -import com.android.intentresolver.ResolvedComponentInfo -import com.android.intentresolver.chooser.DisplayResolveInfo -import com.android.intentresolver.chooser.TargetInfo -import com.android.intentresolver.model.AbstractResolverComparator -import java.util.concurrent.atomic.AtomicReference -import kotlinx.coroutines.CompletableDeferred -import kotlinx.coroutines.CoroutineDispatcher -import kotlinx.coroutines.withContext - -/** Provides sorting methods for lists of [ResolvedComponentInfo]. */ -interface ResolvedComponentSorting { - /** Returns the a copy of the [inputList] sorted by app share score. */ - suspend fun sorted(inputList: List?): List? - - /** Returns the app share score of the [target]. */ - fun getScore(target: DisplayResolveInfo): Float - - /** Returns the app share score of the [targetInfo]. */ - fun getScore(targetInfo: TargetInfo): Float - - /** Updates the model about [targetInfo]. */ - suspend fun updateModel(targetInfo: TargetInfo) - - /** Updates the model about Activity selection. */ - suspend fun updateChooserCounts(packageName: String, user: UserHandle, action: String) - - /** Cleans up resources. Nothing should be called after calling this. */ - fun destroy() -} - -/** - * Provides sorting methods using the given [resolverComparator]. - * - * Long calculations and binder calls are performed on the given [bgDispatcher]. - */ -class ResolvedComponentSortingImpl( - private val bgDispatcher: CoroutineDispatcher, - private val resolverComparator: AbstractResolverComparator, -) : ResolvedComponentSorting { - - private val computeComplete = AtomicReference?>(null) - - @Throws(InterruptedException::class) - private suspend fun computeIfNeeded(inputList: List) { - if (computeComplete.compareAndSet(null, CompletableDeferred())) { - resolverComparator.setCallBack { computeComplete.get()!!.complete(Unit) } - resolverComparator.compute(inputList) - } - with(computeComplete.get()!!) { if (isCompleted) return else return await() } - } - - override suspend fun sorted( - inputList: List?, - ): List? { - if (inputList.isNullOrEmpty()) return inputList - - return withContext(bgDispatcher) { - try { - val beforeRank = System.currentTimeMillis() - computeIfNeeded(inputList) - val sorted = inputList.sortedWith(resolverComparator) - val afterRank = System.currentTimeMillis() - if (DEBUG) { - Log.d(TAG, "Time Cost: ${afterRank - beforeRank}") - } - sorted - } catch (e: InterruptedException) { - Log.e(TAG, "Compute & Sort was interrupted: $e") - null - } - } - } - - override fun getScore(target: DisplayResolveInfo): Float { - return resolverComparator.getScore(target) - } - - override fun getScore(targetInfo: TargetInfo): Float { - return resolverComparator.getScore(targetInfo) - } - - override suspend fun updateModel(targetInfo: TargetInfo) { - withContext(bgDispatcher) { resolverComparator.updateModel(targetInfo) } - } - - override suspend fun updateChooserCounts( - packageName: String, - user: UserHandle, - action: String, - ) { - withContext(bgDispatcher) { - resolverComparator.updateChooserCounts(packageName, user, action) - } - } - - override fun destroy() { - resolverComparator.destroy() - } - - companion object { - private const val TAG = "ResolvedComponentSort" - private const val DEBUG = false - } -} diff --git a/java/src/com/android/intentresolver/v2/platform/AppPredictionModule.kt b/java/src/com/android/intentresolver/v2/platform/AppPredictionModule.kt deleted file mode 100644 index 090fab6b..00000000 --- a/java/src/com/android/intentresolver/v2/platform/AppPredictionModule.kt +++ /dev/null @@ -1,42 +0,0 @@ -/* - * Copyright (C) 2023 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package com.android.intentresolver.v2.platform - -import android.content.pm.PackageManager -import dagger.Module -import dagger.Provides -import dagger.hilt.InstallIn -import dagger.hilt.components.SingletonComponent -import javax.inject.Qualifier -import javax.inject.Singleton - -@Qualifier -@MustBeDocumented -@Retention(AnnotationRetention.RUNTIME) -annotation class AppPredictionAvailable - -@Module -@InstallIn(SingletonComponent::class) -object AppPredictionModule { - - /** Eventually replaced with: Optional, etc. */ - @Provides - @Singleton - @AppPredictionAvailable - fun isAppPredictionAvailable(packageManager: PackageManager): Boolean { - return packageManager.appPredictionServicePackageName != null - } -} diff --git a/java/src/com/android/intentresolver/v2/platform/ImageEditorModule.kt b/java/src/com/android/intentresolver/v2/platform/ImageEditorModule.kt deleted file mode 100644 index efbf053e..00000000 --- a/java/src/com/android/intentresolver/v2/platform/ImageEditorModule.kt +++ /dev/null @@ -1,35 +0,0 @@ -package com.android.intentresolver.v2.platform - -import android.content.ComponentName -import android.content.res.Resources -import androidx.annotation.StringRes -import com.android.intentresolver.R -import com.android.intentresolver.inject.ApplicationOwned -import dagger.Module -import dagger.Provides -import dagger.hilt.InstallIn -import dagger.hilt.components.SingletonComponent -import java.util.Optional -import javax.inject.Qualifier -import javax.inject.Singleton - -internal fun Resources.componentName(@StringRes resId: Int): ComponentName? { - check(getResourceTypeName(resId) == "string") { "resId must be a string" } - return ComponentName.unflattenFromString(getString(resId)) -} - -@Qualifier @MustBeDocumented @Retention(AnnotationRetention.RUNTIME) annotation class ImageEditor - -@Module -@InstallIn(SingletonComponent::class) -object ImageEditorModule { - /** - * The name of the preferred Activity to launch for editing images. This is added to Intents to - * edit images using Intent.ACTION_EDIT. - */ - @Provides - @Singleton - @ImageEditor - fun imageEditorComponent(@ApplicationOwned resources: Resources) = - Optional.ofNullable(resources.componentName(R.string.config_systemImageEditor)) -} diff --git a/java/src/com/android/intentresolver/v2/platform/NearbyShareModule.kt b/java/src/com/android/intentresolver/v2/platform/NearbyShareModule.kt deleted file mode 100644 index 25ee9198..00000000 --- a/java/src/com/android/intentresolver/v2/platform/NearbyShareModule.kt +++ /dev/null @@ -1,32 +0,0 @@ -package com.android.intentresolver.v2.platform - -import android.content.ComponentName -import android.content.res.Resources -import android.provider.Settings.Secure.NEARBY_SHARING_COMPONENT -import com.android.intentresolver.R -import com.android.intentresolver.inject.ApplicationOwned -import dagger.Module -import dagger.Provides -import dagger.hilt.InstallIn -import dagger.hilt.components.SingletonComponent -import java.util.Optional -import javax.inject.Qualifier -import javax.inject.Singleton - -@Qualifier @MustBeDocumented @Retention(AnnotationRetention.RUNTIME) annotation class NearbyShare - -@Module -@InstallIn(SingletonComponent::class) -object NearbyShareModule { - - @Provides - @Singleton - @NearbyShare - fun nearbyShareComponent(@ApplicationOwned resources: Resources, settings: SecureSettings) = - Optional.ofNullable( - ComponentName.unflattenFromString( - settings.getString(NEARBY_SHARING_COMPONENT)?.ifEmpty { null } - ?: resources.getString(R.string.config_defaultNearbySharingComponent), - ) - ) -} diff --git a/java/src/com/android/intentresolver/v2/platform/PlatformSecureSettings.kt b/java/src/com/android/intentresolver/v2/platform/PlatformSecureSettings.kt deleted file mode 100644 index 531152ba..00000000 --- a/java/src/com/android/intentresolver/v2/platform/PlatformSecureSettings.kt +++ /dev/null @@ -1,30 +0,0 @@ -package com.android.intentresolver.v2.platform - -import android.content.ContentResolver -import android.provider.Settings -import javax.inject.Inject - -/** - * Implements [SecureSettings] backed by Settings.Secure and a ContentResolver. - * - * These methods make Binder calls and may block, so use on the Main thread should be avoided. - */ -class PlatformSecureSettings @Inject constructor(private val resolver: ContentResolver) : - SecureSettings { - - override fun getString(name: String): String? { - return Settings.Secure.getString(resolver, name) - } - - override fun getInt(name: String): Int? { - return runCatching { Settings.Secure.getInt(resolver, name) }.getOrNull() - } - - override fun getLong(name: String): Long? { - return runCatching { Settings.Secure.getLong(resolver, name) }.getOrNull() - } - - override fun getFloat(name: String): Float? { - return runCatching { Settings.Secure.getFloat(resolver, name) }.getOrNull() - } -} diff --git a/java/src/com/android/intentresolver/v2/platform/SecureSettings.kt b/java/src/com/android/intentresolver/v2/platform/SecureSettings.kt deleted file mode 100644 index 62ee8ae9..00000000 --- a/java/src/com/android/intentresolver/v2/platform/SecureSettings.kt +++ /dev/null @@ -1,25 +0,0 @@ -package com.android.intentresolver.v2.platform - -import android.provider.Settings.SettingNotFoundException - -/** - * A component which provides access to values from [android.provider.Settings.Secure]. - * - * All methods return nullable types instead of throwing [SettingNotFoundException] which yields - * cleaner, more idiomatic Kotlin code: - * - * // apply a default: val foo = settings.getInt(FOO) ?: DEFAULT_FOO - * - * // assert if missing: val required = settings.getInt(REQUIRED_VALUE) ?: error("required value - * missing") - */ -interface SecureSettings { - - fun getString(name: String): String? - - fun getInt(name: String): Int? - - fun getLong(name: String): Long? - - fun getFloat(name: String): Float? -} diff --git a/java/src/com/android/intentresolver/v2/platform/SecureSettingsModule.kt b/java/src/com/android/intentresolver/v2/platform/SecureSettingsModule.kt deleted file mode 100644 index 18f47023..00000000 --- a/java/src/com/android/intentresolver/v2/platform/SecureSettingsModule.kt +++ /dev/null @@ -1,14 +0,0 @@ -package com.android.intentresolver.v2.platform - -import dagger.Binds -import dagger.Module -import dagger.Reusable -import dagger.hilt.InstallIn -import dagger.hilt.components.SingletonComponent - -@Module -@InstallIn(SingletonComponent::class) -interface SecureSettingsModule { - - @Binds @Reusable fun secureSettings(settings: PlatformSecureSettings): SecureSettings -} diff --git a/java/src/com/android/intentresolver/v2/profiles/AdapterBinder.java b/java/src/com/android/intentresolver/v2/profiles/AdapterBinder.java deleted file mode 100644 index c5b35273..00000000 --- a/java/src/com/android/intentresolver/v2/profiles/AdapterBinder.java +++ /dev/null @@ -1,31 +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.v2.profiles; - -/** - * Delegate to set up a given adapter and page view to be used together. - * - * @param (as in {@link MultiProfilePagerAdapter}). - * @param (as in {@link MultiProfilePagerAdapter}). - */ -public interface AdapterBinder { - /** - * The given {@code view} will be associated with the given {@code adapter}. Do any work - * necessary to configure them compatibly, introduce them to each other, etc. - */ - void bind(PageViewT view, SinglePageAdapterT adapter); -} diff --git a/java/src/com/android/intentresolver/v2/profiles/ChooserMultiProfilePagerAdapter.java b/java/src/com/android/intentresolver/v2/profiles/ChooserMultiProfilePagerAdapter.java deleted file mode 100644 index c078c43f..00000000 --- a/java/src/com/android/intentresolver/v2/profiles/ChooserMultiProfilePagerAdapter.java +++ /dev/null @@ -1,212 +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.v2.profiles; - -import android.content.Context; -import android.os.UserHandle; -import android.view.LayoutInflater; -import android.view.ViewGroup; - -import androidx.recyclerview.widget.GridLayoutManager; -import androidx.recyclerview.widget.RecyclerView; -import androidx.viewpager.widget.PagerAdapter; - -import com.android.intentresolver.ChooserListAdapter; -import com.android.intentresolver.ChooserRecyclerViewAccessibilityDelegate; -import com.android.intentresolver.FeatureFlags; -import com.android.intentresolver.R; -import com.android.intentresolver.emptystate.EmptyStateProvider; -import com.android.intentresolver.grid.ChooserGridAdapter; -import com.android.intentresolver.measurements.Tracer; - -import com.google.common.collect.ImmutableList; - -import java.util.Optional; -import java.util.function.Supplier; - -/** - * A {@link PagerAdapter} which describes the work and personal profile share sheet screens. - */ -public class ChooserMultiProfilePagerAdapter extends MultiProfilePagerAdapter< - RecyclerView, ChooserGridAdapter, ChooserListAdapter> { - private static final int SINGLE_CELL_SPAN_SIZE = 1; - - private final ChooserProfileAdapterBinder mAdapterBinder; - private final BottomPaddingOverrideSupplier mBottomPaddingOverrideSupplier; - - public ChooserMultiProfilePagerAdapter( - Context context, - ImmutableList> tabs, - EmptyStateProvider emptyStateProvider, - Supplier workProfileQuietModeChecker, - @ProfileType int defaultProfile, - UserHandle workProfileUserHandle, - UserHandle cloneProfileUserHandle, - int maxTargetsPerRow, - FeatureFlags featureFlags) { - this( - context, - new ChooserProfileAdapterBinder(maxTargetsPerRow), - tabs, - emptyStateProvider, - workProfileQuietModeChecker, - defaultProfile, - workProfileUserHandle, - cloneProfileUserHandle, - new BottomPaddingOverrideSupplier(context), - featureFlags); - } - - private ChooserMultiProfilePagerAdapter( - Context context, - ChooserProfileAdapterBinder adapterBinder, - ImmutableList> tabs, - EmptyStateProvider emptyStateProvider, - Supplier workProfileQuietModeChecker, - @ProfileType int defaultProfile, - UserHandle workProfileUserHandle, - UserHandle cloneProfileUserHandle, - BottomPaddingOverrideSupplier bottomPaddingOverrideSupplier, - FeatureFlags featureFlags) { - super( - gridAdapter -> gridAdapter.getListAdapter(), - adapterBinder, - tabs, - emptyStateProvider, - workProfileQuietModeChecker, - defaultProfile, - workProfileUserHandle, - cloneProfileUserHandle, - () -> makeProfileView(context, featureFlags), - bottomPaddingOverrideSupplier); - mAdapterBinder = adapterBinder; - mBottomPaddingOverrideSupplier = bottomPaddingOverrideSupplier; - } - - public void setMaxTargetsPerRow(int maxTargetsPerRow) { - mAdapterBinder.setMaxTargetsPerRow(maxTargetsPerRow); - } - - public void setEmptyStateBottomOffset(int bottomOffset) { - mBottomPaddingOverrideSupplier.setEmptyStateBottomOffset(bottomOffset); - setupContainerPadding(); - } - - /** - * Notify adapter about the drawer's collapse state. This will affect the app divider's - * visibility. - */ - public void setIsCollapsed(boolean isCollapsed) { - for (int i = 0, size = getItemCount(); i < size; i++) { - getPageAdapterForIndex(i).setAzLabelVisibility(!isCollapsed); - } - } - - private static ViewGroup makeProfileView( - Context context, FeatureFlags featureFlags) { - LayoutInflater inflater = LayoutInflater.from(context); - ViewGroup rootView = featureFlags.scrollablePreview() - ? (ViewGroup) inflater.inflate(R.layout.chooser_list_per_profile_wrap, null, false) - : (ViewGroup) inflater.inflate(R.layout.chooser_list_per_profile, null, false); - RecyclerView recyclerView = rootView.findViewById(com.android.internal.R.id.resolver_list); - recyclerView.setAccessibilityDelegateCompat( - new ChooserRecyclerViewAccessibilityDelegate(recyclerView)); - return rootView; - } - - @Override - public boolean onHandlePackagesChanged( - ChooserListAdapter listAdapter, boolean waitingToEnableWorkProfile) { - // TODO: why do we need to do the extra `notifyDataSetChanged()` in (only) the Chooser case? - getActiveListAdapter().notifyDataSetChanged(); - return super.onHandlePackagesChanged(listAdapter, waitingToEnableWorkProfile); - } - - @Override - protected final boolean rebuildTab(ChooserListAdapter listAdapter, boolean doPostProcessing) { - if (doPostProcessing) { - Tracer.INSTANCE.beginAppTargetLoadingSection(listAdapter.getUserHandle()); - } - return super.rebuildTab(listAdapter, doPostProcessing); - } - - /** Apply the specified {@code height} as the footer in each tab's adapter. */ - public void setFooterHeightInEveryAdapter(int height) { - for (int i = 0; i < getItemCount(); ++i) { - getPageAdapterForIndex(i).setFooterHeight(height); - } - } - - /** Cleanup system resources */ - public void destroy() { - for (int i = 0, count = getItemCount(); i < count; i++) { - ChooserGridAdapter adapter = getPageAdapterForIndex(i); - if (adapter != null) { - adapter.getListAdapter().onDestroy(); - } - } - } - - private static class BottomPaddingOverrideSupplier implements Supplier> { - private final Context mContext; - private int mBottomOffset; - - BottomPaddingOverrideSupplier(Context context) { - mContext = context; - } - - public void setEmptyStateBottomOffset(int bottomOffset) { - mBottomOffset = bottomOffset; - } - - @Override - public Optional get() { - int initialBottomPadding = mContext.getResources().getDimensionPixelSize( - R.dimen.resolver_empty_state_container_padding_bottom); - return Optional.of(initialBottomPadding + mBottomOffset); - } - } - - private static class ChooserProfileAdapterBinder implements - AdapterBinder { - private int mMaxTargetsPerRow; - - ChooserProfileAdapterBinder(int maxTargetsPerRow) { - mMaxTargetsPerRow = maxTargetsPerRow; - } - - public void setMaxTargetsPerRow(int maxTargetsPerRow) { - mMaxTargetsPerRow = maxTargetsPerRow; - } - - @Override - public void bind( - RecyclerView recyclerView, ChooserGridAdapter chooserGridAdapter) { - GridLayoutManager glm = (GridLayoutManager) recyclerView.getLayoutManager(); - glm.setSpanCount(mMaxTargetsPerRow); - glm.setSpanSizeLookup( - new GridLayoutManager.SpanSizeLookup() { - @Override - public int getSpanSize(int position) { - return chooserGridAdapter.shouldCellSpan(position) - ? SINGLE_CELL_SPAN_SIZE - : glm.getSpanCount(); - } - }); - } - } -} diff --git a/java/src/com/android/intentresolver/v2/profiles/MultiProfilePagerAdapter.java b/java/src/com/android/intentresolver/v2/profiles/MultiProfilePagerAdapter.java deleted file mode 100644 index 341e7043..00000000 --- a/java/src/com/android/intentresolver/v2/profiles/MultiProfilePagerAdapter.java +++ /dev/null @@ -1,694 +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.v2.profiles; - -import android.annotation.Nullable; -import android.os.Trace; -import android.os.UserHandle; -import android.view.LayoutInflater; -import android.view.View; -import android.view.ViewGroup; -import android.widget.Button; -import android.widget.TabHost; -import android.widget.TextView; - -import androidx.viewpager.widget.PagerAdapter; -import androidx.viewpager.widget.ViewPager; - -import com.android.intentresolver.ResolverListAdapter; -import com.android.intentresolver.emptystate.EmptyState; -import com.android.intentresolver.emptystate.EmptyStateProvider; -import com.android.intentresolver.v2.shared.model.Profile; -import com.android.internal.annotations.VisibleForTesting; - -import com.google.common.collect.ImmutableList; - -import java.util.HashSet; -import java.util.Objects; -import java.util.Optional; -import java.util.Set; -import java.util.concurrent.atomic.AtomicBoolean; -import java.util.function.Consumer; -import java.util.function.Function; -import java.util.function.Supplier; - -/** - * Skeletal {@link PagerAdapter} implementation for a UI with per-profile tabs (as in Sharesheet). - * - * @param the type of the widget that represents the contents of a page in this adapter - * @param the type of a "root" adapter class to be instantiated and included in - * the per-profile records. - * @param the concrete type of a {@link ResolverListAdapter} implementation to - * control the contents of a given per-profile list. This is provided for convenience, since it must - * be possible to get the list adapter from the page adapter via our - * mListAdapterExtractor. - */ -public class MultiProfilePagerAdapter< - PageViewT extends ViewGroup, - SinglePageAdapterT, - ListAdapterT extends ResolverListAdapter> extends PagerAdapter { - - public static final int PROFILE_PERSONAL = Profile.Type.PERSONAL.ordinal(); - public static final int PROFILE_WORK = Profile.Type.WORK.ordinal(); - - // Removed, must be constants. This is only used for linting anyway. - // @IntDef({PROFILE_PERSONAL, PROFILE_WORK}) - public @interface ProfileType {} - - private final Function mListAdapterExtractor; - private final AdapterBinder mAdapterBinder; - private final Supplier mPageViewInflater; - - private final ImmutableList> mItems; - - private final EmptyStateProvider mEmptyStateProvider; - private final UserHandle mWorkProfileUserHandle; - private final UserHandle mCloneProfileUserHandle; - private final Supplier mWorkProfileQuietModeChecker; // True when work is quiet. - - private final Set mLoadedPages; - private int mCurrentPage; - private OnProfileSelectedListener mOnProfileSelectedListener; - - protected MultiProfilePagerAdapter( - Function listAdapterExtractor, - AdapterBinder adapterBinder, - ImmutableList> tabs, - EmptyStateProvider emptyStateProvider, - Supplier workProfileQuietModeChecker, - @ProfileType int defaultProfile, - UserHandle workProfileUserHandle, - UserHandle cloneProfileUserHandle, - Supplier pageViewInflater, - Supplier> containerBottomPaddingOverrideSupplier) { - mLoadedPages = new HashSet<>(); - mWorkProfileUserHandle = workProfileUserHandle; - mCloneProfileUserHandle = cloneProfileUserHandle; - mEmptyStateProvider = emptyStateProvider; - mWorkProfileQuietModeChecker = workProfileQuietModeChecker; - - mListAdapterExtractor = listAdapterExtractor; - mAdapterBinder = adapterBinder; - mPageViewInflater = pageViewInflater; - - ImmutableList.Builder> items = - new ImmutableList.Builder<>(); - for (TabConfig tab : tabs) { - // TODO: consider representing tabConfig in a different data structure that can ensure - // uniqueness of their profile assignments (while still respecting the client's - // requested tab order). - items.add( - createProfileDescriptor( - tab.mProfile, - tab.mTabLabel, - tab.mTabAccessibilityLabel, - tab.mTabTag, - tab.mPageAdapter, - containerBottomPaddingOverrideSupplier)); - } - mItems = items.build(); - - mCurrentPage = - hasPageForProfile(defaultProfile) ? getPageNumberForProfile(defaultProfile) : 0; - } - - private ProfileDescriptor createProfileDescriptor( - @ProfileType int profile, - String tabLabel, - String tabAccessibilityLabel, - String tabTag, - SinglePageAdapterT adapter, - Supplier> containerBottomPaddingOverrideSupplier) { - return new ProfileDescriptor<>( - profile, - tabLabel, - tabAccessibilityLabel, - tabTag, - mPageViewInflater.get(), - adapter, - containerBottomPaddingOverrideSupplier); - } - - private boolean hasPageForIndex(int pageIndex) { - return (pageIndex >= 0) && (pageIndex < getCount()); - } - - public final boolean hasPageForProfile(@ProfileType int profile) { - return hasPageForIndex(getPageNumberForProfile(profile)); - } - - private @ProfileType int getProfileForPageNumber(int position) { - if (hasPageForIndex(position)) { - return mItems.get(position).mProfile; - } - return -1; - } - - public int getPageNumberForProfile(@ProfileType int profile) { - for (int i = 0; i < mItems.size(); ++i) { - if (profile == mItems.get(i).mProfile) { - return i; - } - } - return -1; - } - - private ListAdapterT getListAdapterForPageNumber(int pageNumber) { - SinglePageAdapterT pageAdapter = getPageAdapterForIndex(pageNumber); - if (pageAdapter == null) { - return null; - } - return mListAdapterExtractor.apply(pageAdapter); - } - - private @ProfileType int getProfileForUserHandle(UserHandle userHandle) { - if (userHandle.equals(getCloneUserHandle())) { - // TODO: can we push this special case elsewhere -- e.g., when we check against each - // list adapter's user handle in the loop below, could we instead ask the list adapter - // whether it "represents" the queried user handle, and have the personal list adapter - // return true because it knows it's also associated with the clone profile? Or if we - // don't want to make modifications to the list adapter, maybe we could at least specify - // it in our per-page configuration data that we use to build our tabs/pages, and then - // maintain the relevant bookkeeping in our own ProfileDescriptor? - return PROFILE_PERSONAL; - } - for (int i = 0; i < mItems.size(); ++i) { - ListAdapterT listAdapter = getListAdapterForPageNumber(i); - if (listAdapter.getUserHandle().equals(userHandle)) { - return mItems.get(i).mProfile; - } - } - return -1; - } - - private int getPageNumberForUserHandle(UserHandle userHandle) { - return getPageNumberForProfile(getProfileForUserHandle(userHandle)); - } - - /** - * Returns the {@link ListAdapterT} instance of the profile that represents - * userHandle. If there is no such adapter for the specified - * userHandle, returns {@code null}. - *

For example, if there is a work profile on the device with user id 10, calling this method - * with UserHandle.of(10) returns the work profile {@link ListAdapterT}. - */ - @Nullable - public final ListAdapterT getListAdapterForUserHandle(UserHandle userHandle) { - return getListAdapterForPageNumber(getPageNumberForUserHandle(userHandle)); - } - - @Nullable - private ProfileDescriptor getDescriptorForUserHandle( - UserHandle userHandle) { - return getItem(getPageNumberForUserHandle(userHandle)); - } - - private int getPageNumberForTabTag(String tag) { - for (int i = 0; i < mItems.size(); ++i) { - if (Objects.equals(mItems.get(i).mTabTag, tag)) { - return i; - } - } - return -1; - } - - private void updateActiveTabStyle(TabHost tabHost) { - int currentTab = tabHost.getCurrentTab(); - - for (int pageNumber = 0; pageNumber < getItemCount(); ++pageNumber) { - // TODO: can we avoid this downcast by pushing our knowledge of the intended view type - // somewhere else? - TextView tabText = (TextView) tabHost.getTabWidget().getChildAt(pageNumber); - tabText.setSelected(currentTab == pageNumber); - } - } - - public void setupProfileTabs( - LayoutInflater layoutInflater, - TabHost tabHost, - ViewPager viewPager, - int tabButtonLayoutResId, - int tabPageContentViewId, - Runnable onTabChangeListener, - OnProfileSelectedListener clientOnProfileSelectedListener) { - tabHost.setup(); - tabHost.getTabWidget().removeAllViews(); - viewPager.setSaveEnabled(false); - - for (int pageNumber = 0; pageNumber < getItemCount(); ++pageNumber) { - ProfileDescriptor descriptor = mItems.get(pageNumber); - Button profileButton = (Button) layoutInflater.inflate( - tabButtonLayoutResId, tabHost.getTabWidget(), false); - profileButton.setText(descriptor.mTabLabel); - profileButton.setContentDescription(descriptor.mTabAccessibilityLabel); - - TabHost.TabSpec profileTabSpec = tabHost.newTabSpec(descriptor.mTabTag) - .setContent(tabPageContentViewId) - .setIndicator(profileButton); - tabHost.addTab(profileTabSpec); - } - - tabHost.getTabWidget().setVisibility(View.VISIBLE); - - updateActiveTabStyle(tabHost); - - tabHost.setOnTabChangedListener(tabTag -> { - updateActiveTabStyle(tabHost); - - int pageNumber = getPageNumberForTabTag(tabTag); - if (pageNumber >= 0) { - viewPager.setCurrentItem(pageNumber); - } - onTabChangeListener.run(); - }); - - viewPager.setVisibility(View.VISIBLE); - tabHost.setCurrentTab(getCurrentPage()); - mOnProfileSelectedListener = - new OnProfileSelectedListener() { - @Override - public void onProfilePageSelected(@ProfileType int profileId, int pageNumber) { - tabHost.setCurrentTab(pageNumber); - clientOnProfileSelectedListener.onProfilePageSelected( - profileId, pageNumber); - } - - @Override - public void onProfilePageStateChanged(int state) { - clientOnProfileSelectedListener.onProfilePageStateChanged(state); - } - }; - } - - /** - * Sets this instance of this class as {@link ViewPager}'s {@link PagerAdapter} and sets - * an {@link ViewPager.OnPageChangeListener} where it keeps track of the currently displayed - * page and rebuilds the list. - */ - public void setupViewPager(ViewPager viewPager) { - viewPager.setOnPageChangeListener(new ViewPager.SimpleOnPageChangeListener() { - @Override - public void onPageSelected(int position) { - mCurrentPage = position; - if (!mLoadedPages.contains(position)) { - rebuildActiveTab(true); - mLoadedPages.add(position); - } - if (mOnProfileSelectedListener != null) { - mOnProfileSelectedListener.onProfilePageSelected( - getProfileForPageNumber(position), position); - } - } - - @Override - public void onPageScrollStateChanged(int state) { - if (mOnProfileSelectedListener != null) { - mOnProfileSelectedListener.onProfilePageStateChanged(state); - } - } - }); - viewPager.setAdapter(this); - viewPager.setCurrentItem(mCurrentPage); - mLoadedPages.add(mCurrentPage); - } - - public void clearInactiveProfileCache() { - forEachInactivePage(pageNumber -> mLoadedPages.remove(pageNumber)); - } - - @Override - public final ViewGroup instantiateItem(ViewGroup container, int position) { - setupListAdapter(position); - final ProfileDescriptor descriptor = getItem(position); - container.addView(descriptor.mRootView); - return descriptor.mRootView; - } - - @Override - public void destroyItem(ViewGroup container, int position, Object view) { - container.removeView((View) view); - } - - @Override - public int getCount() { - return getItemCount(); - } - - public int getCurrentPage() { - return mCurrentPage; - } - - public final @ProfileType int getActiveProfile() { - return getProfileForPageNumber(getCurrentPage()); - } - - @VisibleForTesting - public UserHandle getCurrentUserHandle() { - return getActiveListAdapter().getUserHandle(); - } - - @Override - public boolean isViewFromObject(View view, Object object) { - return view == object; - } - - @Override - public CharSequence getPageTitle(int position) { - return null; - } - - public UserHandle getCloneUserHandle() { - return mCloneProfileUserHandle; - } - - /** - * Returns the {@link ProfileDescriptor} relevant to the given pageIndex. - *

    - *
  • For a device with only one user, pageIndex value of - * 0 would return the personal profile {@link ProfileDescriptor}.
  • - *
  • For a device with a work profile, pageIndex value of 0 would - * return the personal profile {@link ProfileDescriptor}, and pageIndex value of - * 1 would return the work profile {@link ProfileDescriptor}.
  • - *
- */ - @Nullable - private ProfileDescriptor getItem(int pageIndex) { - if (!hasPageForIndex(pageIndex)) { - return null; - } - return mItems.get(pageIndex); - } - - private ViewGroup getEmptyStateView(int pageIndex) { - return getItem(pageIndex).getEmptyStateView(); - } - - public ViewGroup getActiveEmptyStateView() { - return getEmptyStateView(getCurrentPage()); - } - - /** - * Returns the number of {@link ProfileDescriptor} objects. - *

For a normal consumer device with only one user returns 1. - *

For a device with a work profile returns 2. - */ - public final int getItemCount() { - return mItems.size(); - } - - public final PageViewT getListViewForIndex(int index) { - return getItem(index).getView(); - } - - /** - * Returns the adapter of the list view for the relevant page specified by - * pageIndex. - *

This method is meant to be implemented with an implementation-specific return type - * depending on the adapter type. - */ - @VisibleForTesting - public final SinglePageAdapterT getPageAdapterForIndex(int index) { - if (!hasPageForIndex(index)) { - return null; - } - return getItem(index).getAdapter(); - } - - /** - * Performs view-related initialization procedures for the adapter specified - * by pageIndex. - */ - public final void setupListAdapter(int pageIndex) { - mAdapterBinder.bind(getListViewForIndex(pageIndex), getPageAdapterForIndex(pageIndex)); - } - - /** - * Returns the {@link ListAdapterT} instance of the profile that is currently visible - * to the user. - *

For example, if the user is viewing the work tab in the share sheet, this method returns - * the work profile {@link ListAdapterT}. - */ - @VisibleForTesting - public final ListAdapterT getActiveListAdapter() { - return getListAdapterForPageNumber(getCurrentPage()); - } - - public final ListAdapterT getPersonalListAdapter() { - return getListAdapterForPageNumber(getPageNumberForProfile(PROFILE_PERSONAL)); - } - - @Nullable - public final ListAdapterT getWorkListAdapter() { - if (!hasPageForProfile(PROFILE_WORK)) { - return null; - } - return getListAdapterForPageNumber(getPageNumberForProfile(PROFILE_WORK)); - } - - public final SinglePageAdapterT getCurrentRootAdapter() { - return getPageAdapterForIndex(getCurrentPage()); - } - - public final PageViewT getActiveAdapterView() { - return getListViewForIndex(getCurrentPage()); - } - - private boolean anyAdapterHasItems() { - for (int i = 0; i < mItems.size(); ++i) { - ListAdapterT listAdapter = getListAdapterForPageNumber(i); - if (listAdapter.getCount() > 0) { - return true; - } - } - return false; - } - - public void refreshPackagesInAllTabs() { - // TODO: it's unclear if this legacy logic really requires the active tab to be rebuilt - // first, or if we could just iterate over the tabs in arbitrary order. - getActiveListAdapter().handlePackagesChanged(); - forEachInactivePage(page -> getListAdapterForPageNumber(page).handlePackagesChanged()); - } - - /** - * Notify that there has been a package change which could potentially modify the set of targets - * that should be shown in the specified {@code listAdapter}. This may result in - * "rebuilding" the target list for that adapter. - * - * @param listAdapter an adapter that may need to be updated after the package-change event. - * @param waitingToEnableWorkProfile whether we've turned on the work profile, but haven't yet - * seen an {@code ACTION_USER_UNLOCKED} broadcast. In this case we skip the rebuild of any - * work-profile adapter because we wouldn't expect meaningful results -- but another rebuild - * will be prompted when we eventually get the broadcast. - * - * @return whether we're able to proceed with a Sharesheet session after processing this - * package-change event. If false, we were able to rebuild the targets but determined that there - * aren't any we could present in the UI without the app looking broken, so we should just quit. - */ - public boolean onHandlePackagesChanged( - ListAdapterT listAdapter, boolean waitingToEnableWorkProfile) { - if (listAdapter == getActiveListAdapter()) { - if (listAdapter.getUserHandle().equals(mWorkProfileUserHandle) - && waitingToEnableWorkProfile) { - // We have just turned on the work profile and entered the passcode to start it, - // now we are waiting to receive the ACTION_USER_UNLOCKED broadcast. There is no - // point in reloading the list now, since the work profile user is still turning on. - return true; - } - - boolean listRebuilt = rebuildActiveTab(true); - if (listRebuilt) { - listAdapter.notifyDataSetChanged(); - } - - // TODO: shouldn't we check that the inactive tabs are built before declaring that we - // have to quit for lack of items? - return anyAdapterHasItems(); - } else { - clearInactiveProfileCache(); - return true; - } - } - - /** - * Fully-rebuild the active tab and, if specified, partially-rebuild any other inactive tabs. - */ - public boolean rebuildTabs(boolean includePartialRebuildOfInactiveTabs) { - // TODO: we may be able to determine `includePartialRebuildOfInactiveTabs` ourselves as - // a function of our own instance state. OTOH the purpose of this "partial rebuild" is to - // be able to evaluate the intermediate state of one particular profile tab (i.e. work - // profile) that may not generalize well when we have other "inactive tabs." I.e., either we - // rebuild *all* the inactive tabs just to evaluate some auto-launch conditions that only - // depend on personal and/or work tabs, or we have to explicitly specify the ones we care - // about. It's not the pager-adapter's business to know "which ones we care about," so maybe - // they should be rebuilt lazily when-and-if it comes up (e.g. during the evaluation of - // autolaunch conditions). - boolean rebuildCompleted = rebuildActiveTab(true) || getActiveListAdapter().isTabLoaded(); - if (includePartialRebuildOfInactiveTabs) { - // Per legacy logic, avoid short-circuiting (TODO: why? possibly so that we *start* - // loading the inactive tabs even if we're still waiting on the active tab to finish?). - boolean completedRebuildingInactiveTabs = rebuildInactiveTabs(false); - rebuildCompleted = rebuildCompleted && completedRebuildingInactiveTabs; - } - return rebuildCompleted; - } - - /** - * Rebuilds the tab that is currently visible to the user. - *

Returns {@code true} if rebuild has completed. - */ - public final boolean rebuildActiveTab(boolean doPostProcessing) { - Trace.beginSection("MultiProfilePagerAdapter#rebuildActiveTab"); - boolean result = rebuildTab(getActiveListAdapter(), doPostProcessing); - Trace.endSection(); - return result; - } - - /** - * Rebuilds any tabs that are not currently visible to the user. - *

Returns {@code true} if rebuild has completed in all inactive tabs. - */ - private boolean rebuildInactiveTabs(boolean doPostProcessing) { - Trace.beginSection("MultiProfilePagerAdapter#rebuildInactiveTab"); - AtomicBoolean allRebuildsComplete = new AtomicBoolean(true); - forEachInactivePage(pageNumber -> { - // Evaluate the rebuild for every inactive page, even if we've already seen some adapter - // return an "incomplete" status (i.e., even if `allRebuildsComplete` is already false) - // and so we already know we'll end up returning false for the batch. - // TODO: any particular reason the per-page legacy logic was set up in this order, or - // could we possibly short-circuit the rebuild if the tab is already "loaded"? - ListAdapterT inactiveAdapter = getListAdapterForPageNumber(pageNumber); - boolean rebuildInactivePageCompleted = - rebuildTab(inactiveAdapter, doPostProcessing) || inactiveAdapter.isTabLoaded(); - if (!rebuildInactivePageCompleted) { - allRebuildsComplete.set(false); - } - }); - Trace.endSection(); - return allRebuildsComplete.get(); - } - - protected void forEachPage(Consumer pageNumberHandler) { - for (int pageNumber = 0; pageNumber < getItemCount(); ++pageNumber) { - pageNumberHandler.accept(pageNumber); - } - } - - protected void forEachInactivePage(Consumer inactivePageNumberHandler) { - forEachPage(pageNumber -> { - if (pageNumber != getCurrentPage()) { - inactivePageNumberHandler.accept(pageNumber); - } - }); - } - - protected boolean rebuildTab(ListAdapterT activeListAdapter, boolean doPostProcessing) { - if (shouldSkipRebuild(activeListAdapter)) { - activeListAdapter.postListReadyRunnable(doPostProcessing, /* rebuildCompleted */ true); - return false; - } - return activeListAdapter.rebuildList(doPostProcessing); - } - - private boolean shouldSkipRebuild(ListAdapterT activeListAdapter) { - EmptyState emptyState = mEmptyStateProvider.getEmptyState(activeListAdapter); - return emptyState != null && emptyState.shouldSkipDataRebuild(); - } - - /** - * The empty state screens are shown according to their priority: - *

    - *
  1. (highest priority) cross-profile disabled by policy (handled in - * {@link #rebuildTab(ListAdapterT, boolean)})
  2. - *
  3. no apps available
  4. - *
  5. (least priority) work is off
  6. - *
- * - * The intention is to prevent the user from having to turn - * the work profile on if there will not be any apps resolved - * anyway. - * - * TODO: move this comment to the place where we configure our composite provider. - */ - public void showEmptyResolverListEmptyState(ListAdapterT listAdapter) { - final EmptyState emptyState = mEmptyStateProvider.getEmptyState(listAdapter); - - if (emptyState == null) { - return; - } - - emptyState.onEmptyStateShown(); - - View.OnClickListener clickListener = null; - - if (emptyState.getButtonClickListener() != null) { - clickListener = v -> emptyState.getButtonClickListener().onClick(() -> { - ProfileDescriptor descriptor = - getDescriptorForUserHandle(listAdapter.getUserHandle()); - descriptor.mEmptyStateUi.showSpinner(); - }); - } - - showEmptyState(listAdapter, emptyState, clickListener); - } - - private void showEmptyState( - ListAdapterT activeListAdapter, - EmptyState emptyState, - View.OnClickListener buttonOnClick) { - ProfileDescriptor descriptor = - getDescriptorForUserHandle(activeListAdapter.getUserHandle()); - descriptor.mEmptyStateUi.showEmptyState(emptyState, buttonOnClick); - activeListAdapter.markTabLoaded(); - } - - /** - * Sets up the padding of the view containing the empty state screens for the current adapter - * view. - */ - protected final void setupContainerPadding() { - getItem(getCurrentPage()).setupContainerPadding(); - } - - public void showListView(ListAdapterT activeListAdapter) { - ProfileDescriptor descriptor = - getDescriptorForUserHandle(activeListAdapter.getUserHandle()); - descriptor.mEmptyStateUi.hide(); - } - - /** - * @return whether any "inactive" tab's adapter would show an empty-state screen in our current - * application state. - */ - public final boolean shouldShowEmptyStateScreenInAnyInactiveAdapter() { - AtomicBoolean anyEmpty = new AtomicBoolean(false); - // TODO: The "inactive" condition is legacy logic. Could we simplify and ask "any"? - forEachInactivePage(pageNumber -> { - if (shouldShowEmptyStateScreen(getListAdapterForPageNumber(pageNumber))) { - anyEmpty.set(true); - } - }); - return anyEmpty.get(); - } - - public boolean shouldShowEmptyStateScreen(ListAdapterT listAdapter) { - int count = listAdapter.getUnfilteredCount(); - return (count == 0 && listAdapter.getPlaceholderCount() == 0) - || (listAdapter.getUserHandle().equals(mWorkProfileUserHandle) - && mWorkProfileQuietModeChecker.get()); - } - -} diff --git a/java/src/com/android/intentresolver/v2/profiles/OnProfileSelectedListener.java b/java/src/com/android/intentresolver/v2/profiles/OnProfileSelectedListener.java deleted file mode 100644 index 7bdbec4c..00000000 --- a/java/src/com/android/intentresolver/v2/profiles/OnProfileSelectedListener.java +++ /dev/null @@ -1,46 +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.v2.profiles; - -import androidx.viewpager.widget.ViewPager; - -/** Listener interface for changes between the per-profile UI tabs. */ -public interface OnProfileSelectedListener { - /** - * Callback for when the user changes the active tab. - *

This callback is only called when the intent resolver or share sheet shows - * more than one profile. - * - * @param profileId the ID of the newly-selected profile, e.g. {@link #PROFILE_PERSONAL} - * if the personal profile tab was selected or {@link #PROFILE_WORK} if the - * work profile tab - * was selected. - */ - void onProfilePageSelected(@MultiProfilePagerAdapter.ProfileType int profileId, int pageNumber); - - - /** - * Callback for when the scroll state changes. Useful for discovering when the user begins - * dragging, when the pager is automatically settling to the current page, or when it is - * fully stopped/idle. - * - * @param state {@link ViewPager#SCROLL_STATE_IDLE}, {@link ViewPager#SCROLL_STATE_DRAGGING} - * or {@link ViewPager#SCROLL_STATE_SETTLING} - * @see ViewPager.OnPageChangeListener#onPageScrollStateChanged - */ - void onProfilePageStateChanged(int state); -} diff --git a/java/src/com/android/intentresolver/v2/profiles/OnSwitchOnWorkSelectedListener.java b/java/src/com/android/intentresolver/v2/profiles/OnSwitchOnWorkSelectedListener.java deleted file mode 100644 index 3dbbd4d0..00000000 --- a/java/src/com/android/intentresolver/v2/profiles/OnSwitchOnWorkSelectedListener.java +++ /dev/null @@ -1,27 +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.v2.profiles; - -/** - * Listener for when the user switches on the work profile from the work tab. - */ -public interface OnSwitchOnWorkSelectedListener { - /** - * Callback for when the user switches on the work profile from the work tab. - */ - void onSwitchOnWorkSelected(); -} diff --git a/java/src/com/android/intentresolver/v2/profiles/ProfileDescriptor.java b/java/src/com/android/intentresolver/v2/profiles/ProfileDescriptor.java deleted file mode 100644 index e2e9c19d..00000000 --- a/java/src/com/android/intentresolver/v2/profiles/ProfileDescriptor.java +++ /dev/null @@ -1,82 +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.v2.profiles; - -import android.view.ViewGroup; - -import com.android.intentresolver.v2.emptystate.EmptyStateUiHelper; - -import java.util.Optional; -import java.util.function.Supplier; - -// TODO: `ChooserActivity` also has a per-profile record type. Maybe the "multi-profile pager" -// should be the owner of all per-profile data (especially now that the API is generic)? -class ProfileDescriptor { - final @MultiProfilePagerAdapter.ProfileType int mProfile; - final String mTabLabel; - final String mTabAccessibilityLabel; - final String mTabTag; - - final ViewGroup mRootView; - final EmptyStateUiHelper mEmptyStateUi; - - // TODO: post-refactoring, we may not need to retain these ivars directly (since they may - // be encapsulated within the `EmptyStateUiHelper`?). - private final ViewGroup mEmptyStateView; - - private final SinglePageAdapterT mAdapter; - - public SinglePageAdapterT getAdapter() { - return mAdapter; - } - - public PageViewT getView() { - return mView; - } - - private final PageViewT mView; - - ProfileDescriptor( - @MultiProfilePagerAdapter.ProfileType int forProfile, - String tabLabel, - String tabAccessibilityLabel, - String tabTag, - ViewGroup rootView, - SinglePageAdapterT adapter, - Supplier> containerBottomPaddingOverrideSupplier) { - mProfile = forProfile; - mTabLabel = tabLabel; - mTabAccessibilityLabel = tabAccessibilityLabel; - mTabTag = tabTag; - mRootView = rootView; - mAdapter = adapter; - mEmptyStateView = rootView.findViewById(com.android.internal.R.id.resolver_empty_state); - mView = (PageViewT) rootView.findViewById(com.android.internal.R.id.resolver_list); - mEmptyStateUi = new EmptyStateUiHelper( - rootView, - com.android.internal.R.id.resolver_list, - containerBottomPaddingOverrideSupplier); - } - - protected ViewGroup getEmptyStateView() { - return mEmptyStateView; - } - - public void setupContainerPadding() { - mEmptyStateUi.setupContainerPadding(); - } -} diff --git a/java/src/com/android/intentresolver/v2/profiles/ResolverMultiProfilePagerAdapter.java b/java/src/com/android/intentresolver/v2/profiles/ResolverMultiProfilePagerAdapter.java deleted file mode 100644 index e44cf8da..00000000 --- a/java/src/com/android/intentresolver/v2/profiles/ResolverMultiProfilePagerAdapter.java +++ /dev/null @@ -1,112 +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.v2.profiles; - -import android.content.Context; -import android.os.UserHandle; -import android.view.LayoutInflater; -import android.view.ViewGroup; -import android.widget.ListView; - -import androidx.viewpager.widget.PagerAdapter; - -import com.android.intentresolver.R; -import com.android.intentresolver.ResolverListAdapter; -import com.android.intentresolver.emptystate.EmptyStateProvider; - -import com.google.common.collect.ImmutableList; - -import java.util.Optional; -import java.util.function.Supplier; - -/** - * A {@link PagerAdapter} which describes the work and personal profile intent resolver screens. - */ -public class ResolverMultiProfilePagerAdapter extends - MultiProfilePagerAdapter { - private final BottomPaddingOverrideSupplier mBottomPaddingOverrideSupplier; - - public ResolverMultiProfilePagerAdapter(Context context, - ImmutableList> tabs, - EmptyStateProvider emptyStateProvider, - Supplier workProfileQuietModeChecker, - @ProfileType int defaultProfile, - UserHandle workProfileUserHandle, - UserHandle cloneProfileUserHandle) { - this( - context, - tabs, - emptyStateProvider, - workProfileQuietModeChecker, - defaultProfile, - workProfileUserHandle, - cloneProfileUserHandle, - new BottomPaddingOverrideSupplier()); - } - - private ResolverMultiProfilePagerAdapter( - Context context, - ImmutableList> tabs, - EmptyStateProvider emptyStateProvider, - Supplier workProfileQuietModeChecker, - @ProfileType int defaultProfile, - UserHandle workProfileUserHandle, - UserHandle cloneProfileUserHandle, - BottomPaddingOverrideSupplier bottomPaddingOverrideSupplier) { - super( - listAdapter -> listAdapter, - (listView, bindAdapter) -> listView.setAdapter(bindAdapter), - tabs, - emptyStateProvider, - workProfileQuietModeChecker, - defaultProfile, - workProfileUserHandle, - cloneProfileUserHandle, - () -> (ViewGroup) LayoutInflater.from(context).inflate( - R.layout.resolver_list_per_profile, null, false), - bottomPaddingOverrideSupplier); - mBottomPaddingOverrideSupplier = bottomPaddingOverrideSupplier; - } - - public void setUseLayoutWithDefault(boolean useLayoutWithDefault) { - mBottomPaddingOverrideSupplier.setUseLayoutWithDefault(useLayoutWithDefault); - } - - /** Un-check any item(s) that may be checked in any of our inactive adapter(s). */ - public void clearCheckedItemsInInactiveProfiles() { - // TODO: The "inactive" condition is legacy logic. Could we simplify and clear-all? - forEachInactivePage(pageNumber -> { - ListView inactiveListView = getListViewForIndex(pageNumber); - if (inactiveListView.getCheckedItemCount() > 0) { - inactiveListView.setItemChecked(inactiveListView.getCheckedItemPosition(), false); - } - }); - } - - private static class BottomPaddingOverrideSupplier implements Supplier> { - private boolean mUseLayoutWithDefault; - - public void setUseLayoutWithDefault(boolean useLayoutWithDefault) { - mUseLayoutWithDefault = useLayoutWithDefault; - } - - @Override - public Optional get() { - return mUseLayoutWithDefault ? Optional.empty() : Optional.of(0); - } - } -} diff --git a/java/src/com/android/intentresolver/v2/profiles/TabConfig.java b/java/src/com/android/intentresolver/v2/profiles/TabConfig.java deleted file mode 100644 index 994f8aff..00000000 --- a/java/src/com/android/intentresolver/v2/profiles/TabConfig.java +++ /dev/null @@ -1,38 +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.v2.profiles; - -public class TabConfig { - final @MultiProfilePagerAdapter.ProfileType int mProfile; - final String mTabLabel; - final String mTabAccessibilityLabel; - final String mTabTag; - final PageAdapterT mPageAdapter; - - public TabConfig( - @MultiProfilePagerAdapter.ProfileType int profile, - String tabLabel, - String tabAccessibilityLabel, - String tabTag, - PageAdapterT pageAdapter) { - mProfile = profile; - mTabLabel = tabLabel; - mTabAccessibilityLabel = tabAccessibilityLabel; - mTabTag = tabTag; - mPageAdapter = pageAdapter; - } -} diff --git a/java/src/com/android/intentresolver/v2/shared/model/Profile.kt b/java/src/com/android/intentresolver/v2/shared/model/Profile.kt deleted file mode 100644 index 6e37174c..00000000 --- a/java/src/com/android/intentresolver/v2/shared/model/Profile.kt +++ /dev/null @@ -1,52 +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.v2.shared.model - -import com.android.intentresolver.v2.shared.model.Profile.Type - -/** - * Associates [users][User] into a [Type] instance. - * - * This is a simple abstraction which combines a primary [user][User] with an optional - * [cloned apps][User.Role.CLONE] user. This encapsulates the cloned app user id, while still being - * available where needed. - */ -data class Profile( - val type: Type, - val primary: User, - /** - * An optional [User] of which contains second instances of some applications installed for the - * personal user. This value may only be supplied when creating the PERSONAL profile. - */ - val clone: User? = null -) { - - init { - clone?.apply { - require(primary.role == User.Role.PERSONAL) { - "clone is not supported for profile=${this@Profile.type} / primary=$primary" - } - require(role == User.Role.CLONE) { "clone is not a clone user ($this)" } - } - } - - enum class Type { - PERSONAL, - WORK, - PRIVATE - } -} diff --git a/java/src/com/android/intentresolver/v2/shared/model/User.kt b/java/src/com/android/intentresolver/v2/shared/model/User.kt deleted file mode 100644 index 46279ad0..00000000 --- a/java/src/com/android/intentresolver/v2/shared/model/User.kt +++ /dev/null @@ -1,52 +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.v2.shared.model - -import android.annotation.UserIdInt -import android.os.UserHandle - -/** - * A User represents the owner of a distinct set of content. - * * maps 1:1 to a UserHandle or UserId (Int) value. - * * refers to either [Full][Type.FULL], or a [Profile][Type.PROFILE] user, as indicated by the - * [type] property. - * - * See - * [Users for system developers](https://android.googlesource.com/platform/frameworks/base/+/master/core/java/android/os/Users.md) - * - * ``` - * val users = listOf( - * User(id = 0, role = PERSONAL), - * User(id = 10, role = WORK), - * User(id = 11, role = CLONE), - * User(id = 12, role = PRIVATE), - * ) - * ``` - */ -data class User( - @UserIdInt val id: Int, - val role: Role, -) { - val handle: UserHandle = UserHandle.of(id) - - enum class Role { - PERSONAL, - PRIVATE, - WORK, - CLONE - } -} diff --git a/java/src/com/android/intentresolver/v2/ui/ActionTitle.java b/java/src/com/android/intentresolver/v2/ui/ActionTitle.java deleted file mode 100644 index a1e1c7fa..00000000 --- a/java/src/com/android/intentresolver/v2/ui/ActionTitle.java +++ /dev/null @@ -1,88 +0,0 @@ -/* - * Copyright (C) 2023 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package com.android.intentresolver.v2.ui; - -import android.content.Intent; -import android.provider.MediaStore; - -import androidx.annotation.StringRes; - -import com.android.intentresolver.R; - -/** - * Provides a set of related resources for different use cases. - */ -public enum ActionTitle { - VIEW(Intent.ACTION_VIEW, - R.string.whichViewApplication, - R.string.whichViewApplicationNamed, - R.string.whichViewApplicationLabel), - EDIT(Intent.ACTION_EDIT, - R.string.whichEditApplication, - R.string.whichEditApplicationNamed, - R.string.whichEditApplicationLabel), - SEND(Intent.ACTION_SEND, - R.string.whichSendApplication, - R.string.whichSendApplicationNamed, - R.string.whichSendApplicationLabel), - SENDTO(Intent.ACTION_SENDTO, - R.string.whichSendToApplication, - R.string.whichSendToApplicationNamed, - R.string.whichSendToApplicationLabel), - SEND_MULTIPLE(Intent.ACTION_SEND_MULTIPLE, - R.string.whichSendApplication, - R.string.whichSendApplicationNamed, - R.string.whichSendApplicationLabel), - CAPTURE_IMAGE(MediaStore.ACTION_IMAGE_CAPTURE, - R.string.whichImageCaptureApplication, - R.string.whichImageCaptureApplicationNamed, - R.string.whichImageCaptureApplicationLabel), - DEFAULT(null, - R.string.whichApplication, - R.string.whichApplicationNamed, - R.string.whichApplicationLabel), - HOME(Intent.ACTION_MAIN, - R.string.whichHomeApplication, - R.string.whichHomeApplicationNamed, - R.string.whichHomeApplicationLabel); - - // titles for layout that deals with http(s) intents - public static final int BROWSABLE_TITLE_RES = R.string.whichOpenLinksWith; - public static final int BROWSABLE_HOST_TITLE_RES = R.string.whichOpenHostLinksWith; - public static final int BROWSABLE_HOST_APP_TITLE_RES = R.string.whichOpenHostLinksWithApp; - public static final int BROWSABLE_APP_TITLE_RES = R.string.whichOpenLinksWithApp; - - public final String action; - public final int titleRes; - public final int namedTitleRes; - public final @StringRes int labelRes; - - ActionTitle(String action, int titleRes, int namedTitleRes, @StringRes int labelRes) { - this.action = action; - this.titleRes = titleRes; - this.namedTitleRes = namedTitleRes; - this.labelRes = labelRes; - } - - public static ActionTitle forAction(String action) { - for (ActionTitle title : values()) { - if (title != HOME && action != null && action.equals(title.action)) { - return title; - } - } - return DEFAULT; - } -} diff --git a/java/src/com/android/intentresolver/v2/ui/ProfilePagerResources.kt b/java/src/com/android/intentresolver/v2/ui/ProfilePagerResources.kt deleted file mode 100644 index ca7ae0fc..00000000 --- a/java/src/com/android/intentresolver/v2/ui/ProfilePagerResources.kt +++ /dev/null @@ -1,53 +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.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 - -class ProfilePagerResources -@Inject -constructor( - @ApplicationOwned private val resources: Resources, - private val devicePolicyResources: DevicePolicyResources -) { - private val privateTabLabel by lazy { resources.getString(R.string.resolver_private_tab) } - - private val privateTabAccessibilityLabel by lazy { - resources.getString(R.string.resolver_private_tab_accessibility) - } - - fun profileTabLabel(profile: Profile.Type): String { - return when (profile) { - Profile.Type.PERSONAL -> devicePolicyResources.personalTabLabel - Profile.Type.WORK -> devicePolicyResources.workTabLabel - Profile.Type.PRIVATE -> privateTabLabel - } - } - - fun profileTabAccessibilityLabel(type: Profile.Type): String { - return when (type) { - Profile.Type.PERSONAL -> devicePolicyResources.personalTabAccessibilityLabel - Profile.Type.WORK -> devicePolicyResources.workTabAccessibilityLabel - Profile.Type.PRIVATE -> privateTabAccessibilityLabel - } - } -} diff --git a/java/src/com/android/intentresolver/v2/ui/ShareResultSender.kt b/java/src/com/android/intentresolver/v2/ui/ShareResultSender.kt deleted file mode 100644 index 2b01b5e7..00000000 --- a/java/src/com/android/intentresolver/v2/ui/ShareResultSender.kt +++ /dev/null @@ -1,163 +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.v2.ui - -import android.app.Activity -import android.app.compat.CompatChanges -import android.content.ComponentName -import android.content.Context -import android.content.Intent -import android.content.IntentSender -import android.service.chooser.ChooserResult -import android.service.chooser.ChooserResult.CHOOSER_RESULT_COPY -import android.service.chooser.ChooserResult.CHOOSER_RESULT_EDIT -import android.service.chooser.ChooserResult.CHOOSER_RESULT_SELECTED_COMPONENT -import android.service.chooser.ChooserResult.CHOOSER_RESULT_UNKNOWN -import android.service.chooser.ChooserResult.ResultType -import android.util.Log -import com.android.intentresolver.inject.Background -import com.android.intentresolver.inject.ChooserServiceFlags -import com.android.intentresolver.inject.Main -import com.android.intentresolver.v2.ui.model.ShareAction -import dagger.assisted.Assisted -import dagger.assisted.AssistedFactory -import dagger.assisted.AssistedInject -import dagger.hilt.android.qualifiers.ActivityContext -import kotlinx.coroutines.CoroutineDispatcher -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext - -private const val TAG = "ShareResultSender" - -/** Reports the result of a share to another process across binder, via an [IntentSender] */ -interface ShareResultSender { - /** Reports user selection of an activity to launch from the provided choices. */ - fun onComponentSelected(component: ComponentName, directShare: Boolean) - - /** Reports user invocation of a built-in system action. See [ShareAction]. */ - fun onActionSelected(action: ShareAction) -} - -@AssistedFactory -interface ShareResultSenderFactory { - fun create(callerUid: Int, chosenComponentSender: IntentSender): ShareResultSenderImpl -} - -/** Dispatches Intents via IntentSender */ -fun interface IntentSenderDispatcher { - fun dispatchIntent(intentSender: IntentSender, intent: Intent) -} - -class ShareResultSenderImpl( - private val flags: ChooserServiceFlags, - @Main private val scope: CoroutineScope, - @Background val backgroundDispatcher: CoroutineDispatcher, - private val callerUid: Int, - private val resultSender: IntentSender, - private val intentDispatcher: IntentSenderDispatcher -) : ShareResultSender { - @AssistedInject - constructor( - @ActivityContext context: Context, - flags: ChooserServiceFlags, - @Main scope: CoroutineScope, - @Background backgroundDispatcher: CoroutineDispatcher, - @Assisted callerUid: Int, - @Assisted chosenComponentSender: IntentSender, - ) : this( - flags, - scope, - backgroundDispatcher, - callerUid, - chosenComponentSender, - IntentSenderDispatcher { sender, intent -> sender.dispatchIntent(context, intent) } - ) - - override fun onComponentSelected(component: ComponentName, directShare: Boolean) { - Log.i(TAG, "onComponentSelected: $component directShare=$directShare") - scope.launch { - val intent = createChosenComponentIntent(component, directShare) - intentDispatcher.dispatchIntent(resultSender, intent) - } - } - - override fun onActionSelected(action: ShareAction) { - Log.i(TAG, "onActionSelected: $action") - scope.launch { - if (flags.enableChooserResult() && chooserResultSupported(callerUid)) { - @ResultType val chosenAction = shareActionToChooserResult(action) - val intent: Intent = createSelectedActionIntent(chosenAction) - intentDispatcher.dispatchIntent(resultSender, intent) - } else { - Log.i(TAG, "Not sending SelectedAction") - } - } - } - - private suspend fun createChosenComponentIntent( - component: ComponentName, - direct: Boolean, - ): Intent { - // Add extra with component name for backwards compatibility. - val intent: Intent = Intent().putExtra(Intent.EXTRA_CHOSEN_COMPONENT, component) - - // Add ChooserResult value for Android V+ - if (flags.enableChooserResult() && chooserResultSupported(callerUid)) { - intent.putExtra( - Intent.EXTRA_CHOOSER_RESULT, - ChooserResult(CHOOSER_RESULT_SELECTED_COMPONENT, component, direct) - ) - } else { - Log.i(TAG, "Not including ${Intent.EXTRA_CHOOSER_RESULT}") - } - return intent - } - - @ResultType - private fun shareActionToChooserResult(action: ShareAction) = - when (action) { - ShareAction.SYSTEM_COPY -> CHOOSER_RESULT_COPY - ShareAction.SYSTEM_EDIT -> CHOOSER_RESULT_EDIT - ShareAction.APPLICATION_DEFINED -> CHOOSER_RESULT_UNKNOWN - } - - private fun createSelectedActionIntent(@ResultType result: Int): Intent { - return Intent().putExtra(Intent.EXTRA_CHOOSER_RESULT, ChooserResult(result, null, false)) - } - - private suspend fun chooserResultSupported(uid: Int): Boolean { - return withContext(backgroundDispatcher) { - // background -> Binder call to system_server - CompatChanges.isChangeEnabled(ChooserResult.SEND_CHOOSER_RESULT, uid) - } - } -} - -private fun IntentSender.dispatchIntent(context: Context, intent: Intent) { - try { - sendIntent( - /* context = */ context, - /* code = */ Activity.RESULT_OK, - /* intent = */ intent, - /* onFinished = */ null, - /* handler = */ null - ) - } catch (e: IntentSender.SendIntentException) { - Log.e(TAG, "Failed to send intent to IntentSender", e) - } -} diff --git a/java/src/com/android/intentresolver/v2/ui/ShortcutPolicyModule.kt b/java/src/com/android/intentresolver/v2/ui/ShortcutPolicyModule.kt deleted file mode 100644 index 5e098cd5..00000000 --- a/java/src/com/android/intentresolver/v2/ui/ShortcutPolicyModule.kt +++ /dev/null @@ -1,94 +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.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 deleted file mode 100644 index 67c2a25e..00000000 --- a/java/src/com/android/intentresolver/v2/ui/model/ActivityModel.kt +++ /dev/null @@ -1,81 +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.v2.ui.model - -import android.app.Activity -import android.content.Intent -import android.net.Uri -import android.os.Parcel -import android.os.Parcelable -import com.android.intentresolver.v2.data.model.ANDROID_APP_SCHEME -import com.android.intentresolver.v2.ext.readParcelable -import com.android.intentresolver.v2.ext.requireParcelable -import java.util.Objects - -/** Contains Activity-scope information about the state when started. */ -data class ActivityModel( - /** The [Intent] received by the app */ - val intent: Intent, - /** The identifier for the sending app and user */ - val launchedFromUid: Int, - /** The package of the sending app */ - val launchedFromPackage: String, - /** The referrer as supplied to the activity. */ - val referrer: Uri? -) : Parcelable { - constructor( - source: Parcel - ) : this( - intent = source.requireParcelable(), - launchedFromUid = source.readInt(), - launchedFromPackage = requireNotNull(source.readString()), - referrer = source.readParcelable() - ) - - /** A package name from referrer, if it is an android-app URI */ - val referrerPackage = referrer?.takeIf { it.scheme == ANDROID_APP_SCHEME }?.authority - - override fun describeContents() = 0 /* flags */ - - override fun writeToParcel(dest: Parcel, flags: Int) { - dest.writeParcelable(intent, flags) - dest.writeInt(launchedFromUid) - dest.writeString(launchedFromPackage) - dest.writeParcelable(referrer, flags) - } - - companion object { - const val ACTIVITY_MODEL_KEY = "com.android.intentresolver.ACTIVITY_MODEL" - - @JvmField - @Suppress("unused") - val CREATOR = - object : Parcelable.Creator { - override fun newArray(size: Int) = arrayOfNulls(size) - override fun createFromParcel(source: Parcel) = ActivityModel(source) - } - - @JvmStatic - fun createFrom(activity: Activity): ActivityModel { - return ActivityModel( - activity.intent, - activity.launchedFromUid, - Objects.requireNonNull(activity.launchedFromPackage), - activity.referrer - ) - } - } -} diff --git a/java/src/com/android/intentresolver/v2/ui/model/ResolverRequest.kt b/java/src/com/android/intentresolver/v2/ui/model/ResolverRequest.kt deleted file mode 100644 index 44010caf..00000000 --- a/java/src/com/android/intentresolver/v2/ui/model/ResolverRequest.kt +++ /dev/null @@ -1,68 +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.v2.ui.model - -import android.content.Intent -import android.content.pm.ResolveInfo -import android.os.UserHandle -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( - /** The intent to be resolved to a target. */ - val intent: Intent, - - /** - * Supplied by the system to indicate which profile should be selected by default. This is - * required since ResolverActivity may be launched as either the originating OR target user when - * resolving a cross profile intent. - * - * Valid values are: [PERSONAL][Profile.Type.PERSONAL] and [WORK][Profile.Type.WORK] and null - * when the intent is not a forwarded cross-profile intent. - */ - val selectedProfile: Profile.Type?, - - /** - * When handing a cross profile forwarded intent, this is the user which started the original - * intent. This is required to allow ResolverActivity to be launched as the target user under - * some conditions. - */ - val callingUser: UserHandle?, - - /** - * Indicates if resolving actions for a connected device which has audio capture capability - * (e.g. is a USB Microphone). - * - * When used to handle a connected device, ResolverActivity uses this signal to present a - * warning when a resolved application does not hold the RECORD_AUDIO permission. (If selected - * the app would be able to capture audio directly via the device, bypassing audio API - * permissions.) - */ - val isAudioCaptureDevice: Boolean = false, - - /** A list of a resolved activity targets. This list overrides normal intent resolution. */ - val resolutionList: List? = null, - - /** A customized title for the resolver interface. */ - val title: String? = null, -) { - val isResolvingHome = intent.isHomeIntent() - - /** For compatibility with existing code shared between chooser/resolver. */ - val payloadIntents: List = listOf(intent) -} diff --git a/java/src/com/android/intentresolver/v2/ui/model/ShareAction.kt b/java/src/com/android/intentresolver/v2/ui/model/ShareAction.kt deleted file mode 100644 index e13ef101..00000000 --- a/java/src/com/android/intentresolver/v2/ui/model/ShareAction.kt +++ /dev/null @@ -1,23 +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.v2.ui.model - -enum class ShareAction { - SYSTEM_COPY, - SYSTEM_EDIT, - APPLICATION_DEFINED -} diff --git a/java/src/com/android/intentresolver/v2/ui/viewmodel/ChooserRequestReader.kt b/java/src/com/android/intentresolver/v2/ui/viewmodel/ChooserRequestReader.kt deleted file mode 100644 index a25fcbea..00000000 --- a/java/src/com/android/intentresolver/v2/ui/viewmodel/ChooserRequestReader.kt +++ /dev/null @@ -1,198 +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.v2.ui.viewmodel - -import android.content.ComponentName -import android.content.Intent -import android.content.Intent.EXTRA_ALTERNATE_INTENTS -import android.content.Intent.EXTRA_CHOOSER_CUSTOM_ACTIONS -import android.content.Intent.EXTRA_CHOOSER_MODIFY_SHARE_ACTION -import android.content.Intent.EXTRA_CHOOSER_REFINEMENT_INTENT_SENDER -import android.content.Intent.EXTRA_CHOOSER_RESULT_INTENT_SENDER -import android.content.Intent.EXTRA_CHOOSER_TARGETS -import android.content.Intent.EXTRA_CHOSEN_COMPONENT_INTENT_SENDER -import android.content.Intent.EXTRA_EXCLUDE_COMPONENTS -import android.content.Intent.EXTRA_INITIAL_INTENTS -import android.content.Intent.EXTRA_INTENT -import android.content.Intent.EXTRA_METADATA_TEXT -import android.content.Intent.EXTRA_REPLACEMENT_EXTRAS -import android.content.Intent.EXTRA_TEXT -import android.content.Intent.EXTRA_TITLE -import android.content.Intent.FLAG_ACTIVITY_MULTIPLE_TASK -import android.content.Intent.FLAG_ACTIVITY_NEW_DOCUMENT -import android.content.IntentFilter -import android.content.IntentSender -import android.net.Uri -import android.os.Bundle -import android.service.chooser.ChooserAction -import android.service.chooser.ChooserTarget -import com.android.intentresolver.ChooserActivity -import com.android.intentresolver.ContentTypeHint -import com.android.intentresolver.R -import com.android.intentresolver.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.validation.Validation -import com.android.intentresolver.v2.validation.ValidationResult -import com.android.intentresolver.v2.validation.types.IntentOrUri -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 MAX_CHOOSER_ACTIONS = 5 -private const val MAX_INITIAL_INTENTS = 2 - -internal fun Intent.maybeAddSendActionFlags() = - ifMatch(Intent::hasSendAction) { - addFlags(FLAG_ACTIVITY_NEW_DOCUMENT) - addFlags(FLAG_ACTIVITY_MULTIPLE_TASK) - } - -fun readChooserRequest( - model: ActivityModel, - flags: ChooserServiceFlags -): ValidationResult { - val extras = model.intent.extras ?: Bundle() - @Suppress("DEPRECATION") - return validateFrom(extras::get) { - val targetIntent = required(IntentOrUri(EXTRA_INTENT)).maybeAddSendActionFlags() - - val isSendAction = targetIntent.hasSendAction() - - val additionalTargets = readAlternateIntents() ?: emptyList() - - val replacementExtras = optional(value(EXTRA_REPLACEMENT_EXTRAS)) - - val (customTitle, defaultTitleResource) = - if (isSendAction) { - ignored( - value(EXTRA_TITLE), - "deprecated in P. You may wish to set a preview title by using EXTRA_TITLE " + - "property of the wrapped EXTRA_INTENT." - ) - null to R.string.chooseActivity - } else { - val custom = optional(value(EXTRA_TITLE)) - custom to (custom?.let { 0 } ?: R.string.chooseActivity) - } - - val initialIntents = - optional(array(EXTRA_INITIAL_INTENTS))?.take(MAX_INITIAL_INTENTS)?.map { - it.maybeAddSendActionFlags() - } - ?: emptyList() - - val chosenComponentSender = - optional(value(EXTRA_CHOOSER_RESULT_INTENT_SENDER)) - ?: optional(value(EXTRA_CHOSEN_COMPONENT_INTENT_SENDER)) - - val refinementIntentSender = - optional(value(EXTRA_CHOOSER_REFINEMENT_INTENT_SENDER)) - - val filteredComponents = - optional(array(EXTRA_EXCLUDE_COMPONENTS)) ?: emptyList() - - @Suppress("DEPRECATION") - val callerChooserTargets = - optional(array(EXTRA_CHOOSER_TARGETS)) ?: emptyList() - - val retainInOnStop = - optional(value(ChooserActivity.EXTRA_PRIVATE_RETAIN_IN_ON_STOP)) ?: false - - val sharedText = optional(value(EXTRA_TEXT)) - - val chooserActions = readChooserActions() ?: emptyList() - - val modifyShareAction = optional(value(EXTRA_CHOOSER_MODIFY_SHARE_ACTION)) - - val additionalContentUri: Uri? - val focusedItemPos: Int - if (isSendAction && flags.chooserPayloadToggling()) { - additionalContentUri = optional(value(Intent.EXTRA_CHOOSER_ADDITIONAL_CONTENT_URI)) - focusedItemPos = optional(value(Intent.EXTRA_CHOOSER_FOCUSED_ITEM_POSITION)) ?: 0 - } else { - additionalContentUri = null - focusedItemPos = 0 - } - - val contentTypeHint = - if (flags.chooserAlbumText()) { - when (optional(value(Intent.EXTRA_CHOOSER_CONTENT_TYPE_HINT))) { - Intent.CHOOSER_CONTENT_TYPE_ALBUM -> ContentTypeHint.ALBUM - else -> ContentTypeHint.NONE - } - } else { - ContentTypeHint.NONE - } - - val metadataText = - if (flags.enableSharesheetMetadataExtra()) { - optional(value(EXTRA_METADATA_TEXT)) - } else { - null - } - - ChooserRequest( - targetIntent = targetIntent, - targetAction = targetIntent.action, - isSendActionTarget = isSendAction, - targetType = targetIntent.type, - launchedFromPackage = - requireNotNull(model.launchedFromPackage) { - "launch.fromPackage was null, See Activity.getLaunchedFromPackage()" - }, - title = customTitle, - defaultTitleResource = defaultTitleResource, - referrer = model.referrer, - filteredComponentNames = filteredComponents, - callerChooserTargets = callerChooserTargets, - chooserActions = chooserActions, - modifyShareAction = modifyShareAction, - shouldRetainInOnStop = retainInOnStop, - additionalTargets = additionalTargets, - replacementExtras = replacementExtras, - initialIntents = initialIntents, - chosenComponentSender = chosenComponentSender, - refinementIntentSender = refinementIntentSender, - sharedText = sharedText, - shareTargetFilter = targetIntent.toShareTargetFilter(), - additionalContentUri = additionalContentUri, - focusedItemPosition = focusedItemPos, - contentTypeHint = contentTypeHint, - metadataText = metadataText, - ) - } -} - -fun Validation.readAlternateIntents(): List? = - optional(array(EXTRA_ALTERNATE_INTENTS))?.map { it.maybeAddSendActionFlags() } - -fun Validation.readChooserActions(): List? = - optional(array(EXTRA_CHOOSER_CUSTOM_ACTIONS)) - ?.filter { hasValidIcon(it) } - ?.take(MAX_CHOOSER_ACTIONS) - -private fun Intent.toShareTargetFilter(): IntentFilter? { - return type?.let { - IntentFilter().apply { - action?.also { addAction(it) } - addDataType(it) - } - } -} diff --git a/java/src/com/android/intentresolver/v2/ui/viewmodel/ChooserViewModel.kt b/java/src/com/android/intentresolver/v2/ui/viewmodel/ChooserViewModel.kt deleted file mode 100644 index e39329b1..00000000 --- a/java/src/com/android/intentresolver/v2/ui/viewmodel/ChooserViewModel.kt +++ /dev/null @@ -1,94 +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.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.validation.Invalid -import com.android.intentresolver.v2.validation.Valid -import com.android.intentresolver.v2.validation.ValidationResult -import dagger.Lazy -import dagger.hilt.android.lifecycle.HiltViewModel -import javax.inject.Inject -import kotlinx.coroutines.CoroutineDispatcher -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.launch - -private const val TAG = "ChooserViewModel" - -@HiltViewModel -class ChooserViewModel -@Inject -constructor( - args: SavedStateHandle, - private val shareouselViewModelProvider: Lazy, - private val processUpdatesInteractor: Lazy, - private val fetchPreviewsInteractor: Lazy, - @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, - private val chooserRequestRepository: Lazy, -) : ViewModel() { - - /** Parcelable-only references provided from the creating Activity */ - val activityModel: ActivityModel = - requireNotNull(args[ACTIVITY_MODEL_KEY]) { - "ActivityModel missing in SavedStateHandle! ($ACTIVITY_MODEL_KEY)" - } - - val shareouselViewModel: ShareouselViewModel by lazy { - // TODO: consolidate this logic, this would require a consolidated preview view model but - // for now just postpone starting the payload selection preview machinery until it's needed - assert(flags.chooserPayloadToggling()) { - "An attempt to use payload selection preview with the disabled flag" - } - - viewModelScope.launch(bgDispatcher) { processUpdatesInteractor.get().activate() } - viewModelScope.launch(bgDispatcher) { fetchPreviewsInteractor.get().activate() } - shareouselViewModelProvider.get() - } - - /** - * A [StateFlow] of [ChooserRequest]. - * - * Note: Only safe to access after checking if [initialRequest] is [Valid]. - */ - val request: StateFlow - get() = chooserRequestRepository.get().chooserRequest.asStateFlow() - - init { - if (initialRequest is Invalid) { - Log.w(TAG, "initialRequest is Invalid, initialization failed") - } - } -} diff --git a/java/src/com/android/intentresolver/v2/ui/viewmodel/ResolverRequestReader.kt b/java/src/com/android/intentresolver/v2/ui/viewmodel/ResolverRequestReader.kt deleted file mode 100644 index bbc376ea..00000000 --- a/java/src/com/android/intentresolver/v2/ui/viewmodel/ResolverRequestReader.kt +++ /dev/null @@ -1,59 +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.v2.ui.viewmodel - -import android.os.Bundle -import android.os.UserHandle -import com.android.intentresolver.v2.ResolverActivity.PROFILE_PERSONAL -import com.android.intentresolver.v2.ResolverActivity.PROFILE_WORK -import com.android.intentresolver.v2.shared.model.Profile -import com.android.intentresolver.v2.ui.model.ActivityModel -import com.android.intentresolver.v2.ui.model.ResolverRequest -import com.android.intentresolver.v2.validation.Validation -import com.android.intentresolver.v2.validation.ValidationResult -import com.android.intentresolver.v2.validation.types.value -import com.android.intentresolver.v2.validation.validateFrom - -const val EXTRA_CALLING_USER = "com.android.internal.app.ResolverActivity.EXTRA_CALLING_USER" -const val EXTRA_SELECTED_PROFILE = - "com.android.internal.app.ResolverActivity.EXTRA_SELECTED_PROFILE" -const val EXTRA_IS_AUDIO_CAPTURE_DEVICE = "is_audio_capture_device" - -fun readResolverRequest(launch: ActivityModel): ValidationResult { - @Suppress("DEPRECATION") - return validateFrom((launch.intent.extras ?: Bundle())::get) { - val callingUser = optional(value(EXTRA_CALLING_USER)) - val selectedProfile = checkSelectedProfile() - val audioDevice = optional(value(EXTRA_IS_AUDIO_CAPTURE_DEVICE)) ?: false - ResolverRequest(launch.intent, selectedProfile, callingUser, audioDevice) - } -} - -private fun Validation.checkSelectedProfile(): Profile.Type? { - return when (val selected = optional(value(EXTRA_SELECTED_PROFILE))) { - null -> null - PROFILE_PERSONAL -> Profile.Type.PERSONAL - PROFILE_WORK -> Profile.Type.WORK - else -> - error( - EXTRA_SELECTED_PROFILE + - " has invalid value ($selected)." + - " Must be either ResolverActivity.PROFILE_PERSONAL ($PROFILE_PERSONAL)" + - " or ResolverActivity.PROFILE_WORK ($PROFILE_WORK)." - ) - } -} diff --git a/java/src/com/android/intentresolver/v2/ui/viewmodel/ResolverViewModel.kt b/java/src/com/android/intentresolver/v2/ui/viewmodel/ResolverViewModel.kt deleted file mode 100644 index eb6a1b96..00000000 --- a/java/src/com/android/intentresolver/v2/ui/viewmodel/ResolverViewModel.kt +++ /dev/null @@ -1,70 +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.v2.ui.viewmodel - -import android.util.Log -import androidx.lifecycle.SavedStateHandle -import androidx.lifecycle.ViewModel -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.ResolverRequest -import com.android.intentresolver.v2.validation.Invalid -import com.android.intentresolver.v2.validation.Valid -import dagger.hilt.android.lifecycle.HiltViewModel -import javax.inject.Inject -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.asStateFlow - -private const val TAG = "ResolverViewModel" - -@HiltViewModel -class ResolverViewModel @Inject constructor(args: SavedStateHandle) : ViewModel() { - - /** Parcelable-only references provided from the creating Activity */ - val activityModel: ActivityModel = - requireNotNull(args[ACTIVITY_MODEL_KEY]) { - "ActivityModel missing in SavedStateHandle! ($ACTIVITY_MODEL_KEY)" - } - - /** - * Provided only for the express purpose of early exit in the event of an invalid request. - * - * Note: [request] can only be safely accessed after checking if this value is [Valid]. - */ - internal val initialRequest = readResolverRequest(activityModel) - - private lateinit var _request: MutableStateFlow - - /** - * A [StateFlow] of [ResolverRequest]. - * - * Note: Only safe to access after checking if [initialRequest] is [Valid]. - */ - lateinit var request: StateFlow - private set - - init { - when (initialRequest) { - is Valid -> { - _request = MutableStateFlow(initialRequest.value) - request = _request.asStateFlow() - } - is Invalid -> Log.w(TAG, "initialRequest is Invalid, initialization failed") - } - } -} diff --git a/java/src/com/android/intentresolver/v2/util/MutableLazy.kt b/java/src/com/android/intentresolver/v2/util/MutableLazy.kt deleted file mode 100644 index 4ce9b7fd..00000000 --- a/java/src/com/android/intentresolver/v2/util/MutableLazy.kt +++ /dev/null @@ -1,36 +0,0 @@ -package com.android.intentresolver.v2.util - -import java.util.concurrent.atomic.AtomicReference -import kotlin.reflect.KProperty - -/** A lazy delegate that can be changed to a new lazy or null at any time. */ -class MutableLazy(initializer: () -> T?) : Lazy { - - override val value: T? - get() = lazy.get()?.value - - private var lazy: AtomicReference?> = AtomicReference(lazy(initializer)) - - override fun isInitialized(): Boolean = lazy.get()?.isInitialized() != false - - operator fun getValue(thisRef: Any?, property: KProperty<*>): T? = - lazy.get()?.getValue(thisRef, property) - - /** Replace the existing lazy logic with the [newLazy] */ - fun setLazy(newLazy: Lazy?) { - lazy.set(newLazy) - } - - /** Replace the existing lazy logic with a [Lazy] created from the [newInitializer]. */ - fun setLazy(newInitializer: () -> T?) { - lazy.set(lazy(newInitializer)) - } - - /** Set the lazy logic to null. */ - fun clear() { - lazy.set(null) - } -} - -/** Constructs a [MutableLazy] using the given [initializer] */ -fun mutableLazy(initializer: () -> T?) = MutableLazy(initializer) diff --git a/java/src/com/android/intentresolver/v2/validation/Findings.kt b/java/src/com/android/intentresolver/v2/validation/Findings.kt deleted file mode 100644 index bdf2f00a..00000000 --- a/java/src/com/android/intentresolver/v2/validation/Findings.kt +++ /dev/null @@ -1,120 +0,0 @@ -/* - * Copyright (C) 2023 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package com.android.intentresolver.v2.validation - -import android.util.Log -import com.android.intentresolver.v2.validation.Importance.CRITICAL -import com.android.intentresolver.v2.validation.Importance.WARNING -import kotlin.reflect.KClass - -sealed interface Finding { - val importance: Importance - val message: String -} - -enum class Importance { - CRITICAL, - WARNING, -} - -val Finding.logcatPriority - get() = - when (importance) { - CRITICAL -> Log.ERROR - WARNING -> Log.WARN - } - -fun Finding.log(tag: String) { - Log.println(logcatPriority, tag, message) -} - -private fun formatMessage(key: String? = null, msg: String) = buildString { - key?.also { append("['$key']: ") } - append(msg) -} - -data class IgnoredValue( - val key: String, - val reason: String, -) : Finding { - override val importance = WARNING - - override val message: String - get() = formatMessage(key, "Ignored. $reason") -} - -data class NoValue( - val key: String, - override val importance: Importance, - val allowedType: KClass<*>, -) : Finding { - - override val message: String - get() = - formatMessage( - key, - if (importance == CRITICAL) { - "expected value of ${allowedType.simpleName}, " + "but no value was present" - } else { - "no ${allowedType.simpleName} value present" - } - ) -} - -data class WrongElementType( - val key: String, - override val importance: Importance, - val container: KClass<*>, - val actualType: KClass<*>, - val expectedType: KClass<*> -) : Finding { - override val message: String - get() = - formatMessage( - key, - "${container.simpleName} expected with elements of " + - "${expectedType.simpleName} " + - "but found ${actualType.simpleName} values instead" - ) -} - -data class ValueIsWrongType( - val key: String, - override val importance: Importance, - val actualType: KClass<*>, - val allowedTypes: List>, -) : Finding { - - override val message: String - get() = - formatMessage( - key, - "expected value of ${allowedTypes.map(KClass<*>::simpleName)} " + - "but was ${actualType.simpleName}" - ) -} - -data class UncaughtException(val thrown: Throwable, val key: String? = null) : Finding { - override val importance: Importance - get() = CRITICAL - override val message: String - get() = - formatMessage( - key, - "An unhandled exception was caught during validation: " + - thrown.stackTraceToString() - ) -} diff --git a/java/src/com/android/intentresolver/v2/validation/Validation.kt b/java/src/com/android/intentresolver/v2/validation/Validation.kt deleted file mode 100644 index 6072ec9f..00000000 --- a/java/src/com/android/intentresolver/v2/validation/Validation.kt +++ /dev/null @@ -1,137 +0,0 @@ -/* - * Copyright (C) 2023 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package com.android.intentresolver.v2.validation - -import com.android.intentresolver.v2.validation.Importance.CRITICAL -import com.android.intentresolver.v2.validation.Importance.WARNING - -/** - * Provides a mechanism for validating a result from a set of properties. - * - * The results of validation are provided as [findings]. - */ -interface Validation { - val findings: List - - /** - * Require a valid property. - * - * If [property] is not valid, this [Validation] will be immediately completed as [Invalid]. - * - * @param property the required property - * @return a valid **T** - */ - @Throws(InvalidResultError::class) fun required(property: Validator): T - - /** - * Request an optional value for a property. - * - * If [property] is not valid, this [Validation] will be immediately completed as [Invalid]. - * - * @param property the required property - * @return a valid **T** - */ - fun optional(property: Validator): T? - - /** - * Report a property as __ignored__. - * - * The presence of any value will report a warning citing [reason]. - */ - fun ignored(property: Validator, reason: String) -} - -/** Performs validation for a specific key -> value pair. */ -interface Validator { - val key: String - - /** - * Performs validation on a specific value from [source]. - * - * @param source a source for reading the property value. Values are intentionally untyped - * (Any?) to avoid upstream code from making type assertions through type inference. Types are - * asserted later using a [Validator]. - * @param importance the importance of any findings - */ - fun validate(source: (String) -> Any?, importance: Importance): ValidationResult -} - -internal class InvalidResultError internal constructor() : Error() - -/** - * Perform a number of validations on the source, assembling and returning a Result. - * - * When an exception is thrown by [validate], it is caught here. In response, a failed - * [ValidationResult] is returned containing a [CRITICAL] [Finding] for the exception. - * - * @param validate perform validations and return a [ValidationResult] - */ -fun validateFrom(source: (String) -> Any?, validate: Validation.() -> T): ValidationResult { - val validation = ValidationImpl(source) - return runCatching { validate(validation) } - .fold( - onSuccess = { result -> Valid(result, validation.findings) }, - onFailure = { - when (it) { - // A validator has interrupted validation. Return the findings. - is InvalidResultError -> Invalid(validation.findings) - - // Some other exception was thrown from [validate], - else -> Invalid(error = UncaughtException(it)) - } - } - ) -} - -private class ValidationImpl(val source: (String) -> Any?) : Validation { - override val findings = mutableListOf() - - override fun optional(property: Validator): T? = validate(property, WARNING) - - override fun required(property: Validator): T { - return validate(property, CRITICAL) ?: throw InvalidResultError() - } - - override fun ignored(property: Validator, reason: String) { - val result = property.validate(source, WARNING) - if (result is Valid) { - // Note: Any warnings about the value itself (result.findings) are ignored. - findings += IgnoredValue(property.key, reason) - } - } - - private fun validate(property: Validator, importance: Importance): T? { - return runCatching { property.validate(source, importance) } - .fold( - onSuccess = { result -> - return when (result) { - is Valid -> { - findings += result.warnings - result.value - } - is Invalid -> { - findings += result.errors - null - } - } - }, - onFailure = { - findings += UncaughtException(it, property.key) - null - } - ) - } -} diff --git a/java/src/com/android/intentresolver/v2/validation/ValidationResult.kt b/java/src/com/android/intentresolver/v2/validation/ValidationResult.kt deleted file mode 100644 index f5c467dc..00000000 --- a/java/src/com/android/intentresolver/v2/validation/ValidationResult.kt +++ /dev/null @@ -1,26 +0,0 @@ -/* - * Copyright (C) 2023 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package com.android.intentresolver.v2.validation - -sealed interface ValidationResult - -data class Valid(val value: T, val warnings: List = emptyList()) : ValidationResult { - constructor(value: T, warning: Finding) : this(value, listOf(warning)) -} - -data class Invalid(val errors: List = emptyList()) : ValidationResult { - constructor(error: Finding) : this(listOf(error)) -} diff --git a/java/src/com/android/intentresolver/v2/validation/types/IntentOrUri.kt b/java/src/com/android/intentresolver/v2/validation/types/IntentOrUri.kt deleted file mode 100644 index fc51ba1e..00000000 --- a/java/src/com/android/intentresolver/v2/validation/types/IntentOrUri.kt +++ /dev/null @@ -1,62 +0,0 @@ -/* - * Copyright (C) 2023 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package com.android.intentresolver.v2.validation.types - -import android.content.Intent -import android.net.Uri -import com.android.intentresolver.v2.validation.Importance -import com.android.intentresolver.v2.validation.Invalid -import com.android.intentresolver.v2.validation.NoValue -import com.android.intentresolver.v2.validation.Valid -import com.android.intentresolver.v2.validation.ValidationResult -import com.android.intentresolver.v2.validation.Validator -import com.android.intentresolver.v2.validation.ValueIsWrongType - -class IntentOrUri(override val key: String) : Validator { - - override fun validate( - source: (String) -> Any?, - importance: Importance - ): ValidationResult { - return when (val value = source(key)) { - // An intent, return it. - is Intent -> Valid(value) - - // A Uri was supplied. - // Unfortunately, converting Uri -> Intent requires a toString(). - is Uri -> Valid(Intent.parseUri(value.toString(), Intent.URI_INTENT_SCHEME)) - - // No value present. - null -> - when (importance) { - Importance.WARNING -> Invalid() // No warnings if optional, but missing - Importance.CRITICAL -> Invalid(NoValue(key, importance, Intent::class)) - } - - // Some other type. - else -> { - return Invalid( - ValueIsWrongType( - key, - importance, - actualType = value::class, - allowedTypes = listOf(Intent::class, Uri::class) - ) - ) - } - } - } -} diff --git a/java/src/com/android/intentresolver/v2/validation/types/ParceledArray.kt b/java/src/com/android/intentresolver/v2/validation/types/ParceledArray.kt deleted file mode 100644 index b68d972f..00000000 --- a/java/src/com/android/intentresolver/v2/validation/types/ParceledArray.kt +++ /dev/null @@ -1,84 +0,0 @@ -/* - * Copyright (C) 2023 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package com.android.intentresolver.v2.validation.types - -import com.android.intentresolver.v2.validation.Importance -import com.android.intentresolver.v2.validation.Invalid -import com.android.intentresolver.v2.validation.NoValue -import com.android.intentresolver.v2.validation.Valid -import com.android.intentresolver.v2.validation.ValidationResult -import com.android.intentresolver.v2.validation.Validator -import com.android.intentresolver.v2.validation.ValueIsWrongType -import com.android.intentresolver.v2.validation.WrongElementType -import kotlin.reflect.KClass -import kotlin.reflect.cast - -class ParceledArray( - override val key: String, - private val elementType: KClass, -) : Validator> { - - override fun validate( - source: (String) -> Any?, - importance: Importance - ): ValidationResult> { - return when (val value: Any? = source(key)) { - // No value present. - null -> - when (importance) { - Importance.WARNING -> Invalid() // No warnings if optional, but missing - Importance.CRITICAL -> Invalid(NoValue(key, importance, elementType)) - } - // A parcel does not transfer the element type information for parcelable - // arrays. This leads to a restored type of Array, which is - // incompatible with Array. - - // To handle this safely, treat as Array<*>, assert contents of the expected - // parcelable type, and return as a list. - - is Array<*> -> { - val invalid = value.filterNotNull().firstOrNull { !elementType.isInstance(it) } - when (invalid) { - // No invalid elements, result is ok. - null -> Valid(value.map { elementType.cast(it) }) - - // At least one incorrect element type found. - else -> - Invalid( - WrongElementType( - key, - importance, - actualType = invalid::class, - container = Array::class, - expectedType = elementType - ) - ) - } - } - - // The value is not an Array at all. - else -> - Invalid( - ValueIsWrongType( - key, - importance, - actualType = value::class, - allowedTypes = listOf(elementType) - ) - ) - } - } -} diff --git a/java/src/com/android/intentresolver/v2/validation/types/SimpleValue.kt b/java/src/com/android/intentresolver/v2/validation/types/SimpleValue.kt deleted file mode 100644 index 0badebc4..00000000 --- a/java/src/com/android/intentresolver/v2/validation/types/SimpleValue.kt +++ /dev/null @@ -1,60 +0,0 @@ -/* - * Copyright (C) 2023 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package com.android.intentresolver.v2.validation.types - -import com.android.intentresolver.v2.validation.Importance -import com.android.intentresolver.v2.validation.Invalid -import com.android.intentresolver.v2.validation.NoValue -import com.android.intentresolver.v2.validation.Valid -import com.android.intentresolver.v2.validation.ValidationResult -import com.android.intentresolver.v2.validation.Validator -import com.android.intentresolver.v2.validation.ValueIsWrongType -import kotlin.reflect.KClass -import kotlin.reflect.cast - -class SimpleValue( - override val key: String, - private val expected: KClass, -) : Validator { - - override fun validate(source: (String) -> Any?, importance: Importance): ValidationResult { - val value: Any? = source(key) - return when { - // The value is present and of the expected type. - expected.isInstance(value) -> return Valid(expected.cast(value)) - - // No value is present. - value == null -> - when (importance) { - Importance.WARNING -> Invalid() // No warnings if optional, but missing - Importance.CRITICAL -> Invalid(NoValue(key, importance, expected)) - } - - // The value is some other type. - else -> - Invalid( - listOf( - ValueIsWrongType( - key, - importance, - actualType = value::class, - allowedTypes = listOf(expected) - ) - ) - ) - } - } -} diff --git a/java/src/com/android/intentresolver/v2/validation/types/Validators.kt b/java/src/com/android/intentresolver/v2/validation/types/Validators.kt deleted file mode 100644 index 70993b4d..00000000 --- a/java/src/com/android/intentresolver/v2/validation/types/Validators.kt +++ /dev/null @@ -1,26 +0,0 @@ -/* - * Copyright (C) 2023 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package com.android.intentresolver.v2.validation.types - -import com.android.intentresolver.v2.validation.Validator - -inline fun value(key: String): Validator { - return SimpleValue(key, T::class) -} - -inline fun array(key: String): Validator> { - return ParceledArray(key, T::class) -} diff --git a/java/src/com/android/intentresolver/validation/Findings.kt b/java/src/com/android/intentresolver/validation/Findings.kt new file mode 100644 index 00000000..0d62017f --- /dev/null +++ b/java/src/com/android/intentresolver/validation/Findings.kt @@ -0,0 +1,120 @@ +/* + * Copyright (C) 2023 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.android.intentresolver.validation + +import android.util.Log +import com.android.intentresolver.validation.Importance.CRITICAL +import com.android.intentresolver.validation.Importance.WARNING +import kotlin.reflect.KClass + +sealed interface Finding { + val importance: Importance + val message: String +} + +enum class Importance { + CRITICAL, + WARNING, +} + +val Finding.logcatPriority + get() = + when (importance) { + CRITICAL -> Log.ERROR + WARNING -> Log.WARN + } + +fun Finding.log(tag: String) { + Log.println(logcatPriority, tag, message) +} + +private fun formatMessage(key: String? = null, msg: String) = buildString { + key?.also { append("['$key']: ") } + append(msg) +} + +data class IgnoredValue( + val key: String, + val reason: String, +) : Finding { + override val importance = WARNING + + override val message: String + get() = formatMessage(key, "Ignored. $reason") +} + +data class NoValue( + val key: String, + override val importance: Importance, + val allowedType: KClass<*>, +) : Finding { + + override val message: String + get() = + formatMessage( + key, + if (importance == CRITICAL) { + "expected value of ${allowedType.simpleName}, " + "but no value was present" + } else { + "no ${allowedType.simpleName} value present" + } + ) +} + +data class WrongElementType( + val key: String, + override val importance: Importance, + val container: KClass<*>, + val actualType: KClass<*>, + val expectedType: KClass<*> +) : Finding { + override val message: String + get() = + formatMessage( + key, + "${container.simpleName} expected with elements of " + + "${expectedType.simpleName} " + + "but found ${actualType.simpleName} values instead" + ) +} + +data class ValueIsWrongType( + val key: String, + override val importance: Importance, + val actualType: KClass<*>, + val allowedTypes: List>, +) : Finding { + + override val message: String + get() = + formatMessage( + key, + "expected value of ${allowedTypes.map(KClass<*>::simpleName)} " + + "but was ${actualType.simpleName}" + ) +} + +data class UncaughtException(val thrown: Throwable, val key: String? = null) : Finding { + override val importance: Importance + get() = CRITICAL + override val message: String + get() = + formatMessage( + key, + "An unhandled exception was caught during validation: " + + thrown.stackTraceToString() + ) +} diff --git a/java/src/com/android/intentresolver/validation/Validation.kt b/java/src/com/android/intentresolver/validation/Validation.kt new file mode 100644 index 00000000..6ba62e57 --- /dev/null +++ b/java/src/com/android/intentresolver/validation/Validation.kt @@ -0,0 +1,137 @@ +/* + * Copyright (C) 2023 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.android.intentresolver.validation + +import com.android.intentresolver.validation.Importance.CRITICAL +import com.android.intentresolver.validation.Importance.WARNING + +/** + * Provides a mechanism for validating a result from a set of properties. + * + * The results of validation are provided as [findings]. + */ +interface Validation { + val findings: List + + /** + * Require a valid property. + * + * If [property] is not valid, this [Validation] will be immediately completed as [Invalid]. + * + * @param property the required property + * @return a valid **T** + */ + @Throws(InvalidResultError::class) fun required(property: Validator): T + + /** + * Request an optional value for a property. + * + * If [property] is not valid, this [Validation] will be immediately completed as [Invalid]. + * + * @param property the required property + * @return a valid **T** + */ + fun optional(property: Validator): T? + + /** + * Report a property as __ignored__. + * + * The presence of any value will report a warning citing [reason]. + */ + fun ignored(property: Validator, reason: String) +} + +/** Performs validation for a specific key -> value pair. */ +interface Validator { + val key: String + + /** + * Performs validation on a specific value from [source]. + * + * @param source a source for reading the property value. Values are intentionally untyped + * (Any?) to avoid upstream code from making type assertions through type inference. Types are + * asserted later using a [Validator]. + * @param importance the importance of any findings + */ + fun validate(source: (String) -> Any?, importance: Importance): ValidationResult +} + +internal class InvalidResultError internal constructor() : Error() + +/** + * Perform a number of validations on the source, assembling and returning a Result. + * + * When an exception is thrown by [validate], it is caught here. In response, a failed + * [ValidationResult] is returned containing a [CRITICAL] [Finding] for the exception. + * + * @param validate perform validations and return a [ValidationResult] + */ +fun validateFrom(source: (String) -> Any?, validate: Validation.() -> T): ValidationResult { + val validation = ValidationImpl(source) + return runCatching { validate(validation) } + .fold( + onSuccess = { result -> Valid(result, validation.findings) }, + onFailure = { + when (it) { + // A validator has interrupted validation. Return the findings. + is InvalidResultError -> Invalid(validation.findings) + + // Some other exception was thrown from [validate], + else -> Invalid(error = UncaughtException(it)) + } + } + ) +} + +private class ValidationImpl(val source: (String) -> Any?) : Validation { + override val findings = mutableListOf() + + override fun optional(property: Validator): T? = validate(property, WARNING) + + override fun required(property: Validator): T { + return validate(property, CRITICAL) ?: throw InvalidResultError() + } + + override fun ignored(property: Validator, reason: String) { + val result = property.validate(source, WARNING) + if (result is Valid) { + // Note: Any warnings about the value itself (result.findings) are ignored. + findings += IgnoredValue(property.key, reason) + } + } + + private fun validate(property: Validator, importance: Importance): T? { + return runCatching { property.validate(source, importance) } + .fold( + onSuccess = { result -> + return when (result) { + is Valid -> { + findings += result.warnings + result.value + } + is Invalid -> { + findings += result.errors + null + } + } + }, + onFailure = { + findings += UncaughtException(it, property.key) + null + } + ) + } +} diff --git a/java/src/com/android/intentresolver/validation/ValidationResult.kt b/java/src/com/android/intentresolver/validation/ValidationResult.kt new file mode 100644 index 00000000..9685c70d --- /dev/null +++ b/java/src/com/android/intentresolver/validation/ValidationResult.kt @@ -0,0 +1,26 @@ +/* + * Copyright (C) 2023 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.android.intentresolver.validation + +sealed interface ValidationResult + +data class Valid(val value: T, val warnings: List = emptyList()) : ValidationResult { + constructor(value: T, warning: Finding) : this(value, listOf(warning)) +} + +data class Invalid(val errors: List = emptyList()) : ValidationResult { + constructor(error: Finding) : this(listOf(error)) +} diff --git a/java/src/com/android/intentresolver/validation/types/IntentOrUri.kt b/java/src/com/android/intentresolver/validation/types/IntentOrUri.kt new file mode 100644 index 00000000..74c48a23 --- /dev/null +++ b/java/src/com/android/intentresolver/validation/types/IntentOrUri.kt @@ -0,0 +1,62 @@ +/* + * Copyright (C) 2023 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.android.intentresolver.validation.types + +import android.content.Intent +import android.net.Uri +import com.android.intentresolver.validation.Importance +import com.android.intentresolver.validation.Invalid +import com.android.intentresolver.validation.NoValue +import com.android.intentresolver.validation.Valid +import com.android.intentresolver.validation.ValidationResult +import com.android.intentresolver.validation.Validator +import com.android.intentresolver.validation.ValueIsWrongType + +class IntentOrUri(override val key: String) : Validator { + + override fun validate( + source: (String) -> Any?, + importance: Importance + ): ValidationResult { + return when (val value = source(key)) { + // An intent, return it. + is Intent -> Valid(value) + + // A Uri was supplied. + // Unfortunately, converting Uri -> Intent requires a toString(). + is Uri -> Valid(Intent.parseUri(value.toString(), Intent.URI_INTENT_SCHEME)) + + // No value present. + null -> + when (importance) { + Importance.WARNING -> Invalid() // No warnings if optional, but missing + Importance.CRITICAL -> Invalid(NoValue(key, importance, Intent::class)) + } + + // Some other type. + else -> { + return Invalid( + ValueIsWrongType( + key, + importance, + actualType = value::class, + allowedTypes = listOf(Intent::class, Uri::class) + ) + ) + } + } + } +} diff --git a/java/src/com/android/intentresolver/validation/types/ParceledArray.kt b/java/src/com/android/intentresolver/validation/types/ParceledArray.kt new file mode 100644 index 00000000..5150ec5e --- /dev/null +++ b/java/src/com/android/intentresolver/validation/types/ParceledArray.kt @@ -0,0 +1,84 @@ +/* + * Copyright (C) 2023 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.android.intentresolver.validation.types + +import com.android.intentresolver.validation.Importance +import com.android.intentresolver.validation.Invalid +import com.android.intentresolver.validation.NoValue +import com.android.intentresolver.validation.Valid +import com.android.intentresolver.validation.ValidationResult +import com.android.intentresolver.validation.Validator +import com.android.intentresolver.validation.ValueIsWrongType +import com.android.intentresolver.validation.WrongElementType +import kotlin.reflect.KClass +import kotlin.reflect.cast + +class ParceledArray( + override val key: String, + private val elementType: KClass, +) : Validator> { + + override fun validate( + source: (String) -> Any?, + importance: Importance + ): ValidationResult> { + return when (val value: Any? = source(key)) { + // No value present. + null -> + when (importance) { + Importance.WARNING -> Invalid() // No warnings if optional, but missing + Importance.CRITICAL -> Invalid(NoValue(key, importance, elementType)) + } + // A parcel does not transfer the element type information for parcelable + // arrays. This leads to a restored type of Array, which is + // incompatible with Array. + + // To handle this safely, treat as Array<*>, assert contents of the expected + // parcelable type, and return as a list. + + is Array<*> -> { + val invalid = value.filterNotNull().firstOrNull { !elementType.isInstance(it) } + when (invalid) { + // No invalid elements, result is ok. + null -> Valid(value.map { elementType.cast(it) }) + + // At least one incorrect element type found. + else -> + Invalid( + WrongElementType( + key, + importance, + actualType = invalid::class, + container = Array::class, + expectedType = elementType + ) + ) + } + } + + // The value is not an Array at all. + else -> + Invalid( + ValueIsWrongType( + key, + importance, + actualType = value::class, + allowedTypes = listOf(elementType) + ) + ) + } + } +} diff --git a/java/src/com/android/intentresolver/validation/types/SimpleValue.kt b/java/src/com/android/intentresolver/validation/types/SimpleValue.kt new file mode 100644 index 00000000..64299e11 --- /dev/null +++ b/java/src/com/android/intentresolver/validation/types/SimpleValue.kt @@ -0,0 +1,60 @@ +/* + * Copyright (C) 2023 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.android.intentresolver.validation.types + +import com.android.intentresolver.validation.Importance +import com.android.intentresolver.validation.Invalid +import com.android.intentresolver.validation.NoValue +import com.android.intentresolver.validation.Valid +import com.android.intentresolver.validation.ValidationResult +import com.android.intentresolver.validation.Validator +import com.android.intentresolver.validation.ValueIsWrongType +import kotlin.reflect.KClass +import kotlin.reflect.cast + +class SimpleValue( + override val key: String, + private val expected: KClass, +) : Validator { + + override fun validate(source: (String) -> Any?, importance: Importance): ValidationResult { + val value: Any? = source(key) + return when { + // The value is present and of the expected type. + expected.isInstance(value) -> return Valid(expected.cast(value)) + + // No value is present. + value == null -> + when (importance) { + Importance.WARNING -> Invalid() // No warnings if optional, but missing + Importance.CRITICAL -> Invalid(NoValue(key, importance, expected)) + } + + // The value is some other type. + else -> + Invalid( + listOf( + ValueIsWrongType( + key, + importance, + actualType = value::class, + allowedTypes = listOf(expected) + ) + ) + ) + } + } +} diff --git a/java/src/com/android/intentresolver/validation/types/Validators.kt b/java/src/com/android/intentresolver/validation/types/Validators.kt new file mode 100644 index 00000000..1049f045 --- /dev/null +++ b/java/src/com/android/intentresolver/validation/types/Validators.kt @@ -0,0 +1,26 @@ +/* + * Copyright (C) 2023 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.android.intentresolver.validation.types + +import com.android.intentresolver.validation.Validator + +inline fun value(key: String): Validator { + return SimpleValue(key, T::class) +} + +inline fun array(key: String): Validator> { + return ParceledArray(key, T::class) +} diff --git a/tests/activity/AndroidManifest.xml b/tests/activity/AndroidManifest.xml index be05e99e..00dbd78d 100644 --- a/tests/activity/AndroidManifest.xml +++ b/tests/activity/AndroidManifest.xml @@ -26,8 +26,8 @@ - - + + createPackageManager; public Function onSafelyStartInternalCallback; public Function onSafelyStartCallback; public Function2, ShortcutLoader> shortcutLoaderFactory = (userHandle, callback) -> null; - public ChooserActivity.ChooserListController resolverListController; - public ChooserActivity.ChooserListController workResolverListController; + public ChooserListController resolverListController; + public ChooserListController workResolverListController; public Boolean isVoiceInteraction; public Cursor resolverCursor; public boolean resolverForceException; public ImageLoader imageLoader; - public int alternateProfileSetting; public Resources resources; - public AnnotatedUserHandles annotatedUserHandles; public boolean hasCrossProfileIntents; public boolean isQuietModeEnabled; public Integer myUserId; - public WorkProfileAvailabilityManager mWorkProfileAvailability; public CrossProfileIntentsChecker mCrossProfileIntentsChecker; - public PackageManager packageManager; public void reset() { onSafelyStartInternalCallback = null; isVoiceInteraction = null; - createPackageManager = null; imageLoader = null; resolverCursor = null; resolverForceException = false; - resolverListController = mock(ChooserActivity.ChooserListController.class); - workResolverListController = mock(ChooserActivity.ChooserListController.class); - alternateProfileSetting = 0; + resolverListController = mock(ChooserListController.class); + workResolverListController = mock(ChooserListController.class); resources = null; - annotatedUserHandles = AnnotatedUserHandles.newBuilder() - .setUserIdOfCallingApp(1234) // Must be non-negative. - .setUserHandleSharesheetLaunchedAs(UserHandle.SYSTEM) - .setPersonalProfileUserHandle(UserHandle.SYSTEM) - .build(); hasCrossProfileIntents = true; isQuietModeEnabled = false; myUserId = null; - packageManager = null; - mWorkProfileAvailability = new WorkProfileAvailabilityManager(null, null, null) { - @Override - public boolean isQuietModeEnabled() { - return isQuietModeEnabled; - } - - @Override - public boolean isWorkProfileUserUnlocked() { - return true; - } - - @Override - public void requestQuietModeEnabled(boolean enabled) { - isQuietModeEnabled = enabled; - } - - @Override - public void markWorkProfileEnabledBroadcastReceived() {} - - @Override - public boolean isWaitingToEnableWorkProfile() { - return false; - } - }; shortcutLoaderFactory = ((userHandle, resultConsumer) -> null); - mCrossProfileIntentsChecker = mock(CrossProfileIntentsChecker.class); when(mCrossProfileIntentsChecker.hasCrossProfileIntents(any(), anyInt(), anyInt())) .thenAnswer(invocation -> hasCrossProfileIntents); diff --git a/tests/activity/src/com/android/intentresolver/ChooserActivityTest.java b/tests/activity/src/com/android/intentresolver/ChooserActivityTest.java new file mode 100644 index 00000000..cfbb1c0b --- /dev/null +++ b/tests/activity/src/com/android/intentresolver/ChooserActivityTest.java @@ -0,0 +1,3135 @@ +/* + * Copyright (C) 2016 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.intentresolver; + +import static android.app.Activity.RESULT_OK; + +import static androidx.test.espresso.Espresso.onView; +import static androidx.test.espresso.action.ViewActions.click; +import static androidx.test.espresso.action.ViewActions.longClick; +import static androidx.test.espresso.action.ViewActions.swipeUp; +import static androidx.test.espresso.assertion.ViewAssertions.doesNotExist; +import static androidx.test.espresso.assertion.ViewAssertions.matches; +import static androidx.test.espresso.matcher.ViewMatchers.hasSibling; +import static androidx.test.espresso.matcher.ViewMatchers.isCompletelyDisplayed; +import static androidx.test.espresso.matcher.ViewMatchers.isDisplayed; +import static androidx.test.espresso.matcher.ViewMatchers.withEffectiveVisibility; +import static androidx.test.espresso.matcher.ViewMatchers.withId; +import static androidx.test.espresso.matcher.ViewMatchers.withText; + +import static com.android.intentresolver.ChooserActivity.TARGET_TYPE_CHOOSER_TARGET; +import static com.android.intentresolver.ChooserActivity.TARGET_TYPE_DEFAULT; +import static com.android.intentresolver.ChooserActivity.TARGET_TYPE_SHORTCUTS_FROM_PREDICTION_SERVICE; +import static com.android.intentresolver.ChooserActivity.TARGET_TYPE_SHORTCUTS_FROM_SHORTCUT_MANAGER; +import static com.android.intentresolver.ChooserListAdapter.CALLER_TARGET_SCORE_BOOST; +import static com.android.intentresolver.ChooserListAdapter.SHORTCUT_TARGET_SCORE_BOOST; +import static com.android.intentresolver.MatcherUtils.first; + +import static com.google.common.truth.Truth.assertThat; +import static com.google.common.truth.Truth.assertWithMessage; + +import static junit.framework.Assert.assertNull; + +import static org.hamcrest.CoreMatchers.allOf; +import static org.hamcrest.CoreMatchers.is; +import static org.hamcrest.CoreMatchers.not; +import static org.hamcrest.MatcherAssert.assertThat; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.doReturn; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import android.app.PendingIntent; +import android.app.usage.UsageStatsManager; +import android.content.BroadcastReceiver; +import android.content.ClipData; +import android.content.ClipDescription; +import android.content.ClipboardManager; +import android.content.ComponentName; +import android.content.Context; +import android.content.Intent; +import android.content.IntentFilter; +import android.content.pm.ActivityInfo; +import android.content.pm.ApplicationInfo; +import android.content.pm.PackageManager; +import android.content.pm.ResolveInfo; +import android.content.pm.ShortcutInfo; +import android.content.pm.ShortcutManager.ShareShortcutInfo; +import android.content.res.Configuration; +import android.content.res.Resources; +import android.database.Cursor; +import android.graphics.Bitmap; +import android.graphics.Canvas; +import android.graphics.Color; +import android.graphics.Paint; +import android.graphics.Rect; +import android.graphics.Typeface; +import android.graphics.drawable.Icon; +import android.net.Uri; +import android.os.Bundle; +import android.os.UserHandle; +import android.platform.test.annotations.RequiresFlagsEnabled; +import android.platform.test.flag.junit.CheckFlagsRule; +import android.platform.test.flag.junit.DeviceFlagsValueProvider; +import android.provider.DeviceConfig; +import android.service.chooser.ChooserAction; +import android.service.chooser.ChooserTarget; +import android.text.Spannable; +import android.text.SpannableStringBuilder; +import android.text.Spanned; +import android.text.style.BackgroundColorSpan; +import android.text.style.ForegroundColorSpan; +import android.text.style.StyleSpan; +import android.text.style.UnderlineSpan; +import android.util.Pair; +import android.util.SparseArray; +import android.view.View; +import android.view.WindowManager; +import android.widget.TextView; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.recyclerview.widget.GridLayoutManager; +import androidx.recyclerview.widget.RecyclerView; +import androidx.test.espresso.contrib.RecyclerViewActions; +import androidx.test.espresso.matcher.BoundedDiagnosingMatcher; +import androidx.test.espresso.matcher.ViewMatchers; +import androidx.test.platform.app.InstrumentationRegistry; +import androidx.test.rule.ActivityTestRule; + +import com.android.intentresolver.chooser.DisplayResolveInfo; +import com.android.intentresolver.contentpreview.ImageLoader; +import com.android.intentresolver.contentpreview.ImageLoaderModule; +import com.android.intentresolver.data.repository.FakeUserRepository; +import com.android.intentresolver.data.repository.UserRepository; +import com.android.intentresolver.data.repository.UserRepositoryModule; +import com.android.intentresolver.ext.RecyclerViewExt; +import com.android.intentresolver.inject.ApplicationUser; +import com.android.intentresolver.inject.PackageManagerModule; +import com.android.intentresolver.inject.ProfileParent; +import com.android.intentresolver.logging.EventLog; +import com.android.intentresolver.logging.FakeEventLog; +import com.android.intentresolver.platform.AppPredictionAvailable; +import com.android.intentresolver.platform.AppPredictionModule; +import com.android.intentresolver.platform.ImageEditor; +import com.android.intentresolver.platform.ImageEditorModule; +import com.android.intentresolver.shared.model.User; +import com.android.intentresolver.shortcuts.ShortcutLoader; +import com.android.internal.config.sysui.SystemUiDeviceConfigFlags; + +import dagger.hilt.android.qualifiers.ApplicationContext; +import dagger.hilt.android.testing.BindValue; +import dagger.hilt.android.testing.HiltAndroidRule; +import dagger.hilt.android.testing.HiltAndroidTest; +import dagger.hilt.android.testing.UninstallModules; + +import org.hamcrest.Description; +import org.hamcrest.Matcher; +import org.hamcrest.Matchers; +import org.junit.Before; +import org.junit.Ignore; +import org.junit.Rule; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.junit.runners.Parameterized; +import org.junit.runners.Parameterized.Parameters; +import org.mockito.ArgumentCaptor; +import org.mockito.Mockito; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicReference; +import java.util.function.Consumer; + +import javax.inject.Inject; + +/** + * Instrumentation tests for ChooserActivity. + *

+ * Legacy test suite migrated from framework CoreTests. + */ +@SuppressWarnings("OptionalUsedAsFieldOrParameterType") +@RunWith(Parameterized.class) +@HiltAndroidTest +@UninstallModules({ + AppPredictionModule.class, + ImageEditorModule.class, + PackageManagerModule.class, + ImageLoaderModule.class, + UserRepositoryModule.class, +}) +public class ChooserActivityTest { + + private static FakeEventLog getEventLog(ChooserWrapperActivity activity) { + return (FakeEventLog) activity.mEventLog; + } + + 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( + /* appPredictionAvailable = */ true, + /* appPredictionAvailable = */ false + ); + } + + private static final String TEST_MIME_TYPE = "application/TestType"; + + private static final int CONTENT_PREVIEW_IMAGE = 1; + private static final int CONTENT_PREVIEW_FILE = 2; + private static final int CONTENT_PREVIEW_TEXT = 3; + + @Rule(order = 0) + public CheckFlagsRule mCheckFlagsRule = DeviceFlagsValueProvider.createCheckFlagsRule(); + + @Rule(order = 1) + public HiltAndroidRule mHiltAndroidRule = new HiltAndroidRule(this); + + @Rule(order = 2) + public ActivityTestRule mActivityRule = + new ActivityTestRule<>(ChooserWrapperActivity.class, false, false); + + @Inject + @ApplicationContext + Context mContext; + + /** An arbitrary pre-installed activity that handles this type of intent. */ + @BindValue + @ImageEditor + final Optional mImageEditor = Optional.ofNullable( + ComponentName.unflattenFromString("com.google.android.apps.messaging/" + + ".ui.conversationlist.ShareIntentActivity")); + + /** Whether an AppPredictionService is available for use. */ + @BindValue + @AppPredictionAvailable + final boolean mAppPredictionAvailable; + + @BindValue + PackageManager mPackageManager; + + /** "launchedAs" */ + @BindValue + @ApplicationUser + UserHandle mApplicationUser = PERSONAL_USER_HANDLE; + + @BindValue + @ProfileParent + UserHandle mProfileParent = PERSONAL_USER_HANDLE; + + private final FakeUserRepository mFakeUserRepo = new FakeUserRepository(List.of(PERSONAL_USER)); + + @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 + // permissions we require (which we'll read from the manifest at runtime). + InstrumentationRegistry + .getInstrumentation() + .getUiAutomation() + .adoptShellPermissionIdentity(); + + cleanOverrideData(); + + // Assign @Inject fields + mHiltAndroidRule.inject(); + + // Populate @BindValue dependencies using injected values. These fields contribute + // values to the dependency graph at activity launch time. This allows replacing + // arbitrary bindings per-test case if needed. + mPackageManager = mContext.getPackageManager(); + + // TODO: inject image loader in the prod code and remove this override + ChooserActivityOverrideData.getInstance().imageLoader = mFakeImageLoader; + } + + public ChooserActivityTest(boolean appPredictionAvailable) { + mAppPredictionAvailable = appPredictionAvailable; + } + + private void setDeviceConfigProperty( + @NonNull String propertyName, + @NonNull String value) { + // TODO: consider running with {@link #runWithShellPermissionIdentity()} to more narrowly + // request WRITE_DEVICE_CONFIG permissions if we get rid of the broad grant we currently + // configure in {@link #setup()}. + // TODO: is it really appropriate that this is always set with makeDefault=true? + boolean valueWasSet = DeviceConfig.setProperty( + DeviceConfig.NAMESPACE_SYSTEMUI, + propertyName, + value, + true /* makeDefault */); + if (!valueWasSet) { + throw new IllegalStateException( + "Could not set " + propertyName + " to " + value); + } + } + + public void cleanOverrideData() { + ChooserActivityOverrideData.getInstance().reset(); + + setDeviceConfigProperty( + SystemUiDeviceConfigFlags.APPLY_SHARING_APP_LIMITS_IN_SYSUI, + Boolean.toString(true)); + } + + private static PackageManager createFakePackageManager(ResolveInfo resolveInfo) { + PackageManager packageManager = mock(PackageManager.class); + when(packageManager.resolveActivity(any(Intent.class), any())).thenReturn(resolveInfo); + return packageManager; + } + + @Test + public void customTitle() throws InterruptedException { + Intent viewIntent = createViewTextIntent(); + List resolvedComponentInfos = createResolvedComponentsForTest(2); + + setupResolverControllers(resolvedComponentInfos); + final IChooserWrapper activity = (IChooserWrapper) mActivityRule.launchActivity( + Intent.createChooser(viewIntent, "chooser test")); + + waitForIdle(); + assertThat(activity.getAdapter().getCount(), is(2)); + assertThat(activity.getAdapter().getServiceTargetCount(), is(0)); + onView(withId(android.R.id.title)).check(matches(withText("chooser test"))); + } + + @Test + public void customTitleIgnoredForSendIntents() throws InterruptedException { + Intent sendIntent = createSendTextIntent(); + List resolvedComponentInfos = createResolvedComponentsForTest(2); + + setupResolverControllers(resolvedComponentInfos); + mActivityRule.launchActivity(Intent.createChooser(sendIntent, "chooser test")); + waitForIdle(); + onView(withId(android.R.id.title)) + .check(matches(withText(R.string.whichSendApplication))); + } + + @Test + public void emptyTitle() throws InterruptedException { + Intent sendIntent = createSendTextIntent(); + List resolvedComponentInfos = createResolvedComponentsForTest(2); + + setupResolverControllers(resolvedComponentInfos); + mActivityRule.launchActivity(Intent.createChooser(sendIntent, null)); + waitForIdle(); + onView(withId(android.R.id.title)) + .check(matches(withText(R.string.whichSendApplication))); + } + + @Test + public void test_shareRichTextWithRichTitle_richTextAndRichTitleDisplayed() { + CharSequence title = new SpannableStringBuilder() + .append("Rich", new UnderlineSpan(), Spannable.SPAN_INCLUSIVE_EXCLUSIVE) + .append( + "Title", + new ForegroundColorSpan(Color.RED), + Spannable.SPAN_INCLUSIVE_EXCLUSIVE); + CharSequence sharedText = new SpannableStringBuilder() + .append( + "Rich", + new BackgroundColorSpan(Color.YELLOW), + Spanned.SPAN_INCLUSIVE_EXCLUSIVE) + .append( + "Text", + new StyleSpan(Typeface.ITALIC), + Spanned.SPAN_INCLUSIVE_EXCLUSIVE); + Intent sendIntent = createSendTextIntent(); + sendIntent.putExtra(Intent.EXTRA_TEXT, sharedText); + sendIntent.putExtra(Intent.EXTRA_TITLE, title); + List resolvedComponentInfos = createResolvedComponentsForTest(2); + setupResolverControllers(resolvedComponentInfos); + + mActivityRule.launchActivity(Intent.createChooser(sendIntent, null)); + waitForIdle(); + + onView(withId(com.android.internal.R.id.content_preview_title)) + .check((view, e) -> { + assertThat(view).isInstanceOf(TextView.class); + CharSequence text = ((TextView) view).getText(); + assertThat(text).isInstanceOf(Spanned.class); + Spanned spanned = (Spanned) text; + assertThat(spanned.getSpans(0, spanned.length(), Object.class)) + .hasLength(2); + assertThat(spanned.getSpans(0, 4, UnderlineSpan.class)).hasLength(1); + assertThat(spanned.getSpans(4, spanned.length(), ForegroundColorSpan.class)) + .hasLength(1); + }); + + onView(withId(com.android.internal.R.id.content_preview_text)) + .check((view, e) -> { + assertThat(view).isInstanceOf(TextView.class); + CharSequence text = ((TextView) view).getText(); + assertThat(text).isInstanceOf(Spanned.class); + Spanned spanned = (Spanned) text; + assertThat(spanned.getSpans(0, spanned.length(), Object.class)) + .hasLength(2); + assertThat(spanned.getSpans(0, 4, BackgroundColorSpan.class)).hasLength(1); + assertThat(spanned.getSpans(4, spanned.length(), StyleSpan.class)).hasLength(1); + }); + } + + @Test + public void emptyPreviewTitleAndThumbnail() throws InterruptedException { + Intent sendIntent = createSendTextIntentWithPreview(null, null); + List resolvedComponentInfos = createResolvedComponentsForTest(2); + + setupResolverControllers(resolvedComponentInfos); + mActivityRule.launchActivity(Intent.createChooser(sendIntent, null)); + waitForIdle(); + onView(withId(com.android.internal.R.id.content_preview_title)) + .check(matches(not(isDisplayed()))); + onView(withId(com.android.internal.R.id.content_preview_thumbnail)) + .check(matches(not(isDisplayed()))); + } + + @Test + public void visiblePreviewTitleWithoutThumbnail() throws InterruptedException { + String previewTitle = "My Content Preview Title"; + Intent sendIntent = createSendTextIntentWithPreview(previewTitle, null); + List resolvedComponentInfos = createResolvedComponentsForTest(2); + + setupResolverControllers(resolvedComponentInfos); + mActivityRule.launchActivity(Intent.createChooser(sendIntent, null)); + waitForIdle(); + onView(withId(com.android.internal.R.id.content_preview_title)) + .check(matches(isDisplayed())); + onView(withId(com.android.internal.R.id.content_preview_title)) + .check(matches(withText(previewTitle))); + onView(withId(com.android.internal.R.id.content_preview_thumbnail)) + .check(matches(not(isDisplayed()))); + } + + @Test + public void visiblePreviewTitleWithInvalidThumbnail() throws InterruptedException { + String previewTitle = "My Content Preview Title"; + Intent sendIntent = createSendTextIntentWithPreview(previewTitle, + Uri.parse("tel:(+49)12345789")); + List resolvedComponentInfos = createResolvedComponentsForTest(2); + + setupResolverControllers(resolvedComponentInfos); + mActivityRule.launchActivity(Intent.createChooser(sendIntent, null)); + waitForIdle(); + onView(withId(com.android.internal.R.id.content_preview_title)) + .check(matches(isDisplayed())); + onView(withId(com.android.internal.R.id.content_preview_thumbnail)) + .check(matches(not(isDisplayed()))); + } + + @Test + public void visiblePreviewTitleAndThumbnail() { + 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); + mFakeImageLoader.setBitmap(uri, createBitmap()); + List resolvedComponentInfos = createResolvedComponentsForTest(2); + + setupResolverControllers(resolvedComponentInfos); + mActivityRule.launchActivity(Intent.createChooser(sendIntent, null)); + waitForIdle(); + onView(withId(com.android.internal.R.id.content_preview_title)) + .check(matches(isDisplayed())); + onView(withId(com.android.internal.R.id.content_preview_thumbnail)) + .check(matches(isDisplayed())); + } + + @Test @Ignore + public void twoOptionsAndUserSelectsOne() throws InterruptedException { + Intent sendIntent = createSendTextIntent(); + List resolvedComponentInfos = createResolvedComponentsForTest(2); + + setupResolverControllers(resolvedComponentInfos); + + final IChooserWrapper activity = (IChooserWrapper) + mActivityRule.launchActivity(Intent.createChooser(sendIntent, null)); + waitForIdle(); + + assertThat(activity.getAdapter().getCount(), is(2)); + onView(withId(com.android.internal.R.id.profile_button)).check(doesNotExist()); + + ResolveInfo[] chosen = new ResolveInfo[1]; + ChooserActivityOverrideData.getInstance().onSafelyStartInternalCallback = targetInfo -> { + chosen[0] = targetInfo.getResolveInfo(); + return true; + }; + + ResolveInfo toChoose = resolvedComponentInfos.get(0).getResolveInfoAt(0); + onView(withText(toChoose.activityInfo.name)) + .perform(click()); + waitForIdle(); + assertThat(chosen[0], is(toChoose)); + } + + @Test @Ignore + public void fourOptionsStackedIntoOneTarget() throws InterruptedException { + Intent sendIntent = createSendTextIntent(); + + // create just enough targets to ensure the a-z list should be shown + List resolvedComponentInfos = createResolvedComponentsForTest(1); + + // next create 4 targets in a single app that should be stacked into a single target + String packageName = "xxx.yyy"; + String appName = "aaa"; + ComponentName cn = new ComponentName(packageName, appName); + Intent intent = new Intent("fakeIntent"); + List infosToStack = new ArrayList<>(); + for (int i = 0; i < 4; i++) { + ResolveInfo resolveInfo = ResolverDataProvider.createResolveInfo(i, + UserHandle.USER_CURRENT, PERSONAL_USER_HANDLE); + resolveInfo.activityInfo.applicationInfo.name = appName; + resolveInfo.activityInfo.applicationInfo.packageName = packageName; + resolveInfo.activityInfo.packageName = packageName; + resolveInfo.activityInfo.name = "ccc" + i; + infosToStack.add(new ResolvedComponentInfo(cn, intent, resolveInfo)); + } + resolvedComponentInfos.addAll(infosToStack); + + setupResolverControllers(resolvedComponentInfos); + + final IChooserWrapper activity = (IChooserWrapper) + mActivityRule.launchActivity(Intent.createChooser(sendIntent, null)); + waitForIdle(); + + // expect 1 unique targets + 1 group + 4 ranked app targets + assertThat(activity.getAdapter().getCount(), is(6)); + + ResolveInfo[] chosen = new ResolveInfo[1]; + ChooserActivityOverrideData.getInstance().onSafelyStartInternalCallback = targetInfo -> { + chosen[0] = targetInfo.getResolveInfo(); + return true; + }; + + onView(allOf(withText(appName), hasSibling(withText("")))).perform(click()); + waitForIdle(); + + // clicking will launch a dialog to choose the activity within the app + onView(withText(appName)).check(matches(isDisplayed())); + int i = 0; + for (ResolvedComponentInfo rci: infosToStack) { + onView(withText("ccc" + i)).check(matches(isDisplayed())); + ++i; + } + } + + @Test @Ignore + public void updateChooserCountsAndModelAfterUserSelection() throws InterruptedException { + Intent sendIntent = createSendTextIntent(); + List resolvedComponentInfos = createResolvedComponentsForTest(2); + + setupResolverControllers(resolvedComponentInfos); + + final IChooserWrapper activity = (IChooserWrapper) + mActivityRule.launchActivity(Intent.createChooser(sendIntent, null)); + waitForIdle(); + UsageStatsManager usm = activity.getUsageStatsManager(); + verify(ChooserActivityOverrideData.getInstance().resolverListController, times(1)) + .topK(any(List.class), anyInt()); + assertThat(activity.getIsSelected(), is(false)); + ChooserActivityOverrideData.getInstance().onSafelyStartInternalCallback = targetInfo -> { + return true; + }; + ResolveInfo toChoose = resolvedComponentInfos.get(0).getResolveInfoAt(0); + DisplayResolveInfo testDri = + activity.createTestDisplayResolveInfo( + sendIntent, toChoose, "testLabel", "testInfo", sendIntent); + onView(withText(toChoose.activityInfo.name)) + .perform(click()); + waitForIdle(); + verify(ChooserActivityOverrideData.getInstance().resolverListController, times(1)) + .updateChooserCounts(Mockito.anyString(), any(UserHandle.class), + Mockito.anyString()); + verify(ChooserActivityOverrideData.getInstance().resolverListController, times(1)) + .updateModel(testDri); + assertThat(activity.getIsSelected(), is(true)); + } + + @Ignore // b/148158199 + @Test + public void noResultsFromPackageManager() { + setupResolverControllers(null); + Intent sendIntent = createSendTextIntent(); + final ChooserActivity activity = + mActivityRule.launchActivity(Intent.createChooser(sendIntent, null)); + final IChooserWrapper wrapper = (IChooserWrapper) activity; + + waitForIdle(); + assertThat(activity.isFinishing(), is(false)); + + onView(withId(android.R.id.empty)).check(matches(isDisplayed())); + onView(withId(com.android.internal.R.id.profile_pager)).check(matches(not(isDisplayed()))); + InstrumentationRegistry.getInstrumentation().runOnMainSync( + () -> wrapper.getAdapter().handlePackagesChanged() + ); + // backward compatibility. looks like we finish when data is empty after package change + assertThat(activity.isFinishing(), is(true)); + } + + @Test + public void autoLaunchSingleResult() throws InterruptedException { + ResolveInfo[] chosen = new ResolveInfo[1]; + ChooserActivityOverrideData.getInstance().onSafelyStartInternalCallback = targetInfo -> { + chosen[0] = targetInfo.getResolveInfo(); + return true; + }; + + List resolvedComponentInfos = createResolvedComponentsForTest(1); + setupResolverControllers(resolvedComponentInfos); + + Intent sendIntent = createSendTextIntent(); + final ChooserActivity activity = + mActivityRule.launchActivity(Intent.createChooser(sendIntent, null)); + waitForIdle(); + + assertThat(chosen[0], is(resolvedComponentInfos.get(0).getResolveInfoAt(0))); + assertThat(activity.isFinishing(), is(true)); + } + + @Test @Ignore + public void hasOtherProfileOneOption() { + List personalResolvedComponentInfos = + createResolvedComponentsForTestWithOtherProfile(2, /* userId */ 10); + List workResolvedComponentInfos = createResolvedComponentsForTest(4); + setupResolverControllers(personalResolvedComponentInfos, workResolvedComponentInfos); + markOtherProfileAvailability(/* workAvailable= */ true, /* cloneAvailable= */ false); + + ResolveInfo toChoose = personalResolvedComponentInfos.get(1).getResolveInfoAt(0); + Intent sendIntent = createSendTextIntent(); + final IChooserWrapper activity = (IChooserWrapper) + mActivityRule.launchActivity(Intent.createChooser(sendIntent, null)); + waitForIdle(); + + // The other entry is filtered to the other profile slot + assertThat(activity.getAdapter().getCount(), is(1)); + + ResolveInfo[] chosen = new ResolveInfo[1]; + ChooserActivityOverrideData.getInstance().onSafelyStartInternalCallback = targetInfo -> { + chosen[0] = targetInfo.getResolveInfo(); + return true; + }; + + // Make a stable copy of the components as the original list may be modified + List stableCopy = + createResolvedComponentsForTestWithOtherProfile(2, /* userId= */ 10); + waitForIdle(); + + onView(first(withText(stableCopy.get(1).getResolveInfoAt(0).activityInfo.name))) + .perform(click()); + waitForIdle(); + assertThat(chosen[0], is(toChoose)); + } + + @Test @Ignore + public void hasOtherProfileTwoOptionsAndUserSelectsOne() throws Exception { + Intent sendIntent = createSendTextIntent(); + List resolvedComponentInfos = + createResolvedComponentsForTestWithOtherProfile(3); + ResolveInfo toChoose = resolvedComponentInfos.get(1).getResolveInfoAt(0); + + setupResolverControllers(resolvedComponentInfos); + when(ChooserActivityOverrideData.getInstance().resolverListController.getLastChosen()) + .thenReturn(resolvedComponentInfos.get(0).getResolveInfoAt(0)); + + final IChooserWrapper activity = (IChooserWrapper) + mActivityRule.launchActivity(Intent.createChooser(sendIntent, null)); + waitForIdle(); + + // The other entry is filtered to the other profile slot + assertThat(activity.getAdapter().getCount(), is(2)); + + ResolveInfo[] chosen = new ResolveInfo[1]; + ChooserActivityOverrideData.getInstance().onSafelyStartInternalCallback = targetInfo -> { + chosen[0] = targetInfo.getResolveInfo(); + return true; + }; + + // Make a stable copy of the components as the original list may be modified + List stableCopy = + createResolvedComponentsForTestWithOtherProfile(3); + onView(withText(stableCopy.get(1).getResolveInfoAt(0).activityInfo.name)) + .perform(click()); + waitForIdle(); + assertThat(chosen[0], is(toChoose)); + } + + @Test @Ignore + public void hasLastChosenActivityAndOtherProfile() throws Exception { + Intent sendIntent = createSendTextIntent(); + List resolvedComponentInfos = + createResolvedComponentsForTestWithOtherProfile(3); + ResolveInfo toChoose = resolvedComponentInfos.get(1).getResolveInfoAt(0); + + setupResolverControllers(resolvedComponentInfos); + + final IChooserWrapper activity = (IChooserWrapper) + mActivityRule.launchActivity(Intent.createChooser(sendIntent, null)); + waitForIdle(); + + // The other entry is filtered to the last used slot + assertThat(activity.getAdapter().getCount(), is(2)); + + ResolveInfo[] chosen = new ResolveInfo[1]; + ChooserActivityOverrideData.getInstance().onSafelyStartInternalCallback = targetInfo -> { + chosen[0] = targetInfo.getResolveInfo(); + return true; + }; + + // Make a stable copy of the components as the original list may be modified + List stableCopy = + createResolvedComponentsForTestWithOtherProfile(3); + onView(withText(stableCopy.get(1).getResolveInfoAt(0).activityInfo.name)) + .perform(click()); + waitForIdle(); + assertThat(chosen[0], is(toChoose)); + } + + @Test + @Ignore("b/285309527") + public void testFilePlusTextSharing_ExcludeText() { + Uri uri = createTestContentProviderUri(null, "image/png"); + Intent sendIntent = createSendImageIntent(uri); + mFakeImageLoader.setBitmap(uri, createBitmap()); + sendIntent.putExtra(Intent.EXTRA_TEXT, "https://google.com/search?q=google"); + + List resolvedComponentInfos = Arrays.asList( + ResolverDataProvider.createResolvedComponentInfo( + new ComponentName("org.imageviewer", "ImageTarget"), + sendIntent, PERSONAL_USER_HANDLE), + ResolverDataProvider.createResolvedComponentInfo( + new ComponentName("org.textviewer", "UriTarget"), + new Intent("VIEW_TEXT"), PERSONAL_USER_HANDLE) + ); + + setupResolverControllers(resolvedComponentInfos); + + mActivityRule.launchActivity(Intent.createChooser(sendIntent, null)); + waitForIdle(); + + onView(withId(R.id.include_text_action)) + .check(matches(isDisplayed())) + .perform(click()); + waitForIdle(); + + onView(withId(R.id.content_preview_text)).check(matches(withText("File only"))); + + AtomicReference launchedIntentRef = new AtomicReference<>(); + ChooserActivityOverrideData.getInstance().onSafelyStartInternalCallback = targetInfo -> { + launchedIntentRef.set(targetInfo.getTargetIntent()); + return true; + }; + + onView(withText(resolvedComponentInfos.get(0).getResolveInfoAt(0).activityInfo.name)) + .perform(click()); + waitForIdle(); + assertThat(launchedIntentRef.get().hasExtra(Intent.EXTRA_TEXT)).isFalse(); + } + + @Test + @Ignore("b/285309527") + public void testFilePlusTextSharing_RemoveAndAddBackText() { + Uri uri = createTestContentProviderUri("application/pdf", "image/png"); + Intent sendIntent = createSendImageIntent(uri); + mFakeImageLoader.setBitmap(uri, createBitmap()); + final String text = "https://google.com/search?q=google"; + sendIntent.putExtra(Intent.EXTRA_TEXT, text); + + List resolvedComponentInfos = Arrays.asList( + ResolverDataProvider.createResolvedComponentInfo( + new ComponentName("org.imageviewer", "ImageTarget"), + sendIntent, PERSONAL_USER_HANDLE), + ResolverDataProvider.createResolvedComponentInfo( + new ComponentName("org.textviewer", "UriTarget"), + new Intent("VIEW_TEXT"), PERSONAL_USER_HANDLE) + ); + + setupResolverControllers(resolvedComponentInfos); + + mActivityRule.launchActivity(Intent.createChooser(sendIntent, null)); + waitForIdle(); + + onView(withId(R.id.include_text_action)) + .check(matches(isDisplayed())) + .perform(click()); + waitForIdle(); + onView(withId(R.id.content_preview_text)).check(matches(withText("File only"))); + + onView(withId(R.id.include_text_action)) + .perform(click()); + waitForIdle(); + + onView(withId(R.id.content_preview_text)).check(matches(withText(text))); + + AtomicReference launchedIntentRef = new AtomicReference<>(); + ChooserActivityOverrideData.getInstance().onSafelyStartInternalCallback = targetInfo -> { + launchedIntentRef.set(targetInfo.getTargetIntent()); + return true; + }; + + onView(withText(resolvedComponentInfos.get(0).getResolveInfoAt(0).activityInfo.name)) + .perform(click()); + waitForIdle(); + assertThat(launchedIntentRef.get().getStringExtra(Intent.EXTRA_TEXT)).isEqualTo(text); + } + + @Test + @Ignore("b/285309527") + public void testFilePlusTextSharing_TextExclusionDoesNotAffectAlternativeIntent() { + Uri uri = createTestContentProviderUri("image/png", null); + Intent sendIntent = createSendImageIntent(uri); + mFakeImageLoader.setBitmap(uri, createBitmap()); + sendIntent.putExtra(Intent.EXTRA_TEXT, "https://google.com/search?q=google"); + + Intent alternativeIntent = createSendTextIntent(); + final String text = "alternative intent"; + alternativeIntent.putExtra(Intent.EXTRA_TEXT, text); + + List resolvedComponentInfos = Arrays.asList( + ResolverDataProvider.createResolvedComponentInfo( + new ComponentName("org.imageviewer", "ImageTarget"), + sendIntent, PERSONAL_USER_HANDLE), + ResolverDataProvider.createResolvedComponentInfo( + new ComponentName("org.textviewer", "UriTarget"), + alternativeIntent, PERSONAL_USER_HANDLE) + ); + + setupResolverControllers(resolvedComponentInfos); + + mActivityRule.launchActivity(Intent.createChooser(sendIntent, null)); + waitForIdle(); + + onView(withId(R.id.include_text_action)) + .check(matches(isDisplayed())) + .perform(click()); + waitForIdle(); + + AtomicReference launchedIntentRef = new AtomicReference<>(); + ChooserActivityOverrideData.getInstance().onSafelyStartInternalCallback = targetInfo -> { + launchedIntentRef.set(targetInfo.getTargetIntent()); + return true; + }; + + onView(withText(resolvedComponentInfos.get(1).getResolveInfoAt(0).activityInfo.name)) + .perform(click()); + waitForIdle(); + assertThat(launchedIntentRef.get().getStringExtra(Intent.EXTRA_TEXT)).isEqualTo(text); + } + + @Test + @Ignore("b/285309527") + public void testImagePlusTextSharing_failedThumbnailAndExcludedText_textChanges() { + Uri uri = createTestContentProviderUri("image/png", null); + Intent sendIntent = createSendImageIntent(uri); + sendIntent.putExtra(Intent.EXTRA_TEXT, "https://google.com/search?q=google"); + + List resolvedComponentInfos = Arrays.asList( + ResolverDataProvider.createResolvedComponentInfo( + new ComponentName("org.imageviewer", "ImageTarget"), + sendIntent, PERSONAL_USER_HANDLE), + ResolverDataProvider.createResolvedComponentInfo( + new ComponentName("org.textviewer", "UriTarget"), + new Intent("VIEW_TEXT"), PERSONAL_USER_HANDLE) + ); + + setupResolverControllers(resolvedComponentInfos); + + mActivityRule.launchActivity(Intent.createChooser(sendIntent, null)); + waitForIdle(); + + onView(withId(R.id.include_text_action)) + .check(matches(isDisplayed())) + .perform(click()); + waitForIdle(); + + onView(withId(R.id.image_view)) + .check(matches(withEffectiveVisibility(ViewMatchers.Visibility.GONE))); + onView(withId(R.id.content_preview_text)) + .check(matches(allOf(isDisplayed(), withText("Image only")))); + } + + @Test + public void copyTextToClipboard() { + Intent sendIntent = createSendTextIntent(); + List resolvedComponentInfos = createResolvedComponentsForTest(2); + + setupResolverControllers(resolvedComponentInfos); + + final ChooserActivity activity = + mActivityRule.launchActivity(Intent.createChooser(sendIntent, null)); + waitForIdle(); + + onView(withId(R.id.copy)).check(matches(isDisplayed())); + onView(withId(R.id.copy)).perform(click()); + ClipboardManager clipboard = (ClipboardManager) activity.getSystemService( + Context.CLIPBOARD_SERVICE); + ClipData clipData = clipboard.getPrimaryClip(); + assertThat(clipData).isNotNull(); + assertThat(clipData.getItemAt(0).getText()).isEqualTo("testing intent sending"); + + ClipDescription clipDescription = clipData.getDescription(); + assertThat("text/plain", is(clipDescription.getMimeType(0))); + + assertEquals(mActivityRule.getActivityResult().getResultCode(), RESULT_OK); + } + + @Test + public void copyTextToClipboardLogging() { + Intent sendIntent = createSendTextIntent(); + List resolvedComponentInfos = createResolvedComponentsForTest(2); + + setupResolverControllers(resolvedComponentInfos); + + ChooserWrapperActivity activity = + mActivityRule.launchActivity(Intent.createChooser(sendIntent, null)); + waitForIdle(); + + onView(withId(R.id.copy)).check(matches(isDisplayed())); + onView(withId(R.id.copy)).perform(click()); + FakeEventLog eventLog = getEventLog(activity); + assertThat(eventLog.getActionSelected()) + .isEqualTo(new FakeEventLog.ActionSelected( + /* targetType = */ EventLog.SELECTION_TYPE_COPY)); + } + + @Test + @Ignore + public void testNearbyShareLogging() throws Exception { + Intent sendIntent = createSendTextIntent(); + List resolvedComponentInfos = createResolvedComponentsForTest(2); + + setupResolverControllers(resolvedComponentInfos); + + mActivityRule.launchActivity(Intent.createChooser(sendIntent, null)); + waitForIdle(); + + onView(withId(com.android.internal.R.id.chooser_nearby_button)) + .check(matches(isDisplayed())); + onView(withId(com.android.internal.R.id.chooser_nearby_button)).perform(click()); + + // TODO(b/211669337): Determine the expected SHARESHEET_DIRECT_LOAD_COMPLETE events. + } + + @Test @Ignore + public void testEditImageLogs() { + + Uri uri = createTestContentProviderUri("image/png", null); + Intent sendIntent = createSendImageIntent(uri); + mFakeImageLoader.setBitmap(uri, createBitmap()); + + List resolvedComponentInfos = createResolvedComponentsForTest(2); + + setupResolverControllers(resolvedComponentInfos); + + mActivityRule.launchActivity(Intent.createChooser(sendIntent, null)); + waitForIdle(); + + onView(withId(com.android.internal.R.id.chooser_edit_button)).check(matches(isDisplayed())); + onView(withId(com.android.internal.R.id.chooser_edit_button)).perform(click()); + + // TODO(b/211669337): Determine the expected SHARESHEET_DIRECT_LOAD_COMPLETE events. + } + + + @Test + public void oneVisibleImagePreview() { + Uri uri = createTestContentProviderUri("image/png", null); + + ArrayList uris = new ArrayList<>(); + uris.add(uri); + + Intent sendIntent = createSendUriIntentWithPreview(uris); + mFakeImageLoader.setBitmap(uri, createWideBitmap()); + + List resolvedComponentInfos = createResolvedComponentsForTest(2); + + setupResolverControllers(resolvedComponentInfos); + mActivityRule.launchActivity(Intent.createChooser(sendIntent, null)); + waitForIdle(); + onView(withId(R.id.scrollable_image_preview)) + .check((view, exception) -> { + if (exception != null) { + throw exception; + } + RecyclerView recyclerView = (RecyclerView) view; + RecyclerViewExt.endAnimations(recyclerView); + assertThat("recyclerView adapter item count", + recyclerView.getAdapter().getItemCount(), is(1)); + assertThat("recyclerView child view count", + recyclerView.getChildCount(), is(1)); + View imageView = recyclerView.getChildAt(0); + Rect rect = new Rect(); + boolean isPartiallyVisible = imageView.getGlobalVisibleRect(rect); + assertThat( + "image preview view is not fully visible", + isPartiallyVisible + && rect.width() == imageView.getWidth() + && rect.height() == imageView.getHeight()); + }); + } + + @Test + public void allThumbnailsFailedToLoad_hidePreview() { + Uri uri = createTestContentProviderUri("image/jpg", null); + + ArrayList uris = new ArrayList<>(); + uris.add(uri); + uris.add(uri); + + Intent sendIntent = createSendUriIntentWithPreview(uris); + + List resolvedComponentInfos = createResolvedComponentsForTest(2); + + setupResolverControllers(resolvedComponentInfos); + mActivityRule.launchActivity(Intent.createChooser(sendIntent, null)); + waitForIdle(); + onView(withId(R.id.scrollable_image_preview)) + .check(matches(withEffectiveVisibility(ViewMatchers.Visibility.GONE))); + } + + @Test(timeout = 4_000) + public void testSlowUriMetadata_fallbackToFilePreview() { + Uri uri = createTestContentProviderUri( + "application/pdf", "image/png", /*streamTypeTimeout=*/8_000); + ArrayList uris = new ArrayList<>(1); + uris.add(uri); + Intent sendIntent = createSendUriIntentWithPreview(uris); + mFakeImageLoader.setBitmap(uri, createBitmap()); + + List resolvedComponentInfos = createResolvedComponentsForTest(2); + + setupResolverControllers(resolvedComponentInfos); + // The preview type resolution is expected to timeout and default to file preview, otherwise + // the test should timeout. + mActivityRule.launchActivity(Intent.createChooser(sendIntent, null)); + waitForIdle(); + + onView(withId(R.id.content_preview_filename)).check(matches(isDisplayed())); + onView(withId(R.id.content_preview_filename)).check(matches(withText("image.png"))); + onView(withId(R.id.content_preview_file_icon)).check(matches(isDisplayed())); + } + + @Test(timeout = 4_000) + public void testSendManyFilesWithSmallMetadataDelayAndOneImage_fallbackToFilePreviewUi() { + Uri fileUri = createTestContentProviderUri( + "application/pdf", "application/pdf", /*streamTypeTimeout=*/300); + Uri imageUri = createTestContentProviderUri("application/pdf", "image/png"); + ArrayList uris = new ArrayList<>(50); + for (int i = 0; i < 49; i++) { + uris.add(fileUri); + } + uris.add(imageUri); + Intent sendIntent = createSendUriIntentWithPreview(uris); + mFakeImageLoader.setBitmap(imageUri, createBitmap()); + + List resolvedComponentInfos = createResolvedComponentsForTest(2); + setupResolverControllers(resolvedComponentInfos); + // The preview type resolution is expected to timeout and default to file preview, otherwise + // the test should timeout. + mActivityRule.launchActivity(Intent.createChooser(sendIntent, null)); + + waitForIdle(); + + onView(withId(R.id.content_preview_filename)).check(matches(isDisplayed())); + onView(withId(R.id.content_preview_filename)).check(matches(withText("image.png"))); + onView(withId(R.id.content_preview_file_icon)).check(matches(isDisplayed())); + } + + @Test + public void testManyVisibleImagePreview_ScrollableImagePreview() { + Uri uri = createTestContentProviderUri("image/png", null); + + ArrayList uris = new ArrayList<>(); + uris.add(uri); + uris.add(uri); + uris.add(uri); + uris.add(uri); + uris.add(uri); + uris.add(uri); + uris.add(uri); + uris.add(uri); + uris.add(uri); + uris.add(uri); + + Intent sendIntent = createSendUriIntentWithPreview(uris); + mFakeImageLoader.setBitmap(uri, createBitmap()); + + List resolvedComponentInfos = createResolvedComponentsForTest(2); + + setupResolverControllers(resolvedComponentInfos); + mActivityRule.launchActivity(Intent.createChooser(sendIntent, null)); + waitForIdle(); + onView(withId(R.id.scrollable_image_preview)) + .perform(RecyclerViewActions.scrollToLastPosition()) + .check((view, exception) -> { + if (exception != null) { + throw exception; + } + RecyclerView recyclerView = (RecyclerView) view; + assertThat(recyclerView.getAdapter().getItemCount(), is(uris.size())); + }); + } + + @Test(timeout = 4_000) + public void testPartiallyLoadedMetadata_previewIsShownForTheLoadedPart() { + Uri imgOneUri = createTestContentProviderUri("image/png", null); + Uri imgTwoUri = createTestContentProviderUri("image/png", null) + .buildUpon() + .path("image-2.png") + .build(); + Uri docUri = createTestContentProviderUri("application/pdf", "image/png", 8_000); + ArrayList uris = new ArrayList<>(2); + // two large previews to fill the screen and be presented right away and one + // document that would be delayed by the URI metadata reading + uris.add(imgOneUri); + uris.add(imgTwoUri); + uris.add(docUri); + + Intent sendIntent = createSendUriIntentWithPreview(uris); + mFakeImageLoader.setBitmap(imgOneUri, createWideBitmap(Color.RED)); + mFakeImageLoader.setBitmap(imgTwoUri, createWideBitmap(Color.GREEN)); + mFakeImageLoader.setBitmap(docUri, createWideBitmap(Color.BLUE)); + + List resolvedComponentInfos = createResolvedComponentsForTest(2); + setupResolverControllers(resolvedComponentInfos); + + // the preview type is expected to be resolved quickly based on the first provided URI + // metadata. If, instead, it is dependent on the third URI metadata, the test should either + // timeout or (more probably due to inner timeout) default to file preview type; anyway the + // test will fail. + mActivityRule.launchActivity(Intent.createChooser(sendIntent, null)); + waitForIdle(); + + onView(withId(R.id.scrollable_image_preview)) + .check((view, exception) -> { + if (exception != null) { + 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); + assertThat(imageView).isNotNull(); + }) + .perform(RecyclerViewActions.scrollToLastPosition()) + .check((view, exception) -> { + if (exception != null) { + throw exception; + } + RecyclerView recyclerView = (RecyclerView) view; + assertThat(recyclerView.getChildCount()).isAtLeast(1); + // check that the last view is a loading indicator + View loadingIndicator = + recyclerView.getChildAt(recyclerView.getChildCount() - 1); + assertThat(loadingIndicator).isNotNull(); + }); + waitForIdle(); + } + + @Test + public void testImageAndTextPreview() { + final Uri uri = createTestContentProviderUri("image/png", null); + final String sharedText = "text-" + System.currentTimeMillis(); + + ArrayList uris = new ArrayList<>(); + uris.add(uri); + + Intent sendIntent = createSendUriIntentWithPreview(uris); + sendIntent.putExtra(Intent.EXTRA_TEXT, sharedText); + mFakeImageLoader.setBitmap(uri, createBitmap()); + + List resolvedComponentInfos = createResolvedComponentsForTest(2); + + setupResolverControllers(resolvedComponentInfos); + mActivityRule.launchActivity(Intent.createChooser(sendIntent, null)); + waitForIdle(); + onView(withText(sharedText)) + .check(matches(isDisplayed())); + } + + @Test + public void test_shareImageWithRichText_RichTextIsDisplayed() { + final Uri uri = createTestContentProviderUri("image/png", null); + final CharSequence sharedText = new SpannableStringBuilder() + .append( + "text-", + new StyleSpan(Typeface.BOLD_ITALIC), + Spannable.SPAN_INCLUSIVE_EXCLUSIVE) + .append( + Long.toString(System.currentTimeMillis()), + new ForegroundColorSpan(Color.RED), + Spanned.SPAN_INCLUSIVE_EXCLUSIVE); + + ArrayList uris = new ArrayList<>(); + uris.add(uri); + + Intent sendIntent = createSendUriIntentWithPreview(uris); + sendIntent.putExtra(Intent.EXTRA_TEXT, sharedText); + mFakeImageLoader.setBitmap(uri, createBitmap()); + + List resolvedComponentInfos = createResolvedComponentsForTest(2); + + setupResolverControllers(resolvedComponentInfos); + mActivityRule.launchActivity(Intent.createChooser(sendIntent, null)); + waitForIdle(); + onView(withText(sharedText.toString())) + .check(matches(isDisplayed())) + .check((view, e) -> { + if (e != null) { + throw e; + } + assertThat(view).isInstanceOf(TextView.class); + CharSequence text = ((TextView) view).getText(); + assertThat(text).isInstanceOf(Spanned.class); + Spanned spanned = (Spanned) text; + Object[] spans = spanned.getSpans(0, text.length(), Object.class); + assertThat(spans).hasLength(2); + assertThat(spanned.getSpans(0, 5, StyleSpan.class)).hasLength(1); + assertThat(spanned.getSpans(5, text.length(), ForegroundColorSpan.class)) + .hasLength(1); + }); + } + + @Test + public void testTextPreviewWhenTextIsSharedWithMultipleImages() { + final Uri uri = createTestContentProviderUri("image/png", null); + final String sharedText = "text-" + System.currentTimeMillis(); + + ArrayList uris = new ArrayList<>(); + uris.add(uri); + uris.add(uri); + + Intent sendIntent = createSendUriIntentWithPreview(uris); + sendIntent.putExtra(Intent.EXTRA_TEXT, sharedText); + mFakeImageLoader.setBitmap(uri, createBitmap()); + + List resolvedComponentInfos = createResolvedComponentsForTest(2); + + when( + ChooserActivityOverrideData + .getInstance() + .resolverListController + .getResolversForIntentAsUser( + Mockito.anyBoolean(), + Mockito.anyBoolean(), + Mockito.anyBoolean(), + Mockito.isA(List.class), + Mockito.any(UserHandle.class))) + .thenReturn(resolvedComponentInfos); + mActivityRule.launchActivity(Intent.createChooser(sendIntent, null)); + waitForIdle(); + onView(withText(sharedText)).check(matches(isDisplayed())); + } + + @Test + public void testOnCreateLogging() { + Intent sendIntent = createSendTextIntent(); + sendIntent.setType(TEST_MIME_TYPE); + + ChooserWrapperActivity activity = + mActivityRule.launchActivity(Intent.createChooser(sendIntent, "logger test")); + waitForIdle(); + + FakeEventLog eventLog = getEventLog(activity); + FakeEventLog.ChooserActivityShown event = eventLog.getChooserActivityShown(); + assertThat(event).isNotNull(); + assertThat(event.isWorkProfile()).isFalse(); + assertThat(event.getTargetMimeType()).isEqualTo(TEST_MIME_TYPE); + } + + @Test + public void testOnCreateLoggingFromWorkProfile() { + Intent sendIntent = createSendTextIntent(); + sendIntent.setType(TEST_MIME_TYPE); + + // Launch as work user. + mFakeUserRepo.addUser(WORK_USER, true); + mApplicationUser = WORK_PROFILE_USER_HANDLE; + + ChooserWrapperActivity activity = + mActivityRule.launchActivity(Intent.createChooser(sendIntent, "logger test")); + waitForIdle(); + + FakeEventLog eventLog = getEventLog(activity); + FakeEventLog.ChooserActivityShown event = eventLog.getChooserActivityShown(); + assertThat(event).isNotNull(); + assertThat(event.isWorkProfile()).isTrue(); + assertThat(event.getTargetMimeType()).isEqualTo(TEST_MIME_TYPE); + } + + @Test + public void testEmptyPreviewLogging() { + Intent sendIntent = createSendTextIntentWithPreview(null, null); + + ChooserWrapperActivity activity = + mActivityRule.launchActivity(Intent.createChooser(sendIntent, + "empty preview logger test")); + waitForIdle(); + + FakeEventLog eventLog = getEventLog(activity); + FakeEventLog.ChooserActivityShown event = eventLog.getChooserActivityShown(); + assertThat(event).isNotNull(); + assertThat(event.isWorkProfile()).isFalse(); + assertThat(event.getTargetMimeType()).isNull(); + } + + @Test + public void testTitlePreviewLogging() { + Intent sendIntent = createSendTextIntentWithPreview("TestTitle", null); + + List resolvedComponentInfos = createResolvedComponentsForTest(2); + + setupResolverControllers(resolvedComponentInfos); + + ChooserWrapperActivity activity = + mActivityRule.launchActivity(Intent.createChooser(sendIntent, null)); + waitForIdle(); + + FakeEventLog eventLog = getEventLog(activity); + assertThat(eventLog.getActionShareWithPreview()) + .isEqualTo(new FakeEventLog.ActionShareWithPreview( + /* previewType = */ CONTENT_PREVIEW_TEXT)); + } + + @Test + public void testImagePreviewLogging() { + Uri uri = createTestContentProviderUri("image/png", null); + + ArrayList uris = new ArrayList<>(); + uris.add(uri); + + Intent sendIntent = createSendUriIntentWithPreview(uris); + mFakeImageLoader.setBitmap(uri, createBitmap()); + + List resolvedComponentInfos = createResolvedComponentsForTest(2); + + setupResolverControllers(resolvedComponentInfos); + + ChooserWrapperActivity activity = + mActivityRule.launchActivity(Intent.createChooser(sendIntent, null)); + waitForIdle(); + + FakeEventLog eventLog = getEventLog(activity); + assertThat(eventLog.getActionShareWithPreview()) + .isEqualTo(new FakeEventLog.ActionShareWithPreview( + /* previewType = */ CONTENT_PREVIEW_IMAGE)); + } + + @Test + public void oneVisibleFilePreview() throws InterruptedException { + Uri uri = Uri.parse("content://com.android.frameworks.coretests/app.pdf"); + + ArrayList uris = new ArrayList<>(); + uris.add(uri); + + Intent sendIntent = createSendUriIntentWithPreview(uris); + + List resolvedComponentInfos = createResolvedComponentsForTest(2); + + setupResolverControllers(resolvedComponentInfos); + mActivityRule.launchActivity(Intent.createChooser(sendIntent, null)); + waitForIdle(); + onView(withId(R.id.content_preview_filename)).check(matches(isDisplayed())); + onView(withId(R.id.content_preview_filename)).check(matches(withText("app.pdf"))); + onView(withId(R.id.content_preview_file_icon)).check(matches(isDisplayed())); + } + + + @Test + public void moreThanOneVisibleFilePreview() throws InterruptedException { + Uri uri = Uri.parse("content://com.android.frameworks.coretests/app.pdf"); + + ArrayList uris = new ArrayList<>(); + uris.add(uri); + uris.add(uri); + uris.add(uri); + + Intent sendIntent = createSendUriIntentWithPreview(uris); + + List resolvedComponentInfos = createResolvedComponentsForTest(2); + + setupResolverControllers(resolvedComponentInfos); + mActivityRule.launchActivity(Intent.createChooser(sendIntent, null)); + waitForIdle(); + onView(withId(R.id.content_preview_filename)).check(matches(isDisplayed())); + onView(withId(R.id.content_preview_filename)).check(matches(withText("app.pdf"))); + onView(withId(R.id.content_preview_more_files)).check(matches(isDisplayed())); + onView(withId(R.id.content_preview_more_files)).check(matches(withText("+ 2 more files"))); + onView(withId(R.id.content_preview_file_icon)).check(matches(isDisplayed())); + } + + @Test + public void contentProviderThrowSecurityException() throws InterruptedException { + Uri uri = Uri.parse("content://com.android.frameworks.coretests/app.pdf"); + + ArrayList uris = new ArrayList<>(); + uris.add(uri); + + Intent sendIntent = createSendUriIntentWithPreview(uris); + + List resolvedComponentInfos = createResolvedComponentsForTest(2); + setupResolverControllers(resolvedComponentInfos); + + ChooserActivityOverrideData.getInstance().resolverForceException = true; + + mActivityRule.launchActivity(Intent.createChooser(sendIntent, null)); + waitForIdle(); + onView(withId(R.id.content_preview_filename)).check(matches(isDisplayed())); + onView(withId(R.id.content_preview_filename)).check(matches(withText("app.pdf"))); + onView(withId(R.id.content_preview_file_icon)).check(matches(isDisplayed())); + } + + @Test + public void contentProviderReturnsNoColumns() throws InterruptedException { + Uri uri = Uri.parse("content://com.android.frameworks.coretests/app.pdf"); + + ArrayList uris = new ArrayList<>(); + uris.add(uri); + uris.add(uri); + + Intent sendIntent = createSendUriIntentWithPreview(uris); + + List resolvedComponentInfos = createResolvedComponentsForTest(2); + setupResolverControllers(resolvedComponentInfos); + + Cursor cursor = mock(Cursor.class); + when(cursor.getCount()).thenReturn(1); + Mockito.doNothing().when(cursor).close(); + when(cursor.moveToFirst()).thenReturn(true); + when(cursor.getColumnIndex(Mockito.anyString())).thenReturn(-1); + + ChooserActivityOverrideData.getInstance().resolverCursor = cursor; + + mActivityRule.launchActivity(Intent.createChooser(sendIntent, null)); + waitForIdle(); + onView(withId(R.id.content_preview_filename)).check(matches(isDisplayed())); + onView(withId(R.id.content_preview_filename)).check(matches(withText("app.pdf"))); + onView(withId(R.id.content_preview_more_files)).check(matches(isDisplayed())); + onView(withId(R.id.content_preview_more_files)).check(matches(withText("+ 1 more file"))); + onView(withId(R.id.content_preview_file_icon)).check(matches(isDisplayed())); + } + + @Test + public void testGetBaseScore() { + final float testBaseScore = 0.89f; + + Intent sendIntent = createSendTextIntent(); + List resolvedComponentInfos = createResolvedComponentsForTest(2); + + setupResolverControllers(resolvedComponentInfos); + + when( + ChooserActivityOverrideData + .getInstance() + .resolverListController + .getScore(Mockito.isA(DisplayResolveInfo.class))) + .thenReturn(testBaseScore); + + final IChooserWrapper activity = (IChooserWrapper) + mActivityRule.launchActivity(Intent.createChooser(sendIntent, null)); + waitForIdle(); + + final DisplayResolveInfo testDri = + activity.createTestDisplayResolveInfo( + sendIntent, + ResolverDataProvider.createResolveInfo(3, 0, PERSONAL_USER_HANDLE), + "testLabel", + "testInfo", + sendIntent); + final ChooserListAdapter adapter = activity.getAdapter(); + + assertThat(adapter.getBaseScore(null, 0), is(CALLER_TARGET_SCORE_BOOST)); + assertThat(adapter.getBaseScore(testDri, TARGET_TYPE_DEFAULT), is(testBaseScore)); + assertThat(adapter.getBaseScore(testDri, TARGET_TYPE_CHOOSER_TARGET), is(testBaseScore)); + assertThat(adapter.getBaseScore(testDri, TARGET_TYPE_SHORTCUTS_FROM_PREDICTION_SERVICE), + is(testBaseScore * SHORTCUT_TARGET_SCORE_BOOST)); + assertThat(adapter.getBaseScore(testDri, TARGET_TYPE_SHORTCUTS_FROM_SHORTCUT_MANAGER), + is(testBaseScore * SHORTCUT_TARGET_SCORE_BOOST)); + } + + // This test is too long and too slow and should not be taken as an example for future tests. + @Test + public void testDirectTargetSelectionLogging() { + Intent sendIntent = createSendTextIntent(); + // We need app targets for direct targets to get displayed + List resolvedComponentInfos = createResolvedComponentsForTest(2); + setupResolverControllers(resolvedComponentInfos); + + // create test shortcut loader factory, remember loaders and their callbacks + SparseArray>> shortcutLoaders = + createShortcutLoaderFactory(); + + // Start activity + ChooserWrapperActivity activity = + mActivityRule.launchActivity(Intent.createChooser(sendIntent, null)); + waitForIdle(); + + // verify that ShortcutLoader was queried + ArgumentCaptor appTargets = + ArgumentCaptor.forClass(DisplayResolveInfo[].class); + verify(shortcutLoaders.get(0).first, times(1)).updateAppTargets(appTargets.capture()); + + // send shortcuts + assertThat( + "Wrong number of app targets", + appTargets.getValue().length, + is(resolvedComponentInfos.size())); + List serviceTargets = createDirectShareTargets(1, ""); + ShortcutLoader.Result result = new ShortcutLoader.Result( + true, + appTargets.getValue(), + new ShortcutLoader.ShortcutResultInfo[] { + new ShortcutLoader.ShortcutResultInfo( + appTargets.getValue()[0], + serviceTargets + ) + }, + new HashMap<>(), + new HashMap<>() + ); + activity.getMainExecutor().execute(() -> shortcutLoaders.get(0).second.accept(result)); + waitForIdle(); + + final ChooserListAdapter activeAdapter = activity.getAdapter(); + assertThat( + "Chooser should have 3 targets (2 apps, 1 direct)", + activeAdapter.getCount(), + is(3)); + assertThat( + "Chooser should have exactly one selectable direct target", + activeAdapter.getSelectableServiceTargetCount(), + is(1)); + assertThat( + "The resolver info must match the resolver info used to create the target", + activeAdapter.getItem(0).getResolveInfo(), + is(resolvedComponentInfos.get(0).getResolveInfoAt(0))); + + // Click on the direct target + String name = serviceTargets.get(0).getTitle().toString(); + onView(withText(name)) + .perform(click()); + waitForIdle(); + + FakeEventLog eventLog = getEventLog(activity); + assertThat(eventLog.getShareTargetSelected()).hasSize(1); + FakeEventLog.ShareTargetSelected call = eventLog.getShareTargetSelected().get(0); + assertThat(call.getTargetType()).isEqualTo(EventLog.SELECTION_TYPE_SERVICE); + assertThat(call.getDirectTargetAlsoRanked()).isEqualTo(-1); + var hashResult = call.getDirectTargetHashed(); + var hash = hashResult == null ? "" : hashResult.hashedString; + assertWithMessage("Hash is not predictable but must be obfuscated") + .that(hash).isNotEqualTo(name); + } + + // This test is too long and too slow and should not be taken as an example for future tests. + @Test + public void testDirectTargetLoggingWithRankedAppTarget() { + Intent sendIntent = createSendTextIntent(); + // We need app targets for direct targets to get displayed + List resolvedComponentInfos = createResolvedComponentsForTest(2); + setupResolverControllers(resolvedComponentInfos); + + // create test shortcut loader factory, remember loaders and their callbacks + SparseArray>> shortcutLoaders = + createShortcutLoaderFactory(); + + // Start activity + ChooserWrapperActivity activity = + mActivityRule.launchActivity(Intent.createChooser(sendIntent, null)); + waitForIdle(); + + // verify that ShortcutLoader was queried + ArgumentCaptor appTargets = + ArgumentCaptor.forClass(DisplayResolveInfo[].class); + verify(shortcutLoaders.get(0).first, times(1)).updateAppTargets(appTargets.capture()); + + // send shortcuts + assertThat( + "Wrong number of app targets", + appTargets.getValue().length, + is(resolvedComponentInfos.size())); + List serviceTargets = createDirectShareTargets( + 1, + resolvedComponentInfos.get(0).getResolveInfoAt(0).activityInfo.packageName); + ShortcutLoader.Result result = new ShortcutLoader.Result( + true, + appTargets.getValue(), + new ShortcutLoader.ShortcutResultInfo[] { + new ShortcutLoader.ShortcutResultInfo( + appTargets.getValue()[0], + serviceTargets + ) + }, + new HashMap<>(), + new HashMap<>() + ); + activity.getMainExecutor().execute(() -> shortcutLoaders.get(0).second.accept(result)); + waitForIdle(); + + final ChooserListAdapter activeAdapter = activity.getAdapter(); + assertThat( + "Chooser should have 3 targets (2 apps, 1 direct)", + activeAdapter.getCount(), + is(3)); + assertThat( + "Chooser should have exactly one selectable direct target", + activeAdapter.getSelectableServiceTargetCount(), + is(1)); + assertThat( + "The resolver info must match the resolver info used to create the target", + activeAdapter.getItem(0).getResolveInfo(), + is(resolvedComponentInfos.get(0).getResolveInfoAt(0))); + + // Click on the direct target + String name = serviceTargets.get(0).getTitle().toString(); + onView(withText(name)) + .perform(click()); + waitForIdle(); + + FakeEventLog eventLog = getEventLog(activity); + assertThat(eventLog.getShareTargetSelected()).hasSize(1); + FakeEventLog.ShareTargetSelected call = eventLog.getShareTargetSelected().get(0); + + assertThat(call.getTargetType()).isEqualTo(EventLog.SELECTION_TYPE_SERVICE); + assertThat(call.getDirectTargetAlsoRanked()).isEqualTo(0); + } + + @Test + public void testShortcutTargetWithApplyAppLimits() { + // Set up resources + Resources resources = Mockito.spy( + InstrumentationRegistry.getInstrumentation().getContext().getResources()); + ChooserActivityOverrideData.getInstance().resources = resources; + doReturn(1).when(resources).getInteger(R.integer.config_maxShortcutTargetsPerApp); + Intent sendIntent = createSendTextIntent(); + // We need app targets for direct targets to get displayed + List resolvedComponentInfos = createResolvedComponentsForTest(2); + setupResolverControllers(resolvedComponentInfos); + + // create test shortcut loader factory, remember loaders and their callbacks + SparseArray>> shortcutLoaders = + createShortcutLoaderFactory(); + + // Start activity + final IChooserWrapper activity = (IChooserWrapper) mActivityRule + .launchActivity(Intent.createChooser(sendIntent, null)); + waitForIdle(); + + // verify that ShortcutLoader was queried + ArgumentCaptor appTargets = + ArgumentCaptor.forClass(DisplayResolveInfo[].class); + verify(shortcutLoaders.get(0).first, times(1)).updateAppTargets(appTargets.capture()); + + // send shortcuts + assertThat( + "Wrong number of app targets", + appTargets.getValue().length, + is(resolvedComponentInfos.size())); + List serviceTargets = createDirectShareTargets( + 2, + resolvedComponentInfos.get(0).getResolveInfoAt(0).activityInfo.packageName); + ShortcutLoader.Result result = new ShortcutLoader.Result( + true, + appTargets.getValue(), + new ShortcutLoader.ShortcutResultInfo[] { + new ShortcutLoader.ShortcutResultInfo( + appTargets.getValue()[0], + serviceTargets + ) + }, + new HashMap<>(), + new HashMap<>() + ); + activity.getMainExecutor().execute(() -> shortcutLoaders.get(0).second.accept(result)); + waitForIdle(); + + final ChooserListAdapter activeAdapter = activity.getAdapter(); + assertThat( + "Chooser should have 3 targets (2 apps, 1 direct)", + activeAdapter.getCount(), + is(3)); + assertThat( + "Chooser should have exactly one selectable direct target", + activeAdapter.getSelectableServiceTargetCount(), + is(1)); + assertThat( + "The resolver info must match the resolver info used to create the target", + activeAdapter.getItem(0).getResolveInfo(), + is(resolvedComponentInfos.get(0).getResolveInfoAt(0))); + assertThat( + "The display label must match", + activeAdapter.getItem(0).getDisplayLabel(), + is("testTitle0")); + } + + @Test + public void testShortcutTargetWithoutApplyAppLimits() { + setDeviceConfigProperty( + SystemUiDeviceConfigFlags.APPLY_SHARING_APP_LIMITS_IN_SYSUI, + Boolean.toString(false)); + // Set up resources + Resources resources = Mockito.spy( + InstrumentationRegistry.getInstrumentation().getContext().getResources()); + ChooserActivityOverrideData.getInstance().resources = resources; + doReturn(1).when(resources).getInteger(R.integer.config_maxShortcutTargetsPerApp); + Intent sendIntent = createSendTextIntent(); + // We need app targets for direct targets to get displayed + List resolvedComponentInfos = createResolvedComponentsForTest(2); + setupResolverControllers(resolvedComponentInfos); + + // create test shortcut loader factory, remember loaders and their callbacks + SparseArray>> shortcutLoaders = + createShortcutLoaderFactory(); + + // Start activity + final IChooserWrapper activity = (IChooserWrapper) + mActivityRule.launchActivity(Intent.createChooser(sendIntent, null)); + waitForIdle(); + + // verify that ShortcutLoader was queried + ArgumentCaptor appTargets = + ArgumentCaptor.forClass(DisplayResolveInfo[].class); + verify(shortcutLoaders.get(0).first, times(1)).updateAppTargets(appTargets.capture()); + + // send shortcuts + assertThat( + "Wrong number of app targets", + appTargets.getValue().length, + is(resolvedComponentInfos.size())); + List serviceTargets = createDirectShareTargets( + 2, + resolvedComponentInfos.get(0).getResolveInfoAt(0).activityInfo.packageName); + ShortcutLoader.Result result = new ShortcutLoader.Result( + true, + appTargets.getValue(), + new ShortcutLoader.ShortcutResultInfo[] { + new ShortcutLoader.ShortcutResultInfo( + appTargets.getValue()[0], + serviceTargets + ) + }, + new HashMap<>(), + new HashMap<>() + ); + activity.getMainExecutor().execute(() -> shortcutLoaders.get(0).second.accept(result)); + waitForIdle(); + + final ChooserListAdapter activeAdapter = activity.getAdapter(); + assertThat( + "Chooser should have 4 targets (2 apps, 2 direct)", + activeAdapter.getCount(), + is(4)); + assertThat( + "Chooser should have exactly two selectable direct target", + activeAdapter.getSelectableServiceTargetCount(), + is(2)); + assertThat( + "The resolver info must match the resolver info used to create the target", + activeAdapter.getItem(0).getResolveInfo(), + is(resolvedComponentInfos.get(0).getResolveInfoAt(0))); + assertThat( + "The display label must match", + activeAdapter.getItem(0).getDisplayLabel(), + is("testTitle0")); + assertThat( + "The display label must match", + activeAdapter.getItem(1).getDisplayLabel(), + is("testTitle1")); + } + + @Test + public void testLaunchWithCallerProvidedTarget() { + setDeviceConfigProperty( + SystemUiDeviceConfigFlags.APPLY_SHARING_APP_LIMITS_IN_SYSUI, + Boolean.toString(false)); + // Set up resources + Resources resources = Mockito.spy( + InstrumentationRegistry.getInstrumentation().getContext().getResources()); + ChooserActivityOverrideData.getInstance().resources = resources; + doReturn(1).when(resources).getInteger(R.integer.config_maxShortcutTargetsPerApp); + + // We need app targets for direct targets to get displayed + List resolvedComponentInfos = createResolvedComponentsForTest(2); + setupResolverControllers(resolvedComponentInfos, resolvedComponentInfos); + markOtherProfileAvailability(/* workAvailable= */ true, /* cloneAvailable= */ false); + + // set caller-provided target + Intent chooserIntent = Intent.createChooser(createSendTextIntent(), null); + String callerTargetLabel = "Caller Target"; + ChooserTarget[] targets = new ChooserTarget[] { + new ChooserTarget( + callerTargetLabel, + Icon.createWithBitmap(createBitmap()), + 0.1f, + resolvedComponentInfos.get(0).name, + new Bundle()) + }; + chooserIntent.putExtra(Intent.EXTRA_CHOOSER_TARGETS, targets); + + // create test shortcut loader factory, remember loaders and their callbacks + SparseArray>> shortcutLoaders = + createShortcutLoaderFactory(); + + // Start activity + final IChooserWrapper activity = (IChooserWrapper) + mActivityRule.launchActivity(chooserIntent); + waitForIdle(); + + // verify that ShortcutLoader was queried + ArgumentCaptor appTargets = + ArgumentCaptor.forClass(DisplayResolveInfo[].class); + verify(shortcutLoaders.get(0).first, times(1)).updateAppTargets(appTargets.capture()); + + // send shortcuts + assertThat( + "Wrong number of app targets", + appTargets.getValue().length, + is(resolvedComponentInfos.size())); + ShortcutLoader.Result result = new ShortcutLoader.Result( + true, + appTargets.getValue(), + new ShortcutLoader.ShortcutResultInfo[0], + new HashMap<>(), + new HashMap<>()); + activity.getMainExecutor().execute(() -> shortcutLoaders.get(0).second.accept(result)); + waitForIdle(); + + final ChooserListAdapter activeAdapter = activity.getAdapter(); + assertThat( + "Chooser should have 3 targets (2 apps, 1 direct)", + activeAdapter.getCount(), + is(3)); + assertThat( + "Chooser should have exactly two selectable direct target", + activeAdapter.getSelectableServiceTargetCount(), + is(1)); + assertThat( + "The display label must match", + activeAdapter.getItem(0).getDisplayLabel(), + is(callerTargetLabel)); + + // Switch to work profile and ensure that the target *doesn't* show up there. + onView(withText(R.string.resolver_work_tab)).perform(click()); + waitForIdle(); + + for (int i = 0; i < activity.getWorkListAdapter().getCount(); i++) { + assertThat( + "Chooser target should not show up in opposite profile", + activity.getWorkListAdapter().getItem(i).getDisplayLabel(), + not(callerTargetLabel)); + } + } + + @Test + public void testLaunchWithCustomAction() throws InterruptedException { + List resolvedComponentInfos = createResolvedComponentsForTest(2); + setupResolverControllers(resolvedComponentInfos); + + Context testContext = InstrumentationRegistry.getInstrumentation().getContext(); + final String customActionLabel = "Custom Action"; + final String testAction = "test-broadcast-receiver-action"; + Intent chooserIntent = Intent.createChooser(createSendTextIntent(), null); + chooserIntent.putExtra( + Intent.EXTRA_CHOOSER_CUSTOM_ACTIONS, + new ChooserAction[] { + new ChooserAction.Builder( + Icon.createWithResource("", Resources.ID_NULL), + customActionLabel, + PendingIntent.getBroadcast( + testContext, + 123, + new Intent(testAction), + PendingIntent.FLAG_IMMUTABLE | PendingIntent.FLAG_ONE_SHOT)) + .build() + }); + // Start activity + mActivityRule.launchActivity(chooserIntent); + waitForIdle(); + + final CountDownLatch broadcastInvoked = new CountDownLatch(1); + BroadcastReceiver testReceiver = new BroadcastReceiver() { + @Override + public void onReceive(Context context, Intent intent) { + broadcastInvoked.countDown(); + } + }; + testContext.registerReceiver(testReceiver, new IntentFilter(testAction), + Context.RECEIVER_EXPORTED); + + try { + onView(withText(customActionLabel)).perform(click()); + assertTrue("Timeout waiting for broadcast", + broadcastInvoked.await(5000, TimeUnit.MILLISECONDS)); + } finally { + testContext.unregisterReceiver(testReceiver); + } + } + + @Test + public void testLaunchWithShareModification() throws InterruptedException { + List resolvedComponentInfos = createResolvedComponentsForTest(2); + setupResolverControllers(resolvedComponentInfos); + + Context testContext = InstrumentationRegistry.getInstrumentation().getContext(); + final String modifyShareAction = "test-broadcast-receiver-action"; + Intent chooserIntent = Intent.createChooser(createSendTextIntent(), null); + String label = "modify share"; + PendingIntent pendingIntent = PendingIntent.getBroadcast( + testContext, + 123, + new Intent(modifyShareAction), + PendingIntent.FLAG_IMMUTABLE | PendingIntent.FLAG_ONE_SHOT); + ChooserAction action = new ChooserAction.Builder(Icon.createWithBitmap( + createBitmap()), label, pendingIntent).build(); + chooserIntent.putExtra( + Intent.EXTRA_CHOOSER_MODIFY_SHARE_ACTION, + action); + // Start activity + mActivityRule.launchActivity(chooserIntent); + waitForIdle(); + + final CountDownLatch broadcastInvoked = new CountDownLatch(1); + BroadcastReceiver testReceiver = new BroadcastReceiver() { + @Override + public void onReceive(Context context, Intent intent) { + broadcastInvoked.countDown(); + } + }; + testContext.registerReceiver(testReceiver, new IntentFilter(modifyShareAction), + Context.RECEIVER_EXPORTED); + + try { + onView(withText(label)).perform(click()); + assertTrue("Timeout waiting for broadcast", + broadcastInvoked.await(5000, TimeUnit.MILLISECONDS)); + + } finally { + testContext.unregisterReceiver(testReceiver); + } + } + + @Test + public void testUpdateMaxTargetsPerRow_columnCountIsUpdated() throws InterruptedException { + updateMaxTargetsPerRowResource(/* targetsPerRow= */ 4); + givenAppTargets(/* appCount= */ 16); + Intent sendIntent = createSendTextIntent(); + final ChooserActivity activity = + mActivityRule.launchActivity(Intent.createChooser(sendIntent, null)); + + updateMaxTargetsPerRowResource(/* targetsPerRow= */ 6); + InstrumentationRegistry.getInstrumentation() + .runOnMainSync(() -> activity.onConfigurationChanged( + InstrumentationRegistry.getInstrumentation() + .getContext().getResources().getConfiguration())); + + waitForIdle(); + onView(withId(com.android.internal.R.id.resolver_list)) + .check(matches(withGridColumnCount(6))); + } + + // This test is too long and too slow and should not be taken as an example for future tests. + @Test @Ignore + public void testDirectTargetLoggingWithAppTargetNotRankedPortrait() + throws InterruptedException { + testDirectTargetLoggingWithAppTargetNotRanked(Configuration.ORIENTATION_PORTRAIT, 4); + } + + @Test @Ignore + public void testDirectTargetLoggingWithAppTargetNotRankedLandscape() + throws InterruptedException { + testDirectTargetLoggingWithAppTargetNotRanked(Configuration.ORIENTATION_LANDSCAPE, 8); + } + + private void testDirectTargetLoggingWithAppTargetNotRanked( + int orientation, int appTargetsExpected) { + Configuration configuration = + new Configuration(InstrumentationRegistry.getInstrumentation().getContext() + .getResources().getConfiguration()); + configuration.orientation = orientation; + + Resources resources = Mockito.spy( + InstrumentationRegistry.getInstrumentation().getContext().getResources()); + ChooserActivityOverrideData.getInstance().resources = resources; + doReturn(configuration).when(resources).getConfiguration(); + + Intent sendIntent = createSendTextIntent(); + // We need app targets for direct targets to get displayed + List resolvedComponentInfos = createResolvedComponentsForTest(15); + setupResolverControllers(resolvedComponentInfos); + + // Create direct share target + List serviceTargets = createDirectShareTargets(1, + resolvedComponentInfos.get(14).getResolveInfoAt(0).activityInfo.packageName); + ResolveInfo ri = ResolverDataProvider.createResolveInfo(16, 0, PERSONAL_USER_HANDLE); + + // Start activity + ChooserWrapperActivity activity = + mActivityRule.launchActivity(Intent.createChooser(sendIntent, null)); + // Insert the direct share target + Map directShareToShortcutInfos = new HashMap<>(); + directShareToShortcutInfos.put(serviceTargets.get(0), null); + InstrumentationRegistry.getInstrumentation().runOnMainSync( + () -> activity.getAdapter().addServiceResults( + activity.createTestDisplayResolveInfo(sendIntent, + ri, + "testLabel", + "testInfo", + sendIntent), + serviceTargets, + TARGET_TYPE_CHOOSER_TARGET, + directShareToShortcutInfos, + /* directShareToAppTargets */ null) + ); + + assertThat( + String.format("Chooser should have %d targets (%d apps, 1 direct, 15 A-Z)", + appTargetsExpected + 16, appTargetsExpected), + activity.getAdapter().getCount(), is(appTargetsExpected + 16)); + assertThat("Chooser should have exactly one selectable direct target", + activity.getAdapter().getSelectableServiceTargetCount(), is(1)); + assertThat("The resolver info must match the resolver info used to create the target", + activity.getAdapter().getItem(0).getResolveInfo(), is(ri)); + + // Click on the direct target + String name = serviceTargets.get(0).getTitle().toString(); + onView(withText(name)) + .perform(click()); + waitForIdle(); + + FakeEventLog eventLog = getEventLog(activity); + var invocations = eventLog.getShareTargetSelected(); + assertWithMessage("Only one ShareTargetSelected event logged") + .that(invocations).hasSize(1); + FakeEventLog.ShareTargetSelected call = invocations.get(0); + assertWithMessage("targetType should be SELECTION_TYPE_SERVICE") + .that(call.getTargetType()).isEqualTo(EventLog.SELECTION_TYPE_SERVICE); + assertWithMessage( + "The packages shouldn't match for app target and direct target") + .that(call.getDirectTargetAlsoRanked()).isEqualTo(-1); + } + + @Test + public void testWorkTab_displayedWhenWorkProfileUserAvailable() { + Intent sendIntent = createSendTextIntent(); + sendIntent.setType(TEST_MIME_TYPE); + markOtherProfileAvailability(/* workAvailable= */ true, /* cloneAvailable= */ false); + + mActivityRule.launchActivity(Intent.createChooser(sendIntent, "work tab test")); + waitForIdle(); + + onView(withId(android.R.id.tabs)).check(matches(isDisplayed())); + } + + @Test + public void testWorkTab_hiddenWhenWorkProfileUserNotAvailable() { + Intent sendIntent = createSendTextIntent(); + sendIntent.setType(TEST_MIME_TYPE); + + mActivityRule.launchActivity(Intent.createChooser(sendIntent, "work tab test")); + waitForIdle(); + + onView(withId(android.R.id.tabs)).check(matches(not(isDisplayed()))); + } + + @Test + public void testWorkTab_eachTabUsesExpectedAdapter() { + int personalProfileTargets = 3; + int otherProfileTargets = 1; + List personalResolvedComponentInfos = + createResolvedComponentsForTestWithOtherProfile( + personalProfileTargets + otherProfileTargets, /* userID */ 10); + int workProfileTargets = 4; + List workResolvedComponentInfos = createResolvedComponentsForTest( + workProfileTargets); + setupResolverControllers(personalResolvedComponentInfos, workResolvedComponentInfos); + Intent sendIntent = createSendTextIntent(); + sendIntent.setType(TEST_MIME_TYPE); + markOtherProfileAvailability(/* workAvailable= */ true, /* cloneAvailable= */ false); + + final IChooserWrapper activity = (IChooserWrapper) + mActivityRule.launchActivity(Intent.createChooser(sendIntent, "work tab test")); + waitForIdle(); + + assertThat(activity.getCurrentUserHandle().getIdentifier(), is(0)); + onView(withText(R.string.resolver_work_tab)).perform(click()); + assertThat(activity.getCurrentUserHandle().getIdentifier(), is(10)); + assertThat(activity.getPersonalListAdapter().getCount(), is(personalProfileTargets)); + assertThat(activity.getWorkListAdapter().getCount(), is(workProfileTargets)); + } + + @Test + public void testWorkTab_workProfileHasExpectedNumberOfTargets() throws InterruptedException { + markOtherProfileAvailability(/* workAvailable= */ true, /* cloneAvailable= */ false); + int workProfileTargets = 4; + List personalResolvedComponentInfos = + createResolvedComponentsForTestWithOtherProfile(3, /* userId */ 10); + List workResolvedComponentInfos = + createResolvedComponentsForTest(workProfileTargets); + setupResolverControllers(personalResolvedComponentInfos, workResolvedComponentInfos); + Intent sendIntent = createSendTextIntent(); + sendIntent.setType(TEST_MIME_TYPE); + + final IChooserWrapper activity = (IChooserWrapper) + mActivityRule.launchActivity(Intent.createChooser(sendIntent, "work tab test")); + waitForIdle(); + onView(withText(R.string.resolver_work_tab)).perform(click()); + waitForIdle(); + + assertThat(activity.getWorkListAdapter().getCount(), is(workProfileTargets)); + } + + @Test @Ignore + public void testWorkTab_selectingWorkTabAppOpensAppInWorkProfile() { + markOtherProfileAvailability(/* workAvailable= */ true, /* cloneAvailable= */ false); + List personalResolvedComponentInfos = + createResolvedComponentsForTestWithOtherProfile(3, /* userId */ 10); + int workProfileTargets = 4; + List workResolvedComponentInfos = + createResolvedComponentsForTest(workProfileTargets); + setupResolverControllers(personalResolvedComponentInfos, workResolvedComponentInfos); + Intent sendIntent = createSendTextIntent(); + sendIntent.setType(TEST_MIME_TYPE); + ResolveInfo[] chosen = new ResolveInfo[1]; + ChooserActivityOverrideData.getInstance().onSafelyStartInternalCallback = targetInfo -> { + chosen[0] = targetInfo.getResolveInfo(); + return true; + }; + + mActivityRule.launchActivity(Intent.createChooser(sendIntent, "work tab test")); + waitForIdle(); + onView(withText(R.string.resolver_work_tab)).perform(click()); + waitForIdle(); + + onView(first(allOf( + withText(workResolvedComponentInfos.get(0) + .getResolveInfoAt(0).activityInfo.applicationInfo.name), + isDisplayed()))) + .perform(click()); + waitForIdle(); + assertThat(chosen[0], is(workResolvedComponentInfos.get(0).getResolveInfoAt(0))); + } + + @Test + public void testWorkTab_crossProfileIntentsDisabled_personalToWork_emptyStateShown() { + markOtherProfileAvailability(/* workAvailable= */ true, /* cloneAvailable= */ false); + int workProfileTargets = 4; + List personalResolvedComponentInfos = + createResolvedComponentsForTestWithOtherProfile(3, /* userId */ 10); + List workResolvedComponentInfos = + createResolvedComponentsForTest(workProfileTargets); + ChooserActivityOverrideData.getInstance().hasCrossProfileIntents = false; + setupResolverControllers(personalResolvedComponentInfos, workResolvedComponentInfos); + Intent sendIntent = createSendTextIntent(); + sendIntent.setType(TEST_MIME_TYPE); + + mActivityRule.launchActivity(Intent.createChooser(sendIntent, "work tab test")); + waitForIdle(); + onView(withText(R.string.resolver_work_tab)).perform(click()); + waitForIdle(); + onView(withId(com.android.internal.R.id.contentPanel)) + .perform(swipeUp()); + + onView(withText(R.string.resolver_cross_profile_blocked)) + .check(matches(isDisplayed())); + } + + @Test + public void testWorkTab_workProfileDisabled_emptyStateShown() { + markOtherProfileAvailability(/* workAvailable= */ true, /* cloneAvailable= */ false); + int workProfileTargets = 4; + List personalResolvedComponentInfos = + createResolvedComponentsForTestWithOtherProfile(3, /* userId */ 10); + List workResolvedComponentInfos = + createResolvedComponentsForTest(workProfileTargets); + mFakeUserRepo.updateState(WORK_USER, false); + setupResolverControllers(personalResolvedComponentInfos, workResolvedComponentInfos); + Intent sendIntent = createSendTextIntent(); + sendIntent.setType(TEST_MIME_TYPE); + + mActivityRule.launchActivity(Intent.createChooser(sendIntent, "work tab test")); + waitForIdle(); + onView(withId(com.android.internal.R.id.contentPanel)) + .perform(swipeUp()); + onView(withText(R.string.resolver_work_tab)).perform(click()); + waitForIdle(); + + onView(withText(R.string.resolver_turn_on_work_apps)) + .check(matches(isDisplayed())); + } + + @Test + public void testWorkTab_noWorkAppsAvailable_emptyStateShown() { + markOtherProfileAvailability(/* workAvailable= */ true, /* cloneAvailable= */ false); + List personalResolvedComponentInfos = + createResolvedComponentsForTest(3); + List workResolvedComponentInfos = + createResolvedComponentsForTest(0); + setupResolverControllers(personalResolvedComponentInfos, workResolvedComponentInfos); + Intent sendIntent = createSendTextIntent(); + sendIntent.setType(TEST_MIME_TYPE); + + mActivityRule.launchActivity(Intent.createChooser(sendIntent, "work tab test")); + waitForIdle(); + onView(withId(com.android.internal.R.id.contentPanel)) + .perform(swipeUp()); + onView(withText(R.string.resolver_work_tab)).perform(click()); + waitForIdle(); + + onView(withText(R.string.resolver_no_work_apps_available)) + .check(matches(isDisplayed())); + } + + @Test + @RequiresFlagsEnabled(Flags.FLAG_SCROLLABLE_PREVIEW) + public void testWorkTab_previewIsScrollable() { + markOtherProfileAvailability(/* workAvailable= */ true, /* cloneAvailable= */ false); + List personalResolvedComponentInfos = + createResolvedComponentsForTest(300); + List workResolvedComponentInfos = + createResolvedComponentsForTest(3); + setupResolverControllers(personalResolvedComponentInfos, workResolvedComponentInfos); + + Uri uri = createTestContentProviderUri("image/png", null); + + ArrayList uris = new ArrayList<>(); + uris.add(uri); + + Intent sendIntent = createSendUriIntentWithPreview(uris); + mFakeImageLoader.setBitmap(uri, createWideBitmap()); + + mActivityRule.launchActivity(Intent.createChooser(sendIntent, "Scrollable preview test")); + waitForIdle(); + + onView(withId(com.android.intentresolver.R.id.scrollable_image_preview)) + .check(matches(isDisplayed())); + + onView(withId(com.android.internal.R.id.contentPanel)).perform(swipeUp()); + waitForIdle(); + + onView(withId(com.android.intentresolver.R.id.chooser_headline_row_container)) + .check(matches(isCompletelyDisplayed())); + onView(withId(com.android.intentresolver.R.id.headline)) + .check(matches(isDisplayed())); + onView(withId(com.android.intentresolver.R.id.scrollable_image_preview)) + .check(matches(not(isDisplayed()))); + } + + @Ignore // b/220067877 + @Test + public void testWorkTab_xProfileOff_noAppsAvailable_workOff_xProfileOffEmptyStateShown() { + markOtherProfileAvailability(/* workAvailable= */ true, /* cloneAvailable= */ false); + List personalResolvedComponentInfos = + createResolvedComponentsForTest(3); + List workResolvedComponentInfos = + createResolvedComponentsForTest(0); + setupResolverControllers(personalResolvedComponentInfos, workResolvedComponentInfos); + mFakeUserRepo.updateState(WORK_USER, false); + ChooserActivityOverrideData.getInstance().hasCrossProfileIntents = false; + Intent sendIntent = createSendTextIntent(); + sendIntent.setType(TEST_MIME_TYPE); + + mActivityRule.launchActivity(Intent.createChooser(sendIntent, "work tab test")); + waitForIdle(); + onView(withId(com.android.internal.R.id.contentPanel)) + .perform(swipeUp()); + onView(withText(R.string.resolver_work_tab)).perform(click()); + waitForIdle(); + + onView(withText(R.string.resolver_cross_profile_blocked)) + .check(matches(isDisplayed())); + } + + @Test + public void testWorkTab_noAppsAvailable_workOff_noAppsAvailableEmptyStateShown() { + markOtherProfileAvailability(/* workAvailable= */ true, /* cloneAvailable= */ false); + List personalResolvedComponentInfos = + createResolvedComponentsForTest(3); + List workResolvedComponentInfos = + createResolvedComponentsForTest(0); + setupResolverControllers(personalResolvedComponentInfos, workResolvedComponentInfos); + mFakeUserRepo.updateState(WORK_USER, false); + Intent sendIntent = createSendTextIntent(); + sendIntent.setType(TEST_MIME_TYPE); + + mActivityRule.launchActivity(Intent.createChooser(sendIntent, "work tab test")); + waitForIdle(); + onView(withId(com.android.internal.R.id.contentPanel)) + .perform(swipeUp()); + onView(withText(R.string.resolver_work_tab)).perform(click()); + waitForIdle(); + + onView(withText(R.string.resolver_no_work_apps_available)) + .check(matches(isDisplayed())); + } + + @Test @Ignore("b/222124533") + public void testAppTargetLogging() throws InterruptedException { + Intent sendIntent = createSendTextIntent(); + List resolvedComponentInfos = createResolvedComponentsForTest(2); + + setupResolverControllers(resolvedComponentInfos); + + final IChooserWrapper activity = (IChooserWrapper) + mActivityRule.launchActivity(Intent.createChooser(sendIntent, null)); + waitForIdle(); + + // TODO(b/222124533): other test cases use a timeout to make sure that the UI is fully + // populated; without one, this test flakes. Ideally we should address the need for a + // timeout everywhere instead of introducing one to fix this particular test. + + assertThat(activity.getAdapter().getCount(), is(2)); + onView(withId(com.android.internal.R.id.profile_button)).check(doesNotExist()); + + ResolveInfo[] chosen = new ResolveInfo[1]; + ChooserActivityOverrideData.getInstance().onSafelyStartInternalCallback = targetInfo -> { + chosen[0] = targetInfo.getResolveInfo(); + return true; + }; + + ResolveInfo toChoose = resolvedComponentInfos.get(0).getResolveInfoAt(0); + onView(withText(toChoose.activityInfo.name)) + .perform(click()); + waitForIdle(); + + // TODO(b/211669337): Determine the expected SHARESHEET_DIRECT_LOAD_COMPLETE events. + } + + @Test + public void testDirectTargetLogging() { + Intent sendIntent = createSendTextIntent(); + // We need app targets for direct targets to get displayed + List resolvedComponentInfos = createResolvedComponentsForTest(2); + setupResolverControllers(resolvedComponentInfos); + + // create test shortcut loader factory, remember loaders and their callbacks + SparseArray>> shortcutLoaders = + new SparseArray<>(); + ChooserActivityOverrideData.getInstance().shortcutLoaderFactory = + (userHandle, callback) -> { + Pair> pair = + new Pair<>(mock(ShortcutLoader.class), callback); + shortcutLoaders.put(userHandle.getIdentifier(), pair); + return pair.first; + }; + + // Start activity + ChooserWrapperActivity activity = + mActivityRule.launchActivity(Intent.createChooser(sendIntent, null)); + waitForIdle(); + + // verify that ShortcutLoader was queried + ArgumentCaptor appTargets = + ArgumentCaptor.forClass(DisplayResolveInfo[].class); + verify(shortcutLoaders.get(0).first, times(1)) + .updateAppTargets(appTargets.capture()); + + // send shortcuts + assertThat( + "Wrong number of app targets", + appTargets.getValue().length, + is(resolvedComponentInfos.size())); + List serviceTargets = createDirectShareTargets(1, + resolvedComponentInfos.get(0).getResolveInfoAt(0).activityInfo.packageName); + ShortcutLoader.Result result = new ShortcutLoader.Result( + // TODO: test another value as well + false, + appTargets.getValue(), + new ShortcutLoader.ShortcutResultInfo[] { + new ShortcutLoader.ShortcutResultInfo( + appTargets.getValue()[0], + serviceTargets + ) + }, + new HashMap<>(), + new HashMap<>() + ); + activity.getMainExecutor().execute(() -> shortcutLoaders.get(0).second.accept(result)); + waitForIdle(); + + assertThat("Chooser should have 3 targets (2 apps, 1 direct)", + activity.getAdapter().getCount(), is(3)); + assertThat("Chooser should have exactly one selectable direct target", + activity.getAdapter().getSelectableServiceTargetCount(), is(1)); + assertThat( + "The resolver info must match the resolver info used to create the target", + activity.getAdapter().getItem(0).getResolveInfo(), + is(resolvedComponentInfos.get(0).getResolveInfoAt(0))); + + // Click on the direct target + String name = serviceTargets.get(0).getTitle().toString(); + onView(withText(name)) + .perform(click()); + waitForIdle(); + + FakeEventLog eventLog = getEventLog(activity); + assertThat(eventLog.getShareTargetSelected()).hasSize(1); + FakeEventLog.ShareTargetSelected call = eventLog.getShareTargetSelected().get(0); + assertThat(call.getTargetType()).isEqualTo(EventLog.SELECTION_TYPE_SERVICE); + } + + @Test + public void testDirectTargetPinningDialog() { + Intent sendIntent = createSendTextIntent(); + // We need app targets for direct targets to get displayed + List resolvedComponentInfos = createResolvedComponentsForTest(2); + setupResolverControllers(resolvedComponentInfos); + + // create test shortcut loader factory, remember loaders and their callbacks + SparseArray>> shortcutLoaders = + new SparseArray<>(); + ChooserActivityOverrideData.getInstance().shortcutLoaderFactory = + (userHandle, callback) -> { + Pair> pair = + new Pair<>(mock(ShortcutLoader.class), callback); + shortcutLoaders.put(userHandle.getIdentifier(), pair); + return pair.first; + }; + + // Start activity + final IChooserWrapper activity = (IChooserWrapper) + mActivityRule.launchActivity(Intent.createChooser(sendIntent, null)); + waitForIdle(); + + // verify that ShortcutLoader was queried + ArgumentCaptor appTargets = + ArgumentCaptor.forClass(DisplayResolveInfo[].class); + verify(shortcutLoaders.get(0).first, times(1)) + .updateAppTargets(appTargets.capture()); + + // send shortcuts + List serviceTargets = createDirectShareTargets( + 1, + resolvedComponentInfos.get(0).getResolveInfoAt(0).activityInfo.packageName); + ShortcutLoader.Result result = new ShortcutLoader.Result( + // TODO: test another value as well + false, + appTargets.getValue(), + new ShortcutLoader.ShortcutResultInfo[] { + new ShortcutLoader.ShortcutResultInfo( + appTargets.getValue()[0], + serviceTargets + ) + }, + new HashMap<>(), + new HashMap<>() + ); + activity.getMainExecutor().execute(() -> shortcutLoaders.get(0).second.accept(result)); + waitForIdle(); + + // Long-click on the direct target + String name = serviceTargets.get(0).getTitle().toString(); + onView(withText(name)).perform(longClick()); + waitForIdle(); + + onView(withId(R.id.chooser_dialog_content)).check(matches(isDisplayed())); + } + + @Test @Ignore + public void testEmptyDirectRowLogging() throws InterruptedException { + Intent sendIntent = createSendTextIntent(); + // We need app targets for direct targets to get displayed + List resolvedComponentInfos = createResolvedComponentsForTest(2); + setupResolverControllers(resolvedComponentInfos); + + // Start activity + final IChooserWrapper activity = (IChooserWrapper) + mActivityRule.launchActivity(Intent.createChooser(sendIntent, null)); + + // Thread.sleep shouldn't be a thing in an integration test but it's + // necessary here because of the way the code is structured + Thread.sleep(3000); + + assertThat("Chooser should have 2 app targets", + activity.getAdapter().getCount(), is(2)); + assertThat("Chooser should have no direct targets", + activity.getAdapter().getSelectableServiceTargetCount(), is(0)); + + // TODO(b/211669337): Determine the expected SHARESHEET_DIRECT_LOAD_COMPLETE events. + } + + @Ignore // b/220067877 + @Test + public void testCopyTextToClipboardLogging() throws Exception { + Intent sendIntent = createSendTextIntent(); + List resolvedComponentInfos = createResolvedComponentsForTest(2); + + setupResolverControllers(resolvedComponentInfos); + + final IChooserWrapper activity = (IChooserWrapper) + mActivityRule.launchActivity(Intent.createChooser(sendIntent, null)); + waitForIdle(); + + onView(withId(com.android.internal.R.id.chooser_copy_button)).check(matches(isDisplayed())); + onView(withId(com.android.internal.R.id.chooser_copy_button)).perform(click()); + + // TODO(b/211669337): Determine the expected SHARESHEET_DIRECT_LOAD_COMPLETE events. + } + + @Test @Ignore("b/222124533") + public void testSwitchProfileLogging() throws InterruptedException { + markOtherProfileAvailability(/* workAvailable= */ true, /* cloneAvailable= */ false); + int workProfileTargets = 4; + List personalResolvedComponentInfos = + createResolvedComponentsForTestWithOtherProfile(3, /* userId */ 10); + List workResolvedComponentInfos = + createResolvedComponentsForTest(workProfileTargets); + setupResolverControllers(personalResolvedComponentInfos, workResolvedComponentInfos); + Intent sendIntent = createSendTextIntent(); + sendIntent.setType(TEST_MIME_TYPE); + + final IChooserWrapper activity = (IChooserWrapper) + mActivityRule.launchActivity(Intent.createChooser(sendIntent, "work tab test")); + waitForIdle(); + onView(withText(R.string.resolver_work_tab)).perform(click()); + waitForIdle(); + onView(withText(R.string.resolver_personal_tab)).perform(click()); + waitForIdle(); + + // TODO(b/211669337): Determine the expected SHARESHEET_DIRECT_LOAD_COMPLETE events. + } + + @Test + public void testWorkTab_onePersonalTarget_emptyStateOnWorkTarget_doesNotAutoLaunch() { + markOtherProfileAvailability(/* workAvailable= */ true, /* cloneAvailable= */ false); + int workProfileTargets = 4; + List personalResolvedComponentInfos = + createResolvedComponentsForTestWithOtherProfile(2, /* userId */ 10); + List workResolvedComponentInfos = + createResolvedComponentsForTest(workProfileTargets); + ChooserActivityOverrideData.getInstance().hasCrossProfileIntents = false; + setupResolverControllers(personalResolvedComponentInfos, workResolvedComponentInfos); + Intent sendIntent = createSendTextIntent(); + ResolveInfo[] chosen = new ResolveInfo[1]; + ChooserActivityOverrideData.getInstance().onSafelyStartInternalCallback = targetInfo -> { + chosen[0] = targetInfo.getResolveInfo(); + return true; + }; + + mActivityRule.launchActivity(Intent.createChooser(sendIntent, "Test")); + waitForIdle(); + + assertNull(chosen[0]); + } + + @Test + public void testOneInitialIntent_noAutolaunch() { + List personalResolvedComponentInfos = + createResolvedComponentsForTest(1); + setupResolverControllers(personalResolvedComponentInfos); + Intent chooserIntent = createChooserIntent(createSendTextIntent(), + new Intent[] {new Intent("action.fake")}); + ResolveInfo[] chosen = new ResolveInfo[1]; + ChooserActivityOverrideData.getInstance().onSafelyStartInternalCallback = targetInfo -> { + chosen[0] = targetInfo.getResolveInfo(); + return true; + }; + mPackageManager = createFakePackageManager(createFakeResolveInfo()); + waitForIdle(); + + IChooserWrapper activity = (IChooserWrapper) mActivityRule.launchActivity(chooserIntent); + waitForIdle(); + + assertNull(chosen[0]); + assertThat(activity + .getPersonalListAdapter().getCallerTargetCount(), is(1)); + } + + @Test + public void testWorkTab_withInitialIntents_workTabDoesNotIncludePersonalInitialIntents() { + markOtherProfileAvailability(/* workAvailable= */ true, /* cloneAvailable= */ false); + int workProfileTargets = 1; + List personalResolvedComponentInfos = + createResolvedComponentsForTestWithOtherProfile(2, /* userId */ 10); + List workResolvedComponentInfos = + createResolvedComponentsForTest(workProfileTargets); + setupResolverControllers(personalResolvedComponentInfos, workResolvedComponentInfos); + Intent[] initialIntents = { + new Intent("action.fake1"), + new Intent("action.fake2") + }; + Intent chooserIntent = createChooserIntent(createSendTextIntent(), initialIntents); + mPackageManager = createFakePackageManager(createFakeResolveInfo()); + waitForIdle(); + + IChooserWrapper activity = (IChooserWrapper) mActivityRule.launchActivity(chooserIntent); + waitForIdle(); + + assertThat(activity.getPersonalListAdapter().getCallerTargetCount(), is(2)); + assertThat(activity.getWorkListAdapter().getCallerTargetCount(), is(0)); + } + + @Test + public void testWorkTab_xProfileIntentsDisabled_personalToWork_nonSendIntent_emptyStateShown() { + markOtherProfileAvailability(/* workAvailable= */ true, /* cloneAvailable= */ false); + int workProfileTargets = 4; + List personalResolvedComponentInfos = + createResolvedComponentsForTestWithOtherProfile(3, /* userId */ 10); + List workResolvedComponentInfos = + createResolvedComponentsForTest(workProfileTargets); + ChooserActivityOverrideData.getInstance().hasCrossProfileIntents = false; + setupResolverControllers(personalResolvedComponentInfos, workResolvedComponentInfos); + Intent[] initialIntents = { + new Intent("action.fake1"), + new Intent("action.fake2") + }; + Intent chooserIntent = createChooserIntent(new Intent(), initialIntents); + mPackageManager = createFakePackageManager(createFakeResolveInfo()); + + + mActivityRule.launchActivity(chooserIntent); + waitForIdle(); + onView(withText(R.string.resolver_work_tab)).perform(click()); + waitForIdle(); + onView(withId(com.android.internal.R.id.contentPanel)) + .perform(swipeUp()); + + onView(withText(R.string.resolver_cross_profile_blocked)) + .check(matches(isDisplayed())); + } + + @Test + public void testWorkTab_noWorkAppsAvailable_nonSendIntent_emptyStateShown() { + markOtherProfileAvailability(/* workAvailable= */ true, /* cloneAvailable= */ false); + List personalResolvedComponentInfos = + createResolvedComponentsForTest(3); + List workResolvedComponentInfos = + createResolvedComponentsForTest(0); + setupResolverControllers(personalResolvedComponentInfos, workResolvedComponentInfos); + Intent[] initialIntents = { + new Intent("action.fake1"), + new Intent("action.fake2") + }; + Intent chooserIntent = createChooserIntent(new Intent(), initialIntents); + mPackageManager = createFakePackageManager(createFakeResolveInfo()); + + + mActivityRule.launchActivity(chooserIntent); + waitForIdle(); + onView(withId(com.android.internal.R.id.contentPanel)) + .perform(swipeUp()); + onView(withText(R.string.resolver_work_tab)).perform(click()); + waitForIdle(); + + onView(withText(R.string.resolver_no_work_apps_available)) + .check(matches(isDisplayed())); + } + + @Test + public void testDeduplicateCallerTargetRankedTarget() { + // Create 4 ranked app targets. + List personalResolvedComponentInfos = + createResolvedComponentsForTest(4); + setupResolverControllers(personalResolvedComponentInfos); + // Create caller target which is duplicate with one of app targets + Intent chooserIntent = createChooserIntent(createSendTextIntent(), + new Intent[] {new Intent("action.fake")}); + mPackageManager = createFakePackageManager(ResolverDataProvider.createResolveInfo(0, + UserHandle.USER_CURRENT, PERSONAL_USER_HANDLE)); + waitForIdle(); + + IChooserWrapper activity = (IChooserWrapper) mActivityRule.launchActivity(chooserIntent); + waitForIdle(); + + // Total 4 targets (1 caller target, 3 ranked targets) + assertThat(activity.getAdapter().getCount(), is(4)); + assertThat(activity.getAdapter().getCallerTargetCount(), is(1)); + assertThat(activity.getAdapter().getRankedTargetCount(), is(3)); + } + + @Test + public void test_query_shortcut_loader_for_the_selected_tab() { + markOtherProfileAvailability(/* workAvailable= */ true, /* cloneAvailable= */ false); + List personalResolvedComponentInfos = + createResolvedComponentsForTestWithOtherProfile(3, /* userId */ 10); + List workResolvedComponentInfos = + createResolvedComponentsForTest(3); + setupResolverControllers(personalResolvedComponentInfos, workResolvedComponentInfos); + ShortcutLoader personalProfileShortcutLoader = mock(ShortcutLoader.class); + ShortcutLoader workProfileShortcutLoader = mock(ShortcutLoader.class); + final SparseArray shortcutLoaders = new SparseArray<>(); + shortcutLoaders.put(0, personalProfileShortcutLoader); + shortcutLoaders.put(10, workProfileShortcutLoader); + ChooserActivityOverrideData.getInstance().shortcutLoaderFactory = + (userHandle, callback) -> shortcutLoaders.get(userHandle.getIdentifier(), null); + Intent sendIntent = createSendTextIntent(); + sendIntent.setType(TEST_MIME_TYPE); + + mActivityRule.launchActivity(Intent.createChooser(sendIntent, "work tab test")); + waitForIdle(); + onView(withId(com.android.internal.R.id.contentPanel)) + .perform(swipeUp()); + waitForIdle(); + + verify(personalProfileShortcutLoader, times(1)).updateAppTargets(any()); + + onView(withText(R.string.resolver_work_tab)).perform(click()); + waitForIdle(); + + verify(workProfileShortcutLoader, times(1)).updateAppTargets(any()); + } + + @Test + public void testClonedProfilePresent_personalAdapterIsSetWithPersonalProfile() { + // enable cloneProfile + markOtherProfileAvailability(/* workAvailable= */ false, /* cloneAvailable= */ true); + List resolvedComponentInfos = + createResolvedComponentsWithCloneProfileForTest( + 3, + PERSONAL_USER_HANDLE, + CLONE_PROFILE_USER_HANDLE); + setupResolverControllers(resolvedComponentInfos); + Intent sendIntent = createSendTextIntent(); + + final IChooserWrapper activity = (IChooserWrapper) mActivityRule + .launchActivity(Intent.createChooser(sendIntent, "personalProfileTest")); + waitForIdle(); + + assertThat(activity.getPersonalListAdapter().getUserHandle(), is(PERSONAL_USER_HANDLE)); + assertThat(activity.getAdapter().getCount(), is(3)); + } + + @Test + public void testClonedProfilePresent_personalTabUsesExpectedAdapter() { + markOtherProfileAvailability(/* workAvailable= */ true, /* cloneAvailable= */ true); + List personalResolvedComponentInfos = + createResolvedComponentsForTest(3); + List workResolvedComponentInfos = createResolvedComponentsForTest( + 4); + setupResolverControllers(personalResolvedComponentInfos, workResolvedComponentInfos); + Intent sendIntent = createSendTextIntent(); + sendIntent.setType(TEST_MIME_TYPE); + + + final IChooserWrapper activity = (IChooserWrapper) + mActivityRule.launchActivity(Intent.createChooser(sendIntent, "multi tab test")); + waitForIdle(); + + assertThat(activity.getCurrentUserHandle(), is(PERSONAL_USER_HANDLE)); + } + + private Intent createChooserIntent(Intent intent, Intent[] initialIntents) { + Intent chooserIntent = new Intent(); + chooserIntent.setAction(Intent.ACTION_CHOOSER); + chooserIntent.putExtra(Intent.EXTRA_TEXT, "testing intent sending"); + chooserIntent.putExtra(Intent.EXTRA_TITLE, "some title"); + chooserIntent.putExtra(Intent.EXTRA_INTENT, intent); + chooserIntent.setType("text/plain"); + if (initialIntents != null) { + chooserIntent.putExtra(Intent.EXTRA_INITIAL_INTENTS, initialIntents); + } + return chooserIntent; + } + + /* This is a "test of a test" to make sure that our inherited test class + * is successfully configured to operate on the unbundled-equivalent + * ChooserWrapperActivity. + * + * TODO: remove after unbundling is complete. + */ + @Test + public void testWrapperActivityHasExpectedConcreteType() { + final ChooserActivity activity = mActivityRule.launchActivity( + Intent.createChooser(new Intent("ACTION_FOO"), "foo")); + waitForIdle(); + assertThat(activity).isInstanceOf(ChooserWrapperActivity.class); + } + + private ResolveInfo createFakeResolveInfo() { + ResolveInfo ri = new ResolveInfo(); + ri.activityInfo = new ActivityInfo(); + ri.activityInfo.name = "FakeActivityName"; + ri.activityInfo.packageName = "fake.package.name"; + ri.activityInfo.applicationInfo = new ApplicationInfo(); + ri.activityInfo.applicationInfo.packageName = "fake.package.name"; + ri.userHandle = UserHandle.CURRENT; + return ri; + } + + private Intent createSendTextIntent() { + Intent sendIntent = new Intent(); + sendIntent.setAction(Intent.ACTION_SEND); + sendIntent.putExtra(Intent.EXTRA_TEXT, "testing intent sending"); + sendIntent.setType("text/plain"); + return sendIntent; + } + + private Intent createSendImageIntent(Uri imageThumbnail) { + Intent sendIntent = new Intent(); + sendIntent.setAction(Intent.ACTION_SEND); + sendIntent.putExtra(Intent.EXTRA_STREAM, imageThumbnail); + sendIntent.setType("image/png"); + if (imageThumbnail != null) { + ClipData.Item clipItem = new ClipData.Item(imageThumbnail); + sendIntent.setClipData(new ClipData("Clip Label", new String[]{"image/png"}, clipItem)); + } + + return sendIntent; + } + + private Uri createTestContentProviderUri( + @Nullable String mimeType, @Nullable String streamType) { + return createTestContentProviderUri(mimeType, streamType, 0); + } + + private Uri createTestContentProviderUri( + @Nullable String mimeType, @Nullable String streamType, long streamTypeTimeout) { + String packageName = + InstrumentationRegistry.getInstrumentation().getContext().getPackageName(); + Uri.Builder builder = Uri.parse("content://" + packageName + "/image.png") + .buildUpon(); + if (mimeType != null) { + builder.appendQueryParameter(TestContentProvider.PARAM_MIME_TYPE, mimeType); + } + if (streamType != null) { + builder.appendQueryParameter(TestContentProvider.PARAM_STREAM_TYPE, streamType); + } + if (streamTypeTimeout > 0) { + builder.appendQueryParameter( + TestContentProvider.PARAM_STREAM_TYPE_TIMEOUT, + Long.toString(streamTypeTimeout)); + } + return builder.build(); + } + + private Intent createSendTextIntentWithPreview(String title, Uri imageThumbnail) { + Intent sendIntent = new Intent(); + sendIntent.setAction(Intent.ACTION_SEND); + sendIntent.putExtra(Intent.EXTRA_TEXT, "testing intent sending"); + sendIntent.putExtra(Intent.EXTRA_TITLE, title); + if (imageThumbnail != null) { + ClipData.Item clipItem = new ClipData.Item(imageThumbnail); + sendIntent.setClipData(new ClipData("Clip Label", new String[]{"image/png"}, clipItem)); + } + + return sendIntent; + } + + private Intent createSendUriIntentWithPreview(ArrayList uris) { + Intent sendIntent = new Intent(); + + if (uris.size() > 1) { + sendIntent.setAction(Intent.ACTION_SEND_MULTIPLE); + sendIntent.putExtra(Intent.EXTRA_STREAM, uris); + } else { + sendIntent.setAction(Intent.ACTION_SEND); + sendIntent.putExtra(Intent.EXTRA_STREAM, uris.get(0)); + } + + return sendIntent; + } + + private Intent createViewTextIntent() { + Intent viewIntent = new Intent(); + viewIntent.setAction(Intent.ACTION_VIEW); + viewIntent.putExtra(Intent.EXTRA_TEXT, "testing intent viewing"); + return viewIntent; + } + + private List createResolvedComponentsForTest(int numberOfResults) { + List infoList = new ArrayList<>(numberOfResults); + for (int i = 0; i < numberOfResults; i++) { + infoList.add(ResolverDataProvider.createResolvedComponentInfo(i, PERSONAL_USER_HANDLE)); + } + return infoList; + } + + private List createResolvedComponentsWithCloneProfileForTest( + int numberOfResults, + UserHandle resolvedForPersonalUser, + UserHandle resolvedForClonedUser) { + List infoList = new ArrayList<>(numberOfResults); + for (int i = 0; i < 1; i++) { + infoList.add(ResolverDataProvider.createResolvedComponentInfo(i, + resolvedForPersonalUser)); + } + for (int i = 1; i < numberOfResults; i++) { + infoList.add(ResolverDataProvider.createResolvedComponentInfo(i, + resolvedForClonedUser)); + } + return infoList; + } + + private List createResolvedComponentsForTestWithOtherProfile( + int numberOfResults) { + List infoList = new ArrayList<>(numberOfResults); + for (int i = 0; i < numberOfResults; i++) { + if (i == 0) { + infoList.add(ResolverDataProvider.createResolvedComponentInfoWithOtherId(i, + PERSONAL_USER_HANDLE)); + } else { + infoList.add(ResolverDataProvider.createResolvedComponentInfo(i, + PERSONAL_USER_HANDLE)); + } + } + return infoList; + } + + private List createResolvedComponentsForTestWithOtherProfile( + int numberOfResults, int userId) { + List infoList = new ArrayList<>(numberOfResults); + for (int i = 0; i < numberOfResults; i++) { + if (i == 0) { + infoList.add( + ResolverDataProvider.createResolvedComponentInfoWithOtherId(i, userId, + PERSONAL_USER_HANDLE)); + } else { + infoList.add(ResolverDataProvider.createResolvedComponentInfo(i, + PERSONAL_USER_HANDLE)); + } + } + return infoList; + } + + private List createResolvedComponentsForTestWithUserId( + int numberOfResults, int userId) { + List infoList = new ArrayList<>(numberOfResults); + for (int i = 0; i < numberOfResults; i++) { + infoList.add(ResolverDataProvider.createResolvedComponentInfoWithOtherId(i, userId, + PERSONAL_USER_HANDLE)); + } + return infoList; + } + + private List createDirectShareTargets(int numberOfResults, String packageName) { + Icon icon = Icon.createWithBitmap(createBitmap()); + String testTitle = "testTitle"; + List targets = new ArrayList<>(); + for (int i = 0; i < numberOfResults; i++) { + ComponentName componentName; + if (packageName.isEmpty()) { + componentName = ResolverDataProvider.createComponentName(i); + } else { + componentName = new ComponentName(packageName, packageName + ".class"); + } + ChooserTarget tempTarget = new ChooserTarget( + testTitle + i, + icon, + (float) (1 - ((i + 1) / 10.0)), + componentName, + null); + targets.add(tempTarget); + } + return targets; + } + + private void waitForIdle() { + InstrumentationRegistry.getInstrumentation().waitForIdleSync(); + } + + private Bitmap createBitmap() { + return createBitmap(200, 200); + } + + private Bitmap createWideBitmap() { + return createWideBitmap(Color.RED); + } + + private Bitmap createWideBitmap(int bgColor) { + WindowManager windowManager = InstrumentationRegistry.getInstrumentation() + .getTargetContext() + .getSystemService(WindowManager.class); + int width = 3000; + if (windowManager != null) { + Rect bounds = windowManager.getMaximumWindowMetrics().getBounds(); + width = bounds.width() + 200; + } + return createBitmap(width, 100, bgColor); + } + + private Bitmap createBitmap(int width, int height) { + return createBitmap(width, height, Color.RED); + } + + private Bitmap createBitmap(int width, int height, int bgColor) { + Bitmap bitmap = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888); + Canvas canvas = new Canvas(bitmap); + + Paint paint = new Paint(); + paint.setColor(bgColor); + paint.setStyle(Paint.Style.FILL); + canvas.drawPaint(paint); + + paint.setColor(Color.WHITE); + paint.setAntiAlias(true); + paint.setTextSize(14.f); + paint.setTextAlign(Paint.Align.CENTER); + canvas.drawText("Hi!", (width / 2.f), (height / 2.f), paint); + + return bitmap; + } + + private List createShortcuts(Context context) { + Intent testIntent = new Intent("TestIntent"); + + List shortcuts = new ArrayList<>(); + shortcuts.add(new ShareShortcutInfo( + new ShortcutInfo.Builder(context, "shortcut1") + .setIntent(testIntent).setShortLabel("label1").setRank(3).build(), // 0 2 + new ComponentName("package1", "class1"))); + shortcuts.add(new ShareShortcutInfo( + new ShortcutInfo.Builder(context, "shortcut2") + .setIntent(testIntent).setShortLabel("label2").setRank(7).build(), // 1 3 + new ComponentName("package2", "class2"))); + shortcuts.add(new ShareShortcutInfo( + new ShortcutInfo.Builder(context, "shortcut3") + .setIntent(testIntent).setShortLabel("label3").setRank(1).build(), // 2 0 + new ComponentName("package3", "class3"))); + shortcuts.add(new ShareShortcutInfo( + new ShortcutInfo.Builder(context, "shortcut4") + .setIntent(testIntent).setShortLabel("label4").setRank(3).build(), // 3 2 + new ComponentName("package4", "class4"))); + + return shortcuts; + } + + private void markOtherProfileAvailability(boolean workAvailable, boolean cloneAvailable) { + if (workAvailable) { + mFakeUserRepo.addUser(WORK_USER, /* available= */ true); + } + if (cloneAvailable) { + mFakeUserRepo.addUser(CLONE_USER, /* available= */ true); + } + } + + private void setupResolverControllers( + List personalResolvedComponentInfos) { + setupResolverControllers(personalResolvedComponentInfos, new ArrayList<>()); + } + + private void setupResolverControllers( + List personalResolvedComponentInfos, + List workResolvedComponentInfos) { + when( + ChooserActivityOverrideData + .getInstance() + .resolverListController + .getResolversForIntentAsUser( + Mockito.anyBoolean(), + Mockito.anyBoolean(), + Mockito.anyBoolean(), + Mockito.isA(List.class), + eq(PERSONAL_USER_HANDLE))) + .thenReturn(new ArrayList<>(personalResolvedComponentInfos)); + when( + ChooserActivityOverrideData + .getInstance() + .workResolverListController + .getResolversForIntentAsUser( + Mockito.anyBoolean(), + Mockito.anyBoolean(), + Mockito.anyBoolean(), + Mockito.isA(List.class), + eq(WORK_PROFILE_USER_HANDLE))) + .thenReturn(new ArrayList<>(workResolvedComponentInfos)); + } + + private static GridRecyclerSpanCountMatcher withGridColumnCount(int columnCount) { + return new GridRecyclerSpanCountMatcher(Matchers.is(columnCount)); + } + + private static class GridRecyclerSpanCountMatcher extends + BoundedDiagnosingMatcher { + + private final Matcher mIntegerMatcher; + + private GridRecyclerSpanCountMatcher(Matcher integerMatcher) { + super(RecyclerView.class); + this.mIntegerMatcher = integerMatcher; + } + + @Override + protected void describeMoreTo(Description description) { + description.appendText("RecyclerView grid layout span count to match: "); + this.mIntegerMatcher.describeTo(description); + } + + @Override + protected boolean matchesSafely(RecyclerView view, Description mismatchDescription) { + int spanCount = ((GridLayoutManager) view.getLayoutManager()).getSpanCount(); + if (this.mIntegerMatcher.matches(spanCount)) { + return true; + } else { + mismatchDescription.appendText("RecyclerView grid layout span count was ") + .appendValue(spanCount); + return false; + } + } + } + + private void givenAppTargets(int appCount) { + List resolvedComponentInfos = + createResolvedComponentsForTest(appCount); + setupResolverControllers(resolvedComponentInfos); + } + + private void updateMaxTargetsPerRowResource(int targetsPerRow) { + Resources resources = Mockito.spy( + InstrumentationRegistry.getInstrumentation().getContext().getResources()); + ChooserActivityOverrideData.getInstance().resources = resources; + doReturn(targetsPerRow).when(resources).getInteger( + R.integer.config_chooser_max_targets_per_row); + } + + private SparseArray>> + createShortcutLoaderFactory() { + SparseArray>> shortcutLoaders = + new SparseArray<>(); + ChooserActivityOverrideData.getInstance().shortcutLoaderFactory = + (userHandle, callback) -> { + Pair> pair = + new Pair<>(mock(ShortcutLoader.class), callback); + shortcutLoaders.put(userHandle.getIdentifier(), pair); + return pair.first; + }; + return shortcutLoaders; + } +} diff --git a/tests/activity/src/com/android/intentresolver/ChooserActivityWorkProfileTest.java b/tests/activity/src/com/android/intentresolver/ChooserActivityWorkProfileTest.java new file mode 100644 index 00000000..5795cc37 --- /dev/null +++ b/tests/activity/src/com/android/intentresolver/ChooserActivityWorkProfileTest.java @@ -0,0 +1,504 @@ +/* + * Copyright (C) 2022 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.intentresolver; + +import static android.testing.PollingCheck.waitFor; + +import static androidx.test.espresso.Espresso.onView; +import static androidx.test.espresso.action.ViewActions.click; +import static androidx.test.espresso.action.ViewActions.swipeUp; +import static androidx.test.espresso.assertion.ViewAssertions.matches; +import static androidx.test.espresso.matcher.ViewMatchers.isDisplayed; +import static androidx.test.espresso.matcher.ViewMatchers.isSelected; +import static androidx.test.espresso.matcher.ViewMatchers.withId; +import static androidx.test.espresso.matcher.ViewMatchers.withText; + +import static com.android.intentresolver.ChooserWrapperActivity.sOverrides; +import static com.android.intentresolver.ChooserActivityWorkProfileTest.TestCase.ExpectedBlocker.NO_BLOCKER; +import static com.android.intentresolver.ChooserActivityWorkProfileTest.TestCase.ExpectedBlocker.PERSONAL_PROFILE_ACCESS_BLOCKER; +import static com.android.intentresolver.ChooserActivityWorkProfileTest.TestCase.ExpectedBlocker.PERSONAL_PROFILE_SHARE_BLOCKER; +import static com.android.intentresolver.ChooserActivityWorkProfileTest.TestCase.ExpectedBlocker.WORK_PROFILE_ACCESS_BLOCKER; +import static com.android.intentresolver.ChooserActivityWorkProfileTest.TestCase.ExpectedBlocker.WORK_PROFILE_SHARE_BLOCKER; +import static com.android.intentresolver.ChooserActivityWorkProfileTest.TestCase.Tab.PERSONAL; +import static com.android.intentresolver.ChooserActivityWorkProfileTest.TestCase.Tab.WORK; + +import static org.hamcrest.CoreMatchers.not; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.when; + +import android.companion.DeviceFilter; +import android.content.Intent; +import android.os.UserHandle; + +import androidx.test.espresso.NoMatchingViewException; +import androidx.test.platform.app.InstrumentationRegistry; +import androidx.test.rule.ActivityTestRule; + +import com.android.intentresolver.ChooserActivityWorkProfileTest.TestCase.Tab; +import com.android.intentresolver.data.repository.FakeUserRepository; +import com.android.intentresolver.data.repository.UserRepository; +import com.android.intentresolver.data.repository.UserRepositoryModule; +import com.android.intentresolver.inject.ApplicationUser; +import com.android.intentresolver.inject.ProfileParent; +import com.android.intentresolver.shared.model.User; + +import dagger.hilt.android.testing.BindValue; +import dagger.hilt.android.testing.HiltAndroidRule; +import dagger.hilt.android.testing.HiltAndroidTest; +import dagger.hilt.android.testing.UninstallModules; + +import junit.framework.AssertionFailedError; + +import org.junit.Before; +import org.junit.Rule; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.junit.runners.Parameterized; +import org.mockito.Mockito; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collection; +import java.util.List; + +@DeviceFilter.MediumType +@RunWith(Parameterized.class) +@HiltAndroidTest +@UninstallModules(UserRepositoryModule.class) +public class ChooserActivityWorkProfileTest { + + private static final UserHandle PERSONAL_USER_HANDLE = InstrumentationRegistry + .getInstrumentation().getTargetContext().getUser(); + private static final UserHandle WORK_USER_HANDLE = UserHandle.of(10); + + @Rule(order = 0) + public HiltAndroidRule mHiltAndroidRule = new HiltAndroidRule(this); + + @Rule(order = 1) + public ActivityTestRule 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 ChooserActivityWorkProfileTest(TestCase testCase) { + mTestCase = testCase; + mApplicationUser = mTestCase.getMyUserHandle(); + mProfileParent = PERSONAL_USER_HANDLE; + mUserRepository = new FakeUserRepository(List.of( + new User(PERSONAL_USER_HANDLE.getIdentifier(), User.Role.PERSONAL), + new User(WORK_USER_HANDLE.getIdentifier(), User.Role.WORK))); + } + + @Before + public void cleanOverrideData() { + // TODO: use the other form of `adoptShellPermissionIdentity()` where we explicitly list the + // permissions we require (which we'll read from the manifest at runtime). + InstrumentationRegistry + .getInstrumentation() + .getUiAutomation() + .adoptShellPermissionIdentity(); + + sOverrides.reset(); + } + + @Test + public void testBlocker() { + setUpPersonalAndWorkComponentInfos(); + sOverrides.hasCrossProfileIntents = mTestCase.hasCrossProfileIntents(); + + launchActivity(mTestCase.getIsSendAction()); + switchToTab(mTestCase.getTab()); + + switch (mTestCase.getExpectedBlocker()) { + case NO_BLOCKER: + assertNoBlockerDisplayed(); + break; + case PERSONAL_PROFILE_SHARE_BLOCKER: + assertCantSharePersonalAppsBlockerDisplayed(); + break; + case WORK_PROFILE_SHARE_BLOCKER: + assertCantShareWorkAppsBlockerDisplayed(); + break; + case PERSONAL_PROFILE_ACCESS_BLOCKER: + assertCantAccessPersonalAppsBlockerDisplayed(); + break; + case WORK_PROFILE_ACCESS_BLOCKER: + assertCantAccessWorkAppsBlockerDisplayed(); + break; + } + } + + @Parameterized.Parameters(name = "{0}") + public static Collection tests() { + return Arrays.asList( + new TestCase( + /* isSendAction= */ true, + /* hasCrossProfileIntents= */ true, + /* myUserHandle= */ WORK_USER_HANDLE, + /* tab= */ WORK, + /* expectedBlocker= */ NO_BLOCKER + ), + new TestCase( + /* isSendAction= */ true, + /* hasCrossProfileIntents= */ false, + /* myUserHandle= */ WORK_USER_HANDLE, + /* tab= */ WORK, + /* expectedBlocker= */ NO_BLOCKER + ), + new TestCase( + /* isSendAction= */ true, + /* hasCrossProfileIntents= */ true, + /* myUserHandle= */ PERSONAL_USER_HANDLE, + /* tab= */ WORK, + /* expectedBlocker= */ NO_BLOCKER + ), + new TestCase( + /* isSendAction= */ true, + /* hasCrossProfileIntents= */ false, + /* myUserHandle= */ PERSONAL_USER_HANDLE, + /* tab= */ WORK, + /* expectedBlocker= */ WORK_PROFILE_SHARE_BLOCKER + ), + new TestCase( + /* isSendAction= */ true, + /* hasCrossProfileIntents= */ true, + /* myUserHandle= */ WORK_USER_HANDLE, + /* tab= */ PERSONAL, + /* expectedBlocker= */ NO_BLOCKER + ), + new TestCase( + /* isSendAction= */ true, + /* hasCrossProfileIntents= */ false, + /* myUserHandle= */ WORK_USER_HANDLE, + /* tab= */ PERSONAL, + /* expectedBlocker= */ PERSONAL_PROFILE_SHARE_BLOCKER + ), + new TestCase( + /* isSendAction= */ true, + /* hasCrossProfileIntents= */ true, + /* myUserHandle= */ PERSONAL_USER_HANDLE, + /* tab= */ PERSONAL, + /* expectedBlocker= */ NO_BLOCKER + ), + new TestCase( + /* isSendAction= */ true, + /* hasCrossProfileIntents= */ false, + /* myUserHandle= */ PERSONAL_USER_HANDLE, + /* tab= */ PERSONAL, + /* expectedBlocker= */ NO_BLOCKER + ), + new TestCase( + /* isSendAction= */ false, + /* hasCrossProfileIntents= */ true, + /* myUserHandle= */ WORK_USER_HANDLE, + /* tab= */ WORK, + /* expectedBlocker= */ NO_BLOCKER + ), + new TestCase( + /* isSendAction= */ false, + /* hasCrossProfileIntents= */ false, + /* myUserHandle= */ WORK_USER_HANDLE, + /* tab= */ WORK, + /* expectedBlocker= */ NO_BLOCKER + ), + new TestCase( + /* isSendAction= */ false, + /* hasCrossProfileIntents= */ true, + /* myUserHandle= */ PERSONAL_USER_HANDLE, + /* tab= */ WORK, + /* expectedBlocker= */ NO_BLOCKER + ), + new TestCase( + /* isSendAction= */ false, + /* hasCrossProfileIntents= */ false, + /* myUserHandle= */ PERSONAL_USER_HANDLE, + /* tab= */ WORK, + /* expectedBlocker= */ WORK_PROFILE_ACCESS_BLOCKER + ), + new TestCase( + /* isSendAction= */ false, + /* hasCrossProfileIntents= */ true, + /* myUserHandle= */ WORK_USER_HANDLE, + /* tab= */ PERSONAL, + /* expectedBlocker= */ NO_BLOCKER + ), + new TestCase( + /* isSendAction= */ false, + /* hasCrossProfileIntents= */ false, + /* myUserHandle= */ WORK_USER_HANDLE, + /* tab= */ PERSONAL, + /* expectedBlocker= */ PERSONAL_PROFILE_ACCESS_BLOCKER + ), + new TestCase( + /* isSendAction= */ false, + /* hasCrossProfileIntents= */ true, + /* myUserHandle= */ PERSONAL_USER_HANDLE, + /* tab= */ PERSONAL, + /* expectedBlocker= */ NO_BLOCKER + ), + new TestCase( + /* isSendAction= */ false, + /* hasCrossProfileIntents= */ false, + /* myUserHandle= */ PERSONAL_USER_HANDLE, + /* tab= */ PERSONAL, + /* expectedBlocker= */ NO_BLOCKER + ) + ); + } + + private List createResolvedComponentsForTestWithOtherProfile( + int numberOfResults, int userId, UserHandle resolvedForUser) { + List infoList = new ArrayList<>(numberOfResults); + for (int i = 0; i < numberOfResults; i++) { + infoList.add( + ResolverDataProvider + .createResolvedComponentInfoWithOtherId(i, userId, resolvedForUser)); + } + return infoList; + } + + private List createResolvedComponentsForTest(int numberOfResults, + UserHandle resolvedForUser) { + List infoList = new ArrayList<>(numberOfResults); + for (int i = 0; i < numberOfResults; i++) { + infoList.add(ResolverDataProvider.createResolvedComponentInfo(i, resolvedForUser)); + } + return infoList; + } + + private void setUpPersonalAndWorkComponentInfos() { + int workProfileTargets = 4; + List personalResolvedComponentInfos = + createResolvedComponentsForTestWithOtherProfile(3, + /* userId */ WORK_USER_HANDLE.getIdentifier(), PERSONAL_USER_HANDLE); + List workResolvedComponentInfos = + createResolvedComponentsForTest(workProfileTargets, WORK_USER_HANDLE); + setupResolverControllers(personalResolvedComponentInfos, workResolvedComponentInfos); + } + + private void setupResolverControllers( + List personalResolvedComponentInfos, + List workResolvedComponentInfos) { + when(sOverrides.resolverListController.getResolversForIntentAsUser( + Mockito.anyBoolean(), + Mockito.anyBoolean(), + Mockito.anyBoolean(), + Mockito.isA(List.class), + eq(UserHandle.SYSTEM))) + .thenReturn(new ArrayList<>(personalResolvedComponentInfos)); + when(sOverrides.workResolverListController.getResolversForIntentAsUser( + Mockito.anyBoolean(), + Mockito.anyBoolean(), + Mockito.anyBoolean(), + Mockito.isA(List.class), + eq(UserHandle.SYSTEM))) + .thenReturn(new ArrayList<>(personalResolvedComponentInfos)); + when(sOverrides.workResolverListController.getResolversForIntentAsUser( + Mockito.anyBoolean(), + Mockito.anyBoolean(), + Mockito.anyBoolean(), + Mockito.isA(List.class), + eq(WORK_USER_HANDLE))) + .thenReturn(new ArrayList<>(workResolvedComponentInfos)); + } + + private void waitForIdle() { + InstrumentationRegistry.getInstrumentation().waitForIdleSync(); + } + + private void assertCantAccessWorkAppsBlockerDisplayed() { + onView(withText(R.string.resolver_cross_profile_blocked)) + .check(matches(isDisplayed())); + onView(withText(R.string.resolver_cant_access_work_apps_explanation)) + .check(matches(isDisplayed())); + } + + private void assertCantAccessPersonalAppsBlockerDisplayed() { + onView(withText(R.string.resolver_cross_profile_blocked)) + .check(matches(isDisplayed())); + onView(withText(R.string.resolver_cant_access_personal_apps_explanation)) + .check(matches(isDisplayed())); + } + + private void assertCantShareWorkAppsBlockerDisplayed() { + onView(withText(R.string.resolver_cross_profile_blocked)) + .check(matches(isDisplayed())); + onView(withText(R.string.resolver_cant_share_with_work_apps_explanation)) + .check(matches(isDisplayed())); + } + + private void assertCantSharePersonalAppsBlockerDisplayed() { + onView(withText(R.string.resolver_cross_profile_blocked)) + .check(matches(isDisplayed())); + onView(withText(R.string.resolver_cant_share_with_personal_apps_explanation)) + .check(matches(isDisplayed())); + } + + private void assertNoBlockerDisplayed() { + try { + onView(withText(R.string.resolver_cross_profile_blocked)) + .check(matches(not(isDisplayed()))); + } catch (NoMatchingViewException ignored) { + } + } + + private void switchToTab(Tab tab) { + final int stringId = tab == Tab.WORK ? R.string.resolver_work_tab + : R.string.resolver_personal_tab; + + waitFor(() -> { + onView(withText(stringId)).perform(click()); + waitForIdle(); + + try { + onView(withText(stringId)).check(matches(isSelected())); + return true; + } catch (AssertionFailedError e) { + return false; + } + }); + + onView(withId(com.android.internal.R.id.contentPanel)) + .perform(swipeUp()); + waitForIdle(); + } + + private Intent createTextIntent(boolean isSendAction) { + Intent sendIntent = new Intent(); + if (isSendAction) { + sendIntent.setAction(Intent.ACTION_SEND); + } + sendIntent.putExtra(Intent.EXTRA_TEXT, "testing intent sending"); + sendIntent.setType("text/plain"); + return sendIntent; + } + + private void launchActivity(boolean isSendAction) { + Intent sendIntent = createTextIntent(isSendAction); + mActivityRule.launchActivity(Intent.createChooser(sendIntent, "Test")); + waitForIdle(); + } + + public static class TestCase { + private final boolean mIsSendAction; + private final boolean mHasCrossProfileIntents; + private final UserHandle mMyUserHandle; + private final Tab mTab; + private final ExpectedBlocker mExpectedBlocker; + + public enum ExpectedBlocker { + NO_BLOCKER, + PERSONAL_PROFILE_SHARE_BLOCKER, + WORK_PROFILE_SHARE_BLOCKER, + PERSONAL_PROFILE_ACCESS_BLOCKER, + WORK_PROFILE_ACCESS_BLOCKER + } + + public enum Tab { + WORK, + PERSONAL + } + + public TestCase(boolean isSendAction, boolean hasCrossProfileIntents, + UserHandle myUserHandle, Tab tab, ExpectedBlocker expectedBlocker) { + mIsSendAction = isSendAction; + mHasCrossProfileIntents = hasCrossProfileIntents; + mMyUserHandle = myUserHandle; + mTab = tab; + mExpectedBlocker = expectedBlocker; + } + + public boolean getIsSendAction() { + return mIsSendAction; + } + + public boolean hasCrossProfileIntents() { + return mHasCrossProfileIntents; + } + + public UserHandle getMyUserHandle() { + return mMyUserHandle; + } + + public Tab getTab() { + return mTab; + } + + public ExpectedBlocker getExpectedBlocker() { + return mExpectedBlocker; + } + + @Override + public String toString() { + StringBuilder result = new StringBuilder("test"); + + if (mTab == WORK) { + result.append("WorkTab_"); + } else { + result.append("PersonalTab_"); + } + + if (mIsSendAction) { + result.append("sendAction_"); + } else { + result.append("notSendAction_"); + } + + if (mHasCrossProfileIntents) { + result.append("hasCrossProfileIntents_"); + } else { + result.append("doesNotHaveCrossProfileIntents_"); + } + + if (mMyUserHandle.equals(PERSONAL_USER_HANDLE)) { + result.append("myUserIsPersonal_"); + } else { + result.append("myUserIsWork_"); + } + + if (mExpectedBlocker == ExpectedBlocker.NO_BLOCKER) { + result.append("thenNoBlocker"); + } else if (mExpectedBlocker == PERSONAL_PROFILE_ACCESS_BLOCKER) { + result.append("thenAccessBlockerOnPersonalProfile"); + } else if (mExpectedBlocker == PERSONAL_PROFILE_SHARE_BLOCKER) { + result.append("thenShareBlockerOnPersonalProfile"); + } else if (mExpectedBlocker == WORK_PROFILE_ACCESS_BLOCKER) { + result.append("thenAccessBlockerOnWorkProfile"); + } else if (mExpectedBlocker == WORK_PROFILE_SHARE_BLOCKER) { + result.append("thenShareBlockerOnWorkProfile"); + } + + return result.toString(); + } + } +} diff --git a/tests/activity/src/com/android/intentresolver/ChooserWrapperActivity.java b/tests/activity/src/com/android/intentresolver/ChooserWrapperActivity.java index 37bbc6ce..4b71aa29 100644 --- a/tests/activity/src/com/android/intentresolver/ChooserWrapperActivity.java +++ b/tests/activity/src/com/android/intentresolver/ChooserWrapperActivity.java @@ -16,14 +16,13 @@ package com.android.intentresolver; +import android.annotation.Nullable; import android.app.prediction.AppPredictor; import android.app.usage.UsageStatsManager; -import android.content.ComponentName; import android.content.ContentResolver; import android.content.Context; import android.content.Intent; import android.content.IntentFilter; -import android.content.pm.PackageManager; import android.content.pm.ResolveInfo; import android.content.res.Resources; import android.database.Cursor; @@ -31,17 +30,12 @@ import android.net.Uri; import android.os.Bundle; import android.os.UserHandle; -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; import androidx.lifecycle.ViewModelProvider; import com.android.intentresolver.chooser.DisplayResolveInfo; import com.android.intentresolver.chooser.TargetInfo; import com.android.intentresolver.emptystate.CrossProfileIntentsChecker; -import com.android.intentresolver.grid.ChooserGridAdapter; -import com.android.intentresolver.icons.TargetDataLoader; import com.android.intentresolver.shortcuts.ShortcutLoader; -import com.android.internal.logging.nano.MetricsProto.MetricsEvent; import java.util.List; import java.util.function.Consumer; @@ -55,7 +49,7 @@ public class ChooserWrapperActivity extends ChooserActivity implements IChooserW private UsageStatsManager mUsm; @Override - public ChooserListAdapter createChooserListAdapter( + public final ChooserListAdapter createChooserListAdapter( Context context, List payloadIntents, Intent[] initialIntents, @@ -64,12 +58,9 @@ public class ChooserWrapperActivity extends ChooserActivity implements IChooserW ResolverListController resolverListController, UserHandle userHandle, Intent targetIntent, - Intent referrrerFillInIntent, - int maxTargetsPerRow, - TargetDataLoader targetDataLoader) { - PackageManager packageManager = - sOverrides.packageManager == null ? context.getPackageManager() - : sOverrides.packageManager; + Intent referrerFillInIntent, + int maxTargetsPerRow) { + return new ChooserListAdapter( context, payloadIntents, @@ -79,13 +70,13 @@ public class ChooserWrapperActivity extends ChooserActivity implements IChooserW createListController(userHandle), userHandle, targetIntent, - referrrerFillInIntent, + referrerFillInIntent, this, - packageManager, + mPackageManager, getEventLog(), maxTargetsPerRow, userHandle, - targetDataLoader, + mTargetDataLoader, null, mFeatureFlags); } @@ -97,17 +88,12 @@ public class ChooserWrapperActivity extends ChooserActivity implements IChooserW @Override public ChooserListAdapter getPersonalListAdapter() { - return ((ChooserGridAdapter) mMultiProfilePagerAdapter.getAdapterForIndex(0)) - .getListAdapter(); + return mChooserMultiProfilePagerAdapter.getPersonalListAdapter(); } @Override public ChooserListAdapter getWorkListAdapter() { - if (mMultiProfilePagerAdapter.getInactiveListAdapter() == null) { - return null; - } - return ((ChooserGridAdapter) mMultiProfilePagerAdapter.getAdapterForIndex(1)) - .getListAdapter(); + return mChooserMultiProfilePagerAdapter.getWorkListAdapter(); } @Override @@ -115,16 +101,6 @@ public class ChooserWrapperActivity extends ChooserActivity implements IChooserW return mIsSuccessfullySelected; } - @Override - protected ChooserIntegratedDeviceComponents getIntegratedDeviceComponents() { - return new ChooserIntegratedDeviceComponents( - /* editSharingComponent=*/ null, - // An arbitrary pre-installed activity that handles this type of intent: - /* nearbySharingComponent=*/ new ComponentName( - "com.google.android.apps.messaging", - ".ui.conversationlist.ShareIntentActivity")); - } - @Override public UsageStatsManager getUsageStatsManager() { if (mUsm == null) { @@ -149,14 +125,6 @@ public class ChooserWrapperActivity extends ChooserActivity implements IChooserW return super.createCrossProfileIntentsChecker(); } - @Override - protected WorkProfileAvailabilityManager createWorkProfileAvailabilityManager() { - if (sOverrides.mWorkProfileAvailability != null) { - return sOverrides.mWorkProfileAvailability; - } - return super.createWorkProfileAvailabilityManager(); - } - @Override public void safelyStartActivityInternal(TargetInfo cti, UserHandle user, @Nullable Bundle options) { @@ -168,21 +136,13 @@ 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; } return sOverrides.workResolverListController; } - @Override - public PackageManager getPackageManager() { - if (sOverrides.createPackageManager != null) { - return sOverrides.createPackageManager.apply(super.getPackageManager()); - } - return super.getPackageManager(); - } - @Override public Resources getResources() { if (sOverrides.resources != null) { @@ -211,14 +171,6 @@ public class ChooserWrapperActivity extends ChooserActivity implements IChooserW return super.queryResolver(resolver, uri); } - @Override - protected boolean isWorkProfile() { - if (sOverrides.alternateProfileSetting != 0) { - return sOverrides.alternateProfileSetting == MetricsEvent.MANAGED_PROFILE; - } - return super.isWorkProfile(); - } - @Override public DisplayResolveInfo createTestDisplayResolveInfo( Intent originalIntent, @@ -234,17 +186,11 @@ public class ChooserWrapperActivity extends ChooserActivity implements IChooserW replacementIntent); } - @Override - protected AnnotatedUserHandles computeAnnotatedUserHandles() { - return sOverrides.annotatedUserHandles; - } - @Override public UserHandle getCurrentUserHandle() { - return mMultiProfilePagerAdapter.getCurrentUserHandle(); + return mChooserMultiProfilePagerAdapter.getCurrentUserHandle(); } - @NonNull @Override public Context createContextAsUser(UserHandle user, int flags) { // return the current context as a work profile doesn't really exist in these tests diff --git a/tests/activity/src/com/android/intentresolver/ResolverActivityTest.java b/tests/activity/src/com/android/intentresolver/ResolverActivityTest.java index 81f6f5a6..b44f4f91 100644 --- a/tests/activity/src/com/android/intentresolver/ResolverActivityTest.java +++ b/tests/activity/src/com/android/intentresolver/ResolverActivityTest.java @@ -25,6 +25,7 @@ import static androidx.test.espresso.matcher.ViewMatchers.isDisplayed; import static androidx.test.espresso.matcher.ViewMatchers.isEnabled; import static androidx.test.espresso.matcher.ViewMatchers.withId; import static androidx.test.espresso.matcher.ViewMatchers.withText; +import static androidx.test.platform.app.InstrumentationRegistry.getInstrumentation; import static com.android.intentresolver.MatcherUtils.first; import static com.android.intentresolver.ResolverWrapperActivity.sOverrides; @@ -55,10 +56,21 @@ import androidx.test.platform.app.InstrumentationRegistry; import androidx.test.rule.ActivityTestRule; import androidx.test.runner.AndroidJUnit4; +import com.android.intentresolver.data.repository.FakeUserRepository; +import com.android.intentresolver.data.repository.UserRepository; +import com.android.intentresolver.data.repository.UserRepositoryModule; +import com.android.intentresolver.inject.ApplicationUser; +import com.android.intentresolver.inject.ProfileParent; +import com.android.intentresolver.shared.model.User; import com.android.intentresolver.widget.ResolverDrawerLayout; import com.google.android.collect.Lists; +import dagger.hilt.android.testing.BindValue; +import dagger.hilt.android.testing.HiltAndroidRule; +import dagger.hilt.android.testing.HiltAndroidTest; +import dagger.hilt.android.testing.UninstallModules; + import org.junit.Before; import org.junit.Ignore; import org.junit.Rule; @@ -73,14 +85,21 @@ import java.util.List; * Resolver activity instrumentation tests */ @RunWith(AndroidJUnit4.class) +@HiltAndroidTest +@UninstallModules(UserRepositoryModule.class) public class ResolverActivityTest { - private static final UserHandle PERSONAL_USER_HANDLE = androidx.test.platform.app - .InstrumentationRegistry.getInstrumentation().getTargetContext().getUser(); + private static final UserHandle PERSONAL_USER_HANDLE = + getInstrumentation().getTargetContext().getUser(); private static final UserHandle WORK_PROFILE_USER_HANDLE = UserHandle.of(10); private static final UserHandle CLONE_PROFILE_USER_HANDLE = UserHandle.of(11); + private static final User WORK_PROFILE_USER = + new User(WORK_PROFILE_USER_HANDLE.getIdentifier(), User.Role.WORK); + + @Rule(order = 0) + public HiltAndroidRule mHiltAndroidRule = new HiltAndroidRule(this); - @Rule + @Rule(order = 1) public ActivityTestRule mActivityRule = new ActivityTestRule<>(ResolverWrapperActivity.class, false, false); @@ -88,14 +107,30 @@ public class ResolverActivityTest { public void setup() { // TODO: use the other form of `adoptShellPermissionIdentity()` where we explicitly list the // permissions we require (which we'll read from the manifest at runtime). - androidx.test.platform.app.InstrumentationRegistry - .getInstrumentation() + getInstrumentation() .getUiAutomation() .adoptShellPermissionIdentity(); sOverrides.reset(); } + @BindValue + @ApplicationUser + public final UserHandle mApplicationUser = PERSONAL_USER_HANDLE; + + @BindValue + @ProfileParent + public final UserHandle mProfileParent = PERSONAL_USER_HANDLE; + + /** For setup of test state, a mutable reference of mUserRepository */ + private final FakeUserRepository mFakeUserRepo = + new FakeUserRepository(List.of( + new User(PERSONAL_USER_HANDLE.getIdentifier(), User.Role.PERSONAL) + )); + + @BindValue + public final UserRepository mUserRepository = mFakeUserRepo; + @Test public void twoOptionsAndUserSelectsOne() throws InterruptedException { Intent sendIntent = createSendImageIntent(); @@ -386,15 +421,14 @@ public class ResolverActivityTest { @Test public void testWorkTab_workTabUsesExpectedAdapter() { + markOtherProfileAvailability(/* workAvailable= */ true, /* cloneAvailable= */ false); List personalResolvedComponentInfos = createResolvedComponentsForTestWithOtherProfile(3, /* userId */ 10, PERSONAL_USER_HANDLE); - markOtherProfileAvailability(/* workAvailable= */ true, /* cloneAvailable= */ false); List workResolvedComponentInfos = createResolvedComponentsForTest(4, WORK_PROFILE_USER_HANDLE); setupResolverControllers(personalResolvedComponentInfos, workResolvedComponentInfos); Intent sendIntent = createSendImageIntent(); - markOtherProfileAvailability(/* workAvailable= */ true, /* cloneAvailable= */ false); final ResolverWrapperActivity activity = mActivityRule.launchActivity(sendIntent); waitForIdle(); @@ -406,9 +440,9 @@ public class ResolverActivityTest { @Test public void testWorkTab_personalTabUsesExpectedAdapter() { + markOtherProfileAvailability(/* workAvailable= */ true, /* cloneAvailable= */ false); List personalResolvedComponentInfos = createResolvedComponentsForTestWithOtherProfile(3, PERSONAL_USER_HANDLE); - markOtherProfileAvailability(/* workAvailable= */ true, /* cloneAvailable= */ false); List workResolvedComponentInfos = createResolvedComponentsForTest(4, WORK_PROFILE_USER_HANDLE); setupResolverControllers(personalResolvedComponentInfos, workResolvedComponentInfos); @@ -446,7 +480,8 @@ public class ResolverActivityTest { public void testWorkTab_selectingWorkTabAppOpensAppInWorkProfile() throws InterruptedException { markOtherProfileAvailability(/* workAvailable= */ true, /* cloneAvailable= */ false); List personalResolvedComponentInfos = - createResolvedComponentsForTestWithOtherProfile(3, /* userId */ 10, + createResolvedComponentsForTestWithOtherProfile(3, + /* userId */ WORK_PROFILE_USER_HANDLE.getIdentifier(), PERSONAL_USER_HANDLE); List workResolvedComponentInfos = createResolvedComponentsForTest(4, WORK_PROFILE_USER_HANDLE); @@ -604,7 +639,7 @@ public class ResolverActivityTest { PERSONAL_USER_HANDLE); List workResolvedComponentInfos = createResolvedComponentsForTest(workProfileTargets, WORK_PROFILE_USER_HANDLE); - sOverrides.isQuietModeEnabled = true; + mFakeUserRepo.updateState(WORK_PROFILE_USER, false); setupResolverControllers(personalResolvedComponentInfos, workResolvedComponentInfos); Intent sendIntent = createSendImageIntent(); sendIntent.setType("TestType"); @@ -652,7 +687,7 @@ public class ResolverActivityTest { setupResolverControllers(personalResolvedComponentInfos, workResolvedComponentInfos); Intent sendIntent = createSendImageIntent(); sendIntent.setType("TestType"); - sOverrides.isQuietModeEnabled = true; + mFakeUserRepo.updateState(WORK_PROFILE_USER, false); sOverrides.hasCrossProfileIntents = false; mActivityRule.launchActivity(sendIntent); @@ -722,7 +757,7 @@ public class ResolverActivityTest { setupResolverControllers(personalResolvedComponentInfos, workResolvedComponentInfos); Intent sendIntent = createSendImageIntent(); sendIntent.setType("TestType"); - sOverrides.isQuietModeEnabled = true; + mFakeUserRepo.updateState(WORK_PROFILE_USER, false); mActivityRule.launchActivity(sendIntent); waitForIdle(); @@ -1050,18 +1085,14 @@ public class ResolverActivityTest { } 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( + new User(WORK_PROFILE_USER_HANDLE.getIdentifier(), User.Role.WORK), true); } if (cloneAvailable) { - handles.setCloneProfileUserHandle(CLONE_PROFILE_USER_HANDLE); + mFakeUserRepo.addUser( + new User(CLONE_PROFILE_USER_HANDLE.getIdentifier(), User.Role.CLONE), true); } - sOverrides.annotatedUserHandles = handles.build(); } private void setupResolverControllers( @@ -1077,21 +1108,14 @@ public class ResolverActivityTest { Mockito.anyBoolean(), Mockito.anyBoolean(), Mockito.isA(List.class), - eq(UserHandle.SYSTEM))) - .thenReturn(new ArrayList<>(personalResolvedComponentInfos)); - when(sOverrides.workResolverListController.getResolversForIntentAsUser( - Mockito.anyBoolean(), - Mockito.anyBoolean(), - Mockito.anyBoolean(), - Mockito.isA(List.class), - eq(UserHandle.SYSTEM))) + eq(PERSONAL_USER_HANDLE))) .thenReturn(new ArrayList<>(personalResolvedComponentInfos)); when(sOverrides.workResolverListController.getResolversForIntentAsUser( Mockito.anyBoolean(), Mockito.anyBoolean(), Mockito.anyBoolean(), Mockito.isA(List.class), - eq(UserHandle.of(10)))) + eq(WORK_PROFILE_USER_HANDLE))) .thenReturn(new ArrayList<>(workResolvedComponentInfos)); } } diff --git a/tests/activity/src/com/android/intentresolver/ResolverWrapperActivity.java b/tests/activity/src/com/android/intentresolver/ResolverWrapperActivity.java index d1adfba9..30858c8e 100644 --- a/tests/activity/src/com/android/intentresolver/ResolverWrapperActivity.java +++ b/tests/activity/src/com/android/intentresolver/ResolverWrapperActivity.java @@ -21,9 +21,9 @@ import static org.mockito.ArgumentMatchers.anyInt; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.when; +import android.annotation.Nullable; import android.content.Context; import android.content.Intent; -import android.content.pm.PackageManager; import android.content.pm.ResolveInfo; import android.graphics.drawable.Drawable; import android.os.Bundle; @@ -31,7 +31,6 @@ import android.os.UserHandle; import android.util.Pair; import androidx.annotation.NonNull; -import androidx.annotation.Nullable; import androidx.test.espresso.idling.CountingIdlingResource; import com.android.intentresolver.chooser.DisplayResolveInfo; @@ -54,10 +53,6 @@ public class ResolverWrapperActivity extends ResolverActivity { private final CountingIdlingResource mLabelIdlingResource = new CountingIdlingResource("LoadLabelTask"); - public ResolverWrapperActivity() { - super(/* isIntentPicker= */ true); - } - public CountingIdlingResource getLabelIdlingResource() { return mLabelIdlingResource; } @@ -69,8 +64,7 @@ public class ResolverWrapperActivity extends ResolverActivity { Intent[] initialIntents, List rList, boolean filterLastUsed, - UserHandle userHandle, - TargetDataLoader targetDataLoader) { + UserHandle userHandle) { return new ResolverListAdapter( context, payloadIntents, @@ -82,7 +76,7 @@ public class ResolverWrapperActivity extends ResolverActivity { payloadIntents.get(0), // TODO: extract upstream this, userHandle, - new TargetDataLoaderWrapper(targetDataLoader, mLabelIdlingResource)); + new TargetDataLoaderWrapper(mTargetDataLoader, mLabelIdlingResource)); } @Override @@ -93,27 +87,16 @@ public class ResolverWrapperActivity extends ResolverActivity { return super.createCrossProfileIntentsChecker(); } - @Override - protected WorkProfileAvailabilityManager createWorkProfileAvailabilityManager() { - if (sOverrides.mWorkProfileAvailability != null) { - return sOverrides.mWorkProfileAvailability; - } - return super.createWorkProfileAvailabilityManager(); - } - ResolverListAdapter getAdapter() { return mMultiProfilePagerAdapter.getActiveListAdapter(); } ResolverListAdapter getPersonalListAdapter() { - return ((ResolverListAdapter) mMultiProfilePagerAdapter.getAdapterForIndex(0)); + return mMultiProfilePagerAdapter.getPersonalListAdapter(); } ResolverListAdapter getWorkListAdapter() { - if (mMultiProfilePagerAdapter.getInactiveListAdapter() == null) { - return null; - } - return ((ResolverListAdapter) mMultiProfilePagerAdapter.getAdapterForIndex(1)); + return mMultiProfilePagerAdapter.getWorkListAdapter(); } @Override @@ -142,96 +125,35 @@ public class ResolverWrapperActivity extends ResolverActivity { return sOverrides.workResolverListController; } - @Override - public PackageManager getPackageManager() { - if (sOverrides.createPackageManager != null) { - return sOverrides.createPackageManager.apply(super.getPackageManager()); - } - return super.getPackageManager(); - } - protected UserHandle getCurrentUserHandle() { return mMultiProfilePagerAdapter.getCurrentUserHandle(); } @Override - protected AnnotatedUserHandles computeAnnotatedUserHandles() { - return sOverrides.annotatedUserHandles; - } - @Override - public void startActivityAsUser( - @NonNull Intent intent, - Bundle options, - @NonNull UserHandle user - ) { + public void startActivityAsUser(Intent intent, Bundle options, UserHandle user) { super.startActivityAsUser(intent, options, user); } - @Override - protected List getResolverRankerServiceUserHandleListInternal(UserHandle - userHandle) { - return super.getResolverRankerServiceUserHandleListInternal(userHandle); - } - /** * We cannot directly mock the activity created since instrumentation creates it. *

* Instead, we use static instances of this object to modify behavior. */ - static class OverrideData { + public static class OverrideData { @SuppressWarnings("Since15") - public Function createPackageManager; public Function, Boolean> onSafelyStartInternalCallback; public ResolverListController resolverListController; public ResolverListController workResolverListController; public Boolean isVoiceInteraction; - public AnnotatedUserHandles annotatedUserHandles; - public Integer myUserId; public boolean hasCrossProfileIntents; - public boolean isQuietModeEnabled; - public WorkProfileAvailabilityManager mWorkProfileAvailability; public CrossProfileIntentsChecker mCrossProfileIntentsChecker; public void reset() { onSafelyStartInternalCallback = null; isVoiceInteraction = null; - createPackageManager = null; resolverListController = mock(ResolverListController.class); workResolverListController = mock(ResolverListController.class); - annotatedUserHandles = AnnotatedUserHandles.newBuilder() - .setUserIdOfCallingApp(1234) // Must be non-negative. - .setUserHandleSharesheetLaunchedAs(UserHandle.SYSTEM) - .setPersonalProfileUserHandle(UserHandle.SYSTEM) - .build(); - myUserId = null; hasCrossProfileIntents = true; - isQuietModeEnabled = false; - - mWorkProfileAvailability = new WorkProfileAvailabilityManager(null, null, null) { - @Override - public boolean isQuietModeEnabled() { - return isQuietModeEnabled; - } - - @Override - public boolean isWorkProfileUserUnlocked() { - return true; - } - - @Override - public void requestQuietModeEnabled(boolean enabled) { - isQuietModeEnabled = enabled; - } - - @Override - public void markWorkProfileEnabledBroadcastReceived() {} - - @Override - public boolean isWaitingToEnableWorkProfile() { - return false; - } - }; - mCrossProfileIntentsChecker = mock(CrossProfileIntentsChecker.class); when(mCrossProfileIntentsChecker.hasCrossProfileIntents(any(), anyInt(), anyInt())) .thenAnswer(invocation -> hasCrossProfileIntents); diff --git a/tests/activity/src/com/android/intentresolver/UnbundledChooserActivityTest.java b/tests/activity/src/com/android/intentresolver/UnbundledChooserActivityTest.java deleted file mode 100644 index 4077295c..00000000 --- a/tests/activity/src/com/android/intentresolver/UnbundledChooserActivityTest.java +++ /dev/null @@ -1,3130 +0,0 @@ -/* - * Copyright (C) 2016 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.android.intentresolver; - -import static android.app.Activity.RESULT_OK; - -import static androidx.test.espresso.Espresso.onView; -import static androidx.test.espresso.action.ViewActions.click; -import static androidx.test.espresso.action.ViewActions.longClick; -import static androidx.test.espresso.action.ViewActions.swipeUp; -import static androidx.test.espresso.assertion.ViewAssertions.doesNotExist; -import static androidx.test.espresso.assertion.ViewAssertions.matches; -import static androidx.test.espresso.matcher.ViewMatchers.hasSibling; -import static androidx.test.espresso.matcher.ViewMatchers.isCompletelyDisplayed; -import static androidx.test.espresso.matcher.ViewMatchers.isDisplayed; -import static androidx.test.espresso.matcher.ViewMatchers.withEffectiveVisibility; -import static androidx.test.espresso.matcher.ViewMatchers.withId; -import static androidx.test.espresso.matcher.ViewMatchers.withText; - -import static com.android.intentresolver.ChooserActivity.TARGET_TYPE_CHOOSER_TARGET; -import static com.android.intentresolver.ChooserActivity.TARGET_TYPE_DEFAULT; -import static com.android.intentresolver.ChooserActivity.TARGET_TYPE_SHORTCUTS_FROM_PREDICTION_SERVICE; -import static com.android.intentresolver.ChooserActivity.TARGET_TYPE_SHORTCUTS_FROM_SHORTCUT_MANAGER; -import static com.android.intentresolver.ChooserListAdapter.CALLER_TARGET_SCORE_BOOST; -import static com.android.intentresolver.ChooserListAdapter.SHORTCUT_TARGET_SCORE_BOOST; -import static com.android.intentresolver.MatcherUtils.first; - -import static com.google.common.truth.Truth.assertThat; -import static com.google.common.truth.Truth.assertWithMessage; - -import static junit.framework.Assert.assertNull; - -import static org.hamcrest.CoreMatchers.allOf; -import static org.hamcrest.CoreMatchers.is; -import static org.hamcrest.CoreMatchers.not; -import static org.hamcrest.MatcherAssert.assertThat; -import static org.junit.Assert.assertEquals; -import static org.junit.Assert.assertTrue; -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.ArgumentMatchers.anyInt; -import static org.mockito.ArgumentMatchers.eq; -import static org.mockito.Mockito.doReturn; -import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.times; -import static org.mockito.Mockito.verify; -import static org.mockito.Mockito.when; - -import android.app.PendingIntent; -import android.app.usage.UsageStatsManager; -import android.content.BroadcastReceiver; -import android.content.ClipData; -import android.content.ClipDescription; -import android.content.ClipboardManager; -import android.content.ComponentName; -import android.content.Context; -import android.content.Intent; -import android.content.IntentFilter; -import android.content.pm.ActivityInfo; -import android.content.pm.ApplicationInfo; -import android.content.pm.PackageManager; -import android.content.pm.ResolveInfo; -import android.content.pm.ShortcutInfo; -import android.content.pm.ShortcutManager.ShareShortcutInfo; -import android.content.res.Configuration; -import android.content.res.Resources; -import android.database.Cursor; -import android.graphics.Bitmap; -import android.graphics.Canvas; -import android.graphics.Color; -import android.graphics.Paint; -import android.graphics.Rect; -import android.graphics.Typeface; -import android.graphics.drawable.Icon; -import android.net.Uri; -import android.os.Bundle; -import android.os.UserHandle; -import android.platform.test.annotations.RequiresFlagsEnabled; -import android.platform.test.flag.junit.CheckFlagsRule; -import android.platform.test.flag.junit.DeviceFlagsValueProvider; -import android.provider.DeviceConfig; -import android.service.chooser.ChooserAction; -import android.service.chooser.ChooserTarget; -import android.text.Spannable; -import android.text.SpannableStringBuilder; -import android.text.Spanned; -import android.text.style.BackgroundColorSpan; -import android.text.style.ForegroundColorSpan; -import android.text.style.StyleSpan; -import android.text.style.UnderlineSpan; -import android.util.Pair; -import android.util.SparseArray; -import android.view.View; -import android.view.WindowManager; -import android.widget.TextView; - -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; -import androidx.recyclerview.widget.GridLayoutManager; -import androidx.recyclerview.widget.RecyclerView; -import androidx.test.espresso.contrib.RecyclerViewActions; -import androidx.test.espresso.matcher.BoundedDiagnosingMatcher; -import androidx.test.espresso.matcher.ViewMatchers; -import androidx.test.platform.app.InstrumentationRegistry; -import androidx.test.rule.ActivityTestRule; - -import com.android.intentresolver.chooser.DisplayResolveInfo; -import com.android.intentresolver.contentpreview.ImageLoader; -import com.android.intentresolver.ext.RecyclerViewExt; -import com.android.intentresolver.logging.EventLog; -import com.android.intentresolver.logging.FakeEventLog; -import com.android.intentresolver.shortcuts.ShortcutLoader; -import com.android.internal.config.sysui.SystemUiDeviceConfigFlags; -import com.android.internal.logging.nano.MetricsProto.MetricsEvent; - -import dagger.hilt.android.testing.HiltAndroidRule; -import dagger.hilt.android.testing.HiltAndroidTest; - -import org.hamcrest.Description; -import org.hamcrest.Matcher; -import org.hamcrest.Matchers; -import org.junit.Before; -import org.junit.Ignore; -import org.junit.Rule; -import org.junit.Test; -import org.junit.runner.RunWith; -import org.junit.runners.Parameterized; -import org.mockito.ArgumentCaptor; -import org.mockito.Mockito; - -import java.util.ArrayList; -import java.util.Arrays; -import java.util.Collection; -import java.util.Collections; -import java.util.HashMap; -import java.util.List; -import java.util.Map; -import java.util.concurrent.CountDownLatch; -import java.util.concurrent.TimeUnit; -import java.util.concurrent.atomic.AtomicReference; -import java.util.function.Consumer; -import java.util.function.Function; - -/** - * Instrumentation tests for ChooserActivity. - *

- * Legacy test suite migrated from framework CoreTests. - *

- */ -@RunWith(Parameterized.class) -@HiltAndroidTest -public class UnbundledChooserActivityTest { - - private static FakeEventLog getEventLog(ChooserWrapperActivity activity) { - return (FakeEventLog) activity.mEventLog; - } - - private static final UserHandle PERSONAL_USER_HANDLE = InstrumentationRegistry - .getInstrumentation().getTargetContext().getUser(); - private static final UserHandle WORK_PROFILE_USER_HANDLE = UserHandle.of(10); - private static final UserHandle CLONE_PROFILE_USER_HANDLE = UserHandle.of(11); - - private static final Function DEFAULT_PM = pm -> pm; - private static final Function NO_APP_PREDICTION_SERVICE_PM = - pm -> { - PackageManager mock = Mockito.spy(pm); - when(mock.getAppPredictionServicePackageName()).thenReturn(null); - return mock; - }; - - @Parameterized.Parameters - public static Collection packageManagers() { - return Arrays.asList(new Object[][] { - // Default PackageManager - { DEFAULT_PM }, - // No App Prediction Service - { NO_APP_PREDICTION_SERVICE_PM} - }); - } - - private static final String TEST_MIME_TYPE = "application/TestType"; - - private static final int CONTENT_PREVIEW_IMAGE = 1; - private static final int CONTENT_PREVIEW_FILE = 2; - private static final int CONTENT_PREVIEW_TEXT = 3; - - @Rule(order = 0) - public CheckFlagsRule mCheckFlagsRule = DeviceFlagsValueProvider.createCheckFlagsRule(); - - @Rule(order = 1) - public HiltAndroidRule mHiltAndroidRule = new HiltAndroidRule(this); - - @Rule(order = 2) - public ActivityTestRule mActivityRule = - new ActivityTestRule<>(ChooserWrapperActivity.class, false, false); - - @Before - public void setUp() { - // TODO: use the other form of `adoptShellPermissionIdentity()` where we explicitly list the - // permissions we require (which we'll read from the manifest at runtime). - InstrumentationRegistry - .getInstrumentation() - .getUiAutomation() - .adoptShellPermissionIdentity(); - - cleanOverrideData(); - mHiltAndroidRule.inject(); - } - - private final Function mPackageManagerOverride; - - public UnbundledChooserActivityTest( - Function packageManagerOverride) { - mPackageManagerOverride = packageManagerOverride; - } - - private void setDeviceConfigProperty( - @NonNull String propertyName, - @NonNull String value) { - // TODO: consider running with {@link #runWithShellPermissionIdentity()} to more narrowly - // request WRITE_DEVICE_CONFIG permissions if we get rid of the broad grant we currently - // configure in {@link #setup()}. - // TODO: is it really appropriate that this is always set with makeDefault=true? - boolean valueWasSet = DeviceConfig.setProperty( - DeviceConfig.NAMESPACE_SYSTEMUI, - propertyName, - value, - true /* makeDefault */); - if (!valueWasSet) { - throw new IllegalStateException( - "Could not set " + propertyName + " to " + value); - } - } - - public void cleanOverrideData() { - ChooserActivityOverrideData.getInstance().reset(); - ChooserActivityOverrideData.getInstance().createPackageManager = mPackageManagerOverride; - - setDeviceConfigProperty( - SystemUiDeviceConfigFlags.APPLY_SHARING_APP_LIMITS_IN_SYSUI, - Boolean.toString(true)); - } - - @Test - public void customTitle() throws InterruptedException { - Intent viewIntent = createViewTextIntent(); - List resolvedComponentInfos = createResolvedComponentsForTest(2); - - setupResolverControllers(resolvedComponentInfos); - final IChooserWrapper activity = (IChooserWrapper) mActivityRule.launchActivity( - Intent.createChooser(viewIntent, "chooser test")); - - waitForIdle(); - assertThat(activity.getAdapter().getCount(), is(2)); - assertThat(activity.getAdapter().getServiceTargetCount(), is(0)); - onView(withId(android.R.id.title)).check(matches(withText("chooser test"))); - } - - @Test - public void customTitleIgnoredForSendIntents() throws InterruptedException { - Intent sendIntent = createSendTextIntent(); - List resolvedComponentInfos = createResolvedComponentsForTest(2); - - setupResolverControllers(resolvedComponentInfos); - mActivityRule.launchActivity(Intent.createChooser(sendIntent, "chooser test")); - waitForIdle(); - onView(withId(android.R.id.title)) - .check(matches(withText(R.string.whichSendApplication))); - } - - @Test - public void emptyTitle() throws InterruptedException { - Intent sendIntent = createSendTextIntent(); - List resolvedComponentInfos = createResolvedComponentsForTest(2); - - setupResolverControllers(resolvedComponentInfos); - mActivityRule.launchActivity(Intent.createChooser(sendIntent, null)); - waitForIdle(); - onView(withId(android.R.id.title)) - .check(matches(withText(R.string.whichSendApplication))); - } - - @Test - public void test_shareRichTextWithRichTitle_richTextAndRichTitleDisplayed() { - CharSequence title = new SpannableStringBuilder() - .append("Rich", new UnderlineSpan(), Spannable.SPAN_INCLUSIVE_EXCLUSIVE) - .append( - "Title", - new ForegroundColorSpan(Color.RED), - Spannable.SPAN_INCLUSIVE_EXCLUSIVE); - CharSequence sharedText = new SpannableStringBuilder() - .append( - "Rich", - new BackgroundColorSpan(Color.YELLOW), - Spanned.SPAN_INCLUSIVE_EXCLUSIVE) - .append( - "Text", - new StyleSpan(Typeface.ITALIC), - Spanned.SPAN_INCLUSIVE_EXCLUSIVE); - Intent sendIntent = createSendTextIntent(); - sendIntent.putExtra(Intent.EXTRA_TEXT, sharedText); - sendIntent.putExtra(Intent.EXTRA_TITLE, title); - List resolvedComponentInfos = createResolvedComponentsForTest(2); - setupResolverControllers(resolvedComponentInfos); - - mActivityRule.launchActivity(Intent.createChooser(sendIntent, null)); - waitForIdle(); - - onView(withId(com.android.internal.R.id.content_preview_title)) - .check((view, e) -> { - assertThat(view).isInstanceOf(TextView.class); - CharSequence text = ((TextView) view).getText(); - assertThat(text).isInstanceOf(Spanned.class); - Spanned spanned = (Spanned) text; - assertThat(spanned.getSpans(0, spanned.length(), Object.class)) - .hasLength(2); - assertThat(spanned.getSpans(0, 4, UnderlineSpan.class)).hasLength(1); - assertThat(spanned.getSpans(4, spanned.length(), ForegroundColorSpan.class)) - .hasLength(1); - }); - - onView(withId(com.android.internal.R.id.content_preview_text)) - .check((view, e) -> { - assertThat(view).isInstanceOf(TextView.class); - CharSequence text = ((TextView) view).getText(); - assertThat(text).isInstanceOf(Spanned.class); - Spanned spanned = (Spanned) text; - assertThat(spanned.getSpans(0, spanned.length(), Object.class)) - .hasLength(2); - assertThat(spanned.getSpans(0, 4, BackgroundColorSpan.class)).hasLength(1); - assertThat(spanned.getSpans(4, spanned.length(), StyleSpan.class)).hasLength(1); - }); - } - - @Test - public void emptyPreviewTitleAndThumbnail() throws InterruptedException { - Intent sendIntent = createSendTextIntentWithPreview(null, null); - List resolvedComponentInfos = createResolvedComponentsForTest(2); - - setupResolverControllers(resolvedComponentInfos); - mActivityRule.launchActivity(Intent.createChooser(sendIntent, null)); - waitForIdle(); - onView(withId(com.android.internal.R.id.content_preview_title)) - .check(matches(not(isDisplayed()))); - onView(withId(com.android.internal.R.id.content_preview_thumbnail)) - .check(matches(not(isDisplayed()))); - } - - @Test - public void visiblePreviewTitleWithoutThumbnail() throws InterruptedException { - String previewTitle = "My Content Preview Title"; - Intent sendIntent = createSendTextIntentWithPreview(previewTitle, null); - List resolvedComponentInfos = createResolvedComponentsForTest(2); - - setupResolverControllers(resolvedComponentInfos); - mActivityRule.launchActivity(Intent.createChooser(sendIntent, null)); - waitForIdle(); - onView(withId(com.android.internal.R.id.content_preview_title)) - .check(matches(isDisplayed())); - onView(withId(com.android.internal.R.id.content_preview_title)) - .check(matches(withText(previewTitle))); - onView(withId(com.android.internal.R.id.content_preview_thumbnail)) - .check(matches(not(isDisplayed()))); - } - - @Test - public void visiblePreviewTitleWithInvalidThumbnail() throws InterruptedException { - String previewTitle = "My Content Preview Title"; - Intent sendIntent = createSendTextIntentWithPreview(previewTitle, - Uri.parse("tel:(+49)12345789")); - List resolvedComponentInfos = createResolvedComponentsForTest(2); - - setupResolverControllers(resolvedComponentInfos); - mActivityRule.launchActivity(Intent.createChooser(sendIntent, null)); - waitForIdle(); - onView(withId(com.android.internal.R.id.content_preview_title)) - .check(matches(isDisplayed())); - onView(withId(com.android.internal.R.id.content_preview_thumbnail)) - .check(matches(not(isDisplayed()))); - } - - @Test - public void visiblePreviewTitleAndThumbnail() throws InterruptedException { - String previewTitle = "My Content Preview Title"; - Uri uri = Uri.parse( - "android.resource://com.android.frameworks.coretests/" - + com.android.intentresolver.tests.R.drawable.test320x240); - Intent sendIntent = createSendTextIntentWithPreview(previewTitle, uri); - ChooserActivityOverrideData.getInstance().imageLoader = - createImageLoader(uri, createBitmap()); - List resolvedComponentInfos = createResolvedComponentsForTest(2); - - setupResolverControllers(resolvedComponentInfos); - mActivityRule.launchActivity(Intent.createChooser(sendIntent, null)); - waitForIdle(); - onView(withId(com.android.internal.R.id.content_preview_title)) - .check(matches(isDisplayed())); - onView(withId(com.android.internal.R.id.content_preview_thumbnail)) - .check(matches(isDisplayed())); - } - - @Test @Ignore - public void twoOptionsAndUserSelectsOne() throws InterruptedException { - Intent sendIntent = createSendTextIntent(); - List resolvedComponentInfos = createResolvedComponentsForTest(2); - - setupResolverControllers(resolvedComponentInfos); - - final IChooserWrapper activity = (IChooserWrapper) - mActivityRule.launchActivity(Intent.createChooser(sendIntent, null)); - waitForIdle(); - - assertThat(activity.getAdapter().getCount(), is(2)); - onView(withId(com.android.internal.R.id.profile_button)).check(doesNotExist()); - - ResolveInfo[] chosen = new ResolveInfo[1]; - ChooserActivityOverrideData.getInstance().onSafelyStartInternalCallback = targetInfo -> { - chosen[0] = targetInfo.getResolveInfo(); - return true; - }; - - ResolveInfo toChoose = resolvedComponentInfos.get(0).getResolveInfoAt(0); - onView(withText(toChoose.activityInfo.name)) - .perform(click()); - waitForIdle(); - assertThat(chosen[0], is(toChoose)); - } - - @Test @Ignore - public void fourOptionsStackedIntoOneTarget() throws InterruptedException { - Intent sendIntent = createSendTextIntent(); - - // create just enough targets to ensure the a-z list should be shown - List resolvedComponentInfos = createResolvedComponentsForTest(1); - - // next create 4 targets in a single app that should be stacked into a single target - String packageName = "xxx.yyy"; - String appName = "aaa"; - ComponentName cn = new ComponentName(packageName, appName); - Intent intent = new Intent("fakeIntent"); - List infosToStack = new ArrayList<>(); - for (int i = 0; i < 4; i++) { - ResolveInfo resolveInfo = ResolverDataProvider.createResolveInfo(i, - UserHandle.USER_CURRENT, PERSONAL_USER_HANDLE); - resolveInfo.activityInfo.applicationInfo.name = appName; - resolveInfo.activityInfo.applicationInfo.packageName = packageName; - resolveInfo.activityInfo.packageName = packageName; - resolveInfo.activityInfo.name = "ccc" + i; - infosToStack.add(new ResolvedComponentInfo(cn, intent, resolveInfo)); - } - resolvedComponentInfos.addAll(infosToStack); - - setupResolverControllers(resolvedComponentInfos); - - final IChooserWrapper activity = (IChooserWrapper) - mActivityRule.launchActivity(Intent.createChooser(sendIntent, null)); - waitForIdle(); - - // expect 1 unique targets + 1 group + 4 ranked app targets - assertThat(activity.getAdapter().getCount(), is(6)); - - ResolveInfo[] chosen = new ResolveInfo[1]; - ChooserActivityOverrideData.getInstance().onSafelyStartInternalCallback = targetInfo -> { - chosen[0] = targetInfo.getResolveInfo(); - return true; - }; - - onView(allOf(withText(appName), hasSibling(withText("")))).perform(click()); - waitForIdle(); - - // clicking will launch a dialog to choose the activity within the app - onView(withText(appName)).check(matches(isDisplayed())); - int i = 0; - for (ResolvedComponentInfo rci: infosToStack) { - onView(withText("ccc" + i)).check(matches(isDisplayed())); - ++i; - } - } - - @Test @Ignore - public void updateChooserCountsAndModelAfterUserSelection() throws InterruptedException { - Intent sendIntent = createSendTextIntent(); - List resolvedComponentInfos = createResolvedComponentsForTest(2); - - setupResolverControllers(resolvedComponentInfos); - - final IChooserWrapper activity = (IChooserWrapper) - mActivityRule.launchActivity(Intent.createChooser(sendIntent, null)); - waitForIdle(); - UsageStatsManager usm = activity.getUsageStatsManager(); - verify(ChooserActivityOverrideData.getInstance().resolverListController, times(1)) - .topK(any(List.class), anyInt()); - assertThat(activity.getIsSelected(), is(false)); - ChooserActivityOverrideData.getInstance().onSafelyStartInternalCallback = targetInfo -> { - return true; - }; - ResolveInfo toChoose = resolvedComponentInfos.get(0).getResolveInfoAt(0); - DisplayResolveInfo testDri = - activity.createTestDisplayResolveInfo( - sendIntent, toChoose, "testLabel", "testInfo", sendIntent); - onView(withText(toChoose.activityInfo.name)) - .perform(click()); - waitForIdle(); - verify(ChooserActivityOverrideData.getInstance().resolverListController, times(1)) - .updateChooserCounts(Mockito.anyString(), any(UserHandle.class), - Mockito.anyString()); - verify(ChooserActivityOverrideData.getInstance().resolverListController, times(1)) - .updateModel(testDri); - assertThat(activity.getIsSelected(), is(true)); - } - - @Ignore // b/148158199 - @Test - public void noResultsFromPackageManager() { - setupResolverControllers(null); - Intent sendIntent = createSendTextIntent(); - final ChooserActivity activity = - mActivityRule.launchActivity(Intent.createChooser(sendIntent, null)); - final IChooserWrapper wrapper = (IChooserWrapper) activity; - - waitForIdle(); - assertThat(activity.isFinishing(), is(false)); - - onView(withId(android.R.id.empty)).check(matches(isDisplayed())); - onView(withId(com.android.internal.R.id.profile_pager)).check(matches(not(isDisplayed()))); - InstrumentationRegistry.getInstrumentation().runOnMainSync( - () -> wrapper.getAdapter().handlePackagesChanged() - ); - // backward compatibility. looks like we finish when data is empty after package change - assertThat(activity.isFinishing(), is(true)); - } - - @Test - public void autoLaunchSingleResult() throws InterruptedException { - ResolveInfo[] chosen = new ResolveInfo[1]; - ChooserActivityOverrideData.getInstance().onSafelyStartInternalCallback = targetInfo -> { - chosen[0] = targetInfo.getResolveInfo(); - return true; - }; - - List resolvedComponentInfos = createResolvedComponentsForTest(1); - setupResolverControllers(resolvedComponentInfos); - - Intent sendIntent = createSendTextIntent(); - final ChooserActivity activity = - mActivityRule.launchActivity(Intent.createChooser(sendIntent, null)); - waitForIdle(); - - assertThat(chosen[0], is(resolvedComponentInfos.get(0).getResolveInfoAt(0))); - assertThat(activity.isFinishing(), is(true)); - } - - @Test @Ignore - public void hasOtherProfileOneOption() { - List personalResolvedComponentInfos = - createResolvedComponentsForTestWithOtherProfile(2, /* userId */ 10); - List workResolvedComponentInfos = createResolvedComponentsForTest(4); - setupResolverControllers(personalResolvedComponentInfos, workResolvedComponentInfos); - markOtherProfileAvailability(/* workAvailable= */ true, /* cloneAvailable= */ false); - - ResolveInfo toChoose = personalResolvedComponentInfos.get(1).getResolveInfoAt(0); - Intent sendIntent = createSendTextIntent(); - final IChooserWrapper activity = (IChooserWrapper) - mActivityRule.launchActivity(Intent.createChooser(sendIntent, null)); - waitForIdle(); - - // The other entry is filtered to the other profile slot - assertThat(activity.getAdapter().getCount(), is(1)); - - ResolveInfo[] chosen = new ResolveInfo[1]; - ChooserActivityOverrideData.getInstance().onSafelyStartInternalCallback = targetInfo -> { - chosen[0] = targetInfo.getResolveInfo(); - return true; - }; - - // Make a stable copy of the components as the original list may be modified - List stableCopy = - createResolvedComponentsForTestWithOtherProfile(2, /* userId= */ 10); - waitForIdle(); - - onView(first(withText(stableCopy.get(1).getResolveInfoAt(0).activityInfo.name))) - .perform(click()); - waitForIdle(); - assertThat(chosen[0], is(toChoose)); - } - - @Test @Ignore - public void hasOtherProfileTwoOptionsAndUserSelectsOne() throws Exception { - Intent sendIntent = createSendTextIntent(); - List resolvedComponentInfos = - createResolvedComponentsForTestWithOtherProfile(3); - ResolveInfo toChoose = resolvedComponentInfos.get(1).getResolveInfoAt(0); - - setupResolverControllers(resolvedComponentInfos); - when(ChooserActivityOverrideData.getInstance().resolverListController.getLastChosen()) - .thenReturn(resolvedComponentInfos.get(0).getResolveInfoAt(0)); - - final IChooserWrapper activity = (IChooserWrapper) - mActivityRule.launchActivity(Intent.createChooser(sendIntent, null)); - waitForIdle(); - - // The other entry is filtered to the other profile slot - assertThat(activity.getAdapter().getCount(), is(2)); - - ResolveInfo[] chosen = new ResolveInfo[1]; - ChooserActivityOverrideData.getInstance().onSafelyStartInternalCallback = targetInfo -> { - chosen[0] = targetInfo.getResolveInfo(); - return true; - }; - - // Make a stable copy of the components as the original list may be modified - List stableCopy = - createResolvedComponentsForTestWithOtherProfile(3); - onView(withText(stableCopy.get(1).getResolveInfoAt(0).activityInfo.name)) - .perform(click()); - waitForIdle(); - assertThat(chosen[0], is(toChoose)); - } - - @Test @Ignore - public void hasLastChosenActivityAndOtherProfile() throws Exception { - Intent sendIntent = createSendTextIntent(); - List resolvedComponentInfos = - createResolvedComponentsForTestWithOtherProfile(3); - ResolveInfo toChoose = resolvedComponentInfos.get(1).getResolveInfoAt(0); - - setupResolverControllers(resolvedComponentInfos); - - final IChooserWrapper activity = (IChooserWrapper) - mActivityRule.launchActivity(Intent.createChooser(sendIntent, null)); - waitForIdle(); - - // The other entry is filtered to the last used slot - assertThat(activity.getAdapter().getCount(), is(2)); - - ResolveInfo[] chosen = new ResolveInfo[1]; - ChooserActivityOverrideData.getInstance().onSafelyStartInternalCallback = targetInfo -> { - chosen[0] = targetInfo.getResolveInfo(); - return true; - }; - - // Make a stable copy of the components as the original list may be modified - List stableCopy = - createResolvedComponentsForTestWithOtherProfile(3); - onView(withText(stableCopy.get(1).getResolveInfoAt(0).activityInfo.name)) - .perform(click()); - waitForIdle(); - assertThat(chosen[0], is(toChoose)); - } - - @Test - @Ignore("b/285309527") - public void testFilePlusTextSharing_ExcludeText() { - Uri uri = createTestContentProviderUri(null, "image/png"); - Intent sendIntent = createSendImageIntent(uri); - ChooserActivityOverrideData.getInstance().imageLoader = - createImageLoader(uri, createBitmap()); - sendIntent.putExtra(Intent.EXTRA_TEXT, "https://google.com/search?q=google"); - - List resolvedComponentInfos = Arrays.asList( - ResolverDataProvider.createResolvedComponentInfo( - new ComponentName("org.imageviewer", "ImageTarget"), - sendIntent, PERSONAL_USER_HANDLE), - ResolverDataProvider.createResolvedComponentInfo( - new ComponentName("org.textviewer", "UriTarget"), - new Intent("VIEW_TEXT"), PERSONAL_USER_HANDLE) - ); - - setupResolverControllers(resolvedComponentInfos); - - mActivityRule.launchActivity(Intent.createChooser(sendIntent, null)); - waitForIdle(); - - onView(withId(R.id.include_text_action)) - .check(matches(isDisplayed())) - .perform(click()); - waitForIdle(); - - onView(withId(R.id.content_preview_text)).check(matches(withText("File only"))); - - AtomicReference launchedIntentRef = new AtomicReference<>(); - ChooserActivityOverrideData.getInstance().onSafelyStartInternalCallback = targetInfo -> { - launchedIntentRef.set(targetInfo.getTargetIntent()); - return true; - }; - - onView(withText(resolvedComponentInfos.get(0).getResolveInfoAt(0).activityInfo.name)) - .perform(click()); - waitForIdle(); - assertThat(launchedIntentRef.get().hasExtra(Intent.EXTRA_TEXT)).isFalse(); - } - - @Test - @Ignore("b/285309527") - public void testFilePlusTextSharing_RemoveAndAddBackText() { - Uri uri = createTestContentProviderUri("application/pdf", "image/png"); - Intent sendIntent = createSendImageIntent(uri); - ChooserActivityOverrideData.getInstance().imageLoader = - createImageLoader(uri, createBitmap()); - final String text = "https://google.com/search?q=google"; - sendIntent.putExtra(Intent.EXTRA_TEXT, text); - - List resolvedComponentInfos = Arrays.asList( - ResolverDataProvider.createResolvedComponentInfo( - new ComponentName("org.imageviewer", "ImageTarget"), - sendIntent, PERSONAL_USER_HANDLE), - ResolverDataProvider.createResolvedComponentInfo( - new ComponentName("org.textviewer", "UriTarget"), - new Intent("VIEW_TEXT"), PERSONAL_USER_HANDLE) - ); - - setupResolverControllers(resolvedComponentInfos); - - mActivityRule.launchActivity(Intent.createChooser(sendIntent, null)); - waitForIdle(); - - onView(withId(R.id.include_text_action)) - .check(matches(isDisplayed())) - .perform(click()); - waitForIdle(); - onView(withId(R.id.content_preview_text)).check(matches(withText("File only"))); - - onView(withId(R.id.include_text_action)) - .perform(click()); - waitForIdle(); - - onView(withId(R.id.content_preview_text)).check(matches(withText(text))); - - AtomicReference launchedIntentRef = new AtomicReference<>(); - ChooserActivityOverrideData.getInstance().onSafelyStartInternalCallback = targetInfo -> { - launchedIntentRef.set(targetInfo.getTargetIntent()); - return true; - }; - - onView(withText(resolvedComponentInfos.get(0).getResolveInfoAt(0).activityInfo.name)) - .perform(click()); - waitForIdle(); - assertThat(launchedIntentRef.get().getStringExtra(Intent.EXTRA_TEXT)).isEqualTo(text); - } - - @Test - @Ignore("b/285309527") - public void testFilePlusTextSharing_TextExclusionDoesNotAffectAlternativeIntent() { - Uri uri = createTestContentProviderUri("image/png", null); - Intent sendIntent = createSendImageIntent(uri); - ChooserActivityOverrideData.getInstance().imageLoader = - createImageLoader(uri, createBitmap()); - sendIntent.putExtra(Intent.EXTRA_TEXT, "https://google.com/search?q=google"); - - Intent alternativeIntent = createSendTextIntent(); - final String text = "alternative intent"; - alternativeIntent.putExtra(Intent.EXTRA_TEXT, text); - - List resolvedComponentInfos = Arrays.asList( - ResolverDataProvider.createResolvedComponentInfo( - new ComponentName("org.imageviewer", "ImageTarget"), - sendIntent, PERSONAL_USER_HANDLE), - ResolverDataProvider.createResolvedComponentInfo( - new ComponentName("org.textviewer", "UriTarget"), - alternativeIntent, PERSONAL_USER_HANDLE) - ); - - setupResolverControllers(resolvedComponentInfos); - - mActivityRule.launchActivity(Intent.createChooser(sendIntent, null)); - waitForIdle(); - - onView(withId(R.id.include_text_action)) - .check(matches(isDisplayed())) - .perform(click()); - waitForIdle(); - - AtomicReference launchedIntentRef = new AtomicReference<>(); - ChooserActivityOverrideData.getInstance().onSafelyStartInternalCallback = targetInfo -> { - launchedIntentRef.set(targetInfo.getTargetIntent()); - return true; - }; - - onView(withText(resolvedComponentInfos.get(1).getResolveInfoAt(0).activityInfo.name)) - .perform(click()); - waitForIdle(); - assertThat(launchedIntentRef.get().getStringExtra(Intent.EXTRA_TEXT)).isEqualTo(text); - } - - @Test - @Ignore("b/285309527") - public void testImagePlusTextSharing_failedThumbnailAndExcludedText_textChanges() { - Uri uri = createTestContentProviderUri("image/png", null); - Intent sendIntent = createSendImageIntent(uri); - ChooserActivityOverrideData.getInstance().imageLoader = - new FakeImageLoader(Collections.emptyMap()); - sendIntent.putExtra(Intent.EXTRA_TEXT, "https://google.com/search?q=google"); - - List resolvedComponentInfos = Arrays.asList( - ResolverDataProvider.createResolvedComponentInfo( - new ComponentName("org.imageviewer", "ImageTarget"), - sendIntent, PERSONAL_USER_HANDLE), - ResolverDataProvider.createResolvedComponentInfo( - new ComponentName("org.textviewer", "UriTarget"), - new Intent("VIEW_TEXT"), PERSONAL_USER_HANDLE) - ); - - setupResolverControllers(resolvedComponentInfos); - - mActivityRule.launchActivity(Intent.createChooser(sendIntent, null)); - waitForIdle(); - - onView(withId(R.id.include_text_action)) - .check(matches(isDisplayed())) - .perform(click()); - waitForIdle(); - - onView(withId(R.id.image_view)) - .check(matches(withEffectiveVisibility(ViewMatchers.Visibility.GONE))); - onView(withId(R.id.content_preview_text)) - .check(matches(allOf(isDisplayed(), withText("Image only")))); - } - - @Test - public void copyTextToClipboard() { - Intent sendIntent = createSendTextIntent(); - List resolvedComponentInfos = createResolvedComponentsForTest(2); - - setupResolverControllers(resolvedComponentInfos); - - final ChooserActivity activity = - mActivityRule.launchActivity(Intent.createChooser(sendIntent, null)); - waitForIdle(); - - onView(withId(R.id.copy)).check(matches(isDisplayed())); - onView(withId(R.id.copy)).perform(click()); - ClipboardManager clipboard = (ClipboardManager) activity.getSystemService( - Context.CLIPBOARD_SERVICE); - ClipData clipData = clipboard.getPrimaryClip(); - assertThat(clipData).isNotNull(); - assertThat(clipData.getItemAt(0).getText()).isEqualTo("testing intent sending"); - - ClipDescription clipDescription = clipData.getDescription(); - assertThat("text/plain", is(clipDescription.getMimeType(0))); - - assertEquals(mActivityRule.getActivityResult().getResultCode(), RESULT_OK); - } - - @Test - public void copyTextToClipboardLogging() { - Intent sendIntent = createSendTextIntent(); - List resolvedComponentInfos = createResolvedComponentsForTest(2); - - setupResolverControllers(resolvedComponentInfos); - - ChooserWrapperActivity activity = - mActivityRule.launchActivity(Intent.createChooser(sendIntent, null)); - waitForIdle(); - - onView(withId(R.id.copy)).check(matches(isDisplayed())); - onView(withId(R.id.copy)).perform(click()); - FakeEventLog eventLog = getEventLog(activity); - assertThat(eventLog.getActionSelected()) - .isEqualTo(new FakeEventLog.ActionSelected( - /* targetType = */ EventLog.SELECTION_TYPE_COPY)); - } - - @Test - @Ignore - public void testNearbyShareLogging() throws Exception { - Intent sendIntent = createSendTextIntent(); - List resolvedComponentInfos = createResolvedComponentsForTest(2); - - setupResolverControllers(resolvedComponentInfos); - - mActivityRule.launchActivity(Intent.createChooser(sendIntent, null)); - waitForIdle(); - - onView(withId(com.android.internal.R.id.chooser_nearby_button)) - .check(matches(isDisplayed())); - onView(withId(com.android.internal.R.id.chooser_nearby_button)).perform(click()); - - // TODO(b/211669337): Determine the expected SHARESHEET_DIRECT_LOAD_COMPLETE events. - } - - - - @Test @Ignore - public void testEditImageLogs() { - Uri uri = createTestContentProviderUri("image/png", null); - Intent sendIntent = createSendImageIntent(uri); - ChooserActivityOverrideData.getInstance().imageLoader = - createImageLoader(uri, createBitmap()); - - List resolvedComponentInfos = createResolvedComponentsForTest(2); - - setupResolverControllers(resolvedComponentInfos); - - mActivityRule.launchActivity(Intent.createChooser(sendIntent, null)); - waitForIdle(); - - onView(withId(com.android.internal.R.id.chooser_edit_button)).check(matches(isDisplayed())); - onView(withId(com.android.internal.R.id.chooser_edit_button)).perform(click()); - - // TODO(b/211669337): Determine the expected SHARESHEET_DIRECT_LOAD_COMPLETE events. - } - - - @Test - public void oneVisibleImagePreview() { - Uri uri = createTestContentProviderUri("image/png", null); - - ArrayList uris = new ArrayList<>(); - uris.add(uri); - - Intent sendIntent = createSendUriIntentWithPreview(uris); - ChooserActivityOverrideData.getInstance().imageLoader = - createImageLoader(uri, createWideBitmap()); - - List resolvedComponentInfos = createResolvedComponentsForTest(2); - - setupResolverControllers(resolvedComponentInfos); - mActivityRule.launchActivity(Intent.createChooser(sendIntent, null)); - waitForIdle(); - onView(withId(R.id.scrollable_image_preview)) - .check((view, exception) -> { - if (exception != null) { - throw exception; - } - RecyclerView recyclerView = (RecyclerView) view; - RecyclerViewExt.endAnimations(recyclerView); - assertThat(recyclerView.getAdapter().getItemCount(), is(1)); - assertThat(recyclerView.getChildCount(), is(1)); - View imageView = recyclerView.getChildAt(0); - Rect rect = new Rect(); - boolean isPartiallyVisible = imageView.getGlobalVisibleRect(rect); - assertThat( - "image preview view is not fully visible", - isPartiallyVisible - && rect.width() == imageView.getWidth() - && rect.height() == imageView.getHeight()); - }); - } - - @Test - public void allThumbnailsFailedToLoad_hidePreview() { - Uri uri = createTestContentProviderUri("image/jpg", null); - - ArrayList uris = new ArrayList<>(); - uris.add(uri); - uris.add(uri); - - Intent sendIntent = createSendUriIntentWithPreview(uris); - ChooserActivityOverrideData.getInstance().imageLoader = - new FakeImageLoader(Collections.emptyMap()); - - List resolvedComponentInfos = createResolvedComponentsForTest(2); - - setupResolverControllers(resolvedComponentInfos); - mActivityRule.launchActivity(Intent.createChooser(sendIntent, null)); - waitForIdle(); - onView(withId(R.id.scrollable_image_preview)) - .check(matches(withEffectiveVisibility(ViewMatchers.Visibility.GONE))); - } - - @Test(timeout = 4_000) - public void testSlowUriMetadata_fallbackToFilePreview() { - Uri uri = createTestContentProviderUri( - "application/pdf", "image/png", /*streamTypeTimeout=*/8_000); - ArrayList uris = new ArrayList<>(1); - uris.add(uri); - Intent sendIntent = createSendUriIntentWithPreview(uris); - ChooserActivityOverrideData.getInstance().imageLoader = - createImageLoader(uri, createBitmap()); - - List resolvedComponentInfos = createResolvedComponentsForTest(2); - - setupResolverControllers(resolvedComponentInfos); - // The preview type resolution is expected to timeout and default to file preview, otherwise - // the test should timeout. - mActivityRule.launchActivity(Intent.createChooser(sendIntent, null)); - waitForIdle(); - - onView(withId(R.id.content_preview_filename)).check(matches(isDisplayed())); - onView(withId(R.id.content_preview_filename)).check(matches(withText("image.png"))); - onView(withId(R.id.content_preview_file_icon)).check(matches(isDisplayed())); - } - - @Test(timeout = 4_000) - public void testSendManyFilesWithSmallMetadataDelayAndOneImage_fallbackToFilePreviewUi() { - Uri fileUri = createTestContentProviderUri( - "application/pdf", "application/pdf", /*streamTypeTimeout=*/300); - Uri imageUri = createTestContentProviderUri("application/pdf", "image/png"); - ArrayList uris = new ArrayList<>(50); - for (int i = 0; i < 49; i++) { - uris.add(fileUri); - } - uris.add(imageUri); - Intent sendIntent = createSendUriIntentWithPreview(uris); - ChooserActivityOverrideData.getInstance().imageLoader = - createImageLoader(imageUri, createBitmap()); - - List resolvedComponentInfos = createResolvedComponentsForTest(2); - setupResolverControllers(resolvedComponentInfos); - // The preview type resolution is expected to timeout and default to file preview, otherwise - // the test should timeout. - mActivityRule.launchActivity(Intent.createChooser(sendIntent, null)); - - waitForIdle(); - - onView(withId(R.id.content_preview_filename)).check(matches(isDisplayed())); - onView(withId(R.id.content_preview_filename)).check(matches(withText("image.png"))); - onView(withId(R.id.content_preview_file_icon)).check(matches(isDisplayed())); - } - - @Test - public void testManyVisibleImagePreview_ScrollableImagePreview() { - Uri uri = createTestContentProviderUri("image/png", null); - - ArrayList uris = new ArrayList<>(); - uris.add(uri); - uris.add(uri); - uris.add(uri); - uris.add(uri); - uris.add(uri); - uris.add(uri); - uris.add(uri); - uris.add(uri); - uris.add(uri); - uris.add(uri); - - Intent sendIntent = createSendUriIntentWithPreview(uris); - ChooserActivityOverrideData.getInstance().imageLoader = - createImageLoader(uri, createBitmap()); - - List resolvedComponentInfos = createResolvedComponentsForTest(2); - - setupResolverControllers(resolvedComponentInfos); - mActivityRule.launchActivity(Intent.createChooser(sendIntent, null)); - waitForIdle(); - onView(withId(R.id.scrollable_image_preview)) - .perform(RecyclerViewActions.scrollToLastPosition()) - .check((view, exception) -> { - if (exception != null) { - throw exception; - } - RecyclerView recyclerView = (RecyclerView) view; - assertThat(recyclerView.getAdapter().getItemCount(), is(uris.size())); - }); - } - - @Test(timeout = 4_000) - public void testPartiallyLoadedMetadata_previewIsShownForTheLoadedPart() { - Uri imgOneUri = createTestContentProviderUri("image/png", null); - Uri imgTwoUri = createTestContentProviderUri("image/png", null) - .buildUpon() - .path("image-2.png") - .build(); - Uri docUri = createTestContentProviderUri("application/pdf", "image/png", 8_000); - ArrayList uris = new ArrayList<>(2); - // two large previews to fill the screen and be presented right away and one - // document that would be delayed by the URI metadata reading - uris.add(imgOneUri); - uris.add(imgTwoUri); - uris.add(docUri); - - Intent sendIntent = createSendUriIntentWithPreview(uris); - Map 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 FakeImageLoader(bitmaps); - - List resolvedComponentInfos = createResolvedComponentsForTest(2); - setupResolverControllers(resolvedComponentInfos); - - // the preview type is expected to be resolved quickly based on the first provided URI - // metadata. If, instead, it is dependent on the third URI metadata, the test should either - // timeout or (more probably due to inner timeout) default to file preview type; anyway the - // test will fail. - mActivityRule.launchActivity(Intent.createChooser(sendIntent, null)); - waitForIdle(); - - onView(withId(R.id.scrollable_image_preview)) - .check((view, exception) -> { - if (exception != null) { - 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); - assertThat(imageView).isNotNull(); - }) - .perform(RecyclerViewActions.scrollToLastPosition()) - .check((view, exception) -> { - if (exception != null) { - throw exception; - } - RecyclerView recyclerView = (RecyclerView) view; - assertThat(recyclerView.getChildCount()).isAtLeast(1); - // check that the last view is a loading indicator - View loadingIndicator = - recyclerView.getChildAt(recyclerView.getChildCount() - 1); - assertThat(loadingIndicator).isNotNull(); - }); - waitForIdle(); - } - - @Test - public void testImageAndTextPreview() { - final Uri uri = createTestContentProviderUri("image/png", null); - final String sharedText = "text-" + System.currentTimeMillis(); - - ArrayList uris = new ArrayList<>(); - uris.add(uri); - - Intent sendIntent = createSendUriIntentWithPreview(uris); - sendIntent.putExtra(Intent.EXTRA_TEXT, sharedText); - ChooserActivityOverrideData.getInstance().imageLoader = - createImageLoader(uri, createBitmap()); - - List resolvedComponentInfos = createResolvedComponentsForTest(2); - - setupResolverControllers(resolvedComponentInfos); - mActivityRule.launchActivity(Intent.createChooser(sendIntent, null)); - waitForIdle(); - onView(withText(sharedText)) - .check(matches(isDisplayed())); - } - - @Test - public void test_shareImageWithRichText_RichTextIsDisplayed() { - final Uri uri = createTestContentProviderUri("image/png", null); - final CharSequence sharedText = new SpannableStringBuilder() - .append( - "text-", - new StyleSpan(Typeface.BOLD_ITALIC), - Spannable.SPAN_INCLUSIVE_EXCLUSIVE) - .append( - Long.toString(System.currentTimeMillis()), - new ForegroundColorSpan(Color.RED), - Spanned.SPAN_INCLUSIVE_EXCLUSIVE); - - ArrayList uris = new ArrayList<>(); - uris.add(uri); - - Intent sendIntent = createSendUriIntentWithPreview(uris); - sendIntent.putExtra(Intent.EXTRA_TEXT, sharedText); - ChooserActivityOverrideData.getInstance().imageLoader = - createImageLoader(uri, createBitmap()); - - List resolvedComponentInfos = createResolvedComponentsForTest(2); - - setupResolverControllers(resolvedComponentInfos); - mActivityRule.launchActivity(Intent.createChooser(sendIntent, null)); - waitForIdle(); - onView(withText(sharedText.toString())) - .check(matches(isDisplayed())) - .check((view, e) -> { - if (e != null) { - throw e; - } - assertThat(view).isInstanceOf(TextView.class); - CharSequence text = ((TextView) view).getText(); - assertThat(text).isInstanceOf(Spanned.class); - Spanned spanned = (Spanned) text; - Object[] spans = spanned.getSpans(0, text.length(), Object.class); - assertThat(spans).hasLength(2); - assertThat(spanned.getSpans(0, 5, StyleSpan.class)).hasLength(1); - assertThat(spanned.getSpans(5, text.length(), ForegroundColorSpan.class)) - .hasLength(1); - }); - } - - @Test - public void testTextPreviewWhenTextIsSharedWithMultipleImages() { - final Uri uri = createTestContentProviderUri("image/png", null); - final String sharedText = "text-" + System.currentTimeMillis(); - - ArrayList uris = new ArrayList<>(); - uris.add(uri); - uris.add(uri); - - Intent sendIntent = createSendUriIntentWithPreview(uris); - sendIntent.putExtra(Intent.EXTRA_TEXT, sharedText); - ChooserActivityOverrideData.getInstance().imageLoader = - createImageLoader(uri, createBitmap()); - - List resolvedComponentInfos = createResolvedComponentsForTest(2); - - when( - ChooserActivityOverrideData - .getInstance() - .resolverListController - .getResolversForIntentAsUser( - Mockito.anyBoolean(), - Mockito.anyBoolean(), - Mockito.anyBoolean(), - Mockito.isA(List.class), - Mockito.any(UserHandle.class))) - .thenReturn(resolvedComponentInfos); - mActivityRule.launchActivity(Intent.createChooser(sendIntent, null)); - waitForIdle(); - onView(withText(sharedText)).check(matches(isDisplayed())); - } - - @Test - public void testOnCreateLogging() { - Intent sendIntent = createSendTextIntent(); - sendIntent.setType(TEST_MIME_TYPE); - - ChooserWrapperActivity activity = - mActivityRule.launchActivity(Intent.createChooser(sendIntent, "logger test")); - waitForIdle(); - - FakeEventLog eventLog = getEventLog(activity); - FakeEventLog.ChooserActivityShown event = eventLog.getChooserActivityShown(); - assertThat(event).isNotNull(); - assertThat(event.isWorkProfile()).isFalse(); - assertThat(event.getTargetMimeType()).isEqualTo(TEST_MIME_TYPE); - } - - @Test - public void testOnCreateLoggingFromWorkProfile() { - Intent sendIntent = createSendTextIntent(); - sendIntent.setType(TEST_MIME_TYPE); - ChooserActivityOverrideData.getInstance().alternateProfileSetting = - MetricsEvent.MANAGED_PROFILE; - - ChooserWrapperActivity activity = - mActivityRule.launchActivity(Intent.createChooser(sendIntent, "logger test")); - waitForIdle(); - - FakeEventLog eventLog = getEventLog(activity); - FakeEventLog.ChooserActivityShown event = eventLog.getChooserActivityShown(); - assertThat(event).isNotNull(); - assertThat(event.isWorkProfile()).isTrue(); - assertThat(event.getTargetMimeType()).isEqualTo(TEST_MIME_TYPE); - } - - @Test - public void testEmptyPreviewLogging() { - Intent sendIntent = createSendTextIntentWithPreview(null, null); - - ChooserWrapperActivity activity = - mActivityRule.launchActivity(Intent.createChooser(sendIntent, - "empty preview logger test")); - waitForIdle(); - - FakeEventLog eventLog = getEventLog(activity); - FakeEventLog.ChooserActivityShown event = eventLog.getChooserActivityShown(); - assertThat(event).isNotNull(); - assertThat(event.isWorkProfile()).isFalse(); - assertThat(event.getTargetMimeType()).isNull(); - } - - @Test - public void testTitlePreviewLogging() { - Intent sendIntent = createSendTextIntentWithPreview("TestTitle", null); - - List resolvedComponentInfos = createResolvedComponentsForTest(2); - - setupResolverControllers(resolvedComponentInfos); - - ChooserWrapperActivity activity = - mActivityRule.launchActivity(Intent.createChooser(sendIntent, null)); - waitForIdle(); - - FakeEventLog eventLog = getEventLog(activity); - assertThat(eventLog.getActionShareWithPreview()) - .isEqualTo(new FakeEventLog.ActionShareWithPreview( - /* previewType = */ CONTENT_PREVIEW_TEXT)); - } - - @Test - public void testImagePreviewLogging() { - Uri uri = createTestContentProviderUri("image/png", null); - - ArrayList uris = new ArrayList<>(); - uris.add(uri); - - Intent sendIntent = createSendUriIntentWithPreview(uris); - ChooserActivityOverrideData.getInstance().imageLoader = - createImageLoader(uri, createBitmap()); - - List resolvedComponentInfos = createResolvedComponentsForTest(2); - - setupResolverControllers(resolvedComponentInfos); - - ChooserWrapperActivity activity = - mActivityRule.launchActivity(Intent.createChooser(sendIntent, null)); - waitForIdle(); - - FakeEventLog eventLog = getEventLog(activity); - assertThat(eventLog.getActionShareWithPreview()) - .isEqualTo(new FakeEventLog.ActionShareWithPreview( - /* previewType = */ CONTENT_PREVIEW_IMAGE)); - } - - @Test - public void oneVisibleFilePreview() throws InterruptedException { - Uri uri = Uri.parse("content://com.android.frameworks.coretests/app.pdf"); - - ArrayList uris = new ArrayList<>(); - uris.add(uri); - - Intent sendIntent = createSendUriIntentWithPreview(uris); - - List resolvedComponentInfos = createResolvedComponentsForTest(2); - - setupResolverControllers(resolvedComponentInfos); - mActivityRule.launchActivity(Intent.createChooser(sendIntent, null)); - waitForIdle(); - onView(withId(R.id.content_preview_filename)).check(matches(isDisplayed())); - onView(withId(R.id.content_preview_filename)).check(matches(withText("app.pdf"))); - onView(withId(R.id.content_preview_file_icon)).check(matches(isDisplayed())); - } - - - @Test - public void moreThanOneVisibleFilePreview() throws InterruptedException { - Uri uri = Uri.parse("content://com.android.frameworks.coretests/app.pdf"); - - ArrayList uris = new ArrayList<>(); - uris.add(uri); - uris.add(uri); - uris.add(uri); - - Intent sendIntent = createSendUriIntentWithPreview(uris); - - List resolvedComponentInfos = createResolvedComponentsForTest(2); - - setupResolverControllers(resolvedComponentInfos); - mActivityRule.launchActivity(Intent.createChooser(sendIntent, null)); - waitForIdle(); - onView(withId(R.id.content_preview_filename)).check(matches(isDisplayed())); - onView(withId(R.id.content_preview_filename)).check(matches(withText("app.pdf"))); - onView(withId(R.id.content_preview_more_files)).check(matches(isDisplayed())); - onView(withId(R.id.content_preview_more_files)).check(matches(withText("+ 2 more files"))); - onView(withId(R.id.content_preview_file_icon)).check(matches(isDisplayed())); - } - - @Test - public void contentProviderThrowSecurityException() throws InterruptedException { - Uri uri = Uri.parse("content://com.android.frameworks.coretests/app.pdf"); - - ArrayList uris = new ArrayList<>(); - uris.add(uri); - - Intent sendIntent = createSendUriIntentWithPreview(uris); - - List resolvedComponentInfos = createResolvedComponentsForTest(2); - setupResolverControllers(resolvedComponentInfos); - - ChooserActivityOverrideData.getInstance().resolverForceException = true; - - mActivityRule.launchActivity(Intent.createChooser(sendIntent, null)); - waitForIdle(); - onView(withId(R.id.content_preview_filename)).check(matches(isDisplayed())); - onView(withId(R.id.content_preview_filename)).check(matches(withText("app.pdf"))); - onView(withId(R.id.content_preview_file_icon)).check(matches(isDisplayed())); - } - - @Test - public void contentProviderReturnsNoColumns() throws InterruptedException { - Uri uri = Uri.parse("content://com.android.frameworks.coretests/app.pdf"); - - ArrayList uris = new ArrayList<>(); - uris.add(uri); - uris.add(uri); - - Intent sendIntent = createSendUriIntentWithPreview(uris); - - List resolvedComponentInfos = createResolvedComponentsForTest(2); - setupResolverControllers(resolvedComponentInfos); - - Cursor cursor = mock(Cursor.class); - when(cursor.getCount()).thenReturn(1); - Mockito.doNothing().when(cursor).close(); - when(cursor.moveToFirst()).thenReturn(true); - when(cursor.getColumnIndex(Mockito.anyString())).thenReturn(-1); - - ChooserActivityOverrideData.getInstance().resolverCursor = cursor; - - mActivityRule.launchActivity(Intent.createChooser(sendIntent, null)); - waitForIdle(); - onView(withId(R.id.content_preview_filename)).check(matches(isDisplayed())); - onView(withId(R.id.content_preview_filename)).check(matches(withText("app.pdf"))); - onView(withId(R.id.content_preview_more_files)).check(matches(isDisplayed())); - onView(withId(R.id.content_preview_more_files)).check(matches(withText("+ 1 more file"))); - onView(withId(R.id.content_preview_file_icon)).check(matches(isDisplayed())); - } - - @Test - public void testGetBaseScore() { - final float testBaseScore = 0.89f; - - Intent sendIntent = createSendTextIntent(); - List resolvedComponentInfos = createResolvedComponentsForTest(2); - - setupResolverControllers(resolvedComponentInfos); - - when( - ChooserActivityOverrideData - .getInstance() - .resolverListController - .getScore(Mockito.isA(DisplayResolveInfo.class))) - .thenReturn(testBaseScore); - - final IChooserWrapper activity = (IChooserWrapper) - mActivityRule.launchActivity(Intent.createChooser(sendIntent, null)); - waitForIdle(); - - final DisplayResolveInfo testDri = - activity.createTestDisplayResolveInfo( - sendIntent, - ResolverDataProvider.createResolveInfo(3, 0, PERSONAL_USER_HANDLE), - "testLabel", - "testInfo", - sendIntent); - final ChooserListAdapter adapter = activity.getAdapter(); - - assertThat(adapter.getBaseScore(null, 0), is(CALLER_TARGET_SCORE_BOOST)); - assertThat(adapter.getBaseScore(testDri, TARGET_TYPE_DEFAULT), is(testBaseScore)); - assertThat(adapter.getBaseScore(testDri, TARGET_TYPE_CHOOSER_TARGET), is(testBaseScore)); - assertThat(adapter.getBaseScore(testDri, TARGET_TYPE_SHORTCUTS_FROM_PREDICTION_SERVICE), - is(testBaseScore * SHORTCUT_TARGET_SCORE_BOOST)); - assertThat(adapter.getBaseScore(testDri, TARGET_TYPE_SHORTCUTS_FROM_SHORTCUT_MANAGER), - is(testBaseScore * SHORTCUT_TARGET_SCORE_BOOST)); - } - - // This test is too long and too slow and should not be taken as an example for future tests. - @Test - public void testDirectTargetSelectionLogging() { - Intent sendIntent = createSendTextIntent(); - // We need app targets for direct targets to get displayed - List resolvedComponentInfos = createResolvedComponentsForTest(2); - setupResolverControllers(resolvedComponentInfos); - - // create test shortcut loader factory, remember loaders and their callbacks - SparseArray>> shortcutLoaders = - createShortcutLoaderFactory(); - - // Start activity - ChooserWrapperActivity activity = - mActivityRule.launchActivity(Intent.createChooser(sendIntent, null)); - waitForIdle(); - - // verify that ShortcutLoader was queried - ArgumentCaptor appTargets = - ArgumentCaptor.forClass(DisplayResolveInfo[].class); - verify(shortcutLoaders.get(0).first, times(1)).updateAppTargets(appTargets.capture()); - - // send shortcuts - assertThat( - "Wrong number of app targets", - appTargets.getValue().length, - is(resolvedComponentInfos.size())); - List serviceTargets = createDirectShareTargets(1, ""); - ShortcutLoader.Result result = new ShortcutLoader.Result( - true, - appTargets.getValue(), - new ShortcutLoader.ShortcutResultInfo[] { - new ShortcutLoader.ShortcutResultInfo( - appTargets.getValue()[0], - serviceTargets - ) - }, - new HashMap<>(), - new HashMap<>() - ); - activity.getMainExecutor().execute(() -> shortcutLoaders.get(0).second.accept(result)); - waitForIdle(); - - final ChooserListAdapter activeAdapter = activity.getAdapter(); - assertThat( - "Chooser should have 3 targets (2 apps, 1 direct)", - activeAdapter.getCount(), - is(3)); - assertThat( - "Chooser should have exactly one selectable direct target", - activeAdapter.getSelectableServiceTargetCount(), - is(1)); - assertThat( - "The resolver info must match the resolver info used to create the target", - activeAdapter.getItem(0).getResolveInfo(), - is(resolvedComponentInfos.get(0).getResolveInfoAt(0))); - - // Click on the direct target - String name = serviceTargets.get(0).getTitle().toString(); - onView(withText(name)) - .perform(click()); - waitForIdle(); - - FakeEventLog eventLog = getEventLog(activity); - assertThat(eventLog.getShareTargetSelected()).hasSize(1); - FakeEventLog.ShareTargetSelected call = eventLog.getShareTargetSelected().get(0); - assertThat(call.getTargetType()).isEqualTo(EventLog.SELECTION_TYPE_SERVICE); - assertThat(call.getDirectTargetAlsoRanked()).isEqualTo(-1); - var hashResult = call.getDirectTargetHashed(); - var hash = hashResult == null ? "" : hashResult.hashedString; - assertWithMessage("Hash is not predictable but must be obfuscated") - .that(hash).isNotEqualTo(name); - } - - // This test is too long and too slow and should not be taken as an example for future tests. - @Test - public void testDirectTargetLoggingWithRankedAppTarget() { - Intent sendIntent = createSendTextIntent(); - // We need app targets for direct targets to get displayed - List resolvedComponentInfos = createResolvedComponentsForTest(2); - setupResolverControllers(resolvedComponentInfos); - - // create test shortcut loader factory, remember loaders and their callbacks - SparseArray>> shortcutLoaders = - createShortcutLoaderFactory(); - - // Start activity - ChooserWrapperActivity activity = - mActivityRule.launchActivity(Intent.createChooser(sendIntent, null)); - waitForIdle(); - - // verify that ShortcutLoader was queried - ArgumentCaptor appTargets = - ArgumentCaptor.forClass(DisplayResolveInfo[].class); - verify(shortcutLoaders.get(0).first, times(1)).updateAppTargets(appTargets.capture()); - - // send shortcuts - assertThat( - "Wrong number of app targets", - appTargets.getValue().length, - is(resolvedComponentInfos.size())); - List serviceTargets = createDirectShareTargets( - 1, - resolvedComponentInfos.get(0).getResolveInfoAt(0).activityInfo.packageName); - ShortcutLoader.Result result = new ShortcutLoader.Result( - true, - appTargets.getValue(), - new ShortcutLoader.ShortcutResultInfo[] { - new ShortcutLoader.ShortcutResultInfo( - appTargets.getValue()[0], - serviceTargets - ) - }, - new HashMap<>(), - new HashMap<>() - ); - activity.getMainExecutor().execute(() -> shortcutLoaders.get(0).second.accept(result)); - waitForIdle(); - - final ChooserListAdapter activeAdapter = activity.getAdapter(); - assertThat( - "Chooser should have 3 targets (2 apps, 1 direct)", - activeAdapter.getCount(), - is(3)); - assertThat( - "Chooser should have exactly one selectable direct target", - activeAdapter.getSelectableServiceTargetCount(), - is(1)); - assertThat( - "The resolver info must match the resolver info used to create the target", - activeAdapter.getItem(0).getResolveInfo(), - is(resolvedComponentInfos.get(0).getResolveInfoAt(0))); - - // Click on the direct target - String name = serviceTargets.get(0).getTitle().toString(); - onView(withText(name)) - .perform(click()); - waitForIdle(); - - FakeEventLog eventLog = getEventLog(activity); - assertThat(eventLog.getShareTargetSelected()).hasSize(1); - FakeEventLog.ShareTargetSelected call = eventLog.getShareTargetSelected().get(0); - - assertThat(call.getTargetType()).isEqualTo(EventLog.SELECTION_TYPE_SERVICE); - assertThat(call.getDirectTargetAlsoRanked()).isEqualTo(0); - } - - @Test - public void testShortcutTargetWithApplyAppLimits() { - // Set up resources - Resources resources = Mockito.spy( - InstrumentationRegistry.getInstrumentation().getContext().getResources()); - ChooserActivityOverrideData.getInstance().resources = resources; - doReturn(1).when(resources).getInteger(R.integer.config_maxShortcutTargetsPerApp); - Intent sendIntent = createSendTextIntent(); - // We need app targets for direct targets to get displayed - List resolvedComponentInfos = createResolvedComponentsForTest(2); - setupResolverControllers(resolvedComponentInfos); - - // create test shortcut loader factory, remember loaders and their callbacks - SparseArray>> shortcutLoaders = - createShortcutLoaderFactory(); - - // Start activity - final IChooserWrapper activity = (IChooserWrapper) mActivityRule - .launchActivity(Intent.createChooser(sendIntent, null)); - waitForIdle(); - - // verify that ShortcutLoader was queried - ArgumentCaptor appTargets = - ArgumentCaptor.forClass(DisplayResolveInfo[].class); - verify(shortcutLoaders.get(0).first, times(1)).updateAppTargets(appTargets.capture()); - - // send shortcuts - assertThat( - "Wrong number of app targets", - appTargets.getValue().length, - is(resolvedComponentInfos.size())); - List serviceTargets = createDirectShareTargets( - 2, - resolvedComponentInfos.get(0).getResolveInfoAt(0).activityInfo.packageName); - ShortcutLoader.Result result = new ShortcutLoader.Result( - true, - appTargets.getValue(), - new ShortcutLoader.ShortcutResultInfo[] { - new ShortcutLoader.ShortcutResultInfo( - appTargets.getValue()[0], - serviceTargets - ) - }, - new HashMap<>(), - new HashMap<>() - ); - activity.getMainExecutor().execute(() -> shortcutLoaders.get(0).second.accept(result)); - waitForIdle(); - - final ChooserListAdapter activeAdapter = activity.getAdapter(); - assertThat( - "Chooser should have 3 targets (2 apps, 1 direct)", - activeAdapter.getCount(), - is(3)); - assertThat( - "Chooser should have exactly one selectable direct target", - activeAdapter.getSelectableServiceTargetCount(), - is(1)); - assertThat( - "The resolver info must match the resolver info used to create the target", - activeAdapter.getItem(0).getResolveInfo(), - is(resolvedComponentInfos.get(0).getResolveInfoAt(0))); - assertThat( - "The display label must match", - activeAdapter.getItem(0).getDisplayLabel(), - is("testTitle0")); - } - - @Test - public void testShortcutTargetWithoutApplyAppLimits() { - setDeviceConfigProperty( - SystemUiDeviceConfigFlags.APPLY_SHARING_APP_LIMITS_IN_SYSUI, - Boolean.toString(false)); - // Set up resources - Resources resources = Mockito.spy( - InstrumentationRegistry.getInstrumentation().getContext().getResources()); - ChooserActivityOverrideData.getInstance().resources = resources; - doReturn(1).when(resources).getInteger(R.integer.config_maxShortcutTargetsPerApp); - Intent sendIntent = createSendTextIntent(); - // We need app targets for direct targets to get displayed - List resolvedComponentInfos = createResolvedComponentsForTest(2); - setupResolverControllers(resolvedComponentInfos); - - // create test shortcut loader factory, remember loaders and their callbacks - SparseArray>> shortcutLoaders = - createShortcutLoaderFactory(); - - // Start activity - final IChooserWrapper activity = (IChooserWrapper) - mActivityRule.launchActivity(Intent.createChooser(sendIntent, null)); - waitForIdle(); - - // verify that ShortcutLoader was queried - ArgumentCaptor appTargets = - ArgumentCaptor.forClass(DisplayResolveInfo[].class); - verify(shortcutLoaders.get(0).first, times(1)).updateAppTargets(appTargets.capture()); - - // send shortcuts - assertThat( - "Wrong number of app targets", - appTargets.getValue().length, - is(resolvedComponentInfos.size())); - List serviceTargets = createDirectShareTargets( - 2, - resolvedComponentInfos.get(0).getResolveInfoAt(0).activityInfo.packageName); - ShortcutLoader.Result result = new ShortcutLoader.Result( - true, - appTargets.getValue(), - new ShortcutLoader.ShortcutResultInfo[] { - new ShortcutLoader.ShortcutResultInfo( - appTargets.getValue()[0], - serviceTargets - ) - }, - new HashMap<>(), - new HashMap<>() - ); - activity.getMainExecutor().execute(() -> shortcutLoaders.get(0).second.accept(result)); - waitForIdle(); - - final ChooserListAdapter activeAdapter = activity.getAdapter(); - assertThat( - "Chooser should have 4 targets (2 apps, 2 direct)", - activeAdapter.getCount(), - is(4)); - assertThat( - "Chooser should have exactly two selectable direct target", - activeAdapter.getSelectableServiceTargetCount(), - is(2)); - assertThat( - "The resolver info must match the resolver info used to create the target", - activeAdapter.getItem(0).getResolveInfo(), - is(resolvedComponentInfos.get(0).getResolveInfoAt(0))); - assertThat( - "The display label must match", - activeAdapter.getItem(0).getDisplayLabel(), - is("testTitle0")); - assertThat( - "The display label must match", - activeAdapter.getItem(1).getDisplayLabel(), - is("testTitle1")); - } - - @Test - public void testLaunchWithCallerProvidedTarget() { - setDeviceConfigProperty( - SystemUiDeviceConfigFlags.APPLY_SHARING_APP_LIMITS_IN_SYSUI, - Boolean.toString(false)); - // Set up resources - Resources resources = Mockito.spy( - InstrumentationRegistry.getInstrumentation().getContext().getResources()); - ChooserActivityOverrideData.getInstance().resources = resources; - doReturn(1).when(resources).getInteger(R.integer.config_maxShortcutTargetsPerApp); - - // We need app targets for direct targets to get displayed - List resolvedComponentInfos = createResolvedComponentsForTest(2); - setupResolverControllers(resolvedComponentInfos, resolvedComponentInfos); - markOtherProfileAvailability(/* workAvailable= */ true, /* cloneAvailable= */ false); - - // set caller-provided target - Intent chooserIntent = Intent.createChooser(createSendTextIntent(), null); - String callerTargetLabel = "Caller Target"; - ChooserTarget[] targets = new ChooserTarget[] { - new ChooserTarget( - callerTargetLabel, - Icon.createWithBitmap(createBitmap()), - 0.1f, - resolvedComponentInfos.get(0).name, - new Bundle()) - }; - chooserIntent.putExtra(Intent.EXTRA_CHOOSER_TARGETS, targets); - - // create test shortcut loader factory, remember loaders and their callbacks - SparseArray>> shortcutLoaders = - createShortcutLoaderFactory(); - - // Start activity - final IChooserWrapper activity = (IChooserWrapper) - mActivityRule.launchActivity(chooserIntent); - waitForIdle(); - - // verify that ShortcutLoader was queried - ArgumentCaptor appTargets = - ArgumentCaptor.forClass(DisplayResolveInfo[].class); - verify(shortcutLoaders.get(0).first, times(1)).updateAppTargets(appTargets.capture()); - - // send shortcuts - assertThat( - "Wrong number of app targets", - appTargets.getValue().length, - is(resolvedComponentInfos.size())); - ShortcutLoader.Result result = new ShortcutLoader.Result( - true, - appTargets.getValue(), - new ShortcutLoader.ShortcutResultInfo[0], - new HashMap<>(), - new HashMap<>()); - activity.getMainExecutor().execute(() -> shortcutLoaders.get(0).second.accept(result)); - waitForIdle(); - - final ChooserListAdapter activeAdapter = activity.getAdapter(); - assertThat( - "Chooser should have 3 targets (2 apps, 1 direct)", - activeAdapter.getCount(), - is(3)); - assertThat( - "Chooser should have exactly two selectable direct target", - activeAdapter.getSelectableServiceTargetCount(), - is(1)); - assertThat( - "The display label must match", - activeAdapter.getItem(0).getDisplayLabel(), - is(callerTargetLabel)); - - // Switch to work profile and ensure that the target *doesn't* show up there. - onView(withText(R.string.resolver_work_tab)).perform(click()); - waitForIdle(); - - for (int i = 0; i < activity.getWorkListAdapter().getCount(); i++) { - assertThat( - "Chooser target should not show up in opposite profile", - activity.getWorkListAdapter().getItem(i).getDisplayLabel(), - not(callerTargetLabel)); - } - } - - @Test - public void testLaunchWithCustomAction() throws InterruptedException { - List resolvedComponentInfos = createResolvedComponentsForTest(2); - setupResolverControllers(resolvedComponentInfos); - - Context testContext = InstrumentationRegistry.getInstrumentation().getContext(); - final String customActionLabel = "Custom Action"; - final String testAction = "test-broadcast-receiver-action"; - Intent chooserIntent = Intent.createChooser(createSendTextIntent(), null); - chooserIntent.putExtra( - Intent.EXTRA_CHOOSER_CUSTOM_ACTIONS, - new ChooserAction[] { - new ChooserAction.Builder( - Icon.createWithResource("", Resources.ID_NULL), - customActionLabel, - PendingIntent.getBroadcast( - testContext, - 123, - new Intent(testAction), - PendingIntent.FLAG_IMMUTABLE | PendingIntent.FLAG_ONE_SHOT)) - .build() - }); - // Start activity - mActivityRule.launchActivity(chooserIntent); - waitForIdle(); - - final CountDownLatch broadcastInvoked = new CountDownLatch(1); - BroadcastReceiver testReceiver = new BroadcastReceiver() { - @Override - public void onReceive(Context context, Intent intent) { - broadcastInvoked.countDown(); - } - }; - testContext.registerReceiver(testReceiver, new IntentFilter(testAction), - Context.RECEIVER_EXPORTED); - - try { - onView(withText(customActionLabel)).perform(click()); - assertTrue("Timeout waiting for broadcast", - broadcastInvoked.await(5000, TimeUnit.MILLISECONDS)); - } finally { - testContext.unregisterReceiver(testReceiver); - } - } - - @Test - public void testLaunchWithShareModification() throws InterruptedException { - List resolvedComponentInfos = createResolvedComponentsForTest(2); - setupResolverControllers(resolvedComponentInfos); - - Context testContext = InstrumentationRegistry.getInstrumentation().getContext(); - final String modifyShareAction = "test-broadcast-receiver-action"; - Intent chooserIntent = Intent.createChooser(createSendTextIntent(), null); - String label = "modify share"; - PendingIntent pendingIntent = PendingIntent.getBroadcast( - testContext, - 123, - new Intent(modifyShareAction), - PendingIntent.FLAG_IMMUTABLE | PendingIntent.FLAG_ONE_SHOT); - ChooserAction action = new ChooserAction.Builder(Icon.createWithBitmap( - createBitmap()), label, pendingIntent).build(); - chooserIntent.putExtra( - Intent.EXTRA_CHOOSER_MODIFY_SHARE_ACTION, - action); - // Start activity - mActivityRule.launchActivity(chooserIntent); - waitForIdle(); - - final CountDownLatch broadcastInvoked = new CountDownLatch(1); - BroadcastReceiver testReceiver = new BroadcastReceiver() { - @Override - public void onReceive(Context context, Intent intent) { - broadcastInvoked.countDown(); - } - }; - testContext.registerReceiver(testReceiver, new IntentFilter(modifyShareAction), - Context.RECEIVER_EXPORTED); - - try { - onView(withText(label)).perform(click()); - assertTrue("Timeout waiting for broadcast", - broadcastInvoked.await(5000, TimeUnit.MILLISECONDS)); - - } finally { - testContext.unregisterReceiver(testReceiver); - } - } - - @Test - public void testUpdateMaxTargetsPerRow_columnCountIsUpdated() throws InterruptedException { - updateMaxTargetsPerRowResource(/* targetsPerRow= */ 4); - givenAppTargets(/* appCount= */ 16); - Intent sendIntent = createSendTextIntent(); - final ChooserActivity activity = - mActivityRule.launchActivity(Intent.createChooser(sendIntent, null)); - - updateMaxTargetsPerRowResource(/* targetsPerRow= */ 6); - InstrumentationRegistry.getInstrumentation() - .runOnMainSync(() -> activity.onConfigurationChanged( - InstrumentationRegistry.getInstrumentation() - .getContext().getResources().getConfiguration())); - - waitForIdle(); - onView(withId(com.android.internal.R.id.resolver_list)) - .check(matches(withGridColumnCount(6))); - } - - // This test is too long and too slow and should not be taken as an example for future tests. - @Test @Ignore - public void testDirectTargetLoggingWithAppTargetNotRankedPortrait() - throws InterruptedException { - testDirectTargetLoggingWithAppTargetNotRanked(Configuration.ORIENTATION_PORTRAIT, 4); - } - - @Test @Ignore - public void testDirectTargetLoggingWithAppTargetNotRankedLandscape() - throws InterruptedException { - testDirectTargetLoggingWithAppTargetNotRanked(Configuration.ORIENTATION_LANDSCAPE, 8); - } - - private void testDirectTargetLoggingWithAppTargetNotRanked( - int orientation, int appTargetsExpected) { - Configuration configuration = - new Configuration(InstrumentationRegistry.getInstrumentation().getContext() - .getResources().getConfiguration()); - configuration.orientation = orientation; - - Resources resources = Mockito.spy( - InstrumentationRegistry.getInstrumentation().getContext().getResources()); - ChooserActivityOverrideData.getInstance().resources = resources; - doReturn(configuration).when(resources).getConfiguration(); - - Intent sendIntent = createSendTextIntent(); - // We need app targets for direct targets to get displayed - List resolvedComponentInfos = createResolvedComponentsForTest(15); - setupResolverControllers(resolvedComponentInfos); - - // Create direct share target - List serviceTargets = createDirectShareTargets(1, - resolvedComponentInfos.get(14).getResolveInfoAt(0).activityInfo.packageName); - ResolveInfo ri = ResolverDataProvider.createResolveInfo(16, 0, PERSONAL_USER_HANDLE); - - // Start activity - ChooserWrapperActivity activity = - mActivityRule.launchActivity(Intent.createChooser(sendIntent, null)); - // Insert the direct share target - Map directShareToShortcutInfos = new HashMap<>(); - directShareToShortcutInfos.put(serviceTargets.get(0), null); - InstrumentationRegistry.getInstrumentation().runOnMainSync( - () -> activity.getAdapter().addServiceResults( - activity.createTestDisplayResolveInfo(sendIntent, - ri, - "testLabel", - "testInfo", - sendIntent), - serviceTargets, - TARGET_TYPE_CHOOSER_TARGET, - directShareToShortcutInfos, - /* directShareToAppTargets */ null) - ); - - assertThat( - String.format("Chooser should have %d targets (%d apps, 1 direct, 15 A-Z)", - appTargetsExpected + 16, appTargetsExpected), - activity.getAdapter().getCount(), is(appTargetsExpected + 16)); - assertThat("Chooser should have exactly one selectable direct target", - activity.getAdapter().getSelectableServiceTargetCount(), is(1)); - assertThat("The resolver info must match the resolver info used to create the target", - activity.getAdapter().getItem(0).getResolveInfo(), is(ri)); - - // Click on the direct target - String name = serviceTargets.get(0).getTitle().toString(); - onView(withText(name)) - .perform(click()); - waitForIdle(); - - FakeEventLog eventLog = getEventLog(activity); - var invocations = eventLog.getShareTargetSelected(); - assertWithMessage("Only one ShareTargetSelected event logged") - .that(invocations).hasSize(1); - FakeEventLog.ShareTargetSelected call = invocations.get(0); - assertWithMessage("targetType should be SELECTION_TYPE_SERVICE") - .that(call.getTargetType()).isEqualTo(EventLog.SELECTION_TYPE_SERVICE); - assertWithMessage( - "The packages shouldn't match for app target and direct target") - .that(call.getDirectTargetAlsoRanked()).isEqualTo(-1); - } - - @Test - public void testWorkTab_displayedWhenWorkProfileUserAvailable() { - Intent sendIntent = createSendTextIntent(); - sendIntent.setType(TEST_MIME_TYPE); - markOtherProfileAvailability(/* workAvailable= */ true, /* cloneAvailable= */ false); - - mActivityRule.launchActivity(Intent.createChooser(sendIntent, "work tab test")); - waitForIdle(); - - onView(withId(android.R.id.tabs)).check(matches(isDisplayed())); - } - - @Test - public void testWorkTab_hiddenWhenWorkProfileUserNotAvailable() { - Intent sendIntent = createSendTextIntent(); - sendIntent.setType(TEST_MIME_TYPE); - - mActivityRule.launchActivity(Intent.createChooser(sendIntent, "work tab test")); - waitForIdle(); - - onView(withId(android.R.id.tabs)).check(matches(not(isDisplayed()))); - } - - @Test - public void testWorkTab_eachTabUsesExpectedAdapter() { - int personalProfileTargets = 3; - int otherProfileTargets = 1; - List personalResolvedComponentInfos = - createResolvedComponentsForTestWithOtherProfile( - personalProfileTargets + otherProfileTargets, /* userID */ 10); - int workProfileTargets = 4; - List workResolvedComponentInfos = createResolvedComponentsForTest( - workProfileTargets); - setupResolverControllers(personalResolvedComponentInfos, workResolvedComponentInfos); - Intent sendIntent = createSendTextIntent(); - sendIntent.setType(TEST_MIME_TYPE); - markOtherProfileAvailability(/* workAvailable= */ true, /* cloneAvailable= */ false); - - final IChooserWrapper activity = (IChooserWrapper) - mActivityRule.launchActivity(Intent.createChooser(sendIntent, "work tab test")); - waitForIdle(); - - assertThat(activity.getCurrentUserHandle().getIdentifier(), is(0)); - onView(withText(R.string.resolver_work_tab)).perform(click()); - assertThat(activity.getCurrentUserHandle().getIdentifier(), is(10)); - assertThat(activity.getPersonalListAdapter().getCount(), is(personalProfileTargets)); - assertThat(activity.getWorkListAdapter().getCount(), is(workProfileTargets)); - } - - @Test - public void testWorkTab_workProfileHasExpectedNumberOfTargets() throws InterruptedException { - markOtherProfileAvailability(/* workAvailable= */ true, /* cloneAvailable= */ false); - int workProfileTargets = 4; - List personalResolvedComponentInfos = - createResolvedComponentsForTestWithOtherProfile(3, /* userId */ 10); - List workResolvedComponentInfos = - createResolvedComponentsForTest(workProfileTargets); - setupResolverControllers(personalResolvedComponentInfos, workResolvedComponentInfos); - Intent sendIntent = createSendTextIntent(); - sendIntent.setType(TEST_MIME_TYPE); - - final IChooserWrapper activity = (IChooserWrapper) - mActivityRule.launchActivity(Intent.createChooser(sendIntent, "work tab test")); - waitForIdle(); - onView(withText(R.string.resolver_work_tab)).perform(click()); - waitForIdle(); - - assertThat(activity.getWorkListAdapter().getCount(), is(workProfileTargets)); - } - - @Test @Ignore - public void testWorkTab_selectingWorkTabAppOpensAppInWorkProfile() { - markOtherProfileAvailability(/* workAvailable= */ true, /* cloneAvailable= */ false); - List personalResolvedComponentInfos = - createResolvedComponentsForTestWithOtherProfile(3, /* userId */ 10); - int workProfileTargets = 4; - List workResolvedComponentInfos = - createResolvedComponentsForTest(workProfileTargets); - setupResolverControllers(personalResolvedComponentInfos, workResolvedComponentInfos); - Intent sendIntent = createSendTextIntent(); - sendIntent.setType(TEST_MIME_TYPE); - ResolveInfo[] chosen = new ResolveInfo[1]; - ChooserActivityOverrideData.getInstance().onSafelyStartInternalCallback = targetInfo -> { - chosen[0] = targetInfo.getResolveInfo(); - return true; - }; - - mActivityRule.launchActivity(Intent.createChooser(sendIntent, "work tab test")); - waitForIdle(); - onView(withText(R.string.resolver_work_tab)).perform(click()); - waitForIdle(); - - onView(first(allOf( - withText(workResolvedComponentInfos.get(0) - .getResolveInfoAt(0).activityInfo.applicationInfo.name), - isDisplayed()))) - .perform(click()); - waitForIdle(); - assertThat(chosen[0], is(workResolvedComponentInfos.get(0).getResolveInfoAt(0))); - } - - @Test - public void testWorkTab_crossProfileIntentsDisabled_personalToWork_emptyStateShown() { - markOtherProfileAvailability(/* workAvailable= */ true, /* cloneAvailable= */ false); - int workProfileTargets = 4; - List personalResolvedComponentInfos = - createResolvedComponentsForTestWithOtherProfile(3, /* userId */ 10); - List workResolvedComponentInfos = - createResolvedComponentsForTest(workProfileTargets); - ChooserActivityOverrideData.getInstance().hasCrossProfileIntents = false; - setupResolverControllers(personalResolvedComponentInfos, workResolvedComponentInfos); - Intent sendIntent = createSendTextIntent(); - sendIntent.setType(TEST_MIME_TYPE); - - mActivityRule.launchActivity(Intent.createChooser(sendIntent, "work tab test")); - waitForIdle(); - onView(withText(R.string.resolver_work_tab)).perform(click()); - waitForIdle(); - onView(withId(com.android.internal.R.id.contentPanel)) - .perform(swipeUp()); - - onView(withText(R.string.resolver_cross_profile_blocked)) - .check(matches(isDisplayed())); - } - - @Test - public void testWorkTab_workProfileDisabled_emptyStateShown() { - markOtherProfileAvailability(/* workAvailable= */ true, /* cloneAvailable= */ false); - int workProfileTargets = 4; - List personalResolvedComponentInfos = - createResolvedComponentsForTestWithOtherProfile(3, /* userId */ 10); - List workResolvedComponentInfos = - createResolvedComponentsForTest(workProfileTargets); - ChooserActivityOverrideData.getInstance().isQuietModeEnabled = true; - setupResolverControllers(personalResolvedComponentInfos, workResolvedComponentInfos); - Intent sendIntent = createSendTextIntent(); - sendIntent.setType(TEST_MIME_TYPE); - - mActivityRule.launchActivity(Intent.createChooser(sendIntent, "work tab test")); - waitForIdle(); - onView(withId(com.android.internal.R.id.contentPanel)) - .perform(swipeUp()); - onView(withText(R.string.resolver_work_tab)).perform(click()); - waitForIdle(); - - onView(withText(R.string.resolver_turn_on_work_apps)) - .check(matches(isDisplayed())); - } - - @Test - public void testWorkTab_noWorkAppsAvailable_emptyStateShown() { - markOtherProfileAvailability(/* workAvailable= */ true, /* cloneAvailable= */ false); - List personalResolvedComponentInfos = - createResolvedComponentsForTest(3); - List workResolvedComponentInfos = - createResolvedComponentsForTest(0); - setupResolverControllers(personalResolvedComponentInfos, workResolvedComponentInfos); - Intent sendIntent = createSendTextIntent(); - sendIntent.setType(TEST_MIME_TYPE); - - mActivityRule.launchActivity(Intent.createChooser(sendIntent, "work tab test")); - waitForIdle(); - onView(withId(com.android.internal.R.id.contentPanel)) - .perform(swipeUp()); - onView(withText(R.string.resolver_work_tab)).perform(click()); - waitForIdle(); - - onView(withText(R.string.resolver_no_work_apps_available)) - .check(matches(isDisplayed())); - } - - @Test - @RequiresFlagsEnabled(Flags.FLAG_SCROLLABLE_PREVIEW) - public void testWorkTab_previewIsScrollable() { - markOtherProfileAvailability(/* workAvailable= */ true, /* cloneAvailable= */ false); - List personalResolvedComponentInfos = - createResolvedComponentsForTest(300); - List workResolvedComponentInfos = - createResolvedComponentsForTest(3); - setupResolverControllers(personalResolvedComponentInfos, workResolvedComponentInfos); - - Uri uri = createTestContentProviderUri("image/png", null); - - ArrayList uris = new ArrayList<>(); - uris.add(uri); - - Intent sendIntent = createSendUriIntentWithPreview(uris); - ChooserActivityOverrideData.getInstance().imageLoader = - createImageLoader(uri, createWideBitmap()); - - mActivityRule.launchActivity(Intent.createChooser(sendIntent, "Scrollable preview test")); - waitForIdle(); - - onView(withId(com.android.intentresolver.R.id.scrollable_image_preview)) - .check(matches(isDisplayed())); - - onView(withId(com.android.internal.R.id.contentPanel)).perform(swipeUp()); - waitForIdle(); - - onView(withId(com.android.intentresolver.R.id.chooser_headline_row_container)) - .check(matches(isCompletelyDisplayed())); - onView(withId(com.android.intentresolver.R.id.headline)) - .check(matches(isDisplayed())); - onView(withId(com.android.intentresolver.R.id.scrollable_image_preview)) - .check(matches(not(isDisplayed()))); - } - - @Ignore // b/220067877 - @Test - public void testWorkTab_xProfileOff_noAppsAvailable_workOff_xProfileOffEmptyStateShown() { - markOtherProfileAvailability(/* workAvailable= */ true, /* cloneAvailable= */ false); - List personalResolvedComponentInfos = - createResolvedComponentsForTest(3); - List workResolvedComponentInfos = - createResolvedComponentsForTest(0); - setupResolverControllers(personalResolvedComponentInfos, workResolvedComponentInfos); - ChooserActivityOverrideData.getInstance().isQuietModeEnabled = true; - ChooserActivityOverrideData.getInstance().hasCrossProfileIntents = false; - Intent sendIntent = createSendTextIntent(); - sendIntent.setType(TEST_MIME_TYPE); - - mActivityRule.launchActivity(Intent.createChooser(sendIntent, "work tab test")); - waitForIdle(); - onView(withId(com.android.internal.R.id.contentPanel)) - .perform(swipeUp()); - onView(withText(R.string.resolver_work_tab)).perform(click()); - waitForIdle(); - - onView(withText(R.string.resolver_cross_profile_blocked)) - .check(matches(isDisplayed())); - } - - @Test - public void testWorkTab_noAppsAvailable_workOff_noAppsAvailableEmptyStateShown() { - markOtherProfileAvailability(/* workAvailable= */ true, /* cloneAvailable= */ false); - List personalResolvedComponentInfos = - createResolvedComponentsForTest(3); - List workResolvedComponentInfos = - createResolvedComponentsForTest(0); - setupResolverControllers(personalResolvedComponentInfos, workResolvedComponentInfos); - ChooserActivityOverrideData.getInstance().isQuietModeEnabled = true; - Intent sendIntent = createSendTextIntent(); - sendIntent.setType(TEST_MIME_TYPE); - - mActivityRule.launchActivity(Intent.createChooser(sendIntent, "work tab test")); - waitForIdle(); - onView(withId(com.android.internal.R.id.contentPanel)) - .perform(swipeUp()); - onView(withText(R.string.resolver_work_tab)).perform(click()); - waitForIdle(); - - onView(withText(R.string.resolver_no_work_apps_available)) - .check(matches(isDisplayed())); - } - - @Test @Ignore("b/222124533") - public void testAppTargetLogging() throws InterruptedException { - Intent sendIntent = createSendTextIntent(); - List resolvedComponentInfos = createResolvedComponentsForTest(2); - - setupResolverControllers(resolvedComponentInfos); - - final IChooserWrapper activity = (IChooserWrapper) - mActivityRule.launchActivity(Intent.createChooser(sendIntent, null)); - waitForIdle(); - - // TODO(b/222124533): other test cases use a timeout to make sure that the UI is fully - // populated; without one, this test flakes. Ideally we should address the need for a - // timeout everywhere instead of introducing one to fix this particular test. - - assertThat(activity.getAdapter().getCount(), is(2)); - onView(withId(com.android.internal.R.id.profile_button)).check(doesNotExist()); - - ResolveInfo[] chosen = new ResolveInfo[1]; - ChooserActivityOverrideData.getInstance().onSafelyStartInternalCallback = targetInfo -> { - chosen[0] = targetInfo.getResolveInfo(); - return true; - }; - - ResolveInfo toChoose = resolvedComponentInfos.get(0).getResolveInfoAt(0); - onView(withText(toChoose.activityInfo.name)) - .perform(click()); - waitForIdle(); - - // TODO(b/211669337): Determine the expected SHARESHEET_DIRECT_LOAD_COMPLETE events. - } - - @Test - public void testDirectTargetLogging() { - Intent sendIntent = createSendTextIntent(); - // We need app targets for direct targets to get displayed - List resolvedComponentInfos = createResolvedComponentsForTest(2); - setupResolverControllers(resolvedComponentInfos); - - // create test shortcut loader factory, remember loaders and their callbacks - SparseArray>> shortcutLoaders = - new SparseArray<>(); - ChooserActivityOverrideData.getInstance().shortcutLoaderFactory = - (userHandle, callback) -> { - Pair> pair = - new Pair<>(mock(ShortcutLoader.class), callback); - shortcutLoaders.put(userHandle.getIdentifier(), pair); - return pair.first; - }; - - // Start activity - ChooserWrapperActivity activity = - mActivityRule.launchActivity(Intent.createChooser(sendIntent, null)); - waitForIdle(); - - // verify that ShortcutLoader was queried - ArgumentCaptor appTargets = - ArgumentCaptor.forClass(DisplayResolveInfo[].class); - verify(shortcutLoaders.get(0).first, times(1)) - .updateAppTargets(appTargets.capture()); - - // send shortcuts - assertThat( - "Wrong number of app targets", - appTargets.getValue().length, - is(resolvedComponentInfos.size())); - List serviceTargets = createDirectShareTargets(1, - resolvedComponentInfos.get(0).getResolveInfoAt(0).activityInfo.packageName); - ShortcutLoader.Result result = new ShortcutLoader.Result( - // TODO: test another value as well - false, - appTargets.getValue(), - new ShortcutLoader.ShortcutResultInfo[] { - new ShortcutLoader.ShortcutResultInfo( - appTargets.getValue()[0], - serviceTargets - ) - }, - new HashMap<>(), - new HashMap<>() - ); - activity.getMainExecutor().execute(() -> shortcutLoaders.get(0).second.accept(result)); - waitForIdle(); - - assertThat("Chooser should have 3 targets (2 apps, 1 direct)", - activity.getAdapter().getCount(), is(3)); - assertThat("Chooser should have exactly one selectable direct target", - activity.getAdapter().getSelectableServiceTargetCount(), is(1)); - assertThat( - "The resolver info must match the resolver info used to create the target", - activity.getAdapter().getItem(0).getResolveInfo(), - is(resolvedComponentInfos.get(0).getResolveInfoAt(0))); - - // Click on the direct target - String name = serviceTargets.get(0).getTitle().toString(); - onView(withText(name)) - .perform(click()); - waitForIdle(); - - FakeEventLog eventLog = getEventLog(activity); - assertThat(eventLog.getShareTargetSelected()).hasSize(1); - FakeEventLog.ShareTargetSelected call = eventLog.getShareTargetSelected().get(0); - assertThat(call.getTargetType()).isEqualTo(EventLog.SELECTION_TYPE_SERVICE); - } - - @Test - public void testDirectTargetPinningDialog() { - Intent sendIntent = createSendTextIntent(); - // We need app targets for direct targets to get displayed - List resolvedComponentInfos = createResolvedComponentsForTest(2); - setupResolverControllers(resolvedComponentInfos); - - // create test shortcut loader factory, remember loaders and their callbacks - SparseArray>> shortcutLoaders = - new SparseArray<>(); - ChooserActivityOverrideData.getInstance().shortcutLoaderFactory = - (userHandle, callback) -> { - Pair> pair = - new Pair<>(mock(ShortcutLoader.class), callback); - shortcutLoaders.put(userHandle.getIdentifier(), pair); - return pair.first; - }; - - // Start activity - final IChooserWrapper activity = (IChooserWrapper) - mActivityRule.launchActivity(Intent.createChooser(sendIntent, null)); - waitForIdle(); - - // verify that ShortcutLoader was queried - ArgumentCaptor appTargets = - ArgumentCaptor.forClass(DisplayResolveInfo[].class); - verify(shortcutLoaders.get(0).first, times(1)) - .updateAppTargets(appTargets.capture()); - - // send shortcuts - List serviceTargets = createDirectShareTargets( - 1, - resolvedComponentInfos.get(0).getResolveInfoAt(0).activityInfo.packageName); - ShortcutLoader.Result result = new ShortcutLoader.Result( - // TODO: test another value as well - false, - appTargets.getValue(), - new ShortcutLoader.ShortcutResultInfo[] { - new ShortcutLoader.ShortcutResultInfo( - appTargets.getValue()[0], - serviceTargets - ) - }, - new HashMap<>(), - new HashMap<>() - ); - activity.getMainExecutor().execute(() -> shortcutLoaders.get(0).second.accept(result)); - waitForIdle(); - - // Long-click on the direct target - String name = serviceTargets.get(0).getTitle().toString(); - onView(withText(name)).perform(longClick()); - waitForIdle(); - - onView(withId(R.id.chooser_dialog_content)).check(matches(isDisplayed())); - } - - @Test @Ignore - public void testEmptyDirectRowLogging() throws InterruptedException { - Intent sendIntent = createSendTextIntent(); - // We need app targets for direct targets to get displayed - List resolvedComponentInfos = createResolvedComponentsForTest(2); - setupResolverControllers(resolvedComponentInfos); - - // Start activity - final IChooserWrapper activity = (IChooserWrapper) - mActivityRule.launchActivity(Intent.createChooser(sendIntent, null)); - - // Thread.sleep shouldn't be a thing in an integration test but it's - // necessary here because of the way the code is structured - Thread.sleep(3000); - - assertThat("Chooser should have 2 app targets", - activity.getAdapter().getCount(), is(2)); - assertThat("Chooser should have no direct targets", - activity.getAdapter().getSelectableServiceTargetCount(), is(0)); - - // TODO(b/211669337): Determine the expected SHARESHEET_DIRECT_LOAD_COMPLETE events. - } - - @Ignore // b/220067877 - @Test - public void testCopyTextToClipboardLogging() throws Exception { - Intent sendIntent = createSendTextIntent(); - List resolvedComponentInfos = createResolvedComponentsForTest(2); - - setupResolverControllers(resolvedComponentInfos); - - final IChooserWrapper activity = (IChooserWrapper) - mActivityRule.launchActivity(Intent.createChooser(sendIntent, null)); - waitForIdle(); - - onView(withId(com.android.internal.R.id.chooser_copy_button)).check(matches(isDisplayed())); - onView(withId(com.android.internal.R.id.chooser_copy_button)).perform(click()); - - // TODO(b/211669337): Determine the expected SHARESHEET_DIRECT_LOAD_COMPLETE events. - } - - @Test @Ignore("b/222124533") - public void testSwitchProfileLogging() throws InterruptedException { - markOtherProfileAvailability(/* workAvailable= */ true, /* cloneAvailable= */ false); - int workProfileTargets = 4; - List personalResolvedComponentInfos = - createResolvedComponentsForTestWithOtherProfile(3, /* userId */ 10); - List workResolvedComponentInfos = - createResolvedComponentsForTest(workProfileTargets); - setupResolverControllers(personalResolvedComponentInfos, workResolvedComponentInfos); - Intent sendIntent = createSendTextIntent(); - sendIntent.setType(TEST_MIME_TYPE); - - final IChooserWrapper activity = (IChooserWrapper) - mActivityRule.launchActivity(Intent.createChooser(sendIntent, "work tab test")); - waitForIdle(); - onView(withText(R.string.resolver_work_tab)).perform(click()); - waitForIdle(); - onView(withText(R.string.resolver_personal_tab)).perform(click()); - waitForIdle(); - - // TODO(b/211669337): Determine the expected SHARESHEET_DIRECT_LOAD_COMPLETE events. - } - - @Test - public void testWorkTab_onePersonalTarget_emptyStateOnWorkTarget_doesNotAutoLaunch() { - markOtherProfileAvailability(/* workAvailable= */ true, /* cloneAvailable= */ false); - int workProfileTargets = 4; - List personalResolvedComponentInfos = - createResolvedComponentsForTestWithOtherProfile(2, /* userId */ 10); - List workResolvedComponentInfos = - createResolvedComponentsForTest(workProfileTargets); - ChooserActivityOverrideData.getInstance().hasCrossProfileIntents = false; - setupResolverControllers(personalResolvedComponentInfos, workResolvedComponentInfos); - Intent sendIntent = createSendTextIntent(); - ResolveInfo[] chosen = new ResolveInfo[1]; - ChooserActivityOverrideData.getInstance().onSafelyStartInternalCallback = targetInfo -> { - chosen[0] = targetInfo.getResolveInfo(); - return true; - }; - - mActivityRule.launchActivity(Intent.createChooser(sendIntent, "Test")); - waitForIdle(); - - assertNull(chosen[0]); - } - - @Test - public void testOneInitialIntent_noAutolaunch() { - List personalResolvedComponentInfos = - createResolvedComponentsForTest(1); - setupResolverControllers(personalResolvedComponentInfos); - Intent chooserIntent = createChooserIntent(createSendTextIntent(), - new Intent[] {new Intent("action.fake")}); - ResolveInfo[] chosen = new ResolveInfo[1]; - ChooserActivityOverrideData.getInstance().onSafelyStartInternalCallback = targetInfo -> { - chosen[0] = targetInfo.getResolveInfo(); - return true; - }; - ChooserActivityOverrideData.getInstance().packageManager = mock(PackageManager.class); - ResolveInfo ri = createFakeResolveInfo(); - when( - ChooserActivityOverrideData - .getInstance().packageManager - .resolveActivity(any(Intent.class), any())) - .thenReturn(ri); - waitForIdle(); - - IChooserWrapper activity = (IChooserWrapper) mActivityRule.launchActivity(chooserIntent); - waitForIdle(); - - assertNull(chosen[0]); - assertThat(activity - .getPersonalListAdapter().getCallerTargetCount(), is(1)); - } - - @Test - public void testWorkTab_withInitialIntents_workTabDoesNotIncludePersonalInitialIntents() { - markOtherProfileAvailability(/* workAvailable= */ true, /* cloneAvailable= */ false); - int workProfileTargets = 1; - List personalResolvedComponentInfos = - createResolvedComponentsForTestWithOtherProfile(2, /* userId */ 10); - List workResolvedComponentInfos = - createResolvedComponentsForTest(workProfileTargets); - setupResolverControllers(personalResolvedComponentInfos, workResolvedComponentInfos); - Intent[] initialIntents = { - new Intent("action.fake1"), - new Intent("action.fake2") - }; - Intent chooserIntent = createChooserIntent(createSendTextIntent(), initialIntents); - ChooserActivityOverrideData.getInstance().packageManager = mock(PackageManager.class); - when( - ChooserActivityOverrideData - .getInstance() - .packageManager - .resolveActivity(any(Intent.class), any())) - .thenReturn(createFakeResolveInfo()); - waitForIdle(); - - IChooserWrapper activity = (IChooserWrapper) mActivityRule.launchActivity(chooserIntent); - waitForIdle(); - - assertThat(activity.getPersonalListAdapter().getCallerTargetCount(), is(2)); - assertThat(activity.getWorkListAdapter().getCallerTargetCount(), is(0)); - } - - @Test - public void testWorkTab_xProfileIntentsDisabled_personalToWork_nonSendIntent_emptyStateShown() { - markOtherProfileAvailability(/* workAvailable= */ true, /* cloneAvailable= */ false); - int workProfileTargets = 4; - List personalResolvedComponentInfos = - createResolvedComponentsForTestWithOtherProfile(3, /* userId */ 10); - List workResolvedComponentInfos = - createResolvedComponentsForTest(workProfileTargets); - ChooserActivityOverrideData.getInstance().hasCrossProfileIntents = false; - setupResolverControllers(personalResolvedComponentInfos, workResolvedComponentInfos); - Intent[] initialIntents = { - new Intent("action.fake1"), - new Intent("action.fake2") - }; - Intent chooserIntent = createChooserIntent(new Intent(), initialIntents); - ChooserActivityOverrideData.getInstance().packageManager = mock(PackageManager.class); - when( - ChooserActivityOverrideData - .getInstance() - .packageManager - .resolveActivity(any(Intent.class), any())) - .thenReturn(createFakeResolveInfo()); - - mActivityRule.launchActivity(chooserIntent); - waitForIdle(); - onView(withText(R.string.resolver_work_tab)).perform(click()); - waitForIdle(); - onView(withId(com.android.internal.R.id.contentPanel)) - .perform(swipeUp()); - - onView(withText(R.string.resolver_cross_profile_blocked)) - .check(matches(isDisplayed())); - } - - @Test - public void testWorkTab_noWorkAppsAvailable_nonSendIntent_emptyStateShown() { - markOtherProfileAvailability(/* workAvailable= */ true, /* cloneAvailable= */ false); - List personalResolvedComponentInfos = - createResolvedComponentsForTest(3); - List workResolvedComponentInfos = - createResolvedComponentsForTest(0); - setupResolverControllers(personalResolvedComponentInfos, workResolvedComponentInfos); - Intent[] initialIntents = { - new Intent("action.fake1"), - new Intent("action.fake2") - }; - Intent chooserIntent = createChooserIntent(new Intent(), initialIntents); - ChooserActivityOverrideData.getInstance().packageManager = mock(PackageManager.class); - when( - ChooserActivityOverrideData - .getInstance() - .packageManager - .resolveActivity(any(Intent.class), any())) - .thenReturn(createFakeResolveInfo()); - - mActivityRule.launchActivity(chooserIntent); - waitForIdle(); - onView(withId(com.android.internal.R.id.contentPanel)) - .perform(swipeUp()); - onView(withText(R.string.resolver_work_tab)).perform(click()); - waitForIdle(); - - onView(withText(R.string.resolver_no_work_apps_available)) - .check(matches(isDisplayed())); - } - - @Test - public void testDeduplicateCallerTargetRankedTarget() { - // Create 4 ranked app targets. - List personalResolvedComponentInfos = - createResolvedComponentsForTest(4); - setupResolverControllers(personalResolvedComponentInfos); - // Create caller target which is duplicate with one of app targets - Intent chooserIntent = createChooserIntent(createSendTextIntent(), - new Intent[] {new Intent("action.fake")}); - ChooserActivityOverrideData.getInstance().packageManager = mock(PackageManager.class); - ResolveInfo ri = ResolverDataProvider.createResolveInfo(0, - UserHandle.USER_CURRENT, PERSONAL_USER_HANDLE); - when( - ChooserActivityOverrideData - .getInstance() - .packageManager - .resolveActivity(any(Intent.class), any())) - .thenReturn(ri); - waitForIdle(); - - IChooserWrapper activity = (IChooserWrapper) mActivityRule.launchActivity(chooserIntent); - waitForIdle(); - - // Total 4 targets (1 caller target, 3 ranked targets) - assertThat(activity.getAdapter().getCount(), is(4)); - assertThat(activity.getAdapter().getCallerTargetCount(), is(1)); - assertThat(activity.getAdapter().getRankedTargetCount(), is(3)); - } - - @Test - public void test_query_shortcut_loader_for_the_selected_tab() { - markOtherProfileAvailability(/* workAvailable= */ true, /* cloneAvailable= */ false); - List personalResolvedComponentInfos = - createResolvedComponentsForTestWithOtherProfile(3, /* userId */ 10); - List workResolvedComponentInfos = - createResolvedComponentsForTest(3); - setupResolverControllers(personalResolvedComponentInfos, workResolvedComponentInfos); - ShortcutLoader personalProfileShortcutLoader = mock(ShortcutLoader.class); - ShortcutLoader workProfileShortcutLoader = mock(ShortcutLoader.class); - final SparseArray shortcutLoaders = new SparseArray<>(); - shortcutLoaders.put(0, personalProfileShortcutLoader); - shortcutLoaders.put(10, workProfileShortcutLoader); - ChooserActivityOverrideData.getInstance().shortcutLoaderFactory = - (userHandle, callback) -> shortcutLoaders.get(userHandle.getIdentifier(), null); - Intent sendIntent = createSendTextIntent(); - sendIntent.setType(TEST_MIME_TYPE); - - mActivityRule.launchActivity(Intent.createChooser(sendIntent, "work tab test")); - waitForIdle(); - onView(withId(com.android.internal.R.id.contentPanel)) - .perform(swipeUp()); - waitForIdle(); - - verify(personalProfileShortcutLoader, times(1)).updateAppTargets(any()); - - onView(withText(R.string.resolver_work_tab)).perform(click()); - waitForIdle(); - - verify(workProfileShortcutLoader, times(1)).updateAppTargets(any()); - } - - @Test - public void testClonedProfilePresent_personalAdapterIsSetWithPersonalProfile() { - // enable cloneProfile - markOtherProfileAvailability(/* workAvailable= */ false, /* cloneAvailable= */ true); - List resolvedComponentInfos = - createResolvedComponentsWithCloneProfileForTest( - 3, - PERSONAL_USER_HANDLE, - CLONE_PROFILE_USER_HANDLE); - setupResolverControllers(resolvedComponentInfos); - Intent sendIntent = createSendTextIntent(); - - final IChooserWrapper activity = (IChooserWrapper) mActivityRule - .launchActivity(Intent.createChooser(sendIntent, "personalProfileTest")); - waitForIdle(); - - assertThat(activity.getPersonalListAdapter().getUserHandle(), is(PERSONAL_USER_HANDLE)); - assertThat(activity.getAdapter().getCount(), is(3)); - } - - @Test - public void testClonedProfilePresent_personalTabUsesExpectedAdapter() { - markOtherProfileAvailability(/* workAvailable= */ true, /* cloneAvailable= */ true); - List personalResolvedComponentInfos = - createResolvedComponentsForTest(3); - List workResolvedComponentInfos = createResolvedComponentsForTest( - 4); - setupResolverControllers(personalResolvedComponentInfos, workResolvedComponentInfos); - Intent sendIntent = createSendTextIntent(); - sendIntent.setType(TEST_MIME_TYPE); - - - final IChooserWrapper activity = (IChooserWrapper) - mActivityRule.launchActivity(Intent.createChooser(sendIntent, "multi tab test")); - waitForIdle(); - - assertThat(activity.getCurrentUserHandle(), is(PERSONAL_USER_HANDLE)); - } - - private Intent createChooserIntent(Intent intent, Intent[] initialIntents) { - Intent chooserIntent = new Intent(); - chooserIntent.setAction(Intent.ACTION_CHOOSER); - chooserIntent.putExtra(Intent.EXTRA_TEXT, "testing intent sending"); - chooserIntent.putExtra(Intent.EXTRA_TITLE, "some title"); - chooserIntent.putExtra(Intent.EXTRA_INTENT, intent); - chooserIntent.setType("text/plain"); - if (initialIntents != null) { - chooserIntent.putExtra(Intent.EXTRA_INITIAL_INTENTS, initialIntents); - } - return chooserIntent; - } - - /* This is a "test of a test" to make sure that our inherited test class - * is successfully configured to operate on the unbundled-equivalent - * ChooserWrapperActivity. - * - * TODO: remove after unbundling is complete. - */ - @Test - public void testWrapperActivityHasExpectedConcreteType() { - final ChooserActivity activity = mActivityRule.launchActivity( - Intent.createChooser(new Intent("ACTION_FOO"), "foo")); - waitForIdle(); - assertThat(activity).isInstanceOf(ChooserWrapperActivity.class); - } - - private ResolveInfo createFakeResolveInfo() { - ResolveInfo ri = new ResolveInfo(); - ri.activityInfo = new ActivityInfo(); - ri.activityInfo.name = "FakeActivityName"; - ri.activityInfo.packageName = "fake.package.name"; - ri.activityInfo.applicationInfo = new ApplicationInfo(); - ri.activityInfo.applicationInfo.packageName = "fake.package.name"; - ri.userHandle = UserHandle.CURRENT; - return ri; - } - - private Intent createSendTextIntent() { - Intent sendIntent = new Intent(); - sendIntent.setAction(Intent.ACTION_SEND); - sendIntent.putExtra(Intent.EXTRA_TEXT, "testing intent sending"); - sendIntent.setType("text/plain"); - return sendIntent; - } - - private Intent createSendImageIntent(Uri imageThumbnail) { - Intent sendIntent = new Intent(); - sendIntent.setAction(Intent.ACTION_SEND); - sendIntent.putExtra(Intent.EXTRA_STREAM, imageThumbnail); - sendIntent.setType("image/png"); - if (imageThumbnail != null) { - ClipData.Item clipItem = new ClipData.Item(imageThumbnail); - sendIntent.setClipData(new ClipData("Clip Label", new String[]{"image/png"}, clipItem)); - } - - return sendIntent; - } - - private Uri createTestContentProviderUri( - @Nullable String mimeType, @Nullable String streamType) { - return createTestContentProviderUri(mimeType, streamType, 0); - } - - private Uri createTestContentProviderUri( - @Nullable String mimeType, @Nullable String streamType, long streamTypeTimeout) { - String packageName = - InstrumentationRegistry.getInstrumentation().getContext().getPackageName(); - Uri.Builder builder = Uri.parse("content://" + packageName + "/image.png") - .buildUpon(); - if (mimeType != null) { - builder.appendQueryParameter(TestContentProvider.PARAM_MIME_TYPE, mimeType); - } - if (streamType != null) { - builder.appendQueryParameter(TestContentProvider.PARAM_STREAM_TYPE, streamType); - } - if (streamTypeTimeout > 0) { - builder.appendQueryParameter( - TestContentProvider.PARAM_STREAM_TYPE_TIMEOUT, - Long.toString(streamTypeTimeout)); - } - return builder.build(); - } - - private Intent createSendTextIntentWithPreview(String title, Uri imageThumbnail) { - Intent sendIntent = new Intent(); - sendIntent.setAction(Intent.ACTION_SEND); - sendIntent.putExtra(Intent.EXTRA_TEXT, "testing intent sending"); - sendIntent.putExtra(Intent.EXTRA_TITLE, title); - if (imageThumbnail != null) { - ClipData.Item clipItem = new ClipData.Item(imageThumbnail); - sendIntent.setClipData(new ClipData("Clip Label", new String[]{"image/png"}, clipItem)); - } - - return sendIntent; - } - - private Intent createSendUriIntentWithPreview(ArrayList uris) { - Intent sendIntent = new Intent(); - - if (uris.size() > 1) { - sendIntent.setAction(Intent.ACTION_SEND_MULTIPLE); - sendIntent.putExtra(Intent.EXTRA_STREAM, uris); - } else { - sendIntent.setAction(Intent.ACTION_SEND); - sendIntent.putExtra(Intent.EXTRA_STREAM, uris.get(0)); - } - - return sendIntent; - } - - private Intent createViewTextIntent() { - Intent viewIntent = new Intent(); - viewIntent.setAction(Intent.ACTION_VIEW); - viewIntent.putExtra(Intent.EXTRA_TEXT, "testing intent viewing"); - return viewIntent; - } - - private List createResolvedComponentsForTest(int numberOfResults) { - List infoList = new ArrayList<>(numberOfResults); - for (int i = 0; i < numberOfResults; i++) { - infoList.add(ResolverDataProvider.createResolvedComponentInfo(i, PERSONAL_USER_HANDLE)); - } - return infoList; - } - - private List createResolvedComponentsWithCloneProfileForTest( - int numberOfResults, - UserHandle resolvedForPersonalUser, - UserHandle resolvedForClonedUser) { - List infoList = new ArrayList<>(numberOfResults); - for (int i = 0; i < 1; i++) { - infoList.add(ResolverDataProvider.createResolvedComponentInfo(i, - resolvedForPersonalUser)); - } - for (int i = 1; i < numberOfResults; i++) { - infoList.add(ResolverDataProvider.createResolvedComponentInfo(i, - resolvedForClonedUser)); - } - return infoList; - } - - private List createResolvedComponentsForTestWithOtherProfile( - int numberOfResults) { - List infoList = new ArrayList<>(numberOfResults); - for (int i = 0; i < numberOfResults; i++) { - if (i == 0) { - infoList.add(ResolverDataProvider.createResolvedComponentInfoWithOtherId(i, - PERSONAL_USER_HANDLE)); - } else { - infoList.add(ResolverDataProvider.createResolvedComponentInfo(i, - PERSONAL_USER_HANDLE)); - } - } - return infoList; - } - - private List createResolvedComponentsForTestWithOtherProfile( - int numberOfResults, int userId) { - List infoList = new ArrayList<>(numberOfResults); - for (int i = 0; i < numberOfResults; i++) { - if (i == 0) { - infoList.add( - ResolverDataProvider.createResolvedComponentInfoWithOtherId(i, userId, - PERSONAL_USER_HANDLE)); - } else { - infoList.add(ResolverDataProvider.createResolvedComponentInfo(i, - PERSONAL_USER_HANDLE)); - } - } - return infoList; - } - - private List createResolvedComponentsForTestWithUserId( - int numberOfResults, int userId) { - List infoList = new ArrayList<>(numberOfResults); - for (int i = 0; i < numberOfResults; i++) { - infoList.add(ResolverDataProvider.createResolvedComponentInfoWithOtherId(i, userId, - PERSONAL_USER_HANDLE)); - } - return infoList; - } - - private List createDirectShareTargets(int numberOfResults, String packageName) { - Icon icon = Icon.createWithBitmap(createBitmap()); - String testTitle = "testTitle"; - List targets = new ArrayList<>(); - for (int i = 0; i < numberOfResults; i++) { - ComponentName componentName; - if (packageName.isEmpty()) { - componentName = ResolverDataProvider.createComponentName(i); - } else { - componentName = new ComponentName(packageName, packageName + ".class"); - } - ChooserTarget tempTarget = new ChooserTarget( - testTitle + i, - icon, - (float) (1 - ((i + 1) / 10.0)), - componentName, - null); - targets.add(tempTarget); - } - return targets; - } - - private void waitForIdle() { - InstrumentationRegistry.getInstrumentation().waitForIdleSync(); - } - - private Bitmap createBitmap() { - return createBitmap(200, 200); - } - - private Bitmap createWideBitmap() { - return createWideBitmap(Color.RED); - } - - private Bitmap createWideBitmap(int bgColor) { - WindowManager windowManager = InstrumentationRegistry.getInstrumentation() - .getTargetContext() - .getSystemService(WindowManager.class); - int width = 3000; - if (windowManager != null) { - Rect bounds = windowManager.getMaximumWindowMetrics().getBounds(); - width = bounds.width() + 200; - } - return createBitmap(width, 100, bgColor); - } - - private Bitmap createBitmap(int width, int height) { - return createBitmap(width, height, Color.RED); - } - - private Bitmap createBitmap(int width, int height, int bgColor) { - Bitmap bitmap = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888); - Canvas canvas = new Canvas(bitmap); - - Paint paint = new Paint(); - paint.setColor(bgColor); - paint.setStyle(Paint.Style.FILL); - canvas.drawPaint(paint); - - paint.setColor(Color.WHITE); - paint.setAntiAlias(true); - paint.setTextSize(14.f); - paint.setTextAlign(Paint.Align.CENTER); - canvas.drawText("Hi!", (width / 2.f), (height / 2.f), paint); - - return bitmap; - } - - private List createShortcuts(Context context) { - Intent testIntent = new Intent("TestIntent"); - - List shortcuts = new ArrayList<>(); - shortcuts.add(new ShareShortcutInfo( - new ShortcutInfo.Builder(context, "shortcut1") - .setIntent(testIntent).setShortLabel("label1").setRank(3).build(), // 0 2 - new ComponentName("package1", "class1"))); - shortcuts.add(new ShareShortcutInfo( - new ShortcutInfo.Builder(context, "shortcut2") - .setIntent(testIntent).setShortLabel("label2").setRank(7).build(), // 1 3 - new ComponentName("package2", "class2"))); - shortcuts.add(new ShareShortcutInfo( - new ShortcutInfo.Builder(context, "shortcut3") - .setIntent(testIntent).setShortLabel("label3").setRank(1).build(), // 2 0 - new ComponentName("package3", "class3"))); - shortcuts.add(new ShareShortcutInfo( - new ShortcutInfo.Builder(context, "shortcut4") - .setIntent(testIntent).setShortLabel("label4").setRank(3).build(), // 3 2 - new ComponentName("package4", "class4"))); - - return shortcuts; - } - - private void 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); - } - if (cloneAvailable) { - handles.setCloneProfileUserHandle(CLONE_PROFILE_USER_HANDLE); - } - ChooserWrapperActivity.sOverrides.annotatedUserHandles = handles.build(); - } - - private void setupResolverControllers( - List personalResolvedComponentInfos) { - setupResolverControllers(personalResolvedComponentInfos, new ArrayList<>()); - } - - private void setupResolverControllers( - List personalResolvedComponentInfos, - List workResolvedComponentInfos) { - when( - ChooserActivityOverrideData - .getInstance() - .resolverListController - .getResolversForIntentAsUser( - Mockito.anyBoolean(), - Mockito.anyBoolean(), - Mockito.anyBoolean(), - Mockito.isA(List.class), - eq(UserHandle.SYSTEM))) - .thenReturn(new ArrayList<>(personalResolvedComponentInfos)); - when( - ChooserActivityOverrideData - .getInstance() - .workResolverListController - .getResolversForIntentAsUser( - Mockito.anyBoolean(), - Mockito.anyBoolean(), - Mockito.anyBoolean(), - Mockito.isA(List.class), - eq(UserHandle.SYSTEM))) - .thenReturn(new ArrayList<>(personalResolvedComponentInfos)); - when( - ChooserActivityOverrideData - .getInstance() - .workResolverListController - .getResolversForIntentAsUser( - Mockito.anyBoolean(), - Mockito.anyBoolean(), - Mockito.anyBoolean(), - Mockito.isA(List.class), - eq(UserHandle.of(10)))) - .thenReturn(new ArrayList<>(workResolvedComponentInfos)); - } - - private static GridRecyclerSpanCountMatcher withGridColumnCount(int columnCount) { - return new GridRecyclerSpanCountMatcher(Matchers.is(columnCount)); - } - - private static class GridRecyclerSpanCountMatcher extends - BoundedDiagnosingMatcher { - - private final Matcher mIntegerMatcher; - - private GridRecyclerSpanCountMatcher(Matcher integerMatcher) { - super(RecyclerView.class); - this.mIntegerMatcher = integerMatcher; - } - - @Override - protected void describeMoreTo(Description description) { - description.appendText("RecyclerView grid layout span count to match: "); - this.mIntegerMatcher.describeTo(description); - } - - @Override - protected boolean matchesSafely(RecyclerView view, Description mismatchDescription) { - int spanCount = ((GridLayoutManager) view.getLayoutManager()).getSpanCount(); - if (this.mIntegerMatcher.matches(spanCount)) { - return true; - } else { - mismatchDescription.appendText("RecyclerView grid layout span count was ") - .appendValue(spanCount); - return false; - } - } - } - - private void givenAppTargets(int appCount) { - List resolvedComponentInfos = - createResolvedComponentsForTest(appCount); - setupResolverControllers(resolvedComponentInfos); - } - - private void updateMaxTargetsPerRowResource(int targetsPerRow) { - Resources resources = Mockito.spy( - InstrumentationRegistry.getInstrumentation().getContext().getResources()); - ChooserActivityOverrideData.getInstance().resources = resources; - doReturn(targetsPerRow).when(resources).getInteger( - R.integer.config_chooser_max_targets_per_row); - } - - private SparseArray>> - createShortcutLoaderFactory() { - SparseArray>> shortcutLoaders = - new SparseArray<>(); - ChooserActivityOverrideData.getInstance().shortcutLoaderFactory = - (userHandle, callback) -> { - Pair> pair = - new Pair<>(mock(ShortcutLoader.class), callback); - shortcutLoaders.put(userHandle.getIdentifier(), pair); - return pair.first; - }; - return shortcutLoaders; - } - - private static ImageLoader createImageLoader(Uri uri, Bitmap bitmap) { - return new 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 deleted file mode 100644 index 12def1de..00000000 --- a/tests/activity/src/com/android/intentresolver/UnbundledChooserActivityWorkProfileTest.java +++ /dev/null @@ -1,480 +0,0 @@ -/* - * Copyright (C) 2022 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.android.intentresolver; - -import static android.testing.PollingCheck.waitFor; - -import static androidx.test.espresso.Espresso.onView; -import static androidx.test.espresso.action.ViewActions.click; -import static androidx.test.espresso.action.ViewActions.swipeUp; -import static androidx.test.espresso.assertion.ViewAssertions.matches; -import static androidx.test.espresso.matcher.ViewMatchers.isDisplayed; -import static androidx.test.espresso.matcher.ViewMatchers.isSelected; -import static androidx.test.espresso.matcher.ViewMatchers.withId; -import static androidx.test.espresso.matcher.ViewMatchers.withText; - -import static com.android.intentresolver.ChooserWrapperActivity.sOverrides; -import static com.android.intentresolver.UnbundledChooserActivityWorkProfileTest.TestCase.ExpectedBlocker.NO_BLOCKER; -import static com.android.intentresolver.UnbundledChooserActivityWorkProfileTest.TestCase.ExpectedBlocker.PERSONAL_PROFILE_ACCESS_BLOCKER; -import static com.android.intentresolver.UnbundledChooserActivityWorkProfileTest.TestCase.ExpectedBlocker.PERSONAL_PROFILE_SHARE_BLOCKER; -import static com.android.intentresolver.UnbundledChooserActivityWorkProfileTest.TestCase.ExpectedBlocker.WORK_PROFILE_ACCESS_BLOCKER; -import static com.android.intentresolver.UnbundledChooserActivityWorkProfileTest.TestCase.ExpectedBlocker.WORK_PROFILE_SHARE_BLOCKER; -import static com.android.intentresolver.UnbundledChooserActivityWorkProfileTest.TestCase.Tab.PERSONAL; -import static com.android.intentresolver.UnbundledChooserActivityWorkProfileTest.TestCase.Tab.WORK; - -import static org.hamcrest.CoreMatchers.not; -import static org.mockito.ArgumentMatchers.eq; -import static org.mockito.Mockito.when; - -import android.companion.DeviceFilter; -import android.content.Intent; -import android.os.UserHandle; - -import androidx.test.platform.app.InstrumentationRegistry; -import androidx.test.espresso.NoMatchingViewException; -import androidx.test.rule.ActivityTestRule; - -import com.android.intentresolver.UnbundledChooserActivityWorkProfileTest.TestCase.Tab; - -import junit.framework.AssertionFailedError; - -import org.junit.Before; -import org.junit.Rule; -import org.junit.Test; -import org.junit.runner.RunWith; -import org.junit.runners.Parameterized; -import org.mockito.Mockito; - -import java.util.ArrayList; -import java.util.Arrays; -import java.util.Collection; -import java.util.List; - -import dagger.hilt.android.testing.HiltAndroidRule; -import dagger.hilt.android.testing.HiltAndroidTest; - -@DeviceFilter.MediumType -@RunWith(Parameterized.class) -@HiltAndroidTest -public class UnbundledChooserActivityWorkProfileTest { - - private static final UserHandle PERSONAL_USER_HANDLE = InstrumentationRegistry - .getInstrumentation().getTargetContext().getUser(); - private static final UserHandle WORK_USER_HANDLE = UserHandle.of(10); - - @Rule(order = 0) - public HiltAndroidRule mHiltAndroidRule = new HiltAndroidRule(this); - - @Rule(order = 1) - public ActivityTestRule mActivityRule = - new ActivityTestRule<>(ChooserWrapperActivity.class, false, - false); - private final TestCase mTestCase; - - public UnbundledChooserActivityWorkProfileTest(TestCase testCase) { - mTestCase = testCase; - } - - @Before - public void cleanOverrideData() { - // TODO: use the other form of `adoptShellPermissionIdentity()` where we explicitly list the - // permissions we require (which we'll read from the manifest at runtime). - InstrumentationRegistry - .getInstrumentation() - .getUiAutomation() - .adoptShellPermissionIdentity(); - - sOverrides.reset(); - } - - @Test - public void testBlocker() { - setUpPersonalAndWorkComponentInfos(); - sOverrides.hasCrossProfileIntents = mTestCase.hasCrossProfileIntents(); - - launchActivity(mTestCase.getIsSendAction()); - switchToTab(mTestCase.getTab()); - - switch (mTestCase.getExpectedBlocker()) { - case NO_BLOCKER: - assertNoBlockerDisplayed(); - break; - case PERSONAL_PROFILE_SHARE_BLOCKER: - assertCantSharePersonalAppsBlockerDisplayed(); - break; - case WORK_PROFILE_SHARE_BLOCKER: - assertCantShareWorkAppsBlockerDisplayed(); - break; - case PERSONAL_PROFILE_ACCESS_BLOCKER: - assertCantAccessPersonalAppsBlockerDisplayed(); - break; - case WORK_PROFILE_ACCESS_BLOCKER: - assertCantAccessWorkAppsBlockerDisplayed(); - break; - } - } - - @Parameterized.Parameters(name = "{0}") - public static Collection tests() { - return Arrays.asList( - new TestCase( - /* isSendAction= */ true, - /* hasCrossProfileIntents= */ true, - /* myUserHandle= */ WORK_USER_HANDLE, - /* tab= */ WORK, - /* expectedBlocker= */ NO_BLOCKER - ), - new TestCase( - /* isSendAction= */ true, - /* hasCrossProfileIntents= */ false, - /* myUserHandle= */ WORK_USER_HANDLE, - /* tab= */ WORK, - /* expectedBlocker= */ NO_BLOCKER - ), - new TestCase( - /* isSendAction= */ true, - /* hasCrossProfileIntents= */ true, - /* myUserHandle= */ PERSONAL_USER_HANDLE, - /* tab= */ WORK, - /* expectedBlocker= */ NO_BLOCKER - ), - new TestCase( - /* isSendAction= */ true, - /* hasCrossProfileIntents= */ false, - /* myUserHandle= */ PERSONAL_USER_HANDLE, - /* tab= */ WORK, - /* expectedBlocker= */ WORK_PROFILE_SHARE_BLOCKER - ), - new TestCase( - /* isSendAction= */ true, - /* hasCrossProfileIntents= */ true, - /* myUserHandle= */ WORK_USER_HANDLE, - /* tab= */ PERSONAL, - /* expectedBlocker= */ NO_BLOCKER - ), - new TestCase( - /* isSendAction= */ true, - /* hasCrossProfileIntents= */ false, - /* myUserHandle= */ WORK_USER_HANDLE, - /* tab= */ PERSONAL, - /* expectedBlocker= */ PERSONAL_PROFILE_SHARE_BLOCKER - ), - new TestCase( - /* isSendAction= */ true, - /* hasCrossProfileIntents= */ true, - /* myUserHandle= */ PERSONAL_USER_HANDLE, - /* tab= */ PERSONAL, - /* expectedBlocker= */ NO_BLOCKER - ), - new TestCase( - /* isSendAction= */ true, - /* hasCrossProfileIntents= */ false, - /* myUserHandle= */ PERSONAL_USER_HANDLE, - /* tab= */ PERSONAL, - /* expectedBlocker= */ NO_BLOCKER - ), - new TestCase( - /* isSendAction= */ false, - /* hasCrossProfileIntents= */ true, - /* myUserHandle= */ WORK_USER_HANDLE, - /* tab= */ WORK, - /* expectedBlocker= */ NO_BLOCKER - ), - new TestCase( - /* isSendAction= */ false, - /* hasCrossProfileIntents= */ false, - /* myUserHandle= */ WORK_USER_HANDLE, - /* tab= */ WORK, - /* expectedBlocker= */ NO_BLOCKER - ), - new TestCase( - /* isSendAction= */ false, - /* hasCrossProfileIntents= */ true, - /* myUserHandle= */ PERSONAL_USER_HANDLE, - /* tab= */ WORK, - /* expectedBlocker= */ NO_BLOCKER - ), - new TestCase( - /* isSendAction= */ false, - /* hasCrossProfileIntents= */ false, - /* myUserHandle= */ PERSONAL_USER_HANDLE, - /* tab= */ WORK, - /* expectedBlocker= */ WORK_PROFILE_ACCESS_BLOCKER - ), - new TestCase( - /* isSendAction= */ false, - /* hasCrossProfileIntents= */ true, - /* myUserHandle= */ WORK_USER_HANDLE, - /* tab= */ PERSONAL, - /* expectedBlocker= */ NO_BLOCKER - ), - new TestCase( - /* isSendAction= */ false, - /* hasCrossProfileIntents= */ false, - /* myUserHandle= */ WORK_USER_HANDLE, - /* tab= */ PERSONAL, - /* expectedBlocker= */ PERSONAL_PROFILE_ACCESS_BLOCKER - ), - new TestCase( - /* isSendAction= */ false, - /* hasCrossProfileIntents= */ true, - /* myUserHandle= */ PERSONAL_USER_HANDLE, - /* tab= */ PERSONAL, - /* expectedBlocker= */ NO_BLOCKER - ), - new TestCase( - /* isSendAction= */ false, - /* hasCrossProfileIntents= */ false, - /* myUserHandle= */ PERSONAL_USER_HANDLE, - /* tab= */ PERSONAL, - /* expectedBlocker= */ NO_BLOCKER - ) - ); - } - - private List createResolvedComponentsForTestWithOtherProfile( - int numberOfResults, int userId, UserHandle resolvedForUser) { - List infoList = new ArrayList<>(numberOfResults); - for (int i = 0; i < numberOfResults; i++) { - infoList.add( - ResolverDataProvider - .createResolvedComponentInfoWithOtherId(i, userId, resolvedForUser)); - } - return infoList; - } - - private List createResolvedComponentsForTest(int numberOfResults, - UserHandle resolvedForUser) { - List infoList = new ArrayList<>(numberOfResults); - for (int i = 0; i < numberOfResults; i++) { - infoList.add(ResolverDataProvider.createResolvedComponentInfo(i, resolvedForUser)); - } - return infoList; - } - - 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 personalResolvedComponentInfos = - createResolvedComponentsForTestWithOtherProfile(3, - /* userId */ WORK_USER_HANDLE.getIdentifier(), PERSONAL_USER_HANDLE); - List workResolvedComponentInfos = - createResolvedComponentsForTest(workProfileTargets, WORK_USER_HANDLE); - setupResolverControllers(personalResolvedComponentInfos, workResolvedComponentInfos); - } - - private void setupResolverControllers( - List personalResolvedComponentInfos, - List workResolvedComponentInfos) { - when(sOverrides.resolverListController.getResolversForIntentAsUser( - Mockito.anyBoolean(), - Mockito.anyBoolean(), - Mockito.anyBoolean(), - Mockito.isA(List.class), - eq(UserHandle.SYSTEM))) - .thenReturn(new ArrayList<>(personalResolvedComponentInfos)); - when(sOverrides.workResolverListController.getResolversForIntentAsUser( - Mockito.anyBoolean(), - Mockito.anyBoolean(), - Mockito.anyBoolean(), - Mockito.isA(List.class), - eq(UserHandle.SYSTEM))) - .thenReturn(new ArrayList<>(personalResolvedComponentInfos)); - when(sOverrides.workResolverListController.getResolversForIntentAsUser( - Mockito.anyBoolean(), - Mockito.anyBoolean(), - Mockito.anyBoolean(), - Mockito.isA(List.class), - eq(WORK_USER_HANDLE))) - .thenReturn(new ArrayList<>(workResolvedComponentInfos)); - } - - private void waitForIdle() { - InstrumentationRegistry.getInstrumentation().waitForIdleSync(); - } - - private void assertCantAccessWorkAppsBlockerDisplayed() { - onView(withText(R.string.resolver_cross_profile_blocked)) - .check(matches(isDisplayed())); - onView(withText(R.string.resolver_cant_access_work_apps_explanation)) - .check(matches(isDisplayed())); - } - - private void assertCantAccessPersonalAppsBlockerDisplayed() { - onView(withText(R.string.resolver_cross_profile_blocked)) - .check(matches(isDisplayed())); - onView(withText(R.string.resolver_cant_access_personal_apps_explanation)) - .check(matches(isDisplayed())); - } - - private void assertCantShareWorkAppsBlockerDisplayed() { - onView(withText(R.string.resolver_cross_profile_blocked)) - .check(matches(isDisplayed())); - onView(withText(R.string.resolver_cant_share_with_work_apps_explanation)) - .check(matches(isDisplayed())); - } - - private void assertCantSharePersonalAppsBlockerDisplayed() { - onView(withText(R.string.resolver_cross_profile_blocked)) - .check(matches(isDisplayed())); - onView(withText(R.string.resolver_cant_share_with_personal_apps_explanation)) - .check(matches(isDisplayed())); - } - - private void assertNoBlockerDisplayed() { - try { - onView(withText(R.string.resolver_cross_profile_blocked)) - .check(matches(not(isDisplayed()))); - } catch (NoMatchingViewException ignored) { - } - } - - private void switchToTab(Tab tab) { - final int stringId = tab == Tab.WORK ? R.string.resolver_work_tab - : R.string.resolver_personal_tab; - - waitFor(() -> { - onView(withText(stringId)).perform(click()); - waitForIdle(); - - try { - onView(withText(stringId)).check(matches(isSelected())); - return true; - } catch (AssertionFailedError e) { - return false; - } - }); - - onView(withId(com.android.internal.R.id.contentPanel)) - .perform(swipeUp()); - waitForIdle(); - } - - private Intent createTextIntent(boolean isSendAction) { - Intent sendIntent = new Intent(); - if (isSendAction) { - sendIntent.setAction(Intent.ACTION_SEND); - } - sendIntent.putExtra(Intent.EXTRA_TEXT, "testing intent sending"); - sendIntent.setType("text/plain"); - return sendIntent; - } - - private void launchActivity(boolean isSendAction) { - Intent sendIntent = createTextIntent(isSendAction); - mActivityRule.launchActivity(Intent.createChooser(sendIntent, "Test")); - waitForIdle(); - } - - public static class TestCase { - private final boolean mIsSendAction; - private final boolean mHasCrossProfileIntents; - private final UserHandle mMyUserHandle; - private final Tab mTab; - private final ExpectedBlocker mExpectedBlocker; - - public enum ExpectedBlocker { - NO_BLOCKER, - PERSONAL_PROFILE_SHARE_BLOCKER, - WORK_PROFILE_SHARE_BLOCKER, - PERSONAL_PROFILE_ACCESS_BLOCKER, - WORK_PROFILE_ACCESS_BLOCKER - } - - public enum Tab { - WORK, - PERSONAL - } - - public TestCase(boolean isSendAction, boolean hasCrossProfileIntents, - UserHandle myUserHandle, Tab tab, ExpectedBlocker expectedBlocker) { - mIsSendAction = isSendAction; - mHasCrossProfileIntents = hasCrossProfileIntents; - mMyUserHandle = myUserHandle; - mTab = tab; - mExpectedBlocker = expectedBlocker; - } - - public boolean getIsSendAction() { - return mIsSendAction; - } - - public boolean hasCrossProfileIntents() { - return mHasCrossProfileIntents; - } - - public UserHandle getMyUserHandle() { - return mMyUserHandle; - } - - public Tab getTab() { - return mTab; - } - - public ExpectedBlocker getExpectedBlocker() { - return mExpectedBlocker; - } - - @Override - public String toString() { - StringBuilder result = new StringBuilder("test"); - - if (mTab == WORK) { - result.append("WorkTab_"); - } else { - result.append("PersonalTab_"); - } - - if (mIsSendAction) { - result.append("sendAction_"); - } else { - result.append("notSendAction_"); - } - - if (mHasCrossProfileIntents) { - result.append("hasCrossProfileIntents_"); - } else { - result.append("doesNotHaveCrossProfileIntents_"); - } - - if (mMyUserHandle.equals(PERSONAL_USER_HANDLE)) { - result.append("myUserIsPersonal_"); - } else { - result.append("myUserIsWork_"); - } - - if (mExpectedBlocker == ExpectedBlocker.NO_BLOCKER) { - result.append("thenNoBlocker"); - } else if (mExpectedBlocker == PERSONAL_PROFILE_ACCESS_BLOCKER) { - result.append("thenAccessBlockerOnPersonalProfile"); - } else if (mExpectedBlocker == PERSONAL_PROFILE_SHARE_BLOCKER) { - result.append("thenShareBlockerOnPersonalProfile"); - } else if (mExpectedBlocker == WORK_PROFILE_ACCESS_BLOCKER) { - result.append("thenAccessBlockerOnWorkProfile"); - } else if (mExpectedBlocker == WORK_PROFILE_SHARE_BLOCKER) { - result.append("thenShareBlockerOnWorkProfile"); - } - - return result.toString(); - } - } -} diff --git a/tests/activity/src/com/android/intentresolver/v2/ChooserActivityOverrideData.java b/tests/activity/src/com/android/intentresolver/v2/ChooserActivityOverrideData.java deleted file mode 100644 index 1f3f6429..00000000 --- a/tests/activity/src/com/android/intentresolver/v2/ChooserActivityOverrideData.java +++ /dev/null @@ -1,88 +0,0 @@ -/* - * Copyright (C) 2021 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.android.intentresolver.v2; - -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.ArgumentMatchers.anyInt; -import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.when; - -import android.content.res.Resources; -import android.database.Cursor; -import android.os.UserHandle; - -import com.android.intentresolver.chooser.TargetInfo; -import com.android.intentresolver.contentpreview.ImageLoader; -import com.android.intentresolver.emptystate.CrossProfileIntentsChecker; -import com.android.intentresolver.shortcuts.ShortcutLoader; - -import kotlin.jvm.functions.Function2; - -import java.util.function.Consumer; -import java.util.function.Function; - -/** - * Singleton providing overrides to be applied by any {@code IChooserWrapper} used in testing. - * We cannot directly mock the activity created since instrumentation creates it, so instead we use - * this singleton to modify behavior. - */ -public class ChooserActivityOverrideData { - private static ChooserActivityOverrideData sInstance = null; - - public static ChooserActivityOverrideData getInstance() { - if (sInstance == null) { - sInstance = new ChooserActivityOverrideData(); - } - return sInstance; - } - public Function onSafelyStartInternalCallback; - public Function onSafelyStartCallback; - public Function2, ShortcutLoader> - shortcutLoaderFactory = (userHandle, callback) -> null; - public ChooserListController resolverListController; - public ChooserListController workResolverListController; - public Boolean isVoiceInteraction; - public Cursor resolverCursor; - public boolean resolverForceException; - public ImageLoader imageLoader; - public Resources resources; - public boolean hasCrossProfileIntents; - public boolean isQuietModeEnabled; - public Integer myUserId; - public CrossProfileIntentsChecker mCrossProfileIntentsChecker; - - public void reset() { - onSafelyStartInternalCallback = null; - isVoiceInteraction = null; - imageLoader = null; - resolverCursor = null; - resolverForceException = false; - resolverListController = mock(ChooserListController.class); - workResolverListController = mock(ChooserListController.class); - resources = null; - hasCrossProfileIntents = true; - isQuietModeEnabled = false; - myUserId = null; - shortcutLoaderFactory = ((userHandle, resultConsumer) -> null); - mCrossProfileIntentsChecker = mock(CrossProfileIntentsChecker.class); - when(mCrossProfileIntentsChecker.hasCrossProfileIntents(any(), anyInt(), anyInt())) - .thenAnswer(invocation -> hasCrossProfileIntents); - } - - private ChooserActivityOverrideData() {} -} - diff --git a/tests/activity/src/com/android/intentresolver/v2/ChooserWrapperActivity.java b/tests/activity/src/com/android/intentresolver/v2/ChooserWrapperActivity.java deleted file mode 100644 index 47d9c8c2..00000000 --- a/tests/activity/src/com/android/intentresolver/v2/ChooserWrapperActivity.java +++ /dev/null @@ -1,219 +0,0 @@ -/* - * Copyright (C) 2008 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.android.intentresolver.v2; - -import android.annotation.Nullable; -import android.app.prediction.AppPredictor; -import android.app.usage.UsageStatsManager; -import android.content.ContentResolver; -import android.content.Context; -import android.content.Intent; -import android.content.IntentFilter; -import android.content.pm.ResolveInfo; -import android.content.res.Resources; -import android.database.Cursor; -import android.net.Uri; -import android.os.Bundle; -import android.os.UserHandle; - -import androidx.lifecycle.ViewModelProvider; - -import com.android.intentresolver.ChooserListAdapter; -import com.android.intentresolver.IChooserWrapper; -import com.android.intentresolver.ResolverListController; -import com.android.intentresolver.TestContentPreviewViewModel; -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 java.util.List; -import java.util.function.Consumer; - -/** - * Simple wrapper around chooser activity to be able to initiate it under test. For more - * information, see {@code com.android.internal.app.ChooserWrapperActivity}. - */ -public class ChooserWrapperActivity extends ChooserActivity implements IChooserWrapper { - static final ChooserActivityOverrideData sOverrides = ChooserActivityOverrideData.getInstance(); - private UsageStatsManager mUsm; - - @Override - public final ChooserListAdapter createChooserListAdapter( - Context context, - List payloadIntents, - Intent[] initialIntents, - List rList, - boolean filterLastUsed, - ResolverListController resolverListController, - UserHandle userHandle, - Intent targetIntent, - Intent referrerFillInIntent, - int maxTargetsPerRow) { - - return new ChooserListAdapter( - context, - payloadIntents, - initialIntents, - rList, - filterLastUsed, - createListController(userHandle), - userHandle, - targetIntent, - referrerFillInIntent, - this, - mPackageManager, - getEventLog(), - maxTargetsPerRow, - userHandle, - mTargetDataLoader, - null, - mFeatureFlags); - } - - @Override - public ChooserListAdapter getAdapter() { - return mChooserMultiProfilePagerAdapter.getActiveListAdapter(); - } - - @Override - public ChooserListAdapter getPersonalListAdapter() { - return mChooserMultiProfilePagerAdapter.getPersonalListAdapter(); - } - - @Override - public ChooserListAdapter getWorkListAdapter() { - return mChooserMultiProfilePagerAdapter.getWorkListAdapter(); - } - - @Override - public boolean getIsSelected() { - return mIsSuccessfullySelected; - } - - @Override - public UsageStatsManager getUsageStatsManager() { - if (mUsm == null) { - mUsm = getSystemService(UsageStatsManager.class); - } - return mUsm; - } - - @Override - public boolean isVoiceInteraction() { - if (sOverrides.isVoiceInteraction != null) { - return sOverrides.isVoiceInteraction; - } - return super.isVoiceInteraction(); - } - - @Override - protected CrossProfileIntentsChecker createCrossProfileIntentsChecker() { - if (sOverrides.mCrossProfileIntentsChecker != null) { - return sOverrides.mCrossProfileIntentsChecker; - } - return super.createCrossProfileIntentsChecker(); - } - - @Override - public void safelyStartActivityInternal(TargetInfo cti, UserHandle user, - @Nullable Bundle options) { - if (sOverrides.onSafelyStartInternalCallback != null - && sOverrides.onSafelyStartInternalCallback.apply(cti)) { - return; - } - super.safelyStartActivityInternal(cti, user, options); - } - - @Override - public final ChooserListController createListController(UserHandle userHandle) { - if (userHandle == UserHandle.SYSTEM) { - return sOverrides.resolverListController; - } - return sOverrides.workResolverListController; - } - - @Override - public Resources getResources() { - if (sOverrides.resources != null) { - return sOverrides.resources; - } - return super.getResources(); - } - - @Override - protected ViewModelProvider.Factory createPreviewViewModelFactory() { - return TestContentPreviewViewModel.Companion.wrap( - super.createPreviewViewModelFactory(), - sOverrides.imageLoader); - } - - @Override - public Cursor queryResolver(ContentResolver resolver, Uri uri) { - if (sOverrides.resolverCursor != null) { - return sOverrides.resolverCursor; - } - - if (sOverrides.resolverForceException) { - throw new SecurityException("Test exception handling"); - } - - return super.queryResolver(resolver, uri); - } - - @Override - public DisplayResolveInfo createTestDisplayResolveInfo( - Intent originalIntent, - ResolveInfo pri, - CharSequence pLabel, - CharSequence pInfo, - Intent replacementIntent) { - return DisplayResolveInfo.newDisplayResolveInfo( - originalIntent, - pri, - pLabel, - pInfo, - replacementIntent); - } - - @Override - public UserHandle getCurrentUserHandle() { - return mChooserMultiProfilePagerAdapter.getCurrentUserHandle(); - } - - @Override - public Context createContextAsUser(UserHandle user, int flags) { - // return the current context as a work profile doesn't really exist in these tests - return this; - } - - @Override - protected ShortcutLoader createShortcutLoader( - Context context, - AppPredictor appPredictor, - UserHandle userHandle, - IntentFilter targetIntentFilter, - Consumer callback) { - ShortcutLoader shortcutLoader = - sOverrides.shortcutLoaderFactory.invoke(userHandle, callback); - if (shortcutLoader != null) { - return shortcutLoader; - } - return super.createShortcutLoader( - context, appPredictor, userHandle, targetIntentFilter, callback); - } -} diff --git a/tests/activity/src/com/android/intentresolver/v2/ResolverActivityTest.java b/tests/activity/src/com/android/intentresolver/v2/ResolverActivityTest.java deleted file mode 100644 index 220a12cc..00000000 --- a/tests/activity/src/com/android/intentresolver/v2/ResolverActivityTest.java +++ /dev/null @@ -1,1124 +0,0 @@ -/* - * Copyright (C) 2016 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.android.intentresolver.v2; - -import static androidx.test.espresso.Espresso.onView; -import static androidx.test.espresso.action.ViewActions.click; -import static androidx.test.espresso.action.ViewActions.swipeUp; -import static androidx.test.espresso.assertion.ViewAssertions.matches; -import static androidx.test.espresso.matcher.ViewMatchers.isCompletelyDisplayed; -import static androidx.test.espresso.matcher.ViewMatchers.isDisplayed; -import static androidx.test.espresso.matcher.ViewMatchers.isEnabled; -import static androidx.test.espresso.matcher.ViewMatchers.withId; -import static androidx.test.espresso.matcher.ViewMatchers.withText; -import static androidx.test.platform.app.InstrumentationRegistry.getInstrumentation; - -import static com.android.intentresolver.MatcherUtils.first; -import static com.android.intentresolver.v2.ResolverWrapperActivity.sOverrides; - -import static org.hamcrest.CoreMatchers.allOf; -import static org.hamcrest.CoreMatchers.is; -import static org.hamcrest.CoreMatchers.not; -import static org.hamcrest.MatcherAssert.assertThat; -import static org.junit.Assert.assertFalse; -import static org.junit.Assert.assertNull; -import static org.junit.Assert.fail; -import static org.mockito.ArgumentMatchers.eq; -import static org.mockito.Mockito.when; - -import android.content.Intent; -import android.content.pm.ResolveInfo; -import android.net.Uri; -import android.os.RemoteException; -import android.os.UserHandle; -import android.text.TextUtils; -import android.view.View; -import android.widget.RelativeLayout; -import android.widget.TextView; - -import androidx.test.platform.app.InstrumentationRegistry; -import androidx.test.espresso.Espresso; -import androidx.test.espresso.NoMatchingViewException; -import androidx.test.rule.ActivityTestRule; -import androidx.test.runner.AndroidJUnit4; - -import com.android.intentresolver.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.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 com.android.intentresolver.widget.ResolverDrawerLayout; - -import com.google.android.collect.Lists; - -import dagger.hilt.android.testing.BindValue; -import dagger.hilt.android.testing.HiltAndroidRule; -import dagger.hilt.android.testing.HiltAndroidTest; -import dagger.hilt.android.testing.UninstallModules; - -import org.junit.Before; -import org.junit.Ignore; -import org.junit.Rule; -import org.junit.Test; -import org.junit.runner.RunWith; -import org.mockito.Mockito; - -import java.util.ArrayList; -import java.util.List; - -/** - * Resolver activity instrumentation tests - */ -@RunWith(AndroidJUnit4.class) -@HiltAndroidTest -@UninstallModules(UserRepositoryModule.class) -public class ResolverActivityTest { - - private static final UserHandle PERSONAL_USER_HANDLE = - getInstrumentation().getTargetContext().getUser(); - private static final UserHandle WORK_PROFILE_USER_HANDLE = UserHandle.of(10); - private static final UserHandle CLONE_PROFILE_USER_HANDLE = UserHandle.of(11); - private static final User WORK_PROFILE_USER = - new User(WORK_PROFILE_USER_HANDLE.getIdentifier(), User.Role.WORK); - - @Rule(order = 0) - public HiltAndroidRule mHiltAndroidRule = new HiltAndroidRule(this); - - @Rule(order = 1) - public ActivityTestRule mActivityRule = - new ActivityTestRule<>(ResolverWrapperActivity.class, false, false); - - @Before - public void setup() { - // TODO: use the other form of `adoptShellPermissionIdentity()` where we explicitly list the - // permissions we require (which we'll read from the manifest at runtime). - getInstrumentation() - .getUiAutomation() - .adoptShellPermissionIdentity(); - - sOverrides.reset(); - } - - @BindValue - @ApplicationUser - public final UserHandle mApplicationUser = PERSONAL_USER_HANDLE; - - @BindValue - @ProfileParent - public final UserHandle mProfileParent = PERSONAL_USER_HANDLE; - - /** For setup of test state, a mutable reference of mUserRepository */ - private final FakeUserRepository mFakeUserRepo = - new FakeUserRepository(List.of( - new User(PERSONAL_USER_HANDLE.getIdentifier(), User.Role.PERSONAL) - )); - - @BindValue - public final UserRepository mUserRepository = mFakeUserRepo; - - @Test - public void twoOptionsAndUserSelectsOne() throws InterruptedException { - Intent sendIntent = createSendImageIntent(); - List resolvedComponentInfos = createResolvedComponentsForTest(2, - PERSONAL_USER_HANDLE); - - setupResolverControllers(resolvedComponentInfos); - - final ResolverWrapperActivity activity = mActivityRule.launchActivity(sendIntent); - Espresso.registerIdlingResources(activity.getLabelIdlingResource()); - waitForIdle(); - - assertThat(activity.getAdapter().getCount(), is(2)); - - ResolveInfo[] chosen = new ResolveInfo[1]; - sOverrides.onSafelyStartInternalCallback = result -> { - chosen[0] = result.first.getResolveInfo(); - return true; - }; - - ResolveInfo toChoose = resolvedComponentInfos.get(0).getResolveInfoAt(0); - onView(withText(toChoose.activityInfo.name)) - .perform(click()); - onView(withId(com.android.internal.R.id.button_once)) - .perform(click()); - waitForIdle(); - assertThat(chosen[0], is(toChoose)); - } - - @Ignore // Failing - b/144929805 - @Test - public void setMaxHeight() throws Exception { - Intent sendIntent = createSendImageIntent(); - List resolvedComponentInfos = createResolvedComponentsForTest(2, - PERSONAL_USER_HANDLE); - - setupResolverControllers(resolvedComponentInfos); - waitForIdle(); - - final ResolverWrapperActivity activity = mActivityRule.launchActivity(sendIntent); - final View viewPager = activity.findViewById(com.android.internal.R.id.profile_pager); - final int initialResolverHeight = viewPager.getHeight(); - - activity.runOnUiThread(() -> { - ResolverDrawerLayout layout = (ResolverDrawerLayout) - activity.findViewById( - com.android.internal.R.id.contentPanel); - ((ResolverDrawerLayout.LayoutParams) viewPager.getLayoutParams()).maxHeight - = initialResolverHeight - 1; - // Force a relayout - layout.invalidate(); - layout.requestLayout(); - }); - waitForIdle(); - assertThat("Drawer should be capped at maxHeight", - viewPager.getHeight() == (initialResolverHeight - 1)); - - activity.runOnUiThread(() -> { - ResolverDrawerLayout layout = (ResolverDrawerLayout) - activity.findViewById( - com.android.internal.R.id.contentPanel); - ((ResolverDrawerLayout.LayoutParams) viewPager.getLayoutParams()).maxHeight - = initialResolverHeight + 1; - // Force a relayout - layout.invalidate(); - layout.requestLayout(); - }); - waitForIdle(); - assertThat("Drawer should not change height if its height is less than maxHeight", - viewPager.getHeight() == initialResolverHeight); - } - - @Ignore // Failing - b/144929805 - @Test - public void setShowAtTopToTrue() throws Exception { - Intent sendIntent = createSendImageIntent(); - List resolvedComponentInfos = createResolvedComponentsForTest(2, - PERSONAL_USER_HANDLE); - - setupResolverControllers(resolvedComponentInfos); - waitForIdle(); - - final ResolverWrapperActivity activity = mActivityRule.launchActivity(sendIntent); - final View viewPager = activity.findViewById(com.android.internal.R.id.profile_pager); - final View divider = activity.findViewById(com.android.internal.R.id.divider); - final RelativeLayout profileView = - (RelativeLayout) activity.findViewById(com.android.internal.R.id.profile_button) - .getParent(); - assertThat("Drawer should show at bottom by default", - profileView.getBottom() + divider.getHeight() == viewPager.getTop() - && profileView.getTop() > 0); - - activity.runOnUiThread(() -> { - ResolverDrawerLayout layout = (ResolverDrawerLayout) - activity.findViewById( - com.android.internal.R.id.contentPanel); - layout.setShowAtTop(true); - }); - waitForIdle(); - assertThat("Drawer should show at top with new attribute", - profileView.getBottom() + divider.getHeight() == viewPager.getTop() - && profileView.getTop() == 0); - } - - @Test - public void hasLastChosenActivity() throws Exception { - Intent sendIntent = createSendImageIntent(); - List resolvedComponentInfos = createResolvedComponentsForTest(2, - PERSONAL_USER_HANDLE); - ResolveInfo toChoose = resolvedComponentInfos.get(0).getResolveInfoAt(0); - - setupResolverControllers(resolvedComponentInfos); - when(sOverrides.resolverListController.getLastChosen()) - .thenReturn(resolvedComponentInfos.get(0).getResolveInfoAt(0)); - - final ResolverWrapperActivity activity = mActivityRule.launchActivity(sendIntent); - waitForIdle(); - - // The other entry is filtered to the last used slot - assertThat(activity.getAdapter().getCount(), is(1)); - assertThat(activity.getAdapter().getPlaceholderCount(), is(1)); - - ResolveInfo[] chosen = new ResolveInfo[1]; - sOverrides.onSafelyStartInternalCallback = result -> { - chosen[0] = result.first.getResolveInfo(); - return true; - }; - - onView(withId(com.android.internal.R.id.button_once)).perform(click()); - waitForIdle(); - assertThat(chosen[0], is(toChoose)); - } - - @Test - public void hasOtherProfileOneOption() throws Exception { - List personalResolvedComponentInfos = - createResolvedComponentsForTestWithOtherProfile(2, /* userId */ 10, - PERSONAL_USER_HANDLE); - markOtherProfileAvailability(/* workAvailable= */ true, /* cloneAvailable= */ false); - List workResolvedComponentInfos = createResolvedComponentsForTest(4, - WORK_PROFILE_USER_HANDLE); - setupResolverControllers(personalResolvedComponentInfos, workResolvedComponentInfos); - - ResolveInfo toChoose = personalResolvedComponentInfos.get(1).getResolveInfoAt(0); - Intent sendIntent = createSendImageIntent(); - final ResolverWrapperActivity activity = mActivityRule.launchActivity(sendIntent); - Espresso.registerIdlingResources(activity.getLabelIdlingResource()); - waitForIdle(); - - // The other entry is filtered to the last used slot - assertThat(activity.getAdapter().getCount(), is(1)); - - ResolveInfo[] chosen = new ResolveInfo[1]; - sOverrides.onSafelyStartInternalCallback = result -> { - chosen[0] = result.first.getResolveInfo(); - return true; - }; - // Make a stable copy of the components as the original list may be modified - List stableCopy = - createResolvedComponentsForTestWithOtherProfile(2, /* userId= */ 10, - PERSONAL_USER_HANDLE); - // We pick the first one as there is another one in the work profile side - onView(first(withText(stableCopy.get(1).getResolveInfoAt(0).activityInfo.name))) - .perform(click()); - onView(withId(com.android.internal.R.id.button_once)) - .perform(click()); - waitForIdle(); - assertThat(chosen[0], is(toChoose)); - } - - @Test - public void hasOtherProfileTwoOptionsAndUserSelectsOne() throws Exception { - Intent sendIntent = createSendImageIntent(); - List resolvedComponentInfos = - createResolvedComponentsForTestWithOtherProfile(3, PERSONAL_USER_HANDLE); - ResolveInfo toChoose = resolvedComponentInfos.get(1).getResolveInfoAt(0); - - setupResolverControllers(resolvedComponentInfos); - - final ResolverWrapperActivity activity = mActivityRule.launchActivity(sendIntent); - Espresso.registerIdlingResources(activity.getLabelIdlingResource()); - waitForIdle(); - - // The other entry is filtered to the other profile slot - assertThat(activity.getAdapter().getCount(), is(2)); - - ResolveInfo[] chosen = new ResolveInfo[1]; - sOverrides.onSafelyStartInternalCallback = result -> { - chosen[0] = result.first.getResolveInfo(); - return true; - }; - - // Confirm that the button bar is disabled by default - onView(withId(com.android.internal.R.id.button_once)).check(matches(not(isEnabled()))); - - // Make a stable copy of the components as the original list may be modified - List stableCopy = - createResolvedComponentsForTestWithOtherProfile(2, PERSONAL_USER_HANDLE); - - onView(withText(stableCopy.get(1).getResolveInfoAt(0).activityInfo.name)) - .perform(click()); - onView(withId(com.android.internal.R.id.button_once)).perform(click()); - waitForIdle(); - assertThat(chosen[0], is(toChoose)); - } - - - @Test - public void hasLastChosenActivityAndOtherProfile() throws Exception { - // In this case we prefer the other profile and don't display anything about the last - // chosen activity. - Intent sendIntent = createSendImageIntent(); - List resolvedComponentInfos = - createResolvedComponentsForTestWithOtherProfile(3, PERSONAL_USER_HANDLE); - ResolveInfo toChoose = resolvedComponentInfos.get(1).getResolveInfoAt(0); - - setupResolverControllers(resolvedComponentInfos); - when(sOverrides.resolverListController.getLastChosen()) - .thenReturn(resolvedComponentInfos.get(1).getResolveInfoAt(0)); - - final ResolverWrapperActivity activity = mActivityRule.launchActivity(sendIntent); - Espresso.registerIdlingResources(activity.getLabelIdlingResource()); - waitForIdle(); - - // The other entry is filtered to the other profile slot - assertThat(activity.getAdapter().getCount(), is(2)); - - ResolveInfo[] chosen = new ResolveInfo[1]; - sOverrides.onSafelyStartInternalCallback = result -> { - chosen[0] = result.first.getResolveInfo(); - return true; - }; - - // Confirm that the button bar is disabled by default - onView(withId(com.android.internal.R.id.button_once)).check(matches(not(isEnabled()))); - - // Make a stable copy of the components as the original list may be modified - List stableCopy = - createResolvedComponentsForTestWithOtherProfile(2, PERSONAL_USER_HANDLE); - - onView(withText(stableCopy.get(1).getResolveInfoAt(0).activityInfo.name)) - .perform(click()); - onView(withId(com.android.internal.R.id.button_once)).perform(click()); - waitForIdle(); - assertThat(chosen[0], is(toChoose)); - } - - @Test - public void testWorkTab_displayedWhenWorkProfileUserAvailable() { - Intent sendIntent = createSendImageIntent(); - markOtherProfileAvailability(/* workAvailable= */ true, /* cloneAvailable= */ false); - - mActivityRule.launchActivity(sendIntent); - waitForIdle(); - - onView(withId(com.android.internal.R.id.tabs)).check(matches(isDisplayed())); - } - - @Test - public void testWorkTab_hiddenWhenWorkProfileUserNotAvailable() { - Intent sendIntent = createSendImageIntent(); - - mActivityRule.launchActivity(sendIntent); - waitForIdle(); - - onView(withId(com.android.internal.R.id.tabs)).check(matches(not(isDisplayed()))); - } - - @Test - public void testWorkTab_workTabListPopulatedBeforeGoingToTab() throws InterruptedException { - List personalResolvedComponentInfos = - createResolvedComponentsForTestWithOtherProfile(3, /* userId = */ 10, - PERSONAL_USER_HANDLE); - markOtherProfileAvailability(/* workAvailable= */ true, /* cloneAvailable= */ false); - List workResolvedComponentInfos = createResolvedComponentsForTest(4, - WORK_PROFILE_USER_HANDLE); - setupResolverControllers(personalResolvedComponentInfos, - new ArrayList<>(workResolvedComponentInfos)); - Intent sendIntent = createSendImageIntent(); - - final ResolverWrapperActivity activity = mActivityRule.launchActivity(sendIntent); - waitForIdle(); - - assertThat(activity.getCurrentUserHandle().getIdentifier(), is(0)); - // The work list adapter must be populated in advance before tapping the other tab - assertThat(activity.getWorkListAdapter().getCount(), is(4)); - } - - @Test - public void testWorkTab_workTabUsesExpectedAdapter() { - markOtherProfileAvailability(/* workAvailable= */ true, /* cloneAvailable= */ false); - List personalResolvedComponentInfos = - createResolvedComponentsForTestWithOtherProfile(3, /* userId */ 10, - PERSONAL_USER_HANDLE); - List workResolvedComponentInfos = createResolvedComponentsForTest(4, - WORK_PROFILE_USER_HANDLE); - setupResolverControllers(personalResolvedComponentInfos, workResolvedComponentInfos); - Intent sendIntent = createSendImageIntent(); - - final ResolverWrapperActivity activity = mActivityRule.launchActivity(sendIntent); - waitForIdle(); - onView(withText(R.string.resolver_work_tab)).perform(click()); - - assertThat(activity.getCurrentUserHandle().getIdentifier(), is(10)); - assertThat(activity.getWorkListAdapter().getCount(), is(4)); - } - - @Test - public void testWorkTab_personalTabUsesExpectedAdapter() { - markOtherProfileAvailability(/* workAvailable= */ true, /* cloneAvailable= */ false); - List personalResolvedComponentInfos = - createResolvedComponentsForTestWithOtherProfile(3, PERSONAL_USER_HANDLE); - List workResolvedComponentInfos = createResolvedComponentsForTest(4, - WORK_PROFILE_USER_HANDLE); - setupResolverControllers(personalResolvedComponentInfos, workResolvedComponentInfos); - Intent sendIntent = createSendImageIntent(); - - final ResolverWrapperActivity activity = mActivityRule.launchActivity(sendIntent); - waitForIdle(); - onView(withText(R.string.resolver_work_tab)).perform(click()); - - assertThat(activity.getCurrentUserHandle().getIdentifier(), is(10)); - assertThat(activity.getPersonalListAdapter().getCount(), is(2)); - } - - @Test - public void testWorkTab_workProfileHasExpectedNumberOfTargets() throws InterruptedException { - markOtherProfileAvailability(/* workAvailable= */ true, /* cloneAvailable= */ false); - List personalResolvedComponentInfos = - createResolvedComponentsForTestWithOtherProfile(3, /* userId */ 10, - PERSONAL_USER_HANDLE); - List workResolvedComponentInfos = createResolvedComponentsForTest(4, - WORK_PROFILE_USER_HANDLE); - setupResolverControllers(personalResolvedComponentInfos, workResolvedComponentInfos); - Intent sendIntent = createSendImageIntent(); - - final ResolverWrapperActivity activity = mActivityRule.launchActivity(sendIntent); - waitForIdle(); - - onView(withText(R.string.resolver_work_tab)) - .perform(click()); - waitForIdle(); - assertThat(activity.getWorkListAdapter().getCount(), is(4)); - } - - @Test - public void testWorkTab_selectingWorkTabAppOpensAppInWorkProfile() throws InterruptedException { - markOtherProfileAvailability(/* workAvailable= */ true, /* cloneAvailable= */ false); - List personalResolvedComponentInfos = - createResolvedComponentsForTestWithOtherProfile(3, - /* userId */ WORK_PROFILE_USER_HANDLE.getIdentifier(), - PERSONAL_USER_HANDLE); - List workResolvedComponentInfos = createResolvedComponentsForTest(4, - WORK_PROFILE_USER_HANDLE); - setupResolverControllers(personalResolvedComponentInfos, workResolvedComponentInfos); - Intent sendIntent = createSendImageIntent(); - ResolveInfo[] chosen = new ResolveInfo[1]; - sOverrides.onSafelyStartInternalCallback = result -> { - chosen[0] = result.first.getResolveInfo(); - return true; - }; - - mActivityRule.launchActivity(sendIntent); - waitForIdle(); - onView(withText(R.string.resolver_work_tab)) - .perform(click()); - waitForIdle(); - onView(first(allOf(withText(workResolvedComponentInfos.get(0) - .getResolveInfoAt(0).activityInfo.applicationInfo.name), isCompletelyDisplayed()))) - .perform(click()); - onView(withId(com.android.internal.R.id.button_once)) - .perform(click()); - - waitForIdle(); - assertThat(chosen[0], is(workResolvedComponentInfos.get(0).getResolveInfoAt(0))); - } - - @Test - public void testWorkTab_noPersonalApps_workTabHasExpectedNumberOfTargets() - throws InterruptedException { - markOtherProfileAvailability(/* workAvailable= */ true, /* cloneAvailable= */ false); - List personalResolvedComponentInfos = - createResolvedComponentsForTestWithOtherProfile(1, PERSONAL_USER_HANDLE); - List workResolvedComponentInfos = createResolvedComponentsForTest(4, - WORK_PROFILE_USER_HANDLE); - setupResolverControllers(personalResolvedComponentInfos, workResolvedComponentInfos); - Intent sendIntent = createSendImageIntent(); - - final ResolverWrapperActivity activity = mActivityRule.launchActivity(sendIntent); - waitForIdle(); - onView(withText(R.string.resolver_work_tab)) - .perform(click()); - - waitForIdle(); - assertThat(activity.getWorkListAdapter().getCount(), is(4)); - } - - @Test - public void testWorkTab_headerIsVisibleInPersonalTab() { - markOtherProfileAvailability(/* workAvailable= */ true, /* cloneAvailable= */ false); - List personalResolvedComponentInfos = - createResolvedComponentsForTestWithOtherProfile(1, PERSONAL_USER_HANDLE); - List workResolvedComponentInfos = createResolvedComponentsForTest(4, - WORK_PROFILE_USER_HANDLE); - setupResolverControllers(personalResolvedComponentInfos, workResolvedComponentInfos); - Intent sendIntent = createOpenWebsiteIntent(); - - final ResolverWrapperActivity activity = mActivityRule.launchActivity(sendIntent); - waitForIdle(); - TextView headerText = activity.findViewById(com.android.internal.R.id.title); - String initialText = headerText.getText().toString(); - assertFalse("Header text is empty.", initialText.isEmpty()); - assertThat(headerText.getVisibility(), is(View.VISIBLE)); - } - - @Test - public void testWorkTab_switchTabs_headerStaysSame() { - markOtherProfileAvailability(/* workAvailable= */ true, /* cloneAvailable= */ false); - List personalResolvedComponentInfos = - createResolvedComponentsForTestWithOtherProfile(1, PERSONAL_USER_HANDLE); - List workResolvedComponentInfos = createResolvedComponentsForTest(4, - WORK_PROFILE_USER_HANDLE); - setupResolverControllers(personalResolvedComponentInfos, workResolvedComponentInfos); - Intent sendIntent = createOpenWebsiteIntent(); - - final ResolverWrapperActivity activity = mActivityRule.launchActivity(sendIntent); - waitForIdle(); - TextView headerText = activity.findViewById(com.android.internal.R.id.title); - String initialText = headerText.getText().toString(); - onView(withText(R.string.resolver_work_tab)) - .perform(click()); - - waitForIdle(); - String currentText = headerText.getText().toString(); - assertThat(headerText.getVisibility(), is(View.VISIBLE)); - assertThat(String.format("Header text is not the same when switching tabs, personal profile" - + " header was %s but work profile header is %s", initialText, currentText), - TextUtils.equals(initialText, currentText)); - } - - @Test - public void testWorkTab_noPersonalApps_canStartWorkApps() - throws InterruptedException { - markOtherProfileAvailability(/* workAvailable= */ true, /* cloneAvailable= */ false); - List personalResolvedComponentInfos = - createResolvedComponentsForTestWithOtherProfile(3, /* userId= */ 10, - PERSONAL_USER_HANDLE); - List workResolvedComponentInfos = createResolvedComponentsForTest(4, - WORK_PROFILE_USER_HANDLE); - setupResolverControllers(personalResolvedComponentInfos, workResolvedComponentInfos); - Intent sendIntent = createSendImageIntent(); - ResolveInfo[] chosen = new ResolveInfo[1]; - sOverrides.onSafelyStartInternalCallback = result -> { - chosen[0] = result.first.getResolveInfo(); - return true; - }; - - mActivityRule.launchActivity(sendIntent); - waitForIdle(); - onView(withText(R.string.resolver_work_tab)) - .perform(click()); - waitForIdle(); - onView(first(allOf( - withText(workResolvedComponentInfos.get(0) - .getResolveInfoAt(0).activityInfo.applicationInfo.name), - isDisplayed()))) - .perform(click()); - onView(withId(com.android.internal.R.id.button_once)) - .perform(click()); - waitForIdle(); - - assertThat(chosen[0], is(workResolvedComponentInfos.get(0).getResolveInfoAt(0))); - } - - @Test - public void testWorkTab_crossProfileIntentsDisabled_personalToWork_emptyStateShown() { - markOtherProfileAvailability(/* workAvailable= */ true, /* cloneAvailable= */ false); - int workProfileTargets = 4; - List personalResolvedComponentInfos = - createResolvedComponentsForTestWithOtherProfile(3, /* userId */ 10, - PERSONAL_USER_HANDLE); - List workResolvedComponentInfos = - createResolvedComponentsForTest(workProfileTargets, WORK_PROFILE_USER_HANDLE); - sOverrides.hasCrossProfileIntents = false; - setupResolverControllers(personalResolvedComponentInfos, workResolvedComponentInfos); - Intent sendIntent = createSendImageIntent(); - sendIntent.setType("TestType"); - - mActivityRule.launchActivity(sendIntent); - waitForIdle(); - onView(withText(R.string.resolver_work_tab)).perform(click()); - waitForIdle(); - onView(withId(com.android.internal.R.id.contentPanel)) - .perform(swipeUp()); - - onView(withText(R.string.resolver_cross_profile_blocked)) - .check(matches(isDisplayed())); - } - - @Test - public void testWorkTab_workProfileDisabled_emptyStateShown() { - markOtherProfileAvailability(/* workAvailable= */ true, /* cloneAvailable= */ false); - int workProfileTargets = 4; - List personalResolvedComponentInfos = - createResolvedComponentsForTestWithOtherProfile(3, /* userId */ 10, - PERSONAL_USER_HANDLE); - List workResolvedComponentInfos = - createResolvedComponentsForTest(workProfileTargets, WORK_PROFILE_USER_HANDLE); - mFakeUserRepo.updateState(WORK_PROFILE_USER, false); - setupResolverControllers(personalResolvedComponentInfos, workResolvedComponentInfos); - Intent sendIntent = createSendImageIntent(); - sendIntent.setType("TestType"); - - mActivityRule.launchActivity(sendIntent); - waitForIdle(); - onView(withId(com.android.internal.R.id.contentPanel)) - .perform(swipeUp()); - onView(withText(R.string.resolver_work_tab)).perform(click()); - waitForIdle(); - - onView(withText(R.string.resolver_turn_on_work_apps)) - .check(matches(isDisplayed())); - } - - @Test - public void testWorkTab_noWorkAppsAvailable_emptyStateShown() { - markOtherProfileAvailability(/* workAvailable= */ true, /* cloneAvailable= */ false); - List personalResolvedComponentInfos = - createResolvedComponentsForTest(3, PERSONAL_USER_HANDLE); - List workResolvedComponentInfos = - createResolvedComponentsForTest(0, WORK_PROFILE_USER_HANDLE); - setupResolverControllers(personalResolvedComponentInfos, workResolvedComponentInfos); - Intent sendIntent = createSendImageIntent(); - sendIntent.setType("TestType"); - - mActivityRule.launchActivity(sendIntent); - waitForIdle(); - onView(withId(com.android.internal.R.id.contentPanel)) - .perform(swipeUp()); - onView(withText(R.string.resolver_work_tab)).perform(click()); - waitForIdle(); - - onView(withText(R.string.resolver_no_work_apps_available)) - .check(matches(isDisplayed())); - } - - @Test - public void testWorkTab_xProfileOff_noAppsAvailable_workOff_xProfileOffEmptyStateShown() { - markOtherProfileAvailability(/* workAvailable= */ true, /* cloneAvailable= */ false); - List personalResolvedComponentInfos = - createResolvedComponentsForTest(3, PERSONAL_USER_HANDLE); - List workResolvedComponentInfos = - createResolvedComponentsForTest(0, WORK_PROFILE_USER_HANDLE); - setupResolverControllers(personalResolvedComponentInfos, workResolvedComponentInfos); - Intent sendIntent = createSendImageIntent(); - sendIntent.setType("TestType"); - mFakeUserRepo.updateState(WORK_PROFILE_USER, false); - sOverrides.hasCrossProfileIntents = false; - - mActivityRule.launchActivity(sendIntent); - waitForIdle(); - onView(withId(com.android.internal.R.id.contentPanel)) - .perform(swipeUp()); - onView(withText(R.string.resolver_work_tab)).perform(click()); - waitForIdle(); - - onView(withText(R.string.resolver_cross_profile_blocked)) - .check(matches(isDisplayed())); - } - - @Test - public void testMiniResolver() { - markOtherProfileAvailability(/* workAvailable= */ true, /* cloneAvailable= */ false); - List personalResolvedComponentInfos = - createResolvedComponentsForTest(1, PERSONAL_USER_HANDLE); - List workResolvedComponentInfos = - createResolvedComponentsForTest(1, WORK_PROFILE_USER_HANDLE); - // Personal profile only has a browser - personalResolvedComponentInfos.get(0).getResolveInfoAt(0).handleAllWebDataURI = true; - setupResolverControllers(personalResolvedComponentInfos, workResolvedComponentInfos); - Intent sendIntent = createSendImageIntent(); - sendIntent.setType("TestType"); - - mActivityRule.launchActivity(sendIntent); - waitForIdle(); - onView(withId(com.android.internal.R.id.open_cross_profile)).check(matches(isDisplayed())); - } - - @Test - public void testMiniResolver_noCurrentProfileTarget() { - markOtherProfileAvailability(/* workAvailable= */ true, /* cloneAvailable= */ false); - List personalResolvedComponentInfos = - createResolvedComponentsForTest(0, PERSONAL_USER_HANDLE); - List workResolvedComponentInfos = - createResolvedComponentsForTest(1, WORK_PROFILE_USER_HANDLE); - setupResolverControllers(personalResolvedComponentInfos, workResolvedComponentInfos); - Intent sendIntent = createSendImageIntent(); - sendIntent.setType("TestType"); - - mActivityRule.launchActivity(sendIntent); - waitForIdle(); - - // Need to ensure mini resolver doesn't trigger here. - assertNotMiniResolver(); - } - - private void assertNotMiniResolver() { - try { - onView(withId(com.android.internal.R.id.open_cross_profile)) - .check(matches(isDisplayed())); - } catch (NoMatchingViewException e) { - return; - } - fail("Mini resolver present but shouldn't be"); - } - - @Test - public void testWorkTab_noAppsAvailable_workOff_noAppsAvailableEmptyStateShown() { - markOtherProfileAvailability(/* workAvailable= */ true, /* cloneAvailable= */ false); - List personalResolvedComponentInfos = - createResolvedComponentsForTest(3, PERSONAL_USER_HANDLE); - List workResolvedComponentInfos = - createResolvedComponentsForTest(0, WORK_PROFILE_USER_HANDLE); - setupResolverControllers(personalResolvedComponentInfos, workResolvedComponentInfos); - Intent sendIntent = createSendImageIntent(); - sendIntent.setType("TestType"); - mFakeUserRepo.updateState(WORK_PROFILE_USER, false); - - mActivityRule.launchActivity(sendIntent); - waitForIdle(); - onView(withId(com.android.internal.R.id.contentPanel)) - .perform(swipeUp()); - onView(withText(R.string.resolver_work_tab)).perform(click()); - waitForIdle(); - - onView(withText(R.string.resolver_no_work_apps_available)) - .check(matches(isDisplayed())); - } - - @Test - public void testWorkTab_onePersonalTarget_emptyStateOnWorkTarget_doesNotAutoLaunch() { - markOtherProfileAvailability(/* workAvailable= */ true, /* cloneAvailable= */ false); - int workProfileTargets = 4; - List personalResolvedComponentInfos = - createResolvedComponentsForTestWithOtherProfile(2, /* userId */ 10, - PERSONAL_USER_HANDLE); - List workResolvedComponentInfos = - createResolvedComponentsForTest(workProfileTargets, WORK_PROFILE_USER_HANDLE); - sOverrides.hasCrossProfileIntents = false; - setupResolverControllers(personalResolvedComponentInfos, workResolvedComponentInfos); - Intent sendIntent = createSendImageIntent(); - sendIntent.setType("TestType"); - ResolveInfo[] chosen = new ResolveInfo[1]; - sOverrides.onSafelyStartInternalCallback = result -> { - chosen[0] = result.first.getResolveInfo(); - return true; - }; - - mActivityRule.launchActivity(sendIntent); - waitForIdle(); - - assertNull(chosen[0]); - } - - @Test - public void testLayoutWithDefault_withWorkTab_neverShown() throws RemoteException { - markOtherProfileAvailability(/* workAvailable= */ true, /* cloneAvailable= */ false); - - // In this case we prefer the other profile and don't display anything about the last - // chosen activity. - Intent sendIntent = createSendImageIntent(); - List resolvedComponentInfos = - createResolvedComponentsForTest(2, PERSONAL_USER_HANDLE); - - setupResolverControllers(resolvedComponentInfos); - when(sOverrides.resolverListController.getLastChosen()) - .thenReturn(resolvedComponentInfos.get(1).getResolveInfoAt(0)); - - final ResolverWrapperActivity activity = mActivityRule.launchActivity(sendIntent); - Espresso.registerIdlingResources(activity.getLabelIdlingResource()); - waitForIdle(); - - // The other entry is filtered to the last used slot - assertThat(activity.getAdapter().hasFilteredItem(), is(false)); - assertThat(activity.getAdapter().getCount(), is(2)); - assertThat(activity.getAdapter().getPlaceholderCount(), is(2)); - } - - @Test - public void testClonedProfilePresent_personalAdapterIsSetWithPersonalProfile() { - // enable cloneProfile - markOtherProfileAvailability(/* workAvailable= */ false, /* cloneAvailable= */ true); - List resolvedComponentInfos = - createResolvedComponentsWithCloneProfileForTest( - 3, - PERSONAL_USER_HANDLE, - CLONE_PROFILE_USER_HANDLE); - setupResolverControllers(resolvedComponentInfos); - Intent sendIntent = createSendImageIntent(); - - final ResolverWrapperActivity activity = mActivityRule.launchActivity(sendIntent); - waitForIdle(); - - assertThat(activity.getCurrentUserHandle(), is(PERSONAL_USER_HANDLE)); - assertThat(activity.getAdapter().getCount(), is(3)); - } - - @Test - public void testClonedProfilePresent_personalTabUsesExpectedAdapter() { - // enable cloneProfile - markOtherProfileAvailability(/* workAvailable= */ true, /* cloneAvailable= */ true); - List personalResolvedComponentInfos = - createResolvedComponentsWithCloneProfileForTest( - 3, - PERSONAL_USER_HANDLE, - CLONE_PROFILE_USER_HANDLE); - List workResolvedComponentInfos = createResolvedComponentsForTest(4, - WORK_PROFILE_USER_HANDLE); - setupResolverControllers(personalResolvedComponentInfos, workResolvedComponentInfos); - Intent sendIntent = createSendImageIntent(); - - final ResolverWrapperActivity activity = mActivityRule.launchActivity(sendIntent); - waitForIdle(); - - assertThat(activity.getCurrentUserHandle(), is(PERSONAL_USER_HANDLE)); - assertThat(activity.getAdapter().getCount(), is(3)); - } - - @Test - public void testClonedProfilePresent_layoutWithDefault_neverShown() throws Exception { - // enable cloneProfile - markOtherProfileAvailability(/* workAvailable= */ false, /* cloneAvailable= */ true); - Intent sendIntent = createSendImageIntent(); - List resolvedComponentInfos = - createResolvedComponentsWithCloneProfileForTest( - 2, - PERSONAL_USER_HANDLE, - CLONE_PROFILE_USER_HANDLE); - - setupResolverControllers(resolvedComponentInfos); - when(sOverrides.resolverListController.getLastChosen()) - .thenReturn(resolvedComponentInfos.get(0).getResolveInfoAt(0)); - - final ResolverWrapperActivity activity = mActivityRule.launchActivity(sendIntent); - Espresso.registerIdlingResources(activity.getLabelIdlingResource()); - waitForIdle(); - - assertThat(activity.getAdapter().hasFilteredItem(), is(false)); - assertThat(activity.getAdapter().getCount(), is(2)); - assertThat(activity.getAdapter().getPlaceholderCount(), is(2)); - } - - @Test - public void testClonedProfilePresent_alwaysButtonDisabled() throws Exception { - // enable cloneProfile - markOtherProfileAvailability(/* workAvailable= */ false, /* cloneAvailable= */ true); - Intent sendIntent = createSendImageIntent(); - List resolvedComponentInfos = - createResolvedComponentsWithCloneProfileForTest( - 3, - PERSONAL_USER_HANDLE, - CLONE_PROFILE_USER_HANDLE); - - setupResolverControllers(resolvedComponentInfos); - when(sOverrides.resolverListController.getLastChosen()) - .thenReturn(resolvedComponentInfos.get(0).getResolveInfoAt(0)); - - final ResolverWrapperActivity activity = mActivityRule.launchActivity(sendIntent); - waitForIdle(); - - // Confirm that the button bar is disabled by default - onView(withId(com.android.internal.R.id.button_once)).check(matches(not(isEnabled()))); - onView(withId(com.android.internal.R.id.button_always)).check(matches(not(isEnabled()))); - - // Make a stable copy of the components as the original list may be modified - List stableCopy = - createResolvedComponentsForTestWithOtherProfile(2, PERSONAL_USER_HANDLE); - - onView(withText(stableCopy.get(1).getResolveInfoAt(0).activityInfo.name)) - .perform(click()); - - onView(withId(com.android.internal.R.id.button_once)).check(matches(isEnabled())); - onView(withId(com.android.internal.R.id.button_always)).check(matches(not(isEnabled()))); - } - - @Test - public void testClonedProfilePresent_personalProfileActivityIsStartedInCorrectUser() - throws Exception { - // enable cloneProfile - markOtherProfileAvailability(/* workAvailable= */ true, /* cloneAvailable= */ true); - - List personalResolvedComponentInfos = - createResolvedComponentsWithCloneProfileForTest( - 3, - PERSONAL_USER_HANDLE, - CLONE_PROFILE_USER_HANDLE); - List workResolvedComponentInfos = - createResolvedComponentsForTest(3, WORK_PROFILE_USER_HANDLE); - sOverrides.hasCrossProfileIntents = false; - setupResolverControllers(personalResolvedComponentInfos, workResolvedComponentInfos); - Intent sendIntent = createSendImageIntent(); - sendIntent.setType("TestType"); - final UserHandle[] selectedActivityUserHandle = new UserHandle[1]; - sOverrides.onSafelyStartInternalCallback = result -> { - selectedActivityUserHandle[0] = result.second; - return true; - }; - - final ResolverWrapperActivity activity = mActivityRule.launchActivity(sendIntent); - waitForIdle(); - onView(first(allOf(withText(personalResolvedComponentInfos.get(0) - .getResolveInfoAt(0).activityInfo.applicationInfo.name), isCompletelyDisplayed()))) - .perform(click()); - onView(withId(com.android.internal.R.id.button_once)) - .perform(click()); - waitForIdle(); - - assertThat(selectedActivityUserHandle[0], is(activity.getAdapter().getUserHandle())); - } - - @Test - public void testClonedProfilePresent_workProfileActivityIsStartedInCorrectUser() - throws Exception { - // enable cloneProfile - markOtherProfileAvailability(/* workAvailable= */ true, /* cloneAvailable= */ true); - - List personalResolvedComponentInfos = - createResolvedComponentsWithCloneProfileForTest( - 3, - PERSONAL_USER_HANDLE, - CLONE_PROFILE_USER_HANDLE); - List workResolvedComponentInfos = - createResolvedComponentsForTest(3, WORK_PROFILE_USER_HANDLE); - setupResolverControllers(personalResolvedComponentInfos, workResolvedComponentInfos); - Intent sendIntent = createSendImageIntent(); - sendIntent.setType("TestType"); - final UserHandle[] selectedActivityUserHandle = new UserHandle[1]; - sOverrides.onSafelyStartInternalCallback = result -> { - selectedActivityUserHandle[0] = result.second; - return true; - }; - - final ResolverWrapperActivity activity = mActivityRule.launchActivity(sendIntent); - waitForIdle(); - onView(withText(R.string.resolver_work_tab)) - .perform(click()); - waitForIdle(); - onView(first(allOf(withText(workResolvedComponentInfos.get(0) - .getResolveInfoAt(0).activityInfo.applicationInfo.name), isCompletelyDisplayed()))) - .perform(click()); - onView(withId(com.android.internal.R.id.button_once)) - .perform(click()); - waitForIdle(); - - assertThat(selectedActivityUserHandle[0], is(activity.getAdapter().getUserHandle())); - } - - @Test - public void testClonedProfilePresent_personalProfileResolverComparatorHasCorrectUsers() - throws Exception { - // enable cloneProfile - markOtherProfileAvailability(/* workAvailable= */ false, /* cloneAvailable= */ true); - List resolvedComponentInfos = - createResolvedComponentsWithCloneProfileForTest( - 3, - PERSONAL_USER_HANDLE, - CLONE_PROFILE_USER_HANDLE); - setupResolverControllers(resolvedComponentInfos); - Intent sendIntent = createSendImageIntent(); - - final ResolverWrapperActivity activity = mActivityRule.launchActivity(sendIntent); - waitForIdle(); - List result = activity - .getResolverRankerServiceUserHandleList(PERSONAL_USER_HANDLE); - - assertThat(result.containsAll( - Lists.newArrayList(PERSONAL_USER_HANDLE, CLONE_PROFILE_USER_HANDLE)), is(true)); - } - - private Intent createSendImageIntent() { - Intent sendIntent = new Intent(); - sendIntent.setAction(Intent.ACTION_SEND); - sendIntent.putExtra(Intent.EXTRA_TEXT, "testing intent sending"); - sendIntent.setType("image/jpeg"); - return sendIntent; - } - - private Intent createOpenWebsiteIntent() { - Intent sendIntent = new Intent(); - sendIntent.setAction(Intent.ACTION_VIEW); - sendIntent.setData(Uri.parse("https://google.com")); - return sendIntent; - } - - private List createResolvedComponentsForTest(int numberOfResults, - UserHandle resolvedForUser) { - List infoList = new ArrayList<>(numberOfResults); - for (int i = 0; i < numberOfResults; i++) { - infoList.add(ResolverDataProvider.createResolvedComponentInfo(i, resolvedForUser)); - } - return infoList; - } - - private List createResolvedComponentsWithCloneProfileForTest( - int numberOfResults, - UserHandle resolvedForPersonalUser, - UserHandle resolvedForClonedUser) { - List infoList = new ArrayList<>(numberOfResults); - for (int i = 0; i < 1; i++) { - infoList.add(ResolverDataProvider.createResolvedComponentInfo(i, - resolvedForPersonalUser)); - } - for (int i = 1; i < numberOfResults; i++) { - infoList.add(ResolverDataProvider.createResolvedComponentInfo(i, - resolvedForClonedUser)); - } - return infoList; - } - - private List createResolvedComponentsForTestWithOtherProfile( - int numberOfResults, - UserHandle resolvedForUser) { - List infoList = new ArrayList<>(numberOfResults); - for (int i = 0; i < numberOfResults; i++) { - if (i == 0) { - infoList.add(ResolverDataProvider.createResolvedComponentInfoWithOtherId(i, - resolvedForUser)); - } else { - infoList.add(ResolverDataProvider.createResolvedComponentInfo(i, resolvedForUser)); - } - } - return infoList; - } - - private List createResolvedComponentsForTestWithOtherProfile( - int numberOfResults, int userId, UserHandle resolvedForUser) { - List infoList = new ArrayList<>(numberOfResults); - for (int i = 0; i < numberOfResults; i++) { - if (i == 0) { - infoList.add( - ResolverDataProvider.createResolvedComponentInfoWithOtherId(i, userId, - resolvedForUser)); - } else { - infoList.add(ResolverDataProvider.createResolvedComponentInfo(i, resolvedForUser)); - } - } - return infoList; - } - - private void waitForIdle() { - InstrumentationRegistry.getInstrumentation().waitForIdleSync(); - } - - private void markOtherProfileAvailability(boolean workAvailable, boolean cloneAvailable) { - if (workAvailable) { - mFakeUserRepo.addUser( - new User(WORK_PROFILE_USER_HANDLE.getIdentifier(), User.Role.WORK), true); - } - if (cloneAvailable) { - mFakeUserRepo.addUser( - new User(CLONE_PROFILE_USER_HANDLE.getIdentifier(), User.Role.CLONE), true); - } - } - - private void setupResolverControllers( - List personalResolvedComponentInfos) { - setupResolverControllers(personalResolvedComponentInfos, new ArrayList<>()); - } - - private void setupResolverControllers( - List personalResolvedComponentInfos, - List workResolvedComponentInfos) { - when(sOverrides.resolverListController.getResolversForIntentAsUser( - Mockito.anyBoolean(), - Mockito.anyBoolean(), - Mockito.anyBoolean(), - Mockito.isA(List.class), - eq(PERSONAL_USER_HANDLE))) - .thenReturn(new ArrayList<>(personalResolvedComponentInfos)); - when(sOverrides.workResolverListController.getResolversForIntentAsUser( - Mockito.anyBoolean(), - Mockito.anyBoolean(), - Mockito.anyBoolean(), - Mockito.isA(List.class), - eq(WORK_PROFILE_USER_HANDLE))) - .thenReturn(new ArrayList<>(workResolvedComponentInfos)); - } -} diff --git a/tests/activity/src/com/android/intentresolver/v2/ResolverWrapperActivity.java b/tests/activity/src/com/android/intentresolver/v2/ResolverWrapperActivity.java deleted file mode 100644 index e3d2edbb..00000000 --- a/tests/activity/src/com/android/intentresolver/v2/ResolverWrapperActivity.java +++ /dev/null @@ -1,209 +0,0 @@ -/* - * Copyright (C) 2017 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.android.intentresolver.v2; - -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.ArgumentMatchers.anyInt; -import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.when; - -import android.annotation.Nullable; -import android.content.Context; -import android.content.Intent; -import android.content.pm.ResolveInfo; -import android.graphics.drawable.Drawable; -import android.os.Bundle; -import android.os.UserHandle; -import android.util.Pair; - -import androidx.annotation.NonNull; -import androidx.test.espresso.idling.CountingIdlingResource; - -import com.android.intentresolver.ResolverListAdapter; -import com.android.intentresolver.ResolverListController; -import com.android.intentresolver.chooser.DisplayResolveInfo; -import com.android.intentresolver.chooser.SelectableTargetInfo; -import com.android.intentresolver.chooser.TargetInfo; -import com.android.intentresolver.emptystate.CrossProfileIntentsChecker; -import com.android.intentresolver.icons.LabelInfo; -import com.android.intentresolver.icons.TargetDataLoader; - -import java.util.List; -import java.util.function.Consumer; -import java.util.function.Function; - -/* - * Simple wrapper around chooser activity to be able to initiate it under test - */ -public class ResolverWrapperActivity extends ResolverActivity { - static final OverrideData sOverrides = new OverrideData(); - - private final CountingIdlingResource mLabelIdlingResource = - new CountingIdlingResource("LoadLabelTask"); - - public CountingIdlingResource getLabelIdlingResource() { - return mLabelIdlingResource; - } - - @Override - public ResolverListAdapter createResolverListAdapter( - Context context, - List payloadIntents, - Intent[] initialIntents, - List rList, - boolean filterLastUsed, - UserHandle userHandle) { - return new ResolverListAdapter( - context, - payloadIntents, - initialIntents, - rList, - filterLastUsed, - createListController(userHandle), - userHandle, - payloadIntents.get(0), // TODO: extract upstream - this, - userHandle, - new TargetDataLoaderWrapper(mTargetDataLoader, mLabelIdlingResource)); - } - - @Override - protected CrossProfileIntentsChecker createCrossProfileIntentsChecker() { - if (sOverrides.mCrossProfileIntentsChecker != null) { - return sOverrides.mCrossProfileIntentsChecker; - } - return super.createCrossProfileIntentsChecker(); - } - - ResolverListAdapter getAdapter() { - return mMultiProfilePagerAdapter.getActiveListAdapter(); - } - - ResolverListAdapter getPersonalListAdapter() { - return mMultiProfilePagerAdapter.getPersonalListAdapter(); - } - - ResolverListAdapter getWorkListAdapter() { - return mMultiProfilePagerAdapter.getWorkListAdapter(); - } - - @Override - public boolean isVoiceInteraction() { - if (sOverrides.isVoiceInteraction != null) { - return sOverrides.isVoiceInteraction; - } - return super.isVoiceInteraction(); - } - - @Override - public void safelyStartActivityInternal(TargetInfo cti, UserHandle user, - @Nullable Bundle options) { - if (sOverrides.onSafelyStartInternalCallback != null - && sOverrides.onSafelyStartInternalCallback.apply(new Pair<>(cti, user))) { - return; - } - super.safelyStartActivityInternal(cti, user, options); - } - - @Override - protected ResolverListController createListController(UserHandle userHandle) { - if (userHandle == UserHandle.SYSTEM) { - return sOverrides.resolverListController; - } - return sOverrides.workResolverListController; - } - - protected UserHandle getCurrentUserHandle() { - return mMultiProfilePagerAdapter.getCurrentUserHandle(); - } - - @Override - public void startActivityAsUser(Intent intent, Bundle options, UserHandle user) { - super.startActivityAsUser(intent, options, user); - } - - /** - * We cannot directly mock the activity created since instrumentation creates it. - *

- * Instead, we use static instances of this object to modify behavior. - */ - public static class OverrideData { - @SuppressWarnings("Since15") - public Function, Boolean> onSafelyStartInternalCallback; - public ResolverListController resolverListController; - public ResolverListController workResolverListController; - public Boolean isVoiceInteraction; - public boolean hasCrossProfileIntents; - public CrossProfileIntentsChecker mCrossProfileIntentsChecker; - - public void reset() { - onSafelyStartInternalCallback = null; - isVoiceInteraction = null; - resolverListController = mock(ResolverListController.class); - workResolverListController = mock(ResolverListController.class); - hasCrossProfileIntents = true; - mCrossProfileIntentsChecker = mock(CrossProfileIntentsChecker.class); - when(mCrossProfileIntentsChecker.hasCrossProfileIntents(any(), anyInt(), anyInt())) - .thenAnswer(invocation -> hasCrossProfileIntents); - } - } - - private static class TargetDataLoaderWrapper extends TargetDataLoader { - private final TargetDataLoader mTargetDataLoader; - private final CountingIdlingResource mLabelIdlingResource; - - private TargetDataLoaderWrapper( - TargetDataLoader targetDataLoader, CountingIdlingResource labelIdlingResource) { - mTargetDataLoader = targetDataLoader; - mLabelIdlingResource = labelIdlingResource; - } - - @Override - public void loadAppTargetIcon( - @NonNull DisplayResolveInfo info, - @NonNull UserHandle userHandle, - @NonNull Consumer callback) { - mTargetDataLoader.loadAppTargetIcon(info, userHandle, callback); - } - - @Override - public void loadDirectShareIcon( - @NonNull SelectableTargetInfo info, - @NonNull UserHandle userHandle, - @NonNull Consumer callback) { - mTargetDataLoader.loadDirectShareIcon(info, userHandle, callback); - } - - @Override - public void loadLabel( - @NonNull DisplayResolveInfo info, - @NonNull Consumer callback) { - mLabelIdlingResource.increment(); - mTargetDataLoader.loadLabel( - info, - (result) -> { - mLabelIdlingResource.decrement(); - callback.accept(result); - }); - } - - @Override - public void getOrLoadLabel(@NonNull DisplayResolveInfo info) { - mTargetDataLoader.getOrLoadLabel(info); - } - } -} diff --git a/tests/activity/src/com/android/intentresolver/v2/UnbundledChooserActivityTest.java b/tests/activity/src/com/android/intentresolver/v2/UnbundledChooserActivityTest.java deleted file mode 100644 index 7848983e..00000000 --- a/tests/activity/src/com/android/intentresolver/v2/UnbundledChooserActivityTest.java +++ /dev/null @@ -1,3143 +0,0 @@ -/* - * Copyright (C) 2016 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.android.intentresolver.v2; - -import static android.app.Activity.RESULT_OK; - -import static androidx.test.espresso.Espresso.onView; -import static androidx.test.espresso.action.ViewActions.click; -import static androidx.test.espresso.action.ViewActions.longClick; -import static androidx.test.espresso.action.ViewActions.swipeUp; -import static androidx.test.espresso.assertion.ViewAssertions.doesNotExist; -import static androidx.test.espresso.assertion.ViewAssertions.matches; -import static androidx.test.espresso.matcher.ViewMatchers.hasSibling; -import static androidx.test.espresso.matcher.ViewMatchers.isCompletelyDisplayed; -import static androidx.test.espresso.matcher.ViewMatchers.isDisplayed; -import static androidx.test.espresso.matcher.ViewMatchers.withEffectiveVisibility; -import static androidx.test.espresso.matcher.ViewMatchers.withId; -import static androidx.test.espresso.matcher.ViewMatchers.withText; - -import static com.android.intentresolver.ChooserActivity.TARGET_TYPE_CHOOSER_TARGET; -import static com.android.intentresolver.ChooserActivity.TARGET_TYPE_DEFAULT; -import static com.android.intentresolver.ChooserActivity.TARGET_TYPE_SHORTCUTS_FROM_PREDICTION_SERVICE; -import static com.android.intentresolver.ChooserActivity.TARGET_TYPE_SHORTCUTS_FROM_SHORTCUT_MANAGER; -import static com.android.intentresolver.ChooserListAdapter.CALLER_TARGET_SCORE_BOOST; -import static com.android.intentresolver.ChooserListAdapter.SHORTCUT_TARGET_SCORE_BOOST; -import static com.android.intentresolver.MatcherUtils.first; - -import static com.google.common.truth.Truth.assertThat; -import static com.google.common.truth.Truth.assertWithMessage; - -import static junit.framework.Assert.assertNull; - -import static org.hamcrest.CoreMatchers.allOf; -import static org.hamcrest.CoreMatchers.is; -import static org.hamcrest.CoreMatchers.not; -import static org.hamcrest.MatcherAssert.assertThat; -import static org.junit.Assert.assertEquals; -import static org.junit.Assert.assertTrue; -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.ArgumentMatchers.anyInt; -import static org.mockito.ArgumentMatchers.eq; -import static org.mockito.Mockito.doReturn; -import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.times; -import static org.mockito.Mockito.verify; -import static org.mockito.Mockito.when; - -import android.app.PendingIntent; -import android.app.usage.UsageStatsManager; -import android.content.BroadcastReceiver; -import android.content.ClipData; -import android.content.ClipDescription; -import android.content.ClipboardManager; -import android.content.ComponentName; -import android.content.Context; -import android.content.Intent; -import android.content.IntentFilter; -import android.content.pm.ActivityInfo; -import android.content.pm.ApplicationInfo; -import android.content.pm.PackageManager; -import android.content.pm.ResolveInfo; -import android.content.pm.ShortcutInfo; -import android.content.pm.ShortcutManager.ShareShortcutInfo; -import android.content.res.Configuration; -import android.content.res.Resources; -import android.database.Cursor; -import android.graphics.Bitmap; -import android.graphics.Canvas; -import android.graphics.Color; -import android.graphics.Paint; -import android.graphics.Rect; -import android.graphics.Typeface; -import android.graphics.drawable.Icon; -import android.net.Uri; -import android.os.Bundle; -import android.os.UserHandle; -import android.platform.test.annotations.RequiresFlagsEnabled; -import android.platform.test.flag.junit.CheckFlagsRule; -import android.platform.test.flag.junit.DeviceFlagsValueProvider; -import android.provider.DeviceConfig; -import android.service.chooser.ChooserAction; -import android.service.chooser.ChooserTarget; -import android.text.Spannable; -import android.text.SpannableStringBuilder; -import android.text.Spanned; -import android.text.style.BackgroundColorSpan; -import android.text.style.ForegroundColorSpan; -import android.text.style.StyleSpan; -import android.text.style.UnderlineSpan; -import android.util.Pair; -import android.util.SparseArray; -import android.view.View; -import android.view.WindowManager; -import android.widget.TextView; - -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; -import androidx.recyclerview.widget.GridLayoutManager; -import androidx.recyclerview.widget.RecyclerView; -import androidx.test.espresso.contrib.RecyclerViewActions; -import androidx.test.espresso.matcher.BoundedDiagnosingMatcher; -import androidx.test.espresso.matcher.ViewMatchers; -import androidx.test.platform.app.InstrumentationRegistry; -import androidx.test.rule.ActivityTestRule; - -import com.android.intentresolver.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.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 dagger.hilt.android.qualifiers.ApplicationContext; -import dagger.hilt.android.testing.BindValue; -import dagger.hilt.android.testing.HiltAndroidRule; -import dagger.hilt.android.testing.HiltAndroidTest; -import dagger.hilt.android.testing.UninstallModules; - -import org.hamcrest.Description; -import org.hamcrest.Matcher; -import org.hamcrest.Matchers; -import org.junit.Before; -import org.junit.Ignore; -import org.junit.Rule; -import org.junit.Test; -import org.junit.runner.RunWith; -import org.junit.runners.Parameterized; -import org.junit.runners.Parameterized.Parameters; -import org.mockito.ArgumentCaptor; -import org.mockito.Mockito; - -import java.util.ArrayList; -import java.util.Arrays; -import java.util.HashMap; -import java.util.List; -import java.util.Map; -import java.util.Optional; -import java.util.concurrent.CountDownLatch; -import java.util.concurrent.TimeUnit; -import java.util.concurrent.atomic.AtomicReference; -import java.util.function.Consumer; - -import javax.inject.Inject; - -/** - * Instrumentation tests for ChooserActivity. - *

- * Legacy test suite migrated from framework CoreTests. - */ -@SuppressWarnings("OptionalUsedAsFieldOrParameterType") -@RunWith(Parameterized.class) -@HiltAndroidTest -@UninstallModules({ - AppPredictionModule.class, - ImageEditorModule.class, - PackageManagerModule.class, - ImageLoaderModule.class, - UserRepositoryModule.class, -}) -public class UnbundledChooserActivityTest { - - private static FakeEventLog getEventLog(ChooserWrapperActivity activity) { - return (FakeEventLog) activity.mEventLog; - } - - 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( - /* appPredictionAvailable = */ true, - /* appPredictionAvailable = */ false - ); - } - - private static final String TEST_MIME_TYPE = "application/TestType"; - - private static final int CONTENT_PREVIEW_IMAGE = 1; - private static final int CONTENT_PREVIEW_FILE = 2; - private static final int CONTENT_PREVIEW_TEXT = 3; - - @Rule(order = 0) - public CheckFlagsRule mCheckFlagsRule = DeviceFlagsValueProvider.createCheckFlagsRule(); - - @Rule(order = 1) - public HiltAndroidRule mHiltAndroidRule = new HiltAndroidRule(this); - - @Rule(order = 2) - public ActivityTestRule mActivityRule = - new ActivityTestRule<>(ChooserWrapperActivity.class, false, false); - - @Inject - @ApplicationContext - Context mContext; - - /** An arbitrary pre-installed activity that handles this type of intent. */ - @BindValue - @ImageEditor - final Optional mImageEditor = Optional.ofNullable( - ComponentName.unflattenFromString("com.google.android.apps.messaging/" - + ".ui.conversationlist.ShareIntentActivity")); - - /** Whether an AppPredictionService is available for use. */ - @BindValue - @AppPredictionAvailable - final boolean mAppPredictionAvailable; - - @BindValue - PackageManager mPackageManager; - - /** "launchedAs" */ - @BindValue - @ApplicationUser - UserHandle mApplicationUser = PERSONAL_USER_HANDLE; - - @BindValue - @ProfileParent - UserHandle mProfileParent = PERSONAL_USER_HANDLE; - - private final FakeUserRepository mFakeUserRepo = new FakeUserRepository(List.of(PERSONAL_USER)); - - @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 - // permissions we require (which we'll read from the manifest at runtime). - InstrumentationRegistry - .getInstrumentation() - .getUiAutomation() - .adoptShellPermissionIdentity(); - - cleanOverrideData(); - - // Assign @Inject fields - mHiltAndroidRule.inject(); - - // Populate @BindValue dependencies using injected values. These fields contribute - // values to the dependency graph at activity launch time. This allows replacing - // arbitrary bindings per-test case if needed. - mPackageManager = mContext.getPackageManager(); - - // TODO: inject image loader in the prod code and remove this override - ChooserActivityOverrideData.getInstance().imageLoader = mFakeImageLoader; - } - - public UnbundledChooserActivityTest(boolean appPredictionAvailable) { - mAppPredictionAvailable = appPredictionAvailable; - } - - private void setDeviceConfigProperty( - @NonNull String propertyName, - @NonNull String value) { - // TODO: consider running with {@link #runWithShellPermissionIdentity()} to more narrowly - // request WRITE_DEVICE_CONFIG permissions if we get rid of the broad grant we currently - // configure in {@link #setup()}. - // TODO: is it really appropriate that this is always set with makeDefault=true? - boolean valueWasSet = DeviceConfig.setProperty( - DeviceConfig.NAMESPACE_SYSTEMUI, - propertyName, - value, - true /* makeDefault */); - if (!valueWasSet) { - throw new IllegalStateException( - "Could not set " + propertyName + " to " + value); - } - } - - public void cleanOverrideData() { - ChooserActivityOverrideData.getInstance().reset(); - - setDeviceConfigProperty( - SystemUiDeviceConfigFlags.APPLY_SHARING_APP_LIMITS_IN_SYSUI, - Boolean.toString(true)); - } - - private static PackageManager createFakePackageManager(ResolveInfo resolveInfo) { - PackageManager packageManager = mock(PackageManager.class); - when(packageManager.resolveActivity(any(Intent.class), any())).thenReturn(resolveInfo); - return packageManager; - } - - @Test - public void customTitle() throws InterruptedException { - Intent viewIntent = createViewTextIntent(); - List resolvedComponentInfos = createResolvedComponentsForTest(2); - - setupResolverControllers(resolvedComponentInfos); - final IChooserWrapper activity = (IChooserWrapper) mActivityRule.launchActivity( - Intent.createChooser(viewIntent, "chooser test")); - - waitForIdle(); - assertThat(activity.getAdapter().getCount(), is(2)); - assertThat(activity.getAdapter().getServiceTargetCount(), is(0)); - onView(withId(android.R.id.title)).check(matches(withText("chooser test"))); - } - - @Test - public void customTitleIgnoredForSendIntents() throws InterruptedException { - Intent sendIntent = createSendTextIntent(); - List resolvedComponentInfos = createResolvedComponentsForTest(2); - - setupResolverControllers(resolvedComponentInfos); - mActivityRule.launchActivity(Intent.createChooser(sendIntent, "chooser test")); - waitForIdle(); - onView(withId(android.R.id.title)) - .check(matches(withText(R.string.whichSendApplication))); - } - - @Test - public void emptyTitle() throws InterruptedException { - Intent sendIntent = createSendTextIntent(); - List resolvedComponentInfos = createResolvedComponentsForTest(2); - - setupResolverControllers(resolvedComponentInfos); - mActivityRule.launchActivity(Intent.createChooser(sendIntent, null)); - waitForIdle(); - onView(withId(android.R.id.title)) - .check(matches(withText(R.string.whichSendApplication))); - } - - @Test - public void test_shareRichTextWithRichTitle_richTextAndRichTitleDisplayed() { - CharSequence title = new SpannableStringBuilder() - .append("Rich", new UnderlineSpan(), Spannable.SPAN_INCLUSIVE_EXCLUSIVE) - .append( - "Title", - new ForegroundColorSpan(Color.RED), - Spannable.SPAN_INCLUSIVE_EXCLUSIVE); - CharSequence sharedText = new SpannableStringBuilder() - .append( - "Rich", - new BackgroundColorSpan(Color.YELLOW), - Spanned.SPAN_INCLUSIVE_EXCLUSIVE) - .append( - "Text", - new StyleSpan(Typeface.ITALIC), - Spanned.SPAN_INCLUSIVE_EXCLUSIVE); - Intent sendIntent = createSendTextIntent(); - sendIntent.putExtra(Intent.EXTRA_TEXT, sharedText); - sendIntent.putExtra(Intent.EXTRA_TITLE, title); - List resolvedComponentInfos = createResolvedComponentsForTest(2); - setupResolverControllers(resolvedComponentInfos); - - mActivityRule.launchActivity(Intent.createChooser(sendIntent, null)); - waitForIdle(); - - onView(withId(com.android.internal.R.id.content_preview_title)) - .check((view, e) -> { - assertThat(view).isInstanceOf(TextView.class); - CharSequence text = ((TextView) view).getText(); - assertThat(text).isInstanceOf(Spanned.class); - Spanned spanned = (Spanned) text; - assertThat(spanned.getSpans(0, spanned.length(), Object.class)) - .hasLength(2); - assertThat(spanned.getSpans(0, 4, UnderlineSpan.class)).hasLength(1); - assertThat(spanned.getSpans(4, spanned.length(), ForegroundColorSpan.class)) - .hasLength(1); - }); - - onView(withId(com.android.internal.R.id.content_preview_text)) - .check((view, e) -> { - assertThat(view).isInstanceOf(TextView.class); - CharSequence text = ((TextView) view).getText(); - assertThat(text).isInstanceOf(Spanned.class); - Spanned spanned = (Spanned) text; - assertThat(spanned.getSpans(0, spanned.length(), Object.class)) - .hasLength(2); - assertThat(spanned.getSpans(0, 4, BackgroundColorSpan.class)).hasLength(1); - assertThat(spanned.getSpans(4, spanned.length(), StyleSpan.class)).hasLength(1); - }); - } - - @Test - public void emptyPreviewTitleAndThumbnail() throws InterruptedException { - Intent sendIntent = createSendTextIntentWithPreview(null, null); - List resolvedComponentInfos = createResolvedComponentsForTest(2); - - setupResolverControllers(resolvedComponentInfos); - mActivityRule.launchActivity(Intent.createChooser(sendIntent, null)); - waitForIdle(); - onView(withId(com.android.internal.R.id.content_preview_title)) - .check(matches(not(isDisplayed()))); - onView(withId(com.android.internal.R.id.content_preview_thumbnail)) - .check(matches(not(isDisplayed()))); - } - - @Test - public void visiblePreviewTitleWithoutThumbnail() throws InterruptedException { - String previewTitle = "My Content Preview Title"; - Intent sendIntent = createSendTextIntentWithPreview(previewTitle, null); - List resolvedComponentInfos = createResolvedComponentsForTest(2); - - setupResolverControllers(resolvedComponentInfos); - mActivityRule.launchActivity(Intent.createChooser(sendIntent, null)); - waitForIdle(); - onView(withId(com.android.internal.R.id.content_preview_title)) - .check(matches(isDisplayed())); - onView(withId(com.android.internal.R.id.content_preview_title)) - .check(matches(withText(previewTitle))); - onView(withId(com.android.internal.R.id.content_preview_thumbnail)) - .check(matches(not(isDisplayed()))); - } - - @Test - public void visiblePreviewTitleWithInvalidThumbnail() throws InterruptedException { - String previewTitle = "My Content Preview Title"; - Intent sendIntent = createSendTextIntentWithPreview(previewTitle, - Uri.parse("tel:(+49)12345789")); - List resolvedComponentInfos = createResolvedComponentsForTest(2); - - setupResolverControllers(resolvedComponentInfos); - mActivityRule.launchActivity(Intent.createChooser(sendIntent, null)); - waitForIdle(); - onView(withId(com.android.internal.R.id.content_preview_title)) - .check(matches(isDisplayed())); - onView(withId(com.android.internal.R.id.content_preview_thumbnail)) - .check(matches(not(isDisplayed()))); - } - - @Test - public void visiblePreviewTitleAndThumbnail() { - 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); - mFakeImageLoader.setBitmap(uri, createBitmap()); - List resolvedComponentInfos = createResolvedComponentsForTest(2); - - setupResolverControllers(resolvedComponentInfos); - mActivityRule.launchActivity(Intent.createChooser(sendIntent, null)); - waitForIdle(); - onView(withId(com.android.internal.R.id.content_preview_title)) - .check(matches(isDisplayed())); - onView(withId(com.android.internal.R.id.content_preview_thumbnail)) - .check(matches(isDisplayed())); - } - - @Test @Ignore - public void twoOptionsAndUserSelectsOne() throws InterruptedException { - Intent sendIntent = createSendTextIntent(); - List resolvedComponentInfos = createResolvedComponentsForTest(2); - - setupResolverControllers(resolvedComponentInfos); - - final IChooserWrapper activity = (IChooserWrapper) - mActivityRule.launchActivity(Intent.createChooser(sendIntent, null)); - waitForIdle(); - - assertThat(activity.getAdapter().getCount(), is(2)); - onView(withId(com.android.internal.R.id.profile_button)).check(doesNotExist()); - - ResolveInfo[] chosen = new ResolveInfo[1]; - ChooserActivityOverrideData.getInstance().onSafelyStartInternalCallback = targetInfo -> { - chosen[0] = targetInfo.getResolveInfo(); - return true; - }; - - ResolveInfo toChoose = resolvedComponentInfos.get(0).getResolveInfoAt(0); - onView(withText(toChoose.activityInfo.name)) - .perform(click()); - waitForIdle(); - assertThat(chosen[0], is(toChoose)); - } - - @Test @Ignore - public void fourOptionsStackedIntoOneTarget() throws InterruptedException { - Intent sendIntent = createSendTextIntent(); - - // create just enough targets to ensure the a-z list should be shown - List resolvedComponentInfos = createResolvedComponentsForTest(1); - - // next create 4 targets in a single app that should be stacked into a single target - String packageName = "xxx.yyy"; - String appName = "aaa"; - ComponentName cn = new ComponentName(packageName, appName); - Intent intent = new Intent("fakeIntent"); - List infosToStack = new ArrayList<>(); - for (int i = 0; i < 4; i++) { - ResolveInfo resolveInfo = ResolverDataProvider.createResolveInfo(i, - UserHandle.USER_CURRENT, PERSONAL_USER_HANDLE); - resolveInfo.activityInfo.applicationInfo.name = appName; - resolveInfo.activityInfo.applicationInfo.packageName = packageName; - resolveInfo.activityInfo.packageName = packageName; - resolveInfo.activityInfo.name = "ccc" + i; - infosToStack.add(new ResolvedComponentInfo(cn, intent, resolveInfo)); - } - resolvedComponentInfos.addAll(infosToStack); - - setupResolverControllers(resolvedComponentInfos); - - final IChooserWrapper activity = (IChooserWrapper) - mActivityRule.launchActivity(Intent.createChooser(sendIntent, null)); - waitForIdle(); - - // expect 1 unique targets + 1 group + 4 ranked app targets - assertThat(activity.getAdapter().getCount(), is(6)); - - ResolveInfo[] chosen = new ResolveInfo[1]; - ChooserActivityOverrideData.getInstance().onSafelyStartInternalCallback = targetInfo -> { - chosen[0] = targetInfo.getResolveInfo(); - return true; - }; - - onView(allOf(withText(appName), hasSibling(withText("")))).perform(click()); - waitForIdle(); - - // clicking will launch a dialog to choose the activity within the app - onView(withText(appName)).check(matches(isDisplayed())); - int i = 0; - for (ResolvedComponentInfo rci: infosToStack) { - onView(withText("ccc" + i)).check(matches(isDisplayed())); - ++i; - } - } - - @Test @Ignore - public void updateChooserCountsAndModelAfterUserSelection() throws InterruptedException { - Intent sendIntent = createSendTextIntent(); - List resolvedComponentInfos = createResolvedComponentsForTest(2); - - setupResolverControllers(resolvedComponentInfos); - - final IChooserWrapper activity = (IChooserWrapper) - mActivityRule.launchActivity(Intent.createChooser(sendIntent, null)); - waitForIdle(); - UsageStatsManager usm = activity.getUsageStatsManager(); - verify(ChooserActivityOverrideData.getInstance().resolverListController, times(1)) - .topK(any(List.class), anyInt()); - assertThat(activity.getIsSelected(), is(false)); - ChooserActivityOverrideData.getInstance().onSafelyStartInternalCallback = targetInfo -> { - return true; - }; - ResolveInfo toChoose = resolvedComponentInfos.get(0).getResolveInfoAt(0); - DisplayResolveInfo testDri = - activity.createTestDisplayResolveInfo( - sendIntent, toChoose, "testLabel", "testInfo", sendIntent); - onView(withText(toChoose.activityInfo.name)) - .perform(click()); - waitForIdle(); - verify(ChooserActivityOverrideData.getInstance().resolverListController, times(1)) - .updateChooserCounts(Mockito.anyString(), any(UserHandle.class), - Mockito.anyString()); - verify(ChooserActivityOverrideData.getInstance().resolverListController, times(1)) - .updateModel(testDri); - assertThat(activity.getIsSelected(), is(true)); - } - - @Ignore // b/148158199 - @Test - public void noResultsFromPackageManager() { - setupResolverControllers(null); - Intent sendIntent = createSendTextIntent(); - final ChooserActivity activity = - mActivityRule.launchActivity(Intent.createChooser(sendIntent, null)); - final IChooserWrapper wrapper = (IChooserWrapper) activity; - - waitForIdle(); - assertThat(activity.isFinishing(), is(false)); - - onView(withId(android.R.id.empty)).check(matches(isDisplayed())); - onView(withId(com.android.internal.R.id.profile_pager)).check(matches(not(isDisplayed()))); - InstrumentationRegistry.getInstrumentation().runOnMainSync( - () -> wrapper.getAdapter().handlePackagesChanged() - ); - // backward compatibility. looks like we finish when data is empty after package change - assertThat(activity.isFinishing(), is(true)); - } - - @Test - public void autoLaunchSingleResult() throws InterruptedException { - ResolveInfo[] chosen = new ResolveInfo[1]; - ChooserActivityOverrideData.getInstance().onSafelyStartInternalCallback = targetInfo -> { - chosen[0] = targetInfo.getResolveInfo(); - return true; - }; - - List resolvedComponentInfos = createResolvedComponentsForTest(1); - setupResolverControllers(resolvedComponentInfos); - - Intent sendIntent = createSendTextIntent(); - final ChooserActivity activity = - mActivityRule.launchActivity(Intent.createChooser(sendIntent, null)); - waitForIdle(); - - assertThat(chosen[0], is(resolvedComponentInfos.get(0).getResolveInfoAt(0))); - assertThat(activity.isFinishing(), is(true)); - } - - @Test @Ignore - public void hasOtherProfileOneOption() { - List personalResolvedComponentInfos = - createResolvedComponentsForTestWithOtherProfile(2, /* userId */ 10); - List workResolvedComponentInfos = createResolvedComponentsForTest(4); - setupResolverControllers(personalResolvedComponentInfos, workResolvedComponentInfos); - markOtherProfileAvailability(/* workAvailable= */ true, /* cloneAvailable= */ false); - - ResolveInfo toChoose = personalResolvedComponentInfos.get(1).getResolveInfoAt(0); - Intent sendIntent = createSendTextIntent(); - final IChooserWrapper activity = (IChooserWrapper) - mActivityRule.launchActivity(Intent.createChooser(sendIntent, null)); - waitForIdle(); - - // The other entry is filtered to the other profile slot - assertThat(activity.getAdapter().getCount(), is(1)); - - ResolveInfo[] chosen = new ResolveInfo[1]; - ChooserActivityOverrideData.getInstance().onSafelyStartInternalCallback = targetInfo -> { - chosen[0] = targetInfo.getResolveInfo(); - return true; - }; - - // Make a stable copy of the components as the original list may be modified - List stableCopy = - createResolvedComponentsForTestWithOtherProfile(2, /* userId= */ 10); - waitForIdle(); - - onView(first(withText(stableCopy.get(1).getResolveInfoAt(0).activityInfo.name))) - .perform(click()); - waitForIdle(); - assertThat(chosen[0], is(toChoose)); - } - - @Test @Ignore - public void hasOtherProfileTwoOptionsAndUserSelectsOne() throws Exception { - Intent sendIntent = createSendTextIntent(); - List resolvedComponentInfos = - createResolvedComponentsForTestWithOtherProfile(3); - ResolveInfo toChoose = resolvedComponentInfos.get(1).getResolveInfoAt(0); - - setupResolverControllers(resolvedComponentInfos); - when(ChooserActivityOverrideData.getInstance().resolverListController.getLastChosen()) - .thenReturn(resolvedComponentInfos.get(0).getResolveInfoAt(0)); - - final IChooserWrapper activity = (IChooserWrapper) - mActivityRule.launchActivity(Intent.createChooser(sendIntent, null)); - waitForIdle(); - - // The other entry is filtered to the other profile slot - assertThat(activity.getAdapter().getCount(), is(2)); - - ResolveInfo[] chosen = new ResolveInfo[1]; - ChooserActivityOverrideData.getInstance().onSafelyStartInternalCallback = targetInfo -> { - chosen[0] = targetInfo.getResolveInfo(); - return true; - }; - - // Make a stable copy of the components as the original list may be modified - List stableCopy = - createResolvedComponentsForTestWithOtherProfile(3); - onView(withText(stableCopy.get(1).getResolveInfoAt(0).activityInfo.name)) - .perform(click()); - waitForIdle(); - assertThat(chosen[0], is(toChoose)); - } - - @Test @Ignore - public void hasLastChosenActivityAndOtherProfile() throws Exception { - Intent sendIntent = createSendTextIntent(); - List resolvedComponentInfos = - createResolvedComponentsForTestWithOtherProfile(3); - ResolveInfo toChoose = resolvedComponentInfos.get(1).getResolveInfoAt(0); - - setupResolverControllers(resolvedComponentInfos); - - final IChooserWrapper activity = (IChooserWrapper) - mActivityRule.launchActivity(Intent.createChooser(sendIntent, null)); - waitForIdle(); - - // The other entry is filtered to the last used slot - assertThat(activity.getAdapter().getCount(), is(2)); - - ResolveInfo[] chosen = new ResolveInfo[1]; - ChooserActivityOverrideData.getInstance().onSafelyStartInternalCallback = targetInfo -> { - chosen[0] = targetInfo.getResolveInfo(); - return true; - }; - - // Make a stable copy of the components as the original list may be modified - List stableCopy = - createResolvedComponentsForTestWithOtherProfile(3); - onView(withText(stableCopy.get(1).getResolveInfoAt(0).activityInfo.name)) - .perform(click()); - waitForIdle(); - assertThat(chosen[0], is(toChoose)); - } - - @Test - @Ignore("b/285309527") - public void testFilePlusTextSharing_ExcludeText() { - Uri uri = createTestContentProviderUri(null, "image/png"); - Intent sendIntent = createSendImageIntent(uri); - mFakeImageLoader.setBitmap(uri, createBitmap()); - sendIntent.putExtra(Intent.EXTRA_TEXT, "https://google.com/search?q=google"); - - List resolvedComponentInfos = Arrays.asList( - ResolverDataProvider.createResolvedComponentInfo( - new ComponentName("org.imageviewer", "ImageTarget"), - sendIntent, PERSONAL_USER_HANDLE), - ResolverDataProvider.createResolvedComponentInfo( - new ComponentName("org.textviewer", "UriTarget"), - new Intent("VIEW_TEXT"), PERSONAL_USER_HANDLE) - ); - - setupResolverControllers(resolvedComponentInfos); - - mActivityRule.launchActivity(Intent.createChooser(sendIntent, null)); - waitForIdle(); - - onView(withId(R.id.include_text_action)) - .check(matches(isDisplayed())) - .perform(click()); - waitForIdle(); - - onView(withId(R.id.content_preview_text)).check(matches(withText("File only"))); - - AtomicReference launchedIntentRef = new AtomicReference<>(); - ChooserActivityOverrideData.getInstance().onSafelyStartInternalCallback = targetInfo -> { - launchedIntentRef.set(targetInfo.getTargetIntent()); - return true; - }; - - onView(withText(resolvedComponentInfos.get(0).getResolveInfoAt(0).activityInfo.name)) - .perform(click()); - waitForIdle(); - assertThat(launchedIntentRef.get().hasExtra(Intent.EXTRA_TEXT)).isFalse(); - } - - @Test - @Ignore("b/285309527") - public void testFilePlusTextSharing_RemoveAndAddBackText() { - Uri uri = createTestContentProviderUri("application/pdf", "image/png"); - Intent sendIntent = createSendImageIntent(uri); - mFakeImageLoader.setBitmap(uri, createBitmap()); - final String text = "https://google.com/search?q=google"; - sendIntent.putExtra(Intent.EXTRA_TEXT, text); - - List resolvedComponentInfos = Arrays.asList( - ResolverDataProvider.createResolvedComponentInfo( - new ComponentName("org.imageviewer", "ImageTarget"), - sendIntent, PERSONAL_USER_HANDLE), - ResolverDataProvider.createResolvedComponentInfo( - new ComponentName("org.textviewer", "UriTarget"), - new Intent("VIEW_TEXT"), PERSONAL_USER_HANDLE) - ); - - setupResolverControllers(resolvedComponentInfos); - - mActivityRule.launchActivity(Intent.createChooser(sendIntent, null)); - waitForIdle(); - - onView(withId(R.id.include_text_action)) - .check(matches(isDisplayed())) - .perform(click()); - waitForIdle(); - onView(withId(R.id.content_preview_text)).check(matches(withText("File only"))); - - onView(withId(R.id.include_text_action)) - .perform(click()); - waitForIdle(); - - onView(withId(R.id.content_preview_text)).check(matches(withText(text))); - - AtomicReference launchedIntentRef = new AtomicReference<>(); - ChooserActivityOverrideData.getInstance().onSafelyStartInternalCallback = targetInfo -> { - launchedIntentRef.set(targetInfo.getTargetIntent()); - return true; - }; - - onView(withText(resolvedComponentInfos.get(0).getResolveInfoAt(0).activityInfo.name)) - .perform(click()); - waitForIdle(); - assertThat(launchedIntentRef.get().getStringExtra(Intent.EXTRA_TEXT)).isEqualTo(text); - } - - @Test - @Ignore("b/285309527") - public void testFilePlusTextSharing_TextExclusionDoesNotAffectAlternativeIntent() { - Uri uri = createTestContentProviderUri("image/png", null); - Intent sendIntent = createSendImageIntent(uri); - mFakeImageLoader.setBitmap(uri, createBitmap()); - sendIntent.putExtra(Intent.EXTRA_TEXT, "https://google.com/search?q=google"); - - Intent alternativeIntent = createSendTextIntent(); - final String text = "alternative intent"; - alternativeIntent.putExtra(Intent.EXTRA_TEXT, text); - - List resolvedComponentInfos = Arrays.asList( - ResolverDataProvider.createResolvedComponentInfo( - new ComponentName("org.imageviewer", "ImageTarget"), - sendIntent, PERSONAL_USER_HANDLE), - ResolverDataProvider.createResolvedComponentInfo( - new ComponentName("org.textviewer", "UriTarget"), - alternativeIntent, PERSONAL_USER_HANDLE) - ); - - setupResolverControllers(resolvedComponentInfos); - - mActivityRule.launchActivity(Intent.createChooser(sendIntent, null)); - waitForIdle(); - - onView(withId(R.id.include_text_action)) - .check(matches(isDisplayed())) - .perform(click()); - waitForIdle(); - - AtomicReference launchedIntentRef = new AtomicReference<>(); - ChooserActivityOverrideData.getInstance().onSafelyStartInternalCallback = targetInfo -> { - launchedIntentRef.set(targetInfo.getTargetIntent()); - return true; - }; - - onView(withText(resolvedComponentInfos.get(1).getResolveInfoAt(0).activityInfo.name)) - .perform(click()); - waitForIdle(); - assertThat(launchedIntentRef.get().getStringExtra(Intent.EXTRA_TEXT)).isEqualTo(text); - } - - @Test - @Ignore("b/285309527") - public void testImagePlusTextSharing_failedThumbnailAndExcludedText_textChanges() { - Uri uri = createTestContentProviderUri("image/png", null); - Intent sendIntent = createSendImageIntent(uri); - sendIntent.putExtra(Intent.EXTRA_TEXT, "https://google.com/search?q=google"); - - List resolvedComponentInfos = Arrays.asList( - ResolverDataProvider.createResolvedComponentInfo( - new ComponentName("org.imageviewer", "ImageTarget"), - sendIntent, PERSONAL_USER_HANDLE), - ResolverDataProvider.createResolvedComponentInfo( - new ComponentName("org.textviewer", "UriTarget"), - new Intent("VIEW_TEXT"), PERSONAL_USER_HANDLE) - ); - - setupResolverControllers(resolvedComponentInfos); - - mActivityRule.launchActivity(Intent.createChooser(sendIntent, null)); - waitForIdle(); - - onView(withId(R.id.include_text_action)) - .check(matches(isDisplayed())) - .perform(click()); - waitForIdle(); - - onView(withId(R.id.image_view)) - .check(matches(withEffectiveVisibility(ViewMatchers.Visibility.GONE))); - onView(withId(R.id.content_preview_text)) - .check(matches(allOf(isDisplayed(), withText("Image only")))); - } - - @Test - public void copyTextToClipboard() { - Intent sendIntent = createSendTextIntent(); - List resolvedComponentInfos = createResolvedComponentsForTest(2); - - setupResolverControllers(resolvedComponentInfos); - - final ChooserActivity activity = - mActivityRule.launchActivity(Intent.createChooser(sendIntent, null)); - waitForIdle(); - - onView(withId(R.id.copy)).check(matches(isDisplayed())); - onView(withId(R.id.copy)).perform(click()); - ClipboardManager clipboard = (ClipboardManager) activity.getSystemService( - Context.CLIPBOARD_SERVICE); - ClipData clipData = clipboard.getPrimaryClip(); - assertThat(clipData).isNotNull(); - assertThat(clipData.getItemAt(0).getText()).isEqualTo("testing intent sending"); - - ClipDescription clipDescription = clipData.getDescription(); - assertThat("text/plain", is(clipDescription.getMimeType(0))); - - assertEquals(mActivityRule.getActivityResult().getResultCode(), RESULT_OK); - } - - @Test - public void copyTextToClipboardLogging() { - Intent sendIntent = createSendTextIntent(); - List resolvedComponentInfos = createResolvedComponentsForTest(2); - - setupResolverControllers(resolvedComponentInfos); - - ChooserWrapperActivity activity = - mActivityRule.launchActivity(Intent.createChooser(sendIntent, null)); - waitForIdle(); - - onView(withId(R.id.copy)).check(matches(isDisplayed())); - onView(withId(R.id.copy)).perform(click()); - FakeEventLog eventLog = getEventLog(activity); - assertThat(eventLog.getActionSelected()) - .isEqualTo(new FakeEventLog.ActionSelected( - /* targetType = */ EventLog.SELECTION_TYPE_COPY)); - } - - @Test - @Ignore - public void testNearbyShareLogging() throws Exception { - Intent sendIntent = createSendTextIntent(); - List resolvedComponentInfos = createResolvedComponentsForTest(2); - - setupResolverControllers(resolvedComponentInfos); - - mActivityRule.launchActivity(Intent.createChooser(sendIntent, null)); - waitForIdle(); - - onView(withId(com.android.internal.R.id.chooser_nearby_button)) - .check(matches(isDisplayed())); - onView(withId(com.android.internal.R.id.chooser_nearby_button)).perform(click()); - - // TODO(b/211669337): Determine the expected SHARESHEET_DIRECT_LOAD_COMPLETE events. - } - - @Test @Ignore - public void testEditImageLogs() { - - Uri uri = createTestContentProviderUri("image/png", null); - Intent sendIntent = createSendImageIntent(uri); - mFakeImageLoader.setBitmap(uri, createBitmap()); - - List resolvedComponentInfos = createResolvedComponentsForTest(2); - - setupResolverControllers(resolvedComponentInfos); - - mActivityRule.launchActivity(Intent.createChooser(sendIntent, null)); - waitForIdle(); - - onView(withId(com.android.internal.R.id.chooser_edit_button)).check(matches(isDisplayed())); - onView(withId(com.android.internal.R.id.chooser_edit_button)).perform(click()); - - // TODO(b/211669337): Determine the expected SHARESHEET_DIRECT_LOAD_COMPLETE events. - } - - - @Test - public void oneVisibleImagePreview() { - Uri uri = createTestContentProviderUri("image/png", null); - - ArrayList uris = new ArrayList<>(); - uris.add(uri); - - Intent sendIntent = createSendUriIntentWithPreview(uris); - mFakeImageLoader.setBitmap(uri, createWideBitmap()); - - List resolvedComponentInfos = createResolvedComponentsForTest(2); - - setupResolverControllers(resolvedComponentInfos); - mActivityRule.launchActivity(Intent.createChooser(sendIntent, null)); - waitForIdle(); - onView(withId(R.id.scrollable_image_preview)) - .check((view, exception) -> { - if (exception != null) { - throw exception; - } - RecyclerView recyclerView = (RecyclerView) view; - RecyclerViewExt.endAnimations(recyclerView); - assertThat("recyclerView adapter item count", - recyclerView.getAdapter().getItemCount(), is(1)); - assertThat("recyclerView child view count", - recyclerView.getChildCount(), is(1)); - View imageView = recyclerView.getChildAt(0); - Rect rect = new Rect(); - boolean isPartiallyVisible = imageView.getGlobalVisibleRect(rect); - assertThat( - "image preview view is not fully visible", - isPartiallyVisible - && rect.width() == imageView.getWidth() - && rect.height() == imageView.getHeight()); - }); - } - - @Test - public void allThumbnailsFailedToLoad_hidePreview() { - Uri uri = createTestContentProviderUri("image/jpg", null); - - ArrayList uris = new ArrayList<>(); - uris.add(uri); - uris.add(uri); - - Intent sendIntent = createSendUriIntentWithPreview(uris); - - List resolvedComponentInfos = createResolvedComponentsForTest(2); - - setupResolverControllers(resolvedComponentInfos); - mActivityRule.launchActivity(Intent.createChooser(sendIntent, null)); - waitForIdle(); - onView(withId(R.id.scrollable_image_preview)) - .check(matches(withEffectiveVisibility(ViewMatchers.Visibility.GONE))); - } - - @Test(timeout = 4_000) - public void testSlowUriMetadata_fallbackToFilePreview() { - Uri uri = createTestContentProviderUri( - "application/pdf", "image/png", /*streamTypeTimeout=*/8_000); - ArrayList uris = new ArrayList<>(1); - uris.add(uri); - Intent sendIntent = createSendUriIntentWithPreview(uris); - mFakeImageLoader.setBitmap(uri, createBitmap()); - - List resolvedComponentInfos = createResolvedComponentsForTest(2); - - setupResolverControllers(resolvedComponentInfos); - // The preview type resolution is expected to timeout and default to file preview, otherwise - // the test should timeout. - mActivityRule.launchActivity(Intent.createChooser(sendIntent, null)); - waitForIdle(); - - onView(withId(R.id.content_preview_filename)).check(matches(isDisplayed())); - onView(withId(R.id.content_preview_filename)).check(matches(withText("image.png"))); - onView(withId(R.id.content_preview_file_icon)).check(matches(isDisplayed())); - } - - @Test(timeout = 4_000) - public void testSendManyFilesWithSmallMetadataDelayAndOneImage_fallbackToFilePreviewUi() { - Uri fileUri = createTestContentProviderUri( - "application/pdf", "application/pdf", /*streamTypeTimeout=*/300); - Uri imageUri = createTestContentProviderUri("application/pdf", "image/png"); - ArrayList uris = new ArrayList<>(50); - for (int i = 0; i < 49; i++) { - uris.add(fileUri); - } - uris.add(imageUri); - Intent sendIntent = createSendUriIntentWithPreview(uris); - mFakeImageLoader.setBitmap(imageUri, createBitmap()); - - List resolvedComponentInfos = createResolvedComponentsForTest(2); - setupResolverControllers(resolvedComponentInfos); - // The preview type resolution is expected to timeout and default to file preview, otherwise - // the test should timeout. - mActivityRule.launchActivity(Intent.createChooser(sendIntent, null)); - - waitForIdle(); - - onView(withId(R.id.content_preview_filename)).check(matches(isDisplayed())); - onView(withId(R.id.content_preview_filename)).check(matches(withText("image.png"))); - onView(withId(R.id.content_preview_file_icon)).check(matches(isDisplayed())); - } - - @Test - public void testManyVisibleImagePreview_ScrollableImagePreview() { - Uri uri = createTestContentProviderUri("image/png", null); - - ArrayList uris = new ArrayList<>(); - uris.add(uri); - uris.add(uri); - uris.add(uri); - uris.add(uri); - uris.add(uri); - uris.add(uri); - uris.add(uri); - uris.add(uri); - uris.add(uri); - uris.add(uri); - - Intent sendIntent = createSendUriIntentWithPreview(uris); - mFakeImageLoader.setBitmap(uri, createBitmap()); - - List resolvedComponentInfos = createResolvedComponentsForTest(2); - - setupResolverControllers(resolvedComponentInfos); - mActivityRule.launchActivity(Intent.createChooser(sendIntent, null)); - waitForIdle(); - onView(withId(R.id.scrollable_image_preview)) - .perform(RecyclerViewActions.scrollToLastPosition()) - .check((view, exception) -> { - if (exception != null) { - throw exception; - } - RecyclerView recyclerView = (RecyclerView) view; - assertThat(recyclerView.getAdapter().getItemCount(), is(uris.size())); - }); - } - - @Test(timeout = 4_000) - public void testPartiallyLoadedMetadata_previewIsShownForTheLoadedPart() { - Uri imgOneUri = createTestContentProviderUri("image/png", null); - Uri imgTwoUri = createTestContentProviderUri("image/png", null) - .buildUpon() - .path("image-2.png") - .build(); - Uri docUri = createTestContentProviderUri("application/pdf", "image/png", 8_000); - ArrayList uris = new ArrayList<>(2); - // two large previews to fill the screen and be presented right away and one - // document that would be delayed by the URI metadata reading - uris.add(imgOneUri); - uris.add(imgTwoUri); - uris.add(docUri); - - Intent sendIntent = createSendUriIntentWithPreview(uris); - mFakeImageLoader.setBitmap(imgOneUri, createWideBitmap(Color.RED)); - mFakeImageLoader.setBitmap(imgTwoUri, createWideBitmap(Color.GREEN)); - mFakeImageLoader.setBitmap(docUri, createWideBitmap(Color.BLUE)); - - List resolvedComponentInfos = createResolvedComponentsForTest(2); - setupResolverControllers(resolvedComponentInfos); - - // the preview type is expected to be resolved quickly based on the first provided URI - // metadata. If, instead, it is dependent on the third URI metadata, the test should either - // timeout or (more probably due to inner timeout) default to file preview type; anyway the - // test will fail. - mActivityRule.launchActivity(Intent.createChooser(sendIntent, null)); - waitForIdle(); - - onView(withId(R.id.scrollable_image_preview)) - .check((view, exception) -> { - if (exception != null) { - 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); - assertThat(imageView).isNotNull(); - }) - .perform(RecyclerViewActions.scrollToLastPosition()) - .check((view, exception) -> { - if (exception != null) { - throw exception; - } - RecyclerView recyclerView = (RecyclerView) view; - assertThat(recyclerView.getChildCount()).isAtLeast(1); - // check that the last view is a loading indicator - View loadingIndicator = - recyclerView.getChildAt(recyclerView.getChildCount() - 1); - assertThat(loadingIndicator).isNotNull(); - }); - waitForIdle(); - } - - @Test - public void testImageAndTextPreview() { - final Uri uri = createTestContentProviderUri("image/png", null); - final String sharedText = "text-" + System.currentTimeMillis(); - - ArrayList uris = new ArrayList<>(); - uris.add(uri); - - Intent sendIntent = createSendUriIntentWithPreview(uris); - sendIntent.putExtra(Intent.EXTRA_TEXT, sharedText); - mFakeImageLoader.setBitmap(uri, createBitmap()); - - List resolvedComponentInfos = createResolvedComponentsForTest(2); - - setupResolverControllers(resolvedComponentInfos); - mActivityRule.launchActivity(Intent.createChooser(sendIntent, null)); - waitForIdle(); - onView(withText(sharedText)) - .check(matches(isDisplayed())); - } - - @Test - public void test_shareImageWithRichText_RichTextIsDisplayed() { - final Uri uri = createTestContentProviderUri("image/png", null); - final CharSequence sharedText = new SpannableStringBuilder() - .append( - "text-", - new StyleSpan(Typeface.BOLD_ITALIC), - Spannable.SPAN_INCLUSIVE_EXCLUSIVE) - .append( - Long.toString(System.currentTimeMillis()), - new ForegroundColorSpan(Color.RED), - Spanned.SPAN_INCLUSIVE_EXCLUSIVE); - - ArrayList uris = new ArrayList<>(); - uris.add(uri); - - Intent sendIntent = createSendUriIntentWithPreview(uris); - sendIntent.putExtra(Intent.EXTRA_TEXT, sharedText); - mFakeImageLoader.setBitmap(uri, createBitmap()); - - List resolvedComponentInfos = createResolvedComponentsForTest(2); - - setupResolverControllers(resolvedComponentInfos); - mActivityRule.launchActivity(Intent.createChooser(sendIntent, null)); - waitForIdle(); - onView(withText(sharedText.toString())) - .check(matches(isDisplayed())) - .check((view, e) -> { - if (e != null) { - throw e; - } - assertThat(view).isInstanceOf(TextView.class); - CharSequence text = ((TextView) view).getText(); - assertThat(text).isInstanceOf(Spanned.class); - Spanned spanned = (Spanned) text; - Object[] spans = spanned.getSpans(0, text.length(), Object.class); - assertThat(spans).hasLength(2); - assertThat(spanned.getSpans(0, 5, StyleSpan.class)).hasLength(1); - assertThat(spanned.getSpans(5, text.length(), ForegroundColorSpan.class)) - .hasLength(1); - }); - } - - @Test - public void testTextPreviewWhenTextIsSharedWithMultipleImages() { - final Uri uri = createTestContentProviderUri("image/png", null); - final String sharedText = "text-" + System.currentTimeMillis(); - - ArrayList uris = new ArrayList<>(); - uris.add(uri); - uris.add(uri); - - Intent sendIntent = createSendUriIntentWithPreview(uris); - sendIntent.putExtra(Intent.EXTRA_TEXT, sharedText); - mFakeImageLoader.setBitmap(uri, createBitmap()); - - List resolvedComponentInfos = createResolvedComponentsForTest(2); - - when( - ChooserActivityOverrideData - .getInstance() - .resolverListController - .getResolversForIntentAsUser( - Mockito.anyBoolean(), - Mockito.anyBoolean(), - Mockito.anyBoolean(), - Mockito.isA(List.class), - Mockito.any(UserHandle.class))) - .thenReturn(resolvedComponentInfos); - mActivityRule.launchActivity(Intent.createChooser(sendIntent, null)); - waitForIdle(); - onView(withText(sharedText)).check(matches(isDisplayed())); - } - - @Test - public void testOnCreateLogging() { - Intent sendIntent = createSendTextIntent(); - sendIntent.setType(TEST_MIME_TYPE); - - ChooserWrapperActivity activity = - mActivityRule.launchActivity(Intent.createChooser(sendIntent, "logger test")); - waitForIdle(); - - FakeEventLog eventLog = getEventLog(activity); - FakeEventLog.ChooserActivityShown event = eventLog.getChooserActivityShown(); - assertThat(event).isNotNull(); - assertThat(event.isWorkProfile()).isFalse(); - assertThat(event.getTargetMimeType()).isEqualTo(TEST_MIME_TYPE); - } - - @Test - public void testOnCreateLoggingFromWorkProfile() { - Intent sendIntent = createSendTextIntent(); - sendIntent.setType(TEST_MIME_TYPE); - - // Launch as work user. - mFakeUserRepo.addUser(WORK_USER, true); - mApplicationUser = WORK_PROFILE_USER_HANDLE; - - ChooserWrapperActivity activity = - mActivityRule.launchActivity(Intent.createChooser(sendIntent, "logger test")); - waitForIdle(); - - FakeEventLog eventLog = getEventLog(activity); - FakeEventLog.ChooserActivityShown event = eventLog.getChooserActivityShown(); - assertThat(event).isNotNull(); - assertThat(event.isWorkProfile()).isTrue(); - assertThat(event.getTargetMimeType()).isEqualTo(TEST_MIME_TYPE); - } - - @Test - public void testEmptyPreviewLogging() { - Intent sendIntent = createSendTextIntentWithPreview(null, null); - - ChooserWrapperActivity activity = - mActivityRule.launchActivity(Intent.createChooser(sendIntent, - "empty preview logger test")); - waitForIdle(); - - FakeEventLog eventLog = getEventLog(activity); - FakeEventLog.ChooserActivityShown event = eventLog.getChooserActivityShown(); - assertThat(event).isNotNull(); - assertThat(event.isWorkProfile()).isFalse(); - assertThat(event.getTargetMimeType()).isNull(); - } - - @Test - public void testTitlePreviewLogging() { - Intent sendIntent = createSendTextIntentWithPreview("TestTitle", null); - - List resolvedComponentInfos = createResolvedComponentsForTest(2); - - setupResolverControllers(resolvedComponentInfos); - - ChooserWrapperActivity activity = - mActivityRule.launchActivity(Intent.createChooser(sendIntent, null)); - waitForIdle(); - - FakeEventLog eventLog = getEventLog(activity); - assertThat(eventLog.getActionShareWithPreview()) - .isEqualTo(new FakeEventLog.ActionShareWithPreview( - /* previewType = */ CONTENT_PREVIEW_TEXT)); - } - - @Test - public void testImagePreviewLogging() { - Uri uri = createTestContentProviderUri("image/png", null); - - ArrayList uris = new ArrayList<>(); - uris.add(uri); - - Intent sendIntent = createSendUriIntentWithPreview(uris); - mFakeImageLoader.setBitmap(uri, createBitmap()); - - List resolvedComponentInfos = createResolvedComponentsForTest(2); - - setupResolverControllers(resolvedComponentInfos); - - ChooserWrapperActivity activity = - mActivityRule.launchActivity(Intent.createChooser(sendIntent, null)); - waitForIdle(); - - FakeEventLog eventLog = getEventLog(activity); - assertThat(eventLog.getActionShareWithPreview()) - .isEqualTo(new FakeEventLog.ActionShareWithPreview( - /* previewType = */ CONTENT_PREVIEW_IMAGE)); - } - - @Test - public void oneVisibleFilePreview() throws InterruptedException { - Uri uri = Uri.parse("content://com.android.frameworks.coretests/app.pdf"); - - ArrayList uris = new ArrayList<>(); - uris.add(uri); - - Intent sendIntent = createSendUriIntentWithPreview(uris); - - List resolvedComponentInfos = createResolvedComponentsForTest(2); - - setupResolverControllers(resolvedComponentInfos); - mActivityRule.launchActivity(Intent.createChooser(sendIntent, null)); - waitForIdle(); - onView(withId(R.id.content_preview_filename)).check(matches(isDisplayed())); - onView(withId(R.id.content_preview_filename)).check(matches(withText("app.pdf"))); - onView(withId(R.id.content_preview_file_icon)).check(matches(isDisplayed())); - } - - - @Test - public void moreThanOneVisibleFilePreview() throws InterruptedException { - Uri uri = Uri.parse("content://com.android.frameworks.coretests/app.pdf"); - - ArrayList uris = new ArrayList<>(); - uris.add(uri); - uris.add(uri); - uris.add(uri); - - Intent sendIntent = createSendUriIntentWithPreview(uris); - - List resolvedComponentInfos = createResolvedComponentsForTest(2); - - setupResolverControllers(resolvedComponentInfos); - mActivityRule.launchActivity(Intent.createChooser(sendIntent, null)); - waitForIdle(); - onView(withId(R.id.content_preview_filename)).check(matches(isDisplayed())); - onView(withId(R.id.content_preview_filename)).check(matches(withText("app.pdf"))); - onView(withId(R.id.content_preview_more_files)).check(matches(isDisplayed())); - onView(withId(R.id.content_preview_more_files)).check(matches(withText("+ 2 more files"))); - onView(withId(R.id.content_preview_file_icon)).check(matches(isDisplayed())); - } - - @Test - public void contentProviderThrowSecurityException() throws InterruptedException { - Uri uri = Uri.parse("content://com.android.frameworks.coretests/app.pdf"); - - ArrayList uris = new ArrayList<>(); - uris.add(uri); - - Intent sendIntent = createSendUriIntentWithPreview(uris); - - List resolvedComponentInfos = createResolvedComponentsForTest(2); - setupResolverControllers(resolvedComponentInfos); - - ChooserActivityOverrideData.getInstance().resolverForceException = true; - - mActivityRule.launchActivity(Intent.createChooser(sendIntent, null)); - waitForIdle(); - onView(withId(R.id.content_preview_filename)).check(matches(isDisplayed())); - onView(withId(R.id.content_preview_filename)).check(matches(withText("app.pdf"))); - onView(withId(R.id.content_preview_file_icon)).check(matches(isDisplayed())); - } - - @Test - public void contentProviderReturnsNoColumns() throws InterruptedException { - Uri uri = Uri.parse("content://com.android.frameworks.coretests/app.pdf"); - - ArrayList uris = new ArrayList<>(); - uris.add(uri); - uris.add(uri); - - Intent sendIntent = createSendUriIntentWithPreview(uris); - - List resolvedComponentInfos = createResolvedComponentsForTest(2); - setupResolverControllers(resolvedComponentInfos); - - Cursor cursor = mock(Cursor.class); - when(cursor.getCount()).thenReturn(1); - Mockito.doNothing().when(cursor).close(); - when(cursor.moveToFirst()).thenReturn(true); - when(cursor.getColumnIndex(Mockito.anyString())).thenReturn(-1); - - ChooserActivityOverrideData.getInstance().resolverCursor = cursor; - - mActivityRule.launchActivity(Intent.createChooser(sendIntent, null)); - waitForIdle(); - onView(withId(R.id.content_preview_filename)).check(matches(isDisplayed())); - onView(withId(R.id.content_preview_filename)).check(matches(withText("app.pdf"))); - onView(withId(R.id.content_preview_more_files)).check(matches(isDisplayed())); - onView(withId(R.id.content_preview_more_files)).check(matches(withText("+ 1 more file"))); - onView(withId(R.id.content_preview_file_icon)).check(matches(isDisplayed())); - } - - @Test - public void testGetBaseScore() { - final float testBaseScore = 0.89f; - - Intent sendIntent = createSendTextIntent(); - List resolvedComponentInfos = createResolvedComponentsForTest(2); - - setupResolverControllers(resolvedComponentInfos); - - when( - ChooserActivityOverrideData - .getInstance() - .resolverListController - .getScore(Mockito.isA(DisplayResolveInfo.class))) - .thenReturn(testBaseScore); - - final IChooserWrapper activity = (IChooserWrapper) - mActivityRule.launchActivity(Intent.createChooser(sendIntent, null)); - waitForIdle(); - - final DisplayResolveInfo testDri = - activity.createTestDisplayResolveInfo( - sendIntent, - ResolverDataProvider.createResolveInfo(3, 0, PERSONAL_USER_HANDLE), - "testLabel", - "testInfo", - sendIntent); - final ChooserListAdapter adapter = activity.getAdapter(); - - assertThat(adapter.getBaseScore(null, 0), is(CALLER_TARGET_SCORE_BOOST)); - assertThat(adapter.getBaseScore(testDri, TARGET_TYPE_DEFAULT), is(testBaseScore)); - assertThat(adapter.getBaseScore(testDri, TARGET_TYPE_CHOOSER_TARGET), is(testBaseScore)); - assertThat(adapter.getBaseScore(testDri, TARGET_TYPE_SHORTCUTS_FROM_PREDICTION_SERVICE), - is(testBaseScore * SHORTCUT_TARGET_SCORE_BOOST)); - assertThat(adapter.getBaseScore(testDri, TARGET_TYPE_SHORTCUTS_FROM_SHORTCUT_MANAGER), - is(testBaseScore * SHORTCUT_TARGET_SCORE_BOOST)); - } - - // This test is too long and too slow and should not be taken as an example for future tests. - @Test - public void testDirectTargetSelectionLogging() { - Intent sendIntent = createSendTextIntent(); - // We need app targets for direct targets to get displayed - List resolvedComponentInfos = createResolvedComponentsForTest(2); - setupResolverControllers(resolvedComponentInfos); - - // create test shortcut loader factory, remember loaders and their callbacks - SparseArray>> shortcutLoaders = - createShortcutLoaderFactory(); - - // Start activity - ChooserWrapperActivity activity = - mActivityRule.launchActivity(Intent.createChooser(sendIntent, null)); - waitForIdle(); - - // verify that ShortcutLoader was queried - ArgumentCaptor appTargets = - ArgumentCaptor.forClass(DisplayResolveInfo[].class); - verify(shortcutLoaders.get(0).first, times(1)).updateAppTargets(appTargets.capture()); - - // send shortcuts - assertThat( - "Wrong number of app targets", - appTargets.getValue().length, - is(resolvedComponentInfos.size())); - List serviceTargets = createDirectShareTargets(1, ""); - ShortcutLoader.Result result = new ShortcutLoader.Result( - true, - appTargets.getValue(), - new ShortcutLoader.ShortcutResultInfo[] { - new ShortcutLoader.ShortcutResultInfo( - appTargets.getValue()[0], - serviceTargets - ) - }, - new HashMap<>(), - new HashMap<>() - ); - activity.getMainExecutor().execute(() -> shortcutLoaders.get(0).second.accept(result)); - waitForIdle(); - - final ChooserListAdapter activeAdapter = activity.getAdapter(); - assertThat( - "Chooser should have 3 targets (2 apps, 1 direct)", - activeAdapter.getCount(), - is(3)); - assertThat( - "Chooser should have exactly one selectable direct target", - activeAdapter.getSelectableServiceTargetCount(), - is(1)); - assertThat( - "The resolver info must match the resolver info used to create the target", - activeAdapter.getItem(0).getResolveInfo(), - is(resolvedComponentInfos.get(0).getResolveInfoAt(0))); - - // Click on the direct target - String name = serviceTargets.get(0).getTitle().toString(); - onView(withText(name)) - .perform(click()); - waitForIdle(); - - FakeEventLog eventLog = getEventLog(activity); - assertThat(eventLog.getShareTargetSelected()).hasSize(1); - FakeEventLog.ShareTargetSelected call = eventLog.getShareTargetSelected().get(0); - assertThat(call.getTargetType()).isEqualTo(EventLog.SELECTION_TYPE_SERVICE); - assertThat(call.getDirectTargetAlsoRanked()).isEqualTo(-1); - var hashResult = call.getDirectTargetHashed(); - var hash = hashResult == null ? "" : hashResult.hashedString; - assertWithMessage("Hash is not predictable but must be obfuscated") - .that(hash).isNotEqualTo(name); - } - - // This test is too long and too slow and should not be taken as an example for future tests. - @Test - public void testDirectTargetLoggingWithRankedAppTarget() { - Intent sendIntent = createSendTextIntent(); - // We need app targets for direct targets to get displayed - List resolvedComponentInfos = createResolvedComponentsForTest(2); - setupResolverControllers(resolvedComponentInfos); - - // create test shortcut loader factory, remember loaders and their callbacks - SparseArray>> shortcutLoaders = - createShortcutLoaderFactory(); - - // Start activity - ChooserWrapperActivity activity = - mActivityRule.launchActivity(Intent.createChooser(sendIntent, null)); - waitForIdle(); - - // verify that ShortcutLoader was queried - ArgumentCaptor appTargets = - ArgumentCaptor.forClass(DisplayResolveInfo[].class); - verify(shortcutLoaders.get(0).first, times(1)).updateAppTargets(appTargets.capture()); - - // send shortcuts - assertThat( - "Wrong number of app targets", - appTargets.getValue().length, - is(resolvedComponentInfos.size())); - List serviceTargets = createDirectShareTargets( - 1, - resolvedComponentInfos.get(0).getResolveInfoAt(0).activityInfo.packageName); - ShortcutLoader.Result result = new ShortcutLoader.Result( - true, - appTargets.getValue(), - new ShortcutLoader.ShortcutResultInfo[] { - new ShortcutLoader.ShortcutResultInfo( - appTargets.getValue()[0], - serviceTargets - ) - }, - new HashMap<>(), - new HashMap<>() - ); - activity.getMainExecutor().execute(() -> shortcutLoaders.get(0).second.accept(result)); - waitForIdle(); - - final ChooserListAdapter activeAdapter = activity.getAdapter(); - assertThat( - "Chooser should have 3 targets (2 apps, 1 direct)", - activeAdapter.getCount(), - is(3)); - assertThat( - "Chooser should have exactly one selectable direct target", - activeAdapter.getSelectableServiceTargetCount(), - is(1)); - assertThat( - "The resolver info must match the resolver info used to create the target", - activeAdapter.getItem(0).getResolveInfo(), - is(resolvedComponentInfos.get(0).getResolveInfoAt(0))); - - // Click on the direct target - String name = serviceTargets.get(0).getTitle().toString(); - onView(withText(name)) - .perform(click()); - waitForIdle(); - - FakeEventLog eventLog = getEventLog(activity); - assertThat(eventLog.getShareTargetSelected()).hasSize(1); - FakeEventLog.ShareTargetSelected call = eventLog.getShareTargetSelected().get(0); - - assertThat(call.getTargetType()).isEqualTo(EventLog.SELECTION_TYPE_SERVICE); - assertThat(call.getDirectTargetAlsoRanked()).isEqualTo(0); - } - - @Test - public void testShortcutTargetWithApplyAppLimits() { - // Set up resources - Resources resources = Mockito.spy( - InstrumentationRegistry.getInstrumentation().getContext().getResources()); - ChooserActivityOverrideData.getInstance().resources = resources; - doReturn(1).when(resources).getInteger(R.integer.config_maxShortcutTargetsPerApp); - Intent sendIntent = createSendTextIntent(); - // We need app targets for direct targets to get displayed - List resolvedComponentInfos = createResolvedComponentsForTest(2); - setupResolverControllers(resolvedComponentInfos); - - // create test shortcut loader factory, remember loaders and their callbacks - SparseArray>> shortcutLoaders = - createShortcutLoaderFactory(); - - // Start activity - final IChooserWrapper activity = (IChooserWrapper) mActivityRule - .launchActivity(Intent.createChooser(sendIntent, null)); - waitForIdle(); - - // verify that ShortcutLoader was queried - ArgumentCaptor appTargets = - ArgumentCaptor.forClass(DisplayResolveInfo[].class); - verify(shortcutLoaders.get(0).first, times(1)).updateAppTargets(appTargets.capture()); - - // send shortcuts - assertThat( - "Wrong number of app targets", - appTargets.getValue().length, - is(resolvedComponentInfos.size())); - List serviceTargets = createDirectShareTargets( - 2, - resolvedComponentInfos.get(0).getResolveInfoAt(0).activityInfo.packageName); - ShortcutLoader.Result result = new ShortcutLoader.Result( - true, - appTargets.getValue(), - new ShortcutLoader.ShortcutResultInfo[] { - new ShortcutLoader.ShortcutResultInfo( - appTargets.getValue()[0], - serviceTargets - ) - }, - new HashMap<>(), - new HashMap<>() - ); - activity.getMainExecutor().execute(() -> shortcutLoaders.get(0).second.accept(result)); - waitForIdle(); - - final ChooserListAdapter activeAdapter = activity.getAdapter(); - assertThat( - "Chooser should have 3 targets (2 apps, 1 direct)", - activeAdapter.getCount(), - is(3)); - assertThat( - "Chooser should have exactly one selectable direct target", - activeAdapter.getSelectableServiceTargetCount(), - is(1)); - assertThat( - "The resolver info must match the resolver info used to create the target", - activeAdapter.getItem(0).getResolveInfo(), - is(resolvedComponentInfos.get(0).getResolveInfoAt(0))); - assertThat( - "The display label must match", - activeAdapter.getItem(0).getDisplayLabel(), - is("testTitle0")); - } - - @Test - public void testShortcutTargetWithoutApplyAppLimits() { - setDeviceConfigProperty( - SystemUiDeviceConfigFlags.APPLY_SHARING_APP_LIMITS_IN_SYSUI, - Boolean.toString(false)); - // Set up resources - Resources resources = Mockito.spy( - InstrumentationRegistry.getInstrumentation().getContext().getResources()); - ChooserActivityOverrideData.getInstance().resources = resources; - doReturn(1).when(resources).getInteger(R.integer.config_maxShortcutTargetsPerApp); - Intent sendIntent = createSendTextIntent(); - // We need app targets for direct targets to get displayed - List resolvedComponentInfos = createResolvedComponentsForTest(2); - setupResolverControllers(resolvedComponentInfos); - - // create test shortcut loader factory, remember loaders and their callbacks - SparseArray>> shortcutLoaders = - createShortcutLoaderFactory(); - - // Start activity - final IChooserWrapper activity = (IChooserWrapper) - mActivityRule.launchActivity(Intent.createChooser(sendIntent, null)); - waitForIdle(); - - // verify that ShortcutLoader was queried - ArgumentCaptor appTargets = - ArgumentCaptor.forClass(DisplayResolveInfo[].class); - verify(shortcutLoaders.get(0).first, times(1)).updateAppTargets(appTargets.capture()); - - // send shortcuts - assertThat( - "Wrong number of app targets", - appTargets.getValue().length, - is(resolvedComponentInfos.size())); - List serviceTargets = createDirectShareTargets( - 2, - resolvedComponentInfos.get(0).getResolveInfoAt(0).activityInfo.packageName); - ShortcutLoader.Result result = new ShortcutLoader.Result( - true, - appTargets.getValue(), - new ShortcutLoader.ShortcutResultInfo[] { - new ShortcutLoader.ShortcutResultInfo( - appTargets.getValue()[0], - serviceTargets - ) - }, - new HashMap<>(), - new HashMap<>() - ); - activity.getMainExecutor().execute(() -> shortcutLoaders.get(0).second.accept(result)); - waitForIdle(); - - final ChooserListAdapter activeAdapter = activity.getAdapter(); - assertThat( - "Chooser should have 4 targets (2 apps, 2 direct)", - activeAdapter.getCount(), - is(4)); - assertThat( - "Chooser should have exactly two selectable direct target", - activeAdapter.getSelectableServiceTargetCount(), - is(2)); - assertThat( - "The resolver info must match the resolver info used to create the target", - activeAdapter.getItem(0).getResolveInfo(), - is(resolvedComponentInfos.get(0).getResolveInfoAt(0))); - assertThat( - "The display label must match", - activeAdapter.getItem(0).getDisplayLabel(), - is("testTitle0")); - assertThat( - "The display label must match", - activeAdapter.getItem(1).getDisplayLabel(), - is("testTitle1")); - } - - @Test - public void testLaunchWithCallerProvidedTarget() { - setDeviceConfigProperty( - SystemUiDeviceConfigFlags.APPLY_SHARING_APP_LIMITS_IN_SYSUI, - Boolean.toString(false)); - // Set up resources - Resources resources = Mockito.spy( - InstrumentationRegistry.getInstrumentation().getContext().getResources()); - ChooserActivityOverrideData.getInstance().resources = resources; - doReturn(1).when(resources).getInteger(R.integer.config_maxShortcutTargetsPerApp); - - // We need app targets for direct targets to get displayed - List resolvedComponentInfos = createResolvedComponentsForTest(2); - setupResolverControllers(resolvedComponentInfos, resolvedComponentInfos); - markOtherProfileAvailability(/* workAvailable= */ true, /* cloneAvailable= */ false); - - // set caller-provided target - Intent chooserIntent = Intent.createChooser(createSendTextIntent(), null); - String callerTargetLabel = "Caller Target"; - ChooserTarget[] targets = new ChooserTarget[] { - new ChooserTarget( - callerTargetLabel, - Icon.createWithBitmap(createBitmap()), - 0.1f, - resolvedComponentInfos.get(0).name, - new Bundle()) - }; - chooserIntent.putExtra(Intent.EXTRA_CHOOSER_TARGETS, targets); - - // create test shortcut loader factory, remember loaders and their callbacks - SparseArray>> shortcutLoaders = - createShortcutLoaderFactory(); - - // Start activity - final IChooserWrapper activity = (IChooserWrapper) - mActivityRule.launchActivity(chooserIntent); - waitForIdle(); - - // verify that ShortcutLoader was queried - ArgumentCaptor appTargets = - ArgumentCaptor.forClass(DisplayResolveInfo[].class); - verify(shortcutLoaders.get(0).first, times(1)).updateAppTargets(appTargets.capture()); - - // send shortcuts - assertThat( - "Wrong number of app targets", - appTargets.getValue().length, - is(resolvedComponentInfos.size())); - ShortcutLoader.Result result = new ShortcutLoader.Result( - true, - appTargets.getValue(), - new ShortcutLoader.ShortcutResultInfo[0], - new HashMap<>(), - new HashMap<>()); - activity.getMainExecutor().execute(() -> shortcutLoaders.get(0).second.accept(result)); - waitForIdle(); - - final ChooserListAdapter activeAdapter = activity.getAdapter(); - assertThat( - "Chooser should have 3 targets (2 apps, 1 direct)", - activeAdapter.getCount(), - is(3)); - assertThat( - "Chooser should have exactly two selectable direct target", - activeAdapter.getSelectableServiceTargetCount(), - is(1)); - assertThat( - "The display label must match", - activeAdapter.getItem(0).getDisplayLabel(), - is(callerTargetLabel)); - - // Switch to work profile and ensure that the target *doesn't* show up there. - onView(withText(R.string.resolver_work_tab)).perform(click()); - waitForIdle(); - - for (int i = 0; i < activity.getWorkListAdapter().getCount(); i++) { - assertThat( - "Chooser target should not show up in opposite profile", - activity.getWorkListAdapter().getItem(i).getDisplayLabel(), - not(callerTargetLabel)); - } - } - - @Test - public void testLaunchWithCustomAction() throws InterruptedException { - List resolvedComponentInfos = createResolvedComponentsForTest(2); - setupResolverControllers(resolvedComponentInfos); - - Context testContext = InstrumentationRegistry.getInstrumentation().getContext(); - final String customActionLabel = "Custom Action"; - final String testAction = "test-broadcast-receiver-action"; - Intent chooserIntent = Intent.createChooser(createSendTextIntent(), null); - chooserIntent.putExtra( - Intent.EXTRA_CHOOSER_CUSTOM_ACTIONS, - new ChooserAction[] { - new ChooserAction.Builder( - Icon.createWithResource("", Resources.ID_NULL), - customActionLabel, - PendingIntent.getBroadcast( - testContext, - 123, - new Intent(testAction), - PendingIntent.FLAG_IMMUTABLE | PendingIntent.FLAG_ONE_SHOT)) - .build() - }); - // Start activity - mActivityRule.launchActivity(chooserIntent); - waitForIdle(); - - final CountDownLatch broadcastInvoked = new CountDownLatch(1); - BroadcastReceiver testReceiver = new BroadcastReceiver() { - @Override - public void onReceive(Context context, Intent intent) { - broadcastInvoked.countDown(); - } - }; - testContext.registerReceiver(testReceiver, new IntentFilter(testAction), - Context.RECEIVER_EXPORTED); - - try { - onView(withText(customActionLabel)).perform(click()); - assertTrue("Timeout waiting for broadcast", - broadcastInvoked.await(5000, TimeUnit.MILLISECONDS)); - } finally { - testContext.unregisterReceiver(testReceiver); - } - } - - @Test - public void testLaunchWithShareModification() throws InterruptedException { - List resolvedComponentInfos = createResolvedComponentsForTest(2); - setupResolverControllers(resolvedComponentInfos); - - Context testContext = InstrumentationRegistry.getInstrumentation().getContext(); - final String modifyShareAction = "test-broadcast-receiver-action"; - Intent chooserIntent = Intent.createChooser(createSendTextIntent(), null); - String label = "modify share"; - PendingIntent pendingIntent = PendingIntent.getBroadcast( - testContext, - 123, - new Intent(modifyShareAction), - PendingIntent.FLAG_IMMUTABLE | PendingIntent.FLAG_ONE_SHOT); - ChooserAction action = new ChooserAction.Builder(Icon.createWithBitmap( - createBitmap()), label, pendingIntent).build(); - chooserIntent.putExtra( - Intent.EXTRA_CHOOSER_MODIFY_SHARE_ACTION, - action); - // Start activity - mActivityRule.launchActivity(chooserIntent); - waitForIdle(); - - final CountDownLatch broadcastInvoked = new CountDownLatch(1); - BroadcastReceiver testReceiver = new BroadcastReceiver() { - @Override - public void onReceive(Context context, Intent intent) { - broadcastInvoked.countDown(); - } - }; - testContext.registerReceiver(testReceiver, new IntentFilter(modifyShareAction), - Context.RECEIVER_EXPORTED); - - try { - onView(withText(label)).perform(click()); - assertTrue("Timeout waiting for broadcast", - broadcastInvoked.await(5000, TimeUnit.MILLISECONDS)); - - } finally { - testContext.unregisterReceiver(testReceiver); - } - } - - @Test - public void testUpdateMaxTargetsPerRow_columnCountIsUpdated() throws InterruptedException { - updateMaxTargetsPerRowResource(/* targetsPerRow= */ 4); - givenAppTargets(/* appCount= */ 16); - Intent sendIntent = createSendTextIntent(); - final ChooserActivity activity = - mActivityRule.launchActivity(Intent.createChooser(sendIntent, null)); - - updateMaxTargetsPerRowResource(/* targetsPerRow= */ 6); - InstrumentationRegistry.getInstrumentation() - .runOnMainSync(() -> activity.onConfigurationChanged( - InstrumentationRegistry.getInstrumentation() - .getContext().getResources().getConfiguration())); - - waitForIdle(); - onView(withId(com.android.internal.R.id.resolver_list)) - .check(matches(withGridColumnCount(6))); - } - - // This test is too long and too slow and should not be taken as an example for future tests. - @Test @Ignore - public void testDirectTargetLoggingWithAppTargetNotRankedPortrait() - throws InterruptedException { - testDirectTargetLoggingWithAppTargetNotRanked(Configuration.ORIENTATION_PORTRAIT, 4); - } - - @Test @Ignore - public void testDirectTargetLoggingWithAppTargetNotRankedLandscape() - throws InterruptedException { - testDirectTargetLoggingWithAppTargetNotRanked(Configuration.ORIENTATION_LANDSCAPE, 8); - } - - private void testDirectTargetLoggingWithAppTargetNotRanked( - int orientation, int appTargetsExpected) { - Configuration configuration = - new Configuration(InstrumentationRegistry.getInstrumentation().getContext() - .getResources().getConfiguration()); - configuration.orientation = orientation; - - Resources resources = Mockito.spy( - InstrumentationRegistry.getInstrumentation().getContext().getResources()); - ChooserActivityOverrideData.getInstance().resources = resources; - doReturn(configuration).when(resources).getConfiguration(); - - Intent sendIntent = createSendTextIntent(); - // We need app targets for direct targets to get displayed - List resolvedComponentInfos = createResolvedComponentsForTest(15); - setupResolverControllers(resolvedComponentInfos); - - // Create direct share target - List serviceTargets = createDirectShareTargets(1, - resolvedComponentInfos.get(14).getResolveInfoAt(0).activityInfo.packageName); - ResolveInfo ri = ResolverDataProvider.createResolveInfo(16, 0, PERSONAL_USER_HANDLE); - - // Start activity - ChooserWrapperActivity activity = - mActivityRule.launchActivity(Intent.createChooser(sendIntent, null)); - // Insert the direct share target - Map directShareToShortcutInfos = new HashMap<>(); - directShareToShortcutInfos.put(serviceTargets.get(0), null); - InstrumentationRegistry.getInstrumentation().runOnMainSync( - () -> activity.getAdapter().addServiceResults( - activity.createTestDisplayResolveInfo(sendIntent, - ri, - "testLabel", - "testInfo", - sendIntent), - serviceTargets, - TARGET_TYPE_CHOOSER_TARGET, - directShareToShortcutInfos, - /* directShareToAppTargets */ null) - ); - - assertThat( - String.format("Chooser should have %d targets (%d apps, 1 direct, 15 A-Z)", - appTargetsExpected + 16, appTargetsExpected), - activity.getAdapter().getCount(), is(appTargetsExpected + 16)); - assertThat("Chooser should have exactly one selectable direct target", - activity.getAdapter().getSelectableServiceTargetCount(), is(1)); - assertThat("The resolver info must match the resolver info used to create the target", - activity.getAdapter().getItem(0).getResolveInfo(), is(ri)); - - // Click on the direct target - String name = serviceTargets.get(0).getTitle().toString(); - onView(withText(name)) - .perform(click()); - waitForIdle(); - - FakeEventLog eventLog = getEventLog(activity); - var invocations = eventLog.getShareTargetSelected(); - assertWithMessage("Only one ShareTargetSelected event logged") - .that(invocations).hasSize(1); - FakeEventLog.ShareTargetSelected call = invocations.get(0); - assertWithMessage("targetType should be SELECTION_TYPE_SERVICE") - .that(call.getTargetType()).isEqualTo(EventLog.SELECTION_TYPE_SERVICE); - assertWithMessage( - "The packages shouldn't match for app target and direct target") - .that(call.getDirectTargetAlsoRanked()).isEqualTo(-1); - } - - @Test - public void testWorkTab_displayedWhenWorkProfileUserAvailable() { - Intent sendIntent = createSendTextIntent(); - sendIntent.setType(TEST_MIME_TYPE); - markOtherProfileAvailability(/* workAvailable= */ true, /* cloneAvailable= */ false); - - mActivityRule.launchActivity(Intent.createChooser(sendIntent, "work tab test")); - waitForIdle(); - - onView(withId(android.R.id.tabs)).check(matches(isDisplayed())); - } - - @Test - public void testWorkTab_hiddenWhenWorkProfileUserNotAvailable() { - Intent sendIntent = createSendTextIntent(); - sendIntent.setType(TEST_MIME_TYPE); - - mActivityRule.launchActivity(Intent.createChooser(sendIntent, "work tab test")); - waitForIdle(); - - onView(withId(android.R.id.tabs)).check(matches(not(isDisplayed()))); - } - - @Test - public void testWorkTab_eachTabUsesExpectedAdapter() { - int personalProfileTargets = 3; - int otherProfileTargets = 1; - List personalResolvedComponentInfos = - createResolvedComponentsForTestWithOtherProfile( - personalProfileTargets + otherProfileTargets, /* userID */ 10); - int workProfileTargets = 4; - List workResolvedComponentInfos = createResolvedComponentsForTest( - workProfileTargets); - setupResolverControllers(personalResolvedComponentInfos, workResolvedComponentInfos); - Intent sendIntent = createSendTextIntent(); - sendIntent.setType(TEST_MIME_TYPE); - markOtherProfileAvailability(/* workAvailable= */ true, /* cloneAvailable= */ false); - - final IChooserWrapper activity = (IChooserWrapper) - mActivityRule.launchActivity(Intent.createChooser(sendIntent, "work tab test")); - waitForIdle(); - - assertThat(activity.getCurrentUserHandle().getIdentifier(), is(0)); - onView(withText(R.string.resolver_work_tab)).perform(click()); - assertThat(activity.getCurrentUserHandle().getIdentifier(), is(10)); - assertThat(activity.getPersonalListAdapter().getCount(), is(personalProfileTargets)); - assertThat(activity.getWorkListAdapter().getCount(), is(workProfileTargets)); - } - - @Test - public void testWorkTab_workProfileHasExpectedNumberOfTargets() throws InterruptedException { - markOtherProfileAvailability(/* workAvailable= */ true, /* cloneAvailable= */ false); - int workProfileTargets = 4; - List personalResolvedComponentInfos = - createResolvedComponentsForTestWithOtherProfile(3, /* userId */ 10); - List workResolvedComponentInfos = - createResolvedComponentsForTest(workProfileTargets); - setupResolverControllers(personalResolvedComponentInfos, workResolvedComponentInfos); - Intent sendIntent = createSendTextIntent(); - sendIntent.setType(TEST_MIME_TYPE); - - final IChooserWrapper activity = (IChooserWrapper) - mActivityRule.launchActivity(Intent.createChooser(sendIntent, "work tab test")); - waitForIdle(); - onView(withText(R.string.resolver_work_tab)).perform(click()); - waitForIdle(); - - assertThat(activity.getWorkListAdapter().getCount(), is(workProfileTargets)); - } - - @Test @Ignore - public void testWorkTab_selectingWorkTabAppOpensAppInWorkProfile() { - markOtherProfileAvailability(/* workAvailable= */ true, /* cloneAvailable= */ false); - List personalResolvedComponentInfos = - createResolvedComponentsForTestWithOtherProfile(3, /* userId */ 10); - int workProfileTargets = 4; - List workResolvedComponentInfos = - createResolvedComponentsForTest(workProfileTargets); - setupResolverControllers(personalResolvedComponentInfos, workResolvedComponentInfos); - Intent sendIntent = createSendTextIntent(); - sendIntent.setType(TEST_MIME_TYPE); - ResolveInfo[] chosen = new ResolveInfo[1]; - ChooserActivityOverrideData.getInstance().onSafelyStartInternalCallback = targetInfo -> { - chosen[0] = targetInfo.getResolveInfo(); - return true; - }; - - mActivityRule.launchActivity(Intent.createChooser(sendIntent, "work tab test")); - waitForIdle(); - onView(withText(R.string.resolver_work_tab)).perform(click()); - waitForIdle(); - - onView(first(allOf( - withText(workResolvedComponentInfos.get(0) - .getResolveInfoAt(0).activityInfo.applicationInfo.name), - isDisplayed()))) - .perform(click()); - waitForIdle(); - assertThat(chosen[0], is(workResolvedComponentInfos.get(0).getResolveInfoAt(0))); - } - - @Test - public void testWorkTab_crossProfileIntentsDisabled_personalToWork_emptyStateShown() { - markOtherProfileAvailability(/* workAvailable= */ true, /* cloneAvailable= */ false); - int workProfileTargets = 4; - List personalResolvedComponentInfos = - createResolvedComponentsForTestWithOtherProfile(3, /* userId */ 10); - List workResolvedComponentInfos = - createResolvedComponentsForTest(workProfileTargets); - ChooserActivityOverrideData.getInstance().hasCrossProfileIntents = false; - setupResolverControllers(personalResolvedComponentInfos, workResolvedComponentInfos); - Intent sendIntent = createSendTextIntent(); - sendIntent.setType(TEST_MIME_TYPE); - - mActivityRule.launchActivity(Intent.createChooser(sendIntent, "work tab test")); - waitForIdle(); - onView(withText(R.string.resolver_work_tab)).perform(click()); - waitForIdle(); - onView(withId(com.android.internal.R.id.contentPanel)) - .perform(swipeUp()); - - onView(withText(R.string.resolver_cross_profile_blocked)) - .check(matches(isDisplayed())); - } - - @Test - public void testWorkTab_workProfileDisabled_emptyStateShown() { - markOtherProfileAvailability(/* workAvailable= */ true, /* cloneAvailable= */ false); - int workProfileTargets = 4; - List personalResolvedComponentInfos = - createResolvedComponentsForTestWithOtherProfile(3, /* userId */ 10); - List workResolvedComponentInfos = - createResolvedComponentsForTest(workProfileTargets); - mFakeUserRepo.updateState(WORK_USER, false); - setupResolverControllers(personalResolvedComponentInfos, workResolvedComponentInfos); - Intent sendIntent = createSendTextIntent(); - sendIntent.setType(TEST_MIME_TYPE); - - mActivityRule.launchActivity(Intent.createChooser(sendIntent, "work tab test")); - waitForIdle(); - onView(withId(com.android.internal.R.id.contentPanel)) - .perform(swipeUp()); - onView(withText(R.string.resolver_work_tab)).perform(click()); - waitForIdle(); - - onView(withText(R.string.resolver_turn_on_work_apps)) - .check(matches(isDisplayed())); - } - - @Test - public void testWorkTab_noWorkAppsAvailable_emptyStateShown() { - markOtherProfileAvailability(/* workAvailable= */ true, /* cloneAvailable= */ false); - List personalResolvedComponentInfos = - createResolvedComponentsForTest(3); - List workResolvedComponentInfos = - createResolvedComponentsForTest(0); - setupResolverControllers(personalResolvedComponentInfos, workResolvedComponentInfos); - Intent sendIntent = createSendTextIntent(); - sendIntent.setType(TEST_MIME_TYPE); - - mActivityRule.launchActivity(Intent.createChooser(sendIntent, "work tab test")); - waitForIdle(); - onView(withId(com.android.internal.R.id.contentPanel)) - .perform(swipeUp()); - onView(withText(R.string.resolver_work_tab)).perform(click()); - waitForIdle(); - - onView(withText(R.string.resolver_no_work_apps_available)) - .check(matches(isDisplayed())); - } - - @Test - @RequiresFlagsEnabled(Flags.FLAG_SCROLLABLE_PREVIEW) - public void testWorkTab_previewIsScrollable() { - markOtherProfileAvailability(/* workAvailable= */ true, /* cloneAvailable= */ false); - List personalResolvedComponentInfos = - createResolvedComponentsForTest(300); - List workResolvedComponentInfos = - createResolvedComponentsForTest(3); - setupResolverControllers(personalResolvedComponentInfos, workResolvedComponentInfos); - - Uri uri = createTestContentProviderUri("image/png", null); - - ArrayList uris = new ArrayList<>(); - uris.add(uri); - - Intent sendIntent = createSendUriIntentWithPreview(uris); - mFakeImageLoader.setBitmap(uri, createWideBitmap()); - - mActivityRule.launchActivity(Intent.createChooser(sendIntent, "Scrollable preview test")); - waitForIdle(); - - onView(withId(com.android.intentresolver.R.id.scrollable_image_preview)) - .check(matches(isDisplayed())); - - onView(withId(com.android.internal.R.id.contentPanel)).perform(swipeUp()); - waitForIdle(); - - onView(withId(com.android.intentresolver.R.id.chooser_headline_row_container)) - .check(matches(isCompletelyDisplayed())); - onView(withId(com.android.intentresolver.R.id.headline)) - .check(matches(isDisplayed())); - onView(withId(com.android.intentresolver.R.id.scrollable_image_preview)) - .check(matches(not(isDisplayed()))); - } - - @Ignore // b/220067877 - @Test - public void testWorkTab_xProfileOff_noAppsAvailable_workOff_xProfileOffEmptyStateShown() { - markOtherProfileAvailability(/* workAvailable= */ true, /* cloneAvailable= */ false); - List personalResolvedComponentInfos = - createResolvedComponentsForTest(3); - List workResolvedComponentInfos = - createResolvedComponentsForTest(0); - setupResolverControllers(personalResolvedComponentInfos, workResolvedComponentInfos); - mFakeUserRepo.updateState(WORK_USER, false); - ChooserActivityOverrideData.getInstance().hasCrossProfileIntents = false; - Intent sendIntent = createSendTextIntent(); - sendIntent.setType(TEST_MIME_TYPE); - - mActivityRule.launchActivity(Intent.createChooser(sendIntent, "work tab test")); - waitForIdle(); - onView(withId(com.android.internal.R.id.contentPanel)) - .perform(swipeUp()); - onView(withText(R.string.resolver_work_tab)).perform(click()); - waitForIdle(); - - onView(withText(R.string.resolver_cross_profile_blocked)) - .check(matches(isDisplayed())); - } - - @Test - public void testWorkTab_noAppsAvailable_workOff_noAppsAvailableEmptyStateShown() { - markOtherProfileAvailability(/* workAvailable= */ true, /* cloneAvailable= */ false); - List personalResolvedComponentInfos = - createResolvedComponentsForTest(3); - List workResolvedComponentInfos = - createResolvedComponentsForTest(0); - setupResolverControllers(personalResolvedComponentInfos, workResolvedComponentInfos); - mFakeUserRepo.updateState(WORK_USER, false); - Intent sendIntent = createSendTextIntent(); - sendIntent.setType(TEST_MIME_TYPE); - - mActivityRule.launchActivity(Intent.createChooser(sendIntent, "work tab test")); - waitForIdle(); - onView(withId(com.android.internal.R.id.contentPanel)) - .perform(swipeUp()); - onView(withText(R.string.resolver_work_tab)).perform(click()); - waitForIdle(); - - onView(withText(R.string.resolver_no_work_apps_available)) - .check(matches(isDisplayed())); - } - - @Test @Ignore("b/222124533") - public void testAppTargetLogging() throws InterruptedException { - Intent sendIntent = createSendTextIntent(); - List resolvedComponentInfos = createResolvedComponentsForTest(2); - - setupResolverControllers(resolvedComponentInfos); - - final IChooserWrapper activity = (IChooserWrapper) - mActivityRule.launchActivity(Intent.createChooser(sendIntent, null)); - waitForIdle(); - - // TODO(b/222124533): other test cases use a timeout to make sure that the UI is fully - // populated; without one, this test flakes. Ideally we should address the need for a - // timeout everywhere instead of introducing one to fix this particular test. - - assertThat(activity.getAdapter().getCount(), is(2)); - onView(withId(com.android.internal.R.id.profile_button)).check(doesNotExist()); - - ResolveInfo[] chosen = new ResolveInfo[1]; - ChooserActivityOverrideData.getInstance().onSafelyStartInternalCallback = targetInfo -> { - chosen[0] = targetInfo.getResolveInfo(); - return true; - }; - - ResolveInfo toChoose = resolvedComponentInfos.get(0).getResolveInfoAt(0); - onView(withText(toChoose.activityInfo.name)) - .perform(click()); - waitForIdle(); - - // TODO(b/211669337): Determine the expected SHARESHEET_DIRECT_LOAD_COMPLETE events. - } - - @Test - public void testDirectTargetLogging() { - Intent sendIntent = createSendTextIntent(); - // We need app targets for direct targets to get displayed - List resolvedComponentInfos = createResolvedComponentsForTest(2); - setupResolverControllers(resolvedComponentInfos); - - // create test shortcut loader factory, remember loaders and their callbacks - SparseArray>> shortcutLoaders = - new SparseArray<>(); - ChooserActivityOverrideData.getInstance().shortcutLoaderFactory = - (userHandle, callback) -> { - Pair> pair = - new Pair<>(mock(ShortcutLoader.class), callback); - shortcutLoaders.put(userHandle.getIdentifier(), pair); - return pair.first; - }; - - // Start activity - ChooserWrapperActivity activity = - mActivityRule.launchActivity(Intent.createChooser(sendIntent, null)); - waitForIdle(); - - // verify that ShortcutLoader was queried - ArgumentCaptor appTargets = - ArgumentCaptor.forClass(DisplayResolveInfo[].class); - verify(shortcutLoaders.get(0).first, times(1)) - .updateAppTargets(appTargets.capture()); - - // send shortcuts - assertThat( - "Wrong number of app targets", - appTargets.getValue().length, - is(resolvedComponentInfos.size())); - List serviceTargets = createDirectShareTargets(1, - resolvedComponentInfos.get(0).getResolveInfoAt(0).activityInfo.packageName); - ShortcutLoader.Result result = new ShortcutLoader.Result( - // TODO: test another value as well - false, - appTargets.getValue(), - new ShortcutLoader.ShortcutResultInfo[] { - new ShortcutLoader.ShortcutResultInfo( - appTargets.getValue()[0], - serviceTargets - ) - }, - new HashMap<>(), - new HashMap<>() - ); - activity.getMainExecutor().execute(() -> shortcutLoaders.get(0).second.accept(result)); - waitForIdle(); - - assertThat("Chooser should have 3 targets (2 apps, 1 direct)", - activity.getAdapter().getCount(), is(3)); - assertThat("Chooser should have exactly one selectable direct target", - activity.getAdapter().getSelectableServiceTargetCount(), is(1)); - assertThat( - "The resolver info must match the resolver info used to create the target", - activity.getAdapter().getItem(0).getResolveInfo(), - is(resolvedComponentInfos.get(0).getResolveInfoAt(0))); - - // Click on the direct target - String name = serviceTargets.get(0).getTitle().toString(); - onView(withText(name)) - .perform(click()); - waitForIdle(); - - FakeEventLog eventLog = getEventLog(activity); - assertThat(eventLog.getShareTargetSelected()).hasSize(1); - FakeEventLog.ShareTargetSelected call = eventLog.getShareTargetSelected().get(0); - assertThat(call.getTargetType()).isEqualTo(EventLog.SELECTION_TYPE_SERVICE); - } - - @Test - public void testDirectTargetPinningDialog() { - Intent sendIntent = createSendTextIntent(); - // We need app targets for direct targets to get displayed - List resolvedComponentInfos = createResolvedComponentsForTest(2); - setupResolverControllers(resolvedComponentInfos); - - // create test shortcut loader factory, remember loaders and their callbacks - SparseArray>> shortcutLoaders = - new SparseArray<>(); - ChooserActivityOverrideData.getInstance().shortcutLoaderFactory = - (userHandle, callback) -> { - Pair> pair = - new Pair<>(mock(ShortcutLoader.class), callback); - shortcutLoaders.put(userHandle.getIdentifier(), pair); - return pair.first; - }; - - // Start activity - final IChooserWrapper activity = (IChooserWrapper) - mActivityRule.launchActivity(Intent.createChooser(sendIntent, null)); - waitForIdle(); - - // verify that ShortcutLoader was queried - ArgumentCaptor appTargets = - ArgumentCaptor.forClass(DisplayResolveInfo[].class); - verify(shortcutLoaders.get(0).first, times(1)) - .updateAppTargets(appTargets.capture()); - - // send shortcuts - List serviceTargets = createDirectShareTargets( - 1, - resolvedComponentInfos.get(0).getResolveInfoAt(0).activityInfo.packageName); - ShortcutLoader.Result result = new ShortcutLoader.Result( - // TODO: test another value as well - false, - appTargets.getValue(), - new ShortcutLoader.ShortcutResultInfo[] { - new ShortcutLoader.ShortcutResultInfo( - appTargets.getValue()[0], - serviceTargets - ) - }, - new HashMap<>(), - new HashMap<>() - ); - activity.getMainExecutor().execute(() -> shortcutLoaders.get(0).second.accept(result)); - waitForIdle(); - - // Long-click on the direct target - String name = serviceTargets.get(0).getTitle().toString(); - onView(withText(name)).perform(longClick()); - waitForIdle(); - - onView(withId(R.id.chooser_dialog_content)).check(matches(isDisplayed())); - } - - @Test @Ignore - public void testEmptyDirectRowLogging() throws InterruptedException { - Intent sendIntent = createSendTextIntent(); - // We need app targets for direct targets to get displayed - List resolvedComponentInfos = createResolvedComponentsForTest(2); - setupResolverControllers(resolvedComponentInfos); - - // Start activity - final IChooserWrapper activity = (IChooserWrapper) - mActivityRule.launchActivity(Intent.createChooser(sendIntent, null)); - - // Thread.sleep shouldn't be a thing in an integration test but it's - // necessary here because of the way the code is structured - Thread.sleep(3000); - - assertThat("Chooser should have 2 app targets", - activity.getAdapter().getCount(), is(2)); - assertThat("Chooser should have no direct targets", - activity.getAdapter().getSelectableServiceTargetCount(), is(0)); - - // TODO(b/211669337): Determine the expected SHARESHEET_DIRECT_LOAD_COMPLETE events. - } - - @Ignore // b/220067877 - @Test - public void testCopyTextToClipboardLogging() throws Exception { - Intent sendIntent = createSendTextIntent(); - List resolvedComponentInfos = createResolvedComponentsForTest(2); - - setupResolverControllers(resolvedComponentInfos); - - final IChooserWrapper activity = (IChooserWrapper) - mActivityRule.launchActivity(Intent.createChooser(sendIntent, null)); - waitForIdle(); - - onView(withId(com.android.internal.R.id.chooser_copy_button)).check(matches(isDisplayed())); - onView(withId(com.android.internal.R.id.chooser_copy_button)).perform(click()); - - // TODO(b/211669337): Determine the expected SHARESHEET_DIRECT_LOAD_COMPLETE events. - } - - @Test @Ignore("b/222124533") - public void testSwitchProfileLogging() throws InterruptedException { - markOtherProfileAvailability(/* workAvailable= */ true, /* cloneAvailable= */ false); - int workProfileTargets = 4; - List personalResolvedComponentInfos = - createResolvedComponentsForTestWithOtherProfile(3, /* userId */ 10); - List workResolvedComponentInfos = - createResolvedComponentsForTest(workProfileTargets); - setupResolverControllers(personalResolvedComponentInfos, workResolvedComponentInfos); - Intent sendIntent = createSendTextIntent(); - sendIntent.setType(TEST_MIME_TYPE); - - final IChooserWrapper activity = (IChooserWrapper) - mActivityRule.launchActivity(Intent.createChooser(sendIntent, "work tab test")); - waitForIdle(); - onView(withText(R.string.resolver_work_tab)).perform(click()); - waitForIdle(); - onView(withText(R.string.resolver_personal_tab)).perform(click()); - waitForIdle(); - - // TODO(b/211669337): Determine the expected SHARESHEET_DIRECT_LOAD_COMPLETE events. - } - - @Test - public void testWorkTab_onePersonalTarget_emptyStateOnWorkTarget_doesNotAutoLaunch() { - markOtherProfileAvailability(/* workAvailable= */ true, /* cloneAvailable= */ false); - int workProfileTargets = 4; - List personalResolvedComponentInfos = - createResolvedComponentsForTestWithOtherProfile(2, /* userId */ 10); - List workResolvedComponentInfos = - createResolvedComponentsForTest(workProfileTargets); - ChooserActivityOverrideData.getInstance().hasCrossProfileIntents = false; - setupResolverControllers(personalResolvedComponentInfos, workResolvedComponentInfos); - Intent sendIntent = createSendTextIntent(); - ResolveInfo[] chosen = new ResolveInfo[1]; - ChooserActivityOverrideData.getInstance().onSafelyStartInternalCallback = targetInfo -> { - chosen[0] = targetInfo.getResolveInfo(); - return true; - }; - - mActivityRule.launchActivity(Intent.createChooser(sendIntent, "Test")); - waitForIdle(); - - assertNull(chosen[0]); - } - - @Test - public void testOneInitialIntent_noAutolaunch() { - List personalResolvedComponentInfos = - createResolvedComponentsForTest(1); - setupResolverControllers(personalResolvedComponentInfos); - Intent chooserIntent = createChooserIntent(createSendTextIntent(), - new Intent[] {new Intent("action.fake")}); - ResolveInfo[] chosen = new ResolveInfo[1]; - ChooserActivityOverrideData.getInstance().onSafelyStartInternalCallback = targetInfo -> { - chosen[0] = targetInfo.getResolveInfo(); - return true; - }; - mPackageManager = createFakePackageManager(createFakeResolveInfo()); - waitForIdle(); - - IChooserWrapper activity = (IChooserWrapper) mActivityRule.launchActivity(chooserIntent); - waitForIdle(); - - assertNull(chosen[0]); - assertThat(activity - .getPersonalListAdapter().getCallerTargetCount(), is(1)); - } - - @Test - public void testWorkTab_withInitialIntents_workTabDoesNotIncludePersonalInitialIntents() { - markOtherProfileAvailability(/* workAvailable= */ true, /* cloneAvailable= */ false); - int workProfileTargets = 1; - List personalResolvedComponentInfos = - createResolvedComponentsForTestWithOtherProfile(2, /* userId */ 10); - List workResolvedComponentInfos = - createResolvedComponentsForTest(workProfileTargets); - setupResolverControllers(personalResolvedComponentInfos, workResolvedComponentInfos); - Intent[] initialIntents = { - new Intent("action.fake1"), - new Intent("action.fake2") - }; - Intent chooserIntent = createChooserIntent(createSendTextIntent(), initialIntents); - mPackageManager = createFakePackageManager(createFakeResolveInfo()); - waitForIdle(); - - IChooserWrapper activity = (IChooserWrapper) mActivityRule.launchActivity(chooserIntent); - waitForIdle(); - - assertThat(activity.getPersonalListAdapter().getCallerTargetCount(), is(2)); - assertThat(activity.getWorkListAdapter().getCallerTargetCount(), is(0)); - } - - @Test - public void testWorkTab_xProfileIntentsDisabled_personalToWork_nonSendIntent_emptyStateShown() { - markOtherProfileAvailability(/* workAvailable= */ true, /* cloneAvailable= */ false); - int workProfileTargets = 4; - List personalResolvedComponentInfos = - createResolvedComponentsForTestWithOtherProfile(3, /* userId */ 10); - List workResolvedComponentInfos = - createResolvedComponentsForTest(workProfileTargets); - ChooserActivityOverrideData.getInstance().hasCrossProfileIntents = false; - setupResolverControllers(personalResolvedComponentInfos, workResolvedComponentInfos); - Intent[] initialIntents = { - new Intent("action.fake1"), - new Intent("action.fake2") - }; - Intent chooserIntent = createChooserIntent(new Intent(), initialIntents); - mPackageManager = createFakePackageManager(createFakeResolveInfo()); - - - mActivityRule.launchActivity(chooserIntent); - waitForIdle(); - onView(withText(R.string.resolver_work_tab)).perform(click()); - waitForIdle(); - onView(withId(com.android.internal.R.id.contentPanel)) - .perform(swipeUp()); - - onView(withText(R.string.resolver_cross_profile_blocked)) - .check(matches(isDisplayed())); - } - - @Test - public void testWorkTab_noWorkAppsAvailable_nonSendIntent_emptyStateShown() { - markOtherProfileAvailability(/* workAvailable= */ true, /* cloneAvailable= */ false); - List personalResolvedComponentInfos = - createResolvedComponentsForTest(3); - List workResolvedComponentInfos = - createResolvedComponentsForTest(0); - setupResolverControllers(personalResolvedComponentInfos, workResolvedComponentInfos); - Intent[] initialIntents = { - new Intent("action.fake1"), - new Intent("action.fake2") - }; - Intent chooserIntent = createChooserIntent(new Intent(), initialIntents); - mPackageManager = createFakePackageManager(createFakeResolveInfo()); - - - mActivityRule.launchActivity(chooserIntent); - waitForIdle(); - onView(withId(com.android.internal.R.id.contentPanel)) - .perform(swipeUp()); - onView(withText(R.string.resolver_work_tab)).perform(click()); - waitForIdle(); - - onView(withText(R.string.resolver_no_work_apps_available)) - .check(matches(isDisplayed())); - } - - @Test - public void testDeduplicateCallerTargetRankedTarget() { - // Create 4 ranked app targets. - List personalResolvedComponentInfos = - createResolvedComponentsForTest(4); - setupResolverControllers(personalResolvedComponentInfos); - // Create caller target which is duplicate with one of app targets - Intent chooserIntent = createChooserIntent(createSendTextIntent(), - new Intent[] {new Intent("action.fake")}); - mPackageManager = createFakePackageManager(ResolverDataProvider.createResolveInfo(0, - UserHandle.USER_CURRENT, PERSONAL_USER_HANDLE)); - waitForIdle(); - - IChooserWrapper activity = (IChooserWrapper) mActivityRule.launchActivity(chooserIntent); - waitForIdle(); - - // Total 4 targets (1 caller target, 3 ranked targets) - assertThat(activity.getAdapter().getCount(), is(4)); - assertThat(activity.getAdapter().getCallerTargetCount(), is(1)); - assertThat(activity.getAdapter().getRankedTargetCount(), is(3)); - } - - @Test - public void test_query_shortcut_loader_for_the_selected_tab() { - markOtherProfileAvailability(/* workAvailable= */ true, /* cloneAvailable= */ false); - List personalResolvedComponentInfos = - createResolvedComponentsForTestWithOtherProfile(3, /* userId */ 10); - List workResolvedComponentInfos = - createResolvedComponentsForTest(3); - setupResolverControllers(personalResolvedComponentInfos, workResolvedComponentInfos); - ShortcutLoader personalProfileShortcutLoader = mock(ShortcutLoader.class); - ShortcutLoader workProfileShortcutLoader = mock(ShortcutLoader.class); - final SparseArray shortcutLoaders = new SparseArray<>(); - shortcutLoaders.put(0, personalProfileShortcutLoader); - shortcutLoaders.put(10, workProfileShortcutLoader); - ChooserActivityOverrideData.getInstance().shortcutLoaderFactory = - (userHandle, callback) -> shortcutLoaders.get(userHandle.getIdentifier(), null); - Intent sendIntent = createSendTextIntent(); - sendIntent.setType(TEST_MIME_TYPE); - - mActivityRule.launchActivity(Intent.createChooser(sendIntent, "work tab test")); - waitForIdle(); - onView(withId(com.android.internal.R.id.contentPanel)) - .perform(swipeUp()); - waitForIdle(); - - verify(personalProfileShortcutLoader, times(1)).updateAppTargets(any()); - - onView(withText(R.string.resolver_work_tab)).perform(click()); - waitForIdle(); - - verify(workProfileShortcutLoader, times(1)).updateAppTargets(any()); - } - - @Test - public void testClonedProfilePresent_personalAdapterIsSetWithPersonalProfile() { - // enable cloneProfile - markOtherProfileAvailability(/* workAvailable= */ false, /* cloneAvailable= */ true); - List resolvedComponentInfos = - createResolvedComponentsWithCloneProfileForTest( - 3, - PERSONAL_USER_HANDLE, - CLONE_PROFILE_USER_HANDLE); - setupResolverControllers(resolvedComponentInfos); - Intent sendIntent = createSendTextIntent(); - - final IChooserWrapper activity = (IChooserWrapper) mActivityRule - .launchActivity(Intent.createChooser(sendIntent, "personalProfileTest")); - waitForIdle(); - - assertThat(activity.getPersonalListAdapter().getUserHandle(), is(PERSONAL_USER_HANDLE)); - assertThat(activity.getAdapter().getCount(), is(3)); - } - - @Test - public void testClonedProfilePresent_personalTabUsesExpectedAdapter() { - markOtherProfileAvailability(/* workAvailable= */ true, /* cloneAvailable= */ true); - List personalResolvedComponentInfos = - createResolvedComponentsForTest(3); - List workResolvedComponentInfos = createResolvedComponentsForTest( - 4); - setupResolverControllers(personalResolvedComponentInfos, workResolvedComponentInfos); - Intent sendIntent = createSendTextIntent(); - sendIntent.setType(TEST_MIME_TYPE); - - - final IChooserWrapper activity = (IChooserWrapper) - mActivityRule.launchActivity(Intent.createChooser(sendIntent, "multi tab test")); - waitForIdle(); - - assertThat(activity.getCurrentUserHandle(), is(PERSONAL_USER_HANDLE)); - } - - private Intent createChooserIntent(Intent intent, Intent[] initialIntents) { - Intent chooserIntent = new Intent(); - chooserIntent.setAction(Intent.ACTION_CHOOSER); - chooserIntent.putExtra(Intent.EXTRA_TEXT, "testing intent sending"); - chooserIntent.putExtra(Intent.EXTRA_TITLE, "some title"); - chooserIntent.putExtra(Intent.EXTRA_INTENT, intent); - chooserIntent.setType("text/plain"); - if (initialIntents != null) { - chooserIntent.putExtra(Intent.EXTRA_INITIAL_INTENTS, initialIntents); - } - return chooserIntent; - } - - /* This is a "test of a test" to make sure that our inherited test class - * is successfully configured to operate on the unbundled-equivalent - * ChooserWrapperActivity. - * - * TODO: remove after unbundling is complete. - */ - @Test - public void testWrapperActivityHasExpectedConcreteType() { - final ChooserActivity activity = mActivityRule.launchActivity( - Intent.createChooser(new Intent("ACTION_FOO"), "foo")); - waitForIdle(); - assertThat(activity).isInstanceOf(ChooserWrapperActivity.class); - } - - private ResolveInfo createFakeResolveInfo() { - ResolveInfo ri = new ResolveInfo(); - ri.activityInfo = new ActivityInfo(); - ri.activityInfo.name = "FakeActivityName"; - ri.activityInfo.packageName = "fake.package.name"; - ri.activityInfo.applicationInfo = new ApplicationInfo(); - ri.activityInfo.applicationInfo.packageName = "fake.package.name"; - ri.userHandle = UserHandle.CURRENT; - return ri; - } - - private Intent createSendTextIntent() { - Intent sendIntent = new Intent(); - sendIntent.setAction(Intent.ACTION_SEND); - sendIntent.putExtra(Intent.EXTRA_TEXT, "testing intent sending"); - sendIntent.setType("text/plain"); - return sendIntent; - } - - private Intent createSendImageIntent(Uri imageThumbnail) { - Intent sendIntent = new Intent(); - sendIntent.setAction(Intent.ACTION_SEND); - sendIntent.putExtra(Intent.EXTRA_STREAM, imageThumbnail); - sendIntent.setType("image/png"); - if (imageThumbnail != null) { - ClipData.Item clipItem = new ClipData.Item(imageThumbnail); - sendIntent.setClipData(new ClipData("Clip Label", new String[]{"image/png"}, clipItem)); - } - - return sendIntent; - } - - private Uri createTestContentProviderUri( - @Nullable String mimeType, @Nullable String streamType) { - return createTestContentProviderUri(mimeType, streamType, 0); - } - - private Uri createTestContentProviderUri( - @Nullable String mimeType, @Nullable String streamType, long streamTypeTimeout) { - String packageName = - InstrumentationRegistry.getInstrumentation().getContext().getPackageName(); - Uri.Builder builder = Uri.parse("content://" + packageName + "/image.png") - .buildUpon(); - if (mimeType != null) { - builder.appendQueryParameter(TestContentProvider.PARAM_MIME_TYPE, mimeType); - } - if (streamType != null) { - builder.appendQueryParameter(TestContentProvider.PARAM_STREAM_TYPE, streamType); - } - if (streamTypeTimeout > 0) { - builder.appendQueryParameter( - TestContentProvider.PARAM_STREAM_TYPE_TIMEOUT, - Long.toString(streamTypeTimeout)); - } - return builder.build(); - } - - private Intent createSendTextIntentWithPreview(String title, Uri imageThumbnail) { - Intent sendIntent = new Intent(); - sendIntent.setAction(Intent.ACTION_SEND); - sendIntent.putExtra(Intent.EXTRA_TEXT, "testing intent sending"); - sendIntent.putExtra(Intent.EXTRA_TITLE, title); - if (imageThumbnail != null) { - ClipData.Item clipItem = new ClipData.Item(imageThumbnail); - sendIntent.setClipData(new ClipData("Clip Label", new String[]{"image/png"}, clipItem)); - } - - return sendIntent; - } - - private Intent createSendUriIntentWithPreview(ArrayList uris) { - Intent sendIntent = new Intent(); - - if (uris.size() > 1) { - sendIntent.setAction(Intent.ACTION_SEND_MULTIPLE); - sendIntent.putExtra(Intent.EXTRA_STREAM, uris); - } else { - sendIntent.setAction(Intent.ACTION_SEND); - sendIntent.putExtra(Intent.EXTRA_STREAM, uris.get(0)); - } - - return sendIntent; - } - - private Intent createViewTextIntent() { - Intent viewIntent = new Intent(); - viewIntent.setAction(Intent.ACTION_VIEW); - viewIntent.putExtra(Intent.EXTRA_TEXT, "testing intent viewing"); - return viewIntent; - } - - private List createResolvedComponentsForTest(int numberOfResults) { - List infoList = new ArrayList<>(numberOfResults); - for (int i = 0; i < numberOfResults; i++) { - infoList.add(ResolverDataProvider.createResolvedComponentInfo(i, PERSONAL_USER_HANDLE)); - } - return infoList; - } - - private List createResolvedComponentsWithCloneProfileForTest( - int numberOfResults, - UserHandle resolvedForPersonalUser, - UserHandle resolvedForClonedUser) { - List infoList = new ArrayList<>(numberOfResults); - for (int i = 0; i < 1; i++) { - infoList.add(ResolverDataProvider.createResolvedComponentInfo(i, - resolvedForPersonalUser)); - } - for (int i = 1; i < numberOfResults; i++) { - infoList.add(ResolverDataProvider.createResolvedComponentInfo(i, - resolvedForClonedUser)); - } - return infoList; - } - - private List createResolvedComponentsForTestWithOtherProfile( - int numberOfResults) { - List infoList = new ArrayList<>(numberOfResults); - for (int i = 0; i < numberOfResults; i++) { - if (i == 0) { - infoList.add(ResolverDataProvider.createResolvedComponentInfoWithOtherId(i, - PERSONAL_USER_HANDLE)); - } else { - infoList.add(ResolverDataProvider.createResolvedComponentInfo(i, - PERSONAL_USER_HANDLE)); - } - } - return infoList; - } - - private List createResolvedComponentsForTestWithOtherProfile( - int numberOfResults, int userId) { - List infoList = new ArrayList<>(numberOfResults); - for (int i = 0; i < numberOfResults; i++) { - if (i == 0) { - infoList.add( - ResolverDataProvider.createResolvedComponentInfoWithOtherId(i, userId, - PERSONAL_USER_HANDLE)); - } else { - infoList.add(ResolverDataProvider.createResolvedComponentInfo(i, - PERSONAL_USER_HANDLE)); - } - } - return infoList; - } - - private List createResolvedComponentsForTestWithUserId( - int numberOfResults, int userId) { - List infoList = new ArrayList<>(numberOfResults); - for (int i = 0; i < numberOfResults; i++) { - infoList.add(ResolverDataProvider.createResolvedComponentInfoWithOtherId(i, userId, - PERSONAL_USER_HANDLE)); - } - return infoList; - } - - private List createDirectShareTargets(int numberOfResults, String packageName) { - Icon icon = Icon.createWithBitmap(createBitmap()); - String testTitle = "testTitle"; - List targets = new ArrayList<>(); - for (int i = 0; i < numberOfResults; i++) { - ComponentName componentName; - if (packageName.isEmpty()) { - componentName = ResolverDataProvider.createComponentName(i); - } else { - componentName = new ComponentName(packageName, packageName + ".class"); - } - ChooserTarget tempTarget = new ChooserTarget( - testTitle + i, - icon, - (float) (1 - ((i + 1) / 10.0)), - componentName, - null); - targets.add(tempTarget); - } - return targets; - } - - private void waitForIdle() { - InstrumentationRegistry.getInstrumentation().waitForIdleSync(); - } - - private Bitmap createBitmap() { - return createBitmap(200, 200); - } - - private Bitmap createWideBitmap() { - return createWideBitmap(Color.RED); - } - - private Bitmap createWideBitmap(int bgColor) { - WindowManager windowManager = InstrumentationRegistry.getInstrumentation() - .getTargetContext() - .getSystemService(WindowManager.class); - int width = 3000; - if (windowManager != null) { - Rect bounds = windowManager.getMaximumWindowMetrics().getBounds(); - width = bounds.width() + 200; - } - return createBitmap(width, 100, bgColor); - } - - private Bitmap createBitmap(int width, int height) { - return createBitmap(width, height, Color.RED); - } - - private Bitmap createBitmap(int width, int height, int bgColor) { - Bitmap bitmap = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888); - Canvas canvas = new Canvas(bitmap); - - Paint paint = new Paint(); - paint.setColor(bgColor); - paint.setStyle(Paint.Style.FILL); - canvas.drawPaint(paint); - - paint.setColor(Color.WHITE); - paint.setAntiAlias(true); - paint.setTextSize(14.f); - paint.setTextAlign(Paint.Align.CENTER); - canvas.drawText("Hi!", (width / 2.f), (height / 2.f), paint); - - return bitmap; - } - - private List createShortcuts(Context context) { - Intent testIntent = new Intent("TestIntent"); - - List shortcuts = new ArrayList<>(); - shortcuts.add(new ShareShortcutInfo( - new ShortcutInfo.Builder(context, "shortcut1") - .setIntent(testIntent).setShortLabel("label1").setRank(3).build(), // 0 2 - new ComponentName("package1", "class1"))); - shortcuts.add(new ShareShortcutInfo( - new ShortcutInfo.Builder(context, "shortcut2") - .setIntent(testIntent).setShortLabel("label2").setRank(7).build(), // 1 3 - new ComponentName("package2", "class2"))); - shortcuts.add(new ShareShortcutInfo( - new ShortcutInfo.Builder(context, "shortcut3") - .setIntent(testIntent).setShortLabel("label3").setRank(1).build(), // 2 0 - new ComponentName("package3", "class3"))); - shortcuts.add(new ShareShortcutInfo( - new ShortcutInfo.Builder(context, "shortcut4") - .setIntent(testIntent).setShortLabel("label4").setRank(3).build(), // 3 2 - new ComponentName("package4", "class4"))); - - return shortcuts; - } - - private void markOtherProfileAvailability(boolean workAvailable, boolean cloneAvailable) { - if (workAvailable) { - mFakeUserRepo.addUser(WORK_USER, /* available= */ true); - } - if (cloneAvailable) { - mFakeUserRepo.addUser(CLONE_USER, /* available= */ true); - } - } - - private void setupResolverControllers( - List personalResolvedComponentInfos) { - setupResolverControllers(personalResolvedComponentInfos, new ArrayList<>()); - } - - private void setupResolverControllers( - List personalResolvedComponentInfos, - List workResolvedComponentInfos) { - when( - ChooserActivityOverrideData - .getInstance() - .resolverListController - .getResolversForIntentAsUser( - Mockito.anyBoolean(), - Mockito.anyBoolean(), - Mockito.anyBoolean(), - Mockito.isA(List.class), - eq(PERSONAL_USER_HANDLE))) - .thenReturn(new ArrayList<>(personalResolvedComponentInfos)); - when( - ChooserActivityOverrideData - .getInstance() - .workResolverListController - .getResolversForIntentAsUser( - Mockito.anyBoolean(), - Mockito.anyBoolean(), - Mockito.anyBoolean(), - Mockito.isA(List.class), - eq(WORK_PROFILE_USER_HANDLE))) - .thenReturn(new ArrayList<>(workResolvedComponentInfos)); - } - - private static GridRecyclerSpanCountMatcher withGridColumnCount(int columnCount) { - return new GridRecyclerSpanCountMatcher(Matchers.is(columnCount)); - } - - private static class GridRecyclerSpanCountMatcher extends - BoundedDiagnosingMatcher { - - private final Matcher mIntegerMatcher; - - private GridRecyclerSpanCountMatcher(Matcher integerMatcher) { - super(RecyclerView.class); - this.mIntegerMatcher = integerMatcher; - } - - @Override - protected void describeMoreTo(Description description) { - description.appendText("RecyclerView grid layout span count to match: "); - this.mIntegerMatcher.describeTo(description); - } - - @Override - protected boolean matchesSafely(RecyclerView view, Description mismatchDescription) { - int spanCount = ((GridLayoutManager) view.getLayoutManager()).getSpanCount(); - if (this.mIntegerMatcher.matches(spanCount)) { - return true; - } else { - mismatchDescription.appendText("RecyclerView grid layout span count was ") - .appendValue(spanCount); - return false; - } - } - } - - private void givenAppTargets(int appCount) { - List resolvedComponentInfos = - createResolvedComponentsForTest(appCount); - setupResolverControllers(resolvedComponentInfos); - } - - private void updateMaxTargetsPerRowResource(int targetsPerRow) { - Resources resources = Mockito.spy( - InstrumentationRegistry.getInstrumentation().getContext().getResources()); - ChooserActivityOverrideData.getInstance().resources = resources; - doReturn(targetsPerRow).when(resources).getInteger( - R.integer.config_chooser_max_targets_per_row); - } - - private SparseArray>> - createShortcutLoaderFactory() { - SparseArray>> shortcutLoaders = - new SparseArray<>(); - ChooserActivityOverrideData.getInstance().shortcutLoaderFactory = - (userHandle, callback) -> { - Pair> pair = - new Pair<>(mock(ShortcutLoader.class), callback); - shortcutLoaders.put(userHandle.getIdentifier(), pair); - return pair.first; - }; - return shortcutLoaders; - } -} diff --git a/tests/activity/src/com/android/intentresolver/v2/UnbundledChooserActivityWorkProfileTest.java b/tests/activity/src/com/android/intentresolver/v2/UnbundledChooserActivityWorkProfileTest.java deleted file mode 100644 index 8d83773e..00000000 --- a/tests/activity/src/com/android/intentresolver/v2/UnbundledChooserActivityWorkProfileTest.java +++ /dev/null @@ -1,507 +0,0 @@ -/* - * Copyright (C) 2022 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.android.intentresolver.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; -import static androidx.test.espresso.assertion.ViewAssertions.matches; -import static androidx.test.espresso.matcher.ViewMatchers.isDisplayed; -import static androidx.test.espresso.matcher.ViewMatchers.isSelected; -import static androidx.test.espresso.matcher.ViewMatchers.withId; -import static androidx.test.espresso.matcher.ViewMatchers.withText; - -import static com.android.intentresolver.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; -import static com.android.intentresolver.v2.UnbundledChooserActivityWorkProfileTest.TestCase.ExpectedBlocker.PERSONAL_PROFILE_SHARE_BLOCKER; -import static com.android.intentresolver.v2.UnbundledChooserActivityWorkProfileTest.TestCase.ExpectedBlocker.WORK_PROFILE_ACCESS_BLOCKER; -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; - -import android.companion.DeviceFilter; -import android.content.Intent; -import android.os.UserHandle; - -import androidx.test.platform.app.InstrumentationRegistry; -import androidx.test.espresso.NoMatchingViewException; -import androidx.test.rule.ActivityTestRule; - -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; - -import org.junit.Before; -import org.junit.Rule; -import org.junit.Test; -import org.junit.runner.RunWith; -import org.junit.runners.Parameterized; -import org.mockito.Mockito; - -import java.util.ArrayList; -import java.util.Arrays; -import java.util.Collection; -import java.util.List; - -@DeviceFilter.MediumType -@RunWith(Parameterized.class) -@HiltAndroidTest -@UninstallModules(UserRepositoryModule.class) -public class UnbundledChooserActivityWorkProfileTest { - - private static final UserHandle PERSONAL_USER_HANDLE = InstrumentationRegistry - .getInstrumentation().getTargetContext().getUser(); - private static final UserHandle WORK_USER_HANDLE = UserHandle.of(10); - - @Rule(order = 0) - public HiltAndroidRule mHiltAndroidRule = new HiltAndroidRule(this); - - @Rule(order = 1) - public ActivityTestRule 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 - public void cleanOverrideData() { - // TODO: use the other form of `adoptShellPermissionIdentity()` where we explicitly list the - // permissions we require (which we'll read from the manifest at runtime). - InstrumentationRegistry - .getInstrumentation() - .getUiAutomation() - .adoptShellPermissionIdentity(); - - sOverrides.reset(); - } - - @Test - public void testBlocker() { - setUpPersonalAndWorkComponentInfos(); - sOverrides.hasCrossProfileIntents = mTestCase.hasCrossProfileIntents(); - - launchActivity(mTestCase.getIsSendAction()); - switchToTab(mTestCase.getTab()); - - switch (mTestCase.getExpectedBlocker()) { - case NO_BLOCKER: - assertNoBlockerDisplayed(); - break; - case PERSONAL_PROFILE_SHARE_BLOCKER: - assertCantSharePersonalAppsBlockerDisplayed(); - break; - case WORK_PROFILE_SHARE_BLOCKER: - assertCantShareWorkAppsBlockerDisplayed(); - break; - case PERSONAL_PROFILE_ACCESS_BLOCKER: - assertCantAccessPersonalAppsBlockerDisplayed(); - break; - case WORK_PROFILE_ACCESS_BLOCKER: - assertCantAccessWorkAppsBlockerDisplayed(); - break; - } - } - - @Parameterized.Parameters(name = "{0}") - public static Collection tests() { - return Arrays.asList( - new TestCase( - /* isSendAction= */ true, - /* hasCrossProfileIntents= */ true, - /* myUserHandle= */ WORK_USER_HANDLE, - /* tab= */ WORK, - /* expectedBlocker= */ NO_BLOCKER - ), - new TestCase( - /* isSendAction= */ true, - /* hasCrossProfileIntents= */ false, - /* myUserHandle= */ WORK_USER_HANDLE, - /* tab= */ WORK, - /* expectedBlocker= */ NO_BLOCKER - ), - new TestCase( - /* isSendAction= */ true, - /* hasCrossProfileIntents= */ true, - /* myUserHandle= */ PERSONAL_USER_HANDLE, - /* tab= */ WORK, - /* expectedBlocker= */ NO_BLOCKER - ), - new TestCase( - /* isSendAction= */ true, - /* hasCrossProfileIntents= */ false, - /* myUserHandle= */ PERSONAL_USER_HANDLE, - /* tab= */ WORK, - /* expectedBlocker= */ WORK_PROFILE_SHARE_BLOCKER - ), - new TestCase( - /* isSendAction= */ true, - /* hasCrossProfileIntents= */ true, - /* myUserHandle= */ WORK_USER_HANDLE, - /* tab= */ PERSONAL, - /* expectedBlocker= */ NO_BLOCKER - ), - new TestCase( - /* isSendAction= */ true, - /* hasCrossProfileIntents= */ false, - /* myUserHandle= */ WORK_USER_HANDLE, - /* tab= */ PERSONAL, - /* expectedBlocker= */ PERSONAL_PROFILE_SHARE_BLOCKER - ), - new TestCase( - /* isSendAction= */ true, - /* hasCrossProfileIntents= */ true, - /* myUserHandle= */ PERSONAL_USER_HANDLE, - /* tab= */ PERSONAL, - /* expectedBlocker= */ NO_BLOCKER - ), - new TestCase( - /* isSendAction= */ true, - /* hasCrossProfileIntents= */ false, - /* myUserHandle= */ PERSONAL_USER_HANDLE, - /* tab= */ PERSONAL, - /* expectedBlocker= */ NO_BLOCKER - ), - new TestCase( - /* isSendAction= */ false, - /* hasCrossProfileIntents= */ true, - /* myUserHandle= */ WORK_USER_HANDLE, - /* tab= */ WORK, - /* expectedBlocker= */ NO_BLOCKER - ), - new TestCase( - /* isSendAction= */ false, - /* hasCrossProfileIntents= */ false, - /* myUserHandle= */ WORK_USER_HANDLE, - /* tab= */ WORK, - /* expectedBlocker= */ NO_BLOCKER - ), - new TestCase( - /* isSendAction= */ false, - /* hasCrossProfileIntents= */ true, - /* myUserHandle= */ PERSONAL_USER_HANDLE, - /* tab= */ WORK, - /* expectedBlocker= */ NO_BLOCKER - ), - new TestCase( - /* isSendAction= */ false, - /* hasCrossProfileIntents= */ false, - /* myUserHandle= */ PERSONAL_USER_HANDLE, - /* tab= */ WORK, - /* expectedBlocker= */ WORK_PROFILE_ACCESS_BLOCKER - ), - new TestCase( - /* isSendAction= */ false, - /* hasCrossProfileIntents= */ true, - /* myUserHandle= */ WORK_USER_HANDLE, - /* tab= */ PERSONAL, - /* expectedBlocker= */ NO_BLOCKER - ), - new TestCase( - /* isSendAction= */ false, - /* hasCrossProfileIntents= */ false, - /* myUserHandle= */ WORK_USER_HANDLE, - /* tab= */ PERSONAL, - /* expectedBlocker= */ PERSONAL_PROFILE_ACCESS_BLOCKER - ), - new TestCase( - /* isSendAction= */ false, - /* hasCrossProfileIntents= */ true, - /* myUserHandle= */ PERSONAL_USER_HANDLE, - /* tab= */ PERSONAL, - /* expectedBlocker= */ NO_BLOCKER - ), - new TestCase( - /* isSendAction= */ false, - /* hasCrossProfileIntents= */ false, - /* myUserHandle= */ PERSONAL_USER_HANDLE, - /* tab= */ PERSONAL, - /* expectedBlocker= */ NO_BLOCKER - ) - ); - } - - private List createResolvedComponentsForTestWithOtherProfile( - int numberOfResults, int userId, UserHandle resolvedForUser) { - List infoList = new ArrayList<>(numberOfResults); - for (int i = 0; i < numberOfResults; i++) { - infoList.add( - ResolverDataProvider - .createResolvedComponentInfoWithOtherId(i, userId, resolvedForUser)); - } - return infoList; - } - - private List createResolvedComponentsForTest(int numberOfResults, - UserHandle resolvedForUser) { - List infoList = new ArrayList<>(numberOfResults); - for (int i = 0; i < numberOfResults; i++) { - infoList.add(ResolverDataProvider.createResolvedComponentInfo(i, resolvedForUser)); - } - return infoList; - } - - private void setUpPersonalAndWorkComponentInfos() { - int workProfileTargets = 4; - List personalResolvedComponentInfos = - createResolvedComponentsForTestWithOtherProfile(3, - /* userId */ WORK_USER_HANDLE.getIdentifier(), PERSONAL_USER_HANDLE); - List workResolvedComponentInfos = - createResolvedComponentsForTest(workProfileTargets, WORK_USER_HANDLE); - setupResolverControllers(personalResolvedComponentInfos, workResolvedComponentInfos); - } - - private void setupResolverControllers( - List personalResolvedComponentInfos, - List workResolvedComponentInfos) { - when(sOverrides.resolverListController.getResolversForIntentAsUser( - Mockito.anyBoolean(), - Mockito.anyBoolean(), - Mockito.anyBoolean(), - Mockito.isA(List.class), - eq(UserHandle.SYSTEM))) - .thenReturn(new ArrayList<>(personalResolvedComponentInfos)); - when(sOverrides.workResolverListController.getResolversForIntentAsUser( - Mockito.anyBoolean(), - Mockito.anyBoolean(), - Mockito.anyBoolean(), - Mockito.isA(List.class), - eq(UserHandle.SYSTEM))) - .thenReturn(new ArrayList<>(personalResolvedComponentInfos)); - when(sOverrides.workResolverListController.getResolversForIntentAsUser( - Mockito.anyBoolean(), - Mockito.anyBoolean(), - Mockito.anyBoolean(), - Mockito.isA(List.class), - eq(WORK_USER_HANDLE))) - .thenReturn(new ArrayList<>(workResolvedComponentInfos)); - } - - private void waitForIdle() { - InstrumentationRegistry.getInstrumentation().waitForIdleSync(); - } - - private void assertCantAccessWorkAppsBlockerDisplayed() { - onView(withText(R.string.resolver_cross_profile_blocked)) - .check(matches(isDisplayed())); - onView(withText(R.string.resolver_cant_access_work_apps_explanation)) - .check(matches(isDisplayed())); - } - - private void assertCantAccessPersonalAppsBlockerDisplayed() { - onView(withText(R.string.resolver_cross_profile_blocked)) - .check(matches(isDisplayed())); - onView(withText(R.string.resolver_cant_access_personal_apps_explanation)) - .check(matches(isDisplayed())); - } - - private void assertCantShareWorkAppsBlockerDisplayed() { - onView(withText(R.string.resolver_cross_profile_blocked)) - .check(matches(isDisplayed())); - onView(withText(R.string.resolver_cant_share_with_work_apps_explanation)) - .check(matches(isDisplayed())); - } - - private void assertCantSharePersonalAppsBlockerDisplayed() { - onView(withText(R.string.resolver_cross_profile_blocked)) - .check(matches(isDisplayed())); - onView(withText(R.string.resolver_cant_share_with_personal_apps_explanation)) - .check(matches(isDisplayed())); - } - - private void assertNoBlockerDisplayed() { - try { - onView(withText(R.string.resolver_cross_profile_blocked)) - .check(matches(not(isDisplayed()))); - } catch (NoMatchingViewException ignored) { - } - } - - private void switchToTab(Tab tab) { - final int stringId = tab == Tab.WORK ? R.string.resolver_work_tab - : R.string.resolver_personal_tab; - - waitFor(() -> { - onView(withText(stringId)).perform(click()); - waitForIdle(); - - try { - onView(withText(stringId)).check(matches(isSelected())); - return true; - } catch (AssertionFailedError e) { - return false; - } - }); - - onView(withId(com.android.internal.R.id.contentPanel)) - .perform(swipeUp()); - waitForIdle(); - } - - private Intent createTextIntent(boolean isSendAction) { - Intent sendIntent = new Intent(); - if (isSendAction) { - sendIntent.setAction(Intent.ACTION_SEND); - } - sendIntent.putExtra(Intent.EXTRA_TEXT, "testing intent sending"); - sendIntent.setType("text/plain"); - return sendIntent; - } - - private void launchActivity(boolean isSendAction) { - Intent sendIntent = createTextIntent(isSendAction); - mActivityRule.launchActivity(Intent.createChooser(sendIntent, "Test")); - waitForIdle(); - } - - public static class TestCase { - private final boolean mIsSendAction; - private final boolean mHasCrossProfileIntents; - private final UserHandle mMyUserHandle; - private final Tab mTab; - private final ExpectedBlocker mExpectedBlocker; - - public enum ExpectedBlocker { - NO_BLOCKER, - PERSONAL_PROFILE_SHARE_BLOCKER, - WORK_PROFILE_SHARE_BLOCKER, - PERSONAL_PROFILE_ACCESS_BLOCKER, - WORK_PROFILE_ACCESS_BLOCKER - } - - public enum Tab { - WORK, - PERSONAL - } - - public TestCase(boolean isSendAction, boolean hasCrossProfileIntents, - UserHandle myUserHandle, Tab tab, ExpectedBlocker expectedBlocker) { - mIsSendAction = isSendAction; - mHasCrossProfileIntents = hasCrossProfileIntents; - mMyUserHandle = myUserHandle; - mTab = tab; - mExpectedBlocker = expectedBlocker; - } - - public boolean getIsSendAction() { - return mIsSendAction; - } - - public boolean hasCrossProfileIntents() { - return mHasCrossProfileIntents; - } - - public UserHandle getMyUserHandle() { - return mMyUserHandle; - } - - public Tab getTab() { - return mTab; - } - - public ExpectedBlocker getExpectedBlocker() { - return mExpectedBlocker; - } - - @Override - public String toString() { - StringBuilder result = new StringBuilder("test"); - - if (mTab == WORK) { - result.append("WorkTab_"); - } else { - result.append("PersonalTab_"); - } - - if (mIsSendAction) { - result.append("sendAction_"); - } else { - result.append("notSendAction_"); - } - - if (mHasCrossProfileIntents) { - result.append("hasCrossProfileIntents_"); - } else { - result.append("doesNotHaveCrossProfileIntents_"); - } - - if (mMyUserHandle.equals(PERSONAL_USER_HANDLE)) { - result.append("myUserIsPersonal_"); - } else { - result.append("myUserIsWork_"); - } - - if (mExpectedBlocker == ExpectedBlocker.NO_BLOCKER) { - result.append("thenNoBlocker"); - } else if (mExpectedBlocker == PERSONAL_PROFILE_ACCESS_BLOCKER) { - result.append("thenAccessBlockerOnPersonalProfile"); - } else if (mExpectedBlocker == PERSONAL_PROFILE_SHARE_BLOCKER) { - result.append("thenShareBlockerOnPersonalProfile"); - } else if (mExpectedBlocker == WORK_PROFILE_ACCESS_BLOCKER) { - result.append("thenAccessBlockerOnWorkProfile"); - } else if (mExpectedBlocker == WORK_PROFILE_SHARE_BLOCKER) { - result.append("thenShareBlockerOnWorkProfile"); - } - - return result.toString(); - } - } -} diff --git a/tests/shared/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/interactor/PayloadToggleInteractorKosmos.kt b/tests/shared/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/interactor/PayloadToggleInteractorKosmos.kt index 9e34acff..659c178c 100644 --- a/tests/shared/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/interactor/PayloadToggleInteractorKosmos.kt +++ b/tests/shared/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/interactor/PayloadToggleInteractorKosmos.kt @@ -29,10 +29,10 @@ import com.android.intentresolver.contentpreview.payloadtoggle.domain.intent.pen import com.android.intentresolver.contentpreview.payloadtoggle.domain.intent.targetIntentModifier import com.android.intentresolver.contentpreview.payloadtoggle.domain.update.selectionChangeCallback import com.android.intentresolver.contentpreview.uriMetadataReader +import com.android.intentresolver.data.repository.chooserRequestRepository import com.android.intentresolver.inject.contentUris import com.android.intentresolver.logging.eventLog import com.android.intentresolver.packageManager -import com.android.intentresolver.v2.data.repository.chooserRequestRepository import com.android.systemui.kosmos.Kosmos import com.android.systemui.kosmos.Kosmos.Fixture diff --git a/tests/shared/src/com/android/intentresolver/data/repository/FakeUserRepository.kt b/tests/shared/src/com/android/intentresolver/data/repository/FakeUserRepository.kt new file mode 100644 index 00000000..fb8fbd3f --- /dev/null +++ b/tests/shared/src/com/android/intentresolver/data/repository/FakeUserRepository.kt @@ -0,0 +1,65 @@ +/* + * Copyright (C) 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.intentresolver.data.repository + +import com.android.intentresolver.shared.model.User +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.update + +/** A simple repository which can be initialized from a list and updated. */ +class FakeUserRepository(userList: List) : UserRepository { + internal data class UserState(val user: User, val available: Boolean) + + private val userState = MutableStateFlow(userList.map { UserState(it, available = true) }) + + // Expose a List from List + override val users = userState.map { userList -> userList.map { it.user } } + + fun addUser(user: User, available: Boolean) { + require(userState.value.none { it.user.id == user.id }) { + "A User with ${user.id} already exists!" + } + userState.update { it + UserState(user, available) } + } + + fun removeUser(user: User) { + require(userState.value.any { it.user.id == user.id }) { + "A User with ${user.id} does not exist!" + } + userState.update { it.filterNot { state -> state.user.id == user.id } } + } + + override val availability = + userState.map { userStateList -> userStateList.associate { it.user to it.available } } + + fun updateState(user: User, available: Boolean) { + userState.update { userStateList -> + userStateList.map { userState -> + if (userState.user.id == user.id) { + UserState(user, available) + } else { + userState + } + } + } + } + + override suspend fun requestState(user: User, available: Boolean) { + updateState(user, available) + } +} diff --git a/tests/shared/src/com/android/intentresolver/data/repository/V2RepositoryKosmos.kt b/tests/shared/src/com/android/intentresolver/data/repository/V2RepositoryKosmos.kt new file mode 100644 index 00000000..0b2d3eb4 --- /dev/null +++ b/tests/shared/src/com/android/intentresolver/data/repository/V2RepositoryKosmos.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.data.repository + +import android.content.Intent +import com.android.intentresolver.data.model.ChooserRequest +import com.android.systemui.kosmos.Kosmos +import com.android.systemui.kosmos.Kosmos.Fixture + +var Kosmos.chooserRequestRepository by Fixture { + ChooserRequestRepository( + initialRequest = ChooserRequest(targetIntent = Intent(), launchedFromPackage = "pkg"), + initialActions = emptyList() + ) +} diff --git a/tests/shared/src/com/android/intentresolver/ext/ParcelableExt.kt b/tests/shared/src/com/android/intentresolver/ext/ParcelableExt.kt new file mode 100644 index 00000000..0b9caa32 --- /dev/null +++ b/tests/shared/src/com/android/intentresolver/ext/ParcelableExt.kt @@ -0,0 +1,45 @@ +/* + * Copyright (C) 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.intentresolver.ext + +import android.os.Parcel +import android.os.Parcelable +import java.lang.reflect.Field + +inline fun T.toParcelAndBack(): T { + val creator: Parcelable.Creator = getCreator() + val parcel = Parcel.obtain() + writeToParcel(parcel, 0) + parcel.setDataPosition(0) + return creator.createFromParcel(parcel) +} + +inline fun getCreator(): Parcelable.Creator { + return getCreator(T::class.java) +} + +inline fun getCreator(clazz: Class): Parcelable.Creator { + return try { + val field: Field = clazz.getDeclaredField("CREATOR") + @Suppress("UNCHECKED_CAST") + field.get(null) as Parcelable.Creator + } catch (e: NoSuchFieldException) { + error("$clazz is a Parcelable without CREATOR") + } catch (e: IllegalAccessException) { + error("CREATOR in $clazz::class is not accessible") + } +} diff --git a/tests/shared/src/com/android/intentresolver/platform/FakeSecureSettings.kt b/tests/shared/src/com/android/intentresolver/platform/FakeSecureSettings.kt new file mode 100644 index 00000000..25711b70 --- /dev/null +++ b/tests/shared/src/com/android/intentresolver/platform/FakeSecureSettings.kt @@ -0,0 +1,44 @@ +package com.android.intentresolver.platform + +/** + * Creates a SecureSettings instance with predefined values: + * + * val settings = fakeSecureSettings { + * putString("stringValue", "example") + * putInt("intValue", 42) + * } + */ +fun fakeSecureSettings(block: FakeSecureSettings.Builder.() -> Unit): SecureSettings { + return FakeSecureSettings.Builder().apply(block).build() +} + +/** An in memory implementation of [SecureSettings]. */ +class FakeSecureSettings private constructor(private val map: Map) : + SecureSettings { + + override fun getString(name: String): String? = map[name] + override fun getInt(name: String): Int? = getString(name)?.toIntOrNull() + override fun getLong(name: String): Long? = getString(name)?.toLongOrNull() + override fun getFloat(name: String): Float? = getString(name)?.toFloatOrNull() + + class Builder { + private val map = mutableMapOf() + + fun putString(name: String, value: String) { + map[name] = value + } + fun putInt(name: String, value: Int) { + map[name] = value.toString() + } + fun putLong(name: String, value: Long) { + map[name] = value.toString() + } + fun putFloat(name: String, value: Float) { + map[name] = value.toString() + } + + fun build(): SecureSettings { + return FakeSecureSettings(map.toMap()) + } + } +} diff --git a/tests/shared/src/com/android/intentresolver/platform/FakeUserManager.kt b/tests/shared/src/com/android/intentresolver/platform/FakeUserManager.kt new file mode 100644 index 00000000..b357a691 --- /dev/null +++ b/tests/shared/src/com/android/intentresolver/platform/FakeUserManager.kt @@ -0,0 +1,222 @@ +package com.android.intentresolver.platform + +import android.content.Context +import android.content.pm.UserInfo +import android.content.pm.UserInfo.FLAG_FULL +import android.content.pm.UserInfo.FLAG_INITIALIZED +import android.content.pm.UserInfo.FLAG_PROFILE +import android.content.pm.UserInfo.NO_PROFILE_GROUP_ID +import android.os.IUserManager +import android.os.UserHandle +import android.os.UserManager +import androidx.annotation.NonNull +import com.android.intentresolver.THROWS_EXCEPTION +import com.android.intentresolver.data.repository.AvailabilityChange +import com.android.intentresolver.data.repository.ProfileAdded +import com.android.intentresolver.data.repository.ProfileRemoved +import com.android.intentresolver.data.repository.UserEvent +import com.android.intentresolver.mock +import com.android.intentresolver.platform.FakeUserManager.State +import com.android.intentresolver.whenever +import kotlin.random.Random +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.consumeAsFlow +import org.mockito.Mockito.RETURNS_SELF +import org.mockito.Mockito.doAnswer +import org.mockito.Mockito.doReturn +import org.mockito.Mockito.withSettings + +/** + * A stand-in for [UserManager] to support testing of data layer components which depend on it. + * + * This fake targets system applications which need to interact with any or all of the current + * user's associated profiles (as reported by [getEnabledProfiles]). Support for manipulating + * non-profile (full) secondary users (switching active foreground user, adding or removing users) + * is not included. + * + * Upon creation [FakeUserManager] contains a single primary (full) user with a randomized ID. This + * is available from [FakeUserManager.state] using [primaryUserHandle][State.primaryUserHandle] or + * [getPrimaryUser][State.getPrimaryUser]. + * + * To make state changes, use functions available from [FakeUserManager.state]: + * * [createProfile][State.createProfile] + * * [removeProfile][State.removeProfile] + * * [setQuietMode][State.setQuietMode] + * + * Any functionality not explicitly overridden here is guaranteed to throw an exception when + * accessed (access to the real system service is prevented). + */ +class FakeUserManager(val state: State = State()) : + UserManager(/* context = */ mockContext(), /* service = */ mockService()) { + + enum class ProfileType { + WORK, + CLONE, + PRIVATE + } + + override fun getProfileParent(userHandle: UserHandle): UserHandle? { + return state.getUserOrNull(userHandle)?.let { user -> + if (user.isProfile) { + state.getUserOrNull(UserHandle.of(user.profileGroupId))?.userHandle + } else { + null + } + } + } + + override fun getUserInfo(userId: Int): UserInfo? { + return state.getUserOrNull(UserHandle.of(userId)) + } + + @Suppress("OVERRIDE_DEPRECATION") + override fun getEnabledProfiles(userId: Int): List { + val user = state.users.single { it.id == userId } + return state.users.filter { other -> + user.id == other.id || user.profileGroupId == other.profileGroupId + } + } + + override fun requestQuietModeEnabled( + enableQuietMode: Boolean, + @NonNull userHandle: UserHandle + ): Boolean { + state.setQuietMode(userHandle, enableQuietMode) + return true + } + + override fun isQuietModeEnabled(userHandle: UserHandle): Boolean { + return state.getUser(userHandle).isQuietModeEnabled + } + + override fun toString(): String { + return "FakeUserManager(state=$state)" + } + + class State { + private val eventChannel = Channel() + private val userInfoMap: MutableMap = mutableMapOf() + + /** The id of the primary/full/system user, which is automatically created. */ + val primaryUserHandle: UserHandle + + /** + * Retrieves the primary user. The value returned changes, but the values are immutable. + * + * Do not cache this value in tests, between operations. + */ + fun getPrimaryUser(): UserInfo = getUser(primaryUserHandle) + + private var nextUserId: Int = 100 + Random.nextInt(0, 900) + + /** + * A flow of [UserEvent] which emulates those normally generated from system broadcasts. + * + * Events are produced by calls to [createPrimaryUser], [createProfile], [removeProfile]. + */ + val userEvents: Flow + + val users: List + get() = userInfoMap.values.toList() + + val userHandles: List + get() = userInfoMap.keys.toList() + + init { + primaryUserHandle = createPrimaryUser(allocateNextId()) + userEvents = eventChannel.consumeAsFlow() + } + + private fun allocateNextId() = nextUserId++ + + private fun createPrimaryUser(id: Int): UserHandle { + val userInfo = + UserInfo(id, "", "", FLAG_INITIALIZED or FLAG_FULL, USER_TYPE_FULL_SYSTEM) + userInfoMap[userInfo.userHandle] = userInfo + return userInfo.userHandle + } + + fun getUserOrNull(handle: UserHandle): UserInfo? = userInfoMap[handle] + + fun getUser(handle: UserHandle): UserInfo = + requireNotNull(getUserOrNull(handle)) { + "Expected userInfoMap to contain an entry for $handle" + } + + fun setQuietMode(user: UserHandle, quietMode: Boolean) { + userInfoMap[user]?.also { + it.flags = + if (quietMode) { + it.flags or UserInfo.FLAG_QUIET_MODE + } else { + it.flags and UserInfo.FLAG_QUIET_MODE.inv() + } + eventChannel.trySend(AvailabilityChange(user, quietMode)) + } + } + + fun createProfile(type: ProfileType, parent: UserHandle = primaryUserHandle): UserHandle { + val parentUser = getUser(parent) + require(!parentUser.isProfile) { "Parent user cannot be a profile" } + + // Ensure the parent user has a valid profileGroupId + if (parentUser.profileGroupId == NO_PROFILE_GROUP_ID) { + parentUser.profileGroupId = parentUser.id + } + val id = allocateNextId() + val userInfo = + UserInfo(id, "", "", FLAG_INITIALIZED or FLAG_PROFILE, type.toUserType()).apply { + profileGroupId = parentUser.profileGroupId + } + userInfoMap[userInfo.userHandle] = userInfo + eventChannel.trySend(ProfileAdded(userInfo.userHandle)) + return userInfo.userHandle + } + + fun removeProfile(handle: UserHandle): Boolean { + return userInfoMap[handle]?.let { user -> + require(user.isProfile) { "Only profiles can be removed" } + userInfoMap.remove(user.userHandle) + eventChannel.trySend(ProfileRemoved(user.userHandle)) + return true + } + ?: false + } + + override fun toString() = buildString { + append("State(nextUserId=$nextUserId, userInfoMap=[") + userInfoMap.entries.forEach { + append("UserHandle[${it.key.identifier}] = ${it.value.debugString},") + } + append("])") + } + } +} + +/** A safe mock of [Context] which throws on any unstubbed method call. */ +private fun mockContext(user: UserHandle = UserHandle.SYSTEM): Context { + return mock(withSettings().defaultAnswer(THROWS_EXCEPTION)) { + doAnswer(RETURNS_SELF).whenever(this).applicationContext + doReturn(user).whenever(this).user + doReturn(user.identifier).whenever(this).userId + } +} + +private fun FakeUserManager.ProfileType.toUserType(): String { + return when (this) { + FakeUserManager.ProfileType.WORK -> UserManager.USER_TYPE_PROFILE_MANAGED + FakeUserManager.ProfileType.CLONE -> UserManager.USER_TYPE_PROFILE_CLONE + FakeUserManager.ProfileType.PRIVATE -> UserManager.USER_TYPE_PROFILE_PRIVATE + } +} + +/** A safe mock of [IUserManager] which throws on any unstubbed method call. */ +fun mockService(): IUserManager { + return mock(withSettings().defaultAnswer(THROWS_EXCEPTION)) +} + +val UserInfo.debugString: String + get() = + "UserInfo(id=$id, profileGroupId=$profileGroupId, name=$name, " + + "type=$userType, flags=${UserInfo.flagsToString(flags)})" diff --git a/tests/shared/src/com/android/intentresolver/v2/data/model/FakeChooserRequest.kt b/tests/shared/src/com/android/intentresolver/v2/data/model/FakeChooserRequest.kt deleted file mode 100644 index e697a13d..00000000 --- a/tests/shared/src/com/android/intentresolver/v2/data/model/FakeChooserRequest.kt +++ /dev/null @@ -1,26 +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.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, referrer) diff --git a/tests/shared/src/com/android/intentresolver/v2/data/repository/FakeUserRepository.kt b/tests/shared/src/com/android/intentresolver/v2/data/repository/FakeUserRepository.kt deleted file mode 100644 index 73d9a084..00000000 --- a/tests/shared/src/com/android/intentresolver/v2/data/repository/FakeUserRepository.kt +++ /dev/null @@ -1,65 +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.v2.data.repository - -import com.android.intentresolver.v2.shared.model.User -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.map -import kotlinx.coroutines.flow.update - -/** A simple repository which can be initialized from a list and updated. */ -class FakeUserRepository(userList: List) : UserRepository { - internal data class UserState(val user: User, val available: Boolean) - - private val userState = MutableStateFlow(userList.map { UserState(it, available = true) }) - - // Expose a List from List - override val users = userState.map { userList -> userList.map { it.user } } - - fun addUser(user: User, available: Boolean) { - require(userState.value.none { it.user.id == user.id }) { - "A User with ${user.id} already exists!" - } - userState.update { it + UserState(user, available) } - } - - fun removeUser(user: User) { - require(userState.value.any { it.user.id == user.id }) { - "A User with ${user.id} does not exist!" - } - userState.update { it.filterNot { state -> state.user.id == user.id } } - } - - override val availability = - userState.map { userStateList -> userStateList.associate { it.user to it.available } } - - fun updateState(user: User, available: Boolean) { - userState.update { userStateList -> - userStateList.map { userState -> - if (userState.user.id == user.id) { - UserState(user, available) - } else { - userState - } - } - } - } - - override suspend fun requestState(user: User, available: Boolean) { - updateState(user, available) - } -} diff --git a/tests/shared/src/com/android/intentresolver/v2/data/repository/V2RepositoryKosmos.kt b/tests/shared/src/com/android/intentresolver/v2/data/repository/V2RepositoryKosmos.kt deleted file mode 100644 index ec6b2dec..00000000 --- a/tests/shared/src/com/android/intentresolver/v2/data/repository/V2RepositoryKosmos.kt +++ /dev/null @@ -1,25 +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.v2.data.repository - -import com.android.intentresolver.v2.data.model.fakeChooserRequest -import com.android.systemui.kosmos.Kosmos -import com.android.systemui.kosmos.Kosmos.Fixture - -var Kosmos.chooserRequestRepository by Fixture { - ChooserRequestRepository(fakeChooserRequest(), emptyList()) -} diff --git a/tests/shared/src/com/android/intentresolver/v2/ext/ParcelableExt.kt b/tests/shared/src/com/android/intentresolver/v2/ext/ParcelableExt.kt deleted file mode 100644 index 3878c39c..00000000 --- a/tests/shared/src/com/android/intentresolver/v2/ext/ParcelableExt.kt +++ /dev/null @@ -1,45 +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.v2.ext - -import android.os.Parcel -import android.os.Parcelable -import java.lang.reflect.Field - -inline fun T.toParcelAndBack(): T { - val creator: Parcelable.Creator = getCreator() - val parcel = Parcel.obtain() - writeToParcel(parcel, 0) - parcel.setDataPosition(0) - return creator.createFromParcel(parcel) -} - -inline fun getCreator(): Parcelable.Creator { - return getCreator(T::class.java) -} - -inline fun getCreator(clazz: Class): Parcelable.Creator { - return try { - val field: Field = clazz.getDeclaredField("CREATOR") - @Suppress("UNCHECKED_CAST") - field.get(null) as Parcelable.Creator - } catch (e: NoSuchFieldException) { - error("$clazz is a Parcelable without CREATOR") - } catch (e: IllegalAccessException) { - error("CREATOR in $clazz::class is not accessible") - } -} diff --git a/tests/shared/src/com/android/intentresolver/v2/platform/FakeSecureSettings.kt b/tests/shared/src/com/android/intentresolver/v2/platform/FakeSecureSettings.kt deleted file mode 100644 index 4e279623..00000000 --- a/tests/shared/src/com/android/intentresolver/v2/platform/FakeSecureSettings.kt +++ /dev/null @@ -1,44 +0,0 @@ -package com.android.intentresolver.v2.platform - -/** - * Creates a SecureSettings instance with predefined values: - * - * val settings = fakeSecureSettings { - * putString("stringValue", "example") - * putInt("intValue", 42) - * } - */ -fun fakeSecureSettings(block: FakeSecureSettings.Builder.() -> Unit): SecureSettings { - return FakeSecureSettings.Builder().apply(block).build() -} - -/** An in memory implementation of [SecureSettings]. */ -class FakeSecureSettings private constructor(private val map: Map) : - SecureSettings { - - override fun getString(name: String): String? = map[name] - override fun getInt(name: String): Int? = getString(name)?.toIntOrNull() - override fun getLong(name: String): Long? = getString(name)?.toLongOrNull() - override fun getFloat(name: String): Float? = getString(name)?.toFloatOrNull() - - class Builder { - private val map = mutableMapOf() - - fun putString(name: String, value: String) { - map[name] = value - } - fun putInt(name: String, value: Int) { - map[name] = value.toString() - } - fun putLong(name: String, value: Long) { - map[name] = value.toString() - } - fun putFloat(name: String, value: Float) { - map[name] = value.toString() - } - - fun build(): SecureSettings { - return FakeSecureSettings(map.toMap()) - } - } -} diff --git a/tests/shared/src/com/android/intentresolver/v2/platform/FakeUserManager.kt b/tests/shared/src/com/android/intentresolver/v2/platform/FakeUserManager.kt deleted file mode 100644 index d1b56d5f..00000000 --- a/tests/shared/src/com/android/intentresolver/v2/platform/FakeUserManager.kt +++ /dev/null @@ -1,222 +0,0 @@ -package com.android.intentresolver.v2.platform - -import android.content.Context -import android.content.pm.UserInfo -import android.content.pm.UserInfo.FLAG_FULL -import android.content.pm.UserInfo.FLAG_INITIALIZED -import android.content.pm.UserInfo.FLAG_PROFILE -import android.content.pm.UserInfo.NO_PROFILE_GROUP_ID -import android.os.IUserManager -import android.os.UserHandle -import android.os.UserManager -import androidx.annotation.NonNull -import com.android.intentresolver.THROWS_EXCEPTION -import com.android.intentresolver.mock -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 -import kotlinx.coroutines.channels.Channel -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.consumeAsFlow -import org.mockito.Mockito.RETURNS_SELF -import org.mockito.Mockito.doAnswer -import org.mockito.Mockito.doReturn -import org.mockito.Mockito.withSettings - -/** - * A stand-in for [UserManager] to support testing of data layer components which depend on it. - * - * This fake targets system applications which need to interact with any or all of the current - * user's associated profiles (as reported by [getEnabledProfiles]). Support for manipulating - * non-profile (full) secondary users (switching active foreground user, adding or removing users) - * is not included. - * - * Upon creation [FakeUserManager] contains a single primary (full) user with a randomized ID. This - * is available from [FakeUserManager.state] using [primaryUserHandle][State.primaryUserHandle] or - * [getPrimaryUser][State.getPrimaryUser]. - * - * To make state changes, use functions available from [FakeUserManager.state]: - * * [createProfile][State.createProfile] - * * [removeProfile][State.removeProfile] - * * [setQuietMode][State.setQuietMode] - * - * Any functionality not explicitly overridden here is guaranteed to throw an exception when - * accessed (access to the real system service is prevented). - */ -class FakeUserManager(val state: State = State()) : - UserManager(/* context = */ mockContext(), /* service = */ mockService()) { - - enum class ProfileType { - WORK, - CLONE, - PRIVATE - } - - override fun getProfileParent(userHandle: UserHandle): UserHandle? { - return state.getUserOrNull(userHandle)?.let { user -> - if (user.isProfile) { - state.getUserOrNull(UserHandle.of(user.profileGroupId))?.userHandle - } else { - null - } - } - } - - override fun getUserInfo(userId: Int): UserInfo? { - return state.getUserOrNull(UserHandle.of(userId)) - } - - @Suppress("OVERRIDE_DEPRECATION") - override fun getEnabledProfiles(userId: Int): List { - val user = state.users.single { it.id == userId } - return state.users.filter { other -> - user.id == other.id || user.profileGroupId == other.profileGroupId - } - } - - override fun requestQuietModeEnabled( - enableQuietMode: Boolean, - @NonNull userHandle: UserHandle - ): Boolean { - state.setQuietMode(userHandle, enableQuietMode) - return true - } - - override fun isQuietModeEnabled(userHandle: UserHandle): Boolean { - return state.getUser(userHandle).isQuietModeEnabled - } - - override fun toString(): String { - return "FakeUserManager(state=$state)" - } - - class State { - private val eventChannel = Channel() - private val userInfoMap: MutableMap = mutableMapOf() - - /** The id of the primary/full/system user, which is automatically created. */ - val primaryUserHandle: UserHandle - - /** - * Retrieves the primary user. The value returned changes, but the values are immutable. - * - * Do not cache this value in tests, between operations. - */ - fun getPrimaryUser(): UserInfo = getUser(primaryUserHandle) - - private var nextUserId: Int = 100 + Random.nextInt(0, 900) - - /** - * A flow of [UserEvent] which emulates those normally generated from system broadcasts. - * - * Events are produced by calls to [createPrimaryUser], [createProfile], [removeProfile]. - */ - val userEvents: Flow - - val users: List - get() = userInfoMap.values.toList() - - val userHandles: List - get() = userInfoMap.keys.toList() - - init { - primaryUserHandle = createPrimaryUser(allocateNextId()) - userEvents = eventChannel.consumeAsFlow() - } - - private fun allocateNextId() = nextUserId++ - - private fun createPrimaryUser(id: Int): UserHandle { - val userInfo = - UserInfo(id, "", "", FLAG_INITIALIZED or FLAG_FULL, USER_TYPE_FULL_SYSTEM) - userInfoMap[userInfo.userHandle] = userInfo - return userInfo.userHandle - } - - fun getUserOrNull(handle: UserHandle): UserInfo? = userInfoMap[handle] - - fun getUser(handle: UserHandle): UserInfo = - requireNotNull(getUserOrNull(handle)) { - "Expected userInfoMap to contain an entry for $handle" - } - - fun setQuietMode(user: UserHandle, quietMode: Boolean) { - userInfoMap[user]?.also { - it.flags = - if (quietMode) { - it.flags or UserInfo.FLAG_QUIET_MODE - } else { - it.flags and UserInfo.FLAG_QUIET_MODE.inv() - } - eventChannel.trySend(AvailabilityChange(user, quietMode)) - } - } - - fun createProfile(type: ProfileType, parent: UserHandle = primaryUserHandle): UserHandle { - val parentUser = getUser(parent) - require(!parentUser.isProfile) { "Parent user cannot be a profile" } - - // Ensure the parent user has a valid profileGroupId - if (parentUser.profileGroupId == NO_PROFILE_GROUP_ID) { - parentUser.profileGroupId = parentUser.id - } - val id = allocateNextId() - val userInfo = - UserInfo(id, "", "", FLAG_INITIALIZED or FLAG_PROFILE, type.toUserType()).apply { - profileGroupId = parentUser.profileGroupId - } - userInfoMap[userInfo.userHandle] = userInfo - eventChannel.trySend(ProfileAdded(userInfo.userHandle)) - return userInfo.userHandle - } - - fun removeProfile(handle: UserHandle): Boolean { - return userInfoMap[handle]?.let { user -> - require(user.isProfile) { "Only profiles can be removed" } - userInfoMap.remove(user.userHandle) - eventChannel.trySend(ProfileRemoved(user.userHandle)) - return true - } - ?: false - } - - override fun toString() = buildString { - append("State(nextUserId=$nextUserId, userInfoMap=[") - userInfoMap.entries.forEach { - append("UserHandle[${it.key.identifier}] = ${it.value.debugString},") - } - append("])") - } - } -} - -/** A safe mock of [Context] which throws on any unstubbed method call. */ -private fun mockContext(user: UserHandle = UserHandle.SYSTEM): Context { - return mock(withSettings().defaultAnswer(THROWS_EXCEPTION)) { - doAnswer(RETURNS_SELF).whenever(this).applicationContext - doReturn(user).whenever(this).user - doReturn(user.identifier).whenever(this).userId - } -} - -private fun FakeUserManager.ProfileType.toUserType(): String { - return when (this) { - FakeUserManager.ProfileType.WORK -> UserManager.USER_TYPE_PROFILE_MANAGED - FakeUserManager.ProfileType.CLONE -> UserManager.USER_TYPE_PROFILE_CLONE - FakeUserManager.ProfileType.PRIVATE -> UserManager.USER_TYPE_PROFILE_PRIVATE - } -} - -/** A safe mock of [IUserManager] which throws on any unstubbed method call. */ -fun mockService(): IUserManager { - return mock(withSettings().defaultAnswer(THROWS_EXCEPTION)) -} - -val UserInfo.debugString: String - get() = - "UserInfo(id=$id, profileGroupId=$profileGroupId, name=$name, " + - "type=$userType, flags=${UserInfo.flagsToString(flags)})" diff --git a/tests/unit/src/com/android/intentresolver/AnnotatedUserHandlesTest.kt b/tests/unit/src/com/android/intentresolver/AnnotatedUserHandlesTest.kt deleted file mode 100644 index cd2fbc7a..00000000 --- a/tests/unit/src/com/android/intentresolver/AnnotatedUserHandlesTest.kt +++ /dev/null @@ -1,79 +0,0 @@ -/* - * Copyright (C) 2023 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.android.intentresolver - -import android.os.UserHandle - -import com.google.common.truth.Truth.assertThat - -import org.junit.Test - -class AnnotatedUserHandlesTest { - - @Test - fun testBasicProperties() { // Fields that are reflected back w/o logic. - val info = AnnotatedUserHandles.newBuilder() - .setUserIdOfCallingApp(42) - .setUserHandleSharesheetLaunchedAs(UserHandle.of(116)) - .setPersonalProfileUserHandle(UserHandle.of(117)) - .setWorkProfileUserHandle(UserHandle.of(118)) - .setCloneProfileUserHandle(UserHandle.of(119)) - .build() - - assertThat(info.userIdOfCallingApp).isEqualTo(42) - assertThat(info.userHandleSharesheetLaunchedAs.identifier).isEqualTo(116) - assertThat(info.personalProfileUserHandle.identifier).isEqualTo(117) - assertThat(info.workProfileUserHandle?.identifier).isEqualTo(118) - assertThat(info.cloneProfileUserHandle?.identifier).isEqualTo(119) - } - - @Test - fun testWorkTabInitiallySelectedWhenLaunchedFromWorkProfile() { - val info = AnnotatedUserHandles.newBuilder() - .setUserIdOfCallingApp(42) - .setPersonalProfileUserHandle(UserHandle.of(101)) - .setWorkProfileUserHandle(UserHandle.of(202)) - .setUserHandleSharesheetLaunchedAs(UserHandle.of(202)) - .build() - - assertThat(info.tabOwnerUserHandleForLaunch.identifier).isEqualTo(202) - } - - @Test - fun testPersonalTabInitiallySelectedWhenLaunchedFromPersonalProfile() { - val info = AnnotatedUserHandles.newBuilder() - .setUserIdOfCallingApp(42) - .setPersonalProfileUserHandle(UserHandle.of(101)) - .setWorkProfileUserHandle(UserHandle.of(202)) - .setUserHandleSharesheetLaunchedAs(UserHandle.of(101)) - .build() - - assertThat(info.tabOwnerUserHandleForLaunch.identifier).isEqualTo(101) - } - - @Test - fun testPersonalTabInitiallySelectedWhenLaunchedFromOtherProfile() { - val info = AnnotatedUserHandles.newBuilder() - .setUserIdOfCallingApp(42) - .setPersonalProfileUserHandle(UserHandle.of(101)) - .setWorkProfileUserHandle(UserHandle.of(202)) - .setUserHandleSharesheetLaunchedAs(UserHandle.of(303)) - .build() - - assertThat(info.tabOwnerUserHandleForLaunch.identifier).isEqualTo(101) - } -} diff --git a/tests/unit/src/com/android/intentresolver/ChooserActionFactoryTest.kt b/tests/unit/src/com/android/intentresolver/ChooserActionFactoryTest.kt index 55a94ebd..0c2ae800 100644 --- a/tests/unit/src/com/android/intentresolver/ChooserActionFactoryTest.kt +++ b/tests/unit/src/com/android/intentresolver/ChooserActionFactoryTest.kt @@ -29,8 +29,10 @@ import android.service.chooser.ChooserAction import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.platform.app.InstrumentationRegistry import com.android.intentresolver.logging.EventLog -import com.google.common.collect.ImmutableList +import com.android.intentresolver.ui.ShareResultSender +import com.android.intentresolver.ui.model.ShareAction import com.google.common.truth.Truth.assertThat +import java.util.Optional import java.util.concurrent.CountDownLatch import java.util.concurrent.TimeUnit import java.util.function.Consumer @@ -40,15 +42,15 @@ import org.junit.Assert.assertTrue import org.junit.Before import org.junit.Test import org.junit.runner.RunWith -import org.mockito.Mockito +import org.mockito.kotlin.mock +import org.mockito.kotlin.verify @RunWith(AndroidJUnit4::class) class ChooserActionFactoryTest { - private val context = InstrumentationRegistry.getInstrumentation().getContext() + private val context = InstrumentationRegistry.getInstrumentation().context private val logger = mock() 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 = @@ -89,27 +91,7 @@ class ChooserActionFactoryTest { // click it customActions[0].onClicked.run() - Mockito.verify(logger).logCustomActionSelected(eq(0)) - assertEquals(Activity.RESULT_OK, resultConsumer.latestReturn) - // Verify the pending intent has been called - assertTrue("Timed out waiting for broadcast", countdown.await(2500, TimeUnit.MILLISECONDS)) - } - - @Test - fun testNoModifyShareAction() { - val factory = createFactory(includeModifyShare = false) - - assertThat(factory.modifyShareAction).isNull() - } - - @Test - fun testModifyShareAction() { - val factory = createFactory(includeModifyShare = true) - - val action = factory.modifyShareAction ?: error("Modify share action should not be null") - action.onClicked.run() - - Mockito.verify(logger).logActionSelected(eq(EventLog.SELECTION_TYPE_MODIFY_SHARE)) + verify(logger).logCustomActionSelected(eq(0)) assertEquals(Activity.RESULT_OK, resultConsumer.latestReturn) // Verify the pending intent has been called assertTrue("Timed out waiting for broadcast", countdown.await(2500, TimeUnit.MILLISECONDS)) @@ -122,21 +104,20 @@ class ChooserActionFactoryTest { putExtra(Intent.EXTRA_TEXT, "Text to show") } - val chooserRequest = - mock { - whenever(this.targetIntent).thenReturn(targetIntent) - whenever(chooserActions).thenReturn(ImmutableList.of()) - } val testSubject = ChooserActionFactory( - context, - chooserRequest, - mock(), - logger, - {}, - { null }, - mock(), - {}, + /* context = */ context, + /* targetIntent = */ targetIntent, + /* referrerPackageName = */ null, + /* chooserActions = */ emptyList(), + /* imageEditor = */ Optional.empty(), + /* log = */ logger, + /* onUpdateSharedTextIsExcluded = */ {}, + /* firstVisibleImageQuery = */ { null }, + /* activityStarter = */ mock(), + /* shareResultSender = */ null, + /* finishCallback = */ {}, + /* clipboardManager = */ mock(), ) assertThat(testSubject.copyButtonRunnable).isNull() } @@ -144,50 +125,51 @@ class ChooserActionFactoryTest { @Test fun sendActionNoText_noCopyRunnable() { val targetIntent = Intent(Intent.ACTION_SEND) - - val chooserRequest = - mock { - whenever(this.targetIntent).thenReturn(targetIntent) - whenever(chooserActions).thenReturn(ImmutableList.of()) - } val testSubject = ChooserActionFactory( - context, - chooserRequest, - mock(), - logger, - {}, - { null }, - mock(), - {}, + /* context = */ context, + /* targetIntent = */ targetIntent, + /* referrerPackageName = */ "com.example", + /* chooserActions = */ emptyList(), + /* imageEditor = */ Optional.empty(), + /* log = */ logger, + /* onUpdateSharedTextIsExcluded = */ {}, + /* firstVisibleImageQuery = */ { null }, + /* activityStarter = */ mock(), + /* shareResultSender = */ null, + /* finishCallback = */ {}, + /* clipboardManager = */ mock(), ) assertThat(testSubject.copyButtonRunnable).isNull() } @Test - fun sendActionWithText_nonNullCopyRunnable() { + fun sendActionWithTextCopyRunnable() { val targetIntent = Intent(Intent.ACTION_SEND).apply { putExtra(Intent.EXTRA_TEXT, "Text") } - - val chooserRequest = - mock { - whenever(this.targetIntent).thenReturn(targetIntent) - whenever(chooserActions).thenReturn(ImmutableList.of()) - } + val resultSender = mock() val testSubject = ChooserActionFactory( - context, - chooserRequest, - mock(), - logger, - {}, - { null }, - mock(), - {}, + /* context = */ context, + /* targetIntent = */ targetIntent, + /* referrerPackageName = */ "com.example", + /* chooserActions = */ emptyList(), + /* imageEditor = */ Optional.empty(), + /* log = */ logger, + /* onUpdateSharedTextIsExcluded = */ {}, + /* firstVisibleImageQuery = */ { null }, + /* activityStarter = */ mock(), + /* shareResultSender = */ resultSender, + /* finishCallback = */ {}, + /* clipboardManager = */ mock(), ) assertThat(testSubject.copyButtonRunnable).isNotNull() + + testSubject.copyButtonRunnable?.run() + + verify(resultSender) { 1 * { onActionSelected(ShareAction.SYSTEM_COPY) } } } - private fun createFactory(includeModifyShare: Boolean = false): ChooserActionFactory { + private fun createFactory(): ChooserActionFactory { val testPendingIntent = PendingIntent.getBroadcast(context, 0, Intent(testAction), PendingIntent.FLAG_IMMUTABLE) val targetIntent = Intent() @@ -198,30 +180,19 @@ class ChooserActionFactoryTest { testPendingIntent ) .build() - val chooserRequest = mock() - whenever(chooserRequest.targetIntent).thenReturn(targetIntent) - whenever(chooserRequest.chooserActions).thenReturn(ImmutableList.of(action)) - - if (includeModifyShare) { - val modifyShare = - ChooserAction.Builder( - Icon.createWithResource("", Resources.ID_NULL), - modifyShareLabel, - testPendingIntent - ) - .build() - whenever(chooserRequest.modifyShareAction).thenReturn(modifyShare) - } - return ChooserActionFactory( - context, - chooserRequest, - mock(), - logger, - {}, - { null }, - mock(), - resultConsumer + /* context = */ context, + /* targetIntent = */ targetIntent, + /* referrerPackageName = */ "com.example", + /* chooserActions = */ listOf(action), + /* imageEditor = */ Optional.empty(), + /* log = */ logger, + /* onUpdateSharedTextIsExcluded = */ {}, + /* firstVisibleImageQuery = */ { null }, + /* activityStarter = */ mock(), + /* shareResultSender = */ null, + /* finishCallback = */ resultConsumer, + /* clipboardManager = */ mock(), ) } } diff --git a/tests/unit/src/com/android/intentresolver/ChooserIntegratedDeviceComponentsTest.kt b/tests/unit/src/com/android/intentresolver/ChooserIntegratedDeviceComponentsTest.kt deleted file mode 100644 index 9a5dabdb..00000000 --- a/tests/unit/src/com/android/intentresolver/ChooserIntegratedDeviceComponentsTest.kt +++ /dev/null @@ -1,71 +0,0 @@ -/* - * Copyright (C) 2023 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.android.intentresolver - -import android.content.ComponentName -import android.provider.Settings -import android.testing.TestableContext -import androidx.test.ext.junit.runners.AndroidJUnit4 -import androidx.test.platform.app.InstrumentationRegistry -import com.google.common.truth.Truth.assertThat -import org.junit.Test -import org.junit.runner.RunWith - -@RunWith(AndroidJUnit4::class) -class ChooserIntegratedDeviceComponentsTest { - private val secureSettings = mock() - private val testableContext = - TestableContext(InstrumentationRegistry.getInstrumentation().getContext()) - - @Test - fun testEditorAndNearby() { - val resources = testableContext.getOrCreateTestableResources() - - resources.addOverride(R.string.config_systemImageEditor, "") - resources.addOverride(R.string.config_defaultNearbySharingComponent, "") - - var components = ChooserIntegratedDeviceComponents.get(testableContext, secureSettings) - - assertThat(components.editSharingComponent).isNull() - assertThat(components.nearbySharingComponent).isNull() - - val editor = ComponentName.unflattenFromString("com.android/com.android.Editor") - val nearby = ComponentName.unflattenFromString("com.android/com.android.nearby") - - resources.addOverride(R.string.config_systemImageEditor, editor?.flattenToString()) - resources.addOverride( - R.string.config_defaultNearbySharingComponent, nearby?.flattenToString()) - - components = ChooserIntegratedDeviceComponents.get(testableContext, secureSettings) - - assertThat(components.editSharingComponent).isEqualTo(editor) - assertThat(components.nearbySharingComponent).isEqualTo(nearby) - - val anotherNearby = - ComponentName.unflattenFromString("com.android/com.android.another_nearby") - whenever( - secureSettings.getString( - any(), - eq(Settings.Secure.NEARBY_SHARING_COMPONENT) - ) - ).thenReturn(anotherNearby?.flattenToString()) - - components = ChooserIntegratedDeviceComponents.get(testableContext, secureSettings) - - assertThat(components.nearbySharingComponent).isEqualTo(anotherNearby) - } -} diff --git a/tests/unit/src/com/android/intentresolver/ChooserRequestParametersTest.kt b/tests/unit/src/com/android/intentresolver/ChooserRequestParametersTest.kt deleted file mode 100644 index e721b5bb..00000000 --- a/tests/unit/src/com/android/intentresolver/ChooserRequestParametersTest.kt +++ /dev/null @@ -1,86 +0,0 @@ -/* - * Copyright (C) 2023 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.android.intentresolver - -import android.app.PendingIntent -import android.content.Intent -import android.graphics.drawable.Icon -import android.net.Uri -import android.service.chooser.ChooserAction -import androidx.test.ext.junit.runners.AndroidJUnit4 -import androidx.test.platform.app.InstrumentationRegistry -import com.google.common.truth.Truth.assertThat -import org.junit.Test -import org.junit.runner.RunWith - -@RunWith(AndroidJUnit4::class) -class ChooserRequestParametersTest { - @Test - fun testChooserActions() { - val actionCount = 3 - val intent = Intent(Intent.ACTION_SEND) - val actions = createChooserActions(actionCount) - val chooserIntent = - Intent(Intent.ACTION_CHOOSER).apply { - putExtra(Intent.EXTRA_INTENT, intent) - putExtra(Intent.EXTRA_CHOOSER_CUSTOM_ACTIONS, actions) - } - val request = ChooserRequestParameters(chooserIntent, "", Uri.EMPTY) - assertThat(request.chooserActions).containsExactlyElementsIn(actions).inOrder() - } - - @Test - fun testChooserActions_empty() { - val intent = Intent(Intent.ACTION_SEND) - val chooserIntent = - Intent(Intent.ACTION_CHOOSER).apply { putExtra(Intent.EXTRA_INTENT, intent) } - val request = ChooserRequestParameters(chooserIntent, "", Uri.EMPTY) - assertThat(request.chooserActions).isEmpty() - } - - @Test - fun testChooserActions_tooMany() { - val intent = Intent(Intent.ACTION_SEND) - val chooserActions = createChooserActions(10) - val chooserIntent = - Intent(Intent.ACTION_CHOOSER).apply { - putExtra(Intent.EXTRA_INTENT, intent) - putExtra(Intent.EXTRA_CHOOSER_CUSTOM_ACTIONS, chooserActions) - } - - val request = ChooserRequestParameters(chooserIntent, "", Uri.EMPTY) - - val expectedActions = chooserActions.sliceArray(0 until 5) - assertThat(request.chooserActions).containsExactlyElementsIn(expectedActions).inOrder() - } - - private fun createChooserActions(count: Int): Array { - return Array(count) { i -> createChooserAction("$i") } - } - - private fun createChooserAction(label: CharSequence): ChooserAction { - val icon = Icon.createWithContentUri("content://org.package.app/image") - val pendingIntent = - PendingIntent.getBroadcast( - InstrumentationRegistry.getInstrumentation().getTargetContext(), - 0, - Intent("TESTACTION"), - PendingIntent.FLAG_IMMUTABLE - ) - return ChooserAction.Builder(icon, label, pendingIntent).build() - } -} diff --git a/tests/unit/src/com/android/intentresolver/MultiProfilePagerAdapterTest.kt b/tests/unit/src/com/android/intentresolver/MultiProfilePagerAdapterTest.kt deleted file mode 100644 index ed06f7d1..00000000 --- a/tests/unit/src/com/android/intentresolver/MultiProfilePagerAdapterTest.kt +++ /dev/null @@ -1,277 +0,0 @@ -/* - * Copyright (C) 2023 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.android.intentresolver - -import android.os.UserHandle -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import android.widget.ListView -import androidx.test.platform.app.InstrumentationRegistry -import com.android.intentresolver.MultiProfilePagerAdapter.PROFILE_PERSONAL -import com.android.intentresolver.MultiProfilePagerAdapter.PROFILE_WORK -import com.android.intentresolver.emptystate.EmptyStateProvider -import com.google.common.collect.ImmutableList -import com.google.common.truth.Truth.assertThat -import java.util.Optional -import java.util.function.Supplier -import org.junit.Test -import org.mockito.Mockito.never -import org.mockito.Mockito.verify - -class MultiProfilePagerAdapterTest { - private val PERSONAL_USER_HANDLE = UserHandle.of(10) - private val WORK_USER_HANDLE = UserHandle.of(20) - - private val context = InstrumentationRegistry.getInstrumentation().getContext() - private val inflater = Supplier { - LayoutInflater.from(context).inflate(R.layout.resolver_list_per_profile, null, false) - as ViewGroup - } - - @Test - fun testSinglePageProfileAdapter() { - val personalListAdapter = - mock { whenever(getUserHandle()).thenReturn(PERSONAL_USER_HANDLE) } - val pagerAdapter = - MultiProfilePagerAdapter( - { listAdapter: ResolverListAdapter -> listAdapter }, - { listView: ListView, bindAdapter: ResolverListAdapter -> - listView.setAdapter(bindAdapter) - }, - ImmutableList.of(personalListAdapter), - object : EmptyStateProvider {}, - { false }, - PROFILE_PERSONAL, - null, - null, - inflater, - { Optional.empty() } - ) - assertThat(pagerAdapter.count).isEqualTo(1) - assertThat(pagerAdapter.currentPage).isEqualTo(PROFILE_PERSONAL) - assertThat(pagerAdapter.currentUserHandle).isEqualTo(PERSONAL_USER_HANDLE) - assertThat(pagerAdapter.getAdapterForIndex(0)).isSameInstanceAs(personalListAdapter) - assertThat(pagerAdapter.activeListAdapter).isSameInstanceAs(personalListAdapter) - assertThat(pagerAdapter.inactiveListAdapter).isNull() - assertThat(pagerAdapter.personalListAdapter).isSameInstanceAs(personalListAdapter) - assertThat(pagerAdapter.workListAdapter).isNull() - assertThat(pagerAdapter.itemCount).isEqualTo(1) - // TODO: consider covering some of the package-private methods (and making them public?). - // TODO: consider exercising responsibilities as an implementation of a ViewPager adapter. - } - - @Test - fun testTwoProfilePagerAdapter() { - val personalListAdapter = - mock { whenever(getUserHandle()).thenReturn(PERSONAL_USER_HANDLE) } - val workListAdapter = - mock { whenever(getUserHandle()).thenReturn(WORK_USER_HANDLE) } - val pagerAdapter = - MultiProfilePagerAdapter( - { listAdapter: ResolverListAdapter -> listAdapter }, - { listView: ListView, bindAdapter: ResolverListAdapter -> - listView.setAdapter(bindAdapter) - }, - ImmutableList.of(personalListAdapter, workListAdapter), - object : EmptyStateProvider {}, - { false }, - PROFILE_PERSONAL, - WORK_USER_HANDLE, // TODO: why does this test pass even if this is null? - null, - inflater, - { Optional.empty() } - ) - assertThat(pagerAdapter.count).isEqualTo(2) - assertThat(pagerAdapter.currentPage).isEqualTo(PROFILE_PERSONAL) - assertThat(pagerAdapter.currentUserHandle).isEqualTo(PERSONAL_USER_HANDLE) - assertThat(pagerAdapter.getAdapterForIndex(0)).isSameInstanceAs(personalListAdapter) - assertThat(pagerAdapter.getAdapterForIndex(1)).isSameInstanceAs(workListAdapter) - assertThat(pagerAdapter.activeListAdapter).isSameInstanceAs(personalListAdapter) - assertThat(pagerAdapter.inactiveListAdapter).isSameInstanceAs(workListAdapter) - assertThat(pagerAdapter.personalListAdapter).isSameInstanceAs(personalListAdapter) - assertThat(pagerAdapter.workListAdapter).isSameInstanceAs(workListAdapter) - assertThat(pagerAdapter.itemCount).isEqualTo(2) - // TODO: consider covering some of the package-private methods (and making them public?). - // TODO: consider exercising responsibilities as an implementation of a ViewPager adapter; - // especially matching profiles to ListViews? - // TODO: test ProfileSelectedListener (and getters for "current" state) as the selected - // page changes. Currently there's no API to change the selected page directly; that's - // only possible through manipulation of the bound ViewPager. - } - - @Test - fun testTwoProfilePagerAdapter_workIsDefault() { - val personalListAdapter = - mock { whenever(getUserHandle()).thenReturn(PERSONAL_USER_HANDLE) } - val workListAdapter = - mock { whenever(getUserHandle()).thenReturn(WORK_USER_HANDLE) } - val pagerAdapter = - MultiProfilePagerAdapter( - { listAdapter: ResolverListAdapter -> listAdapter }, - { listView: ListView, bindAdapter: ResolverListAdapter -> - listView.setAdapter(bindAdapter) - }, - ImmutableList.of(personalListAdapter, workListAdapter), - object : EmptyStateProvider {}, - { false }, - PROFILE_WORK, // <-- This test specifically requests we start on work profile. - WORK_USER_HANDLE, // TODO: why does this test pass even if this is null? - null, - inflater, - { Optional.empty() } - ) - assertThat(pagerAdapter.count).isEqualTo(2) - assertThat(pagerAdapter.currentPage).isEqualTo(PROFILE_WORK) - assertThat(pagerAdapter.currentUserHandle).isEqualTo(WORK_USER_HANDLE) - assertThat(pagerAdapter.getAdapterForIndex(0)).isSameInstanceAs(personalListAdapter) - assertThat(pagerAdapter.getAdapterForIndex(1)).isSameInstanceAs(workListAdapter) - assertThat(pagerAdapter.activeListAdapter).isSameInstanceAs(workListAdapter) - assertThat(pagerAdapter.inactiveListAdapter).isSameInstanceAs(personalListAdapter) - assertThat(pagerAdapter.personalListAdapter).isSameInstanceAs(personalListAdapter) - assertThat(pagerAdapter.workListAdapter).isSameInstanceAs(workListAdapter) - assertThat(pagerAdapter.itemCount).isEqualTo(2) - // TODO: consider covering some of the package-private methods (and making them public?). - // TODO: test ProfileSelectedListener (and getters for "current" state) as the selected - // page changes. Currently there's no API to change the selected page directly; that's - // only possible through manipulation of the bound ViewPager. - } - - @Test - fun testBottomPaddingDelegate_default() { - val container = - mock { - whenever(getPaddingLeft()).thenReturn(1) - whenever(getPaddingTop()).thenReturn(2) - whenever(getPaddingRight()).thenReturn(3) - whenever(getPaddingBottom()).thenReturn(4) - } - val pagerAdapter = - MultiProfilePagerAdapter( - { listAdapter: ResolverListAdapter -> listAdapter }, - { listView: ListView, bindAdapter: ResolverListAdapter -> - listView.setAdapter(bindAdapter) - }, - ImmutableList.of(), - object : EmptyStateProvider {}, - { false }, - PROFILE_PERSONAL, - null, - null, - inflater, - { Optional.empty() } - ) - pagerAdapter.setupContainerPadding(container) - verify(container, never()).setPadding(any(), any(), any(), any()) - } - - @Test - fun testBottomPaddingDelegate_override() { - val container = - mock { - whenever(getPaddingLeft()).thenReturn(1) - whenever(getPaddingTop()).thenReturn(2) - whenever(getPaddingRight()).thenReturn(3) - whenever(getPaddingBottom()).thenReturn(4) - } - val pagerAdapter = - MultiProfilePagerAdapter( - { listAdapter: ResolverListAdapter -> listAdapter }, - { listView: ListView, bindAdapter: ResolverListAdapter -> - listView.setAdapter(bindAdapter) - }, - ImmutableList.of(), - object : EmptyStateProvider {}, - { false }, - PROFILE_PERSONAL, - null, - null, - inflater, - { Optional.of(42) } - ) - pagerAdapter.setupContainerPadding(container) - verify(container).setPadding(1, 2, 3, 42) - } - - @Test - fun testPresumedQuietModeEmptyStateForWorkProfile_whenQuiet() { - // TODO: this is "presumed" because the conditions to determine whether we "should" show an - // empty state aren't enforced to align with the conditions when we actually *would* -- I - // believe `shouldShowEmptyStateScreen` should be implemented in terms of the provider? - val personalListAdapter = - mock { - whenever(getUserHandle()).thenReturn(PERSONAL_USER_HANDLE) - whenever(getUnfilteredCount()).thenReturn(1) - } - val workListAdapter = - mock { - whenever(getUserHandle()).thenReturn(WORK_USER_HANDLE) - whenever(getUnfilteredCount()).thenReturn(1) - } - val pagerAdapter = - MultiProfilePagerAdapter( - { listAdapter: ResolverListAdapter -> listAdapter }, - { listView: ListView, bindAdapter: ResolverListAdapter -> - listView.setAdapter(bindAdapter) - }, - ImmutableList.of(personalListAdapter, workListAdapter), - object : EmptyStateProvider {}, - { true }, // <-- Work mode is quiet. - PROFILE_WORK, - WORK_USER_HANDLE, - null, - inflater, - { Optional.empty() } - ) - assertThat(pagerAdapter.shouldShowEmptyStateScreen(workListAdapter)).isTrue() - assertThat(pagerAdapter.shouldShowEmptyStateScreen(personalListAdapter)).isFalse() - } - - @Test - fun testPresumedQuietModeEmptyStateForWorkProfile_notWhenNotQuiet() { - // TODO: this is "presumed" because the conditions to determine whether we "should" show an - // empty state aren't enforced to align with the conditions when we actually *would* -- I - // believe `shouldShowEmptyStateScreen` should be implemented in terms of the provider? - val personalListAdapter = - mock { - whenever(getUserHandle()).thenReturn(PERSONAL_USER_HANDLE) - whenever(getUnfilteredCount()).thenReturn(1) - } - val workListAdapter = - mock { - whenever(getUserHandle()).thenReturn(WORK_USER_HANDLE) - whenever(getUnfilteredCount()).thenReturn(1) - } - val pagerAdapter = - MultiProfilePagerAdapter( - { listAdapter: ResolverListAdapter -> listAdapter }, - { listView: ListView, bindAdapter: ResolverListAdapter -> - listView.setAdapter(bindAdapter) - }, - ImmutableList.of(personalListAdapter, workListAdapter), - object : EmptyStateProvider {}, - { false }, // <-- Work mode is not quiet. - PROFILE_WORK, - WORK_USER_HANDLE, - null, - inflater, - { Optional.empty() } - ) - assertThat(pagerAdapter.shouldShowEmptyStateScreen(workListAdapter)).isFalse() - assertThat(pagerAdapter.shouldShowEmptyStateScreen(personalListAdapter)).isFalse() - } -} diff --git a/tests/unit/src/com/android/intentresolver/ProfileAvailabilityTest.kt b/tests/unit/src/com/android/intentresolver/ProfileAvailabilityTest.kt new file mode 100644 index 00000000..47db0cf5 --- /dev/null +++ b/tests/unit/src/com/android/intentresolver/ProfileAvailabilityTest.kt @@ -0,0 +1,74 @@ +/* + * Copyright (C) 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.intentresolver + +import com.android.intentresolver.annotation.JavaInterop +import com.android.intentresolver.data.repository.FakeUserRepository +import com.android.intentresolver.domain.interactor.UserInteractor +import com.android.intentresolver.shared.model.Profile +import com.android.intentresolver.shared.model.User +import com.google.common.truth.Truth.assertThat +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import org.junit.Test + +@OptIn(ExperimentalCoroutinesApi::class, JavaInterop::class) +class ProfileAvailabilityTest { + private val personalUser = User(0, User.Role.PERSONAL) + private val workUser = User(10, User.Role.WORK) + + private val personalProfile = Profile(Profile.Type.PERSONAL, personalUser) + private val workProfile = Profile(Profile.Type.WORK, workUser) + + private val repository = FakeUserRepository(listOf(personalUser, workUser)) + private val interactor = UserInteractor(repository, launchedAs = personalUser.handle) + + @Test + fun testProfileAvailable() = runTest { + val availability = ProfileAvailability(interactor, this, Dispatchers.IO) + + assertThat(availability.isAvailable(personalProfile)).isTrue() + assertThat(availability.isAvailable(workProfile)).isTrue() + + availability.requestQuietModeState(workProfile, true) + runCurrent() + + assertThat(availability.isAvailable(workProfile)).isFalse() + + availability.requestQuietModeState(workProfile, false) + runCurrent() + + assertThat(availability.isAvailable(workProfile)).isTrue() + } + + @Test + fun waitingToEnableProfile() = runTest { + val availability = ProfileAvailability(interactor, this, Dispatchers.IO) + + availability.requestQuietModeState(workProfile, true) + assertThat(availability.waitingToEnableProfile).isFalse() + runCurrent() + + availability.requestQuietModeState(workProfile, false) + assertThat(availability.waitingToEnableProfile).isTrue() + runCurrent() + + assertThat(availability.waitingToEnableProfile).isFalse() + } +} diff --git a/tests/unit/src/com/android/intentresolver/ProfileHelperTest.kt b/tests/unit/src/com/android/intentresolver/ProfileHelperTest.kt new file mode 100644 index 00000000..05d642f7 --- /dev/null +++ b/tests/unit/src/com/android/intentresolver/ProfileHelperTest.kt @@ -0,0 +1,275 @@ +/* + * Copyright (C) 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.intentresolver + +import com.android.intentresolver.Flags.FLAG_ENABLE_PRIVATE_PROFILE +import com.android.intentresolver.annotation.JavaInterop +import com.android.intentresolver.data.repository.FakeUserRepository +import com.android.intentresolver.domain.interactor.UserInteractor +import com.android.intentresolver.inject.FakeIntentResolverFlags +import com.android.intentresolver.shared.model.Profile +import com.android.intentresolver.shared.model.User +import com.google.common.truth.Truth.assertThat +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.test.runTest +import org.junit.Test + +@OptIn(JavaInterop::class) +class ProfileHelperTest { + + private val personalUser = User(0, User.Role.PERSONAL) + private val cloneUser = User(10, User.Role.CLONE) + + private val personalProfile = Profile(Profile.Type.PERSONAL, personalUser) + private val personalWithCloneProfile = Profile(Profile.Type.PERSONAL, personalUser, cloneUser) + + private val workUser = User(11, User.Role.WORK) + private val workProfile = Profile(Profile.Type.WORK, workUser) + + private val privateUser = User(12, User.Role.PRIVATE) + private val privateProfile = Profile(Profile.Type.PRIVATE, privateUser) + + private val flags = + FakeIntentResolverFlags().apply { setFlag(FLAG_ENABLE_PRIVATE_PROFILE, true) } + + private fun assertProfiles( + helper: ProfileHelper, + personalProfile: Profile, + workProfile: Profile? = null, + privateProfile: Profile? = null + ) { + assertThat(helper.personalProfile).isEqualTo(personalProfile) + assertThat(helper.personalHandle).isEqualTo(personalProfile.primary.handle) + + personalProfile.clone?.also { + assertThat(helper.cloneUserPresent).isTrue() + assertThat(helper.cloneHandle).isEqualTo(it.handle) + } + ?: { + assertThat(helper.cloneUserPresent).isFalse() + assertThat(helper.cloneHandle).isNull() + } + + workProfile?.also { + assertThat(helper.workProfilePresent).isTrue() + assertThat(helper.workProfile).isEqualTo(it) + assertThat(helper.workHandle).isEqualTo(it.primary.handle) + } + ?: { + assertThat(helper.workProfilePresent).isFalse() + assertThat(helper.workProfile).isNull() + assertThat(helper.workHandle).isNull() + } + + privateProfile?.also { + assertThat(helper.privateProfilePresent).isTrue() + assertThat(helper.privateProfile).isEqualTo(it) + assertThat(helper.privateHandle).isEqualTo(it.primary.handle) + } + ?: { + assertThat(helper.privateProfilePresent).isFalse() + assertThat(helper.privateProfile).isNull() + assertThat(helper.privateHandle).isNull() + } + } + + @Test + fun launchedByPersonal() = runTest { + val repository = FakeUserRepository(listOf(personalUser)) + val interactor = UserInteractor(repository, launchedAs = personalUser.handle) + + val helper = + ProfileHelper( + interactor = interactor, + scope = this, + background = Dispatchers.Unconfined, + flags = flags + ) + + assertProfiles(helper, personalProfile) + + assertThat(helper.isLaunchedAsCloneProfile).isFalse() + assertThat(helper.launchedAsProfileType).isEqualTo(Profile.Type.PERSONAL) + assertThat(helper.getQueryIntentsHandle(personalUser.handle)) + .isEqualTo(personalProfile.primary.handle) + assertThat(helper.tabOwnerUserHandleForLaunch).isEqualTo(personalProfile.primary.handle) + } + + @Test + fun launchedByPersonal_withClone() = runTest { + val repository = FakeUserRepository(listOf(personalUser, cloneUser)) + val interactor = UserInteractor(repository, launchedAs = personalUser.handle) + + val helper = + ProfileHelper( + interactor = interactor, + scope = this, + background = Dispatchers.Unconfined, + flags = flags + ) + + assertProfiles(helper, personalWithCloneProfile) + + assertThat(helper.isLaunchedAsCloneProfile).isFalse() + assertThat(helper.launchedAsProfileType).isEqualTo(Profile.Type.PERSONAL) + assertThat(helper.getQueryIntentsHandle(personalUser.handle)).isEqualTo(personalUser.handle) + assertThat(helper.tabOwnerUserHandleForLaunch).isEqualTo(personalProfile.primary.handle) + } + + @Test + fun launchedByClone() = runTest { + val repository = FakeUserRepository(listOf(personalUser, cloneUser)) + val interactor = UserInteractor(repository, launchedAs = cloneUser.handle) + + val helper = + ProfileHelper( + interactor = interactor, + scope = this, + background = Dispatchers.Unconfined, + flags = flags + ) + + assertProfiles(helper, personalWithCloneProfile) + + assertThat(helper.isLaunchedAsCloneProfile).isTrue() + assertThat(helper.launchedAsProfileType).isEqualTo(Profile.Type.PERSONAL) + assertThat(helper.getQueryIntentsHandle(personalWithCloneProfile.primary.handle)) + .isEqualTo(personalWithCloneProfile.clone?.handle) + assertThat(helper.tabOwnerUserHandleForLaunch) + .isEqualTo(personalWithCloneProfile.primary.handle) + } + + @Test + fun launchedByPersonal_withWork() = runTest { + val repository = FakeUserRepository(listOf(personalUser, workUser)) + val interactor = UserInteractor(repository, launchedAs = personalUser.handle) + + val helper = + ProfileHelper( + interactor = interactor, + scope = this, + background = Dispatchers.Unconfined, + flags = flags + ) + + assertProfiles(helper, personalProfile = personalProfile, workProfile = workProfile) + + assertThat(helper.launchedAsProfileType).isEqualTo(Profile.Type.PERSONAL) + assertThat(helper.isLaunchedAsCloneProfile).isFalse() + assertThat(helper.getQueryIntentsHandle(personalUser.handle)) + .isEqualTo(personalProfile.primary.handle) + assertThat(helper.getQueryIntentsHandle(workUser.handle)) + .isEqualTo(workProfile.primary.handle) + assertThat(helper.tabOwnerUserHandleForLaunch).isEqualTo(personalProfile.primary.handle) + } + + @Test + fun launchedByWork() = runTest { + val repository = FakeUserRepository(listOf(personalUser, workUser)) + val interactor = UserInteractor(repository, launchedAs = workUser.handle) + + val helper = + ProfileHelper( + interactor = interactor, + scope = this, + background = Dispatchers.Unconfined, + flags = flags + ) + + assertProfiles(helper, personalProfile = personalProfile, workProfile = workProfile) + + assertThat(helper.isLaunchedAsCloneProfile).isFalse() + assertThat(helper.launchedAsProfileType).isEqualTo(Profile.Type.WORK) + assertThat(helper.getQueryIntentsHandle(personalProfile.primary.handle)) + .isEqualTo(personalProfile.primary.handle) + assertThat(helper.getQueryIntentsHandle(workProfile.primary.handle)) + .isEqualTo(workProfile.primary.handle) + assertThat(helper.tabOwnerUserHandleForLaunch).isEqualTo(workProfile.primary.handle) + } + + @Test + fun launchedByPersonal_withPrivate() = runTest { + val repository = FakeUserRepository(listOf(personalUser, privateUser)) + val interactor = UserInteractor(repository, launchedAs = personalUser.handle) + + val helper = + ProfileHelper( + interactor = interactor, + scope = this, + background = Dispatchers.Unconfined, + flags = flags + ) + + assertProfiles(helper, personalProfile = personalProfile, privateProfile = privateProfile) + + assertThat(helper.isLaunchedAsCloneProfile).isFalse() + assertThat(helper.launchedAsProfileType).isEqualTo(Profile.Type.PERSONAL) + assertThat(helper.getQueryIntentsHandle(personalProfile.primary.handle)) + .isEqualTo(personalProfile.primary.handle) + assertThat(helper.getQueryIntentsHandle(privateProfile.primary.handle)) + .isEqualTo(privateProfile.primary.handle) + assertThat(helper.tabOwnerUserHandleForLaunch).isEqualTo(personalProfile.primary.handle) + } + + @Test + fun launchedByPrivate() = runTest { + val repository = FakeUserRepository(listOf(personalUser, privateUser)) + val interactor = UserInteractor(repository, launchedAs = privateUser.handle) + + val helper = + ProfileHelper( + interactor = interactor, + scope = this, + background = Dispatchers.Unconfined, + flags = flags + ) + + assertProfiles(helper, personalProfile = personalProfile, privateProfile = privateProfile) + + assertThat(helper.isLaunchedAsCloneProfile).isFalse() + assertThat(helper.launchedAsProfileType).isEqualTo(Profile.Type.PRIVATE) + assertThat(helper.getQueryIntentsHandle(personalProfile.primary.handle)) + .isEqualTo(personalProfile.primary.handle) + assertThat(helper.getQueryIntentsHandle(privateProfile.primary.handle)) + .isEqualTo(privateProfile.primary.handle) + assertThat(helper.tabOwnerUserHandleForLaunch).isEqualTo(privateProfile.primary.handle) + } + + @Test + fun launchedByPersonal_withPrivate_privateDisabled() = runTest { + flags.setFlag(FLAG_ENABLE_PRIVATE_PROFILE, false) + + val repository = FakeUserRepository(listOf(personalUser, privateUser)) + val interactor = UserInteractor(repository, launchedAs = personalUser.handle) + + val helper = + ProfileHelper( + interactor = interactor, + scope = this, + background = Dispatchers.Unconfined, + flags = flags + ) + + assertProfiles(helper, personalProfile = personalProfile, privateProfile = null) + + assertThat(helper.isLaunchedAsCloneProfile).isFalse() + assertThat(helper.launchedAsProfileType).isEqualTo(Profile.Type.PERSONAL) + assertThat(helper.getQueryIntentsHandle(personalProfile.primary.handle)) + .isEqualTo(personalProfile.primary.handle) + assertThat(helper.tabOwnerUserHandleForLaunch).isEqualTo(personalProfile.primary.handle) + } +} 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 index feda8133..2bbda0cc 100644 --- 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 @@ -19,17 +19,18 @@ package com.android.intentresolver.contentpreview.payloadtoggle.domain.interactor import android.app.Activity +import android.content.Intent import android.graphics.Bitmap import android.graphics.drawable.Icon import com.android.intentresolver.contentpreview.payloadtoggle.data.model.CustomActionModel import com.android.intentresolver.contentpreview.payloadtoggle.data.repository.activityResultRepository import com.android.intentresolver.contentpreview.payloadtoggle.domain.model.ActionModel +import com.android.intentresolver.data.model.ChooserRequest +import com.android.intentresolver.data.repository.ChooserRequestRepository +import com.android.intentresolver.data.repository.chooserRequestRepository import com.android.intentresolver.icon.BitmapIcon import com.android.intentresolver.util.comparingElementsUsingTransform import com.android.intentresolver.util.runKosmosTest -import com.android.intentresolver.v2.data.model.fakeChooserRequest -import com.android.intentresolver.v2.data.repository.ChooserRequestRepository -import com.android.intentresolver.v2.data.repository.chooserRequestRepository import com.google.common.truth.Truth.assertThat import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.StateFlow @@ -44,7 +45,8 @@ class CustomActionsInteractorTest { val icon = Icon.createWithBitmap(bitmap) chooserRequestRepository = ChooserRequestRepository( - initialRequest = fakeChooserRequest(), + initialRequest = + ChooserRequest(targetIntent = Intent(), launchedFromPackage = "pkg"), initialActions = listOf( CustomActionModel(label = "label1", icon = icon, performAction = {}), @@ -92,7 +94,8 @@ class CustomActionsInteractorTest { var actionSent = false chooserRequestRepository = ChooserRequestRepository( - initialRequest = fakeChooserRequest(), + initialRequest = + ChooserRequest(targetIntent = Intent(), launchedFromPackage = "pkg"), initialActions = listOf( CustomActionModel( 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 index c56d8026..f8fc4911 100644 --- 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 @@ -25,8 +25,8 @@ import com.android.intentresolver.contentpreview.payloadtoggle.data.repository.p import com.android.intentresolver.contentpreview.payloadtoggle.domain.intent.TargetIntentModifier import com.android.intentresolver.contentpreview.payloadtoggle.domain.intent.targetIntentModifier import com.android.intentresolver.contentpreview.payloadtoggle.shared.model.PreviewModel +import com.android.intentresolver.data.repository.chooserRequestRepository import com.android.intentresolver.util.runKosmosTest -import com.android.intentresolver.v2.data.repository.chooserRequestRepository import com.google.common.truth.Truth.assertThat import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.first 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 index 7a4f4754..570c346c 100644 --- 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 @@ -24,8 +24,8 @@ import com.android.intentresolver.contentpreview.payloadtoggle.domain.model.Shar import com.android.intentresolver.contentpreview.payloadtoggle.domain.model.ValueUpdate import com.android.intentresolver.contentpreview.payloadtoggle.domain.update.SelectionChangeCallback import com.android.intentresolver.contentpreview.payloadtoggle.domain.update.selectionChangeCallback +import com.android.intentresolver.data.repository.chooserRequestRepository import com.android.intentresolver.util.runKosmosTest -import com.android.intentresolver.v2.data.repository.chooserRequestRepository import com.google.common.truth.Truth.assertThat import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.launch 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 index 58804456..e5c91e80 100644 --- 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 @@ -40,13 +40,13 @@ import com.android.intentresolver.contentpreview.payloadtoggle.domain.interactor 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.data.repository.chooserRequestRepository import com.android.intentresolver.icon.BitmapIcon import com.android.intentresolver.logging.FakeEventLog import com.android.intentresolver.logging.eventLog import com.android.intentresolver.util.KosmosTestScope import com.android.intentresolver.util.comparingElementsUsingTransform import com.android.intentresolver.util.runKosmosTest -import com.android.intentresolver.v2.data.repository.chooserRequestRepository import com.android.systemui.kosmos.Kosmos import com.android.systemui.kosmos.Kosmos.Fixture import com.google.common.truth.Truth.assertThat diff --git a/tests/unit/src/com/android/intentresolver/coroutines/Flow.kt b/tests/unit/src/com/android/intentresolver/coroutines/Flow.kt new file mode 100644 index 00000000..ca60824d --- /dev/null +++ b/tests/unit/src/com/android/intentresolver/coroutines/Flow.kt @@ -0,0 +1,89 @@ +@file:Suppress("OPT_IN_USAGE") + +package com.android.intentresolver.coroutines + +/* + * Copyright (C) 2022 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import kotlin.coroutines.CoroutineContext +import kotlin.coroutines.EmptyCoroutineContext +import kotlin.properties.ReadOnlyProperty +import kotlin.reflect.KProperty +import kotlinx.coroutines.CoroutineStart +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.runCurrent + +/** + * Collect [flow] in a new [Job] and return a getter for the last collected value. + * + * ``` + * fun myTest() = runTest { + * // ... + * val actual by collectLastValue(underTest.flow) + * assertThat(actual).isEqualTo(expected) + * } + * ``` + */ +fun TestScope.collectLastValue( + flow: Flow, + context: CoroutineContext = EmptyCoroutineContext, + start: CoroutineStart = CoroutineStart.DEFAULT, +): FlowValue { + val values by + collectValues( + flow = flow, + context = context, + start = start, + ) + return FlowValueImpl { values.lastOrNull() } +} + +/** + * Collect [flow] in a new [Job] and return a getter for the collection of values collected. + * + * ``` + * fun myTest() = runTest { + * // ... + * val values by collectValues(underTest.flow) + * assertThat(values).isEqualTo(listOf(expected1, expected2, ...)) + * } + * ``` + */ +fun TestScope.collectValues( + flow: Flow, + context: CoroutineContext = EmptyCoroutineContext, + start: CoroutineStart = CoroutineStart.DEFAULT, +): FlowValue> { + val values = mutableListOf() + backgroundScope.launch(context, start) { flow.collect(values::add) } + return FlowValueImpl { + runCurrent() + values.toList() + } +} + +/** @see collectLastValue */ +interface FlowValue : ReadOnlyProperty { + operator fun invoke(): T +} + +private class FlowValueImpl(private val block: () -> T) : FlowValue { + override operator fun invoke(): T = block() + override fun getValue(thisRef: Any?, property: KProperty<*>): T = invoke() +} diff --git a/tests/unit/src/com/android/intentresolver/data/repository/FakeUserRepositoryTest.kt b/tests/unit/src/com/android/intentresolver/data/repository/FakeUserRepositoryTest.kt new file mode 100644 index 00000000..2fad37f2 --- /dev/null +++ b/tests/unit/src/com/android/intentresolver/data/repository/FakeUserRepositoryTest.kt @@ -0,0 +1,108 @@ +/* + * Copyright (C) 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.intentresolver.data.repository + +import com.android.intentresolver.coroutines.collectLastValue +import com.android.intentresolver.shared.model.User +import com.google.common.truth.Truth.assertThat +import kotlin.random.Random +import kotlinx.coroutines.test.runTest +import org.junit.Test + +class FakeUserRepositoryTest { + private val baseId = Random.nextInt(1000, 2000) + + private val personalUser = User(id = baseId, role = User.Role.PERSONAL) + private val cloneUser = User(id = baseId + 1, role = User.Role.CLONE) + private val workUser = User(id = baseId + 2, role = User.Role.WORK) + private val privateUser = User(id = baseId + 3, role = User.Role.PRIVATE) + + @Test + fun init() = runTest { + val repo = FakeUserRepository(listOf(personalUser, workUser, privateUser)) + + val users by collectLastValue(repo.users) + assertThat(users).containsExactly(personalUser, workUser, privateUser) + } + + @Test + fun addUser() = runTest { + val repo = FakeUserRepository(emptyList()) + + val users by collectLastValue(repo.users) + assertThat(users).isEmpty() + + repo.addUser(personalUser, true) + assertThat(users).containsExactly(personalUser) + + repo.addUser(workUser, false) + assertThat(users).containsExactly(personalUser, workUser) + } + + @Test + fun removeUser() = runTest { + val repo = FakeUserRepository(listOf(personalUser, workUser)) + + val users by collectLastValue(repo.users) + repo.removeUser(workUser) + assertThat(users).containsExactly(personalUser) + + repo.removeUser(personalUser) + assertThat(users).isEmpty() + } + + @Test + fun isAvailable_defaultValue() = runTest { + val repo = FakeUserRepository(listOf(personalUser, workUser)) + + val available by collectLastValue(repo.availability) + + repo.requestState(workUser, false) + assertThat(available!![workUser]).isFalse() + + repo.requestState(workUser, true) + assertThat(available!![workUser]).isTrue() + } + + @Test + fun isAvailable() = runTest { + val repo = FakeUserRepository(listOf(personalUser, workUser)) + + val available by collectLastValue(repo.availability) + assertThat(available!![workUser]).isTrue() + + repo.requestState(workUser, false) + assertThat(available!![workUser]).isFalse() + + repo.requestState(workUser, true) + assertThat(available!![workUser]).isTrue() + } + + @Test + fun isAvailable_addRemove() = runTest { + val repo = FakeUserRepository(listOf(personalUser, workUser)) + + val available by collectLastValue(repo.availability) + assertThat(available!![workUser]).isTrue() + + repo.removeUser(workUser) + assertThat(available!![workUser]).isNull() + + repo.addUser(workUser, true) + assertThat(available!![workUser]).isTrue() + } +} diff --git a/tests/unit/src/com/android/intentresolver/data/repository/UserRepositoryImplTest.kt b/tests/unit/src/com/android/intentresolver/data/repository/UserRepositoryImplTest.kt new file mode 100644 index 00000000..3ae9878d --- /dev/null +++ b/tests/unit/src/com/android/intentresolver/data/repository/UserRepositoryImplTest.kt @@ -0,0 +1,211 @@ +package com.android.intentresolver.data.repository + +import android.content.pm.UserInfo +import android.os.UserHandle +import android.os.UserHandle.SYSTEM +import android.os.UserHandle.USER_SYSTEM +import android.os.UserManager +import com.android.intentresolver.coroutines.collectLastValue +import com.android.intentresolver.mock +import com.android.intentresolver.platform.FakeUserManager +import com.android.intentresolver.platform.FakeUserManager.ProfileType +import com.android.intentresolver.shared.model.User +import com.android.intentresolver.shared.model.User.Role +import com.android.intentresolver.whenever +import com.google.common.truth.Truth.assertThat +import com.google.common.truth.Truth.assertWithMessage +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.runTest +import org.junit.Test +import org.mockito.Mockito +import org.mockito.Mockito.doReturn + +internal class UserRepositoryImplTest { + private val userManager = FakeUserManager() + private val userState = userManager.state + + @Test + fun initialization() = runTest { + val repo = createUserRepository(userManager) + val users by collectLastValue(repo.users) + + assertWithMessage("collectLastValue(repo.users)").that(users).isNotNull() + assertThat(users) + .containsExactly(User(userState.primaryUserHandle.identifier, Role.PERSONAL)) + } + + @Test + fun createProfile() = runTest { + val repo = createUserRepository(userManager) + val users by collectLastValue(repo.users) + + assertWithMessage("collectLastValue(repo.users)").that(users).isNotNull() + assertThat(users).hasSize(1) + + val profile = userState.createProfile(ProfileType.WORK) + assertThat(users).hasSize(2) + assertThat(users).contains(User(profile.identifier, Role.WORK)) + } + + @Test + fun removeProfile() = runTest { + val repo = createUserRepository(userManager) + val users by collectLastValue(repo.users) + + assertWithMessage("collectLastValue(repo.users)").that(users).isNotNull() + val work = userState.createProfile(ProfileType.WORK) + assertThat(users).contains(User(work.identifier, Role.WORK)) + + userState.removeProfile(work) + assertThat(users).doesNotContain(User(work.identifier, Role.WORK)) + } + + @Test + fun isAvailable() = runTest { + val repo = createUserRepository(userManager) + val work = userState.createProfile(ProfileType.WORK) + val workUser = User(work.identifier, Role.WORK) + + val available by collectLastValue(repo.availability) + assertThat(available?.get(workUser)).isTrue() + + userState.setQuietMode(work, true) + assertThat(available?.get(workUser)).isFalse() + + userState.setQuietMode(work, false) + assertThat(available?.get(workUser)).isTrue() + } + + @Test + fun onHandleAvailabilityChange_userStateMaintained() = runTest { + val repo = createUserRepository(userManager) + val private = userState.createProfile(ProfileType.PRIVATE) + val privateUser = User(private.identifier, Role.PRIVATE) + + val users by collectLastValue(repo.users) + + repo.requestState(privateUser, false) + repo.requestState(privateUser, true) + + assertWithMessage("users.size").that(users?.size ?: 0).isEqualTo(2) // personal + private + + assertWithMessage("No duplicate IDs") + .that(users?.count { it.id == private.identifier }) + .isEqualTo(1) + } + + @Test + fun requestState() = runTest { + val repo = createUserRepository(userManager) + val work = userState.createProfile(ProfileType.WORK) + val workUser = User(work.identifier, Role.WORK) + + val available by collectLastValue(repo.availability) + assertThat(available?.get(workUser)).isTrue() + + repo.requestState(workUser, false) + assertThat(available?.get(workUser)).isFalse() + + repo.requestState(workUser, true) + assertThat(available?.get(workUser)).isTrue() + } + + /** + * This and all the 'recovers_from_*' tests below all configure a static event flow instead of + * using [FakeUserManager]. These tests verify that a invalid broadcast causes the flow to + * reinitialize with the user profile group. + */ + @Test + fun recovers_from_invalid_profile_added_event() = runTest { + val userManager = + mockUserManager(validUser = USER_SYSTEM, invalidUser = UserHandle.USER_NULL) + val events = flowOf(ProfileAdded(UserHandle.of(UserHandle.USER_NULL))) + val repo = + UserRepositoryImpl( + profileParent = SYSTEM, + userManager = userManager, + userEvents = events, + scope = backgroundScope, + backgroundDispatcher = Dispatchers.Unconfined + ) + val users by collectLastValue(repo.users) + + assertWithMessage("collectLastValue(repo.users)").that(users).isNotNull() + assertThat(users).containsExactly(User(USER_SYSTEM, Role.PERSONAL)) + } + + @Test + fun recovers_from_invalid_profile_removed_event() = runTest { + val userManager = + mockUserManager(validUser = USER_SYSTEM, invalidUser = UserHandle.USER_NULL) + val events = flowOf(ProfileRemoved(UserHandle.of(UserHandle.USER_NULL))) + val repo = + UserRepositoryImpl( + profileParent = SYSTEM, + userManager = userManager, + userEvents = events, + scope = backgroundScope, + backgroundDispatcher = Dispatchers.Unconfined + ) + val users by collectLastValue(repo.users) + + assertWithMessage("collectLastValue(repo.users)").that(users).isNotNull() + assertThat(users).containsExactly(User(USER_SYSTEM, Role.PERSONAL)) + } + + @Test + fun recovers_from_invalid_profile_available_event() = runTest { + val userManager = + mockUserManager(validUser = USER_SYSTEM, invalidUser = UserHandle.USER_NULL) + val events = flowOf(AvailabilityChange(UserHandle.of(UserHandle.USER_NULL))) + val repo = + UserRepositoryImpl(SYSTEM, userManager, events, backgroundScope, Dispatchers.Unconfined) + val users by collectLastValue(repo.users) + + assertWithMessage("collectLastValue(repo.users)").that(users).isNotNull() + assertThat(users).containsExactly(User(USER_SYSTEM, Role.PERSONAL)) + } + + @Test + fun recovers_from_unknown_event() = runTest { + val userManager = + mockUserManager(validUser = USER_SYSTEM, invalidUser = UserHandle.USER_NULL) + val events = flowOf(UnknownEvent("UNKNOWN_EVENT")) + val repo = + UserRepositoryImpl( + profileParent = SYSTEM, + userManager = userManager, + userEvents = events, + scope = backgroundScope, + backgroundDispatcher = Dispatchers.Unconfined + ) + val users by collectLastValue(repo.users) + + assertWithMessage("collectLastValue(repo.users)").that(users).isNotNull() + assertThat(users).containsExactly(User(USER_SYSTEM, Role.PERSONAL)) + } +} + +@Suppress("SameParameterValue", "DEPRECATION") +private fun mockUserManager(validUser: Int, invalidUser: Int) = + mock { + val info = UserInfo(validUser, "", "", UserInfo.FLAG_FULL) + doReturn(listOf(info)).whenever(this).getEnabledProfiles(Mockito.anyInt()) + + doReturn(info).whenever(this).getUserInfo(Mockito.eq(validUser)) + + doReturn(listOf()).whenever(this).getEnabledProfiles(Mockito.eq(invalidUser)) + + doReturn(null).whenever(this).getUserInfo(Mockito.eq(invalidUser)) + } + +private fun TestScope.createUserRepository(userManager: FakeUserManager) = + UserRepositoryImpl( + profileParent = userManager.state.primaryUserHandle, + userManager = userManager, + userEvents = userManager.state.userEvents, + scope = backgroundScope, + backgroundDispatcher = Dispatchers.Unconfined + ) diff --git a/tests/unit/src/com/android/intentresolver/domain/interactor/UserInteractorTest.kt b/tests/unit/src/com/android/intentresolver/domain/interactor/UserInteractorTest.kt new file mode 100644 index 00000000..4d6f2e5b --- /dev/null +++ b/tests/unit/src/com/android/intentresolver/domain/interactor/UserInteractorTest.kt @@ -0,0 +1,206 @@ +/* + * Copyright (C) 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.intentresolver.domain.interactor + +import com.android.intentresolver.coroutines.collectLastValue +import com.android.intentresolver.data.repository.FakeUserRepository +import com.android.intentresolver.shared.model.Profile +import com.android.intentresolver.shared.model.Profile.Type.PERSONAL +import com.android.intentresolver.shared.model.Profile.Type.PRIVATE +import com.android.intentresolver.shared.model.Profile.Type.WORK +import com.android.intentresolver.shared.model.User +import com.android.intentresolver.shared.model.User.Role +import com.google.common.truth.Truth.assertThat +import com.google.common.truth.Truth.assertWithMessage +import kotlin.random.Random +import kotlinx.coroutines.test.runTest +import org.junit.Test +import org.junit.runner.RunWith +import org.junit.runners.JUnit4 + +@RunWith(JUnit4::class) +class UserInteractorTest { + private val baseId = Random.nextInt(1000, 2000) + + private val personalUser = User(id = baseId, role = Role.PERSONAL) + private val cloneUser = User(id = baseId + 1, role = Role.CLONE) + private val workUser = User(id = baseId + 2, role = Role.WORK) + private val privateUser = User(id = baseId + 3, role = Role.PRIVATE) + + val personalProfile = Profile(PERSONAL, personalUser) + val workProfile = Profile(WORK, workUser) + val privateProfile = Profile(PRIVATE, privateUser) + + @Test + fun launchedByProfile(): Unit = runTest { + val profileInteractor = + UserInteractor( + userRepository = FakeUserRepository(listOf(personalUser, cloneUser)), + launchedAs = personalUser.handle + ) + + val launchedAsProfile by collectLastValue(profileInteractor.launchedAsProfile) + + assertThat(launchedAsProfile).isEqualTo(Profile(PERSONAL, personalUser, cloneUser)) + } + + @Test + fun launchedByProfile_asClone(): Unit = runTest { + val profileInteractor = + UserInteractor( + userRepository = FakeUserRepository(listOf(personalUser, cloneUser)), + launchedAs = cloneUser.handle + ) + val profiles by collectLastValue(profileInteractor.launchedAsProfile) + + assertThat(profiles).isEqualTo(Profile(PERSONAL, personalUser, cloneUser)) + } + + @Test + fun profiles_withPersonal(): Unit = runTest { + val profileInteractor = + UserInteractor( + userRepository = FakeUserRepository(listOf(personalUser)), + launchedAs = personalUser.handle + ) + + val profiles by collectLastValue(profileInteractor.profiles) + + assertThat(profiles).containsExactly(Profile(PERSONAL, personalUser)) + } + + @Test + fun profiles_addClone(): Unit = runTest { + val fakeUserRepo = FakeUserRepository(listOf(personalUser)) + val profileInteractor = + UserInteractor(userRepository = fakeUserRepo, launchedAs = personalUser.handle) + + val profiles by collectLastValue(profileInteractor.profiles) + assertThat(profiles).containsExactly(Profile(PERSONAL, personalUser)) + + fakeUserRepo.addUser(cloneUser, available = true) + assertThat(profiles).containsExactly(Profile(PERSONAL, personalUser, cloneUser)) + } + + @Test + fun profiles_withPersonalAndClone(): Unit = runTest { + val profileInteractor = + UserInteractor( + userRepository = FakeUserRepository(listOf(personalUser, cloneUser)), + launchedAs = personalUser.handle + ) + val profiles by collectLastValue(profileInteractor.profiles) + + assertThat(profiles).containsExactly(Profile(PERSONAL, personalUser, cloneUser)) + } + + @Test + fun profiles_withAllSupportedTypes(): Unit = runTest { + val profileInteractor = + UserInteractor( + userRepository = + FakeUserRepository(listOf(personalUser, cloneUser, workUser, privateUser)), + launchedAs = personalUser.handle + ) + val profiles by collectLastValue(profileInteractor.profiles) + + assertThat(profiles) + .containsExactly( + Profile(PERSONAL, personalUser, cloneUser), + Profile(WORK, workUser), + Profile(PRIVATE, privateUser) + ) + } + + @Test + fun profiles_preservesIterationOrder(): Unit = runTest { + val profileInteractor = + UserInteractor( + userRepository = + FakeUserRepository(listOf(workUser, cloneUser, privateUser, personalUser)), + launchedAs = personalUser.handle + ) + + val profiles by collectLastValue(profileInteractor.profiles) + + assertThat(profiles) + .containsExactly( + Profile(WORK, workUser), + Profile(PRIVATE, privateUser), + Profile(PERSONAL, personalUser, cloneUser), + ) + } + + @Test + fun isAvailable_defaultValue() = runTest { + val userRepo = FakeUserRepository(listOf(personalUser)) + userRepo.addUser(workUser, false) + + val interactor = UserInteractor(userRepository = userRepo, launchedAs = personalUser.handle) + + val availability by collectLastValue(interactor.availability) + + assertWithMessage("personalAvailable").that(availability?.get(personalProfile)).isTrue() + assertWithMessage("workAvailable").that(availability?.get(workProfile)).isFalse() + } + + @Test + fun isAvailable() = runTest { + val userRepo = FakeUserRepository(listOf(workUser, personalUser)) + val interactor = UserInteractor(userRepository = userRepo, launchedAs = personalUser.handle) + + val availability by collectLastValue(interactor.availability) + + // Default state is enabled in FakeUserManager + assertWithMessage("workAvailable").that(availability?.get(workProfile)).isTrue() + + // Making user unavailable makes profile unavailable + userRepo.requestState(workUser, false) + assertWithMessage("workAvailable").that(availability?.get(workProfile)).isFalse() + + // Making user available makes profile available again + userRepo.requestState(workUser, true) + assertWithMessage("workAvailable").that(availability?.get(workProfile)).isTrue() + + // When a user is removed availability is removed as well. + userRepo.removeUser(workUser) + assertWithMessage("workAvailable").that(availability?.get(workProfile)).isNull() + } + + /** + * Similar to the above test in reverse: uses UserInteractor to modify state, and verify the + * state of the UserRepository. + */ + @Test + fun updateState() = runTest { + val userRepo = FakeUserRepository(listOf(workUser, personalUser)) + val userInteractor = + UserInteractor(userRepository = userRepo, launchedAs = personalUser.handle) + val workProfile = Profile(Profile.Type.WORK, workUser) + + val availability by collectLastValue(userRepo.availability) + + // Default state is enabled in FakeUserManager + assertWithMessage("workAvailable").that(availability?.get(workUser)).isTrue() + + userInteractor.updateState(workProfile, false) + assertWithMessage("workAvailable").that(availability?.get(workUser)).isFalse() + + userInteractor.updateState(workProfile, true) + assertWithMessage("workAvailable").that(availability?.get(workUser)).isTrue() + } +} diff --git a/tests/unit/src/com/android/intentresolver/emptystate/EmptyStateUiHelperTest.kt b/tests/unit/src/com/android/intentresolver/emptystate/EmptyStateUiHelperTest.kt index bc5545db..9efaeb85 100644 --- a/tests/unit/src/com/android/intentresolver/emptystate/EmptyStateUiHelperTest.kt +++ b/tests/unit/src/com/android/intentresolver/emptystate/EmptyStateUiHelperTest.kt @@ -20,17 +20,31 @@ import android.view.LayoutInflater import android.view.View import android.view.ViewGroup import android.widget.FrameLayout +import android.widget.TextView import androidx.test.platform.app.InstrumentationRegistry +import com.android.intentresolver.any +import com.android.intentresolver.mock import com.google.common.truth.Truth.assertThat +import java.util.Optional +import java.util.function.Supplier import org.junit.Before import org.junit.Test +import org.mockito.Mockito.never +import org.mockito.Mockito.verify class EmptyStateUiHelperTest { private val context = InstrumentationRegistry.getInstrumentation().getContext() + var shouldOverrideContainerPadding = false + val containerPaddingSupplier = + Supplier> { + Optional.ofNullable(if (shouldOverrideContainerPadding) 42 else null) + } + lateinit var rootContainer: ViewGroup - lateinit var emptyStateTitleView: View - lateinit var emptyStateSubtitleView: View + lateinit var mainListView: View // Visible when no empty state is showing. + lateinit var emptyStateTitleView: TextView + lateinit var emptyStateSubtitleView: TextView lateinit var emptyStateButtonView: View lateinit var emptyStateProgressView: View lateinit var emptyStateDefaultTextView: View @@ -47,21 +61,26 @@ class EmptyStateUiHelperTest { rootContainer, true ) + mainListView = rootContainer.requireViewById(com.android.internal.R.id.resolver_list) emptyStateRootView = rootContainer.requireViewById(com.android.internal.R.id.resolver_empty_state) emptyStateTitleView = rootContainer.requireViewById(com.android.internal.R.id.resolver_empty_state_title) - emptyStateSubtitleView = rootContainer.requireViewById( - com.android.internal.R.id.resolver_empty_state_subtitle) - emptyStateButtonView = rootContainer.requireViewById( - com.android.internal.R.id.resolver_empty_state_button) - emptyStateProgressView = rootContainer.requireViewById( - com.android.internal.R.id.resolver_empty_state_progress) - emptyStateDefaultTextView = - rootContainer.requireViewById(com.android.internal.R.id.empty) - emptyStateContainerView = rootContainer.requireViewById( - com.android.internal.R.id.resolver_empty_state_container) - emptyStateUiHelper = EmptyStateUiHelper(rootContainer) + emptyStateSubtitleView = + rootContainer.requireViewById(com.android.internal.R.id.resolver_empty_state_subtitle) + emptyStateButtonView = + rootContainer.requireViewById(com.android.internal.R.id.resolver_empty_state_button) + emptyStateProgressView = + rootContainer.requireViewById(com.android.internal.R.id.resolver_empty_state_progress) + emptyStateDefaultTextView = rootContainer.requireViewById(com.android.internal.R.id.empty) + emptyStateContainerView = + rootContainer.requireViewById(com.android.internal.R.id.resolver_empty_state_container) + emptyStateUiHelper = + EmptyStateUiHelper( + rootContainer, + com.android.internal.R.id.resolver_list, + containerPaddingSupplier + ) } @Test @@ -105,9 +124,104 @@ class EmptyStateUiHelperTest { @Test fun testHide() { emptyStateRootView.visibility = View.VISIBLE + mainListView.visibility = View.GONE emptyStateUiHelper.hide() assertThat(emptyStateRootView.visibility).isEqualTo(View.GONE) + assertThat(mainListView.visibility).isEqualTo(View.VISIBLE) + } + + @Test + fun testBottomPaddingDelegate_default() { + shouldOverrideContainerPadding = false + emptyStateContainerView.setPadding(1, 2, 3, 4) + + emptyStateUiHelper.setupContainerPadding() + + assertThat(emptyStateContainerView.paddingLeft).isEqualTo(1) + assertThat(emptyStateContainerView.paddingTop).isEqualTo(2) + assertThat(emptyStateContainerView.paddingRight).isEqualTo(3) + assertThat(emptyStateContainerView.paddingBottom).isEqualTo(4) + } + + @Test + fun testBottomPaddingDelegate_override() { + shouldOverrideContainerPadding = true // Set bottom padding to 42. + emptyStateContainerView.setPadding(1, 2, 3, 4) + + emptyStateUiHelper.setupContainerPadding() + + assertThat(emptyStateContainerView.paddingLeft).isEqualTo(1) + assertThat(emptyStateContainerView.paddingTop).isEqualTo(2) + assertThat(emptyStateContainerView.paddingRight).isEqualTo(3) + assertThat(emptyStateContainerView.paddingBottom).isEqualTo(42) + } + + @Test + fun testShowEmptyState_noOnClickHandler() { + mainListView.visibility = View.VISIBLE + + // Note: an `EmptyState.ClickListener` isn't invoked directly by the UI helper; it has to be + // built into the "on-click handler" that's injected to implement the button-press. We won't + // display the button without a click "handler," even if it *does* have a `ClickListener`. + val clickListener = mock() + + val emptyState = + object : EmptyState { + override fun getTitle() = "Test title" + override fun getSubtitle() = "Test subtitle" + + override fun getButtonClickListener() = clickListener + } + emptyStateUiHelper.showEmptyState(emptyState, null) + + assertThat(mainListView.visibility).isEqualTo(View.GONE) + assertThat(emptyStateRootView.visibility).isEqualTo(View.VISIBLE) + assertThat(emptyStateTitleView.visibility).isEqualTo(View.VISIBLE) + assertThat(emptyStateSubtitleView.visibility).isEqualTo(View.VISIBLE) + assertThat(emptyStateButtonView.visibility).isEqualTo(View.GONE) + assertThat(emptyStateProgressView.visibility).isEqualTo(View.GONE) + assertThat(emptyStateDefaultTextView.visibility).isEqualTo(View.GONE) + + assertThat(emptyStateTitleView.text).isEqualTo("Test title") + assertThat(emptyStateSubtitleView.text).isEqualTo("Test subtitle") + + verify(clickListener, never()).onClick(any()) + } + + @Test + fun testShowEmptyState_withOnClickHandlerAndClickListener() { + mainListView.visibility = View.VISIBLE + + val clickListener = mock() + val onClickHandler = mock() + + val emptyState = + object : EmptyState { + override fun getTitle() = "Test title" + override fun getSubtitle() = "Test subtitle" + + override fun getButtonClickListener() = clickListener + } + emptyStateUiHelper.showEmptyState(emptyState, onClickHandler) + + assertThat(mainListView.visibility).isEqualTo(View.GONE) + assertThat(emptyStateRootView.visibility).isEqualTo(View.VISIBLE) + assertThat(emptyStateTitleView.visibility).isEqualTo(View.VISIBLE) + assertThat(emptyStateSubtitleView.visibility).isEqualTo(View.VISIBLE) + assertThat(emptyStateButtonView.visibility).isEqualTo(View.VISIBLE) // Now shown. + assertThat(emptyStateProgressView.visibility).isEqualTo(View.GONE) + assertThat(emptyStateDefaultTextView.visibility).isEqualTo(View.GONE) + + assertThat(emptyStateTitleView.text).isEqualTo("Test title") + assertThat(emptyStateSubtitleView.text).isEqualTo("Test subtitle") + + emptyStateButtonView.performClick() + + verify(onClickHandler).onClick(emptyStateButtonView) + // The test didn't explicitly configure its `OnClickListener` to relay the click event on + // to the `EmptyState.ClickListener`, so it still won't have fired here. + verify(clickListener, never()).onClick(any()) } } diff --git a/tests/unit/src/com/android/intentresolver/ext/CreationExtrasExtTest.kt b/tests/unit/src/com/android/intentresolver/ext/CreationExtrasExtTest.kt new file mode 100644 index 00000000..c09047a1 --- /dev/null +++ b/tests/unit/src/com/android/intentresolver/ext/CreationExtrasExtTest.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.ext + +import android.graphics.Point +import androidx.core.os.bundleOf +import androidx.lifecycle.DEFAULT_ARGS_KEY +import androidx.lifecycle.viewmodel.CreationExtras +import androidx.lifecycle.viewmodel.MutableCreationExtras +import androidx.test.ext.truth.os.BundleSubject.assertThat +import org.junit.Test + +class CreationExtrasExtTest { + @Test + fun addDefaultArgs_addsWhenAbsent() { + val creationExtras: CreationExtras = MutableCreationExtras() // empty + + val updated = creationExtras.addDefaultArgs("POINT" to Point(1, 1)) + + val defaultArgs = updated[DEFAULT_ARGS_KEY] + assertThat(defaultArgs).containsKey("POINT") + assertThat(defaultArgs).parcelable("POINT").marshallsEquallyTo(Point(1, 1)) + } + + @Test + fun addDefaultArgs_addsToExisting() { + val creationExtras: CreationExtras = + MutableCreationExtras().apply { + set(DEFAULT_ARGS_KEY, bundleOf("POINT1" to Point(1, 1))) + } + + val updated = creationExtras.addDefaultArgs("POINT2" to Point(2, 2)) + + val defaultArgs = updated[DEFAULT_ARGS_KEY] + assertThat(defaultArgs).containsKey("POINT1") + assertThat(defaultArgs).containsKey("POINT2") + assertThat(defaultArgs).parcelable("POINT1").marshallsEquallyTo(Point(1, 1)) + assertThat(defaultArgs).parcelable("POINT2").marshallsEquallyTo(Point(2, 2)) + } +} diff --git a/tests/unit/src/com/android/intentresolver/ext/IntentExtTest.kt b/tests/unit/src/com/android/intentresolver/ext/IntentExtTest.kt new file mode 100644 index 00000000..bf1e159c --- /dev/null +++ b/tests/unit/src/com/android/intentresolver/ext/IntentExtTest.kt @@ -0,0 +1,85 @@ +/* + * Copyright (C) 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.android.intentresolver.ext + +import android.content.ComponentName +import android.content.Intent +import com.google.common.truth.Truth.assertThat +import com.google.common.truth.Truth.assertWithMessage +import java.util.function.Predicate +import org.junit.Test + +class IntentExtTest { + + private val hasSendAction = + Predicate { + it?.action == Intent.ACTION_SEND || it?.action == Intent.ACTION_SEND_MULTIPLE + } + + @Test + fun hasAction() { + val sendIntent = Intent(Intent.ACTION_SEND) + assertThat(sendIntent.hasAction(Intent.ACTION_SEND)).isTrue() + assertThat(sendIntent.hasAction(Intent.ACTION_VIEW)).isFalse() + } + + @Test + fun hasComponent() { + assertThat(Intent().hasComponent()).isFalse() + assertThat(Intent().setComponent(ComponentName("A", "B")).hasComponent()).isTrue() + } + + @Test + fun hasSendAction() { + assertThat(Intent(Intent.ACTION_SEND).hasSendAction()).isTrue() + assertThat(Intent(Intent.ACTION_SEND_MULTIPLE).hasSendAction()).isTrue() + assertThat(Intent(Intent.ACTION_SENDTO).hasSendAction()).isFalse() + assertThat(Intent(Intent.ACTION_VIEW).hasSendAction()).isFalse() + } + + @Test + fun hasSingleCategory() { + val intent = Intent().addCategory(Intent.CATEGORY_HOME) + assertThat(intent.hasSingleCategory(Intent.CATEGORY_HOME)).isTrue() + assertThat(intent.hasSingleCategory(Intent.CATEGORY_DEFAULT)).isFalse() + + intent.addCategory(Intent.CATEGORY_TEST) + assertThat(intent.hasSingleCategory(Intent.CATEGORY_TEST)).isFalse() + } + + @Test + fun ifMatch_matched() { + val sendIntent = Intent(Intent.ACTION_SEND) + val sendMultipleIntent = Intent(Intent.ACTION_SEND_MULTIPLE) + + sendIntent.ifMatch(hasSendAction) { addFlags(Intent.FLAG_ACTIVITY_MATCH_EXTERNAL) } + sendMultipleIntent.ifMatch(hasSendAction) { addFlags(Intent.FLAG_ACTIVITY_MATCH_EXTERNAL) } + assertWithMessage("sendIntent flags") + .that(sendIntent.flags) + .isEqualTo(Intent.FLAG_ACTIVITY_MATCH_EXTERNAL) + assertWithMessage("sendMultipleIntent flags") + .that(sendMultipleIntent.flags) + .isEqualTo(Intent.FLAG_ACTIVITY_MATCH_EXTERNAL) + } + + @Test + fun ifMatch_notMatched() { + val viewIntent = Intent(Intent.ACTION_VIEW) + + viewIntent.ifMatch(hasSendAction) { addFlags(Intent.FLAG_ACTIVITY_MATCH_EXTERNAL) } + assertWithMessage("viewIntent flags").that(viewIntent.flags).isEqualTo(0) + } +} diff --git a/tests/unit/src/com/android/intentresolver/platform/FakeSecureSettingsTest.kt b/tests/unit/src/com/android/intentresolver/platform/FakeSecureSettingsTest.kt new file mode 100644 index 00000000..1f08e541 --- /dev/null +++ b/tests/unit/src/com/android/intentresolver/platform/FakeSecureSettingsTest.kt @@ -0,0 +1,61 @@ +package com.android.intentresolver.platform + +import com.google.common.truth.Truth.assertThat + +class FakeSecureSettingsTest { + + private val secureSettings = fakeSecureSettings { + putInt(intKey, intVal) + putString(stringKey, stringVal) + putFloat(floatKey, floatVal) + putLong(longKey, longVal) + } + + fun testExpectedValues_returned() { + assertThat(secureSettings.getInt(intKey)).isEqualTo(intVal) + assertThat(secureSettings.getString(stringKey)).isEqualTo(stringVal) + assertThat(secureSettings.getFloat(floatKey)).isEqualTo(floatVal) + assertThat(secureSettings.getLong(longKey)).isEqualTo(longVal) + } + + fun testUndefinedValues_returnNull() { + assertThat(secureSettings.getInt("unknown")).isNull() + assertThat(secureSettings.getString("unknown")).isNull() + assertThat(secureSettings.getFloat("unknown")).isNull() + assertThat(secureSettings.getLong("unknown")).isNull() + } + + /** + * FakeSecureSettings models the real secure settings by storing values in String form. The + * value is returned if/when it can be parsed from the string value, otherwise null. + */ + fun testMismatchedTypes() { + assertThat(secureSettings.getString(intKey)).isEqualTo(intVal.toString()) + assertThat(secureSettings.getString(floatKey)).isEqualTo(floatVal.toString()) + assertThat(secureSettings.getString(longKey)).isEqualTo(longVal.toString()) + + assertThat(secureSettings.getInt(stringKey)).isNull() + assertThat(secureSettings.getLong(stringKey)).isNull() + assertThat(secureSettings.getFloat(stringKey)).isNull() + + assertThat(secureSettings.getInt(longKey)).isNull() + assertThat(secureSettings.getFloat(longKey)).isNull() // TODO: verify Long.MAX > Float.MAX ? + + assertThat(secureSettings.getLong(floatKey)).isNull() // TODO: or is Float.MAX > Long.MAX? + assertThat(secureSettings.getInt(floatKey)).isNull() + } + + companion object Data { + const val intKey = "int" + const val intVal = Int.MAX_VALUE + + const val stringKey = "string" + const val stringVal = "String" + + const val floatKey = "float" + const val floatVal = Float.MAX_VALUE + + const val longKey = "long" + const val longVal = Long.MAX_VALUE + } +} diff --git a/tests/unit/src/com/android/intentresolver/platform/FakeUserManagerTest.kt b/tests/unit/src/com/android/intentresolver/platform/FakeUserManagerTest.kt new file mode 100644 index 00000000..5be6b50e --- /dev/null +++ b/tests/unit/src/com/android/intentresolver/platform/FakeUserManagerTest.kt @@ -0,0 +1,128 @@ +package com.android.intentresolver.platform + +import android.content.pm.UserInfo +import android.content.pm.UserInfo.NO_PROFILE_GROUP_ID +import android.os.UserHandle +import android.os.UserManager +import com.android.intentresolver.platform.FakeUserManager.ProfileType +import com.google.common.truth.Correspondence +import com.google.common.truth.Truth.assertThat +import com.google.common.truth.Truth.assertWithMessage +import org.junit.Assert.assertTrue +import org.junit.Test + +class FakeUserManagerTest { + private val userManager = FakeUserManager() + private val state = userManager.state + + @Test + fun initialState() { + val personal = userManager.getEnabledProfiles(state.primaryUserHandle.identifier).single() + + assertThat(personal.id).isEqualTo(state.primaryUserHandle.identifier) + assertThat(personal.userType).isEqualTo(UserManager.USER_TYPE_FULL_SYSTEM) + assertThat(personal.flags and UserInfo.FLAG_FULL).isEqualTo(UserInfo.FLAG_FULL) + } + + @Test + fun getProfileParent() { + val workHandle = state.createProfile(ProfileType.WORK) + + assertThat(userManager.getProfileParent(state.primaryUserHandle)).isNull() + assertThat(userManager.getProfileParent(workHandle)).isEqualTo(state.primaryUserHandle) + assertThat(userManager.getProfileParent(UserHandle.of(-1))).isNull() + } + + @Test + fun getUserInfo() { + val personalUser = + requireNotNull(userManager.getUserInfo(state.primaryUserHandle.identifier)) { + "Expected getUserInfo to return non-null" + } + assertTrue(userInfoAreEqual.apply(personalUser, state.getPrimaryUser())) + + val workHandle = state.createProfile(ProfileType.WORK) + + val workUser = + requireNotNull(userManager.getUserInfo(workHandle.identifier)) { + "Expected getUserInfo to return non-null" + } + assertTrue( + userInfoAreEqual.apply(workUser, userManager.getUserInfo(workHandle.identifier)!!) + ) + } + + @Test + fun getEnabledProfiles_usingParentId() { + val personal = state.primaryUserHandle + val work = state.createProfile(ProfileType.WORK) + val private = state.createProfile(ProfileType.PRIVATE) + + val enabledProfiles = userManager.getEnabledProfiles(personal.identifier) + + assertWithMessage("enabledProfiles: List") + .that(enabledProfiles) + .comparingElementsUsing(userInfoEquality) + .displayingDiffsPairedBy { it.id } + .containsExactly(state.getPrimaryUser(), state.getUser(work), state.getUser(private)) + } + + @Test + fun getEnabledProfiles_usingProfileId() { + val clone = state.createProfile(ProfileType.CLONE) + + val enabledProfiles = userManager.getEnabledProfiles(clone.identifier) + + assertWithMessage("getEnabledProfiles(clone.identifier)") + .that(enabledProfiles) + .comparingElementsUsing(userInfoEquality) + .displayingDiffsPairedBy { it.id } + .containsExactly(state.getPrimaryUser(), state.getUser(clone)) + } + + @Test + fun getUserOrNull() { + val personal = state.getPrimaryUser() + + assertThat(state.getUserOrNull(personal.userHandle)).isEqualTo(personal) + assertThat(state.getUserOrNull(UserHandle.of(personal.id - 1))).isNull() + } + + @Test + fun createProfile() { + // Order dependent: profile creation modifies the primary user + val workHandle = state.createProfile(ProfileType.WORK) + + val primaryUser = state.getPrimaryUser() + val workUser = state.getUser(workHandle) + + assertThat(primaryUser.profileGroupId).isNotEqualTo(NO_PROFILE_GROUP_ID) + assertThat(workUser.profileGroupId).isEqualTo(primaryUser.profileGroupId) + } + + @Test + fun removeProfile() { + val personal = state.getPrimaryUser() + val work = state.createProfile(ProfileType.WORK) + val private = state.createProfile(ProfileType.PRIVATE) + + state.removeProfile(private) + assertThat(state.userHandles).containsExactly(personal.userHandle, work) + } + + @Test(expected = IllegalArgumentException::class) + fun removeProfile_primaryNotAllowed() { + state.removeProfile(state.primaryUserHandle) + } +} + +private val userInfoAreEqual = + Correspondence.BinaryPredicate { actual, expected -> + actual.id == expected.id && + actual.profileGroupId == expected.profileGroupId && + actual.userType == expected.userType && + actual.flags == expected.flags + } + +val userInfoEquality: Correspondence = + Correspondence.from(userInfoAreEqual, "==") diff --git a/tests/unit/src/com/android/intentresolver/platform/NearbyShareModuleTest.kt b/tests/unit/src/com/android/intentresolver/platform/NearbyShareModuleTest.kt new file mode 100644 index 00000000..56b691e6 --- /dev/null +++ b/tests/unit/src/com/android/intentresolver/platform/NearbyShareModuleTest.kt @@ -0,0 +1,79 @@ +package com.android.intentresolver.platform + +import android.content.ComponentName +import android.content.Context +import android.content.res.Configuration +import android.provider.Settings +import android.testing.TestableResources +import androidx.test.platform.app.InstrumentationRegistry +import com.android.intentresolver.R +import com.google.common.truth.Truth8.assertThat +import org.junit.Before +import org.junit.Test + +class NearbyShareModuleTest { + + lateinit var context: Context + + /** Create Resources with overridden values. */ + private fun Context.fakeResources( + config: Configuration? = null, + block: TestableResources.() -> Unit + ) = + TestableResources(resources) + .apply { config?.let { overrideConfiguration(it) } } + .apply(block) + .resources + + @Before + fun setup() { + val instr = InstrumentationRegistry.getInstrumentation() + context = instr.context + } + + @Test + fun valueIsAbsent_whenUnset() { + val secureSettings = fakeSecureSettings {} + val resources = + context.fakeResources { addOverride(R.string.config_defaultNearbySharingComponent, "") } + + val componentName = NearbyShareModule.nearbyShareComponent(resources, secureSettings) + assertThat(componentName).isEmpty() + } + + @Test + fun defaultValue_readFromResources() { + val secureSettings = fakeSecureSettings {} + val resources = + context.fakeResources { + addOverride( + R.string.config_defaultNearbySharingComponent, + "com.example/.ComponentName" + ) + } + + val nearbyShareComponent = NearbyShareModule.nearbyShareComponent(resources, secureSettings) + + assertThat(nearbyShareComponent) + .hasValue(ComponentName.unflattenFromString("com.example/.ComponentName")) + } + + @Test + fun secureSettings_overridesDefault() { + val secureSettings = fakeSecureSettings { + putString(Settings.Secure.NEARBY_SHARING_COMPONENT, "com.example/.BComponent") + } + val resources = + context.fakeResources { + addOverride( + R.string.config_defaultNearbySharingComponent, + "com.example/.AComponent" + ) + } + + val nearbyShareComponent = NearbyShareModule.nearbyShareComponent(resources, secureSettings) + + assertThat(nearbyShareComponent) + .hasValue(ComponentName.unflattenFromString("com.example/.BComponent")) + } +} diff --git a/tests/unit/src/com/android/intentresolver/profiles/MultiProfilePagerAdapterTest.kt b/tests/unit/src/com/android/intentresolver/profiles/MultiProfilePagerAdapterTest.kt new file mode 100644 index 00000000..edeb5c8c --- /dev/null +++ b/tests/unit/src/com/android/intentresolver/profiles/MultiProfilePagerAdapterTest.kt @@ -0,0 +1,342 @@ +/* + * Copyright (C) 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.intentresolver.profiles + +import android.os.UserHandle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.ListView +import androidx.test.platform.app.InstrumentationRegistry +import com.android.intentresolver.R +import com.android.intentresolver.ResolverListAdapter +import com.android.intentresolver.emptystate.EmptyStateProvider +import com.android.intentresolver.mock +import com.android.intentresolver.profiles.MultiProfilePagerAdapter.PROFILE_PERSONAL +import com.android.intentresolver.profiles.MultiProfilePagerAdapter.PROFILE_WORK +import com.android.intentresolver.whenever +import com.google.common.collect.ImmutableList +import com.google.common.truth.Truth.assertThat +import java.util.Optional +import java.util.function.Supplier +import org.junit.Test + +class MultiProfilePagerAdapterTest { + private val PERSONAL_USER_HANDLE = UserHandle.of(10) + private val WORK_USER_HANDLE = UserHandle.of(20) + + private val context = InstrumentationRegistry.getInstrumentation().getContext() + private val inflater = Supplier { + LayoutInflater.from(context).inflate(R.layout.resolver_list_per_profile, null, false) + as ViewGroup + } + + @Test + fun testSinglePageProfileAdapter() { + val personalListAdapter = + mock { whenever(getUserHandle()).thenReturn(PERSONAL_USER_HANDLE) } + val pagerAdapter = + MultiProfilePagerAdapter( + { listAdapter: ResolverListAdapter -> listAdapter }, + { listView: ListView, bindAdapter: ResolverListAdapter -> + listView.setAdapter(bindAdapter) + }, + ImmutableList.of( + TabConfig( + PROFILE_PERSONAL, + "personal", + "personal_a11y", + "TAG_PERSONAL", + personalListAdapter + ) + ), + object : EmptyStateProvider {}, + { false }, + PROFILE_PERSONAL, + null, + null, + inflater, + { Optional.empty() } + ) + assertThat(pagerAdapter.count).isEqualTo(1) + assertThat(pagerAdapter.currentPage).isEqualTo(PROFILE_PERSONAL) + assertThat(pagerAdapter.currentUserHandle).isEqualTo(PERSONAL_USER_HANDLE) + assertThat(pagerAdapter.getPageAdapterForIndex(0)).isSameInstanceAs(personalListAdapter) + assertThat(pagerAdapter.activeListAdapter).isSameInstanceAs(personalListAdapter) + assertThat(pagerAdapter.personalListAdapter).isSameInstanceAs(personalListAdapter) + assertThat(pagerAdapter.workListAdapter).isNull() + assertThat(pagerAdapter.itemCount).isEqualTo(1) + // TODO: consider covering some of the package-private methods (and making them public?). + // TODO: consider exercising responsibilities as an implementation of a ViewPager adapter. + } + + @Test + fun testTwoProfilePagerAdapter() { + val personalListAdapter = + mock { whenever(getUserHandle()).thenReturn(PERSONAL_USER_HANDLE) } + val workListAdapter = + mock { whenever(getUserHandle()).thenReturn(WORK_USER_HANDLE) } + val pagerAdapter = + MultiProfilePagerAdapter( + { listAdapter: ResolverListAdapter -> listAdapter }, + { listView: ListView, bindAdapter: ResolverListAdapter -> + listView.setAdapter(bindAdapter) + }, + ImmutableList.of( + TabConfig( + PROFILE_PERSONAL, + "personal", + "personal_a11y", + "TAG_PERSONAL", + personalListAdapter + ), + TabConfig(PROFILE_WORK, "work", "work_a11y", "TAG_WORK", workListAdapter) + ), + object : EmptyStateProvider {}, + { false }, + PROFILE_PERSONAL, + WORK_USER_HANDLE, // TODO: why does this test pass even if this is null? + null, + inflater, + { Optional.empty() } + ) + assertThat(pagerAdapter.count).isEqualTo(2) + assertThat(pagerAdapter.currentPage).isEqualTo(PROFILE_PERSONAL) + assertThat(pagerAdapter.currentUserHandle).isEqualTo(PERSONAL_USER_HANDLE) + assertThat(pagerAdapter.getPageAdapterForIndex(0)).isSameInstanceAs(personalListAdapter) + assertThat(pagerAdapter.getPageAdapterForIndex(1)).isSameInstanceAs(workListAdapter) + assertThat(pagerAdapter.activeListAdapter).isSameInstanceAs(personalListAdapter) + assertThat(pagerAdapter.personalListAdapter).isSameInstanceAs(personalListAdapter) + assertThat(pagerAdapter.workListAdapter).isSameInstanceAs(workListAdapter) + assertThat(pagerAdapter.itemCount).isEqualTo(2) + // TODO: consider covering some of the package-private methods (and making them public?). + // TODO: consider exercising responsibilities as an implementation of a ViewPager adapter; + // especially matching profiles to ListViews? + // TODO: test ProfileSelectedListener (and getters for "current" state) as the selected + // page changes. Currently there's no API to change the selected page directly; that's + // only possible through manipulation of the bound ViewPager. + } + + @Test + fun testTwoProfilePagerAdapter_workIsDefault() { + val personalListAdapter = + mock { whenever(getUserHandle()).thenReturn(PERSONAL_USER_HANDLE) } + val workListAdapter = + mock { whenever(getUserHandle()).thenReturn(WORK_USER_HANDLE) } + val pagerAdapter = + MultiProfilePagerAdapter( + { listAdapter: ResolverListAdapter -> listAdapter }, + { listView: ListView, bindAdapter: ResolverListAdapter -> + listView.setAdapter(bindAdapter) + }, + ImmutableList.of( + TabConfig( + PROFILE_PERSONAL, + "personal", + "personal_a11y", + "TAG_PERSONAL", + personalListAdapter + ), + TabConfig(PROFILE_WORK, "work", "work_a11y", "TAG_WORK", workListAdapter) + ), + object : EmptyStateProvider {}, + { false }, + PROFILE_WORK, // <-- This test specifically requests we start on work profile. + WORK_USER_HANDLE, // TODO: why does this test pass even if this is null? + null, + inflater, + { Optional.empty() } + ) + assertThat(pagerAdapter.count).isEqualTo(2) + assertThat(pagerAdapter.currentPage).isEqualTo(PROFILE_WORK) + assertThat(pagerAdapter.currentUserHandle).isEqualTo(WORK_USER_HANDLE) + assertThat(pagerAdapter.getPageAdapterForIndex(0)).isSameInstanceAs(personalListAdapter) + assertThat(pagerAdapter.getPageAdapterForIndex(1)).isSameInstanceAs(workListAdapter) + assertThat(pagerAdapter.activeListAdapter).isSameInstanceAs(workListAdapter) + assertThat(pagerAdapter.personalListAdapter).isSameInstanceAs(personalListAdapter) + assertThat(pagerAdapter.workListAdapter).isSameInstanceAs(workListAdapter) + assertThat(pagerAdapter.itemCount).isEqualTo(2) + // TODO: consider covering some of the package-private methods (and making them public?). + // TODO: test ProfileSelectedListener (and getters for "current" state) as the selected + // page changes. Currently there's no API to change the selected page directly; that's + // only possible through manipulation of the bound ViewPager. + } + + @Test + fun testBottomPaddingDelegate_default() { + val personalListAdapter = + mock { whenever(getUserHandle()).thenReturn(PERSONAL_USER_HANDLE) } + val pagerAdapter = + MultiProfilePagerAdapter( + { listAdapter: ResolverListAdapter -> listAdapter }, + { listView: ListView, bindAdapter: ResolverListAdapter -> + listView.setAdapter(bindAdapter) + }, + ImmutableList.of( + TabConfig( + PROFILE_PERSONAL, + "personal", + "personal_a11y", + "TAG_PERSONAL", + personalListAdapter + ) + ), + object : EmptyStateProvider {}, + { false }, + PROFILE_PERSONAL, + null, + null, + inflater, + { Optional.empty() } + ) + val container = + pagerAdapter + .getActiveEmptyStateView() + .requireViewById(com.android.internal.R.id.resolver_empty_state_container) + container.setPadding(1, 2, 3, 4) + pagerAdapter.setupContainerPadding() + assertThat(container.paddingLeft).isEqualTo(1) + assertThat(container.paddingTop).isEqualTo(2) + assertThat(container.paddingRight).isEqualTo(3) + assertThat(container.paddingBottom).isEqualTo(4) + } + + @Test + fun testBottomPaddingDelegate_override() { + val personalListAdapter = + mock { whenever(getUserHandle()).thenReturn(PERSONAL_USER_HANDLE) } + val pagerAdapter = + MultiProfilePagerAdapter( + { listAdapter: ResolverListAdapter -> listAdapter }, + { listView: ListView, bindAdapter: ResolverListAdapter -> + listView.setAdapter(bindAdapter) + }, + ImmutableList.of( + TabConfig( + PROFILE_PERSONAL, + "personal", + "personal_a11y", + "TAG_PERSONAL", + personalListAdapter + ) + ), + object : EmptyStateProvider {}, + { false }, + PROFILE_PERSONAL, + null, + null, + inflater, + { Optional.of(42) } + ) + val container = + pagerAdapter + .getActiveEmptyStateView() + .requireViewById(com.android.internal.R.id.resolver_empty_state_container) + container.setPadding(1, 2, 3, 4) + pagerAdapter.setupContainerPadding() + assertThat(container.paddingLeft).isEqualTo(1) + assertThat(container.paddingTop).isEqualTo(2) + assertThat(container.paddingRight).isEqualTo(3) + assertThat(container.paddingBottom).isEqualTo(42) + } + + @Test + fun testPresumedQuietModeEmptyStateForWorkProfile_whenQuiet() { + // TODO: this is "presumed" because the conditions to determine whether we "should" show an + // empty state aren't enforced to align with the conditions when we actually *would* -- I + // believe `shouldShowEmptyStateScreen` should be implemented in terms of the provider? + val personalListAdapter = + mock { + whenever(getUserHandle()).thenReturn(PERSONAL_USER_HANDLE) + whenever(getUnfilteredCount()).thenReturn(1) + } + val workListAdapter = + mock { + whenever(getUserHandle()).thenReturn(WORK_USER_HANDLE) + whenever(getUnfilteredCount()).thenReturn(1) + } + val pagerAdapter = + MultiProfilePagerAdapter( + { listAdapter: ResolverListAdapter -> listAdapter }, + { listView: ListView, bindAdapter: ResolverListAdapter -> + listView.setAdapter(bindAdapter) + }, + ImmutableList.of( + TabConfig( + PROFILE_PERSONAL, + "personal", + "personal_a11y", + "TAG_PERSONAL", + personalListAdapter + ), + TabConfig(PROFILE_WORK, "work", "work_a11y", "TAG_WORK", workListAdapter) + ), + object : EmptyStateProvider {}, + { true }, // <-- Work mode is quiet. + PROFILE_WORK, + WORK_USER_HANDLE, + null, + inflater, + { Optional.empty() } + ) + assertThat(pagerAdapter.shouldShowEmptyStateScreen(workListAdapter)).isTrue() + assertThat(pagerAdapter.shouldShowEmptyStateScreen(personalListAdapter)).isFalse() + } + + @Test + fun testPresumedQuietModeEmptyStateForWorkProfile_notWhenNotQuiet() { + // TODO: this is "presumed" because the conditions to determine whether we "should" show an + // empty state aren't enforced to align with the conditions when we actually *would* -- I + // believe `shouldShowEmptyStateScreen` should be implemented in terms of the provider? + val personalListAdapter = + mock { + whenever(getUserHandle()).thenReturn(PERSONAL_USER_HANDLE) + whenever(getUnfilteredCount()).thenReturn(1) + } + val workListAdapter = + mock { + whenever(getUserHandle()).thenReturn(WORK_USER_HANDLE) + whenever(getUnfilteredCount()).thenReturn(1) + } + val pagerAdapter = + MultiProfilePagerAdapter( + { listAdapter: ResolverListAdapter -> listAdapter }, + { listView: ListView, bindAdapter: ResolverListAdapter -> + listView.setAdapter(bindAdapter) + }, + ImmutableList.of( + TabConfig( + PROFILE_PERSONAL, + "personal", + "personal_a11y", + "TAG_PERSONAL", + personalListAdapter + ), + TabConfig(PROFILE_WORK, "work", "work_a11y", "TAG_WORK", workListAdapter) + ), + object : EmptyStateProvider {}, + { false }, // <-- Work mode is not quiet. + PROFILE_WORK, + WORK_USER_HANDLE, + null, + inflater, + { Optional.empty() } + ) + assertThat(pagerAdapter.shouldShowEmptyStateScreen(workListAdapter)).isFalse() + assertThat(pagerAdapter.shouldShowEmptyStateScreen(personalListAdapter)).isFalse() + } +} diff --git a/tests/unit/src/com/android/intentresolver/ui/ShareResultSenderImplTest.kt b/tests/unit/src/com/android/intentresolver/ui/ShareResultSenderImplTest.kt new file mode 100644 index 00000000..c254a856 --- /dev/null +++ b/tests/unit/src/com/android/intentresolver/ui/ShareResultSenderImplTest.kt @@ -0,0 +1,190 @@ +/* + * Copyright (C) 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.intentresolver.ui + +import android.app.PendingIntent +import android.compat.testing.PlatformCompatChangeRule +import android.content.ComponentName +import android.content.Intent +import android.os.Process +import android.service.chooser.ChooserResult +import android.service.chooser.Flags +import androidx.test.platform.app.InstrumentationRegistry +import com.android.intentresolver.inject.FakeChooserServiceFlags +import com.android.intentresolver.ui.model.ShareAction +import com.google.common.truth.Truth.assertThat +import com.google.common.truth.Truth.assertWithMessage +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import libcore.junit.util.compat.CoreCompatChangeRule.DisableCompatChanges +import libcore.junit.util.compat.CoreCompatChangeRule.EnableCompatChanges +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TestRule + +@OptIn(ExperimentalCoroutinesApi::class) +class ShareResultSenderImplTest { + + private val context = InstrumentationRegistry.getInstrumentation().context + + @get:Rule val compatChangeRule: TestRule = PlatformCompatChangeRule() + + val flags = FakeChooserServiceFlags() + + @OptIn(ExperimentalCoroutinesApi::class) + @EnableCompatChanges(ChooserResult.SEND_CHOOSER_RESULT) + @Test + fun onComponentSelected_chooserResultEnabled() = runTest { + val pi = PendingIntent.getBroadcast(context, 0, Intent(), PendingIntent.FLAG_IMMUTABLE) + val deferred = CompletableDeferred() + val intentDispatcher = IntentSenderDispatcher { _, intent -> deferred.complete(intent) } + + flags.setFlag(Flags.FLAG_ENABLE_CHOOSER_RESULT, true) + + val resultSender = + ShareResultSenderImpl( + flags = flags, + scope = this, + backgroundDispatcher = UnconfinedTestDispatcher(testScheduler), + callerUid = Process.myUid(), + resultSender = pi.intentSender, + intentDispatcher = intentDispatcher + ) + + resultSender.onComponentSelected(ComponentName("example.com", "Foo"), true) + runCurrent() + + val intentReceived = deferred.await() + val chooserResult = + intentReceived.getParcelableExtra( + Intent.EXTRA_CHOOSER_RESULT, + ChooserResult::class.java + ) + assertThat(chooserResult).isNotNull() + assertThat(chooserResult?.type).isEqualTo(ChooserResult.CHOOSER_RESULT_SELECTED_COMPONENT) + assertThat(chooserResult?.selectedComponent).isEqualTo(ComponentName("example.com", "Foo")) + assertThat(chooserResult?.isShortcut).isTrue() + } + + @DisableCompatChanges(ChooserResult.SEND_CHOOSER_RESULT) + @Test + fun onComponentSelected_chooserResultDisabled() = runTest { + val pi = PendingIntent.getBroadcast(context, 0, Intent(), PendingIntent.FLAG_IMMUTABLE) + val deferred = CompletableDeferred() + val intentDispatcher = IntentSenderDispatcher { _, intent -> deferred.complete(intent) } + + flags.setFlag(Flags.FLAG_ENABLE_CHOOSER_RESULT, true) + + val resultSender = + ShareResultSenderImpl( + flags = flags, + scope = this, + backgroundDispatcher = UnconfinedTestDispatcher(testScheduler), + callerUid = Process.myUid(), + resultSender = pi.intentSender, + intentDispatcher = intentDispatcher + ) + + resultSender.onComponentSelected(ComponentName("example.com", "Foo"), true) + runCurrent() + + val intentReceived = deferred.await() + val componentName = + intentReceived.getParcelableExtra( + Intent.EXTRA_CHOSEN_COMPONENT, + ComponentName::class.java + ) + + assertWithMessage("EXTRA_CHOSEN_COMPONENT from received intent") + .that(componentName) + .isEqualTo(ComponentName("example.com", "Foo")) + + assertWithMessage("received intent has EXTRA_CHOOSER_RESULT") + .that(intentReceived.hasExtra(Intent.EXTRA_CHOOSER_RESULT)) + .isFalse() + } + + @EnableCompatChanges(ChooserResult.SEND_CHOOSER_RESULT) + @Test + fun onActionSelected_chooserResultEnabled() = runTest { + val pi = PendingIntent.getBroadcast(context, 0, Intent(), PendingIntent.FLAG_IMMUTABLE) + val deferred = CompletableDeferred() + val intentDispatcher = IntentSenderDispatcher { _, intent -> deferred.complete(intent) } + + flags.setFlag(Flags.FLAG_ENABLE_CHOOSER_RESULT, true) + + val resultSender = + ShareResultSenderImpl( + flags = flags, + scope = this, + backgroundDispatcher = UnconfinedTestDispatcher(testScheduler), + callerUid = Process.myUid(), + resultSender = pi.intentSender, + intentDispatcher = intentDispatcher + ) + + resultSender.onActionSelected(ShareAction.SYSTEM_COPY) + runCurrent() + + val intentReceived = deferred.await() + val chosenComponent = + intentReceived.getParcelableExtra( + Intent.EXTRA_CHOSEN_COMPONENT, + ChooserResult::class.java + ) + assertThat(chosenComponent).isNull() + + val chooserResult = + intentReceived.getParcelableExtra( + Intent.EXTRA_CHOOSER_RESULT, + ChooserResult::class.java + ) + assertThat(chooserResult).isNotNull() + assertThat(chooserResult?.type).isEqualTo(ChooserResult.CHOOSER_RESULT_COPY) + assertThat(chooserResult?.selectedComponent).isNull() + assertThat(chooserResult?.isShortcut).isFalse() + } + + @DisableCompatChanges(ChooserResult.SEND_CHOOSER_RESULT) + @Test + fun onActionSelected_chooserResultDisabled() = runTest { + val pi = PendingIntent.getBroadcast(context, 0, Intent(), PendingIntent.FLAG_IMMUTABLE) + val deferred = CompletableDeferred() + val intentDispatcher = IntentSenderDispatcher { _, intent -> deferred.complete(intent) } + + flags.setFlag(Flags.FLAG_ENABLE_CHOOSER_RESULT, true) + + val resultSender = + ShareResultSenderImpl( + flags = flags, + scope = this, + backgroundDispatcher = UnconfinedTestDispatcher(testScheduler), + callerUid = Process.myUid(), + resultSender = pi.intentSender, + intentDispatcher = intentDispatcher + ) + + resultSender.onActionSelected(ShareAction.SYSTEM_COPY) + runCurrent() + + // No result should have been sent, this should never complete + assertWithMessage("deferred result isComplete").that(deferred.isCompleted).isFalse() + } +} diff --git a/tests/unit/src/com/android/intentresolver/ui/model/ActivityModelTest.kt b/tests/unit/src/com/android/intentresolver/ui/model/ActivityModelTest.kt new file mode 100644 index 00000000..737f02fe --- /dev/null +++ b/tests/unit/src/com/android/intentresolver/ui/model/ActivityModelTest.kt @@ -0,0 +1,107 @@ +/* + * Copyright (C) 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.intentresolver.ui.model + +import android.content.Intent +import android.content.Intent.ACTION_CHOOSER +import android.content.Intent.EXTRA_TEXT +import android.net.Uri +import com.android.intentresolver.ext.toParcelAndBack +import com.google.common.truth.Truth.assertThat +import com.google.common.truth.Truth.assertWithMessage +import org.junit.Test + +class ActivityModelTest { + + @Test + fun testDefaultValues() { + val input = ActivityModel(Intent(ACTION_CHOOSER), 0, "example.com", null) + + val output = input.toParcelAndBack() + + assertEquals(input, output) + } + + @Test + fun testCommonValues() { + val intent = Intent(ACTION_CHOOSER).apply { putExtra(EXTRA_TEXT, "Test") } + val input = + ActivityModel(intent, 1234, "com.example", Uri.parse("android-app://example.com")) + + val output = input.toParcelAndBack() + + assertEquals(input, output) + } + + @Test + fun testReferrerPackage_withAppReferrer_usesReferrer() { + val launch1 = + ActivityModel( + intent = Intent(), + launchedFromUid = 1000, + launchedFromPackage = "other.example.com", + referrer = Uri.parse("android-app://app.example.com") + ) + + assertThat(launch1.referrerPackage).isEqualTo("app.example.com") + } + + @Test + fun testReferrerPackage_httpReferrer_isNull() { + val launch = + ActivityModel( + intent = Intent(), + launchedFromUid = 1000, + launchedFromPackage = "example.com", + referrer = Uri.parse("http://some.other.value") + ) + + assertThat(launch.referrerPackage).isNull() + } + + @Test + fun testReferrerPackage_nullReferrer_isNull() { + val launch = + ActivityModel( + intent = Intent(), + launchedFromUid = 1000, + launchedFromPackage = "example.com", + referrer = null + ) + + assertThat(launch.referrerPackage).isNull() + } + + private fun assertEquals(expected: ActivityModel, actual: ActivityModel) { + // Test fields separately: Intent does not override equals() + assertWithMessage("%s.filterEquals(%s)", actual.intent, expected.intent) + .that(actual.intent.filterEquals(expected.intent)) + .isTrue() + + assertWithMessage("actual fromUid is equal to expected") + .that(actual.launchedFromUid) + .isEqualTo(expected.launchedFromUid) + + assertWithMessage("actual fromPackage is equal to expected") + .that(actual.launchedFromPackage) + .isEqualTo(expected.launchedFromPackage) + + assertWithMessage("actual referrer is equal to expected") + .that(actual.referrer) + .isEqualTo(expected.referrer) + } +} diff --git a/tests/unit/src/com/android/intentresolver/ui/viewmodel/ChooserRequestTest.kt b/tests/unit/src/com/android/intentresolver/ui/viewmodel/ChooserRequestTest.kt new file mode 100644 index 00000000..56c019fd --- /dev/null +++ b/tests/unit/src/com/android/intentresolver/ui/viewmodel/ChooserRequestTest.kt @@ -0,0 +1,297 @@ +/* + * Copyright (C) 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.android.intentresolver.ui.viewmodel + +import android.content.Intent +import android.content.Intent.ACTION_CHOOSER +import android.content.Intent.ACTION_SEND +import android.content.Intent.ACTION_SEND_MULTIPLE +import android.content.Intent.ACTION_VIEW +import android.content.Intent.EXTRA_ALTERNATE_INTENTS +import android.content.Intent.EXTRA_CHOOSER_ADDITIONAL_CONTENT_URI +import android.content.Intent.EXTRA_CHOOSER_FOCUSED_ITEM_POSITION +import android.content.Intent.EXTRA_INTENT +import android.content.Intent.EXTRA_REFERRER +import android.net.Uri +import android.service.chooser.Flags +import androidx.core.net.toUri +import androidx.core.os.bundleOf +import com.android.intentresolver.ContentTypeHint +import com.android.intentresolver.data.model.ChooserRequest +import com.android.intentresolver.inject.FakeChooserServiceFlags +import com.android.intentresolver.ui.model.ActivityModel +import com.android.intentresolver.validation.Importance +import com.android.intentresolver.validation.Invalid +import com.android.intentresolver.validation.NoValue +import com.android.intentresolver.validation.Valid +import com.google.common.truth.Truth.assertThat +import org.junit.Test + +private fun createActivityModel( + targetIntent: Intent?, + referrer: Uri? = null, + additionalIntents: List? = null +) = + ActivityModel( + Intent(ACTION_CHOOSER).apply { + targetIntent?.also { putExtra(EXTRA_INTENT, it) } + additionalIntents?.also { putExtra(EXTRA_ALTERNATE_INTENTS, it.toTypedArray()) } + }, + launchedFromUid = 10000, + launchedFromPackage = "com.android.example", + referrer = referrer ?: "android-app://com.android.example".toUri() + ) + +class ChooserRequestTest { + + private val fakeChooserServiceFlags = + 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 missingIntent() { + val model = createActivityModel(targetIntent = null) + val result = readChooserRequest(model, fakeChooserServiceFlags) + + assertThat(result).isInstanceOf(Invalid::class.java) + result as Invalid + + assertThat(result.errors) + .containsExactly(NoValue(EXTRA_INTENT, Importance.CRITICAL, Intent::class)) + } + + @Test + fun referrerFillIn() { + val referrer = Uri.parse("android-app://example.com") + val model = createActivityModel(targetIntent = Intent(ACTION_SEND), referrer) + model.intent.putExtras(bundleOf(EXTRA_REFERRER to referrer)) + + val result = readChooserRequest(model, fakeChooserServiceFlags) + + assertThat(result).isInstanceOf(Valid::class.java) + result as Valid + + val fillIn = result.value.getReferrerFillInIntent() + assertThat(fillIn.hasExtra(EXTRA_REFERRER)).isTrue() + assertThat(fillIn.getParcelableExtra(EXTRA_REFERRER, Uri::class.java)).isEqualTo(referrer) + } + + @Test + fun referrerPackage_isNullWithNonAppReferrer() { + val referrer = Uri.parse("http://example.com") + val intent = Intent().putExtras(bundleOf(EXTRA_INTENT to Intent(ACTION_SEND))) + + val model = createActivityModel(targetIntent = intent, referrer = referrer) + + val result = readChooserRequest(model, fakeChooserServiceFlags) + + assertThat(result).isInstanceOf(Valid::class.java) + result as Valid + + assertThat(result.value.referrerPackage).isNull() + } + + @Test + fun referrerPackage_fromAppReferrer() { + val referrer = Uri.parse("android-app://example.com") + val model = createActivityModel(targetIntent = Intent(ACTION_SEND), referrer) + + model.intent.putExtras(bundleOf(EXTRA_REFERRER to referrer)) + + val result = readChooserRequest(model, fakeChooserServiceFlags) + + assertThat(result).isInstanceOf(Valid::class.java) + result as Valid + + assertThat(result.value.referrerPackage).isEqualTo(referrer.authority) + } + + @Test + fun payloadIntents_includesTargetThenAdditional() { + val intent1 = Intent(ACTION_SEND) + val intent2 = Intent(ACTION_SEND_MULTIPLE) + val model = createActivityModel(targetIntent = intent1, additionalIntents = listOf(intent2)) + + val result = readChooserRequest(model, fakeChooserServiceFlags) + + assertThat(result).isInstanceOf(Valid::class.java) + result as Valid + + assertThat(result.value.payloadIntents).containsExactly(intent1, intent2) + } + + @Test + fun testRequest_withOnlyRequiredValues() { + val intent = Intent().putExtras(bundleOf(EXTRA_INTENT to Intent(ACTION_SEND))) + val model = createActivityModel(targetIntent = intent) + + val result = readChooserRequest(model, fakeChooserServiceFlags) + + assertThat(result).isInstanceOf(Valid::class.java) + result as Valid + + assertThat(result.value.launchedFromPackage).isEqualTo(model.launchedFromPackage) + } + + @Test + fun testRequest_actionSendWithAdditionalContentUri() { + 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_SEND)).apply { + intent.putExtra(EXTRA_CHOOSER_ADDITIONAL_CONTENT_URI, uri) + intent.putExtra(EXTRA_CHOOSER_FOCUSED_ITEM_POSITION, position) + } + + val result = readChooserRequest(model, fakeChooserServiceFlags) + + assertThat(result).isInstanceOf(Valid::class.java) + result as Valid + + assertThat(result.value.additionalContentUri).isEqualTo(uri) + assertThat(result.value.focusedItemPosition).isEqualTo(position) + } + + @Test + fun testRequest_actionSendWithAdditionalContentUri_parametersIgnoredWhenFlagDisabled() { + fakeChooserServiceFlags.setFlag(Flags.FLAG_CHOOSER_PAYLOAD_TOGGLING, false) + val uri = Uri.parse("content://org.pkg/path") + val position = 10 + val model = + createActivityModel(targetIntent = Intent(ACTION_SEND)).apply { + intent.putExtra(EXTRA_CHOOSER_ADDITIONAL_CONTENT_URI, uri) + intent.putExtra(EXTRA_CHOOSER_FOCUSED_ITEM_POSITION, position) + } + val result = readChooserRequest(model, fakeChooserServiceFlags) + + assertThat(result).isInstanceOf(Valid::class.java) + result as Valid + + assertThat(result.value.additionalContentUri).isNull() + assertThat(result.value.focusedItemPosition).isEqualTo(0) + assertThat(result.warnings).isEmpty() + } + + @Test + fun testRequest_actionSendWithInvalidAdditionalContentUri() { + fakeChooserServiceFlags.setFlag(Flags.FLAG_CHOOSER_PAYLOAD_TOGGLING, true) + val model = + createActivityModel(targetIntent = Intent(ACTION_SEND)).apply { + intent.putExtra(EXTRA_CHOOSER_ADDITIONAL_CONTENT_URI, "__invalid__") + intent.putExtra(EXTRA_CHOOSER_FOCUSED_ITEM_POSITION, "__invalid__") + } + + val result = readChooserRequest(model, fakeChooserServiceFlags) + + assertThat(result).isInstanceOf(Valid::class.java) + result as Valid + + assertThat(result.value.additionalContentUri).isNull() + assertThat(result.value.focusedItemPosition).isEqualTo(0) + } + + @Test + fun testRequest_actionSendWithoutAdditionalContentUri() { + fakeChooserServiceFlags.setFlag(Flags.FLAG_CHOOSER_PAYLOAD_TOGGLING, true) + val model = createActivityModel(targetIntent = Intent(ACTION_SEND)) + + val result = readChooserRequest(model, fakeChooserServiceFlags) + + assertThat(result).isInstanceOf(Valid::class.java) + result as Valid + + assertThat(result.value.additionalContentUri).isNull() + assertThat(result.value.focusedItemPosition).isEqualTo(0) + } + + @Test + fun testRequest_actionViewWithAdditionalContentUri() { + 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 { + intent.putExtra(EXTRA_CHOOSER_ADDITIONAL_CONTENT_URI, uri) + intent.putExtra(EXTRA_CHOOSER_FOCUSED_ITEM_POSITION, position) + } + + val result = readChooserRequest(model, fakeChooserServiceFlags) + + assertThat(result).isInstanceOf(Valid::class.java) + result as Valid + + assertThat(result.value.additionalContentUri).isNull() + assertThat(result.value.focusedItemPosition).isEqualTo(0) + assertThat(result.warnings).isEmpty() + } + + @Test + fun testAlbumType() { + fakeChooserServiceFlags.setFlag(Flags.FLAG_CHOOSER_ALBUM_TEXT, true) + val model = createActivityModel(Intent(ACTION_SEND)) + model.intent.putExtra( + Intent.EXTRA_CHOOSER_CONTENT_TYPE_HINT, + Intent.CHOOSER_CONTENT_TYPE_ALBUM + ) + + val result = readChooserRequest(model, fakeChooserServiceFlags) + + assertThat(result).isInstanceOf(Valid::class.java) + result as Valid + + assertThat(result.value.contentTypeHint).isEqualTo(ContentTypeHint.ALBUM) + assertThat(result.warnings).isEmpty() + } + + @Test + 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 { + intent.putExtra(Intent.EXTRA_METADATA_TEXT, metadataText) + } + + val result = readChooserRequest(model, fakeChooserServiceFlags) + + assertThat(result).isInstanceOf(Valid::class.java) + result as Valid + + assertThat(result.value.metadataText).isNull() + } + + @Test + fun metadataText_whenFlagTrue_isPassedText() { + // Arrange + fakeChooserServiceFlags.setFlag(Flags.FLAG_ENABLE_SHARESHEET_METADATA_EXTRA, true) + val metadataText: CharSequence = "Test metadata text" + val model = + createActivityModel(targetIntent = Intent()).apply { + intent.putExtra(Intent.EXTRA_METADATA_TEXT, metadataText) + } + + val result = readChooserRequest(model, fakeChooserServiceFlags) + + assertThat(result).isInstanceOf(Valid::class.java) + result as Valid + + assertThat(result.value.metadataText).isEqualTo(metadataText) + } +} diff --git a/tests/unit/src/com/android/intentresolver/ui/viewmodel/ResolverRequestTest.kt b/tests/unit/src/com/android/intentresolver/ui/viewmodel/ResolverRequestTest.kt new file mode 100644 index 00000000..bd80235d --- /dev/null +++ b/tests/unit/src/com/android/intentresolver/ui/viewmodel/ResolverRequestTest.kt @@ -0,0 +1,128 @@ +/* + * Copyright (C) 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.android.intentresolver.ui.viewmodel + +import android.content.Intent +import android.content.Intent.ACTION_VIEW +import android.net.Uri +import android.os.UserHandle +import androidx.core.net.toUri +import androidx.core.os.bundleOf +import com.android.intentresolver.ResolverActivity.PROFILE_WORK +import com.android.intentresolver.shared.model.Profile.Type.WORK +import com.android.intentresolver.ui.model.ActivityModel +import com.android.intentresolver.ui.model.ResolverRequest +import com.android.intentresolver.validation.Invalid +import com.android.intentresolver.validation.UncaughtException +import com.android.intentresolver.validation.Valid +import com.google.common.truth.Truth.assertThat +import com.google.common.truth.Truth.assertWithMessage +import org.junit.Test + +private val targetUri = Uri.parse("content://example.com/123") + +private fun createActivityModel( + targetIntent: Intent, + referrer: Uri? = null, +) = + ActivityModel( + intent = targetIntent, + launchedFromUid = 10000, + launchedFromPackage = "com.android.example", + referrer = referrer ?: "android-app://com.android.example".toUri() + ) + +class ResolverRequestTest { + @Test + fun testDefaults() { + val intent = Intent(ACTION_VIEW).apply { data = targetUri } + val activity = createActivityModel(intent) + + val result = readResolverRequest(activity) + + assertThat(result).isInstanceOf(Valid::class.java) + result as Valid + + assertThat(result.warnings).isEmpty() + + assertThat(result.value.intent.filterEquals(activity.intent)).isTrue() + assertThat(result.value.callingUser).isNull() + assertThat(result.value.selectedProfile).isNull() + } + + @Test + fun testInvalidSelectedProfile() { + val intent = + Intent(ACTION_VIEW).apply { + data = targetUri + putExtra(EXTRA_SELECTED_PROFILE, -1000) + } + + val activity = createActivityModel(intent) + + val result = readResolverRequest(activity) + + assertThat(result).isInstanceOf(Invalid::class.java) + result as Invalid + + assertWithMessage("the first finding") + .that(result.errors.firstOrNull()) + .isInstanceOf(UncaughtException::class.java) + } + + @Test + fun payloadIntents_includesOnlyTarget() { + val intent2 = Intent(Intent.ACTION_SEND_MULTIPLE) + val intent1 = + Intent(Intent.ACTION_SEND).apply { + putParcelableArrayListExtra(Intent.EXTRA_ALTERNATE_INTENTS, arrayListOf(intent2)) + } + val activity = createActivityModel(targetIntent = intent1) + + val result = readResolverRequest(activity) + + assertThat(result).isInstanceOf(Valid::class.java) + result as Valid + + // Assert that payloadIntents does NOT include EXTRA_ALTERNATE_INTENTS + // that is only supported for Chooser and should be not be added here. + assertThat(result.value.payloadIntents).containsExactly(intent1) + } + + @Test + fun testAllValues() { + val intent = Intent(ACTION_VIEW).apply { data = Uri.parse("content://example.com/123") } + val activity = createActivityModel(targetIntent = intent) + + activity.intent.putExtras( + bundleOf( + EXTRA_CALLING_USER to UserHandle.of(123), + EXTRA_SELECTED_PROFILE to PROFILE_WORK, + EXTRA_IS_AUDIO_CAPTURE_DEVICE to true, + ) + ) + + val result = readResolverRequest(activity) + + assertThat(result).isInstanceOf(Valid::class.java) + result as Valid + + assertThat(result.value.intent.filterEquals(activity.intent)).isTrue() + assertThat(result.value.isAudioCaptureDevice).isTrue() + assertThat(result.value.callingUser).isEqualTo(UserHandle.of(123)) + assertThat(result.value.selectedProfile).isEqualTo(WORK) + } +} diff --git a/tests/unit/src/com/android/intentresolver/v2/ChooserActionFactoryTest.kt b/tests/unit/src/com/android/intentresolver/v2/ChooserActionFactoryTest.kt deleted file mode 100644 index 8c55ffa5..00000000 --- a/tests/unit/src/com/android/intentresolver/v2/ChooserActionFactoryTest.kt +++ /dev/null @@ -1,225 +0,0 @@ -/* - * Copyright (C) 2023 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.android.intentresolver.v2 - -import android.app.Activity -import android.app.PendingIntent -import android.content.BroadcastReceiver -import android.content.Context -import android.content.Context.RECEIVER_EXPORTED -import android.content.Intent -import android.content.IntentFilter -import android.content.res.Resources -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.v2.ui.ShareResultSender -import com.android.intentresolver.v2.ui.model.ShareAction -import com.android.intentresolver.whenever -import com.google.common.collect.ImmutableList -import com.google.common.truth.Truth.assertThat -import java.util.Optional -import java.util.concurrent.CountDownLatch -import java.util.concurrent.TimeUnit -import java.util.function.Consumer -import org.junit.After -import org.junit.Assert.assertEquals -import org.junit.Assert.assertTrue -import org.junit.Before -import org.junit.Test -import org.junit.runner.RunWith -import org.mockito.Mockito.eq -import org.mockito.Mockito.times -import org.mockito.Mockito.verify - -@RunWith(AndroidJUnit4::class) -class ChooserActionFactoryTest { - private val context = InstrumentationRegistry.getInstrumentation().context - - private val logger = mock() - private val actionLabel = "Action label" - private val testAction = "com.android.intentresolver.testaction" - private val countdown = CountDownLatch(1) - private val testReceiver: BroadcastReceiver = - object : BroadcastReceiver() { - override fun onReceive(context: Context, intent: Intent) { - // Just doing at most a single countdown per test. - countdown.countDown() - } - } - private val resultConsumer = - object : Consumer { - var latestReturn = Integer.MIN_VALUE - - override fun accept(resultCode: Int) { - latestReturn = resultCode - } - } - - @Before - fun setup() { - context.registerReceiver(testReceiver, IntentFilter(testAction), RECEIVER_EXPORTED) - } - - @After - fun teardown() { - context.unregisterReceiver(testReceiver) - } - - @Test - fun testCreateCustomActions() { - val factory = createFactory() - - val customActions = factory.createCustomActions() - - assertThat(customActions.size).isEqualTo(1) - assertThat(customActions[0].label).isEqualTo(actionLabel) - - // click it - customActions[0].onClicked.run() - - verify(logger).logCustomActionSelected(eq(0)) - 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 { - putExtra(Intent.EXTRA_TEXT, "Text to show") - } - - val chooserRequest = - mock { - whenever(this.targetIntent).thenReturn(targetIntent) - whenever(chooserActions).thenReturn(ImmutableList.of()) - } - val testSubject = - ChooserActionFactory( - /* context = */ context, - /* targetIntent = */ chooserRequest.targetIntent, - /* referrerPackageName = */ chooserRequest.referrerPackageName, - /* chooserActions = */ chooserRequest.chooserActions, - /* imageEditor = */ Optional.empty(), - /* log = */ logger, - /* onUpdateSharedTextIsExcluded = */ {}, - /* firstVisibleImageQuery = */ { null }, - /* activityStarter = */ mock(), - /* shareResultSender = */ null, - /* finishCallback = */ {}, - /* clipboardManager = */ mock(), - ) - assertThat(testSubject.copyButtonRunnable).isNull() - } - - @Test - fun sendActionNoText_noCopyRunnable() { - val targetIntent = Intent(Intent.ACTION_SEND) - - val chooserRequest = - mock { - whenever(this.targetIntent).thenReturn(targetIntent) - whenever(chooserActions).thenReturn(ImmutableList.of()) - } - val testSubject = - ChooserActionFactory( - /* context = */ context, - /* targetIntent = */ chooserRequest.targetIntent, - /* referrerPackageName = */ chooserRequest.referrerPackageName, - /* chooserActions = */ chooserRequest.chooserActions, - /* imageEditor = */ Optional.empty(), - /* log = */ logger, - /* onUpdateSharedTextIsExcluded = */ {}, - /* firstVisibleImageQuery = */ { null }, - /* activityStarter = */ mock(), - /* shareResultSender = */ null, - /* finishCallback = */ {}, - /* clipboardManager = */ mock(), - ) - assertThat(testSubject.copyButtonRunnable).isNull() - } - - @Test - fun sendActionWithTextCopyRunnable() { - val targetIntent = Intent(Intent.ACTION_SEND).apply { putExtra(Intent.EXTRA_TEXT, "Text") } - - val chooserRequest = - mock { - whenever(this.targetIntent).thenReturn(targetIntent) - whenever(chooserActions).thenReturn(ImmutableList.of()) - } - - val resultSender = mock() - val testSubject = - ChooserActionFactory( - /* context = */ context, - /* targetIntent = */ chooserRequest.targetIntent, - /* referrerPackageName = */ chooserRequest.referrerPackageName, - /* chooserActions = */ chooserRequest.chooserActions, - /* imageEditor = */ Optional.empty(), - /* log = */ logger, - /* onUpdateSharedTextIsExcluded = */ {}, - /* firstVisibleImageQuery = */ { null }, - /* activityStarter = */ mock(), - /* shareResultSender = */ resultSender, - /* finishCallback = */ {}, - /* clipboardManager = */ mock(), - ) - assertThat(testSubject.copyButtonRunnable).isNotNull() - - testSubject.copyButtonRunnable?.run() - - verify(resultSender, times(1)).onActionSelected(ShareAction.SYSTEM_COPY) - } - - private fun createFactory(): ChooserActionFactory { - val testPendingIntent = - PendingIntent.getBroadcast(context, 0, Intent(testAction), PendingIntent.FLAG_IMMUTABLE) - val targetIntent = Intent() - val action = - ChooserAction.Builder( - Icon.createWithResource("", Resources.ID_NULL), - actionLabel, - testPendingIntent - ) - .build() - val chooserRequest = mock() - whenever(chooserRequest.targetIntent).thenReturn(targetIntent) - whenever(chooserRequest.chooserActions).thenReturn(ImmutableList.of(action)) - - return ChooserActionFactory( - /* context = */ context, - /* targetIntent = */ chooserRequest.targetIntent, - /* referrerPackageName = */ chooserRequest.referrerPackageName, - /* chooserActions = */ chooserRequest.chooserActions, - /* imageEditor = */ Optional.empty(), - /* log = */ logger, - /* onUpdateSharedTextIsExcluded = */ {}, - /* firstVisibleImageQuery = */ { null }, - /* activityStarter = */ mock(), - /* shareResultSender = */ null, - /* finishCallback = */ resultConsumer, - /* clipboardManager = */ mock(), - ) - } -} diff --git a/tests/unit/src/com/android/intentresolver/v2/ProfileAvailabilityTest.kt b/tests/unit/src/com/android/intentresolver/v2/ProfileAvailabilityTest.kt deleted file mode 100644 index c0d5ed4e..00000000 --- a/tests/unit/src/com/android/intentresolver/v2/ProfileAvailabilityTest.kt +++ /dev/null @@ -1,74 +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.v2 - -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 -import com.android.intentresolver.v2.shared.model.User -import com.google.common.truth.Truth.assertThat -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.test.runCurrent -import kotlinx.coroutines.test.runTest -import org.junit.Test - -@OptIn(ExperimentalCoroutinesApi::class, JavaInterop::class) -class ProfileAvailabilityTest { - private val personalUser = User(0, User.Role.PERSONAL) - private val workUser = User(10, User.Role.WORK) - - private val personalProfile = Profile(Profile.Type.PERSONAL, personalUser) - private val workProfile = Profile(Profile.Type.WORK, workUser) - - private val repository = FakeUserRepository(listOf(personalUser, workUser)) - private val interactor = UserInteractor(repository, launchedAs = personalUser.handle) - - @Test - fun testProfileAvailable() = runTest { - val availability = ProfileAvailability(interactor, this, Dispatchers.IO) - - assertThat(availability.isAvailable(personalProfile)).isTrue() - assertThat(availability.isAvailable(workProfile)).isTrue() - - availability.requestQuietModeState(workProfile, true) - runCurrent() - - assertThat(availability.isAvailable(workProfile)).isFalse() - - availability.requestQuietModeState(workProfile, false) - runCurrent() - - assertThat(availability.isAvailable(workProfile)).isTrue() - } - - @Test - fun waitingToEnableProfile() = runTest { - val availability = ProfileAvailability(interactor, this, Dispatchers.IO) - - availability.requestQuietModeState(workProfile, true) - assertThat(availability.waitingToEnableProfile).isFalse() - runCurrent() - - availability.requestQuietModeState(workProfile, false) - assertThat(availability.waitingToEnableProfile).isTrue() - runCurrent() - - assertThat(availability.waitingToEnableProfile).isFalse() - } -} diff --git a/tests/unit/src/com/android/intentresolver/v2/ProfileHelperTest.kt b/tests/unit/src/com/android/intentresolver/v2/ProfileHelperTest.kt deleted file mode 100644 index 06d795fe..00000000 --- a/tests/unit/src/com/android/intentresolver/v2/ProfileHelperTest.kt +++ /dev/null @@ -1,275 +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.v2 - -import com.android.intentresolver.Flags.FLAG_ENABLE_PRIVATE_PROFILE -import com.android.intentresolver.inject.FakeIntentResolverFlags -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 -import com.android.intentresolver.v2.shared.model.User -import com.google.common.truth.Truth.assertThat -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.test.runTest -import org.junit.Test - -@OptIn(JavaInterop::class) -class ProfileHelperTest { - - private val personalUser = User(0, User.Role.PERSONAL) - private val cloneUser = User(10, User.Role.CLONE) - - private val personalProfile = Profile(Profile.Type.PERSONAL, personalUser) - private val personalWithCloneProfile = Profile(Profile.Type.PERSONAL, personalUser, cloneUser) - - private val workUser = User(11, User.Role.WORK) - private val workProfile = Profile(Profile.Type.WORK, workUser) - - private val privateUser = User(12, User.Role.PRIVATE) - private val privateProfile = Profile(Profile.Type.PRIVATE, privateUser) - - private val flags = - FakeIntentResolverFlags().apply { setFlag(FLAG_ENABLE_PRIVATE_PROFILE, true) } - - private fun assertProfiles( - helper: ProfileHelper, - personalProfile: Profile, - workProfile: Profile? = null, - privateProfile: Profile? = null - ) { - assertThat(helper.personalProfile).isEqualTo(personalProfile) - assertThat(helper.personalHandle).isEqualTo(personalProfile.primary.handle) - - personalProfile.clone?.also { - assertThat(helper.cloneUserPresent).isTrue() - assertThat(helper.cloneHandle).isEqualTo(it.handle) - } - ?: { - assertThat(helper.cloneUserPresent).isFalse() - assertThat(helper.cloneHandle).isNull() - } - - workProfile?.also { - assertThat(helper.workProfilePresent).isTrue() - assertThat(helper.workProfile).isEqualTo(it) - assertThat(helper.workHandle).isEqualTo(it.primary.handle) - } - ?: { - assertThat(helper.workProfilePresent).isFalse() - assertThat(helper.workProfile).isNull() - assertThat(helper.workHandle).isNull() - } - - privateProfile?.also { - assertThat(helper.privateProfilePresent).isTrue() - assertThat(helper.privateProfile).isEqualTo(it) - assertThat(helper.privateHandle).isEqualTo(it.primary.handle) - } - ?: { - assertThat(helper.privateProfilePresent).isFalse() - assertThat(helper.privateProfile).isNull() - assertThat(helper.privateHandle).isNull() - } - } - - @Test - fun launchedByPersonal() = runTest { - val repository = FakeUserRepository(listOf(personalUser)) - val interactor = UserInteractor(repository, launchedAs = personalUser.handle) - - val helper = - ProfileHelper( - interactor = interactor, - scope = this, - background = Dispatchers.Unconfined, - flags = flags - ) - - assertProfiles(helper, personalProfile) - - assertThat(helper.isLaunchedAsCloneProfile).isFalse() - assertThat(helper.launchedAsProfileType).isEqualTo(Profile.Type.PERSONAL) - assertThat(helper.getQueryIntentsHandle(personalUser.handle)) - .isEqualTo(personalProfile.primary.handle) - assertThat(helper.tabOwnerUserHandleForLaunch).isEqualTo(personalProfile.primary.handle) - } - - @Test - fun launchedByPersonal_withClone() = runTest { - val repository = FakeUserRepository(listOf(personalUser, cloneUser)) - val interactor = UserInteractor(repository, launchedAs = personalUser.handle) - - val helper = - ProfileHelper( - interactor = interactor, - scope = this, - background = Dispatchers.Unconfined, - flags = flags - ) - - assertProfiles(helper, personalWithCloneProfile) - - assertThat(helper.isLaunchedAsCloneProfile).isFalse() - assertThat(helper.launchedAsProfileType).isEqualTo(Profile.Type.PERSONAL) - assertThat(helper.getQueryIntentsHandle(personalUser.handle)).isEqualTo(personalUser.handle) - assertThat(helper.tabOwnerUserHandleForLaunch).isEqualTo(personalProfile.primary.handle) - } - - @Test - fun launchedByClone() = runTest { - val repository = FakeUserRepository(listOf(personalUser, cloneUser)) - val interactor = UserInteractor(repository, launchedAs = cloneUser.handle) - - val helper = - ProfileHelper( - interactor = interactor, - scope = this, - background = Dispatchers.Unconfined, - flags = flags - ) - - assertProfiles(helper, personalWithCloneProfile) - - assertThat(helper.isLaunchedAsCloneProfile).isTrue() - assertThat(helper.launchedAsProfileType).isEqualTo(Profile.Type.PERSONAL) - assertThat(helper.getQueryIntentsHandle(personalWithCloneProfile.primary.handle)) - .isEqualTo(personalWithCloneProfile.clone?.handle) - assertThat(helper.tabOwnerUserHandleForLaunch) - .isEqualTo(personalWithCloneProfile.primary.handle) - } - - @Test - fun launchedByPersonal_withWork() = runTest { - val repository = FakeUserRepository(listOf(personalUser, workUser)) - val interactor = UserInteractor(repository, launchedAs = personalUser.handle) - - val helper = - ProfileHelper( - interactor = interactor, - scope = this, - background = Dispatchers.Unconfined, - flags = flags - ) - - assertProfiles(helper, personalProfile = personalProfile, workProfile = workProfile) - - assertThat(helper.launchedAsProfileType).isEqualTo(Profile.Type.PERSONAL) - assertThat(helper.isLaunchedAsCloneProfile).isFalse() - assertThat(helper.getQueryIntentsHandle(personalUser.handle)) - .isEqualTo(personalProfile.primary.handle) - assertThat(helper.getQueryIntentsHandle(workUser.handle)) - .isEqualTo(workProfile.primary.handle) - assertThat(helper.tabOwnerUserHandleForLaunch).isEqualTo(personalProfile.primary.handle) - } - - @Test - fun launchedByWork() = runTest { - val repository = FakeUserRepository(listOf(personalUser, workUser)) - val interactor = UserInteractor(repository, launchedAs = workUser.handle) - - val helper = - ProfileHelper( - interactor = interactor, - scope = this, - background = Dispatchers.Unconfined, - flags = flags - ) - - assertProfiles(helper, personalProfile = personalProfile, workProfile = workProfile) - - assertThat(helper.isLaunchedAsCloneProfile).isFalse() - assertThat(helper.launchedAsProfileType).isEqualTo(Profile.Type.WORK) - assertThat(helper.getQueryIntentsHandle(personalProfile.primary.handle)) - .isEqualTo(personalProfile.primary.handle) - assertThat(helper.getQueryIntentsHandle(workProfile.primary.handle)) - .isEqualTo(workProfile.primary.handle) - assertThat(helper.tabOwnerUserHandleForLaunch).isEqualTo(workProfile.primary.handle) - } - - @Test - fun launchedByPersonal_withPrivate() = runTest { - val repository = FakeUserRepository(listOf(personalUser, privateUser)) - val interactor = UserInteractor(repository, launchedAs = personalUser.handle) - - val helper = - ProfileHelper( - interactor = interactor, - scope = this, - background = Dispatchers.Unconfined, - flags = flags - ) - - assertProfiles(helper, personalProfile = personalProfile, privateProfile = privateProfile) - - assertThat(helper.isLaunchedAsCloneProfile).isFalse() - assertThat(helper.launchedAsProfileType).isEqualTo(Profile.Type.PERSONAL) - assertThat(helper.getQueryIntentsHandle(personalProfile.primary.handle)) - .isEqualTo(personalProfile.primary.handle) - assertThat(helper.getQueryIntentsHandle(privateProfile.primary.handle)) - .isEqualTo(privateProfile.primary.handle) - assertThat(helper.tabOwnerUserHandleForLaunch).isEqualTo(personalProfile.primary.handle) - } - - @Test - fun launchedByPrivate() = runTest { - val repository = FakeUserRepository(listOf(personalUser, privateUser)) - val interactor = UserInteractor(repository, launchedAs = privateUser.handle) - - val helper = - ProfileHelper( - interactor = interactor, - scope = this, - background = Dispatchers.Unconfined, - flags = flags - ) - - assertProfiles(helper, personalProfile = personalProfile, privateProfile = privateProfile) - - assertThat(helper.isLaunchedAsCloneProfile).isFalse() - assertThat(helper.launchedAsProfileType).isEqualTo(Profile.Type.PRIVATE) - assertThat(helper.getQueryIntentsHandle(personalProfile.primary.handle)) - .isEqualTo(personalProfile.primary.handle) - assertThat(helper.getQueryIntentsHandle(privateProfile.primary.handle)) - .isEqualTo(privateProfile.primary.handle) - assertThat(helper.tabOwnerUserHandleForLaunch).isEqualTo(privateProfile.primary.handle) - } - - @Test - fun launchedByPersonal_withPrivate_privateDisabled() = runTest { - flags.setFlag(FLAG_ENABLE_PRIVATE_PROFILE, false) - - val repository = FakeUserRepository(listOf(personalUser, privateUser)) - val interactor = UserInteractor(repository, launchedAs = personalUser.handle) - - val helper = - ProfileHelper( - interactor = interactor, - scope = this, - background = Dispatchers.Unconfined, - flags = flags - ) - - assertProfiles(helper, personalProfile = personalProfile, privateProfile = null) - - assertThat(helper.isLaunchedAsCloneProfile).isFalse() - assertThat(helper.launchedAsProfileType).isEqualTo(Profile.Type.PERSONAL) - assertThat(helper.getQueryIntentsHandle(personalProfile.primary.handle)) - .isEqualTo(personalProfile.primary.handle) - assertThat(helper.tabOwnerUserHandleForLaunch).isEqualTo(personalProfile.primary.handle) - } -} diff --git a/tests/unit/src/com/android/intentresolver/v2/coroutines/Flow.kt b/tests/unit/src/com/android/intentresolver/v2/coroutines/Flow.kt deleted file mode 100644 index a5677d94..00000000 --- a/tests/unit/src/com/android/intentresolver/v2/coroutines/Flow.kt +++ /dev/null @@ -1,89 +0,0 @@ -@file:Suppress("OPT_IN_USAGE") - -package com.android.intentresolver.v2.coroutines - -/* - * Copyright (C) 2022 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import kotlin.coroutines.CoroutineContext -import kotlin.coroutines.EmptyCoroutineContext -import kotlin.properties.ReadOnlyProperty -import kotlin.reflect.KProperty -import kotlinx.coroutines.CoroutineStart -import kotlinx.coroutines.Job -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.launch -import kotlinx.coroutines.test.TestScope -import kotlinx.coroutines.test.runCurrent - -/** - * Collect [flow] in a new [Job] and return a getter for the last collected value. - * - * ``` - * fun myTest() = runTest { - * // ... - * val actual by collectLastValue(underTest.flow) - * assertThat(actual).isEqualTo(expected) - * } - * ``` - */ -fun TestScope.collectLastValue( - flow: Flow, - context: CoroutineContext = EmptyCoroutineContext, - start: CoroutineStart = CoroutineStart.DEFAULT, -): FlowValue { - val values by - collectValues( - flow = flow, - context = context, - start = start, - ) - return FlowValueImpl { values.lastOrNull() } -} - -/** - * Collect [flow] in a new [Job] and return a getter for the collection of values collected. - * - * ``` - * fun myTest() = runTest { - * // ... - * val values by collectValues(underTest.flow) - * assertThat(values).isEqualTo(listOf(expected1, expected2, ...)) - * } - * ``` - */ -fun TestScope.collectValues( - flow: Flow, - context: CoroutineContext = EmptyCoroutineContext, - start: CoroutineStart = CoroutineStart.DEFAULT, -): FlowValue> { - val values = mutableListOf() - backgroundScope.launch(context, start) { flow.collect(values::add) } - return FlowValueImpl { - runCurrent() - values.toList() - } -} - -/** @see collectLastValue */ -interface FlowValue : ReadOnlyProperty { - operator fun invoke(): T -} - -private class FlowValueImpl(private val block: () -> T) : FlowValue { - override operator fun invoke(): T = block() - override fun getValue(thisRef: Any?, property: KProperty<*>): T = invoke() -} diff --git a/tests/unit/src/com/android/intentresolver/v2/data/repository/FakeUserRepositoryTest.kt b/tests/unit/src/com/android/intentresolver/v2/data/repository/FakeUserRepositoryTest.kt deleted file mode 100644 index d10ea8d0..00000000 --- a/tests/unit/src/com/android/intentresolver/v2/data/repository/FakeUserRepositoryTest.kt +++ /dev/null @@ -1,108 +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.v2.data.repository - -import com.android.intentresolver.v2.coroutines.collectLastValue -import com.android.intentresolver.v2.shared.model.User -import com.google.common.truth.Truth.assertThat -import kotlin.random.Random -import kotlinx.coroutines.test.runTest -import org.junit.Test - -class FakeUserRepositoryTest { - private val baseId = Random.nextInt(1000, 2000) - - private val personalUser = User(id = baseId, role = User.Role.PERSONAL) - private val cloneUser = User(id = baseId + 1, role = User.Role.CLONE) - private val workUser = User(id = baseId + 2, role = User.Role.WORK) - private val privateUser = User(id = baseId + 3, role = User.Role.PRIVATE) - - @Test - fun init() = runTest { - val repo = FakeUserRepository(listOf(personalUser, workUser, privateUser)) - - val users by collectLastValue(repo.users) - assertThat(users).containsExactly(personalUser, workUser, privateUser) - } - - @Test - fun addUser() = runTest { - val repo = FakeUserRepository(emptyList()) - - val users by collectLastValue(repo.users) - assertThat(users).isEmpty() - - repo.addUser(personalUser, true) - assertThat(users).containsExactly(personalUser) - - repo.addUser(workUser, false) - assertThat(users).containsExactly(personalUser, workUser) - } - - @Test - fun removeUser() = runTest { - val repo = FakeUserRepository(listOf(personalUser, workUser)) - - val users by collectLastValue(repo.users) - repo.removeUser(workUser) - assertThat(users).containsExactly(personalUser) - - repo.removeUser(personalUser) - assertThat(users).isEmpty() - } - - @Test - fun isAvailable_defaultValue() = runTest { - val repo = FakeUserRepository(listOf(personalUser, workUser)) - - val available by collectLastValue(repo.availability) - - repo.requestState(workUser, false) - assertThat(available!![workUser]).isFalse() - - repo.requestState(workUser, true) - assertThat(available!![workUser]).isTrue() - } - - @Test - fun isAvailable() = runTest { - val repo = FakeUserRepository(listOf(personalUser, workUser)) - - val available by collectLastValue(repo.availability) - assertThat(available!![workUser]).isTrue() - - repo.requestState(workUser, false) - assertThat(available!![workUser]).isFalse() - - repo.requestState(workUser, true) - assertThat(available!![workUser]).isTrue() - } - - @Test - fun isAvailable_addRemove() = runTest { - val repo = FakeUserRepository(listOf(personalUser, workUser)) - - val available by collectLastValue(repo.availability) - assertThat(available!![workUser]).isTrue() - - repo.removeUser(workUser) - assertThat(available!![workUser]).isNull() - - repo.addUser(workUser, true) - assertThat(available!![workUser]).isTrue() - } -} diff --git a/tests/unit/src/com/android/intentresolver/v2/data/repository/UserRepositoryImplTest.kt b/tests/unit/src/com/android/intentresolver/v2/data/repository/UserRepositoryImplTest.kt deleted file mode 100644 index 3fcc4c84..00000000 --- a/tests/unit/src/com/android/intentresolver/v2/data/repository/UserRepositoryImplTest.kt +++ /dev/null @@ -1,211 +0,0 @@ -package com.android.intentresolver.v2.data.repository - -import android.content.pm.UserInfo -import android.os.UserHandle -import android.os.UserHandle.SYSTEM -import android.os.UserHandle.USER_SYSTEM -import android.os.UserManager -import com.android.intentresolver.mock -import com.android.intentresolver.v2.coroutines.collectLastValue -import com.android.intentresolver.v2.platform.FakeUserManager -import com.android.intentresolver.v2.platform.FakeUserManager.ProfileType -import com.android.intentresolver.v2.shared.model.User -import com.android.intentresolver.v2.shared.model.User.Role -import com.android.intentresolver.whenever -import com.google.common.truth.Truth.assertThat -import com.google.common.truth.Truth.assertWithMessage -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.flow.flowOf -import kotlinx.coroutines.test.TestScope -import kotlinx.coroutines.test.runTest -import org.junit.Test -import org.mockito.Mockito -import org.mockito.Mockito.doReturn - -internal class UserRepositoryImplTest { - private val userManager = FakeUserManager() - private val userState = userManager.state - - @Test - fun initialization() = runTest { - val repo = createUserRepository(userManager) - val users by collectLastValue(repo.users) - - assertWithMessage("collectLastValue(repo.users)").that(users).isNotNull() - assertThat(users) - .containsExactly(User(userState.primaryUserHandle.identifier, Role.PERSONAL)) - } - - @Test - fun createProfile() = runTest { - val repo = createUserRepository(userManager) - val users by collectLastValue(repo.users) - - assertWithMessage("collectLastValue(repo.users)").that(users).isNotNull() - assertThat(users).hasSize(1) - - val profile = userState.createProfile(ProfileType.WORK) - assertThat(users).hasSize(2) - assertThat(users).contains(User(profile.identifier, Role.WORK)) - } - - @Test - fun removeProfile() = runTest { - val repo = createUserRepository(userManager) - val users by collectLastValue(repo.users) - - assertWithMessage("collectLastValue(repo.users)").that(users).isNotNull() - val work = userState.createProfile(ProfileType.WORK) - assertThat(users).contains(User(work.identifier, Role.WORK)) - - userState.removeProfile(work) - assertThat(users).doesNotContain(User(work.identifier, Role.WORK)) - } - - @Test - fun isAvailable() = runTest { - val repo = createUserRepository(userManager) - val work = userState.createProfile(ProfileType.WORK) - val workUser = User(work.identifier, Role.WORK) - - val available by collectLastValue(repo.availability) - assertThat(available?.get(workUser)).isTrue() - - userState.setQuietMode(work, true) - assertThat(available?.get(workUser)).isFalse() - - userState.setQuietMode(work, false) - assertThat(available?.get(workUser)).isTrue() - } - - @Test - fun onHandleAvailabilityChange_userStateMaintained() = runTest { - val repo = createUserRepository(userManager) - val private = userState.createProfile(ProfileType.PRIVATE) - val privateUser = User(private.identifier, Role.PRIVATE) - - val users by collectLastValue(repo.users) - - repo.requestState(privateUser, false) - repo.requestState(privateUser, true) - - assertWithMessage("users.size").that(users?.size ?: 0).isEqualTo(2) // personal + private - - assertWithMessage("No duplicate IDs") - .that(users?.count { it.id == private.identifier }) - .isEqualTo(1) - } - - @Test - fun requestState() = runTest { - val repo = createUserRepository(userManager) - val work = userState.createProfile(ProfileType.WORK) - val workUser = User(work.identifier, Role.WORK) - - val available by collectLastValue(repo.availability) - assertThat(available?.get(workUser)).isTrue() - - repo.requestState(workUser, false) - assertThat(available?.get(workUser)).isFalse() - - repo.requestState(workUser, true) - assertThat(available?.get(workUser)).isTrue() - } - - /** - * This and all the 'recovers_from_*' tests below all configure a static event flow instead of - * using [FakeUserManager]. These tests verify that a invalid broadcast causes the flow to - * reinitialize with the user profile group. - */ - @Test - fun recovers_from_invalid_profile_added_event() = runTest { - val userManager = - mockUserManager(validUser = USER_SYSTEM, invalidUser = UserHandle.USER_NULL) - val events = flowOf(ProfileAdded(UserHandle.of(UserHandle.USER_NULL))) - val repo = - UserRepositoryImpl( - profileParent = SYSTEM, - userManager = userManager, - userEvents = events, - scope = backgroundScope, - backgroundDispatcher = Dispatchers.Unconfined - ) - val users by collectLastValue(repo.users) - - assertWithMessage("collectLastValue(repo.users)").that(users).isNotNull() - assertThat(users).containsExactly(User(USER_SYSTEM, Role.PERSONAL)) - } - - @Test - fun recovers_from_invalid_profile_removed_event() = runTest { - val userManager = - mockUserManager(validUser = USER_SYSTEM, invalidUser = UserHandle.USER_NULL) - val events = flowOf(ProfileRemoved(UserHandle.of(UserHandle.USER_NULL))) - val repo = - UserRepositoryImpl( - profileParent = SYSTEM, - userManager = userManager, - userEvents = events, - scope = backgroundScope, - backgroundDispatcher = Dispatchers.Unconfined - ) - val users by collectLastValue(repo.users) - - assertWithMessage("collectLastValue(repo.users)").that(users).isNotNull() - assertThat(users).containsExactly(User(USER_SYSTEM, Role.PERSONAL)) - } - - @Test - fun recovers_from_invalid_profile_available_event() = runTest { - val userManager = - mockUserManager(validUser = USER_SYSTEM, invalidUser = UserHandle.USER_NULL) - val events = flowOf(AvailabilityChange(UserHandle.of(UserHandle.USER_NULL))) - val repo = - UserRepositoryImpl(SYSTEM, userManager, events, backgroundScope, Dispatchers.Unconfined) - val users by collectLastValue(repo.users) - - assertWithMessage("collectLastValue(repo.users)").that(users).isNotNull() - assertThat(users).containsExactly(User(USER_SYSTEM, Role.PERSONAL)) - } - - @Test - fun recovers_from_unknown_event() = runTest { - val userManager = - mockUserManager(validUser = USER_SYSTEM, invalidUser = UserHandle.USER_NULL) - val events = flowOf(UnknownEvent("UNKNOWN_EVENT")) - val repo = - UserRepositoryImpl( - profileParent = SYSTEM, - userManager = userManager, - userEvents = events, - scope = backgroundScope, - backgroundDispatcher = Dispatchers.Unconfined - ) - val users by collectLastValue(repo.users) - - assertWithMessage("collectLastValue(repo.users)").that(users).isNotNull() - assertThat(users).containsExactly(User(USER_SYSTEM, Role.PERSONAL)) - } -} - -@Suppress("SameParameterValue", "DEPRECATION") -private fun mockUserManager(validUser: Int, invalidUser: Int) = - mock { - val info = UserInfo(validUser, "", "", UserInfo.FLAG_FULL) - doReturn(listOf(info)).whenever(this).getEnabledProfiles(Mockito.anyInt()) - - doReturn(info).whenever(this).getUserInfo(Mockito.eq(validUser)) - - doReturn(listOf()).whenever(this).getEnabledProfiles(Mockito.eq(invalidUser)) - - doReturn(null).whenever(this).getUserInfo(Mockito.eq(invalidUser)) - } - -private fun TestScope.createUserRepository(userManager: FakeUserManager) = - UserRepositoryImpl( - profileParent = userManager.state.primaryUserHandle, - userManager = userManager, - userEvents = userManager.state.userEvents, - scope = backgroundScope, - backgroundDispatcher = Dispatchers.Unconfined - ) diff --git a/tests/unit/src/com/android/intentresolver/v2/domain/interactor/UserInteractorTest.kt b/tests/unit/src/com/android/intentresolver/v2/domain/interactor/UserInteractorTest.kt deleted file mode 100644 index a81a315b..00000000 --- a/tests/unit/src/com/android/intentresolver/v2/domain/interactor/UserInteractorTest.kt +++ /dev/null @@ -1,208 +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.v2.domain.interactor - -import com.android.intentresolver.v2.coroutines.collectLastValue -import com.android.intentresolver.v2.data.repository.FakeUserRepository -import com.android.intentresolver.v2.shared.model.Profile -import com.android.intentresolver.v2.shared.model.Profile.Type.PERSONAL -import com.android.intentresolver.v2.shared.model.Profile.Type.PRIVATE -import com.android.intentresolver.v2.shared.model.Profile.Type.WORK -import com.android.intentresolver.v2.shared.model.User -import com.android.intentresolver.v2.shared.model.User.Role -import com.google.common.truth.Truth.assertThat -import com.google.common.truth.Truth.assertWithMessage -import kotlin.random.Random -import kotlinx.coroutines.test.runTest -import org.junit.Test -import org.junit.runner.RunWith -import org.junit.runners.JUnit4 - -@RunWith(JUnit4::class) -class UserInteractorTest { - private val baseId = Random.nextInt(1000, 2000) - - private val personalUser = User(id = baseId, role = Role.PERSONAL) - private val cloneUser = User(id = baseId + 1, role = Role.CLONE) - private val workUser = User(id = baseId + 2, role = Role.WORK) - private val privateUser = User(id = baseId + 3, role = Role.PRIVATE) - - val personalProfile = Profile(PERSONAL, personalUser) - val workProfile = Profile(WORK, workUser) - val privateProfile = Profile(PRIVATE, privateUser) - - @Test - fun launchedByProfile(): Unit = runTest { - val profileInteractor = - UserInteractor( - userRepository = FakeUserRepository(listOf(personalUser, cloneUser)), - launchedAs = personalUser.handle - ) - - val launchedAsProfile by collectLastValue(profileInteractor.launchedAsProfile) - - assertThat(launchedAsProfile).isEqualTo(Profile(PERSONAL, personalUser, cloneUser)) - } - - @Test - fun launchedByProfile_asClone(): Unit = runTest { - val profileInteractor = - UserInteractor( - userRepository = FakeUserRepository(listOf(personalUser, cloneUser)), - launchedAs = cloneUser.handle - ) - val profiles by collectLastValue(profileInteractor.launchedAsProfile) - - assertThat(profiles).isEqualTo(Profile(PERSONAL, personalUser, cloneUser)) - } - - @Test - fun profiles_withPersonal(): Unit = runTest { - val profileInteractor = - UserInteractor( - userRepository = FakeUserRepository(listOf(personalUser)), - launchedAs = personalUser.handle - ) - - val profiles by collectLastValue(profileInteractor.profiles) - - assertThat(profiles).containsExactly(Profile(PERSONAL, personalUser)) - } - - @Test - fun profiles_addClone(): Unit = runTest { - val fakeUserRepo = FakeUserRepository(listOf(personalUser)) - val profileInteractor = - UserInteractor(userRepository = fakeUserRepo, launchedAs = personalUser.handle) - - val profiles by collectLastValue(profileInteractor.profiles) - assertThat(profiles).containsExactly(Profile(PERSONAL, personalUser)) - - fakeUserRepo.addUser(cloneUser, available = true) - assertThat(profiles).containsExactly(Profile(PERSONAL, personalUser, cloneUser)) - } - - @Test - fun profiles_withPersonalAndClone(): Unit = runTest { - val profileInteractor = - UserInteractor( - userRepository = FakeUserRepository(listOf(personalUser, cloneUser)), - launchedAs = personalUser.handle - ) - val profiles by collectLastValue(profileInteractor.profiles) - - assertThat(profiles).containsExactly(Profile(PERSONAL, personalUser, cloneUser)) - } - - @Test - fun profiles_withAllSupportedTypes(): Unit = runTest { - val profileInteractor = - UserInteractor( - userRepository = - FakeUserRepository(listOf(personalUser, cloneUser, workUser, privateUser)), - launchedAs = personalUser.handle - ) - val profiles by collectLastValue(profileInteractor.profiles) - - assertThat(profiles) - .containsExactly( - Profile(PERSONAL, personalUser, cloneUser), - Profile(WORK, workUser), - Profile(PRIVATE, privateUser) - ) - } - - @Test - fun profiles_preservesIterationOrder(): Unit = runTest { - val profileInteractor = - UserInteractor( - userRepository = - FakeUserRepository(listOf(workUser, cloneUser, privateUser, personalUser)), - launchedAs = personalUser.handle - ) - - val profiles by collectLastValue(profileInteractor.profiles) - - assertThat(profiles) - .containsExactly( - Profile(WORK, workUser), - Profile(PRIVATE, privateUser), - Profile(PERSONAL, personalUser, cloneUser), - ) - } - - @Test - fun isAvailable_defaultValue() = runTest { - val userRepo = FakeUserRepository(listOf(personalUser)) - userRepo.addUser(workUser, false) - - val interactor = - UserInteractor(userRepository = userRepo, launchedAs = personalUser.handle) - - val availability by collectLastValue(interactor.availability) - - assertWithMessage("personalAvailable").that(availability?.get(personalProfile)).isTrue() - assertWithMessage("workAvailable").that(availability?.get(workProfile)).isFalse() - } - - @Test - fun isAvailable() = runTest { - val userRepo = FakeUserRepository(listOf(workUser, personalUser)) - val interactor = - UserInteractor(userRepository = userRepo, launchedAs = personalUser.handle) - - val availability by collectLastValue(interactor.availability) - - // Default state is enabled in FakeUserManager - assertWithMessage("workAvailable").that(availability?.get(workProfile)).isTrue() - - // Making user unavailable makes profile unavailable - userRepo.requestState(workUser, false) - assertWithMessage("workAvailable").that(availability?.get(workProfile)).isFalse() - - // Making user available makes profile available again - userRepo.requestState(workUser, true) - assertWithMessage("workAvailable").that(availability?.get(workProfile)).isTrue() - - // When a user is removed availability is removed as well. - userRepo.removeUser(workUser) - assertWithMessage("workAvailable").that(availability?.get(workProfile)).isNull() - } - - /** - * Similar to the above test in reverse: uses UserInteractor to modify state, and verify the - * state of the UserRepository. - */ - @Test - fun updateState() = runTest { - val userRepo = FakeUserRepository(listOf(workUser, personalUser)) - val userInteractor = - UserInteractor(userRepository = userRepo, launchedAs = personalUser.handle) - val workProfile = Profile(Profile.Type.WORK, workUser) - - val availability by collectLastValue(userRepo.availability) - - // Default state is enabled in FakeUserManager - assertWithMessage("workAvailable").that(availability?.get(workUser)).isTrue() - - userInteractor.updateState(workProfile, false) - assertWithMessage("workAvailable").that(availability?.get(workUser)).isFalse() - - userInteractor.updateState(workProfile, true) - assertWithMessage("workAvailable").that(availability?.get(workUser)).isTrue() - } -} diff --git a/tests/unit/src/com/android/intentresolver/v2/emptystate/EmptyStateUiHelperTest.kt b/tests/unit/src/com/android/intentresolver/v2/emptystate/EmptyStateUiHelperTest.kt deleted file mode 100644 index 696dd1fd..00000000 --- a/tests/unit/src/com/android/intentresolver/v2/emptystate/EmptyStateUiHelperTest.kt +++ /dev/null @@ -1,228 +0,0 @@ -/* - * Copyright (C) 2023 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.android.intentresolver.v2.emptystate - -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import android.widget.FrameLayout -import android.widget.TextView -import androidx.test.platform.app.InstrumentationRegistry -import com.android.intentresolver.any -import com.android.intentresolver.emptystate.EmptyState -import com.android.intentresolver.mock -import com.google.common.truth.Truth.assertThat -import java.util.Optional -import java.util.function.Supplier -import org.junit.Before -import org.junit.Test -import org.mockito.Mockito.never -import org.mockito.Mockito.verify - -class EmptyStateUiHelperTest { - private val context = InstrumentationRegistry.getInstrumentation().getContext() - - var shouldOverrideContainerPadding = false - val containerPaddingSupplier = - Supplier> { - Optional.ofNullable(if (shouldOverrideContainerPadding) 42 else null) - } - - lateinit var rootContainer: ViewGroup - lateinit var mainListView: View // Visible when no empty state is showing. - lateinit var emptyStateTitleView: TextView - lateinit var emptyStateSubtitleView: TextView - lateinit var emptyStateButtonView: View - lateinit var emptyStateProgressView: View - lateinit var emptyStateDefaultTextView: View - lateinit var emptyStateContainerView: View - lateinit var emptyStateRootView: View - lateinit var emptyStateUiHelper: EmptyStateUiHelper - - @Before - fun setup() { - rootContainer = FrameLayout(context) - LayoutInflater.from(context) - .inflate( - com.android.intentresolver.R.layout.resolver_list_per_profile, - rootContainer, - true - ) - mainListView = rootContainer.requireViewById(com.android.internal.R.id.resolver_list) - emptyStateRootView = - rootContainer.requireViewById(com.android.internal.R.id.resolver_empty_state) - emptyStateTitleView = - rootContainer.requireViewById(com.android.internal.R.id.resolver_empty_state_title) - emptyStateSubtitleView = - rootContainer.requireViewById(com.android.internal.R.id.resolver_empty_state_subtitle) - emptyStateButtonView = - rootContainer.requireViewById(com.android.internal.R.id.resolver_empty_state_button) - emptyStateProgressView = - rootContainer.requireViewById(com.android.internal.R.id.resolver_empty_state_progress) - emptyStateDefaultTextView = rootContainer.requireViewById(com.android.internal.R.id.empty) - emptyStateContainerView = - rootContainer.requireViewById(com.android.internal.R.id.resolver_empty_state_container) - emptyStateUiHelper = - EmptyStateUiHelper( - rootContainer, - com.android.internal.R.id.resolver_list, - containerPaddingSupplier - ) - } - - @Test - fun testResetViewVisibilities() { - // First set each view's visibility to differ from the expected "reset" state so we can then - // assert that they're all reset afterward. - // TODO: for historic reasons "reset" doesn't cover `emptyStateContainerView`; should it? - emptyStateRootView.visibility = View.GONE - emptyStateTitleView.visibility = View.GONE - emptyStateSubtitleView.visibility = View.GONE - emptyStateButtonView.visibility = View.VISIBLE - emptyStateProgressView.visibility = View.VISIBLE - emptyStateDefaultTextView.visibility = View.VISIBLE - - emptyStateUiHelper.resetViewVisibilities() - - assertThat(emptyStateRootView.visibility).isEqualTo(View.VISIBLE) - assertThat(emptyStateTitleView.visibility).isEqualTo(View.VISIBLE) - assertThat(emptyStateSubtitleView.visibility).isEqualTo(View.VISIBLE) - assertThat(emptyStateButtonView.visibility).isEqualTo(View.INVISIBLE) - assertThat(emptyStateProgressView.visibility).isEqualTo(View.GONE) - assertThat(emptyStateDefaultTextView.visibility).isEqualTo(View.GONE) - } - - @Test - fun testShowSpinner() { - emptyStateTitleView.visibility = View.VISIBLE - emptyStateButtonView.visibility = View.VISIBLE - emptyStateProgressView.visibility = View.GONE - emptyStateDefaultTextView.visibility = View.VISIBLE - - emptyStateUiHelper.showSpinner() - - // TODO: should this cover any other views? Subtitle? - assertThat(emptyStateTitleView.visibility).isEqualTo(View.INVISIBLE) - assertThat(emptyStateButtonView.visibility).isEqualTo(View.INVISIBLE) - assertThat(emptyStateProgressView.visibility).isEqualTo(View.VISIBLE) - assertThat(emptyStateDefaultTextView.visibility).isEqualTo(View.GONE) - } - - @Test - fun testHide() { - emptyStateRootView.visibility = View.VISIBLE - mainListView.visibility = View.GONE - - emptyStateUiHelper.hide() - - assertThat(emptyStateRootView.visibility).isEqualTo(View.GONE) - assertThat(mainListView.visibility).isEqualTo(View.VISIBLE) - } - - @Test - fun testBottomPaddingDelegate_default() { - shouldOverrideContainerPadding = false - emptyStateContainerView.setPadding(1, 2, 3, 4) - - emptyStateUiHelper.setupContainerPadding() - - assertThat(emptyStateContainerView.paddingLeft).isEqualTo(1) - assertThat(emptyStateContainerView.paddingTop).isEqualTo(2) - assertThat(emptyStateContainerView.paddingRight).isEqualTo(3) - assertThat(emptyStateContainerView.paddingBottom).isEqualTo(4) - } - - @Test - fun testBottomPaddingDelegate_override() { - shouldOverrideContainerPadding = true // Set bottom padding to 42. - emptyStateContainerView.setPadding(1, 2, 3, 4) - - emptyStateUiHelper.setupContainerPadding() - - assertThat(emptyStateContainerView.paddingLeft).isEqualTo(1) - assertThat(emptyStateContainerView.paddingTop).isEqualTo(2) - assertThat(emptyStateContainerView.paddingRight).isEqualTo(3) - assertThat(emptyStateContainerView.paddingBottom).isEqualTo(42) - } - - @Test - fun testShowEmptyState_noOnClickHandler() { - mainListView.visibility = View.VISIBLE - - // Note: an `EmptyState.ClickListener` isn't invoked directly by the UI helper; it has to be - // built into the "on-click handler" that's injected to implement the button-press. We won't - // display the button without a click "handler," even if it *does* have a `ClickListener`. - val clickListener = mock() - - val emptyState = - object : EmptyState { - override fun getTitle() = "Test title" - override fun getSubtitle() = "Test subtitle" - - override fun getButtonClickListener() = clickListener - } - emptyStateUiHelper.showEmptyState(emptyState, null) - - assertThat(mainListView.visibility).isEqualTo(View.GONE) - assertThat(emptyStateRootView.visibility).isEqualTo(View.VISIBLE) - assertThat(emptyStateTitleView.visibility).isEqualTo(View.VISIBLE) - assertThat(emptyStateSubtitleView.visibility).isEqualTo(View.VISIBLE) - assertThat(emptyStateButtonView.visibility).isEqualTo(View.GONE) - assertThat(emptyStateProgressView.visibility).isEqualTo(View.GONE) - assertThat(emptyStateDefaultTextView.visibility).isEqualTo(View.GONE) - - assertThat(emptyStateTitleView.text).isEqualTo("Test title") - assertThat(emptyStateSubtitleView.text).isEqualTo("Test subtitle") - - verify(clickListener, never()).onClick(any()) - } - - @Test - fun testShowEmptyState_withOnClickHandlerAndClickListener() { - mainListView.visibility = View.VISIBLE - - val clickListener = mock() - val onClickHandler = mock() - - val emptyState = - object : EmptyState { - override fun getTitle() = "Test title" - override fun getSubtitle() = "Test subtitle" - - override fun getButtonClickListener() = clickListener - } - emptyStateUiHelper.showEmptyState(emptyState, onClickHandler) - - assertThat(mainListView.visibility).isEqualTo(View.GONE) - assertThat(emptyStateRootView.visibility).isEqualTo(View.VISIBLE) - assertThat(emptyStateTitleView.visibility).isEqualTo(View.VISIBLE) - assertThat(emptyStateSubtitleView.visibility).isEqualTo(View.VISIBLE) - assertThat(emptyStateButtonView.visibility).isEqualTo(View.VISIBLE) // Now shown. - assertThat(emptyStateProgressView.visibility).isEqualTo(View.GONE) - assertThat(emptyStateDefaultTextView.visibility).isEqualTo(View.GONE) - - assertThat(emptyStateTitleView.text).isEqualTo("Test title") - assertThat(emptyStateSubtitleView.text).isEqualTo("Test subtitle") - - emptyStateButtonView.performClick() - - verify(onClickHandler).onClick(emptyStateButtonView) - // The test didn't explicitly configure its `OnClickListener` to relay the click event on - // to the `EmptyState.ClickListener`, so it still won't have fired here. - verify(clickListener, never()).onClick(any()) - } -} diff --git a/tests/unit/src/com/android/intentresolver/v2/ext/CreationExtrasExtTest.kt b/tests/unit/src/com/android/intentresolver/v2/ext/CreationExtrasExtTest.kt deleted file mode 100644 index 5eac6bd6..00000000 --- a/tests/unit/src/com/android/intentresolver/v2/ext/CreationExtrasExtTest.kt +++ /dev/null @@ -1,54 +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.v2.ext - -import android.graphics.Point -import androidx.core.os.bundleOf -import androidx.lifecycle.DEFAULT_ARGS_KEY -import androidx.lifecycle.viewmodel.CreationExtras -import androidx.lifecycle.viewmodel.MutableCreationExtras -import androidx.test.ext.truth.os.BundleSubject.assertThat -import org.junit.Test - -class CreationExtrasExtTest { - @Test - fun addDefaultArgs_addsWhenAbsent() { - val creationExtras: CreationExtras = MutableCreationExtras() // empty - - val updated = creationExtras.addDefaultArgs("POINT" to Point(1, 1)) - - val defaultArgs = updated[DEFAULT_ARGS_KEY] - assertThat(defaultArgs).containsKey("POINT") - assertThat(defaultArgs).parcelable("POINT").marshallsEquallyTo(Point(1, 1)) - } - - @Test - fun addDefaultArgs_addsToExisting() { - val creationExtras: CreationExtras = - MutableCreationExtras().apply { - set(DEFAULT_ARGS_KEY, bundleOf("POINT1" to Point(1, 1))) - } - - val updated = creationExtras.addDefaultArgs("POINT2" to Point(2, 2)) - - val defaultArgs = updated[DEFAULT_ARGS_KEY] - assertThat(defaultArgs).containsKey("POINT1") - assertThat(defaultArgs).containsKey("POINT2") - assertThat(defaultArgs).parcelable("POINT1").marshallsEquallyTo(Point(1, 1)) - assertThat(defaultArgs).parcelable("POINT2").marshallsEquallyTo(Point(2, 2)) - } -} diff --git a/tests/unit/src/com/android/intentresolver/v2/ext/IntentExtTest.kt b/tests/unit/src/com/android/intentresolver/v2/ext/IntentExtTest.kt deleted file mode 100644 index 2ccd548a..00000000 --- a/tests/unit/src/com/android/intentresolver/v2/ext/IntentExtTest.kt +++ /dev/null @@ -1,85 +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.v2.ext - -import android.content.ComponentName -import android.content.Intent -import com.google.common.truth.Truth.assertThat -import com.google.common.truth.Truth.assertWithMessage -import java.util.function.Predicate -import org.junit.Test - -class IntentExtTest { - - private val hasSendAction = - Predicate { - it?.action == Intent.ACTION_SEND || it?.action == Intent.ACTION_SEND_MULTIPLE - } - - @Test - fun hasAction() { - val sendIntent = Intent(Intent.ACTION_SEND) - assertThat(sendIntent.hasAction(Intent.ACTION_SEND)).isTrue() - assertThat(sendIntent.hasAction(Intent.ACTION_VIEW)).isFalse() - } - - @Test - fun hasComponent() { - assertThat(Intent().hasComponent()).isFalse() - assertThat(Intent().setComponent(ComponentName("A", "B")).hasComponent()).isTrue() - } - - @Test - fun hasSendAction() { - assertThat(Intent(Intent.ACTION_SEND).hasSendAction()).isTrue() - assertThat(Intent(Intent.ACTION_SEND_MULTIPLE).hasSendAction()).isTrue() - assertThat(Intent(Intent.ACTION_SENDTO).hasSendAction()).isFalse() - assertThat(Intent(Intent.ACTION_VIEW).hasSendAction()).isFalse() - } - - @Test - fun hasSingleCategory() { - val intent = Intent().addCategory(Intent.CATEGORY_HOME) - assertThat(intent.hasSingleCategory(Intent.CATEGORY_HOME)).isTrue() - assertThat(intent.hasSingleCategory(Intent.CATEGORY_DEFAULT)).isFalse() - - intent.addCategory(Intent.CATEGORY_TEST) - assertThat(intent.hasSingleCategory(Intent.CATEGORY_TEST)).isFalse() - } - - @Test - fun ifMatch_matched() { - val sendIntent = Intent(Intent.ACTION_SEND) - val sendMultipleIntent = Intent(Intent.ACTION_SEND_MULTIPLE) - - sendIntent.ifMatch(hasSendAction) { addFlags(Intent.FLAG_ACTIVITY_MATCH_EXTERNAL) } - sendMultipleIntent.ifMatch(hasSendAction) { addFlags(Intent.FLAG_ACTIVITY_MATCH_EXTERNAL) } - assertWithMessage("sendIntent flags") - .that(sendIntent.flags) - .isEqualTo(Intent.FLAG_ACTIVITY_MATCH_EXTERNAL) - assertWithMessage("sendMultipleIntent flags") - .that(sendMultipleIntent.flags) - .isEqualTo(Intent.FLAG_ACTIVITY_MATCH_EXTERNAL) - } - - @Test - fun ifMatch_notMatched() { - val viewIntent = Intent(Intent.ACTION_VIEW) - - viewIntent.ifMatch(hasSendAction) { addFlags(Intent.FLAG_ACTIVITY_MATCH_EXTERNAL) } - assertWithMessage("viewIntent flags").that(viewIntent.flags).isEqualTo(0) - } -} diff --git a/tests/unit/src/com/android/intentresolver/v2/listcontroller/ChooserRequestFilteredComponentsTest.kt b/tests/unit/src/com/android/intentresolver/v2/listcontroller/ChooserRequestFilteredComponentsTest.kt deleted file mode 100644 index 59494bed..00000000 --- a/tests/unit/src/com/android/intentresolver/v2/listcontroller/ChooserRequestFilteredComponentsTest.kt +++ /dev/null @@ -1,61 +0,0 @@ -/* - * Copyright (C) 2023 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.android.intentresolver.v2.listcontroller - -import android.content.ComponentName -import com.android.intentresolver.ChooserRequestParameters -import com.android.intentresolver.whenever -import com.google.common.collect.ImmutableList -import com.google.common.truth.Truth.assertThat -import org.junit.Before -import org.junit.Test -import org.mockito.Mock -import org.mockito.MockitoAnnotations - -class ChooserRequestFilteredComponentsTest { - - @Mock lateinit var mockChooserRequestParameters: ChooserRequestParameters - - private lateinit var chooserRequestFilteredComponents: ChooserRequestFilteredComponents - - @Before - fun setup() { - MockitoAnnotations.initMocks(this) - - chooserRequestFilteredComponents = - ChooserRequestFilteredComponents(mockChooserRequestParameters) - } - - @Test - fun isComponentFiltered_returnsRequestParametersFilteredState() { - // Arrange - whenever(mockChooserRequestParameters.filteredComponentNames) - .thenReturn( - ImmutableList.of(ComponentName("FilteredPackage", "FilteredClass")), - ) - val testComponent = ComponentName("TestPackage", "TestClass") - val filteredComponent = ComponentName("FilteredPackage", "FilteredClass") - - // Act - val result = chooserRequestFilteredComponents.isComponentFiltered(testComponent) - val filteredResult = chooserRequestFilteredComponents.isComponentFiltered(filteredComponent) - - // Assert - assertThat(result).isFalse() - assertThat(filteredResult).isTrue() - } -} diff --git a/tests/unit/src/com/android/intentresolver/v2/listcontroller/FakeResolverComparator.kt b/tests/unit/src/com/android/intentresolver/v2/listcontroller/FakeResolverComparator.kt deleted file mode 100644 index ce40567e..00000000 --- a/tests/unit/src/com/android/intentresolver/v2/listcontroller/FakeResolverComparator.kt +++ /dev/null @@ -1,83 +0,0 @@ -/* - * Copyright (C) 2023 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.android.intentresolver.v2.listcontroller - -import android.content.ComponentName -import android.content.Context -import android.content.Intent -import android.content.pm.ResolveInfo -import android.content.res.Configuration -import android.content.res.Resources -import android.os.Message -import android.os.UserHandle -import com.android.intentresolver.ResolvedComponentInfo -import com.android.intentresolver.chooser.TargetInfo -import com.android.intentresolver.model.AbstractResolverComparator -import com.android.intentresolver.whenever -import java.util.Locale -import org.mockito.Mockito - -class FakeResolverComparator( - context: Context = - Mockito.mock(Context::class.java).also { - val mockResources = Mockito.mock(Resources::class.java) - whenever(it.resources).thenReturn(mockResources) - whenever(mockResources.configuration) - .thenReturn(Configuration().apply { setLocale(Locale.US) }) - }, - targetIntent: Intent = Intent("TestAction"), - resolvedActivityUserSpaceList: List = emptyList(), - promoteToFirst: ComponentName? = null, -) : - AbstractResolverComparator( - context, - targetIntent, - resolvedActivityUserSpaceList, - promoteToFirst, - ) { - var lastUpdateModel: TargetInfo? = null - private set - var lastUpdateChooserCounts: Triple? = null - private set - var destroyCalled = false - private set - - override fun compare(lhs: ResolveInfo?, rhs: ResolveInfo?): Int = - lhs!!.activityInfo.packageName.compareTo(rhs!!.activityInfo.packageName) - - override fun doCompute(targets: MutableList?) {} - - override fun getScore(targetInfo: TargetInfo?): Float = 1.23f - - override fun handleResultMessage(message: Message?) {} - - override fun updateModel(targetInfo: TargetInfo?) { - lastUpdateModel = targetInfo - } - - override fun updateChooserCounts( - packageName: String, - user: UserHandle, - action: String, - ) { - lastUpdateChooserCounts = Triple(packageName, user, action) - } - - override fun destroy() { - destroyCalled = true - } -} diff --git a/tests/unit/src/com/android/intentresolver/v2/listcontroller/FilterableComponentsTest.kt b/tests/unit/src/com/android/intentresolver/v2/listcontroller/FilterableComponentsTest.kt deleted file mode 100644 index 396505e6..00000000 --- a/tests/unit/src/com/android/intentresolver/v2/listcontroller/FilterableComponentsTest.kt +++ /dev/null @@ -1,77 +0,0 @@ -/* - * Copyright (C) 2023 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.android.intentresolver.v2.listcontroller - -import android.content.ComponentName -import com.android.intentresolver.ChooserRequestParameters -import com.android.intentresolver.whenever -import com.google.common.collect.ImmutableList -import com.google.common.truth.Truth.assertThat -import org.junit.Before -import org.junit.Test -import org.mockito.Mock -import org.mockito.MockitoAnnotations - -class FilterableComponentsTest { - - @Mock lateinit var mockChooserRequestParameters: ChooserRequestParameters - - private val unfilteredComponent = ComponentName("TestPackage", "TestClass") - private val filteredComponent = ComponentName("FilteredPackage", "FilteredClass") - private val noComponentFiltering = NoComponentFiltering() - - private lateinit var chooserRequestFilteredComponents: ChooserRequestFilteredComponents - - @Before - fun setup() { - MockitoAnnotations.initMocks(this) - - chooserRequestFilteredComponents = - ChooserRequestFilteredComponents(mockChooserRequestParameters) - } - - @Test - fun isComponentFiltered_noComponentFiltering_neverFilters() { - // Arrange - - // Act - val unfilteredResult = noComponentFiltering.isComponentFiltered(unfilteredComponent) - val filteredResult = noComponentFiltering.isComponentFiltered(filteredComponent) - - // Assert - assertThat(unfilteredResult).isFalse() - assertThat(filteredResult).isFalse() - } - - @Test - fun isComponentFiltered_chooserRequestFilteredComponents_filtersAccordingToChooserRequest() { - // Arrange - whenever(mockChooserRequestParameters.filteredComponentNames) - .thenReturn( - ImmutableList.of(filteredComponent), - ) - - // Act - val unfilteredResult = - chooserRequestFilteredComponents.isComponentFiltered(unfilteredComponent) - val filteredResult = chooserRequestFilteredComponents.isComponentFiltered(filteredComponent) - - // Assert - assertThat(unfilteredResult).isFalse() - assertThat(filteredResult).isTrue() - } -} diff --git a/tests/unit/src/com/android/intentresolver/v2/listcontroller/IntentResolverTest.kt b/tests/unit/src/com/android/intentresolver/v2/listcontroller/IntentResolverTest.kt deleted file mode 100644 index 09f6d373..00000000 --- a/tests/unit/src/com/android/intentresolver/v2/listcontroller/IntentResolverTest.kt +++ /dev/null @@ -1,499 +0,0 @@ -/* - * Copyright (C) 2023 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.android.intentresolver.v2.listcontroller - -import android.content.ComponentName -import android.content.Intent -import android.content.IntentFilter -import android.content.pm.ActivityInfo -import android.content.pm.PackageManager -import android.content.pm.ResolveInfo -import android.net.Uri -import android.os.UserHandle -import com.android.intentresolver.any -import com.android.intentresolver.eq -import com.android.intentresolver.kotlinArgumentCaptor -import com.android.intentresolver.whenever -import com.google.common.truth.Truth.assertThat -import java.lang.IndexOutOfBoundsException -import org.junit.Assert.assertThrows -import org.junit.Before -import org.junit.Test -import org.mockito.Mock -import org.mockito.Mockito.anyInt -import org.mockito.Mockito.verify -import org.mockito.MockitoAnnotations - -class IntentResolverTest { - - @Mock lateinit var mockPackageManager: PackageManager - - private lateinit var intentResolver: IntentResolver - - private val fakePinnableComponents = - object : PinnableComponents { - override fun isComponentPinned(name: ComponentName): Boolean { - return name.packageName == "PinnedPackage" - } - } - - @Before - fun setup() { - MockitoAnnotations.initMocks(this) - - intentResolver = - IntentResolverImpl(mockPackageManager, ResolveListDeduperImpl(fakePinnableComponents)) - } - - @Test - fun getResolversForIntentAsUser_noIntents_returnsEmptyList() { - // Arrange - val testIntents = emptyList() - - // Act - val result = - intentResolver.getResolversForIntentAsUser( - shouldGetResolvedFilter = false, - shouldGetActivityMetadata = false, - shouldGetOnlyDefaultActivities = false, - intents = testIntents, - userHandle = UserHandle(456), - ) - - // Assert - assertThat(result).isEmpty() - } - - @Test - fun getResolversForIntentAsUser_noResolveInfo_returnsEmptyList() { - // Arrange - val testIntents = listOf(Intent("TestAction")) - val testResolveInfos = emptyList() - whenever(mockPackageManager.queryIntentActivitiesAsUser(any(), anyInt(), any())) - .thenReturn(testResolveInfos) - - // Act - val result = - intentResolver.getResolversForIntentAsUser( - shouldGetResolvedFilter = false, - shouldGetActivityMetadata = false, - shouldGetOnlyDefaultActivities = false, - intents = testIntents, - userHandle = UserHandle(456), - ) - - // Assert - assertThat(result).isEmpty() - } - - @Test - fun getResolversForIntentAsUser_returnsAllResolveComponentInfo() { - // Arrange - val testIntent1 = Intent("TestAction1") - val testIntent2 = Intent("TestAction2") - val testIntents = listOf(testIntent1, testIntent2) - val testResolveInfos1 = - listOf( - ResolveInfo().apply { - userHandle = UserHandle(456) - activityInfo = ActivityInfo() - activityInfo.packageName = "TestPackage1" - activityInfo.name = "TestClass1" - }, - ResolveInfo().apply { - userHandle = UserHandle(456) - activityInfo = ActivityInfo() - activityInfo.packageName = "TestPackage2" - activityInfo.name = "TestClass2" - }, - ) - val testResolveInfos2 = - listOf( - ResolveInfo().apply { - userHandle = UserHandle(456) - activityInfo = ActivityInfo() - activityInfo.packageName = "TestPackage3" - activityInfo.name = "TestClass3" - }, - ResolveInfo().apply { - userHandle = UserHandle(456) - activityInfo = ActivityInfo() - activityInfo.packageName = "TestPackage4" - activityInfo.name = "TestClass4" - }, - ) - whenever( - mockPackageManager.queryIntentActivitiesAsUser( - eq(testIntent1), - anyInt(), - any(), - ) - ) - .thenReturn(testResolveInfos1) - whenever( - mockPackageManager.queryIntentActivitiesAsUser( - eq(testIntent2), - anyInt(), - any(), - ) - ) - .thenReturn(testResolveInfos2) - - // Act - val result = - intentResolver.getResolversForIntentAsUser( - shouldGetResolvedFilter = false, - shouldGetActivityMetadata = false, - shouldGetOnlyDefaultActivities = false, - intents = testIntents, - userHandle = UserHandle(456), - ) - - // Assert - result.forEachIndexed { index, it -> - val postfix = index + 1 - assertThat(it.name.packageName).isEqualTo("TestPackage$postfix") - assertThat(it.name.className).isEqualTo("TestClass$postfix") - assertThrows(IndexOutOfBoundsException::class.java) { it.getIntentAt(1) } - } - assertThat(result.map { it.getIntentAt(0) }) - .containsExactly( - testIntent1, - testIntent1, - testIntent2, - testIntent2, - ) - } - - @Test - fun getResolversForIntentAsUser_resolveInfoWithoutUserHandle_isSkipped() { - // Arrange - val testIntent = Intent("TestAction") - val testIntents = listOf(testIntent) - val testResolveInfos = - listOf( - ResolveInfo().apply { - activityInfo = ActivityInfo() - activityInfo.packageName = "TestPackage" - activityInfo.name = "TestClass" - }, - ) - whenever( - mockPackageManager.queryIntentActivitiesAsUser( - any(), - anyInt(), - any(), - ) - ) - .thenReturn(testResolveInfos) - - // Act - val result = - intentResolver.getResolversForIntentAsUser( - shouldGetResolvedFilter = false, - shouldGetActivityMetadata = false, - shouldGetOnlyDefaultActivities = false, - intents = testIntents, - userHandle = UserHandle(456), - ) - - // Assert - assertThat(result).isEmpty() - } - - @Test - fun getResolversForIntentAsUser_duplicateComponents_areCombined() { - // Arrange - val testIntent1 = Intent("TestAction1") - val testIntent2 = Intent("TestAction2") - val testIntents = listOf(testIntent1, testIntent2) - val testResolveInfos1 = - listOf( - ResolveInfo().apply { - userHandle = UserHandle(456) - activityInfo = ActivityInfo() - activityInfo.packageName = "DuplicatePackage" - activityInfo.name = "DuplicateClass" - }, - ) - val testResolveInfos2 = - listOf( - ResolveInfo().apply { - userHandle = UserHandle(456) - activityInfo = ActivityInfo() - activityInfo.packageName = "DuplicatePackage" - activityInfo.name = "DuplicateClass" - }, - ) - whenever( - mockPackageManager.queryIntentActivitiesAsUser( - eq(testIntent1), - anyInt(), - any(), - ) - ) - .thenReturn(testResolveInfos1) - whenever( - mockPackageManager.queryIntentActivitiesAsUser( - eq(testIntent2), - anyInt(), - any(), - ) - ) - .thenReturn(testResolveInfos2) - - // Act - val result = - intentResolver.getResolversForIntentAsUser( - shouldGetResolvedFilter = false, - shouldGetActivityMetadata = false, - shouldGetOnlyDefaultActivities = false, - intents = testIntents, - userHandle = UserHandle(456), - ) - - // Assert - assertThat(result).hasSize(1) - with(result.first()) { - assertThat(name.packageName).isEqualTo("DuplicatePackage") - assertThat(name.className).isEqualTo("DuplicateClass") - assertThat(getIntentAt(0)).isEqualTo(testIntent1) - assertThat(getIntentAt(1)).isEqualTo(testIntent2) - assertThrows(IndexOutOfBoundsException::class.java) { getIntentAt(2) } - } - } - - @Test - fun getResolversForIntentAsUser_pinnedComponentsArePinned() { - // Arrange - val testIntent1 = Intent("TestAction1") - val testIntent2 = Intent("TestAction2") - val testIntents = listOf(testIntent1, testIntent2) - val testResolveInfos1 = - listOf( - ResolveInfo().apply { - userHandle = UserHandle(456) - activityInfo = ActivityInfo() - activityInfo.packageName = "UnpinnedPackage" - activityInfo.name = "UnpinnedClass" - }, - ) - val testResolveInfos2 = - listOf( - ResolveInfo().apply { - userHandle = UserHandle(456) - activityInfo = ActivityInfo() - activityInfo.packageName = "PinnedPackage" - activityInfo.name = "PinnedClass" - }, - ) - whenever( - mockPackageManager.queryIntentActivitiesAsUser( - eq(testIntent1), - anyInt(), - any(), - ) - ) - .thenReturn(testResolveInfos1) - whenever( - mockPackageManager.queryIntentActivitiesAsUser( - eq(testIntent2), - anyInt(), - any(), - ) - ) - .thenReturn(testResolveInfos2) - - // Act - val result = - intentResolver.getResolversForIntentAsUser( - shouldGetResolvedFilter = false, - shouldGetActivityMetadata = false, - shouldGetOnlyDefaultActivities = false, - intents = testIntents, - userHandle = UserHandle(456), - ) - - // Assert - assertThat(result.map { it.isPinned }).containsExactly(false, true) - } - - @Test - fun getResolversForIntentAsUser_whenNoExtraBehavior_usesBaseFlags() { - // Arrange - val baseFlags = - PackageManager.MATCH_DIRECT_BOOT_AWARE or - PackageManager.MATCH_DIRECT_BOOT_UNAWARE or - PackageManager.MATCH_CLONE_PROFILE - val testIntent = Intent() - val testIntents = listOf(testIntent) - - // Act - intentResolver.getResolversForIntentAsUser( - shouldGetResolvedFilter = false, - shouldGetActivityMetadata = false, - shouldGetOnlyDefaultActivities = false, - intents = testIntents, - userHandle = UserHandle(456), - ) - - // Assert - val flags = kotlinArgumentCaptor() - verify(mockPackageManager) - .queryIntentActivitiesAsUser( - any(), - flags.capture(), - any(), - ) - assertThat(flags.value).isEqualTo(baseFlags) - } - - @Test - fun getResolversForIntentAsUser_whenShouldGetResolvedFilter_usesGetResolvedFilterFlag() { - // Arrange - val testIntent = Intent() - val testIntents = listOf(testIntent) - - // Act - intentResolver.getResolversForIntentAsUser( - shouldGetResolvedFilter = true, - shouldGetActivityMetadata = false, - shouldGetOnlyDefaultActivities = false, - intents = testIntents, - userHandle = UserHandle(456), - ) - - // Assert - val flags = kotlinArgumentCaptor() - verify(mockPackageManager) - .queryIntentActivitiesAsUser( - any(), - flags.capture(), - any(), - ) - assertThat(flags.value and PackageManager.GET_RESOLVED_FILTER) - .isEqualTo(PackageManager.GET_RESOLVED_FILTER) - } - - @Test - fun getResolversForIntentAsUser_whenShouldGetActivityMetadata_usesGetMetaDataFlag() { - // Arrange - val testIntent = Intent() - val testIntents = listOf(testIntent) - - // Act - intentResolver.getResolversForIntentAsUser( - shouldGetResolvedFilter = false, - shouldGetActivityMetadata = true, - shouldGetOnlyDefaultActivities = false, - intents = testIntents, - userHandle = UserHandle(456), - ) - - // Assert - val flags = kotlinArgumentCaptor() - verify(mockPackageManager) - .queryIntentActivitiesAsUser( - any(), - flags.capture(), - any(), - ) - assertThat(flags.value and PackageManager.GET_META_DATA) - .isEqualTo(PackageManager.GET_META_DATA) - } - - @Test - fun getResolversForIntentAsUser_whenShouldGetOnlyDefaultActivities_usesMatchDefaultOnlyFlag() { - // Arrange - val testIntent = Intent() - val testIntents = listOf(testIntent) - - // Act - intentResolver.getResolversForIntentAsUser( - shouldGetResolvedFilter = false, - shouldGetActivityMetadata = false, - shouldGetOnlyDefaultActivities = true, - intents = testIntents, - userHandle = UserHandle(456), - ) - - // Assert - val flags = kotlinArgumentCaptor() - verify(mockPackageManager) - .queryIntentActivitiesAsUser( - any(), - flags.capture(), - any(), - ) - assertThat(flags.value and PackageManager.MATCH_DEFAULT_ONLY) - .isEqualTo(PackageManager.MATCH_DEFAULT_ONLY) - } - - @Test - fun getResolversForIntentAsUser_whenWebIntent_usesMatchInstantFlag() { - // Arrange - val testIntent = Intent(Intent.ACTION_VIEW, Uri.fromParts(IntentFilter.SCHEME_HTTP, "", "")) - val testIntents = listOf(testIntent) - - // Act - intentResolver.getResolversForIntentAsUser( - shouldGetResolvedFilter = false, - shouldGetActivityMetadata = false, - shouldGetOnlyDefaultActivities = false, - intents = testIntents, - userHandle = UserHandle(456), - ) - - // Assert - val flags = kotlinArgumentCaptor() - verify(mockPackageManager) - .queryIntentActivitiesAsUser( - any(), - flags.capture(), - any(), - ) - assertThat(flags.value and PackageManager.MATCH_INSTANT) - .isEqualTo(PackageManager.MATCH_INSTANT) - } - - @Test - fun getResolversForIntentAsUser_whenActivityMatchExternalFlag_usesMatchInstantFlag() { - // Arrange - val testIntent = Intent().addFlags(Intent.FLAG_ACTIVITY_MATCH_EXTERNAL) - val testIntents = listOf(testIntent) - - // Act - intentResolver.getResolversForIntentAsUser( - shouldGetResolvedFilter = false, - shouldGetActivityMetadata = false, - shouldGetOnlyDefaultActivities = false, - intents = testIntents, - userHandle = UserHandle(456), - ) - - // Assert - val flags = kotlinArgumentCaptor() - verify(mockPackageManager) - .queryIntentActivitiesAsUser( - any(), - flags.capture(), - any(), - ) - assertThat(flags.value and PackageManager.MATCH_INSTANT) - .isEqualTo(PackageManager.MATCH_INSTANT) - } -} diff --git a/tests/unit/src/com/android/intentresolver/v2/listcontroller/LastChosenManagerTest.kt b/tests/unit/src/com/android/intentresolver/v2/listcontroller/LastChosenManagerTest.kt deleted file mode 100644 index ce5e52b1..00000000 --- a/tests/unit/src/com/android/intentresolver/v2/listcontroller/LastChosenManagerTest.kt +++ /dev/null @@ -1,111 +0,0 @@ -/* - * Copyright (C) 2023 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.android.intentresolver.v2.listcontroller - -import android.content.ComponentName -import android.content.ContentResolver -import android.content.Intent -import android.content.IntentFilter -import android.content.pm.IPackageManager -import android.content.pm.PackageManager -import android.content.pm.ResolveInfo -import com.android.intentresolver.any -import com.android.intentresolver.eq -import com.android.intentresolver.nullable -import com.android.intentresolver.whenever -import com.google.common.truth.Truth.assertThat -import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.test.TestScope -import kotlinx.coroutines.test.UnconfinedTestDispatcher -import kotlinx.coroutines.test.runCurrent -import kotlinx.coroutines.test.runTest -import org.junit.Before -import org.junit.Test -import org.mockito.Mock -import org.mockito.Mockito.isNull -import org.mockito.Mockito.verify -import org.mockito.MockitoAnnotations - -@OptIn(ExperimentalCoroutinesApi::class) -class LastChosenManagerTest { - - private val testDispatcher = UnconfinedTestDispatcher() - private val testScope = TestScope(testDispatcher) - private val testTargetIntent = Intent("TestAction") - - @Mock lateinit var mockContentResolver: ContentResolver - @Mock lateinit var mockIPackageManager: IPackageManager - - private lateinit var lastChosenManager: LastChosenManager - - @Before - fun setup() { - MockitoAnnotations.initMocks(this) - - lastChosenManager = - PackageManagerLastChosenManager(mockContentResolver, testDispatcher, testTargetIntent) { - mockIPackageManager - } - } - - @Test - fun getLastChosen_returnsLastChosenActivity() = - testScope.runTest { - // Arrange - val testResolveInfo = ResolveInfo() - whenever(mockIPackageManager.getLastChosenActivity(any(), nullable(), any())) - .thenReturn(testResolveInfo) - - // Act - val lastChosen = lastChosenManager.getLastChosen() - runCurrent() - - // Assert - verify(mockIPackageManager) - .getLastChosenActivity( - eq(testTargetIntent), - isNull(), - eq(PackageManager.MATCH_DEFAULT_ONLY), - ) - assertThat(lastChosen).isSameInstanceAs(testResolveInfo) - } - - @Test - fun setLastChosen_setsLastChosenActivity() = - testScope.runTest { - // Arrange - val testComponent = ComponentName("TestPackage", "TestClass") - val testIntent = Intent().apply { component = testComponent } - val testIntentFilter = IntentFilter() - val testMatch = 456 - - // Act - lastChosenManager.setLastChosen(testIntent, testIntentFilter, testMatch) - runCurrent() - - // Assert - verify(mockIPackageManager) - .setLastChosenActivity( - eq(testIntent), - isNull(), - eq(PackageManager.MATCH_DEFAULT_ONLY), - eq(testIntentFilter), - eq(testMatch), - eq(testComponent), - ) - } -} diff --git a/tests/unit/src/com/android/intentresolver/v2/listcontroller/PinnableComponentsTest.kt b/tests/unit/src/com/android/intentresolver/v2/listcontroller/PinnableComponentsTest.kt deleted file mode 100644 index 112342ad..00000000 --- a/tests/unit/src/com/android/intentresolver/v2/listcontroller/PinnableComponentsTest.kt +++ /dev/null @@ -1,74 +0,0 @@ -/* - * Copyright (C) 2023 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.android.intentresolver.v2.listcontroller - -import android.content.ComponentName -import android.content.SharedPreferences -import com.android.intentresolver.any -import com.android.intentresolver.eq -import com.android.intentresolver.whenever -import com.google.common.truth.Truth.assertThat -import org.junit.Before -import org.junit.Test -import org.mockito.Mock -import org.mockito.MockitoAnnotations - -class PinnableComponentsTest { - - @Mock lateinit var mockSharedPreferences: SharedPreferences - - private val unpinnedComponent = ComponentName("TestPackage", "TestClass") - private val pinnedComponent = ComponentName("PinnedPackage", "PinnedClass") - private val noComponentPinning = NoComponentPinning() - - private lateinit var sharedPreferencesPinnedComponents: PinnableComponents - - @Before - fun setup() { - MockitoAnnotations.initMocks(this) - - sharedPreferencesPinnedComponents = SharedPreferencesPinnedComponents(mockSharedPreferences) - } - - @Test - fun isComponentPinned_noComponentPinning_neverPins() { - // Arrange - - // Act - val unpinnedResult = noComponentPinning.isComponentPinned(unpinnedComponent) - val pinnedResult = noComponentPinning.isComponentPinned(pinnedComponent) - - // Assert - assertThat(unpinnedResult).isFalse() - assertThat(pinnedResult).isFalse() - } - - @Test - fun isComponentFiltered_chooserRequestFilteredComponents_filtersAccordingToChooserRequest() { - // Arrange - whenever(mockSharedPreferences.getBoolean(eq(pinnedComponent.flattenToString()), any())) - .thenReturn(true) - - // Act - val unpinnedResult = sharedPreferencesPinnedComponents.isComponentPinned(unpinnedComponent) - val pinnedResult = sharedPreferencesPinnedComponents.isComponentPinned(pinnedComponent) - - // Assert - assertThat(unpinnedResult).isFalse() - assertThat(pinnedResult).isTrue() - } -} diff --git a/tests/unit/src/com/android/intentresolver/v2/listcontroller/ResolveListDeduperTest.kt b/tests/unit/src/com/android/intentresolver/v2/listcontroller/ResolveListDeduperTest.kt deleted file mode 100644 index 26f0199e..00000000 --- a/tests/unit/src/com/android/intentresolver/v2/listcontroller/ResolveListDeduperTest.kt +++ /dev/null @@ -1,125 +0,0 @@ -/* - * Copyright (C) 2023 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.android.intentresolver.v2.listcontroller - -import android.content.ComponentName -import android.content.Intent -import android.content.pm.ActivityInfo -import android.content.pm.ResolveInfo -import android.os.UserHandle -import com.android.intentresolver.ResolvedComponentInfo -import com.google.common.truth.Truth.assertThat -import java.lang.IndexOutOfBoundsException -import org.junit.Assert.assertThrows -import org.junit.Before -import org.junit.Test - -class ResolveListDeduperTest { - - private lateinit var resolveListDeduper: ResolveListDeduper - - @Before - fun setup() { - resolveListDeduper = ResolveListDeduperImpl(NoComponentPinning()) - } - - @Test - fun addResolveListDedupe_addsDifferentComponents() { - // Arrange - val testIntent = Intent() - val testResolveInfo1 = - ResolveInfo().apply { - userHandle = UserHandle(456) - activityInfo = ActivityInfo() - activityInfo.packageName = "TestPackage1" - activityInfo.name = "TestClass1" - } - val testResolveInfo2 = - ResolveInfo().apply { - userHandle = UserHandle(456) - activityInfo = ActivityInfo() - activityInfo.packageName = "TestPackage2" - activityInfo.name = "TestClass2" - } - val testResolvedComponentInfo1 = - ResolvedComponentInfo( - ComponentName("TestPackage1", "TestClass1"), - testIntent, - testResolveInfo1, - ) - .apply { isPinned = false } - val listUnderTest = mutableListOf(testResolvedComponentInfo1) - val listToAdd = listOf(testResolveInfo2) - - // Act - resolveListDeduper.addToResolveListWithDedupe( - into = listUnderTest, - intent = testIntent, - from = listToAdd, - ) - - // Assert - listUnderTest.forEachIndexed { index, it -> - val postfix = index + 1 - assertThat(it.name.packageName).isEqualTo("TestPackage$postfix") - assertThat(it.name.className).isEqualTo("TestClass$postfix") - assertThat(it.getIntentAt(0)).isEqualTo(testIntent) - assertThrows(IndexOutOfBoundsException::class.java) { it.getIntentAt(1) } - } - } - - @Test - fun addResolveListDedupe_combinesDuplicateComponents() { - // Arrange - val testIntent = Intent() - val testResolveInfo1 = - ResolveInfo().apply { - userHandle = UserHandle(456) - activityInfo = ActivityInfo() - activityInfo.packageName = "DuplicatePackage" - activityInfo.name = "DuplicateClass" - } - val testResolveInfo2 = - ResolveInfo().apply { - userHandle = UserHandle(456) - activityInfo = ActivityInfo() - activityInfo.packageName = "DuplicatePackage" - activityInfo.name = "DuplicateClass" - } - val testResolvedComponentInfo1 = - ResolvedComponentInfo( - ComponentName("DuplicatePackage", "DuplicateClass"), - testIntent, - testResolveInfo1, - ) - .apply { isPinned = false } - val listUnderTest = mutableListOf(testResolvedComponentInfo1) - val listToAdd = listOf(testResolveInfo2) - - // Act - resolveListDeduper.addToResolveListWithDedupe( - into = listUnderTest, - intent = testIntent, - from = listToAdd, - ) - - // Assert - assertThat(listUnderTest).containsExactly(testResolvedComponentInfo1) - assertThat(testResolvedComponentInfo1.getResolveInfoAt(0)).isEqualTo(testResolveInfo1) - assertThat(testResolvedComponentInfo1.getResolveInfoAt(1)).isEqualTo(testResolveInfo2) - } -} diff --git a/tests/unit/src/com/android/intentresolver/v2/listcontroller/ResolvedComponentFilteringTest.kt b/tests/unit/src/com/android/intentresolver/v2/listcontroller/ResolvedComponentFilteringTest.kt deleted file mode 100644 index 9786b801..00000000 --- a/tests/unit/src/com/android/intentresolver/v2/listcontroller/ResolvedComponentFilteringTest.kt +++ /dev/null @@ -1,309 +0,0 @@ -/* - * Copyright (C) 2023 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.android.intentresolver.v2.listcontroller - -import android.content.ComponentName -import android.content.Intent -import android.content.pm.ActivityInfo -import android.content.pm.ApplicationInfo -import android.content.pm.PackageManager -import android.content.pm.ResolveInfo -import com.android.intentresolver.ResolvedComponentInfo -import com.google.common.truth.Truth.assertThat -import kotlinx.coroutines.test.runTest -import org.junit.Assert.assertThrows -import org.junit.Before -import org.junit.Test - -class ResolvedComponentFilteringTest { - - private lateinit var resolvedComponentFiltering: ResolvedComponentFiltering - - private val fakeFilterableComponents = - object : FilterableComponents { - override fun isComponentFiltered(name: ComponentName): Boolean { - return name.packageName == "FilteredPackage" - } - } - - private val fakePermissionChecker = - object : PermissionChecker { - override suspend fun checkComponentPermission( - permission: String, - uid: Int, - owningUid: Int, - exported: Boolean - ): Int { - return if (permission == "MissingPermission") { - PackageManager.PERMISSION_DENIED - } else { - PackageManager.PERMISSION_GRANTED - } - } - } - - @Before - fun setup() { - resolvedComponentFiltering = - ResolvedComponentFilteringImpl( - launchedFromUid = 123, - filterableComponents = fakeFilterableComponents, - permissionChecker = fakePermissionChecker, - ) - } - - @Test - fun filterIneligibleActivities_returnsListWithoutFilteredComponents() = runTest { - // Arrange - val testIntent = Intent("TestAction") - val testResolveInfo = - ResolveInfo().apply { - activityInfo = ActivityInfo() - activityInfo.packageName = "TestPackage" - activityInfo.name = "TestClass" - activityInfo.permission = "TestPermission" - activityInfo.applicationInfo = ApplicationInfo() - activityInfo.applicationInfo.uid = 456 - activityInfo.exported = false - } - val filteredResolveInfo = - ResolveInfo().apply { - activityInfo = ActivityInfo() - activityInfo.packageName = "FilteredPackage" - activityInfo.name = "FilteredClass" - activityInfo.permission = "TestPermission" - activityInfo.applicationInfo = ApplicationInfo() - activityInfo.applicationInfo.uid = 456 - activityInfo.exported = false - } - val missingPermissionResolveInfo = - ResolveInfo().apply { - activityInfo = ActivityInfo() - activityInfo.packageName = "NoPermissionPackage" - activityInfo.name = "NoPermissionClass" - activityInfo.permission = "MissingPermission" - activityInfo.applicationInfo = ApplicationInfo() - activityInfo.applicationInfo.uid = 456 - activityInfo.exported = false - } - val testInput = - listOf( - ResolvedComponentInfo( - ComponentName("TestPackage", "TestClass"), - testIntent, - testResolveInfo, - ), - ResolvedComponentInfo( - ComponentName("FilteredPackage", "FilteredClass"), - testIntent, - filteredResolveInfo, - ), - ResolvedComponentInfo( - ComponentName("NoPermissionPackage", "NoPermissionClass"), - testIntent, - missingPermissionResolveInfo, - ) - ) - - // Act - val result = resolvedComponentFiltering.filterIneligibleActivities(testInput) - - // Assert - assertThat(result).hasSize(1) - with(result.first()) { - assertThat(name.packageName).isEqualTo("TestPackage") - assertThat(name.className).isEqualTo("TestClass") - assertThat(getIntentAt(0)).isEqualTo(testIntent) - assertThrows(IndexOutOfBoundsException::class.java) { getIntentAt(1) } - assertThat(getResolveInfoAt(0)).isEqualTo(testResolveInfo) - assertThrows(IndexOutOfBoundsException::class.java) { getResolveInfoAt(1) } - } - } - - @Test - fun filterLowPriority_filtersAfterFirstDifferentPriority() { - // Arrange - val testIntent = Intent("TestAction") - val testResolveInfo = - ResolveInfo().apply { - priority = 1 - isDefault = true - } - val equalResolveInfo = - ResolveInfo().apply { - priority = 1 - isDefault = true - } - val diffResolveInfo = - ResolveInfo().apply { - priority = 2 - isDefault = true - } - val testInput = - listOf( - ResolvedComponentInfo( - ComponentName("TestPackage", "TestClass"), - testIntent, - testResolveInfo, - ), - ResolvedComponentInfo( - ComponentName("EqualPackage", "EqualClass"), - testIntent, - equalResolveInfo, - ), - ResolvedComponentInfo( - ComponentName("DiffPackage", "DiffClass"), - testIntent, - diffResolveInfo, - ), - ) - - // Act - val result = resolvedComponentFiltering.filterLowPriority(testInput) - - // Assert - assertThat(result).hasSize(2) - with(result.first()) { - assertThat(name.packageName).isEqualTo("TestPackage") - assertThat(name.className).isEqualTo("TestClass") - assertThat(getIntentAt(0)).isEqualTo(testIntent) - assertThrows(IndexOutOfBoundsException::class.java) { getIntentAt(1) } - assertThat(getResolveInfoAt(0)).isEqualTo(testResolveInfo) - assertThrows(IndexOutOfBoundsException::class.java) { getResolveInfoAt(1) } - } - with(result[1]) { - assertThat(name.packageName).isEqualTo("EqualPackage") - assertThat(name.className).isEqualTo("EqualClass") - assertThat(getIntentAt(0)).isEqualTo(testIntent) - assertThrows(IndexOutOfBoundsException::class.java) { getIntentAt(1) } - assertThat(getResolveInfoAt(0)).isEqualTo(equalResolveInfo) - assertThrows(IndexOutOfBoundsException::class.java) { getResolveInfoAt(1) } - } - } - - @Test - fun filterLowPriority_filtersAfterFirstDifferentDefault() { - // Arrange - val testIntent = Intent("TestAction") - val testResolveInfo = - ResolveInfo().apply { - priority = 1 - isDefault = true - } - val equalResolveInfo = - ResolveInfo().apply { - priority = 1 - isDefault = true - } - val diffResolveInfo = - ResolveInfo().apply { - priority = 1 - isDefault = false - } - val testInput = - listOf( - ResolvedComponentInfo( - ComponentName("TestPackage", "TestClass"), - testIntent, - testResolveInfo, - ), - ResolvedComponentInfo( - ComponentName("EqualPackage", "EqualClass"), - testIntent, - equalResolveInfo, - ), - ResolvedComponentInfo( - ComponentName("DiffPackage", "DiffClass"), - testIntent, - diffResolveInfo, - ), - ) - - // Act - val result = resolvedComponentFiltering.filterLowPriority(testInput) - - // Assert - assertThat(result).hasSize(2) - with(result.first()) { - assertThat(name.packageName).isEqualTo("TestPackage") - assertThat(name.className).isEqualTo("TestClass") - assertThat(getIntentAt(0)).isEqualTo(testIntent) - assertThrows(IndexOutOfBoundsException::class.java) { getIntentAt(1) } - assertThat(getResolveInfoAt(0)).isEqualTo(testResolveInfo) - assertThrows(IndexOutOfBoundsException::class.java) { getResolveInfoAt(1) } - } - with(result[1]) { - assertThat(name.packageName).isEqualTo("EqualPackage") - assertThat(name.className).isEqualTo("EqualClass") - assertThat(getIntentAt(0)).isEqualTo(testIntent) - assertThrows(IndexOutOfBoundsException::class.java) { getIntentAt(1) } - assertThat(getResolveInfoAt(0)).isEqualTo(equalResolveInfo) - assertThrows(IndexOutOfBoundsException::class.java) { getResolveInfoAt(1) } - } - } - - @Test - fun filterLowPriority_whenNoDifference_returnsOriginal() { - // Arrange - val testIntent = Intent("TestAction") - val testResolveInfo = - ResolveInfo().apply { - priority = 1 - isDefault = true - } - val equalResolveInfo = - ResolveInfo().apply { - priority = 1 - isDefault = true - } - val testInput = - listOf( - ResolvedComponentInfo( - ComponentName("TestPackage", "TestClass"), - testIntent, - testResolveInfo, - ), - ResolvedComponentInfo( - ComponentName("EqualPackage", "EqualClass"), - testIntent, - equalResolveInfo, - ), - ) - - // Act - val result = resolvedComponentFiltering.filterLowPriority(testInput) - - // Assert - assertThat(result).hasSize(2) - with(result.first()) { - assertThat(name.packageName).isEqualTo("TestPackage") - assertThat(name.className).isEqualTo("TestClass") - assertThat(getIntentAt(0)).isEqualTo(testIntent) - assertThrows(IndexOutOfBoundsException::class.java) { getIntentAt(1) } - assertThat(getResolveInfoAt(0)).isEqualTo(testResolveInfo) - assertThrows(IndexOutOfBoundsException::class.java) { getResolveInfoAt(1) } - } - with(result[1]) { - assertThat(name.packageName).isEqualTo("EqualPackage") - assertThat(name.className).isEqualTo("EqualClass") - assertThat(getIntentAt(0)).isEqualTo(testIntent) - assertThrows(IndexOutOfBoundsException::class.java) { getIntentAt(1) } - assertThat(getResolveInfoAt(0)).isEqualTo(equalResolveInfo) - assertThrows(IndexOutOfBoundsException::class.java) { getResolveInfoAt(1) } - } - } -} diff --git a/tests/unit/src/com/android/intentresolver/v2/listcontroller/ResolvedComponentSortingTest.kt b/tests/unit/src/com/android/intentresolver/v2/listcontroller/ResolvedComponentSortingTest.kt deleted file mode 100644 index 39b328ee..00000000 --- a/tests/unit/src/com/android/intentresolver/v2/listcontroller/ResolvedComponentSortingTest.kt +++ /dev/null @@ -1,197 +0,0 @@ -/* - * Copyright (C) 2023 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.android.intentresolver.v2.listcontroller - -import android.content.ComponentName -import android.content.Intent -import android.content.pm.ActivityInfo -import android.content.pm.ApplicationInfo -import android.content.pm.ResolveInfo -import android.os.UserHandle -import com.android.intentresolver.ResolvedComponentInfo -import com.android.intentresolver.chooser.DisplayResolveInfo -import com.android.intentresolver.chooser.TargetInfo -import com.google.common.truth.Truth.assertThat -import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.async -import kotlinx.coroutines.test.TestScope -import kotlinx.coroutines.test.UnconfinedTestDispatcher -import kotlinx.coroutines.test.runCurrent -import kotlinx.coroutines.test.runTest -import org.junit.Test -import org.mockito.Mockito - -@OptIn(ExperimentalCoroutinesApi::class) -class ResolvedComponentSortingTest { - - private val testDispatcher = UnconfinedTestDispatcher() - private val testScope = TestScope(testDispatcher) - - private val fakeResolverComparator = FakeResolverComparator() - - private val resolvedComponentSorting = - ResolvedComponentSortingImpl(testDispatcher, fakeResolverComparator) - - @Test - fun sorted_onNullList_returnsNull() = - testScope.runTest { - // Arrange - val testInput: List? = null - - // Act - val result = resolvedComponentSorting.sorted(testInput) - runCurrent() - - // Assert - assertThat(result).isNull() - } - - @Test - fun sorted_onEmptyList_returnsEmptyList() = - testScope.runTest { - // Arrange - val testInput = emptyList() - - // Act - val result = resolvedComponentSorting.sorted(testInput) - runCurrent() - - // Assert - assertThat(result).isEmpty() - } - - @Test - fun sorted_returnsListSortedByGivenComparator() = - testScope.runTest { - // Arrange - val testIntent = Intent("TestAction") - val testInput = - listOf( - ResolveInfo().apply { - activityInfo = ActivityInfo() - activityInfo.packageName = "TestPackage3" - activityInfo.name = "TestClass3" - }, - ResolveInfo().apply { - activityInfo = ActivityInfo() - activityInfo.packageName = "TestPackage1" - activityInfo.name = "TestClass1" - }, - ResolveInfo().apply { - activityInfo = ActivityInfo() - activityInfo.packageName = "TestPackage2" - activityInfo.name = "TestClass2" - }, - ) - .map { - it.targetUserId = UserHandle.USER_CURRENT - ResolvedComponentInfo( - ComponentName(it.activityInfo.packageName, it.activityInfo.name), - testIntent, - it, - ) - } - - // Act - val result = async { resolvedComponentSorting.sorted(testInput) } - runCurrent() - - // Assert - assertThat(result.await()?.map { it.name.packageName }) - .containsExactly("TestPackage1", "TestPackage2", "TestPackage3") - .inOrder() - } - - @Test - fun getScore_displayResolveInfo_returnsTheScoreAccordingToTheResolverComparator() { - // Arrange - val testTarget = - DisplayResolveInfo.newDisplayResolveInfo( - Intent(), - ResolveInfo().apply { - activityInfo = ActivityInfo() - activityInfo.name = "TestClass" - activityInfo.applicationInfo = ApplicationInfo() - activityInfo.applicationInfo.packageName = "TestPackage" - }, - Intent(), - ) - - // Act - val result = resolvedComponentSorting.getScore(testTarget) - - // Assert - assertThat(result).isEqualTo(1.23f) - } - - @Test - fun getScore_targetInfo_returnsTheScoreAccordingToTheResolverComparator() { - // Arrange - val mockTargetInfo = Mockito.mock(TargetInfo::class.java) - - // Act - val result = resolvedComponentSorting.getScore(mockTargetInfo) - - // Assert - assertThat(result).isEqualTo(1.23f) - } - - @Test - fun updateModel_updatesResolverComparatorModel() = - testScope.runTest { - // Arrange - val mockTargetInfo = Mockito.mock(TargetInfo::class.java) - assertThat(fakeResolverComparator.lastUpdateModel).isNull() - - // Act - resolvedComponentSorting.updateModel(mockTargetInfo) - runCurrent() - - // Assert - assertThat(fakeResolverComparator.lastUpdateModel).isSameInstanceAs(mockTargetInfo) - } - - @Test - fun updateChooserCounts_updatesResolverComparaterChooserCounts() = - testScope.runTest { - // Arrange - val testPackageName = "TestPackage" - val testUser = UserHandle(456) - val testAction = "TestAction" - assertThat(fakeResolverComparator.lastUpdateChooserCounts).isNull() - - // Act - resolvedComponentSorting.updateChooserCounts(testPackageName, testUser, testAction) - runCurrent() - - // Assert - assertThat(fakeResolverComparator.lastUpdateChooserCounts) - .isEqualTo(Triple(testPackageName, testUser, testAction)) - } - - @Test - fun destroy_destroysResolverComparator() { - // Arrange - assertThat(fakeResolverComparator.destroyCalled).isFalse() - - // Act - resolvedComponentSorting.destroy() - - // Assert - assertThat(fakeResolverComparator.destroyCalled).isTrue() - } -} diff --git a/tests/unit/src/com/android/intentresolver/v2/listcontroller/SharedPreferencesPinnedComponentsTest.kt b/tests/unit/src/com/android/intentresolver/v2/listcontroller/SharedPreferencesPinnedComponentsTest.kt deleted file mode 100644 index 9d6394fa..00000000 --- a/tests/unit/src/com/android/intentresolver/v2/listcontroller/SharedPreferencesPinnedComponentsTest.kt +++ /dev/null @@ -1,63 +0,0 @@ -/* - * Copyright (C) 2023 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.android.intentresolver.v2.listcontroller - -import android.content.ComponentName -import android.content.SharedPreferences -import com.android.intentresolver.any -import com.android.intentresolver.eq -import com.android.intentresolver.whenever -import com.google.common.truth.Truth -import org.junit.Before -import org.junit.Test -import org.mockito.Mock -import org.mockito.Mockito -import org.mockito.MockitoAnnotations - -class SharedPreferencesPinnedComponentsTest { - - @Mock lateinit var mockSharedPreferences: SharedPreferences - - private lateinit var sharedPreferencesPinnedComponents: SharedPreferencesPinnedComponents - - @Before - fun setup() { - MockitoAnnotations.initMocks(this) - - sharedPreferencesPinnedComponents = SharedPreferencesPinnedComponents(mockSharedPreferences) - } - - @Test - fun isComponentPinned_returnsSavedPinnedState() { - // Arrange - val testComponent = ComponentName("TestPackage", "TestClass") - val pinnedComponent = ComponentName("PinnedPackage", "PinnedClass") - whenever(mockSharedPreferences.getBoolean(eq(pinnedComponent.flattenToString()), any())) - .thenReturn(true) - - // Act - val result = sharedPreferencesPinnedComponents.isComponentPinned(testComponent) - val pinnedResult = sharedPreferencesPinnedComponents.isComponentPinned(pinnedComponent) - - // Assert - Mockito.verify(mockSharedPreferences).getBoolean(eq(testComponent.flattenToString()), any()) - Mockito.verify(mockSharedPreferences) - .getBoolean(eq(pinnedComponent.flattenToString()), any()) - Truth.assertThat(result).isFalse() - Truth.assertThat(pinnedResult).isTrue() - } -} diff --git a/tests/unit/src/com/android/intentresolver/v2/platform/FakeSecureSettingsTest.kt b/tests/unit/src/com/android/intentresolver/v2/platform/FakeSecureSettingsTest.kt deleted file mode 100644 index 04c7093d..00000000 --- a/tests/unit/src/com/android/intentresolver/v2/platform/FakeSecureSettingsTest.kt +++ /dev/null @@ -1,61 +0,0 @@ -package com.android.intentresolver.v2.platform - -import com.google.common.truth.Truth.assertThat - -class FakeSecureSettingsTest { - - private val secureSettings = fakeSecureSettings { - putInt(intKey, intVal) - putString(stringKey, stringVal) - putFloat(floatKey, floatVal) - putLong(longKey, longVal) - } - - fun testExpectedValues_returned() { - assertThat(secureSettings.getInt(intKey)).isEqualTo(intVal) - assertThat(secureSettings.getString(stringKey)).isEqualTo(stringVal) - assertThat(secureSettings.getFloat(floatKey)).isEqualTo(floatVal) - assertThat(secureSettings.getLong(longKey)).isEqualTo(longVal) - } - - fun testUndefinedValues_returnNull() { - assertThat(secureSettings.getInt("unknown")).isNull() - assertThat(secureSettings.getString("unknown")).isNull() - assertThat(secureSettings.getFloat("unknown")).isNull() - assertThat(secureSettings.getLong("unknown")).isNull() - } - - /** - * FakeSecureSettings models the real secure settings by storing values in String form. The - * value is returned if/when it can be parsed from the string value, otherwise null. - */ - fun testMismatchedTypes() { - assertThat(secureSettings.getString(intKey)).isEqualTo(intVal.toString()) - assertThat(secureSettings.getString(floatKey)).isEqualTo(floatVal.toString()) - assertThat(secureSettings.getString(longKey)).isEqualTo(longVal.toString()) - - assertThat(secureSettings.getInt(stringKey)).isNull() - assertThat(secureSettings.getLong(stringKey)).isNull() - assertThat(secureSettings.getFloat(stringKey)).isNull() - - assertThat(secureSettings.getInt(longKey)).isNull() - assertThat(secureSettings.getFloat(longKey)).isNull() // TODO: verify Long.MAX > Float.MAX ? - - assertThat(secureSettings.getLong(floatKey)).isNull() // TODO: or is Float.MAX > Long.MAX? - assertThat(secureSettings.getInt(floatKey)).isNull() - } - - companion object Data { - const val intKey = "int" - const val intVal = Int.MAX_VALUE - - const val stringKey = "string" - const val stringVal = "String" - - const val floatKey = "float" - const val floatVal = Float.MAX_VALUE - - const val longKey = "long" - const val longVal = Long.MAX_VALUE - } -} diff --git a/tests/unit/src/com/android/intentresolver/v2/platform/FakeUserManagerTest.kt b/tests/unit/src/com/android/intentresolver/v2/platform/FakeUserManagerTest.kt deleted file mode 100644 index a2239192..00000000 --- a/tests/unit/src/com/android/intentresolver/v2/platform/FakeUserManagerTest.kt +++ /dev/null @@ -1,128 +0,0 @@ -package com.android.intentresolver.v2.platform - -import android.content.pm.UserInfo -import android.content.pm.UserInfo.NO_PROFILE_GROUP_ID -import android.os.UserHandle -import android.os.UserManager -import com.android.intentresolver.v2.platform.FakeUserManager.ProfileType -import com.google.common.truth.Correspondence -import com.google.common.truth.Truth.assertThat -import com.google.common.truth.Truth.assertWithMessage -import org.junit.Assert.assertTrue -import org.junit.Test - -class FakeUserManagerTest { - private val userManager = FakeUserManager() - private val state = userManager.state - - @Test - fun initialState() { - val personal = userManager.getEnabledProfiles(state.primaryUserHandle.identifier).single() - - assertThat(personal.id).isEqualTo(state.primaryUserHandle.identifier) - assertThat(personal.userType).isEqualTo(UserManager.USER_TYPE_FULL_SYSTEM) - assertThat(personal.flags and UserInfo.FLAG_FULL).isEqualTo(UserInfo.FLAG_FULL) - } - - @Test - fun getProfileParent() { - val workHandle = state.createProfile(ProfileType.WORK) - - assertThat(userManager.getProfileParent(state.primaryUserHandle)).isNull() - assertThat(userManager.getProfileParent(workHandle)).isEqualTo(state.primaryUserHandle) - assertThat(userManager.getProfileParent(UserHandle.of(-1))).isNull() - } - - @Test - fun getUserInfo() { - val personalUser = - requireNotNull(userManager.getUserInfo(state.primaryUserHandle.identifier)) { - "Expected getUserInfo to return non-null" - } - assertTrue(userInfoAreEqual.apply(personalUser, state.getPrimaryUser())) - - val workHandle = state.createProfile(ProfileType.WORK) - - val workUser = - requireNotNull(userManager.getUserInfo(workHandle.identifier)) { - "Expected getUserInfo to return non-null" - } - assertTrue( - userInfoAreEqual.apply(workUser, userManager.getUserInfo(workHandle.identifier)!!) - ) - } - - @Test - fun getEnabledProfiles_usingParentId() { - val personal = state.primaryUserHandle - val work = state.createProfile(ProfileType.WORK) - val private = state.createProfile(ProfileType.PRIVATE) - - val enabledProfiles = userManager.getEnabledProfiles(personal.identifier) - - assertWithMessage("enabledProfiles: List") - .that(enabledProfiles) - .comparingElementsUsing(userInfoEquality) - .displayingDiffsPairedBy { it.id } - .containsExactly(state.getPrimaryUser(), state.getUser(work), state.getUser(private)) - } - - @Test - fun getEnabledProfiles_usingProfileId() { - val clone = state.createProfile(ProfileType.CLONE) - - val enabledProfiles = userManager.getEnabledProfiles(clone.identifier) - - assertWithMessage("getEnabledProfiles(clone.identifier)") - .that(enabledProfiles) - .comparingElementsUsing(userInfoEquality) - .displayingDiffsPairedBy { it.id } - .containsExactly(state.getPrimaryUser(), state.getUser(clone)) - } - - @Test - fun getUserOrNull() { - val personal = state.getPrimaryUser() - - assertThat(state.getUserOrNull(personal.userHandle)).isEqualTo(personal) - assertThat(state.getUserOrNull(UserHandle.of(personal.id - 1))).isNull() - } - - @Test - fun createProfile() { - // Order dependent: profile creation modifies the primary user - val workHandle = state.createProfile(ProfileType.WORK) - - val primaryUser = state.getPrimaryUser() - val workUser = state.getUser(workHandle) - - assertThat(primaryUser.profileGroupId).isNotEqualTo(NO_PROFILE_GROUP_ID) - assertThat(workUser.profileGroupId).isEqualTo(primaryUser.profileGroupId) - } - - @Test - fun removeProfile() { - val personal = state.getPrimaryUser() - val work = state.createProfile(ProfileType.WORK) - val private = state.createProfile(ProfileType.PRIVATE) - - state.removeProfile(private) - assertThat(state.userHandles).containsExactly(personal.userHandle, work) - } - - @Test(expected = IllegalArgumentException::class) - fun removeProfile_primaryNotAllowed() { - state.removeProfile(state.primaryUserHandle) - } -} - -private val userInfoAreEqual = - Correspondence.BinaryPredicate { actual, expected -> - actual.id == expected.id && - actual.profileGroupId == expected.profileGroupId && - actual.userType == expected.userType && - actual.flags == expected.flags - } - -val userInfoEquality: Correspondence = - Correspondence.from(userInfoAreEqual, "==") diff --git a/tests/unit/src/com/android/intentresolver/v2/platform/NearbyShareModuleTest.kt b/tests/unit/src/com/android/intentresolver/v2/platform/NearbyShareModuleTest.kt deleted file mode 100644 index fd5c8b3f..00000000 --- a/tests/unit/src/com/android/intentresolver/v2/platform/NearbyShareModuleTest.kt +++ /dev/null @@ -1,83 +0,0 @@ -package com.android.intentresolver.v2.platform - -import android.content.ComponentName -import android.content.Context -import android.content.res.Configuration -import android.provider.Settings -import android.testing.TestableResources - -import androidx.test.platform.app.InstrumentationRegistry - -import com.android.intentresolver.R - -import com.google.common.truth.Truth8.assertThat - -import org.junit.Before -import org.junit.Test - -class NearbyShareModuleTest { - - lateinit var context: Context - - /** Create Resources with overridden values. */ - private fun Context.fakeResources( - config: Configuration? = null, - block: TestableResources.() -> Unit - ) = - TestableResources(resources) - .apply { config?.let { overrideConfiguration(it) } } - .apply(block) - .resources - - @Before - fun setup() { - val instr = InstrumentationRegistry.getInstrumentation() - context = instr.context - } - - @Test - fun valueIsAbsent_whenUnset() { - val secureSettings = fakeSecureSettings {} - val resources = - context.fakeResources { addOverride(R.string.config_defaultNearbySharingComponent, "") } - - val componentName = NearbyShareModule.nearbyShareComponent(resources, secureSettings) - assertThat(componentName).isEmpty() - } - - @Test - fun defaultValue_readFromResources() { - val secureSettings = fakeSecureSettings {} - val resources = - context.fakeResources { - addOverride( - R.string.config_defaultNearbySharingComponent, - "com.example/.ComponentName" - ) - } - - val nearbyShareComponent = NearbyShareModule.nearbyShareComponent(resources, secureSettings) - - assertThat(nearbyShareComponent).hasValue( - ComponentName.unflattenFromString("com.example/.ComponentName")) - } - - @Test - fun secureSettings_overridesDefault() { - val secureSettings = fakeSecureSettings { - putString(Settings.Secure.NEARBY_SHARING_COMPONENT, "com.example/.BComponent") - } - val resources = - context.fakeResources { - addOverride( - R.string.config_defaultNearbySharingComponent, - "com.example/.AComponent" - ) - } - - val nearbyShareComponent = NearbyShareModule.nearbyShareComponent(resources, secureSettings) - - assertThat(nearbyShareComponent).hasValue( - ComponentName.unflattenFromString("com.example/.BComponent")) - } -} diff --git a/tests/unit/src/com/android/intentresolver/v2/profiles/MultiProfilePagerAdapterTest.kt b/tests/unit/src/com/android/intentresolver/v2/profiles/MultiProfilePagerAdapterTest.kt deleted file mode 100644 index 5b6b5d99..00000000 --- a/tests/unit/src/com/android/intentresolver/v2/profiles/MultiProfilePagerAdapterTest.kt +++ /dev/null @@ -1,342 +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.v2.profiles - -import android.os.UserHandle -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import android.widget.ListView -import androidx.test.platform.app.InstrumentationRegistry -import com.android.intentresolver.MultiProfilePagerAdapter.PROFILE_PERSONAL -import com.android.intentresolver.MultiProfilePagerAdapter.PROFILE_WORK -import com.android.intentresolver.R -import com.android.intentresolver.ResolverListAdapter -import com.android.intentresolver.emptystate.EmptyStateProvider -import com.android.intentresolver.mock -import com.android.intentresolver.whenever -import com.google.common.collect.ImmutableList -import com.google.common.truth.Truth.assertThat -import java.util.Optional -import java.util.function.Supplier -import org.junit.Test - -class MultiProfilePagerAdapterTest { - private val PERSONAL_USER_HANDLE = UserHandle.of(10) - private val WORK_USER_HANDLE = UserHandle.of(20) - - private val context = InstrumentationRegistry.getInstrumentation().getContext() - private val inflater = Supplier { - LayoutInflater.from(context).inflate(R.layout.resolver_list_per_profile, null, false) - as ViewGroup - } - - @Test - fun testSinglePageProfileAdapter() { - val personalListAdapter = - mock { whenever(getUserHandle()).thenReturn(PERSONAL_USER_HANDLE) } - val pagerAdapter = - MultiProfilePagerAdapter( - { listAdapter: ResolverListAdapter -> listAdapter }, - { listView: ListView, bindAdapter: ResolverListAdapter -> - listView.setAdapter(bindAdapter) - }, - ImmutableList.of( - TabConfig( - PROFILE_PERSONAL, - "personal", - "personal_a11y", - "TAG_PERSONAL", - personalListAdapter - ) - ), - object : EmptyStateProvider {}, - { false }, - PROFILE_PERSONAL, - null, - null, - inflater, - { Optional.empty() } - ) - assertThat(pagerAdapter.count).isEqualTo(1) - assertThat(pagerAdapter.currentPage).isEqualTo(PROFILE_PERSONAL) - assertThat(pagerAdapter.currentUserHandle).isEqualTo(PERSONAL_USER_HANDLE) - assertThat(pagerAdapter.getPageAdapterForIndex(0)).isSameInstanceAs(personalListAdapter) - assertThat(pagerAdapter.activeListAdapter).isSameInstanceAs(personalListAdapter) - assertThat(pagerAdapter.personalListAdapter).isSameInstanceAs(personalListAdapter) - assertThat(pagerAdapter.workListAdapter).isNull() - assertThat(pagerAdapter.itemCount).isEqualTo(1) - // TODO: consider covering some of the package-private methods (and making them public?). - // TODO: consider exercising responsibilities as an implementation of a ViewPager adapter. - } - - @Test - fun testTwoProfilePagerAdapter() { - val personalListAdapter = - mock { whenever(getUserHandle()).thenReturn(PERSONAL_USER_HANDLE) } - val workListAdapter = - mock { whenever(getUserHandle()).thenReturn(WORK_USER_HANDLE) } - val pagerAdapter = - MultiProfilePagerAdapter( - { listAdapter: ResolverListAdapter -> listAdapter }, - { listView: ListView, bindAdapter: ResolverListAdapter -> - listView.setAdapter(bindAdapter) - }, - ImmutableList.of( - TabConfig( - PROFILE_PERSONAL, - "personal", - "personal_a11y", - "TAG_PERSONAL", - personalListAdapter - ), - TabConfig(PROFILE_WORK, "work", "work_a11y", "TAG_WORK", workListAdapter) - ), - object : EmptyStateProvider {}, - { false }, - PROFILE_PERSONAL, - WORK_USER_HANDLE, // TODO: why does this test pass even if this is null? - null, - inflater, - { Optional.empty() } - ) - assertThat(pagerAdapter.count).isEqualTo(2) - assertThat(pagerAdapter.currentPage).isEqualTo(PROFILE_PERSONAL) - assertThat(pagerAdapter.currentUserHandle).isEqualTo(PERSONAL_USER_HANDLE) - assertThat(pagerAdapter.getPageAdapterForIndex(0)).isSameInstanceAs(personalListAdapter) - assertThat(pagerAdapter.getPageAdapterForIndex(1)).isSameInstanceAs(workListAdapter) - assertThat(pagerAdapter.activeListAdapter).isSameInstanceAs(personalListAdapter) - assertThat(pagerAdapter.personalListAdapter).isSameInstanceAs(personalListAdapter) - assertThat(pagerAdapter.workListAdapter).isSameInstanceAs(workListAdapter) - assertThat(pagerAdapter.itemCount).isEqualTo(2) - // TODO: consider covering some of the package-private methods (and making them public?). - // TODO: consider exercising responsibilities as an implementation of a ViewPager adapter; - // especially matching profiles to ListViews? - // TODO: test ProfileSelectedListener (and getters for "current" state) as the selected - // page changes. Currently there's no API to change the selected page directly; that's - // only possible through manipulation of the bound ViewPager. - } - - @Test - fun testTwoProfilePagerAdapter_workIsDefault() { - val personalListAdapter = - mock { whenever(getUserHandle()).thenReturn(PERSONAL_USER_HANDLE) } - val workListAdapter = - mock { whenever(getUserHandle()).thenReturn(WORK_USER_HANDLE) } - val pagerAdapter = - MultiProfilePagerAdapter( - { listAdapter: ResolverListAdapter -> listAdapter }, - { listView: ListView, bindAdapter: ResolverListAdapter -> - listView.setAdapter(bindAdapter) - }, - ImmutableList.of( - TabConfig( - PROFILE_PERSONAL, - "personal", - "personal_a11y", - "TAG_PERSONAL", - personalListAdapter - ), - TabConfig(PROFILE_WORK, "work", "work_a11y", "TAG_WORK", workListAdapter) - ), - object : EmptyStateProvider {}, - { false }, - PROFILE_WORK, // <-- This test specifically requests we start on work profile. - WORK_USER_HANDLE, // TODO: why does this test pass even if this is null? - null, - inflater, - { Optional.empty() } - ) - assertThat(pagerAdapter.count).isEqualTo(2) - assertThat(pagerAdapter.currentPage).isEqualTo(PROFILE_WORK) - assertThat(pagerAdapter.currentUserHandle).isEqualTo(WORK_USER_HANDLE) - assertThat(pagerAdapter.getPageAdapterForIndex(0)).isSameInstanceAs(personalListAdapter) - assertThat(pagerAdapter.getPageAdapterForIndex(1)).isSameInstanceAs(workListAdapter) - assertThat(pagerAdapter.activeListAdapter).isSameInstanceAs(workListAdapter) - assertThat(pagerAdapter.personalListAdapter).isSameInstanceAs(personalListAdapter) - assertThat(pagerAdapter.workListAdapter).isSameInstanceAs(workListAdapter) - assertThat(pagerAdapter.itemCount).isEqualTo(2) - // TODO: consider covering some of the package-private methods (and making them public?). - // TODO: test ProfileSelectedListener (and getters for "current" state) as the selected - // page changes. Currently there's no API to change the selected page directly; that's - // only possible through manipulation of the bound ViewPager. - } - - @Test - fun testBottomPaddingDelegate_default() { - val personalListAdapter = - mock { whenever(getUserHandle()).thenReturn(PERSONAL_USER_HANDLE) } - val pagerAdapter = - MultiProfilePagerAdapter( - { listAdapter: ResolverListAdapter -> listAdapter }, - { listView: ListView, bindAdapter: ResolverListAdapter -> - listView.setAdapter(bindAdapter) - }, - ImmutableList.of( - TabConfig( - PROFILE_PERSONAL, - "personal", - "personal_a11y", - "TAG_PERSONAL", - personalListAdapter - ) - ), - object : EmptyStateProvider {}, - { false }, - PROFILE_PERSONAL, - null, - null, - inflater, - { Optional.empty() } - ) - val container = - pagerAdapter - .getActiveEmptyStateView() - .requireViewById(com.android.internal.R.id.resolver_empty_state_container) - container.setPadding(1, 2, 3, 4) - pagerAdapter.setupContainerPadding() - assertThat(container.paddingLeft).isEqualTo(1) - assertThat(container.paddingTop).isEqualTo(2) - assertThat(container.paddingRight).isEqualTo(3) - assertThat(container.paddingBottom).isEqualTo(4) - } - - @Test - fun testBottomPaddingDelegate_override() { - val personalListAdapter = - mock { whenever(getUserHandle()).thenReturn(PERSONAL_USER_HANDLE) } - val pagerAdapter = - MultiProfilePagerAdapter( - { listAdapter: ResolverListAdapter -> listAdapter }, - { listView: ListView, bindAdapter: ResolverListAdapter -> - listView.setAdapter(bindAdapter) - }, - ImmutableList.of( - TabConfig( - PROFILE_PERSONAL, - "personal", - "personal_a11y", - "TAG_PERSONAL", - personalListAdapter - ) - ), - object : EmptyStateProvider {}, - { false }, - PROFILE_PERSONAL, - null, - null, - inflater, - { Optional.of(42) } - ) - val container = - pagerAdapter - .getActiveEmptyStateView() - .requireViewById(com.android.internal.R.id.resolver_empty_state_container) - container.setPadding(1, 2, 3, 4) - pagerAdapter.setupContainerPadding() - assertThat(container.paddingLeft).isEqualTo(1) - assertThat(container.paddingTop).isEqualTo(2) - assertThat(container.paddingRight).isEqualTo(3) - assertThat(container.paddingBottom).isEqualTo(42) - } - - @Test - fun testPresumedQuietModeEmptyStateForWorkProfile_whenQuiet() { - // TODO: this is "presumed" because the conditions to determine whether we "should" show an - // empty state aren't enforced to align with the conditions when we actually *would* -- I - // believe `shouldShowEmptyStateScreen` should be implemented in terms of the provider? - val personalListAdapter = - mock { - whenever(getUserHandle()).thenReturn(PERSONAL_USER_HANDLE) - whenever(getUnfilteredCount()).thenReturn(1) - } - val workListAdapter = - mock { - whenever(getUserHandle()).thenReturn(WORK_USER_HANDLE) - whenever(getUnfilteredCount()).thenReturn(1) - } - val pagerAdapter = - MultiProfilePagerAdapter( - { listAdapter: ResolverListAdapter -> listAdapter }, - { listView: ListView, bindAdapter: ResolverListAdapter -> - listView.setAdapter(bindAdapter) - }, - ImmutableList.of( - TabConfig( - PROFILE_PERSONAL, - "personal", - "personal_a11y", - "TAG_PERSONAL", - personalListAdapter - ), - TabConfig(PROFILE_WORK, "work", "work_a11y", "TAG_WORK", workListAdapter) - ), - object : EmptyStateProvider {}, - { true }, // <-- Work mode is quiet. - PROFILE_WORK, - WORK_USER_HANDLE, - null, - inflater, - { Optional.empty() } - ) - assertThat(pagerAdapter.shouldShowEmptyStateScreen(workListAdapter)).isTrue() - assertThat(pagerAdapter.shouldShowEmptyStateScreen(personalListAdapter)).isFalse() - } - - @Test - fun testPresumedQuietModeEmptyStateForWorkProfile_notWhenNotQuiet() { - // TODO: this is "presumed" because the conditions to determine whether we "should" show an - // empty state aren't enforced to align with the conditions when we actually *would* -- I - // believe `shouldShowEmptyStateScreen` should be implemented in terms of the provider? - val personalListAdapter = - mock { - whenever(getUserHandle()).thenReturn(PERSONAL_USER_HANDLE) - whenever(getUnfilteredCount()).thenReturn(1) - } - val workListAdapter = - mock { - whenever(getUserHandle()).thenReturn(WORK_USER_HANDLE) - whenever(getUnfilteredCount()).thenReturn(1) - } - val pagerAdapter = - MultiProfilePagerAdapter( - { listAdapter: ResolverListAdapter -> listAdapter }, - { listView: ListView, bindAdapter: ResolverListAdapter -> - listView.setAdapter(bindAdapter) - }, - ImmutableList.of( - TabConfig( - PROFILE_PERSONAL, - "personal", - "personal_a11y", - "TAG_PERSONAL", - personalListAdapter - ), - TabConfig(PROFILE_WORK, "work", "work_a11y", "TAG_WORK", workListAdapter) - ), - object : EmptyStateProvider {}, - { false }, // <-- Work mode is not quiet. - PROFILE_WORK, - WORK_USER_HANDLE, - null, - inflater, - { Optional.empty() } - ) - assertThat(pagerAdapter.shouldShowEmptyStateScreen(workListAdapter)).isFalse() - assertThat(pagerAdapter.shouldShowEmptyStateScreen(personalListAdapter)).isFalse() - } -} diff --git a/tests/unit/src/com/android/intentresolver/v2/ui/ShareResultSenderImplTest.kt b/tests/unit/src/com/android/intentresolver/v2/ui/ShareResultSenderImplTest.kt deleted file mode 100644 index d894cad5..00000000 --- a/tests/unit/src/com/android/intentresolver/v2/ui/ShareResultSenderImplTest.kt +++ /dev/null @@ -1,190 +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.v2.ui - -import android.app.PendingIntent -import android.compat.testing.PlatformCompatChangeRule -import android.content.ComponentName -import android.content.Intent -import android.os.Process -import android.service.chooser.ChooserResult -import android.service.chooser.Flags -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 -import com.google.common.truth.Truth.assertWithMessage -import kotlinx.coroutines.CompletableDeferred -import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.test.UnconfinedTestDispatcher -import kotlinx.coroutines.test.runCurrent -import kotlinx.coroutines.test.runTest -import libcore.junit.util.compat.CoreCompatChangeRule.DisableCompatChanges -import libcore.junit.util.compat.CoreCompatChangeRule.EnableCompatChanges -import org.junit.Rule -import org.junit.Test -import org.junit.rules.TestRule - -@OptIn(ExperimentalCoroutinesApi::class) -class ShareResultSenderImplTest { - - private val context = InstrumentationRegistry.getInstrumentation().context - - @get:Rule val compatChangeRule: TestRule = PlatformCompatChangeRule() - - val flags = FakeChooserServiceFlags() - - @OptIn(ExperimentalCoroutinesApi::class) - @EnableCompatChanges(ChooserResult.SEND_CHOOSER_RESULT) - @Test - fun onComponentSelected_chooserResultEnabled() = runTest { - val pi = PendingIntent.getBroadcast(context, 0, Intent(), PendingIntent.FLAG_IMMUTABLE) - val deferred = CompletableDeferred() - val intentDispatcher = IntentSenderDispatcher { _, intent -> deferred.complete(intent) } - - flags.setFlag(Flags.FLAG_ENABLE_CHOOSER_RESULT, true) - - val resultSender = - ShareResultSenderImpl( - flags = flags, - scope = this, - backgroundDispatcher = UnconfinedTestDispatcher(testScheduler), - callerUid = Process.myUid(), - resultSender = pi.intentSender, - intentDispatcher = intentDispatcher - ) - - resultSender.onComponentSelected(ComponentName("example.com", "Foo"), true) - runCurrent() - - val intentReceived = deferred.await() - val chooserResult = - intentReceived.getParcelableExtra( - Intent.EXTRA_CHOOSER_RESULT, - ChooserResult::class.java - ) - assertThat(chooserResult).isNotNull() - assertThat(chooserResult?.type).isEqualTo(ChooserResult.CHOOSER_RESULT_SELECTED_COMPONENT) - assertThat(chooserResult?.selectedComponent).isEqualTo(ComponentName("example.com", "Foo")) - assertThat(chooserResult?.isShortcut).isTrue() - } - - @DisableCompatChanges(ChooserResult.SEND_CHOOSER_RESULT) - @Test - fun onComponentSelected_chooserResultDisabled() = runTest { - val pi = PendingIntent.getBroadcast(context, 0, Intent(), PendingIntent.FLAG_IMMUTABLE) - val deferred = CompletableDeferred() - val intentDispatcher = IntentSenderDispatcher { _, intent -> deferred.complete(intent) } - - flags.setFlag(Flags.FLAG_ENABLE_CHOOSER_RESULT, true) - - val resultSender = - ShareResultSenderImpl( - flags = flags, - scope = this, - backgroundDispatcher = UnconfinedTestDispatcher(testScheduler), - callerUid = Process.myUid(), - resultSender = pi.intentSender, - intentDispatcher = intentDispatcher - ) - - resultSender.onComponentSelected(ComponentName("example.com", "Foo"), true) - runCurrent() - - val intentReceived = deferred.await() - val componentName = - intentReceived.getParcelableExtra( - Intent.EXTRA_CHOSEN_COMPONENT, - ComponentName::class.java - ) - - assertWithMessage("EXTRA_CHOSEN_COMPONENT from received intent") - .that(componentName) - .isEqualTo(ComponentName("example.com", "Foo")) - - assertWithMessage("received intent has EXTRA_CHOOSER_RESULT") - .that(intentReceived.hasExtra(Intent.EXTRA_CHOOSER_RESULT)) - .isFalse() - } - - @EnableCompatChanges(ChooserResult.SEND_CHOOSER_RESULT) - @Test - fun onActionSelected_chooserResultEnabled() = runTest { - val pi = PendingIntent.getBroadcast(context, 0, Intent(), PendingIntent.FLAG_IMMUTABLE) - val deferred = CompletableDeferred() - val intentDispatcher = IntentSenderDispatcher { _, intent -> deferred.complete(intent) } - - flags.setFlag(Flags.FLAG_ENABLE_CHOOSER_RESULT, true) - - val resultSender = - ShareResultSenderImpl( - flags = flags, - scope = this, - backgroundDispatcher = UnconfinedTestDispatcher(testScheduler), - callerUid = Process.myUid(), - resultSender = pi.intentSender, - intentDispatcher = intentDispatcher - ) - - resultSender.onActionSelected(ShareAction.SYSTEM_COPY) - runCurrent() - - val intentReceived = deferred.await() - val chosenComponent = - intentReceived.getParcelableExtra( - Intent.EXTRA_CHOSEN_COMPONENT, - ChooserResult::class.java - ) - assertThat(chosenComponent).isNull() - - val chooserResult = - intentReceived.getParcelableExtra( - Intent.EXTRA_CHOOSER_RESULT, - ChooserResult::class.java - ) - assertThat(chooserResult).isNotNull() - assertThat(chooserResult?.type).isEqualTo(ChooserResult.CHOOSER_RESULT_COPY) - assertThat(chooserResult?.selectedComponent).isNull() - assertThat(chooserResult?.isShortcut).isFalse() - } - - @DisableCompatChanges(ChooserResult.SEND_CHOOSER_RESULT) - @Test - fun onActionSelected_chooserResultDisabled() = runTest { - val pi = PendingIntent.getBroadcast(context, 0, Intent(), PendingIntent.FLAG_IMMUTABLE) - val deferred = CompletableDeferred() - val intentDispatcher = IntentSenderDispatcher { _, intent -> deferred.complete(intent) } - - flags.setFlag(Flags.FLAG_ENABLE_CHOOSER_RESULT, true) - - val resultSender = - ShareResultSenderImpl( - flags = flags, - scope = this, - backgroundDispatcher = UnconfinedTestDispatcher(testScheduler), - callerUid = Process.myUid(), - resultSender = pi.intentSender, - intentDispatcher = intentDispatcher - ) - - resultSender.onActionSelected(ShareAction.SYSTEM_COPY) - runCurrent() - - // No result should have been sent, this should never complete - assertWithMessage("deferred result isComplete").that(deferred.isCompleted).isFalse() - } -} diff --git a/tests/unit/src/com/android/intentresolver/v2/ui/model/ActivityModelTest.kt b/tests/unit/src/com/android/intentresolver/v2/ui/model/ActivityModelTest.kt deleted file mode 100644 index 049fa001..00000000 --- a/tests/unit/src/com/android/intentresolver/v2/ui/model/ActivityModelTest.kt +++ /dev/null @@ -1,107 +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.v2.ui.model - -import android.content.Intent -import android.content.Intent.ACTION_CHOOSER -import android.content.Intent.EXTRA_TEXT -import android.net.Uri -import com.android.intentresolver.v2.ext.toParcelAndBack -import com.google.common.truth.Truth.assertThat -import com.google.common.truth.Truth.assertWithMessage -import org.junit.Test - -class ActivityModelTest { - - @Test - fun testDefaultValues() { - val input = ActivityModel(Intent(ACTION_CHOOSER), 0, "example.com", null) - - val output = input.toParcelAndBack() - - assertEquals(input, output) - } - - @Test - fun testCommonValues() { - val intent = Intent(ACTION_CHOOSER).apply { putExtra(EXTRA_TEXT, "Test") } - val input = - ActivityModel(intent, 1234, "com.example", Uri.parse("android-app://example.com")) - - val output = input.toParcelAndBack() - - assertEquals(input, output) - } - - @Test - fun testReferrerPackage_withAppReferrer_usesReferrer() { - val launch1 = - ActivityModel( - intent = Intent(), - launchedFromUid = 1000, - launchedFromPackage = "other.example.com", - referrer = Uri.parse("android-app://app.example.com") - ) - - assertThat(launch1.referrerPackage).isEqualTo("app.example.com") - } - - @Test - fun testReferrerPackage_httpReferrer_isNull() { - val launch = - ActivityModel( - intent = Intent(), - launchedFromUid = 1000, - launchedFromPackage = "example.com", - referrer = Uri.parse("http://some.other.value") - ) - - assertThat(launch.referrerPackage).isNull() - } - - @Test - fun testReferrerPackage_nullReferrer_isNull() { - val launch = - ActivityModel( - intent = Intent(), - launchedFromUid = 1000, - launchedFromPackage = "example.com", - referrer = null - ) - - assertThat(launch.referrerPackage).isNull() - } - - private fun assertEquals(expected: ActivityModel, actual: ActivityModel) { - // Test fields separately: Intent does not override equals() - assertWithMessage("%s.filterEquals(%s)", actual.intent, expected.intent) - .that(actual.intent.filterEquals(expected.intent)) - .isTrue() - - assertWithMessage("actual fromUid is equal to expected") - .that(actual.launchedFromUid) - .isEqualTo(expected.launchedFromUid) - - assertWithMessage("actual fromPackage is equal to expected") - .that(actual.launchedFromPackage) - .isEqualTo(expected.launchedFromPackage) - - assertWithMessage("actual referrer is equal to expected") - .that(actual.referrer) - .isEqualTo(expected.referrer) - } -} diff --git a/tests/unit/src/com/android/intentresolver/v2/ui/viewmodel/ChooserRequestTest.kt b/tests/unit/src/com/android/intentresolver/v2/ui/viewmodel/ChooserRequestTest.kt deleted file mode 100644 index 987d55fc..00000000 --- a/tests/unit/src/com/android/intentresolver/v2/ui/viewmodel/ChooserRequestTest.kt +++ /dev/null @@ -1,297 +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.v2.ui.viewmodel - -import android.content.Intent -import android.content.Intent.ACTION_CHOOSER -import android.content.Intent.ACTION_SEND -import android.content.Intent.ACTION_SEND_MULTIPLE -import android.content.Intent.ACTION_VIEW -import android.content.Intent.EXTRA_ALTERNATE_INTENTS -import android.content.Intent.EXTRA_CHOOSER_ADDITIONAL_CONTENT_URI -import android.content.Intent.EXTRA_CHOOSER_FOCUSED_ITEM_POSITION -import android.content.Intent.EXTRA_INTENT -import android.content.Intent.EXTRA_REFERRER -import android.net.Uri -import android.service.chooser.Flags -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.validation.Importance -import com.android.intentresolver.v2.validation.Invalid -import com.android.intentresolver.v2.validation.NoValue -import com.android.intentresolver.v2.validation.Valid -import com.google.common.truth.Truth.assertThat -import org.junit.Test - -private fun createActivityModel( - targetIntent: Intent?, - referrer: Uri? = null, - additionalIntents: List? = null -) = - ActivityModel( - Intent(ACTION_CHOOSER).apply { - targetIntent?.also { putExtra(EXTRA_INTENT, it) } - additionalIntents?.also { putExtra(EXTRA_ALTERNATE_INTENTS, it.toTypedArray()) } - }, - launchedFromUid = 10000, - launchedFromPackage = "com.android.example", - referrer = referrer ?: "android-app://com.android.example".toUri() - ) - -class ChooserRequestTest { - - private val fakeChooserServiceFlags = - 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 missingIntent() { - val model = createActivityModel(targetIntent = null) - val result = readChooserRequest(model, fakeChooserServiceFlags) - - assertThat(result).isInstanceOf(Invalid::class.java) - result as Invalid - - assertThat(result.errors) - .containsExactly(NoValue(EXTRA_INTENT, Importance.CRITICAL, Intent::class)) - } - - @Test - fun referrerFillIn() { - val referrer = Uri.parse("android-app://example.com") - val model = createActivityModel(targetIntent = Intent(ACTION_SEND), referrer) - model.intent.putExtras(bundleOf(EXTRA_REFERRER to referrer)) - - val result = readChooserRequest(model, fakeChooserServiceFlags) - - assertThat(result).isInstanceOf(Valid::class.java) - result as Valid - - val fillIn = result.value.getReferrerFillInIntent() - assertThat(fillIn.hasExtra(EXTRA_REFERRER)).isTrue() - assertThat(fillIn.getParcelableExtra(EXTRA_REFERRER, Uri::class.java)).isEqualTo(referrer) - } - - @Test - fun referrerPackage_isNullWithNonAppReferrer() { - val referrer = Uri.parse("http://example.com") - val intent = Intent().putExtras(bundleOf(EXTRA_INTENT to Intent(ACTION_SEND))) - - val model = createActivityModel(targetIntent = intent, referrer = referrer) - - val result = readChooserRequest(model, fakeChooserServiceFlags) - - assertThat(result).isInstanceOf(Valid::class.java) - result as Valid - - assertThat(result.value.referrerPackage).isNull() - } - - @Test - fun referrerPackage_fromAppReferrer() { - val referrer = Uri.parse("android-app://example.com") - val model = createActivityModel(targetIntent = Intent(ACTION_SEND), referrer) - - model.intent.putExtras(bundleOf(EXTRA_REFERRER to referrer)) - - val result = readChooserRequest(model, fakeChooserServiceFlags) - - assertThat(result).isInstanceOf(Valid::class.java) - result as Valid - - assertThat(result.value.referrerPackage).isEqualTo(referrer.authority) - } - - @Test - fun payloadIntents_includesTargetThenAdditional() { - val intent1 = Intent(ACTION_SEND) - val intent2 = Intent(ACTION_SEND_MULTIPLE) - val model = createActivityModel(targetIntent = intent1, additionalIntents = listOf(intent2)) - - val result = readChooserRequest(model, fakeChooserServiceFlags) - - assertThat(result).isInstanceOf(Valid::class.java) - result as Valid - - assertThat(result.value.payloadIntents).containsExactly(intent1, intent2) - } - - @Test - fun testRequest_withOnlyRequiredValues() { - val intent = Intent().putExtras(bundleOf(EXTRA_INTENT to Intent(ACTION_SEND))) - val model = createActivityModel(targetIntent = intent) - - val result = readChooserRequest(model, fakeChooserServiceFlags) - - assertThat(result).isInstanceOf(Valid::class.java) - result as Valid - - assertThat(result.value.launchedFromPackage).isEqualTo(model.launchedFromPackage) - } - - @Test - fun testRequest_actionSendWithAdditionalContentUri() { - 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_SEND)).apply { - intent.putExtra(EXTRA_CHOOSER_ADDITIONAL_CONTENT_URI, uri) - intent.putExtra(EXTRA_CHOOSER_FOCUSED_ITEM_POSITION, position) - } - - val result = readChooserRequest(model, fakeChooserServiceFlags) - - assertThat(result).isInstanceOf(Valid::class.java) - result as Valid - - assertThat(result.value.additionalContentUri).isEqualTo(uri) - assertThat(result.value.focusedItemPosition).isEqualTo(position) - } - - @Test - fun testRequest_actionSendWithAdditionalContentUri_parametersIgnoredWhenFlagDisabled() { - fakeChooserServiceFlags.setFlag(Flags.FLAG_CHOOSER_PAYLOAD_TOGGLING, false) - val uri = Uri.parse("content://org.pkg/path") - val position = 10 - val model = - createActivityModel(targetIntent = Intent(ACTION_SEND)).apply { - intent.putExtra(EXTRA_CHOOSER_ADDITIONAL_CONTENT_URI, uri) - intent.putExtra(EXTRA_CHOOSER_FOCUSED_ITEM_POSITION, position) - } - val result = readChooserRequest(model, fakeChooserServiceFlags) - - assertThat(result).isInstanceOf(Valid::class.java) - result as Valid - - assertThat(result.value.additionalContentUri).isNull() - assertThat(result.value.focusedItemPosition).isEqualTo(0) - assertThat(result.warnings).isEmpty() - } - - @Test - fun testRequest_actionSendWithInvalidAdditionalContentUri() { - fakeChooserServiceFlags.setFlag(Flags.FLAG_CHOOSER_PAYLOAD_TOGGLING, true) - val model = - createActivityModel(targetIntent = Intent(ACTION_SEND)).apply { - intent.putExtra(EXTRA_CHOOSER_ADDITIONAL_CONTENT_URI, "__invalid__") - intent.putExtra(EXTRA_CHOOSER_FOCUSED_ITEM_POSITION, "__invalid__") - } - - val result = readChooserRequest(model, fakeChooserServiceFlags) - - assertThat(result).isInstanceOf(Valid::class.java) - result as Valid - - assertThat(result.value.additionalContentUri).isNull() - assertThat(result.value.focusedItemPosition).isEqualTo(0) - } - - @Test - fun testRequest_actionSendWithoutAdditionalContentUri() { - fakeChooserServiceFlags.setFlag(Flags.FLAG_CHOOSER_PAYLOAD_TOGGLING, true) - val model = createActivityModel(targetIntent = Intent(ACTION_SEND)) - - val result = readChooserRequest(model, fakeChooserServiceFlags) - - assertThat(result).isInstanceOf(Valid::class.java) - result as Valid - - assertThat(result.value.additionalContentUri).isNull() - assertThat(result.value.focusedItemPosition).isEqualTo(0) - } - - @Test - fun testRequest_actionViewWithAdditionalContentUri() { - 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 { - intent.putExtra(EXTRA_CHOOSER_ADDITIONAL_CONTENT_URI, uri) - intent.putExtra(EXTRA_CHOOSER_FOCUSED_ITEM_POSITION, position) - } - - val result = readChooserRequest(model, fakeChooserServiceFlags) - - assertThat(result).isInstanceOf(Valid::class.java) - result as Valid - - assertThat(result.value.additionalContentUri).isNull() - assertThat(result.value.focusedItemPosition).isEqualTo(0) - assertThat(result.warnings).isEmpty() - } - - @Test - fun testAlbumType() { - fakeChooserServiceFlags.setFlag(Flags.FLAG_CHOOSER_ALBUM_TEXT, true) - val model = createActivityModel(Intent(ACTION_SEND)) - model.intent.putExtra( - Intent.EXTRA_CHOOSER_CONTENT_TYPE_HINT, - Intent.CHOOSER_CONTENT_TYPE_ALBUM - ) - - val result = readChooserRequest(model, fakeChooserServiceFlags) - - assertThat(result).isInstanceOf(Valid::class.java) - result as Valid - - assertThat(result.value.contentTypeHint).isEqualTo(ContentTypeHint.ALBUM) - assertThat(result.warnings).isEmpty() - } - - @Test - 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 { - intent.putExtra(Intent.EXTRA_METADATA_TEXT, metadataText) - } - - val result = readChooserRequest(model, fakeChooserServiceFlags) - - assertThat(result).isInstanceOf(Valid::class.java) - result as Valid - - assertThat(result.value.metadataText).isNull() - } - - @Test - fun metadataText_whenFlagTrue_isPassedText() { - // Arrange - fakeChooserServiceFlags.setFlag(Flags.FLAG_ENABLE_SHARESHEET_METADATA_EXTRA, true) - val metadataText: CharSequence = "Test metadata text" - val model = - createActivityModel(targetIntent = Intent()).apply { - intent.putExtra(Intent.EXTRA_METADATA_TEXT, metadataText) - } - - val result = readChooserRequest(model, fakeChooserServiceFlags) - - assertThat(result).isInstanceOf(Valid::class.java) - result as Valid - - assertThat(result.value.metadataText).isEqualTo(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 deleted file mode 100644 index f6475663..00000000 --- a/tests/unit/src/com/android/intentresolver/v2/ui/viewmodel/ResolverRequestTest.kt +++ /dev/null @@ -1,128 +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.v2.ui.viewmodel - -import android.content.Intent -import android.content.Intent.ACTION_VIEW -import android.net.Uri -import android.os.UserHandle -import androidx.core.net.toUri -import androidx.core.os.bundleOf -import com.android.intentresolver.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.ResolverRequest -import com.android.intentresolver.v2.validation.Invalid -import com.android.intentresolver.v2.validation.UncaughtException -import com.android.intentresolver.v2.validation.Valid -import com.google.common.truth.Truth.assertThat -import com.google.common.truth.Truth.assertWithMessage -import org.junit.Test - -private val targetUri = Uri.parse("content://example.com/123") - -private fun createActivityModel( - targetIntent: Intent, - referrer: Uri? = null, -) = - ActivityModel( - intent = targetIntent, - launchedFromUid = 10000, - launchedFromPackage = "com.android.example", - referrer = referrer ?: "android-app://com.android.example".toUri() - ) - -class ResolverRequestTest { - @Test - fun testDefaults() { - val intent = Intent(ACTION_VIEW).apply { data = targetUri } - val activity = createActivityModel(intent) - - val result = readResolverRequest(activity) - - assertThat(result).isInstanceOf(Valid::class.java) - result as Valid - - assertThat(result.warnings).isEmpty() - - assertThat(result.value.intent.filterEquals(activity.intent)).isTrue() - assertThat(result.value.callingUser).isNull() - assertThat(result.value.selectedProfile).isNull() - } - - @Test - fun testInvalidSelectedProfile() { - val intent = - Intent(ACTION_VIEW).apply { - data = targetUri - putExtra(EXTRA_SELECTED_PROFILE, -1000) - } - - val activity = createActivityModel(intent) - - val result = readResolverRequest(activity) - - assertThat(result).isInstanceOf(Invalid::class.java) - result as Invalid - - assertWithMessage("the first finding") - .that(result.errors.firstOrNull()) - .isInstanceOf(UncaughtException::class.java) - } - - @Test - fun payloadIntents_includesOnlyTarget() { - val intent2 = Intent(Intent.ACTION_SEND_MULTIPLE) - val intent1 = - Intent(Intent.ACTION_SEND).apply { - putParcelableArrayListExtra(Intent.EXTRA_ALTERNATE_INTENTS, arrayListOf(intent2)) - } - val activity = createActivityModel(targetIntent = intent1) - - val result = readResolverRequest(activity) - - assertThat(result).isInstanceOf(Valid::class.java) - result as Valid - - // Assert that payloadIntents does NOT include EXTRA_ALTERNATE_INTENTS - // that is only supported for Chooser and should be not be added here. - assertThat(result.value.payloadIntents).containsExactly(intent1) - } - - @Test - fun testAllValues() { - val intent = Intent(ACTION_VIEW).apply { data = Uri.parse("content://example.com/123") } - val activity = createActivityModel(targetIntent = intent) - - activity.intent.putExtras( - bundleOf( - EXTRA_CALLING_USER to UserHandle.of(123), - EXTRA_SELECTED_PROFILE to PROFILE_WORK, - EXTRA_IS_AUDIO_CAPTURE_DEVICE to true, - ) - ) - - val result = readResolverRequest(activity) - - assertThat(result).isInstanceOf(Valid::class.java) - result as Valid - - assertThat(result.value.intent.filterEquals(activity.intent)).isTrue() - assertThat(result.value.isAudioCaptureDevice).isTrue() - assertThat(result.value.callingUser).isEqualTo(UserHandle.of(123)) - assertThat(result.value.selectedProfile).isEqualTo(WORK) - } -} diff --git a/tests/unit/src/com/android/intentresolver/v2/validation/ValidationTest.kt b/tests/unit/src/com/android/intentresolver/v2/validation/ValidationTest.kt deleted file mode 100644 index dbaa7c4e..00000000 --- a/tests/unit/src/com/android/intentresolver/v2/validation/ValidationTest.kt +++ /dev/null @@ -1,122 +0,0 @@ -package com.android.intentresolver.v2.validation - -import com.android.intentresolver.v2.validation.types.value -import com.google.common.truth.Truth.assertThat -import org.junit.Assert.fail -import org.junit.Test - -class ValidationTest { - - /** Test required values. */ - @Test - fun required_valuePresent() { - val result: ValidationResult = - validateFrom({ 1 }) { - val required: Int = required(value("key")) - "return value: $required" - } - - assertThat(result).isInstanceOf(Valid::class.java) - result as Valid - - assertThat(result.value).isEqualTo("return value: 1") - assertThat(result.warnings).isEmpty() - } - - /** Test reporting of absent required values. */ - @Test - fun required_valueAbsent() { - val result: ValidationResult = - validateFrom({ null }) { - required(value("key")) - fail("'required' should have thrown an exception") - "return value" - } - - assertThat(result).isInstanceOf(Invalid::class.java) - result as Invalid - - assertThat(result.errors).containsExactly( - NoValue("key", Importance.CRITICAL, Int::class)) - } - - /** Test optional values are ignored when absent. */ - @Test - fun optional_valuePresent() { - val result: ValidationResult = - validateFrom({ 1 }) { - val optional: Int? = optional(value("key")) - "return value: $optional" - } - - assertThat(result).isInstanceOf(Valid::class.java) - result as Valid - - assertThat(result.value).isEqualTo("return value: 1") - assertThat(result.warnings).isEmpty() - } - - /** Test optional values are ignored when absent. */ - @Test - fun optional_valueAbsent() { - val result: ValidationResult = - validateFrom({ null }) { - val optional: String? = optional(value("key")) - "return value: $optional" - } - - assertThat(result).isInstanceOf(Valid::class.java) - result as Valid - - assertThat(result.value).isEqualTo("return value: null") - assertThat(result.warnings).isEmpty() - } - - /** Test reporting of ignored values. */ - @Test - fun ignored_valuePresent() { - val result: ValidationResult = - validateFrom(mapOf("key" to 1)::get) { - ignored(value("key"), "no longer supported") - "result value" - } - - assertThat(result).isInstanceOf(Valid::class.java) - result as Valid - - assertThat(result.value).isEqualTo("result value") - assertThat(result.warnings) - .containsExactly(IgnoredValue("key", "no longer supported")) - } - - /** Test reporting of ignored values. */ - @Test - fun ignored_valueAbsent() { - val result: ValidationResult = - validateFrom({ null }) { - ignored(value("key"), "ignored when option foo is set") - "result value" - } - assertThat(result).isInstanceOf(Valid::class.java) - result as Valid - - assertThat(result.value).isEqualTo("result value") - assertThat(result.warnings).isEmpty() - } - - /** Test handling of exceptions in the validation function. */ - @Test - fun thrown_exception() { - val result: ValidationResult = - validateFrom({ null }) { - error("something") - } - - assertThat(result).isInstanceOf(Invalid::class.java) - result as Invalid - - val errorType = result.errors.map { it::class }.first() - assertThat(errorType).isEqualTo(UncaughtException::class) - } - -} diff --git a/tests/unit/src/com/android/intentresolver/v2/validation/types/IntentOrUriTest.kt b/tests/unit/src/com/android/intentresolver/v2/validation/types/IntentOrUriTest.kt deleted file mode 100644 index 03429f4c..00000000 --- a/tests/unit/src/com/android/intentresolver/v2/validation/types/IntentOrUriTest.kt +++ /dev/null @@ -1,118 +0,0 @@ -package com.android.intentresolver.v2.validation.types - -import android.content.Intent -import android.content.Intent.URI_INTENT_SCHEME -import android.net.Uri -import androidx.core.net.toUri -import androidx.test.ext.truth.content.IntentSubject.assertThat -import com.android.intentresolver.v2.validation.Importance.CRITICAL -import com.android.intentresolver.v2.validation.Importance.WARNING -import com.android.intentresolver.v2.validation.Invalid -import com.android.intentresolver.v2.validation.NoValue -import com.android.intentresolver.v2.validation.Valid -import com.android.intentresolver.v2.validation.ValueIsWrongType -import com.google.common.truth.Truth.assertThat -import org.junit.Test - -class IntentOrUriTest { - - /** Test for validation success when the value is an Intent. */ - @Test - fun intent() { - val keyValidator = IntentOrUri("key") - val values = mapOf("key" to Intent("GO")) - - val result = keyValidator.validate(values::get, CRITICAL) - - assertThat(result).isInstanceOf(Valid::class.java) - result as Valid - assertThat(result.value).hasAction("GO") - } - - /** Test for validation success when the value is a Uri. */ - @Test - fun uri() { - val keyValidator = IntentOrUri("key") - val values = mapOf("key" to Intent("GO").toUri(URI_INTENT_SCHEME).toUri()) - - val result = keyValidator.validate(values::get, CRITICAL) - - assertThat(result).isInstanceOf(Valid::class.java) - result as Valid - assertThat(result.value).hasAction("GO") - } - - /** Test the failure result when the value is missing. */ - @Test - fun missing() { - val keyValidator = IntentOrUri("key") - - val result = keyValidator.validate({ null }, CRITICAL) - - assertThat(result).isInstanceOf(Invalid::class.java) - result as Invalid - - assertThat(result.errors) - .containsExactly(NoValue("key", CRITICAL, Intent::class)) - } - - /** Check validation passes when value is null and importance is [WARNING] (optional). */ - @Test - fun optional() { - val keyValidator = ParceledArray("key", Intent::class) - - val result = keyValidator.validate(source = { null }, WARNING) - - assertThat(result).isInstanceOf(Invalid::class.java) - result as Invalid> - assertThat(result.errors).isEmpty() - } - - /** - * Test for failure result when the value is neither Intent nor Uri, with importance CRITICAL. - */ - @Test - fun wrongType_required() { - val keyValidator = IntentOrUri("key") - val values = mapOf("key" to 1) - - val result = keyValidator.validate(values::get, CRITICAL) - - assertThat(result).isInstanceOf(Invalid::class.java) - result as Invalid - - assertThat(result.errors) - .containsExactly( - ValueIsWrongType( - "key", - importance = CRITICAL, - actualType = Int::class, - allowedTypes = listOf(Intent::class, Uri::class) - ) - ) - } - - /** - * Test for warnings when the value is neither Intent nor Uri, with importance WARNING. - */ - @Test - fun wrongType_optional() { - val keyValidator = IntentOrUri("key") - val values = mapOf("key" to 1) - - val result = keyValidator.validate(values::get, WARNING) - - assertThat(result).isInstanceOf(Invalid::class.java) - result as Invalid - - assertThat(result.errors) - .containsExactly( - ValueIsWrongType( - "key", - importance = WARNING, - actualType = Int::class, - allowedTypes = listOf(Intent::class, Uri::class) - ) - ) - } -} diff --git a/tests/unit/src/com/android/intentresolver/v2/validation/types/ParceledArrayTest.kt b/tests/unit/src/com/android/intentresolver/v2/validation/types/ParceledArrayTest.kt deleted file mode 100644 index 637873ea..00000000 --- a/tests/unit/src/com/android/intentresolver/v2/validation/types/ParceledArrayTest.kt +++ /dev/null @@ -1,101 +0,0 @@ -package com.android.intentresolver.v2.validation.types - -import android.content.Intent -import android.graphics.Point -import com.android.intentresolver.v2.validation.Importance.CRITICAL -import com.android.intentresolver.v2.validation.Importance.WARNING -import com.android.intentresolver.v2.validation.Invalid -import com.android.intentresolver.v2.validation.NoValue -import com.android.intentresolver.v2.validation.Valid -import com.android.intentresolver.v2.validation.ValueIsWrongType -import com.android.intentresolver.v2.validation.WrongElementType -import com.google.common.truth.Truth.assertThat -import org.junit.Test - -class ParceledArrayTest { - - /** Check that a array is handled correctly when valid. */ - @Test - fun valid() { - val keyValidator = ParceledArray("key", elementType = String::class) - val values = mapOf("key" to arrayOf("String")) - - val result = keyValidator.validate(values::get, CRITICAL) - - assertThat(result).isInstanceOf(Valid::class.java) - result as Valid> - assertThat(result.value).containsExactly("String") - } - - /** Check correct failure result when an array has the wrong element type. */ - @Test - fun wrongElementType() { - val keyValidator = ParceledArray("key", elementType = Intent::class) - val values = mapOf("key" to arrayOf(Point())) - - val result = keyValidator.validate(values::get, CRITICAL) - - assertThat(result).isInstanceOf(Invalid::class.java) - result as Invalid> - - assertThat(result.errors) - .containsExactly( - // TODO: report with a new class `WrongElementType` to improve clarity - WrongElementType( - "key", - importance = CRITICAL, - container = Array::class, - actualType = Point::class, - expectedType = Intent::class - ) - ) - } - - /** Check correct failure result when an array value is missing. */ - @Test - fun missing() { - val keyValidator = ParceledArray("key", Intent::class) - - val result = keyValidator.validate(source = { null }, CRITICAL) - - assertThat(result).isInstanceOf(Invalid::class.java) - result as Invalid> - - assertThat(result.errors).containsExactly(NoValue("key", CRITICAL, Intent::class)) - } - - /** Check validation passes when value is null and importance is [WARNING] (optional). */ - @Test - fun optional() { - val keyValidator = ParceledArray("key", Intent::class) - - val result = keyValidator.validate(source = { null }, WARNING) - - assertThat(result).isInstanceOf(Invalid::class.java) - result as Invalid> - - assertThat(result.errors).isEmpty() - } - - /** Check correct failure result when the array value itself is the wrong type. */ - @Test - fun wrongType() { - val keyValidator = ParceledArray("key", Intent::class) - val values = mapOf("key" to 1) - - val result = keyValidator.validate(values::get, CRITICAL) - - assertThat(result).isInstanceOf(Invalid::class.java) - result as Invalid> - - assertThat(result.errors) - .containsExactly( - ValueIsWrongType( - "key", - importance = CRITICAL, - actualType = Int::class, - allowedTypes = listOf(Intent::class) - ) - ) - } -} diff --git a/tests/unit/src/com/android/intentresolver/v2/validation/types/SimpleValueTest.kt b/tests/unit/src/com/android/intentresolver/v2/validation/types/SimpleValueTest.kt deleted file mode 100644 index 93d76d46..00000000 --- a/tests/unit/src/com/android/intentresolver/v2/validation/types/SimpleValueTest.kt +++ /dev/null @@ -1,77 +0,0 @@ -package com.android.intentresolver.v2.validation.types - -import com.android.intentresolver.v2.validation.Importance.CRITICAL -import com.android.intentresolver.v2.validation.Importance.WARNING -import com.android.intentresolver.v2.validation.Invalid -import com.android.intentresolver.v2.validation.NoValue -import com.android.intentresolver.v2.validation.Valid -import com.android.intentresolver.v2.validation.ValueIsWrongType -import org.junit.Test -import com.google.common.truth.Truth.assertThat - -class SimpleValueTest { - - /** Test for validation success when the value is present and the correct type. */ - @Test - fun present() { - val keyValidator = SimpleValue("key", expected = Double::class) - val values = mapOf("key" to Math.PI) - - val result = keyValidator.validate(values::get, CRITICAL) - - - assertThat(result).isInstanceOf(Valid::class.java) - result as Valid - assertThat(result.value).isEqualTo(Math.PI) - } - - /** Test for validation success when the value is present and the correct type. */ - @Test - fun wrongType() { - val keyValidator = SimpleValue("key", expected = Double::class) - val values = mapOf("key" to "Apple Pie") - - val result = keyValidator.validate(values::get, CRITICAL) - - assertThat(result).isInstanceOf(Invalid::class.java) - result as Invalid - assertThat(result.errors).containsExactly( - ValueIsWrongType( - "key", - importance = CRITICAL, - actualType = String::class, - allowedTypes = listOf(Double::class) - ) - ) - } - - /** Test the failure result when the value is missing. */ - @Test - fun missing() { - val keyValidator = SimpleValue("key", expected = Double::class) - - val result = keyValidator.validate(source = { null }, CRITICAL) - - assertThat(result).isInstanceOf(Invalid::class.java) - result as Invalid - - assertThat(result.errors).containsExactly(NoValue("key", CRITICAL, Double::class)) - } - - - /** Test the failure result when the value is missing. */ - @Test - fun optional() { - val keyValidator = SimpleValue("key", expected = Double::class) - - val result = keyValidator.validate(source = { null }, WARNING) - - assertThat(result).isInstanceOf(Invalid::class.java) - result as Invalid - - // Note: As single optional validation result, the return must be Invalid - // when there is no value to return, but no errors will be reported because - // an optional value cannot be "missing". - assertThat(result.errors).isEmpty() - } -} diff --git a/tests/unit/src/com/android/intentresolver/validation/ValidationTest.kt b/tests/unit/src/com/android/intentresolver/validation/ValidationTest.kt new file mode 100644 index 00000000..18cf2f26 --- /dev/null +++ b/tests/unit/src/com/android/intentresolver/validation/ValidationTest.kt @@ -0,0 +1,116 @@ +package com.android.intentresolver.validation + +import com.android.intentresolver.validation.types.value +import com.google.common.truth.Truth.assertThat +import org.junit.Assert.fail +import org.junit.Test + +class ValidationTest { + + /** Test required values. */ + @Test + fun required_valuePresent() { + val result: ValidationResult = + validateFrom({ 1 }) { + val required: Int = required(value("key")) + "return value: $required" + } + + assertThat(result).isInstanceOf(Valid::class.java) + result as Valid + + assertThat(result.value).isEqualTo("return value: 1") + assertThat(result.warnings).isEmpty() + } + + /** Test reporting of absent required values. */ + @Test + fun required_valueAbsent() { + val result: ValidationResult = + validateFrom({ null }) { + required(value("key")) + fail("'required' should have thrown an exception") + "return value" + } + + assertThat(result).isInstanceOf(Invalid::class.java) + result as Invalid + + assertThat(result.errors).containsExactly(NoValue("key", Importance.CRITICAL, Int::class)) + } + + /** Test optional values are ignored when absent. */ + @Test + fun optional_valuePresent() { + val result: ValidationResult = + validateFrom({ 1 }) { + val optional: Int? = optional(value("key")) + "return value: $optional" + } + + assertThat(result).isInstanceOf(Valid::class.java) + result as Valid + + assertThat(result.value).isEqualTo("return value: 1") + assertThat(result.warnings).isEmpty() + } + + /** Test optional values are ignored when absent. */ + @Test + fun optional_valueAbsent() { + val result: ValidationResult = + validateFrom({ null }) { + val optional: String? = optional(value("key")) + "return value: $optional" + } + + assertThat(result).isInstanceOf(Valid::class.java) + result as Valid + + assertThat(result.value).isEqualTo("return value: null") + assertThat(result.warnings).isEmpty() + } + + /** Test reporting of ignored values. */ + @Test + fun ignored_valuePresent() { + val result: ValidationResult = + validateFrom(mapOf("key" to 1)::get) { + ignored(value("key"), "no longer supported") + "result value" + } + + assertThat(result).isInstanceOf(Valid::class.java) + result as Valid + + assertThat(result.value).isEqualTo("result value") + assertThat(result.warnings).containsExactly(IgnoredValue("key", "no longer supported")) + } + + /** Test reporting of ignored values. */ + @Test + fun ignored_valueAbsent() { + val result: ValidationResult = + validateFrom({ null }) { + ignored(value("key"), "ignored when option foo is set") + "result value" + } + assertThat(result).isInstanceOf(Valid::class.java) + result as Valid + + assertThat(result.value).isEqualTo("result value") + assertThat(result.warnings).isEmpty() + } + + /** Test handling of exceptions in the validation function. */ + @Test + fun thrown_exception() { + val result: ValidationResult = validateFrom({ null }) { error("something") } + + assertThat(result).isInstanceOf(Invalid::class.java) + result as Invalid + + val errorType = result.errors.map { it::class }.first() + assertThat(errorType).isEqualTo(UncaughtException::class) + } +} diff --git a/tests/unit/src/com/android/intentresolver/validation/types/IntentOrUriTest.kt b/tests/unit/src/com/android/intentresolver/validation/types/IntentOrUriTest.kt new file mode 100644 index 00000000..c2ce5a6b --- /dev/null +++ b/tests/unit/src/com/android/intentresolver/validation/types/IntentOrUriTest.kt @@ -0,0 +1,115 @@ +package com.android.intentresolver.validation.types + +import android.content.Intent +import android.content.Intent.URI_INTENT_SCHEME +import android.net.Uri +import androidx.core.net.toUri +import androidx.test.ext.truth.content.IntentSubject.assertThat +import com.android.intentresolver.validation.Importance.CRITICAL +import com.android.intentresolver.validation.Importance.WARNING +import com.android.intentresolver.validation.Invalid +import com.android.intentresolver.validation.NoValue +import com.android.intentresolver.validation.Valid +import com.android.intentresolver.validation.ValueIsWrongType +import com.google.common.truth.Truth.assertThat +import org.junit.Test + +class IntentOrUriTest { + + /** Test for validation success when the value is an Intent. */ + @Test + fun intent() { + val keyValidator = IntentOrUri("key") + val values = mapOf("key" to Intent("GO")) + + val result = keyValidator.validate(values::get, CRITICAL) + + assertThat(result).isInstanceOf(Valid::class.java) + result as Valid + assertThat(result.value).hasAction("GO") + } + + /** Test for validation success when the value is a Uri. */ + @Test + fun uri() { + val keyValidator = IntentOrUri("key") + val values = mapOf("key" to Intent("GO").toUri(URI_INTENT_SCHEME).toUri()) + + val result = keyValidator.validate(values::get, CRITICAL) + + assertThat(result).isInstanceOf(Valid::class.java) + result as Valid + assertThat(result.value).hasAction("GO") + } + + /** Test the failure result when the value is missing. */ + @Test + fun missing() { + val keyValidator = IntentOrUri("key") + + val result = keyValidator.validate({ null }, CRITICAL) + + assertThat(result).isInstanceOf(Invalid::class.java) + result as Invalid + + assertThat(result.errors).containsExactly(NoValue("key", CRITICAL, Intent::class)) + } + + /** Check validation passes when value is null and importance is [WARNING] (optional). */ + @Test + fun optional() { + val keyValidator = ParceledArray("key", Intent::class) + + val result = keyValidator.validate(source = { null }, WARNING) + + assertThat(result).isInstanceOf(Invalid::class.java) + result as Invalid> + assertThat(result.errors).isEmpty() + } + + /** + * Test for failure result when the value is neither Intent nor Uri, with importance CRITICAL. + */ + @Test + fun wrongType_required() { + val keyValidator = IntentOrUri("key") + val values = mapOf("key" to 1) + + val result = keyValidator.validate(values::get, CRITICAL) + + assertThat(result).isInstanceOf(Invalid::class.java) + result as Invalid + + assertThat(result.errors) + .containsExactly( + ValueIsWrongType( + "key", + importance = CRITICAL, + actualType = Int::class, + allowedTypes = listOf(Intent::class, Uri::class) + ) + ) + } + + /** Test for warnings when the value is neither Intent nor Uri, with importance WARNING. */ + @Test + fun wrongType_optional() { + val keyValidator = IntentOrUri("key") + val values = mapOf("key" to 1) + + val result = keyValidator.validate(values::get, WARNING) + + assertThat(result).isInstanceOf(Invalid::class.java) + result as Invalid + + assertThat(result.errors) + .containsExactly( + ValueIsWrongType( + "key", + importance = WARNING, + actualType = Int::class, + allowedTypes = listOf(Intent::class, Uri::class) + ) + ) + } +} diff --git a/tests/unit/src/com/android/intentresolver/validation/types/ParceledArrayTest.kt b/tests/unit/src/com/android/intentresolver/validation/types/ParceledArrayTest.kt new file mode 100644 index 00000000..6d513021 --- /dev/null +++ b/tests/unit/src/com/android/intentresolver/validation/types/ParceledArrayTest.kt @@ -0,0 +1,101 @@ +package com.android.intentresolver.validation.types + +import android.content.Intent +import android.graphics.Point +import com.android.intentresolver.validation.Importance.CRITICAL +import com.android.intentresolver.validation.Importance.WARNING +import com.android.intentresolver.validation.Invalid +import com.android.intentresolver.validation.NoValue +import com.android.intentresolver.validation.Valid +import com.android.intentresolver.validation.ValueIsWrongType +import com.android.intentresolver.validation.WrongElementType +import com.google.common.truth.Truth.assertThat +import org.junit.Test + +class ParceledArrayTest { + + /** Check that a array is handled correctly when valid. */ + @Test + fun valid() { + val keyValidator = ParceledArray("key", elementType = String::class) + val values = mapOf("key" to arrayOf("String")) + + val result = keyValidator.validate(values::get, CRITICAL) + + assertThat(result).isInstanceOf(Valid::class.java) + result as Valid> + assertThat(result.value).containsExactly("String") + } + + /** Check correct failure result when an array has the wrong element type. */ + @Test + fun wrongElementType() { + val keyValidator = ParceledArray("key", elementType = Intent::class) + val values = mapOf("key" to arrayOf(Point())) + + val result = keyValidator.validate(values::get, CRITICAL) + + assertThat(result).isInstanceOf(Invalid::class.java) + result as Invalid> + + assertThat(result.errors) + .containsExactly( + // TODO: report with a new class `WrongElementType` to improve clarity + WrongElementType( + "key", + importance = CRITICAL, + container = Array::class, + actualType = Point::class, + expectedType = Intent::class + ) + ) + } + + /** Check correct failure result when an array value is missing. */ + @Test + fun missing() { + val keyValidator = ParceledArray("key", Intent::class) + + val result = keyValidator.validate(source = { null }, CRITICAL) + + assertThat(result).isInstanceOf(Invalid::class.java) + result as Invalid> + + assertThat(result.errors).containsExactly(NoValue("key", CRITICAL, Intent::class)) + } + + /** Check validation passes when value is null and importance is [WARNING] (optional). */ + @Test + fun optional() { + val keyValidator = ParceledArray("key", Intent::class) + + val result = keyValidator.validate(source = { null }, WARNING) + + assertThat(result).isInstanceOf(Invalid::class.java) + result as Invalid> + + assertThat(result.errors).isEmpty() + } + + /** Check correct failure result when the array value itself is the wrong type. */ + @Test + fun wrongType() { + val keyValidator = ParceledArray("key", Intent::class) + val values = mapOf("key" to 1) + + val result = keyValidator.validate(values::get, CRITICAL) + + assertThat(result).isInstanceOf(Invalid::class.java) + result as Invalid> + + assertThat(result.errors) + .containsExactly( + ValueIsWrongType( + "key", + importance = CRITICAL, + actualType = Int::class, + allowedTypes = listOf(Intent::class) + ) + ) + } +} diff --git a/tests/unit/src/com/android/intentresolver/validation/types/SimpleValueTest.kt b/tests/unit/src/com/android/intentresolver/validation/types/SimpleValueTest.kt new file mode 100644 index 00000000..fd740b6f --- /dev/null +++ b/tests/unit/src/com/android/intentresolver/validation/types/SimpleValueTest.kt @@ -0,0 +1,76 @@ +package com.android.intentresolver.validation.types + +import com.android.intentresolver.validation.Importance.CRITICAL +import com.android.intentresolver.validation.Importance.WARNING +import com.android.intentresolver.validation.Invalid +import com.android.intentresolver.validation.NoValue +import com.android.intentresolver.validation.Valid +import com.android.intentresolver.validation.ValueIsWrongType +import com.google.common.truth.Truth.assertThat +import org.junit.Test + +class SimpleValueTest { + + /** Test for validation success when the value is present and the correct type. */ + @Test + fun present() { + val keyValidator = SimpleValue("key", expected = Double::class) + val values = mapOf("key" to Math.PI) + + val result = keyValidator.validate(values::get, CRITICAL) + + assertThat(result).isInstanceOf(Valid::class.java) + result as Valid + assertThat(result.value).isEqualTo(Math.PI) + } + + /** Test for validation success when the value is present and the correct type. */ + @Test + fun wrongType() { + val keyValidator = SimpleValue("key", expected = Double::class) + val values = mapOf("key" to "Apple Pie") + + val result = keyValidator.validate(values::get, CRITICAL) + + assertThat(result).isInstanceOf(Invalid::class.java) + result as Invalid + assertThat(result.errors) + .containsExactly( + ValueIsWrongType( + "key", + importance = CRITICAL, + actualType = String::class, + allowedTypes = listOf(Double::class) + ) + ) + } + + /** Test the failure result when the value is missing. */ + @Test + fun missing() { + val keyValidator = SimpleValue("key", expected = Double::class) + + val result = keyValidator.validate(source = { null }, CRITICAL) + + assertThat(result).isInstanceOf(Invalid::class.java) + result as Invalid + + assertThat(result.errors).containsExactly(NoValue("key", CRITICAL, Double::class)) + } + + /** Test the failure result when the value is missing. */ + @Test + fun optional() { + val keyValidator = SimpleValue("key", expected = Double::class) + + val result = keyValidator.validate(source = { null }, WARNING) + + assertThat(result).isInstanceOf(Invalid::class.java) + result as Invalid + + // Note: As single optional validation result, the return must be Invalid + // when there is no value to return, but no errors will be reported because + // an optional value cannot be "missing". + assertThat(result.errors).isEmpty() + } +} -- cgit v1.2.3-59-g8ed1b From 62b5d2c6b06af7982769fc18cc696f0ae330b84f Mon Sep 17 00:00:00 2001 From: Mark Renouf Date: Wed, 3 Apr 2024 21:23:50 -0400 Subject: Add missing copyright headers Bug: NA Test: NA Change-Id: I1732d13e27fdceddea550f53ea160149bbc04044 --- java/src/com/android/intentresolver/ChooserSelector.kt | 16 ++++++++++++++++ .../android/intentresolver/inject/FeatureFlagsModule.kt | 16 ++++++++++++++++ .../com/android/intentresolver/inject/SingletonModule.kt | 16 ++++++++++++++++ .../android/intentresolver/platform/ImageEditorModule.kt | 16 ++++++++++++++++ .../android/intentresolver/platform/NearbyShareModule.kt | 16 ++++++++++++++++ .../intentresolver/platform/PlatformSecureSettings.kt | 16 ++++++++++++++++ .../android/intentresolver/platform/SecureSettings.kt | 16 ++++++++++++++++ .../intentresolver/platform/SecureSettingsModule.kt | 16 ++++++++++++++++ .../com/android/intentresolver/widget/BadgeTextView.kt | 16 ++++++++++++++++ .../intentresolver/widget/ChooserNestedScrollView.kt | 16 ++++++++++++++++ .../intentresolver/platform/FakeSecureSettings.kt | 16 ++++++++++++++++ .../android/intentresolver/platform/FakeUserManager.kt | 16 ++++++++++++++++ .../android/intentresolver/ChooserListAdapterDataTest.kt | 16 ++++++++++++++++ .../data/repository/UserRepositoryImplTest.kt | 16 ++++++++++++++++ .../intentresolver/platform/FakeSecureSettingsTest.kt | 16 ++++++++++++++++ .../intentresolver/platform/FakeUserManagerTest.kt | 16 ++++++++++++++++ .../intentresolver/platform/NearbyShareModuleTest.kt | 16 ++++++++++++++++ .../com/android/intentresolver/util/UriFiltersTest.kt | 16 ++++++++++++++++ .../android/intentresolver/validation/ValidationTest.kt | 16 ++++++++++++++++ .../intentresolver/validation/types/IntentOrUriTest.kt | 16 ++++++++++++++++ .../intentresolver/validation/types/ParceledArrayTest.kt | 16 ++++++++++++++++ .../intentresolver/validation/types/SimpleValueTest.kt | 16 ++++++++++++++++ 22 files changed, 352 insertions(+) (limited to 'java/src') diff --git a/java/src/com/android/intentresolver/ChooserSelector.kt b/java/src/com/android/intentresolver/ChooserSelector.kt index 378bc06c..c1174e95 100644 --- a/java/src/com/android/intentresolver/ChooserSelector.kt +++ b/java/src/com/android/intentresolver/ChooserSelector.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 import android.content.BroadcastReceiver diff --git a/java/src/com/android/intentresolver/inject/FeatureFlagsModule.kt b/java/src/com/android/intentresolver/inject/FeatureFlagsModule.kt index 0f9a18c1..d7be67db 100644 --- a/java/src/com/android/intentresolver/inject/FeatureFlagsModule.kt +++ b/java/src/com/android/intentresolver/inject/FeatureFlagsModule.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.inject import android.service.chooser.FeatureFlagsImpl as ChooserServiceFlagsImpl diff --git a/java/src/com/android/intentresolver/inject/SingletonModule.kt b/java/src/com/android/intentresolver/inject/SingletonModule.kt index e517800d..af054625 100644 --- a/java/src/com/android/intentresolver/inject/SingletonModule.kt +++ b/java/src/com/android/intentresolver/inject/SingletonModule.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.inject import android.content.Context diff --git a/java/src/com/android/intentresolver/platform/ImageEditorModule.kt b/java/src/com/android/intentresolver/platform/ImageEditorModule.kt index 54b93939..24257968 100644 --- a/java/src/com/android/intentresolver/platform/ImageEditorModule.kt +++ b/java/src/com/android/intentresolver/platform/ImageEditorModule.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.platform import android.content.ComponentName diff --git a/java/src/com/android/intentresolver/platform/NearbyShareModule.kt b/java/src/com/android/intentresolver/platform/NearbyShareModule.kt index 4eaa24c0..6cb30b41 100644 --- a/java/src/com/android/intentresolver/platform/NearbyShareModule.kt +++ b/java/src/com/android/intentresolver/platform/NearbyShareModule.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.platform import android.content.ComponentName diff --git a/java/src/com/android/intentresolver/platform/PlatformSecureSettings.kt b/java/src/com/android/intentresolver/platform/PlatformSecureSettings.kt index d2319873..0c802c97 100644 --- a/java/src/com/android/intentresolver/platform/PlatformSecureSettings.kt +++ b/java/src/com/android/intentresolver/platform/PlatformSecureSettings.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.platform import android.content.ContentResolver diff --git a/java/src/com/android/intentresolver/platform/SecureSettings.kt b/java/src/com/android/intentresolver/platform/SecureSettings.kt index 86fc8e98..8a1dc531 100644 --- a/java/src/com/android/intentresolver/platform/SecureSettings.kt +++ b/java/src/com/android/intentresolver/platform/SecureSettings.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.platform import android.provider.Settings.SettingNotFoundException diff --git a/java/src/com/android/intentresolver/platform/SecureSettingsModule.kt b/java/src/com/android/intentresolver/platform/SecureSettingsModule.kt index 260e50a1..fa3ee4fe 100644 --- a/java/src/com/android/intentresolver/platform/SecureSettingsModule.kt +++ b/java/src/com/android/intentresolver/platform/SecureSettingsModule.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.platform import dagger.Binds diff --git a/java/src/com/android/intentresolver/widget/BadgeTextView.kt b/java/src/com/android/intentresolver/widget/BadgeTextView.kt index b6cadd86..6674d92d 100644 --- a/java/src/com/android/intentresolver/widget/BadgeTextView.kt +++ b/java/src/com/android/intentresolver/widget/BadgeTextView.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.widget import android.content.Context diff --git a/java/src/com/android/intentresolver/widget/ChooserNestedScrollView.kt b/java/src/com/android/intentresolver/widget/ChooserNestedScrollView.kt index 26464ca1..e86de888 100644 --- a/java/src/com/android/intentresolver/widget/ChooserNestedScrollView.kt +++ b/java/src/com/android/intentresolver/widget/ChooserNestedScrollView.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.widget import android.content.Context diff --git a/tests/shared/src/com/android/intentresolver/platform/FakeSecureSettings.kt b/tests/shared/src/com/android/intentresolver/platform/FakeSecureSettings.kt index 25711b70..862be76f 100644 --- a/tests/shared/src/com/android/intentresolver/platform/FakeSecureSettings.kt +++ b/tests/shared/src/com/android/intentresolver/platform/FakeSecureSettings.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.platform /** diff --git a/tests/shared/src/com/android/intentresolver/platform/FakeUserManager.kt b/tests/shared/src/com/android/intentresolver/platform/FakeUserManager.kt index b357a691..ff1e84bd 100644 --- a/tests/shared/src/com/android/intentresolver/platform/FakeUserManager.kt +++ b/tests/shared/src/com/android/intentresolver/platform/FakeUserManager.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.platform import android.content.Context diff --git a/tests/unit/src/com/android/intentresolver/ChooserListAdapterDataTest.kt b/tests/unit/src/com/android/intentresolver/ChooserListAdapterDataTest.kt index ca91c243..e974cb7d 100644 --- a/tests/unit/src/com/android/intentresolver/ChooserListAdapterDataTest.kt +++ b/tests/unit/src/com/android/intentresolver/ChooserListAdapterDataTest.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 import android.content.Context diff --git a/tests/unit/src/com/android/intentresolver/data/repository/UserRepositoryImplTest.kt b/tests/unit/src/com/android/intentresolver/data/repository/UserRepositoryImplTest.kt index 3ae9878d..a8acbfe1 100644 --- a/tests/unit/src/com/android/intentresolver/data/repository/UserRepositoryImplTest.kt +++ b/tests/unit/src/com/android/intentresolver/data/repository/UserRepositoryImplTest.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.data.repository import android.content.pm.UserInfo diff --git a/tests/unit/src/com/android/intentresolver/platform/FakeSecureSettingsTest.kt b/tests/unit/src/com/android/intentresolver/platform/FakeSecureSettingsTest.kt index 1f08e541..fd74b50a 100644 --- a/tests/unit/src/com/android/intentresolver/platform/FakeSecureSettingsTest.kt +++ b/tests/unit/src/com/android/intentresolver/platform/FakeSecureSettingsTest.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.platform import com.google.common.truth.Truth.assertThat diff --git a/tests/unit/src/com/android/intentresolver/platform/FakeUserManagerTest.kt b/tests/unit/src/com/android/intentresolver/platform/FakeUserManagerTest.kt index 5be6b50e..fdc32207 100644 --- a/tests/unit/src/com/android/intentresolver/platform/FakeUserManagerTest.kt +++ b/tests/unit/src/com/android/intentresolver/platform/FakeUserManagerTest.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.platform import android.content.pm.UserInfo diff --git a/tests/unit/src/com/android/intentresolver/platform/NearbyShareModuleTest.kt b/tests/unit/src/com/android/intentresolver/platform/NearbyShareModuleTest.kt index 56b691e6..71ef2919 100644 --- a/tests/unit/src/com/android/intentresolver/platform/NearbyShareModuleTest.kt +++ b/tests/unit/src/com/android/intentresolver/platform/NearbyShareModuleTest.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.platform import android.content.ComponentName diff --git a/tests/unit/src/com/android/intentresolver/util/UriFiltersTest.kt b/tests/unit/src/com/android/intentresolver/util/UriFiltersTest.kt index 18218064..32c19f13 100644 --- a/tests/unit/src/com/android/intentresolver/util/UriFiltersTest.kt +++ b/tests/unit/src/com/android/intentresolver/util/UriFiltersTest.kt @@ -1,3 +1,19 @@ +/* + * Copyright (C) 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + package com.android.intentresolver.util import android.app.PendingIntent diff --git a/tests/unit/src/com/android/intentresolver/validation/ValidationTest.kt b/tests/unit/src/com/android/intentresolver/validation/ValidationTest.kt index 18cf2f26..93a5ec0c 100644 --- a/tests/unit/src/com/android/intentresolver/validation/ValidationTest.kt +++ b/tests/unit/src/com/android/intentresolver/validation/ValidationTest.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.validation import com.android.intentresolver.validation.types.value diff --git a/tests/unit/src/com/android/intentresolver/validation/types/IntentOrUriTest.kt b/tests/unit/src/com/android/intentresolver/validation/types/IntentOrUriTest.kt index c2ce5a6b..f8622ce0 100644 --- a/tests/unit/src/com/android/intentresolver/validation/types/IntentOrUriTest.kt +++ b/tests/unit/src/com/android/intentresolver/validation/types/IntentOrUriTest.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.validation.types import android.content.Intent diff --git a/tests/unit/src/com/android/intentresolver/validation/types/ParceledArrayTest.kt b/tests/unit/src/com/android/intentresolver/validation/types/ParceledArrayTest.kt index 6d513021..5284cbec 100644 --- a/tests/unit/src/com/android/intentresolver/validation/types/ParceledArrayTest.kt +++ b/tests/unit/src/com/android/intentresolver/validation/types/ParceledArrayTest.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.validation.types import android.content.Intent diff --git a/tests/unit/src/com/android/intentresolver/validation/types/SimpleValueTest.kt b/tests/unit/src/com/android/intentresolver/validation/types/SimpleValueTest.kt index fd740b6f..1b6bace1 100644 --- a/tests/unit/src/com/android/intentresolver/validation/types/SimpleValueTest.kt +++ b/tests/unit/src/com/android/intentresolver/validation/types/SimpleValueTest.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.validation.types import com.android.intentresolver.validation.Importance.CRITICAL -- cgit v1.2.3-59-g8ed1b From 6d8919f9ae045ca1fa1948d4482e39960a9c1e77 Mon Sep 17 00:00:00 2001 From: Mark Renouf Date: Tue, 9 Apr 2024 15:26:27 -0400 Subject: Fix import order to finally agree with checkstyle and IDE rules [automated cleanup] $ find . -name "*.java" | xargs google-java-format --aosp --fix-imports-only --replace Bug: 333533940 Test: N/A Flag: None Change-Id: I6a3ebe44c43d80529c046916afbe71e5f148c543 --- java/src/com/android/intentresolver/ChooserActivity.java | 4 ++-- java/src/com/android/intentresolver/ChooserRefinementManager.java | 1 - java/src/com/android/intentresolver/ChooserRequestParameters.java | 2 -- java/src/com/android/intentresolver/ResolverActivity.java | 4 ++-- java/src/com/android/intentresolver/SimpleIconFactory.java | 1 - .../intentresolver/contentpreview/ChooserContentPreviewUi.java | 4 ++-- .../contentpreview/FilesPlusTextContentPreviewUi.java | 4 ++-- .../intentresolver/contentpreview/UnifiedContentPreviewUi.java | 6 +++--- .../com/android/intentresolver/ChooserActivityWorkProfileTest.java | 2 +- .../src/com/android/intentresolver/logging/EventLogImplTest.java | 2 +- 10 files changed, 13 insertions(+), 17 deletions(-) (limited to 'java/src') diff --git a/java/src/com/android/intentresolver/ChooserActivity.java b/java/src/com/android/intentresolver/ChooserActivity.java index a2bde24c..7e2c9c5a 100644 --- a/java/src/com/android/intentresolver/ChooserActivity.java +++ b/java/src/com/android/intentresolver/ChooserActivity.java @@ -155,6 +155,8 @@ import dagger.hilt.android.AndroidEntryPoint; import kotlin.Pair; +import kotlinx.coroutines.CoroutineDispatcher; + import java.util.ArrayList; import java.util.Arrays; import java.util.Collections; @@ -172,8 +174,6 @@ import java.util.function.Supplier; import javax.inject.Inject; -import kotlinx.coroutines.CoroutineDispatcher; - /** * The Chooser Activity handles intent resolution specifically for sharing intents - * for example, as generated by {@see android.content.Intent#createChooser(Intent, CharSequence)}. diff --git a/java/src/com/android/intentresolver/ChooserRefinementManager.java b/java/src/com/android/intentresolver/ChooserRefinementManager.java index 474b240f..79484240 100644 --- a/java/src/com/android/intentresolver/ChooserRefinementManager.java +++ b/java/src/com/android/intentresolver/ChooserRefinementManager.java @@ -41,7 +41,6 @@ import java.util.function.Consumer; import javax.inject.Inject; - /** * Helper class to manage Sharesheet's "refinement" flow, where callers supply a "refinement * activity" that will be invoked when a target is selected, allowing the calling app to add diff --git a/java/src/com/android/intentresolver/ChooserRequestParameters.java b/java/src/com/android/intentresolver/ChooserRequestParameters.java index 6c7f8264..06f56e3b 100644 --- a/java/src/com/android/intentresolver/ChooserRequestParameters.java +++ b/java/src/com/android/intentresolver/ChooserRequestParameters.java @@ -16,7 +16,6 @@ package com.android.intentresolver; -import static java.util.Objects.requireNonNullElse; import android.content.ComponentName; import android.content.Intent; @@ -43,7 +42,6 @@ import java.net.URISyntaxException; import java.util.ArrayList; import java.util.Arrays; import java.util.List; -import java.util.Objects; import java.util.Optional; import java.util.stream.Collector; import java.util.stream.Collectors; diff --git a/java/src/com/android/intentresolver/ResolverActivity.java b/java/src/com/android/intentresolver/ResolverActivity.java index 17e957ae..2f1f8ee5 100644 --- a/java/src/com/android/intentresolver/ResolverActivity.java +++ b/java/src/com/android/intentresolver/ResolverActivity.java @@ -127,6 +127,8 @@ import dagger.hilt.android.AndroidEntryPoint; import kotlin.Pair; +import kotlinx.coroutines.CoroutineDispatcher; + import java.util.ArrayList; import java.util.Arrays; import java.util.Iterator; @@ -136,8 +138,6 @@ import java.util.Set; import javax.inject.Inject; -import kotlinx.coroutines.CoroutineDispatcher; - /** * This is a copy of ResolverActivity to support IntentResolver's ChooserActivity. This code is * *not* the resolver that is actually triggered by the system right now (you want diff --git a/java/src/com/android/intentresolver/SimpleIconFactory.java b/java/src/com/android/intentresolver/SimpleIconFactory.java index 750b24ac..f4871e36 100644 --- a/java/src/com/android/intentresolver/SimpleIconFactory.java +++ b/java/src/com/android/intentresolver/SimpleIconFactory.java @@ -58,7 +58,6 @@ import org.xmlpull.v1.XmlPullParser; import java.nio.ByteBuffer; import java.util.Optional; - /** * @deprecated Use the Launcher3 Iconloaderlib at packages/apps/Launcher3/iconloaderlib. This class * is a temporary fork of Iconloader. It combines all necessary methods to render app icons that are diff --git a/java/src/com/android/intentresolver/contentpreview/ChooserContentPreviewUi.java b/java/src/com/android/intentresolver/contentpreview/ChooserContentPreviewUi.java index 67458697..4cb30341 100644 --- a/java/src/com/android/intentresolver/contentpreview/ChooserContentPreviewUi.java +++ b/java/src/com/android/intentresolver/contentpreview/ChooserContentPreviewUi.java @@ -37,12 +37,12 @@ import com.android.intentresolver.ContentTypeHint; import com.android.intentresolver.widget.ActionRow; import com.android.intentresolver.widget.ImagePreviewView.TransitionElementStatusCallback; +import kotlinx.coroutines.CoroutineScope; + import java.util.List; import java.util.function.Consumer; import java.util.function.Supplier; -import kotlinx.coroutines.CoroutineScope; - /** * Collection of helpers for building the content preview UI displayed in * {@link com.android.intentresolver.ChooserActivity}. diff --git a/java/src/com/android/intentresolver/contentpreview/FilesPlusTextContentPreviewUi.java b/java/src/com/android/intentresolver/contentpreview/FilesPlusTextContentPreviewUi.java index 4758534d..0367e9d5 100644 --- a/java/src/com/android/intentresolver/contentpreview/FilesPlusTextContentPreviewUi.java +++ b/java/src/com/android/intentresolver/contentpreview/FilesPlusTextContentPreviewUi.java @@ -36,12 +36,12 @@ import com.android.intentresolver.R; import com.android.intentresolver.widget.ActionRow; import com.android.intentresolver.widget.ScrollableImagePreviewView; +import kotlinx.coroutines.CoroutineScope; + import java.util.HashMap; import java.util.List; import java.util.function.Consumer; -import kotlinx.coroutines.CoroutineScope; - /** * FilesPlusTextContentPreviewUi is shown when the user is sending 1 or more files along with * non-empty EXTRA_TEXT. The text can be toggled with a checkbox. If a single image file is being diff --git a/java/src/com/android/intentresolver/contentpreview/UnifiedContentPreviewUi.java b/java/src/com/android/intentresolver/contentpreview/UnifiedContentPreviewUi.java index b248e429..77252112 100644 --- a/java/src/com/android/intentresolver/contentpreview/UnifiedContentPreviewUi.java +++ b/java/src/com/android/intentresolver/contentpreview/UnifiedContentPreviewUi.java @@ -31,12 +31,12 @@ import com.android.intentresolver.widget.ActionRow; import com.android.intentresolver.widget.ImagePreviewView.TransitionElementStatusCallback; import com.android.intentresolver.widget.ScrollableImagePreviewView; -import java.util.List; -import java.util.Objects; - import kotlinx.coroutines.CoroutineScope; import kotlinx.coroutines.flow.Flow; +import java.util.List; +import java.util.Objects; + class UnifiedContentPreviewUi extends ContentPreviewUi { private final boolean mShowEditAction; @Nullable diff --git a/tests/activity/src/com/android/intentresolver/ChooserActivityWorkProfileTest.java b/tests/activity/src/com/android/intentresolver/ChooserActivityWorkProfileTest.java index 5795cc37..022ae2e1 100644 --- a/tests/activity/src/com/android/intentresolver/ChooserActivityWorkProfileTest.java +++ b/tests/activity/src/com/android/intentresolver/ChooserActivityWorkProfileTest.java @@ -27,7 +27,6 @@ import static androidx.test.espresso.matcher.ViewMatchers.isSelected; import static androidx.test.espresso.matcher.ViewMatchers.withId; import static androidx.test.espresso.matcher.ViewMatchers.withText; -import static com.android.intentresolver.ChooserWrapperActivity.sOverrides; import static com.android.intentresolver.ChooserActivityWorkProfileTest.TestCase.ExpectedBlocker.NO_BLOCKER; import static com.android.intentresolver.ChooserActivityWorkProfileTest.TestCase.ExpectedBlocker.PERSONAL_PROFILE_ACCESS_BLOCKER; import static com.android.intentresolver.ChooserActivityWorkProfileTest.TestCase.ExpectedBlocker.PERSONAL_PROFILE_SHARE_BLOCKER; @@ -35,6 +34,7 @@ import static com.android.intentresolver.ChooserActivityWorkProfileTest.TestCase import static com.android.intentresolver.ChooserActivityWorkProfileTest.TestCase.ExpectedBlocker.WORK_PROFILE_SHARE_BLOCKER; import static com.android.intentresolver.ChooserActivityWorkProfileTest.TestCase.Tab.PERSONAL; import static com.android.intentresolver.ChooserActivityWorkProfileTest.TestCase.Tab.WORK; +import static com.android.intentresolver.ChooserWrapperActivity.sOverrides; import static org.hamcrest.CoreMatchers.not; import static org.mockito.ArgumentMatchers.eq; diff --git a/tests/unit/src/com/android/intentresolver/logging/EventLogImplTest.java b/tests/unit/src/com/android/intentresolver/logging/EventLogImplTest.java index d75ea99b..feb277ea 100644 --- a/tests/unit/src/com/android/intentresolver/logging/EventLogImplTest.java +++ b/tests/unit/src/com/android/intentresolver/logging/EventLogImplTest.java @@ -32,10 +32,10 @@ import static org.mockito.Mockito.verifyNoMoreInteractions; import android.content.Intent; import android.metrics.LogMaker; +import com.android.intentresolver.contentpreview.ContentPreviewType; import com.android.intentresolver.logging.EventLogImpl.SharesheetStandardEvent; import com.android.intentresolver.logging.EventLogImpl.SharesheetStartedEvent; import com.android.intentresolver.logging.EventLogImpl.SharesheetTargetSelectedEvent; -import com.android.intentresolver.contentpreview.ContentPreviewType; import com.android.internal.logging.InstanceId; import com.android.internal.logging.InstanceIdSequence; import com.android.internal.logging.MetricsLogger; -- cgit v1.2.3-59-g8ed1b From 701ca1248f76f8998e8caaa9eba07d7cad853892 Mon Sep 17 00:00:00 2001 From: Matt Casey Date: Thu, 11 Apr 2024 15:54:58 +0000 Subject: Shareousel layout fixes for empty action row. Only show action row if it's non-empty. Move margin to only be added if there are actions. Bug: 328793346 Test: Visual verification with ShareTest Flag: ACONFIG android.service.chooser.chooser_payload_toggling NEXTFOOD Change-Id: I24464717f3a3f16e59348985ce3640d11c7e9853 --- .../ui/composable/ShareouselComposable.kt | 24 ++++++++++++---------- 1 file changed, 13 insertions(+), 11 deletions(-) (limited to 'java/src') diff --git a/java/src/com/android/intentresolver/contentpreview/payloadtoggle/ui/composable/ShareouselComposable.kt b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/ui/composable/ShareouselComposable.kt index feb6f3a8..991cfc5f 100644 --- a/java/src/com/android/intentresolver/contentpreview/payloadtoggle/ui/composable/ShareouselComposable.kt +++ b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/ui/composable/ShareouselComposable.kt @@ -76,7 +76,6 @@ private fun Shareousel(viewModel: ShareouselViewModel, keySet: PreviewsModel) { .padding(vertical = 16.dp), ) { PreviewCarousel(keySet, viewModel) - Spacer(Modifier.height(16.dp)) ActionCarousel(viewModel) } } @@ -153,16 +152,19 @@ private fun ShareouselCard(viewModel: ShareouselPreviewViewModel) { @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)) } + if (actions.isNotEmpty()) { + Spacer(Modifier.height(16.dp)) + 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)) } + } } } } -- cgit v1.2.3-59-g8ed1b From 880960a31f18163d39f2c5c63d7a195207c59171 Mon Sep 17 00:00:00 2001 From: Matt Casey Date: Thu, 11 Apr 2024 17:32:55 +0000 Subject: Add horizontal margins for shareousel actions. Bug: 328790561 Test: Visual verification with ShareTest Flag: ACONFIG android.service.chooser.chooser_payload_toggling NEXTFOOD Change-Id: I758d3c99440eb0abd22aae45dd9b496543199a48 --- .../payloadtoggle/ui/composable/ShareouselComposable.kt | 7 +++++++ 1 file changed, 7 insertions(+) (limited to 'java/src') diff --git a/java/src/com/android/intentresolver/contentpreview/payloadtoggle/ui/composable/ShareouselComposable.kt b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/ui/composable/ShareouselComposable.kt index 991cfc5f..7558d994 100644 --- a/java/src/com/android/intentresolver/contentpreview/payloadtoggle/ui/composable/ShareouselComposable.kt +++ b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/ui/composable/ShareouselComposable.kt @@ -29,6 +29,7 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyRow import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.itemsIndexed @@ -159,12 +160,18 @@ private fun ActionCarousel(viewModel: ShareouselViewModel) { modifier = Modifier.height(32.dp), ) { itemsIndexed(actions) { idx, actionViewModel -> + if (idx == 0) { + Spacer(Modifier.width(dimensionResource(R.dimen.chooser_edge_margin_normal))) + } ShareouselAction( label = actionViewModel.label, onClick = { actionViewModel.onClicked() }, ) { actionViewModel.icon?.let { Image(icon = it, modifier = Modifier.size(16.dp)) } } + if (idx == actions.size - 1) { + Spacer(Modifier.width(dimensionResource(R.dimen.chooser_edge_margin_normal))) + } } } } -- cgit v1.2.3-59-g8ed1b From 35e74a7706a9e6eec33d3a063ab31f19ebbe73a9 Mon Sep 17 00:00:00 2001 From: Andrey Epin Date: Wed, 10 Apr 2024 16:21:27 -0700 Subject: Payload selection change UI improvements. The main idea is to postpone the new pager adapter (along with the list adapters) attachment to the view until the data is fully loaded thus minimizing the number of UI updates. The old adapter gets destroyed but kept attached to the view and the destroyed status of the list adapters is used to derived the appropriate UI state (make target views disabled). Profile tab switchng is disabled for an active (executing or pending) selection callback. This is due to synchronization challenges between the old (attached) and the new (detached) pager adapters, and difficulty of tracking loading state for multiple adapters in the legacy code. Known issues: * profile buttons does not have a disabled state; * profile buttons use drawable with a fade-in animation that causes a flashing effect; * target icons are animated in that causes a flashing effect; * UI is not disabled when launched into an active selection callback (i.e. when re-created on rotation); * there are some visual artifacts when navigating between tabs after a selection change has been changed. Bug: 325465291 Test: atest IntentResolver-tests-unit Test: atest IntentResolver-tests-activity Test: manual testing with ShatTest app Change-Id: Ic9caa7c229b5ec0547be43974fa0c3536ecde095 --- .../android/intentresolver/ChooserActivity.java | 99 +++++++++++++++------- .../com/android/intentresolver/ChooserHelper.kt | 36 ++++++-- .../android/intentresolver/ChooserListAdapter.java | 18 ++++ .../intentresolver/ResolverListAdapter.java | 7 ++ .../android/intentresolver/ResolverViewPager.java | 8 +- .../ProcessTargetIntentUpdatesInteractor.kt | 2 +- .../interactor/UpdateChooserRequestInteractor.kt | 3 +- .../interactor/UpdateTargetIntentInteractor.kt | 2 +- .../profiles/MultiProfilePagerAdapter.java | 29 +++++-- 9 files changed, 153 insertions(+), 51 deletions(-) (limited to 'java/src') diff --git a/java/src/com/android/intentresolver/ChooserActivity.java b/java/src/com/android/intentresolver/ChooserActivity.java index a2bde24c..0db69552 100644 --- a/java/src/com/android/intentresolver/ChooserActivity.java +++ b/java/src/com/android/intentresolver/ChooserActivity.java @@ -84,6 +84,7 @@ import android.view.WindowManager; import android.widget.FrameLayout; import android.widget.ImageView; import android.widget.TabHost; +import android.widget.TabWidget; import android.widget.TextView; import android.widget.Toast; @@ -218,9 +219,10 @@ public class ChooserActivity extends Hilt_ChooserActivity implements private boolean mRegistered; private PackageMonitor mPersonalPackageMonitor; private PackageMonitor mWorkPackageMonitor; - protected View mProfileView; protected ResolverDrawerLayout mResolverDrawerLayout; + private TabHost mTabHost; + private ResolverViewPager mViewPager; protected ChooserMultiProfilePagerAdapter mChooserMultiProfilePagerAdapter; protected final LatencyTracker mLatencyTracker = getLatencyTracker(); @@ -305,8 +307,6 @@ public class ChooserActivity extends Hilt_ChooserActivity implements private final EnterTransitionAnimationDelegate mEnterTransitionAnimationDelegate = new EnterTransitionAnimationDelegate(this, () -> mResolverDrawerLayout); - private final View mContentView = null; - private final Map mProfileRecords = new HashMap<>(); private boolean mExcludeSharedText = false; @@ -345,6 +345,7 @@ public class ChooserActivity extends Hilt_ChooserActivity implements mChooserHelper.setInitializer(this::initialize); if (mChooserServiceFeatureFlags.chooserPayloadToggling()) { mChooserHelper.setOnChooserRequestChanged(this::onChooserRequestChanged); + mChooserHelper.setOnPendingSelection(this::onPendingSelection); } } @@ -406,9 +407,8 @@ public class ChooserActivity extends Hilt_ChooserActivity implements @Override protected final void onSaveInstanceState(Bundle outState) { super.onSaveInstanceState(outState); - ViewPager viewPager = findViewById(com.android.internal.R.id.profile_pager); - if (viewPager != null) { - outState.putInt(LAST_SHOWN_TAB_KEY, viewPager.getCurrentItem()); + if (mViewPager != null) { + outState.putInt(LAST_SHOWN_TAB_KEY, mViewPager.getCurrentItem()); } } @@ -648,7 +648,7 @@ public class ChooserActivity extends Hilt_ChooserActivity implements } private void onChooserRequestChanged(ChooserRequest chooserRequest) { - // intentional reference comarison + // intentional reference comparison if (mRequest == chooserRequest) { return; } @@ -658,6 +658,23 @@ public class ChooserActivity extends Hilt_ChooserActivity implements mChooserContentPreviewUi.updateModifyShareAction(); if (recreateAdapters) { recreatePagerAdapter(); + } else { + setTabsViewEnabled(true); + } + } + + private void onPendingSelection() { + setTabsViewEnabled(false); + } + + private void onAppTargetsLoaded(ResolverListAdapter listAdapter) { + if (mChooserMultiProfilePagerAdapter == null) { + return; + } + if (!isProfilePagerAdapterAttached() + && listAdapter == mChooserMultiProfilePagerAdapter.getActiveListAdapter()) { + mChooserMultiProfilePagerAdapter.setupViewPager(mViewPager); + setTabsViewEnabled(true); } } @@ -698,9 +715,12 @@ public class ChooserActivity extends Hilt_ChooserActivity implements mRequest.getShareTargetFilter() ); + int currentPage = mChooserMultiProfilePagerAdapter.getCurrentPage(); if (mChooserMultiProfilePagerAdapter != null) { mChooserMultiProfilePagerAdapter.destroy(); } + // Update the pager adapter but do not attach it to the view till the targets are reloaded, + // see onChooserAppTargetsLoaded method. mChooserMultiProfilePagerAdapter = createMultiProfilePagerAdapter( /* context = */ this, mProfilePagerResources, @@ -710,8 +730,7 @@ public class ChooserActivity extends Hilt_ChooserActivity implements mRequest.getInitialIntents(), mMaxTargetsPerRow, mFeatureFlags); - mChooserMultiProfilePagerAdapter.setupViewPager( - requireViewById(com.android.internal.R.id.profile_pager)); + mChooserMultiProfilePagerAdapter.setCurrentPage(currentPage); if (mPersonalPackageMonitor != null) { mPersonalPackageMonitor.unregister(); } @@ -736,15 +755,25 @@ public class ChooserActivity extends Hilt_ChooserActivity implements } postRebuildList( mChooserMultiProfilePagerAdapter.rebuildTabs( - mProfiles.getWorkProfilePresent() - || mProfiles.getPrivateProfilePresent())); + mProfiles.getWorkProfilePresent() || mProfiles.getPrivateProfilePresent())); + setTabsViewEnabled(false); + } + + private void setTabsViewEnabled(boolean isEnabled) { + TabWidget tabs = mTabHost.getTabWidget(); + if (tabs != null) { + tabs.setEnabled(isEnabled); + } + View tabContent = mTabHost.findViewById(com.android.internal.R.id.profile_pager); + if (tabContent != null) { + tabContent.setEnabled(isEnabled); + } } @Override protected 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)); + if (mViewPager != null) { + mViewPager.setCurrentItem(savedInstanceState.getInt(LAST_SHOWN_TAB_KEY)); } mChooserMultiProfilePagerAdapter.clearInactiveProfileCache(); } @@ -1168,8 +1197,9 @@ public class ChooserActivity extends Hilt_ChooserActivity implements : R.layout.chooser_grid; setContentView(mLayoutId); - mChooserMultiProfilePagerAdapter.setupViewPager( - requireViewById(com.android.internal.R.id.profile_pager)); + mTabHost = findViewById(com.android.internal.R.id.profile_tabhost); + mViewPager = requireViewById(com.android.internal.R.id.profile_pager); + mChooserMultiProfilePagerAdapter.setupViewPager(mViewPager); boolean result = postRebuildList(rebuildCompleted); Trace.endSection(); return result; @@ -1236,16 +1266,13 @@ public class ChooserActivity extends Hilt_ChooserActivity implements } private void setupProfileTabs() { - TabHost tabHost = findViewById(com.android.internal.R.id.profile_tabhost); - ViewPager viewPager = findViewById(com.android.internal.R.id.profile_pager); - mChooserMultiProfilePagerAdapter.setupProfileTabs( getLayoutInflater(), - tabHost, - viewPager, + mTabHost, + mViewPager, R.layout.resolver_profile_tab_button, com.android.internal.R.id.profile_pager, - () -> onProfileTabSelected(viewPager.getCurrentItem()), + () -> onProfileTabSelected(mViewPager.getCurrentItem()), new OnProfileSelectedListener() { @Override public void onProfilePageSelected(@ProfileType int profileId, int pageNumber) {} @@ -1256,8 +1283,8 @@ public class ChooserActivity extends Hilt_ChooserActivity implements } }); mOnSwitchOnWorkSelectedListener = () -> { - View workTab = tabHost.getTabWidget().getChildAt( - mChooserMultiProfilePagerAdapter.getPageNumberForProfile(PROFILE_WORK)); + View workTab = mTabHost.getTabWidget().getChildAt( + mChooserMultiProfilePagerAdapter.getPageNumberForProfile(PROFILE_WORK)); workTab.setFocusable(true); workTab.setFocusableInTouchMode(true); workTab.requestFocus(); @@ -1487,9 +1514,8 @@ public class ChooserActivity extends Hilt_ChooserActivity implements 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); + if (mViewPager.isLayoutRtl()) { + mChooserMultiProfilePagerAdapter.setupViewPager(mViewPager); } mShouldDisplayLandscape = shouldDisplayLandscape(newConfig.orientation); @@ -2152,7 +2178,7 @@ public class ChooserActivity extends Hilt_ChooserActivity implements */ private void handleLayoutChange(View v, int left, int top, int right, int bottom, int oldLeft, int oldTop, int oldRight, int oldBottom) { - if (mChooserMultiProfilePagerAdapter == null) { + if (mChooserMultiProfilePagerAdapter == null || !isProfilePagerAdapterAttached()) { return; } RecyclerView recyclerView = mChooserMultiProfilePagerAdapter.getActiveAdapterView(); @@ -2269,6 +2295,10 @@ public class ChooserActivity extends Hilt_ChooserActivity implements return Math.min(offset, bottom - top); } + private boolean isProfilePagerAdapterAttached() { + return mChooserMultiProfilePagerAdapter == mViewPager.getAdapter(); + } + /** * If we have a tabbed view and are showing 1 row in the current profile and an empty * state screen in another profile, to prevent cropping of the empty state screen we show @@ -2296,9 +2326,17 @@ public class ChooserActivity extends Hilt_ChooserActivity implements //TODO: move this block inside ChooserListAdapter (should be called when // ResolverListAdapter#mPostListReadyRunnable is executed. if (chooserListAdapter.getDisplayResolveInfoCount() == 0) { + if (rebuildComplete && mChooserServiceFeatureFlags.chooserPayloadToggling()) { + onAppTargetsLoaded(listAdapter); + } chooserListAdapter.notifyDataSetChanged(); } else { - chooserListAdapter.updateAlphabeticalList(); + if (mChooserServiceFeatureFlags.chooserPayloadToggling()) { + chooserListAdapter.updateAlphabeticalList( + () -> onAppTargetsLoaded(listAdapter)); + } else { + chooserListAdapter.updateAlphabeticalList(); + } } if (rebuildComplete) { @@ -2542,8 +2580,7 @@ public class ChooserActivity extends Hilt_ChooserActivity implements } private void setHorizontalScrollingEnabled(boolean enabled) { - ResolverViewPager viewPager = findViewById(com.android.internal.R.id.profile_pager); - viewPager.setSwipingEnabled(enabled); + mViewPager.setSwipingEnabled(enabled); } private void setVerticalScrollEnabled(boolean enabled) { diff --git a/java/src/com/android/intentresolver/ChooserHelper.kt b/java/src/com/android/intentresolver/ChooserHelper.kt index 25c2b40f..6317ee1d 100644 --- a/java/src/com/android/intentresolver/ChooserHelper.kt +++ b/java/src/com/android/intentresolver/ChooserHelper.kt @@ -28,9 +28,8 @@ import androidx.lifecycle.lifecycleScope import androidx.lifecycle.repeatOnLifecycle import com.android.intentresolver.annotation.JavaInterop import com.android.intentresolver.contentpreview.payloadtoggle.data.repository.ActivityResultRepository +import com.android.intentresolver.contentpreview.payloadtoggle.data.repository.PendingSelectionCallbackRepository import com.android.intentresolver.data.model.ChooserRequest -import com.android.intentresolver.domain.interactor.UserInteractor -import com.android.intentresolver.inject.Background import com.android.intentresolver.ui.viewmodel.ChooserViewModel import com.android.intentresolver.validation.Invalid import com.android.intentresolver.validation.Valid @@ -38,9 +37,13 @@ import com.android.intentresolver.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.combine +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.filter import kotlinx.coroutines.flow.filterNotNull import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.launch private const val TAG: String = "ChooserHelper" @@ -79,17 +82,19 @@ class ChooserHelper @Inject constructor( hostActivity: Activity, - private val userInteractor: UserInteractor, private val activityResultRepo: ActivityResultRepository, - @Background private val background: CoroutineDispatcher, + private val pendingSelectionCallbackRepo: PendingSelectionCallbackRepository, ) : DefaultLifecycleObserver { // This is guaranteed by Hilt, since only a ComponentActivity is injectable. private val activity: ComponentActivity = hostActivity as ComponentActivity private val viewModel by activity.viewModels() + // TODO: provide the following through an init object passed into [setInitialize] private lateinit var activityInitializer: Runnable - + /** Invoked when there are updates to ChooserRequest */ var onChooserRequestChanged: Consumer = Consumer {} + /** Invoked when there are a new change to payload selection */ + var onPendingSelection: Runnable = Runnable {} init { activity.lifecycle.addObserver(this) @@ -130,8 +135,25 @@ constructor( } activity.lifecycleScope.launch { + val hasPendingCallbackFlow = + pendingSelectionCallbackRepo.pendingTargetIntent + .map { it != null } + .distinctUntilChanged() + .onEach { hasPendingCallback -> + if (hasPendingCallback) { + onPendingSelection.run() + } + } activity.lifecycle.repeatOnLifecycle(Lifecycle.State.STARTED) { - viewModel.request.collect { onChooserRequestChanged.accept(it) } + viewModel.request + .combine(hasPendingCallbackFlow) { request, hasPendingCallback -> + request to hasPendingCallback + } + // only take ChooserRequest if there are no pending callbacks + .filter { !it.second } + .map { it.first } + .distinctUntilChanged(areEquivalent = { old, new -> old === new }) + .collect { onChooserRequestChanged.accept(it) } } } } diff --git a/java/src/com/android/intentresolver/ChooserListAdapter.java b/java/src/com/android/intentresolver/ChooserListAdapter.java index e8d4fdde..29b5698b 100644 --- a/java/src/com/android/intentresolver/ChooserListAdapter.java +++ b/java/src/com/android/intentresolver/ChooserListAdapter.java @@ -347,9 +347,16 @@ public class ChooserListAdapter extends ResolverListAdapter { false); } + @Override + public void onDestroy() { + super.onDestroy(); + notifyDataSetChanged(); + } + @VisibleForTesting @Override public void onBindView(View view, TargetInfo info, int position) { + view.setEnabled(!isDestroyed()); final ViewHolder holder = (ViewHolder) view.getTag(); resetViewHolder(holder); @@ -478,7 +485,17 @@ public class ChooserListAdapter extends ResolverListAdapter { } } + /** + * Group application targets + */ public void updateAlphabeticalList() { + updateAlphabeticalList(() -> {}); + } + + /** + * Group application targets + */ + public void updateAlphabeticalList(Runnable onCompleted) { final DisplayResolveInfoAzInfoComparator comparator = new DisplayResolveInfoAzInfoComparator(mContext); final List allTargets = new ArrayList<>(); @@ -523,6 +540,7 @@ public class ChooserListAdapter extends ResolverListAdapter { mSortedList.clear(); mSortedList.addAll(newList); notifyDataSetChanged(); + onCompleted.run(); } private void loadMissingLabels(List targets) { diff --git a/java/src/com/android/intentresolver/ResolverListAdapter.java b/java/src/com/android/intentresolver/ResolverListAdapter.java index 80d07d2c..9843cf8d 100644 --- a/java/src/com/android/intentresolver/ResolverListAdapter.java +++ b/java/src/com/android/intentresolver/ResolverListAdapter.java @@ -448,6 +448,9 @@ public class ResolverListAdapter extends BaseAdapter { // Send an "incomplete" list-ready while the async task is running. postListReadyRunnable(doPostProcessing, /* rebuildCompleted */ false); mBgExecutor.execute(() -> { + if (isDestroyed()) { + return; + } List sortedComponents = null; //TODO: the try-catch logic here is to formally match the AsyncTask's behavior. // Empirically, we don't need it as in the case on an exception, the app will crash and @@ -785,6 +788,10 @@ public class ResolverListAdapter extends BaseAdapter { mRequestedLabels.clear(); } + public final boolean isDestroyed() { + return mDestroyed.get(); + } + private static ColorMatrixColorFilter getSuspendedColorMatrix() { if (sSuspendedMatrixColorFilter == null) { diff --git a/java/src/com/android/intentresolver/ResolverViewPager.java b/java/src/com/android/intentresolver/ResolverViewPager.java index 0496579d..891ace87 100644 --- a/java/src/com/android/intentresolver/ResolverViewPager.java +++ b/java/src/com/android/intentresolver/ResolverViewPager.java @@ -75,6 +75,12 @@ public class ResolverViewPager extends ViewPager { @Override public boolean onInterceptTouchEvent(MotionEvent ev) { - return !isLayoutRtl() && mSwipingEnabled && super.onInterceptTouchEvent(ev); + return !isEnabled() + || (!isLayoutRtl() && mSwipingEnabled && super.onInterceptTouchEvent(ev)); + } + + @Override + public boolean onTouchEvent(MotionEvent ev) { + return isEnabled() && super.onTouchEvent(ev); } } diff --git a/java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/interactor/ProcessTargetIntentUpdatesInteractor.kt b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/interactor/ProcessTargetIntentUpdatesInteractor.kt index 04416a3d..c202eabf 100644 --- a/java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/interactor/ProcessTargetIntentUpdatesInteractor.kt +++ b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/interactor/ProcessTargetIntentUpdatesInteractor.kt @@ -34,7 +34,7 @@ constructor( repository.pendingTargetIntent.collectLatest { targetIntent -> targetIntent ?: return@collectLatest selectionCallback.onSelectionChanged(targetIntent)?.let { update -> - chooserRequestInteractor.applyUpdate(update) + chooserRequestInteractor.applyUpdate(targetIntent, update) } repository.pendingTargetIntent.compareAndSet(targetIntent, null) } 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 index 941dfca1..dd16f0c1 100644 --- a/java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/interactor/UpdateChooserRequestInteractor.kt +++ b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/interactor/UpdateChooserRequestInteractor.kt @@ -34,9 +34,10 @@ constructor( private val repository: ChooserRequestRepository, @CustomAction private val pendingIntentSender: PendingIntentSender, ) { - fun applyUpdate(update: ShareouselUpdate) { + fun applyUpdate(targetIntent: Intent, update: ShareouselUpdate) { repository.chooserRequest.update { current -> current.copy( + targetIntent = targetIntent, callerChooserTargets = update.callerTargets.getOrDefault(current.callerChooserTargets), modifyShareAction = 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 index 429e34e9..d99d69ab 100644 --- a/java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/interactor/UpdateTargetIntentInteractor.kt +++ b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/interactor/UpdateTargetIntentInteractor.kt @@ -31,7 +31,7 @@ constructor( * sharing application, so that it can react to the new intent. */ fun updateTargetIntent(targetIntent: Intent) { - chooserRequestInteractor.setTargetIntent(targetIntent) repository.pendingTargetIntent.value = targetIntent + chooserRequestInteractor.setTargetIntent(targetIntent) } } diff --git a/java/src/com/android/intentresolver/profiles/MultiProfilePagerAdapter.java b/java/src/com/android/intentresolver/profiles/MultiProfilePagerAdapter.java index 48de37de..11a6caca 100644 --- a/java/src/com/android/intentresolver/profiles/MultiProfilePagerAdapter.java +++ b/java/src/com/android/intentresolver/profiles/MultiProfilePagerAdapter.java @@ -302,15 +302,7 @@ public class MultiProfilePagerAdapter< viewPager.setOnPageChangeListener(new ViewPager.SimpleOnPageChangeListener() { @Override public void onPageSelected(int position) { - mCurrentPage = position; - if (!mLoadedPages.contains(position)) { - rebuildActiveTab(true); - mLoadedPages.add(position); - } - if (mOnProfileSelectedListener != null) { - mOnProfileSelectedListener.onProfilePageSelected( - getProfileForPageNumber(position), position); - } + MultiProfilePagerAdapter.this.onPageSelected(position); } @Override @@ -325,6 +317,18 @@ public class MultiProfilePagerAdapter< mLoadedPages.add(mCurrentPage); } + private void onPageSelected(int position) { + mCurrentPage = position; + if (!mLoadedPages.contains(position)) { + rebuildActiveTab(true); + mLoadedPages.add(position); + } + if (mOnProfileSelectedListener != null) { + mOnProfileSelectedListener.onProfilePageSelected( + getProfileForPageNumber(position), position); + } + } + public void clearInactiveProfileCache() { forEachInactivePage(pageNumber -> mLoadedPages.remove(pageNumber)); } @@ -351,6 +355,13 @@ public class MultiProfilePagerAdapter< return mCurrentPage; } + /** + * Set active adapter page. A support method for the poayload reselection logic. + */ + public void setCurrentPage(int page) { + onPageSelected(page); + } + public final @ProfileType int getActiveProfile() { return getProfileForPageNumber(getCurrentPage()); } -- cgit v1.2.3-59-g8ed1b From dd541997aa8c9eff1b64f035afc1a7bb86b96517 Mon Sep 17 00:00:00 2001 From: Matt Casey Date: Fri, 12 Apr 2024 19:22:07 +0000 Subject: Add metadata text to shareousel UI Test: atest ShareouselViewModelTest Test: Manual testing with ShareTest (updating string on toggle) Bug: 325463573 Bug: 325462259 Flag: ACONFIG android.service.chooser.chooser_payload_toggling NEXTFOOD Change-Id: I73d2ee2ad37321a8ebf9e559e37427563c2e2ffa --- .../contentpreview/ShareouselContentPreviewUi.kt | 24 +++++++++++++++++++++- .../domain/interactor/ChooserRequestInteractor.kt | 3 +++ .../ui/viewmodel/ShareouselViewModel.kt | 5 +++++ .../ui/viewmodel/ShareouselViewModelTest.kt | 18 ++++++++++++++++ 4 files changed, 49 insertions(+), 1 deletion(-) (limited to 'java/src') diff --git a/java/src/com/android/intentresolver/contentpreview/ShareouselContentPreviewUi.kt b/java/src/com/android/intentresolver/contentpreview/ShareouselContentPreviewUi.kt index fa0859e0..5fce711c 100644 --- a/java/src/com/android/intentresolver/contentpreview/ShareouselContentPreviewUi.kt +++ b/java/src/com/android/intentresolver/contentpreview/ShareouselContentPreviewUi.kt @@ -33,6 +33,8 @@ import com.android.intentresolver.R import com.android.intentresolver.contentpreview.payloadtoggle.ui.composable.Shareousel import com.android.intentresolver.contentpreview.payloadtoggle.ui.viewmodel.ShareouselViewModel import com.android.intentresolver.ui.viewmodel.ChooserViewModel +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.launch @VisibleForTesting(otherwise = VisibleForTesting.PACKAGE_PRIVATE) class ShareouselContentPreviewUi : ContentPreviewUi() { @@ -56,7 +58,7 @@ class ShareouselContentPreviewUi : ContentPreviewUi() { val viewModel: ShareouselViewModel = vm.shareouselViewModel headlineViewParent?.let { - LaunchedEffect(viewModel) { bindHeadline(viewModel, headlineViewParent) } + LaunchedEffect(viewModel) { bindHeader(viewModel, headlineViewParent) } } MaterialTheme( @@ -73,6 +75,13 @@ class ShareouselContentPreviewUi : ContentPreviewUi() { } } + private suspend fun bindHeader(viewModel: ShareouselViewModel, headlineViewParent: View) { + coroutineScope { + launch { bindHeadline(viewModel, headlineViewParent) } + launch { bindMetadataText(viewModel, headlineViewParent) } + } + } + private suspend fun bindHeadline(viewModel: ShareouselViewModel, headlineViewParent: View) { viewModel.headline.collect { headline -> headlineViewParent.findViewById(R.id.headline)?.apply { @@ -85,4 +94,17 @@ class ShareouselContentPreviewUi : ContentPreviewUi() { } } } + + private suspend fun bindMetadataText(viewModel: ShareouselViewModel, headlineViewParent: View) { + viewModel.metadataText.collect { metadata -> + headlineViewParent.findViewById(R.id.metadata)?.apply { + if (metadata?.isNotBlank() == true) { + text = metadata + visibility = View.VISIBLE + } else { + visibility = View.GONE + } + } + } + } } diff --git a/java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/interactor/ChooserRequestInteractor.kt b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/interactor/ChooserRequestInteractor.kt index c70fc83e..953e91b3 100644 --- a/java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/interactor/ChooserRequestInteractor.kt +++ b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/interactor/ChooserRequestInteractor.kt @@ -35,4 +35,7 @@ constructor( val customActions: Flow> get() = repository.customActions.asSharedFlow() + + val metadataText: Flow + get() = repository.chooserRequest.map { it.metadataText } } 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 index 6eccaffa..082581dc 100644 --- a/java/src/com/android/intentresolver/contentpreview/payloadtoggle/ui/viewmodel/ShareouselViewModel.kt +++ b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/ui/viewmodel/ShareouselViewModel.kt @@ -21,6 +21,7 @@ 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.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 @@ -47,6 +48,8 @@ import kotlinx.coroutines.plus data class ShareouselViewModel( /** Text displayed at the top of the share sheet when Shareousel is present. */ val headline: Flow, + /** App-provided text shown beneath the headline. */ + val metadataText: Flow, /** * Previews which are available for presentation within Shareousel. Use [preview] to create a * [ShareouselPreviewViewModel] for a given [PreviewModel]. @@ -68,6 +71,7 @@ object ShareouselViewModelModule { actionsInteractor: CustomActionsInteractor, headlineGenerator: HeadlineGenerator, selectionInteractor: SelectionInteractor, + chooserRequestInteractor: ChooserRequestInteractor, // TODO: remove if possible @ViewModelOwned scope: CoroutineScope, ): ShareouselViewModel { @@ -87,6 +91,7 @@ object ShareouselViewModelModule { ContentType.Video -> headlineGenerator.getVideosHeadline(numItems) } }, + metadataText = chooserRequestInteractor.metadataText, previews = keySet, actions = actionsInteractor.customActions.map { actions -> 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 index e5c91e80..35ef6613 100644 --- 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 @@ -33,6 +33,7 @@ import com.android.intentresolver.contentpreview.payloadtoggle.domain.intent.Pen import com.android.intentresolver.contentpreview.payloadtoggle.domain.intent.TargetIntentModifier import com.android.intentresolver.contentpreview.payloadtoggle.domain.intent.pendingIntentSender import com.android.intentresolver.contentpreview.payloadtoggle.domain.intent.targetIntentModifier +import com.android.intentresolver.contentpreview.payloadtoggle.domain.interactor.chooserRequestInteractor import com.android.intentresolver.contentpreview.payloadtoggle.domain.interactor.customActionsInteractor import com.android.intentresolver.contentpreview.payloadtoggle.domain.interactor.headlineGenerator import com.android.intentresolver.contentpreview.payloadtoggle.domain.interactor.payloadToggleImageLoader @@ -40,6 +41,7 @@ import com.android.intentresolver.contentpreview.payloadtoggle.domain.interactor 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.data.model.ChooserRequest import com.android.intentresolver.data.repository.chooserRequestRepository import com.android.intentresolver.icon.BitmapIcon import com.android.intentresolver.logging.FakeEventLog @@ -65,6 +67,7 @@ class ShareouselViewModelTest { imageLoader = payloadToggleImageLoader, actionsInteractor = customActionsInteractor, headlineGenerator = headlineGenerator, + chooserRequestInteractor = chooserRequestInteractor, selectionInteractor = selectionInteractor, scope = viewModelScope, ) @@ -88,6 +91,21 @@ class ShareouselViewModelTest { assertThat(shareouselViewModel.headline.first()).isEqualTo("IMAGES: 2") } + @Test + fun metadataText() = runTest { + val request = + ChooserRequest( + targetIntent = Intent(), + launchedFromPackage = "", + metadataText = "Hello" + ) + chooserRequestRepository.chooserRequest.value = request + + runCurrent() + + assertThat(shareouselViewModel.metadataText.first()).isEqualTo("Hello") + } + @Test fun previews() = runTest(targetIntentModifier = { Intent() }) { -- cgit v1.2.3-59-g8ed1b From 8f765fb2b0e0589c30a7329fde05d3d3c81e4b19 Mon Sep 17 00:00:00 2001 From: Matt Casey Date: Fri, 12 Apr 2024 16:24:14 +0000 Subject: Update shareousel actions to align with mock Bug: 302691505 Test: Visual verification Flag: ACONFIG android.service.chooser.chooser_payload_toggling NEXTFOOD Change-Id: I86b8faf2c250fe1e6b6ca846d912669884156361 --- .../ui/composable/ComposeIconComposable.kt | 19 +++++++++++++++---- .../ui/composable/ShareouselComposable.kt | 21 +++++++++++++++++++-- 2 files changed, 34 insertions(+), 6 deletions(-) (limited to 'java/src') diff --git a/java/src/com/android/intentresolver/contentpreview/payloadtoggle/ui/composable/ComposeIconComposable.kt b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/ui/composable/ComposeIconComposable.kt index 38138225..8cf237da 100644 --- a/java/src/com/android/intentresolver/contentpreview/payloadtoggle/ui/composable/ComposeIconComposable.kt +++ b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/ui/composable/ComposeIconComposable.kt @@ -22,6 +22,7 @@ import androidx.compose.foundation.Image import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.ColorFilter import androidx.compose.ui.graphics.asImageBitmap import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.painterResource @@ -31,11 +32,16 @@ import com.android.intentresolver.icon.ComposeIcon import com.android.intentresolver.icon.ResourceIcon @Composable -fun Image(icon: ComposeIcon, modifier: Modifier = Modifier) { +fun Image(icon: ComposeIcon, modifier: Modifier = Modifier, colorFilter: ColorFilter? = null) { when (icon) { - is AdaptiveIcon -> Image(icon.wrapped, modifier) + is AdaptiveIcon -> Image(icon.wrapped, modifier, colorFilter = colorFilter) is BitmapIcon -> - Image(icon.bitmap.asImageBitmap(), contentDescription = null, modifier = modifier) + Image( + icon.bitmap.asImageBitmap(), + contentDescription = null, + modifier = modifier, + colorFilter = colorFilter + ) is ResourceIcon -> { val localContext = LocalContext.current val wrappedContext: Context = @@ -43,7 +49,12 @@ fun Image(icon: ComposeIcon, modifier: Modifier = Modifier) { override fun getResources(): Resources = icon.res } CompositionLocalProvider(LocalContext provides wrappedContext) { - Image(painterResource(icon.resId), contentDescription = null, modifier = modifier) + Image( + painterResource(icon.resId), + contentDescription = null, + modifier = modifier, + colorFilter = colorFilter + ) } } } diff --git a/java/src/com/android/intentresolver/contentpreview/payloadtoggle/ui/composable/ShareouselComposable.kt b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/ui/composable/ShareouselComposable.kt index 7558d994..0cb7306d 100644 --- a/java/src/com/android/intentresolver/contentpreview/payloadtoggle/ui/composable/ShareouselComposable.kt +++ b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/ui/composable/ShareouselComposable.kt @@ -36,6 +36,8 @@ import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.AssistChip +import androidx.compose.material3.AssistChipDefaults +import androidx.compose.material3.LocalContentColor import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable @@ -44,6 +46,7 @@ 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.ColorFilter import androidx.compose.ui.graphics.RectangleShape import androidx.compose.ui.graphics.asImageBitmap import androidx.compose.ui.layout.ContentScale @@ -167,7 +170,13 @@ private fun ActionCarousel(viewModel: ShareouselViewModel) { label = actionViewModel.label, onClick = { actionViewModel.onClicked() }, ) { - actionViewModel.icon?.let { Image(icon = it, modifier = Modifier.size(16.dp)) } + actionViewModel.icon?.let { + Image( + icon = it, + modifier = Modifier.size(16.dp), + colorFilter = ColorFilter.tint(LocalContentColor.current) + ) + } } if (idx == actions.size - 1) { Spacer(Modifier.width(dimensionResource(R.dimen.chooser_edge_margin_normal))) @@ -188,7 +197,15 @@ private fun ShareouselAction( onClick = onClick, label = { Text(label) }, leadingIcon = leadingIcon, - modifier = modifier + border = null, + shape = RoundedCornerShape(1000.dp), // pill shape. + colors = + AssistChipDefaults.assistChipColors( + containerColor = MaterialTheme.colorScheme.surfaceContainerHigh, + labelColor = MaterialTheme.colorScheme.onSurface, + leadingIconContentColor = MaterialTheme.colorScheme.onSurface + ), + modifier = modifier, ) } -- cgit v1.2.3-59-g8ed1b From 6ee42dcf70adbc77d4c923383df627f83a574b1d Mon Sep 17 00:00:00 2001 From: Alan Chen Date: Mon, 15 Apr 2024 15:53:22 -0700 Subject: Fix NPE when edit action is null Make editButtonRunnable null when there is no valid editSharingTarget to prevent a NPE that will crash the app creating the sharesheet. This will remove the edit button in ChooserContentPreviewUi when no editor is available. Bug: 330383989 Test: manual - see recording in issue Change-Id: I3d84ce548bf303062bf6b3bd0a2e6c396ddbb86c --- .../android/intentresolver/ChooserActionFactory.java | 19 ++++++++++++------- 1 file changed, 12 insertions(+), 7 deletions(-) (limited to 'java/src') diff --git a/java/src/com/android/intentresolver/ChooserActionFactory.java b/java/src/com/android/intentresolver/ChooserActionFactory.java index ffe83fa6..79998fbc 100644 --- a/java/src/com/android/intentresolver/ChooserActionFactory.java +++ b/java/src/com/android/intentresolver/ChooserActionFactory.java @@ -99,7 +99,7 @@ public final class ChooserActionFactory implements ChooserContentPreviewUi.Actio private final Context mContext; @Nullable private Runnable mCopyButtonRunnable; - private Runnable mEditButtonRunnable; + @Nullable private Runnable mEditButtonRunnable; private final ImmutableList mCustomActions; private final Consumer mExcludeSharedTextAction; @Nullable private final ShareResultSender mShareResultSender; @@ -158,7 +158,7 @@ public final class ChooserActionFactory implements ChooserContentPreviewUi.Actio ChooserActionFactory( Context context, @Nullable Runnable copyButtonRunnable, - Runnable editButtonRunnable, + @Nullable Runnable editButtonRunnable, List customActions, Consumer onUpdateSharedTextIsExcluded, EventLog log, @@ -174,10 +174,12 @@ public final class ChooserActionFactory implements ChooserContentPreviewUi.Actio mFinishCallback = finishCallback; if (mShareResultSender != null) { - mEditButtonRunnable = () -> { - mShareResultSender.onActionSelected(ShareAction.SYSTEM_EDIT); - editButtonRunnable.run(); - }; + if (mEditButtonRunnable != null) { + mEditButtonRunnable = () -> { + mShareResultSender.onActionSelected(ShareAction.SYSTEM_EDIT); + editButtonRunnable.run(); + }; + } if (mCopyButtonRunnable != null) { mCopyButtonRunnable = () -> { mShareResultSender.onActionSelected(ShareAction.SYSTEM_COPY); @@ -281,6 +283,7 @@ public final class ChooserActionFactory implements ChooserContentPreviewUi.Actio return clipData; } + @Nullable private static TargetInfo getEditSharingTarget( Context context, Intent originalIntent, @@ -325,11 +328,13 @@ public final class ChooserActionFactory implements ChooserContentPreviewUi.Actio return dri; } + @Nullable private static Runnable makeEditButtonRunnable( - TargetInfo editSharingTarget, + @Nullable TargetInfo editSharingTarget, Callable firstVisibleImageQuery, ActionActivityStarter activityStarter, EventLog log) { + if (editSharingTarget == null) return null; return () -> { // Log share completion via edit. log.logActionSelected(EventLog.SELECTION_TYPE_EDIT); -- cgit v1.2.3-59-g8ed1b From 4ca5dc54a57d61b8bd116a2259f51a7cdc5ea184 Mon Sep 17 00:00:00 2001 From: Mark Renouf Date: Wed, 17 Apr 2024 17:26:13 -0400 Subject: Fix "NoAppsAvailableEmptyStateProvider" message for Private Updates the logic to simplify and correct correct labels for private profile. When there is only a single visible profile (personal), the default empty state is returned, otherwise present the more informative "No work apps", "No private apps" or "No personal apps" is shown. To send an intent to share via Sharesheet which matches no installed apps: adb shell am start -a android.intent.action.CHOOSER --eu android.intent.extra.INTENT data:text/plain;base64,SGVsbG8sIFdvcmxkIQ== Explanation: ChooserActivity accepts EXTRA_INTENT containing either an Intent or a URI. In this case the data: uri is decoded and results in a string -as- the intent which doesn't match any apps. Bug: 334039327 Test: manual, see adb command Test: With work profile paused, and unpaused Test: With private profile locked, and unlocked Change-Id: I1e2565686fb8f7bcf15611541e905cf903e64704 --- java/res/values/strings.xml | 3 + .../android/intentresolver/ChooserActivity.java | 7 +- .../android/intentresolver/ProfileAvailability.kt | 18 ++++ .../android/intentresolver/ResolverActivity.java | 11 ++- .../data/repository/DevicePolicyResources.kt | 43 +++++++++ .../NoAppsAvailableEmptyStateProvider.java | 101 +++++++-------------- .../intentresolver/ui/ProfilePagerResources.kt | 8 ++ 7 files changed, 112 insertions(+), 79 deletions(-) (limited to 'java/src') diff --git a/java/res/values/strings.xml b/java/res/values/strings.xml index 5c1210b7..17a514d7 100644 --- a/java/res/values/strings.xml +++ b/java/res/values/strings.xml @@ -295,6 +295,9 @@ No personal apps + + No private apps + Open %s in your personal profile? diff --git a/java/src/com/android/intentresolver/ChooserActivity.java b/java/src/com/android/intentresolver/ChooserActivity.java index 7e2c9c5a..43d28761 100644 --- a/java/src/com/android/intentresolver/ChooserActivity.java +++ b/java/src/com/android/intentresolver/ChooserActivity.java @@ -1051,11 +1051,10 @@ public class ChooserActivity extends Hilt_ChooserActivity implements getMetricsCategory()); EmptyStateProvider noAppsEmptyStateProvider = new NoAppsAvailableEmptyStateProvider( - this, - profileHelper.getWorkHandle(), - profileHelper.getPersonalHandle(), + mProfiles, + mProfileAvailability, getMetricsCategory(), - profileHelper.getTabOwnerUserHandleForLaunch() + mProfilePagerResources ); // Return composite provider, the order matters (the higher, the more priority) diff --git a/java/src/com/android/intentresolver/ProfileAvailability.kt b/java/src/com/android/intentresolver/ProfileAvailability.kt index cf3e566e..c8e78552 100644 --- a/java/src/com/android/intentresolver/ProfileAvailability.kt +++ b/java/src/com/android/intentresolver/ProfileAvailability.kt @@ -53,6 +53,24 @@ class ProfileAvailability( } } + /** + * The number of profiles which are visible. All profiles count except for private which is + * hidden when locked. + */ + fun visibleProfileCount() = + runBlocking(background) { + val availability = userInteractor.availability.first() + val profiles = userInteractor.profiles.first() + profiles + .filter { + when (it.type) { + Profile.Type.PRIVATE -> availability[it] == true + else -> true + } + } + .size + } + /** Used by WorkProfilePausedEmptyStateProvider */ fun requestQuietModeState(profile: Profile, quietMode: Boolean) { val enableProfile = !quietMode diff --git a/java/src/com/android/intentresolver/ResolverActivity.java b/java/src/com/android/intentresolver/ResolverActivity.java index 2f1f8ee5..4e763f94 100644 --- a/java/src/com/android/intentresolver/ResolverActivity.java +++ b/java/src/com/android/intentresolver/ResolverActivity.java @@ -112,6 +112,7 @@ import com.android.intentresolver.profiles.ResolverMultiProfilePagerAdapter; import com.android.intentresolver.profiles.TabConfig; import com.android.intentresolver.shared.model.Profile; import com.android.intentresolver.ui.ActionTitle; +import com.android.intentresolver.ui.ProfilePagerResources; import com.android.intentresolver.ui.model.ActivityModel; import com.android.intentresolver.ui.model.ResolverRequest; import com.android.intentresolver.ui.viewmodel.ResolverViewModel; @@ -153,6 +154,7 @@ public class ResolverActivity extends Hilt_ResolverActivity implements @Inject public ResolverHelper mResolverHelper; @Inject public PackageManager mPackageManager; @Inject public DevicePolicyResources mDevicePolicyResources; + @Inject public ProfilePagerResources mProfilePagerResources; @Inject public IntentForwarding mIntentForwarding; @Inject public FeatureFlags mFeatureFlags; @@ -963,12 +965,11 @@ public class ResolverActivity extends Hilt_ResolverActivity implements }, getMetricsCategory()); - final EmptyStateProvider noAppsEmptyStateProvider = new NoAppsAvailableEmptyStateProvider( - this, - workProfileUserHandle, - mProfiles.getPersonalHandle(), + EmptyStateProvider noAppsEmptyStateProvider = new NoAppsAvailableEmptyStateProvider( + mProfiles, + mProfileAvailability, getMetricsCategory(), - mProfiles.getTabOwnerUserHandleForLaunch() + mProfilePagerResources ); // Return composite provider, the order matters (the higher, the more priority) diff --git a/java/src/com/android/intentresolver/data/repository/DevicePolicyResources.kt b/java/src/com/android/intentresolver/data/repository/DevicePolicyResources.kt index c396b720..75faa068 100644 --- a/java/src/com/android/intentresolver/data/repository/DevicePolicyResources.kt +++ b/java/src/com/android/intentresolver/data/repository/DevicePolicyResources.kt @@ -18,6 +18,9 @@ package com.android.intentresolver.data.repository import android.app.admin.DevicePolicyManager import android.app.admin.DevicePolicyResources.Strings.Core.FORWARD_INTENT_TO_PERSONAL import android.app.admin.DevicePolicyResources.Strings.Core.FORWARD_INTENT_TO_WORK +import android.app.admin.DevicePolicyResources.Strings.Core.RESOLVER_CROSS_PROFILE_BLOCKED_TITLE +import android.app.admin.DevicePolicyResources.Strings.Core.RESOLVER_NO_PERSONAL_APPS +import android.app.admin.DevicePolicyResources.Strings.Core.RESOLVER_NO_WORK_APPS import android.app.admin.DevicePolicyResources.Strings.Core.RESOLVER_PERSONAL_TAB import android.app.admin.DevicePolicyResources.Strings.Core.RESOLVER_PERSONAL_TAB_ACCESSIBILITY import android.app.admin.DevicePolicyResources.Strings.Core.RESOLVER_WORK_PROFILE_NOT_SUPPORTED @@ -83,6 +86,46 @@ constructor( ) } + val noPersonalApps by lazy { + requireNotNull( + policyResources.getString(RESOLVER_NO_PERSONAL_APPS) { + resources.getString(R.string.resolver_no_personal_apps_available) + } + ) + } + + val noWorkApps by lazy { + requireNotNull( + policyResources.getString(RESOLVER_NO_WORK_APPS) { + resources.getString(R.string.resolver_no_work_apps_available) + } + ) + } + + val crossProfileBlocked by lazy { + requireNotNull( + policyResources.getString(RESOLVER_CROSS_PROFILE_BLOCKED_TITLE) { + resources.getString(R.string.resolver_cross_profile_blocked) + } + ) + } + + fun toPersonalBlockedByPolicyMessage(sendAction: Boolean): String { + return if (sendAction) { + resources.getString(R.string.resolver_cant_share_with_personal_apps_explanation) + } else { + resources.getString(R.string.resolver_cant_access_personal_apps_explanation) + } + } + + fun toWorkBlockedByPolicyMessage(sendAction: Boolean): String { + return if (sendAction) { + resources.getString(R.string.resolver_cant_share_with_work_apps_explanation) + } else { + resources.getString(R.string.resolver_cant_access_work_apps_explanation) + } + } + fun getWorkProfileNotSupportedMessage(launcherName: String): String { return requireNotNull( policyResources.getString( diff --git a/java/src/com/android/intentresolver/emptystate/NoAppsAvailableEmptyStateProvider.java b/java/src/com/android/intentresolver/emptystate/NoAppsAvailableEmptyStateProvider.java index 7bfea4f8..af9d56d1 100644 --- a/java/src/com/android/intentresolver/emptystate/NoAppsAvailableEmptyStateProvider.java +++ b/java/src/com/android/intentresolver/emptystate/NoAppsAvailableEmptyStateProvider.java @@ -16,24 +16,22 @@ package com.android.intentresolver.emptystate; -import static android.app.admin.DevicePolicyResources.Strings.Core.RESOLVER_NO_PERSONAL_APPS; -import static android.app.admin.DevicePolicyResources.Strings.Core.RESOLVER_NO_WORK_APPS; + +import static com.android.intentresolver.shared.model.Profile.Type.PERSONAL; + +import static java.util.Objects.requireNonNull; import android.app.admin.DevicePolicyEventLogger; -import android.app.admin.DevicePolicyManager; -import android.content.Context; -import android.content.pm.ResolveInfo; import android.os.UserHandle; import android.stats.devicepolicy.nano.DevicePolicyEnums; import androidx.annotation.NonNull; -import androidx.annotation.Nullable; -import com.android.intentresolver.R; -import com.android.intentresolver.ResolvedComponentInfo; +import com.android.intentresolver.ProfileAvailability; +import com.android.intentresolver.ProfileHelper; import com.android.intentresolver.ResolverListAdapter; - -import java.util.List; +import com.android.intentresolver.shared.model.Profile; +import com.android.intentresolver.ui.ProfilePagerResources; /** * Chooser/ResolverActivity empty state provider that returns empty state which is shown when @@ -41,77 +39,40 @@ import java.util.List; */ public class NoAppsAvailableEmptyStateProvider implements EmptyStateProvider { - @NonNull - private final Context mContext; - @Nullable - private final UserHandle mWorkProfileUserHandle; - @Nullable - private final UserHandle mPersonalProfileUserHandle; - @NonNull - private final String mMetricsCategory; - @NonNull - private final UserHandle mTabOwnerUserHandleForLaunch; - - public NoAppsAvailableEmptyStateProvider(@NonNull Context context, - @Nullable UserHandle workProfileUserHandle, - @Nullable UserHandle personalProfileUserHandle, @NonNull String metricsCategory, - @NonNull UserHandle tabOwnerUserHandleForLaunch) { - mContext = context; - mWorkProfileUserHandle = workProfileUserHandle; - mPersonalProfileUserHandle = personalProfileUserHandle; + @NonNull private final String mMetricsCategory; + private final ProfilePagerResources mProfilePagerResources; + private final ProfileHelper mProfileHelper; + private final ProfileAvailability mProfileAvailability; + + public NoAppsAvailableEmptyStateProvider( + ProfileHelper profileHelper, + ProfileAvailability profileAvailability, + @NonNull String metricsCategory, + ProfilePagerResources profilePagerResources) { + mProfileHelper = profileHelper; + mProfileAvailability = profileAvailability; mMetricsCategory = metricsCategory; - mTabOwnerUserHandleForLaunch = tabOwnerUserHandleForLaunch; + mProfilePagerResources = profilePagerResources; } - @Nullable + @NonNull @Override - @SuppressWarnings("ReferenceEquality") public EmptyState getEmptyState(ResolverListAdapter resolverListAdapter) { UserHandle listUserHandle = resolverListAdapter.getUserHandle(); - - if (mWorkProfileUserHandle != null - && (mTabOwnerUserHandleForLaunch.equals(listUserHandle) - || !hasAppsInOtherProfile(resolverListAdapter))) { - - String title; - if (listUserHandle == mPersonalProfileUserHandle) { - title = mContext.getSystemService( - DevicePolicyManager.class).getResources().getString( - RESOLVER_NO_PERSONAL_APPS, - () -> mContext.getString(R.string.resolver_no_personal_apps_available)); - } else { - title = mContext.getSystemService( - DevicePolicyManager.class).getResources().getString( - RESOLVER_NO_WORK_APPS, - () -> mContext.getString(R.string.resolver_no_work_apps_available)); - } - + if (mProfileAvailability.visibleProfileCount() == 1) { + return new DefaultEmptyState(); + } else { + Profile.Type profileType = + requireNonNull(mProfileHelper.findProfileType(listUserHandle)); + String title = mProfilePagerResources.noAppsMessage(profileType); return new NoAppsAvailableEmptyState( - title, mMetricsCategory, - /* isPersonalProfile= */ listUserHandle == mPersonalProfileUserHandle + title, + mMetricsCategory, + /* isPersonalProfile= */ profileType == PERSONAL ); - } else if (mWorkProfileUserHandle == null) { - // Return default empty state without tracking - return new DefaultEmptyState(); } - - return null; } - private boolean hasAppsInOtherProfile(ResolverListAdapter adapter) { - if (mWorkProfileUserHandle == null) { - return false; - } - List resolversForIntent = - adapter.getResolversForUser(mTabOwnerUserHandleForLaunch); - for (ResolvedComponentInfo info : resolversForIntent) { - ResolveInfo resolveInfo = info.getResolveInfoAt(0); - if (resolveInfo.targetUserId != UserHandle.USER_CURRENT) { - return true; - } - } - return false; - } public static class DefaultEmptyState implements EmptyState { @Override diff --git a/java/src/com/android/intentresolver/ui/ProfilePagerResources.kt b/java/src/com/android/intentresolver/ui/ProfilePagerResources.kt index baab9a4c..0d07af8f 100644 --- a/java/src/com/android/intentresolver/ui/ProfilePagerResources.kt +++ b/java/src/com/android/intentresolver/ui/ProfilePagerResources.kt @@ -50,4 +50,12 @@ constructor( Profile.Type.PRIVATE -> privateTabAccessibilityLabel } } + + fun noAppsMessage(type: Profile.Type): String { + return when (type) { + Profile.Type.PERSONAL -> devicePolicyResources.noPersonalApps + Profile.Type.WORK -> devicePolicyResources.noWorkApps + Profile.Type.PRIVATE -> resources.getString(R.string.resolver_no_private_apps_available) + } + } } -- cgit v1.2.3-59-g8ed1b From a6a3fe4434af644d01acd21da0c976c4483501bc Mon Sep 17 00:00:00 2001 From: Andrey Epin Date: Mon, 22 Apr 2024 18:15:54 -0700 Subject: Remove scrollable preview flag Bug: 287102904 Test: atest IntentResolver-tests-unit Test: atest IntentResolver-tests-activity Change-Id: I028a246907a076680e26acdb816b3f32020fa772 --- aconfig/FeatureFlags.aconfig | 7 -- java/res/layout/chooser_grid.xml | 97 ---------------------- java/res/layout/chooser_list_per_profile.xml | 34 -------- .../android/intentresolver/ChooserActivity.java | 26 ++---- .../intentresolver/grid/ChooserGridAdapter.java | 21 +---- .../profiles/ChooserMultiProfilePagerAdapter.java | 22 ++--- .../intentresolver/ChooserActivityTest.java | 2 - .../contentpreview/FileContentPreviewUiTest.kt | 4 +- .../FilesPlusTextContentPreviewUiTest.kt | 8 +- .../contentpreview/TextContentPreviewUiTest.kt | 8 +- .../contentpreview/UnifiedContentPreviewUiTest.kt | 4 +- 11 files changed, 36 insertions(+), 197 deletions(-) delete mode 100644 java/res/layout/chooser_grid.xml delete mode 100644 java/res/layout/chooser_list_per_profile.xml (limited to 'java/src') diff --git a/aconfig/FeatureFlags.aconfig b/aconfig/FeatureFlags.aconfig index 583d8502..02f1c872 100644 --- a/aconfig/FeatureFlags.aconfig +++ b/aconfig/FeatureFlags.aconfig @@ -15,13 +15,6 @@ flag { } } -flag { - name: "scrollable_preview" - namespace: "intentresolver" - description: "Makes preview scrollable with multiple profiles" - bug: "287102904" -} - flag { name: "target_data_caching" namespace: "intentresolver" diff --git a/java/res/layout/chooser_grid.xml b/java/res/layout/chooser_grid.xml deleted file mode 100644 index 8320b284..00000000 --- a/java/res/layout/chooser_grid.xml +++ /dev/null @@ -1,97 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/java/res/layout/chooser_list_per_profile.xml b/java/res/layout/chooser_list_per_profile.xml deleted file mode 100644 index ef82090c..00000000 --- a/java/res/layout/chooser_list_per_profile.xml +++ /dev/null @@ -1,34 +0,0 @@ - - - - - - diff --git a/java/src/com/android/intentresolver/ChooserActivity.java b/java/src/com/android/intentresolver/ChooserActivity.java index b712788c..b712edf4 100644 --- a/java/src/com/android/intentresolver/ChooserActivity.java +++ b/java/src/com/android/intentresolver/ChooserActivity.java @@ -506,8 +506,7 @@ public class ChooserActivity extends Hilt_ChooserActivity implements mProfiles, mProfileAvailability, mRequest.getInitialIntents(), - mMaxTargetsPerRow, - mFeatureFlags); + mMaxTargetsPerRow); if (!configureContentView(mTargetDataLoader)) { mPersonalPackageMonitor = createPackageMonitor( @@ -728,8 +727,7 @@ public class ChooserActivity extends Hilt_ChooserActivity implements mProfiles, mProfileAvailability, mRequest.getInitialIntents(), - mMaxTargetsPerRow, - mFeatureFlags); + mMaxTargetsPerRow); mChooserMultiProfilePagerAdapter.setCurrentPage(currentPage); if (mPersonalPackageMonitor != null) { mPersonalPackageMonitor.unregister(); @@ -1191,9 +1189,7 @@ public class ChooserActivity extends Hilt_ChooserActivity implements boolean rebuildCompleted = mChooserMultiProfilePagerAdapter.rebuildTabs( mProfiles.getWorkProfilePresent()); - mLayoutId = mFeatureFlags.scrollablePreview() - ? R.layout.chooser_grid_scrollable_preview - : R.layout.chooser_grid; + mLayoutId = R.layout.chooser_grid_scrollable_preview; setContentView(mLayoutId); mTabHost = findViewById(com.android.internal.R.id.profile_tabhost); @@ -1362,8 +1358,7 @@ public class ChooserActivity extends Hilt_ChooserActivity implements mProfiles, mProfileAvailability, mRequest.getInitialIntents(), - mMaxTargetsPerRow, - mFeatureFlags); + mMaxTargetsPerRow); } private ChooserMultiProfilePagerAdapter createMultiProfilePagerAdapter( @@ -1373,8 +1368,7 @@ public class ChooserActivity extends Hilt_ChooserActivity implements ProfileHelper profileHelper, ProfileAvailability profileAvailability, List initialIntents, - int maxTargetsPerRow, - FeatureFlags featureFlags) { + int maxTargetsPerRow) { Log.d(TAG, "createMultiProfilePagerAdapter"); Profile launchedAs = profileHelper.getLaunchedAsProfile(); @@ -1418,8 +1412,7 @@ public class ChooserActivity extends Hilt_ChooserActivity implements launchedAs.getType().ordinal(), profileHelper.getWorkHandle(), profileHelper.getCloneHandle(), - maxTargetsPerRow, - featureFlags); + maxTargetsPerRow); } protected EmptyStateProvider createBlockerEmptyStateProvider() { @@ -1576,9 +1569,7 @@ public class ChooserActivity extends Hilt_ChooserActivity implements getResources(), getLayoutInflater(), parent, - mFeatureFlags.scrollablePreview() - ? findViewById(R.id.chooser_headline_row_container) - : null); + findViewById(R.id.chooser_headline_row_container)); if (layout != null) { adjustPreviewWidth(getResources().getConfiguration().orientation, layout); @@ -2477,8 +2468,7 @@ public class ChooserActivity extends Hilt_ChooserActivity implements ResolverListAdapter adapter = mChooserMultiProfilePagerAdapter.getListAdapterForUserHandle( UserHandle.of(UserHandle.myUserId())); boolean isEmpty = adapter == null || adapter.getCount() == 0; - return (mFeatureFlags.scrollablePreview() || mProfiles.getWorkProfilePresent()) - && (!isEmpty || shouldShowContentPreviewWhenEmpty()); + return !isEmpty || shouldShowContentPreviewWhenEmpty(); } /** diff --git a/java/src/com/android/intentresolver/grid/ChooserGridAdapter.java b/java/src/com/android/intentresolver/grid/ChooserGridAdapter.java index ba76a4a0..cda69b9e 100644 --- a/java/src/com/android/intentresolver/grid/ChooserGridAdapter.java +++ b/java/src/com/android/intentresolver/grid/ChooserGridAdapter.java @@ -149,9 +149,7 @@ public final class ChooserGridAdapter extends RecyclerView.Adapter gridAdapter.getListAdapter(), + gridAdapter -> gridAdapter.getListAdapter(), adapterBinder, tabs, emptyStateProvider, @@ -91,7 +87,7 @@ public class ChooserMultiProfilePagerAdapter extends MultiProfilePagerAdapter< defaultProfile, workProfileUserHandle, cloneProfileUserHandle, - () -> makeProfileView(context, featureFlags), + () -> makeProfileView(context), bottomPaddingOverrideSupplier); mAdapterBinder = adapterBinder; mBottomPaddingOverrideSupplier = bottomPaddingOverrideSupplier; @@ -116,12 +112,10 @@ public class ChooserMultiProfilePagerAdapter extends MultiProfilePagerAdapter< } } - private static ViewGroup makeProfileView( - Context context, FeatureFlags featureFlags) { + private static ViewGroup makeProfileView(Context context) { LayoutInflater inflater = LayoutInflater.from(context); - ViewGroup rootView = featureFlags.scrollablePreview() - ? (ViewGroup) inflater.inflate(R.layout.chooser_list_per_profile_wrap, null, false) - : (ViewGroup) inflater.inflate(R.layout.chooser_list_per_profile, null, false); + ViewGroup rootView = + (ViewGroup) inflater.inflate(R.layout.chooser_list_per_profile_wrap, null, false); RecyclerView recyclerView = rootView.findViewById(com.android.internal.R.id.resolver_list); recyclerView.setAccessibilityDelegateCompat( new ChooserRecyclerViewAccessibilityDelegate(recyclerView)); diff --git a/tests/activity/src/com/android/intentresolver/ChooserActivityTest.java b/tests/activity/src/com/android/intentresolver/ChooserActivityTest.java index cfbb1c0b..66f7650d 100644 --- a/tests/activity/src/com/android/intentresolver/ChooserActivityTest.java +++ b/tests/activity/src/com/android/intentresolver/ChooserActivityTest.java @@ -88,7 +88,6 @@ import android.graphics.drawable.Icon; import android.net.Uri; import android.os.Bundle; import android.os.UserHandle; -import android.platform.test.annotations.RequiresFlagsEnabled; import android.platform.test.flag.junit.CheckFlagsRule; import android.platform.test.flag.junit.DeviceFlagsValueProvider; import android.provider.DeviceConfig; @@ -2229,7 +2228,6 @@ public class ChooserActivityTest { } @Test - @RequiresFlagsEnabled(Flags.FLAG_SCROLLABLE_PREVIEW) public void testWorkTab_previewIsScrollable() { markOtherProfileAvailability(/* workAvailable= */ true, /* cloneAvailable= */ false); List personalResolvedComponentInfos = diff --git a/tests/unit/src/com/android/intentresolver/contentpreview/FileContentPreviewUiTest.kt b/tests/unit/src/com/android/intentresolver/contentpreview/FileContentPreviewUiTest.kt index a540dfa2..25e8b239 100644 --- a/tests/unit/src/com/android/intentresolver/contentpreview/FileContentPreviewUiTest.kt +++ b/tests/unit/src/com/android/intentresolver/contentpreview/FileContentPreviewUiTest.kt @@ -61,7 +61,9 @@ class FileContentPreviewUiTest { @Test fun test_display_titleAndMetadataIsDisplayed() { val layoutInflater = LayoutInflater.from(context) - val gridLayout = layoutInflater.inflate(R.layout.chooser_grid, null, false) as ViewGroup + val gridLayout = + layoutInflater.inflate(R.layout.chooser_grid_scrollable_preview, null, false) + as ViewGroup val previewView = testSubject.display( diff --git a/tests/unit/src/com/android/intentresolver/contentpreview/FilesPlusTextContentPreviewUiTest.kt b/tests/unit/src/com/android/intentresolver/contentpreview/FilesPlusTextContentPreviewUiTest.kt index 259ffdac..bd0a8efa 100644 --- a/tests/unit/src/com/android/intentresolver/contentpreview/FilesPlusTextContentPreviewUiTest.kt +++ b/tests/unit/src/com/android/intentresolver/contentpreview/FilesPlusTextContentPreviewUiTest.kt @@ -284,7 +284,9 @@ class FilesPlusTextContentPreviewUiTest { testMetadataText, ) val layoutInflater = LayoutInflater.from(context) - val gridLayout = layoutInflater.inflate(R.layout.chooser_grid, null, false) as ViewGroup + val gridLayout = + layoutInflater.inflate(R.layout.chooser_grid_scrollable_preview, null, false) + as ViewGroup val previewView = testSubject.display(context.resources, LayoutInflater.from(context), gridLayout, null) @@ -373,7 +375,9 @@ class FilesPlusTextContentPreviewUiTest { testMetadataText, ) val layoutInflater = LayoutInflater.from(context) - val gridLayout = layoutInflater.inflate(R.layout.chooser_grid, null, false) as ViewGroup + val gridLayout = + layoutInflater.inflate(R.layout.chooser_grid_scrollable_preview, null, false) + as ViewGroup loadedFileMetadata?.let(testSubject::updatePreviewMetadata) return testSubject.display( diff --git a/tests/unit/src/com/android/intentresolver/contentpreview/TextContentPreviewUiTest.kt b/tests/unit/src/com/android/intentresolver/contentpreview/TextContentPreviewUiTest.kt index 1c96070c..0416d71a 100644 --- a/tests/unit/src/com/android/intentresolver/contentpreview/TextContentPreviewUiTest.kt +++ b/tests/unit/src/com/android/intentresolver/contentpreview/TextContentPreviewUiTest.kt @@ -76,7 +76,9 @@ class TextContentPreviewUiTest { @Test fun test_display_headlineIsDisplayed() { val layoutInflater = LayoutInflater.from(context) - val gridLayout = layoutInflater.inflate(R.layout.chooser_grid, null, false) as ViewGroup + val gridLayout = + layoutInflater.inflate(R.layout.chooser_grid_scrollable_preview, null, false) + as ViewGroup val previewView = testSubject.display( @@ -125,7 +127,9 @@ class TextContentPreviewUiTest { @Test fun test_display_albumHeadlineOverride() { val layoutInflater = LayoutInflater.from(context) - val gridLayout = layoutInflater.inflate(R.layout.chooser_grid, null, false) as ViewGroup + val gridLayout = + layoutInflater.inflate(R.layout.chooser_grid_scrollable_preview, null, false) + as ViewGroup val albumSubject = TextContentPreviewUi( diff --git a/tests/unit/src/com/android/intentresolver/contentpreview/UnifiedContentPreviewUiTest.kt b/tests/unit/src/com/android/intentresolver/contentpreview/UnifiedContentPreviewUiTest.kt index faeaf133..07575be0 100644 --- a/tests/unit/src/com/android/intentresolver/contentpreview/UnifiedContentPreviewUiTest.kt +++ b/tests/unit/src/com/android/intentresolver/contentpreview/UnifiedContentPreviewUiTest.kt @@ -276,7 +276,9 @@ class UnifiedContentPreviewUiTest { testMetadataText, ) val layoutInflater = LayoutInflater.from(context) - val gridLayout = layoutInflater.inflate(R.layout.chooser_grid, null, false) as ViewGroup + val gridLayout = + layoutInflater.inflate(R.layout.chooser_grid_scrollable_preview, null, false) + as ViewGroup val previewView = testSubject.display( -- cgit v1.2.3-59-g8ed1b From b67d4b095c88dc715b9bedde8f873af0f4ac04f3 Mon Sep 17 00:00:00 2001 From: Andrey Epin Date: Mon, 22 Apr 2024 19:43:16 -0700 Subject: Remove preview view from ChooserGridAdapter Mechanical refactoring: * inline getSystemRowCount() and simplify the result code. * remove now unused VIEW_TYPE_CONTENT_PREVIEW view type and ChooserActivityDelegate methods. Bug: 287102904 Test: atest IntentResolver-tests-unit Test: atest IntentResolver-tests-activity Test: manual functionality smoke test Change-Id: I63124e05652759eb508b99ba0ac771b1eacb1e77 --- .../android/intentresolver/ChooserActivity.java | 17 ++------- .../intentresolver/grid/ChooserGridAdapter.java | 41 +++------------------- 2 files changed, 7 insertions(+), 51 deletions(-) (limited to 'java/src') diff --git a/java/src/com/android/intentresolver/ChooserActivity.java b/java/src/com/android/intentresolver/ChooserActivity.java index b712edf4..c7b8acbe 100644 --- a/java/src/com/android/intentresolver/ChooserActivity.java +++ b/java/src/com/android/intentresolver/ChooserActivity.java @@ -609,9 +609,7 @@ public class ChooserActivity extends Hilt_ChooserActivity implements mRequest.getMetadataText(), mChooserServiceFeatureFlags.chooserPayloadToggling()); updateStickyContentPreview(); - if (shouldShowStickyContentPreview() - || mChooserMultiProfilePagerAdapter - .getCurrentRootAdapter().getSystemRowCount() != 0) { + if (shouldShowStickyContentPreview()) { getEventLog().logActionShareWithPreview( mChooserContentPreviewUi.getPreferredContentPreview()); } @@ -1976,16 +1974,6 @@ public class ChooserActivity extends Hilt_ChooserActivity implements return new ChooserGridAdapter( context, new ChooserGridAdapter.ChooserActivityDelegate() { - @Override - public boolean shouldShowTabs() { - return mProfiles.getWorkProfilePresent(); - } - - @Override - public View buildContentPreview(ViewGroup parent) { - return createContentPreviewView(parent); - } - @Override public void onTargetSelected(int itemIndex) { startSelected(itemIndex, false, true); @@ -2229,8 +2217,7 @@ public class ChooserActivity extends Hilt_ChooserActivity implements int top, int bottom, RecyclerView recyclerView, ChooserGridAdapter gridAdapter) { int offset = mSystemWindowInsets != null ? mSystemWindowInsets.bottom : 0; - int rowsToShow = gridAdapter.getSystemRowCount() - + gridAdapter.getServiceTargetRowCount() + int rowsToShow = gridAdapter.getServiceTargetRowCount() + gridAdapter.getCallerAndRankedTargetRowCount(); // then this is most likely not a SEND_* action, so check diff --git a/java/src/com/android/intentresolver/grid/ChooserGridAdapter.java b/java/src/com/android/intentresolver/grid/ChooserGridAdapter.java index cda69b9e..7cf9d2e9 100644 --- a/java/src/com/android/intentresolver/grid/ChooserGridAdapter.java +++ b/java/src/com/android/intentresolver/grid/ChooserGridAdapter.java @@ -66,15 +66,6 @@ public final class ChooserGridAdapter extends RecyclerView.Adapter 0 && position < countSum) return VIEW_TYPE_CONTENT_PREVIEW; + int count = 0; + int countSum = count; countSum += (count = getServiceTargetRowCount()); if (count > 0 && position < countSum) return VIEW_TYPE_DIRECT_SHARE; @@ -538,8 +509,6 @@ public final class ChooserGridAdapter extends RecyclerView.Adapter Date: Tue, 23 Apr 2024 12:45:19 -0700 Subject: Remove obsolete headline views from preview UI variants With scrollable preview's been released, headline views within preview variants are obsolete and can be deleted. Bug: 287102904 Test: atest IntentResolver-tests-unit Test: atest IntentResolver-tests-activity Test: manual functionality smoke test Change-Id: I26ad0afc4e3801c57025044107121bacff6dc860 --- java/res/layout/chooser_grid_preview_file.xml | 8 - .../res/layout/chooser_grid_preview_files_text.xml | 8 - java/res/layout/chooser_grid_preview_text.xml | 9 - .../android/intentresolver/ChooserActivity.java | 2 +- .../contentpreview/ChooserContentPreviewUi.java | 15 +- .../contentpreview/ContentPreviewUi.java | 2 +- .../contentpreview/FileContentPreviewUi.java | 7 +- .../FilesPlusTextContentPreviewUi.java | 6 +- .../contentpreview/NoContextPreviewUi.kt | 2 +- .../contentpreview/ShareouselContentPreviewUi.kt | 12 +- .../contentpreview/TextContentPreviewUi.java | 7 +- .../contentpreview/UnifiedContentPreviewUi.java | 7 +- .../contentpreview/FileContentPreviewUiTest.kt | 36 +-- .../FilesPlusTextContentPreviewUiTest.kt | 282 ++++++--------------- .../contentpreview/TextContentPreviewUiTest.kt | 45 +--- .../contentpreview/UnifiedContentPreviewUiTest.kt | 234 +++-------------- 16 files changed, 155 insertions(+), 527 deletions(-) (limited to 'java/src') diff --git a/java/res/layout/chooser_grid_preview_file.xml b/java/res/layout/chooser_grid_preview_file.xml index 90832d23..4e8cf7ba 100644 --- a/java/res/layout/chooser_grid_preview_file.xml +++ b/java/res/layout/chooser_grid_preview_file.xml @@ -26,14 +26,6 @@ android:orientation="vertical" android:background="?androidprv:attr/materialColorSurfaceContainer"> - - - - - - - diff --git a/java/src/com/android/intentresolver/ChooserActivity.java b/java/src/com/android/intentresolver/ChooserActivity.java index c7b8acbe..75447cc2 100644 --- a/java/src/com/android/intentresolver/ChooserActivity.java +++ b/java/src/com/android/intentresolver/ChooserActivity.java @@ -1567,7 +1567,7 @@ public class ChooserActivity extends Hilt_ChooserActivity implements getResources(), getLayoutInflater(), parent, - findViewById(R.id.chooser_headline_row_container)); + requireViewById(R.id.chooser_headline_row_container)); if (layout != null) { adjustPreviewWidth(getResources().getConfiguration().orientation, layout); diff --git a/java/src/com/android/intentresolver/contentpreview/ChooserContentPreviewUi.java b/java/src/com/android/intentresolver/contentpreview/ChooserContentPreviewUi.java index 4cb30341..4b955c49 100644 --- a/java/src/com/android/intentresolver/contentpreview/ChooserContentPreviewUi.java +++ b/java/src/com/android/intentresolver/contentpreview/ChooserContentPreviewUi.java @@ -97,7 +97,6 @@ public final class ChooserContentPreviewUi { @VisibleForTesting final ContentPreviewUi mContentPreviewUi; private final Supplier mModifyShareActionFactory; - @Nullable private View mHeadlineParent; public ChooserContentPreviewUi( @@ -226,15 +225,12 @@ public final class ChooserContentPreviewUi { Resources resources, LayoutInflater layoutInflater, ViewGroup parent, - @Nullable View headlineViewParent) { + View headlineViewParent) { ViewGroup layout = mContentPreviewUi.display(resources, layoutInflater, parent, headlineViewParent); - mHeadlineParent = headlineViewParent == null ? layout : headlineViewParent; - if (mHeadlineParent != null) { - ContentPreviewUi.displayModifyShareAction( - mHeadlineParent, mModifyShareActionFactory.get()); - } + mHeadlineParent = headlineViewParent; + ContentPreviewUi.displayModifyShareAction(mHeadlineParent, mModifyShareActionFactory.get()); return layout; } @@ -242,10 +238,7 @@ public final class ChooserContentPreviewUi { * Update Modify Share Action, if it is inflated. */ public void updateModifyShareAction() { - if (mHeadlineParent != null) { - ContentPreviewUi.displayModifyShareAction( - mHeadlineParent, mModifyShareActionFactory.get()); - } + 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 71d5fc0b..8eaf3568 100644 --- a/java/src/com/android/intentresolver/contentpreview/ContentPreviewUi.java +++ b/java/src/com/android/intentresolver/contentpreview/ContentPreviewUi.java @@ -48,7 +48,7 @@ public abstract class ContentPreviewUi { Resources resources, LayoutInflater layoutInflater, ViewGroup parent, - @Nullable View headlineViewParent); + View headlineViewParent); protected static void updateViewWithImage(ImageView imageView, Bitmap image) { if (image == null) { diff --git a/java/src/com/android/intentresolver/contentpreview/FileContentPreviewUi.java b/java/src/com/android/intentresolver/contentpreview/FileContentPreviewUi.java index d127d929..1749c6f7 100644 --- a/java/src/com/android/intentresolver/contentpreview/FileContentPreviewUi.java +++ b/java/src/com/android/intentresolver/contentpreview/FileContentPreviewUi.java @@ -76,7 +76,7 @@ class FileContentPreviewUi extends ContentPreviewUi { Resources resources, LayoutInflater layoutInflater, ViewGroup parent, - @Nullable View headlineViewParent) { + View headlineViewParent) { return displayInternal(resources, layoutInflater, parent, headlineViewParent); } @@ -84,12 +84,9 @@ class FileContentPreviewUi extends ContentPreviewUi { Resources resources, LayoutInflater layoutInflater, ViewGroup parent, - @Nullable View headlineViewParent) { + View headlineViewParent) { mContentPreview = (ViewGroup) layoutInflater.inflate( R.layout.chooser_grid_preview_file, parent, false); - if (headlineViewParent == null) { - headlineViewParent = mContentPreview; - } inflateHeadline(headlineViewParent); displayHeadline(headlineViewParent, mHeadlineGenerator.getFilesHeadline(mFileCount)); diff --git a/java/src/com/android/intentresolver/contentpreview/FilesPlusTextContentPreviewUi.java b/java/src/com/android/intentresolver/contentpreview/FilesPlusTextContentPreviewUi.java index 0367e9d5..b50f5bc8 100644 --- a/java/src/com/android/intentresolver/contentpreview/FilesPlusTextContentPreviewUi.java +++ b/java/src/com/android/intentresolver/contentpreview/FilesPlusTextContentPreviewUi.java @@ -108,7 +108,7 @@ class FilesPlusTextContentPreviewUi extends ContentPreviewUi { Resources resources, LayoutInflater layoutInflater, ViewGroup parent, - @Nullable View headlineViewParent) { + View headlineViewParent) { return displayInternal(layoutInflater, parent, headlineViewParent); } @@ -133,10 +133,10 @@ class FilesPlusTextContentPreviewUi extends ContentPreviewUi { private ViewGroup displayInternal( LayoutInflater layoutInflater, ViewGroup parent, - @Nullable View headlineViewParent) { + View headlineViewParent) { mContentPreviewView = (ViewGroup) layoutInflater.inflate( R.layout.chooser_grid_preview_files_text, parent, false); - mHeadliveView = headlineViewParent == null ? mContentPreviewView : headlineViewParent; + mHeadliveView = headlineViewParent; inflateHeadline(mHeadliveView); final ActionRow actionRow = diff --git a/java/src/com/android/intentresolver/contentpreview/NoContextPreviewUi.kt b/java/src/com/android/intentresolver/contentpreview/NoContextPreviewUi.kt index 31a7006c..924e6499 100644 --- a/java/src/com/android/intentresolver/contentpreview/NoContextPreviewUi.kt +++ b/java/src/com/android/intentresolver/contentpreview/NoContextPreviewUi.kt @@ -29,7 +29,7 @@ internal class NoContextPreviewUi(private val type: Int) : ContentPreviewUi() { resources: Resources?, layoutInflater: LayoutInflater?, parent: ViewGroup?, - headlineViewParent: View?, + headlineViewParent: View, ): ViewGroup? { Log.e(TAG, "Unexpected content preview type: $type") return null diff --git a/java/src/com/android/intentresolver/contentpreview/ShareouselContentPreviewUi.kt b/java/src/com/android/intentresolver/contentpreview/ShareouselContentPreviewUi.kt index 5fce711c..57a51239 100644 --- a/java/src/com/android/intentresolver/contentpreview/ShareouselContentPreviewUi.kt +++ b/java/src/com/android/intentresolver/contentpreview/ShareouselContentPreviewUi.kt @@ -45,21 +45,17 @@ class ShareouselContentPreviewUi : ContentPreviewUi() { resources: Resources, layoutInflater: LayoutInflater, parent: ViewGroup, - headlineViewParent: View?, + headlineViewParent: View, ): ViewGroup = displayInternal(parent, headlineViewParent) - private fun displayInternal(parent: ViewGroup, headlineViewParent: View?): ViewGroup { - if (headlineViewParent != null) { - inflateHeadline(headlineViewParent) - } + private fun displayInternal(parent: ViewGroup, headlineViewParent: View): ViewGroup { + inflateHeadline(headlineViewParent) return ComposeView(parent.context).apply { setContent { val vm: ChooserViewModel = viewModel() val viewModel: ShareouselViewModel = vm.shareouselViewModel - headlineViewParent?.let { - LaunchedEffect(viewModel) { bindHeader(viewModel, headlineViewParent) } - } + LaunchedEffect(viewModel) { bindHeader(viewModel, headlineViewParent) } MaterialTheme( colorScheme = diff --git a/java/src/com/android/intentresolver/contentpreview/TextContentPreviewUi.java b/java/src/com/android/intentresolver/contentpreview/TextContentPreviewUi.java index a7ae81b0..ae7ddcd9 100644 --- a/java/src/com/android/intentresolver/contentpreview/TextContentPreviewUi.java +++ b/java/src/com/android/intentresolver/contentpreview/TextContentPreviewUi.java @@ -82,19 +82,16 @@ class TextContentPreviewUi extends ContentPreviewUi { Resources resources, LayoutInflater layoutInflater, ViewGroup parent, - @Nullable View headlineViewParent) { + View headlineViewParent) { return displayInternal(layoutInflater, parent, headlineViewParent); } private ViewGroup displayInternal( LayoutInflater layoutInflater, ViewGroup parent, - @Nullable View headlineViewParent) { + View headlineViewParent) { ViewGroup contentPreviewLayout = (ViewGroup) layoutInflater.inflate( R.layout.chooser_grid_preview_text, parent, false); - if (headlineViewParent == null) { - headlineViewParent = contentPreviewLayout; - } inflateHeadline(headlineViewParent); final ActionRow actionRow = diff --git a/java/src/com/android/intentresolver/contentpreview/UnifiedContentPreviewUi.java b/java/src/com/android/intentresolver/contentpreview/UnifiedContentPreviewUi.java index 77252112..88311016 100644 --- a/java/src/com/android/intentresolver/contentpreview/UnifiedContentPreviewUi.java +++ b/java/src/com/android/intentresolver/contentpreview/UnifiedContentPreviewUi.java @@ -54,7 +54,6 @@ class UnifiedContentPreviewUi extends ContentPreviewUi { private List mFiles; @Nullable private ViewGroup mContentPreviewView; - @Nullable private View mHeadlineView; UnifiedContentPreviewUi( @@ -93,7 +92,7 @@ class UnifiedContentPreviewUi extends ContentPreviewUi { Resources resources, LayoutInflater layoutInflater, ViewGroup parent, - @Nullable View headlineViewParent) { + View headlineViewParent) { return displayInternal(layoutInflater, parent, headlineViewParent); } @@ -109,10 +108,10 @@ class UnifiedContentPreviewUi extends ContentPreviewUi { } private ViewGroup displayInternal( - LayoutInflater layoutInflater, ViewGroup parent, @Nullable View headlineViewParent) { + LayoutInflater layoutInflater, ViewGroup parent, View headlineViewParent) { mContentPreviewView = (ViewGroup) layoutInflater.inflate( R.layout.chooser_grid_preview_image, parent, false); - mHeadlineView = headlineViewParent == null ? mContentPreviewView : headlineViewParent; + mHeadlineView = headlineViewParent; inflateHeadline(mHeadlineView); final ActionRow actionRow = diff --git a/tests/unit/src/com/android/intentresolver/contentpreview/FileContentPreviewUiTest.kt b/tests/unit/src/com/android/intentresolver/contentpreview/FileContentPreviewUiTest.kt index 25e8b239..0e4e36ab 100644 --- a/tests/unit/src/com/android/intentresolver/contentpreview/FileContentPreviewUiTest.kt +++ b/tests/unit/src/com/android/intentresolver/contentpreview/FileContentPreviewUiTest.kt @@ -64,46 +64,24 @@ class FileContentPreviewUiTest { val gridLayout = layoutInflater.inflate(R.layout.chooser_grid_scrollable_preview, null, false) as ViewGroup + val headlineRow = gridLayout.requireViewById(R.id.chooser_headline_row_container) + + assertThat(headlineRow.findViewById(R.id.headline)).isNull() + assertThat(headlineRow.findViewById(R.id.metadata)).isNull() val previewView = testSubject.display( context.resources, layoutInflater, gridLayout, - /*headlineViewParent=*/ null + headlineRow, ) assertThat(previewView).isNotNull() - val headlineView = previewView?.findViewById(R.id.headline) - assertThat(headlineView).isNotNull() - assertThat(headlineView?.text).isEqualTo(text) - val metadataView = previewView?.findViewById(R.id.metadata) - assertThat(metadataView?.text).isEqualTo(testMetadataText) - } - - @Test - fun test_displayWithExternalHeaderView() { - val layoutInflater = LayoutInflater.from(context) - val gridLayout = - layoutInflater.inflate(R.layout.chooser_grid_scrollable_preview, null, false) - as ViewGroup - val externalHeaderView = - gridLayout.requireViewById(R.id.chooser_headline_row_container) - - assertThat(externalHeaderView.findViewById(R.id.headline)).isNull() - assertThat(externalHeaderView.findViewById(R.id.metadata)).isNull() - - val previewView = - testSubject.display(context.resources, layoutInflater, gridLayout, externalHeaderView) - - assertThat(previewView).isNotNull() - assertThat(previewView.findViewById(R.id.headline)).isNull() - assertThat(previewView.findViewById(R.id.metadata)).isNull() - - val headlineView = externalHeaderView.findViewById(R.id.headline) + val headlineView = headlineRow.findViewById(R.id.headline) assertThat(headlineView).isNotNull() assertThat(headlineView?.text).isEqualTo(text) - val metadataView = externalHeaderView.findViewById(R.id.metadata) + val metadataView = headlineRow.findViewById(R.id.metadata) assertThat(metadataView?.text).isEqualTo(testMetadataText) } } diff --git a/tests/unit/src/com/android/intentresolver/contentpreview/FilesPlusTextContentPreviewUiTest.kt b/tests/unit/src/com/android/intentresolver/contentpreview/FilesPlusTextContentPreviewUiTest.kt index bd0a8efa..da0ddd12 100644 --- a/tests/unit/src/com/android/intentresolver/contentpreview/FilesPlusTextContentPreviewUiTest.kt +++ b/tests/unit/src/com/android/intentresolver/contentpreview/FilesPlusTextContentPreviewUiTest.kt @@ -52,9 +52,13 @@ class FilesPlusTextContentPreviewUiTest { private val actionFactory = object : ChooserContentPreviewUi.ActionFactory { override fun getEditButtonRunnable(): Runnable? = null + override fun getCopyButtonRunnable(): Runnable? = null + override fun createCustomActions(): List = emptyList() + override fun getModifyShareAction(): ActionRow.Action? = null + override fun getExcludeSharedTextAction(): Consumer = Consumer {} } private val imageLoader = mock() @@ -70,200 +74,106 @@ class FilesPlusTextContentPreviewUiTest { get() = getInstrumentation().context @Test - fun test_displayImagesPlusTextWithoutUriMetadata_showImagesHeadline() { + fun test_displayImagesPlusTextWithoutUriMetadataHeader_showImagesHeadline() { val sharedFileCount = 2 - val previewView = testLoadingHeadline("image/*", sharedFileCount) - - verify(headlineGenerator, times(1)).getImagesHeadline(sharedFileCount) - verifyPreviewHeadline(previewView, HEADLINE_IMAGES) - verifyPreviewMetadata(previewView, testMetadataText) - verifySharedText(previewView) - } - - @Test - fun test_displayImagesPlusTextWithoutUriMetadataExternalHeader_showImagesHeadline() { - val sharedFileCount = 2 - val (previewView, headerParent) = testLoadingExternalHeadline("image/*", sharedFileCount) + val (previewView, headlineRow) = testLoadingHeadline("image/*", sharedFileCount) + assertWithMessage("Preview parent should not be null").that(previewView).isNotNull() verify(headlineGenerator, times(1)).getImagesHeadline(sharedFileCount) - verifyInternalHeadlineAbsence(previewView) - verifyPreviewHeadline(headerParent, HEADLINE_IMAGES) - verifyPreviewMetadata(headerParent, testMetadataText) + verifyPreviewHeadline(headlineRow, HEADLINE_IMAGES) + verifyPreviewMetadata(headlineRow, testMetadataText) verifySharedText(previewView) } @Test - fun test_displayVideosPlusTextWithoutUriMetadata_showVideosHeadline() { + fun test_displayVideosPlusTextWithoutUriMetadataHeader_showVideosHeadline() { val sharedFileCount = 2 - val previewView = testLoadingHeadline("video/*", sharedFileCount) + val (previewView, headlineRow) = testLoadingHeadline("video/*", sharedFileCount) verify(headlineGenerator, times(1)).getVideosHeadline(sharedFileCount) - verifyPreviewHeadline(previewView, HEADLINE_VIDEOS) - verifyPreviewMetadata(previewView, testMetadataText) - verifySharedText(previewView) - } - - @Test - fun test_displayVideosPlusTextWithoutUriMetadataExternalHeader_showVideosHeadline() { - val sharedFileCount = 2 - val (previewView, headerParent) = testLoadingExternalHeadline("video/*", sharedFileCount) - - verify(headlineGenerator, times(1)).getVideosHeadline(sharedFileCount) - verifyInternalHeadlineAbsence(previewView) - verifyPreviewHeadline(headerParent, HEADLINE_VIDEOS) - verifyPreviewMetadata(headerParent, testMetadataText) - verifySharedText(previewView) - } - - @Test - fun test_displayDocsPlusTextWithoutUriMetadata_showFilesHeadline() { - val sharedFileCount = 2 - val previewView = testLoadingHeadline("application/pdf", sharedFileCount) - - verify(headlineGenerator, times(1)).getFilesHeadline(sharedFileCount) - verifyPreviewHeadline(previewView, HEADLINE_FILES) - verifyPreviewMetadata(previewView, testMetadataText) - verifySharedText(previewView) - } - - @Test - fun test_displayDocsPlusTextWithoutUriMetadataExternalHeader_showFilesHeadline() { - val sharedFileCount = 2 - val (previewView, headerParent) = - testLoadingExternalHeadline("application/pdf", sharedFileCount) - - verify(headlineGenerator, times(1)).getFilesHeadline(sharedFileCount) - verifyInternalHeadlineAbsence(previewView) - verifyPreviewHeadline(headerParent, HEADLINE_FILES) - verifyPreviewMetadata(headerParent, testMetadataText) + assertWithMessage("Preview parent should not be null").that(previewView).isNotNull() + verifyPreviewHeadline(headlineRow, HEADLINE_VIDEOS) + verifyPreviewMetadata(headlineRow, testMetadataText) verifySharedText(previewView) } @Test - fun test_displayMixedContentPlusTextWithoutUriMetadata_showFilesHeadline() { + fun test_displayDocsPlusTextWithoutUriMetadataHeader_showFilesHeadline() { val sharedFileCount = 2 - val previewView = testLoadingHeadline("*/*", sharedFileCount) + val (previewView, headlineRow) = testLoadingHeadline("application/pdf", sharedFileCount) verify(headlineGenerator, times(1)).getFilesHeadline(sharedFileCount) - verifyPreviewHeadline(previewView, HEADLINE_FILES) - verifyPreviewMetadata(previewView, testMetadataText) + assertWithMessage("Preview parent should not be null").that(previewView).isNotNull() + verifyPreviewHeadline(headlineRow, HEADLINE_FILES) + verifyPreviewMetadata(headlineRow, testMetadataText) verifySharedText(previewView) } @Test - fun test_displayMixedContentPlusTextWithoutUriMetadataExternalHeader_showFilesHeadline() { + fun test_displayMixedContentPlusTextWithoutUriMetadataHeader_showFilesHeadline() { val sharedFileCount = 2 - val (previewView, headerParent) = testLoadingExternalHeadline("*/*", sharedFileCount) + val (previewView, headlineRow) = testLoadingHeadline("*/*", sharedFileCount) verify(headlineGenerator, times(1)).getFilesHeadline(sharedFileCount) - verifyInternalHeadlineAbsence(previewView) - verifyPreviewHeadline(headerParent, HEADLINE_FILES) - verifyPreviewMetadata(headerParent, testMetadataText) - verifySharedText(previewView) - } - - @Test - fun test_displayImagesPlusTextWithUriMetadataSet_showImagesHeadline() { - val loadedFileMetadata = createFileInfosWithMimeTypes("image/png", "image/jpeg") - val sharedFileCount = loadedFileMetadata.size - val previewView = testLoadingHeadline("image/*", sharedFileCount, loadedFileMetadata) - - verify(headlineGenerator, times(1)).getImagesHeadline(sharedFileCount) - verifyPreviewHeadline(previewView, HEADLINE_IMAGES) - verifyPreviewMetadata(previewView, testMetadataText) + assertWithMessage("Preview parent should not be null").that(previewView).isNotNull() + verifyPreviewHeadline(headlineRow, HEADLINE_FILES) + verifyPreviewMetadata(headlineRow, testMetadataText) verifySharedText(previewView) } @Test - fun test_displayImagesPlusTextWithUriMetadataSetExternalHeader_showImagesHeadline() { + fun test_displayImagesPlusTextWithUriMetadataSetHeader_showImagesHeadline() { val loadedFileMetadata = createFileInfosWithMimeTypes("image/png", "image/jpeg") val sharedFileCount = loadedFileMetadata.size - val (previewView, headerParent) = - testLoadingExternalHeadline("image/*", sharedFileCount, loadedFileMetadata) + val (previewView, headlineRow) = + testLoadingHeadline("image/*", sharedFileCount, loadedFileMetadata) verify(headlineGenerator, times(1)).getImagesHeadline(sharedFileCount) - verifyInternalHeadlineAbsence(previewView) - verifyPreviewHeadline(headerParent, HEADLINE_IMAGES) - verifyPreviewMetadata(headerParent, testMetadataText) - verifySharedText(previewView) - } - - @Test - fun test_displayVideosPlusTextWithUriMetadataSet_showVideosHeadline() { - val loadedFileMetadata = createFileInfosWithMimeTypes("video/mp4", "video/mp4") - val sharedFileCount = loadedFileMetadata.size - val previewView = testLoadingHeadline("video/*", sharedFileCount, loadedFileMetadata) - - verify(headlineGenerator, times(1)).getVideosHeadline(sharedFileCount) - verifyPreviewHeadline(previewView, HEADLINE_VIDEOS) - verifyPreviewMetadata(previewView, testMetadataText) + assertWithMessage("Preview parent should not be null").that(previewView).isNotNull() + verifyPreviewHeadline(headlineRow, HEADLINE_IMAGES) + verifyPreviewMetadata(headlineRow, testMetadataText) verifySharedText(previewView) } @Test - fun test_displayVideosPlusTextWithUriMetadataSetExternalHeader_showVideosHeadline() { + fun test_displayVideosPlusTextWithUriMetadataSetHeader_showVideosHeadline() { val loadedFileMetadata = createFileInfosWithMimeTypes("video/mp4", "video/mp4") val sharedFileCount = loadedFileMetadata.size - val (previewView, headerParent) = - testLoadingExternalHeadline("video/*", sharedFileCount, loadedFileMetadata) + val (previewView, headlineRow) = + testLoadingHeadline("video/*", sharedFileCount, loadedFileMetadata) verify(headlineGenerator, times(1)).getVideosHeadline(sharedFileCount) - verifyInternalHeadlineAbsence(previewView) - verifyPreviewHeadline(headerParent, HEADLINE_VIDEOS) - verifyPreviewMetadata(headerParent, testMetadataText) - verifySharedText(previewView) - } - - @Test - fun test_displayImagesAndVideosPlusTextWithUriMetadataSet_showFilesHeadline() { - val loadedFileMetadata = createFileInfosWithMimeTypes("image/png", "video/mp4") - val sharedFileCount = loadedFileMetadata.size - val previewView = testLoadingHeadline("*/*", sharedFileCount, loadedFileMetadata) - - verify(headlineGenerator, times(1)).getFilesHeadline(sharedFileCount) - verifyPreviewHeadline(previewView, HEADLINE_FILES) - verifyPreviewMetadata(previewView, testMetadataText) + assertWithMessage("Preview parent should not be null").that(previewView).isNotNull() + verifyPreviewHeadline(headlineRow, HEADLINE_VIDEOS) + verifyPreviewMetadata(headlineRow, testMetadataText) verifySharedText(previewView) } @Test - fun test_displayImagesAndVideosPlusTextWithUriMetadataSetExternalHeader_showFilesHeadline() { + fun test_displayImagesAndVideosPlusTextWithUriMetadataSetHeader_showFilesHeadline() { val loadedFileMetadata = createFileInfosWithMimeTypes("image/png", "video/mp4") val sharedFileCount = loadedFileMetadata.size - val (previewView, headerParent) = - testLoadingExternalHeadline("*/*", sharedFileCount, loadedFileMetadata) + val (previewView, headlineRow) = + testLoadingHeadline("*/*", sharedFileCount, loadedFileMetadata) verify(headlineGenerator, times(1)).getFilesHeadline(sharedFileCount) - verifyInternalHeadlineAbsence(previewView) - verifyPreviewHeadline(headerParent, HEADLINE_FILES) - verifyPreviewMetadata(headerParent, testMetadataText) + assertWithMessage("Preview parent should not be null").that(previewView).isNotNull() + verifyPreviewHeadline(headlineRow, HEADLINE_FILES) + verifyPreviewMetadata(headlineRow, testMetadataText) verifySharedText(previewView) } @Test - fun test_displayDocsPlusTextWithUriMetadataSet_showFilesHeadline() { + fun test_displayDocsPlusTextWithUriMetadataSetHeader_showFilesHeadline() { val loadedFileMetadata = createFileInfosWithMimeTypes("application/pdf", "application/pdf") val sharedFileCount = loadedFileMetadata.size - val previewView = + val (previewView, headlineRow) = testLoadingHeadline("application/pdf", sharedFileCount, loadedFileMetadata) verify(headlineGenerator, times(1)).getFilesHeadline(sharedFileCount) - verifyPreviewHeadline(previewView, HEADLINE_FILES) - verifyPreviewMetadata(previewView, testMetadataText) - verifySharedText(previewView) - } - - @Test - fun test_displayDocsPlusTextWithUriMetadataSetExternalHeader_showFilesHeadline() { - val loadedFileMetadata = createFileInfosWithMimeTypes("application/pdf", "application/pdf") - val sharedFileCount = loadedFileMetadata.size - val (previewView, headerParent) = - testLoadingExternalHeadline("application/pdf", sharedFileCount, loadedFileMetadata) - - verify(headlineGenerator, times(1)).getFilesHeadline(sharedFileCount) - verifyInternalHeadlineAbsence(previewView) - verifyPreviewHeadline(headerParent, HEADLINE_FILES) - verifyPreviewMetadata(headerParent, testMetadataText) + assertWithMessage("Preview parent should not be null").that(previewView).isNotNull() + verifyPreviewHeadline(headlineRow, HEADLINE_FILES) + verifyPreviewMetadata(headlineRow, testMetadataText) verifySharedText(previewView) } @@ -287,25 +197,30 @@ class FilesPlusTextContentPreviewUiTest { val gridLayout = layoutInflater.inflate(R.layout.chooser_grid_scrollable_preview, null, false) as ViewGroup + val headlineRow = gridLayout.requireViewById(R.id.chooser_headline_row_container) - val previewView = - testSubject.display(context.resources, LayoutInflater.from(context), gridLayout, null) + testSubject.display( + context.resources, + LayoutInflater.from(context), + gridLayout, + headlineRow + ) verify(headlineGenerator, times(1)).getFilesHeadline(sharedFileCount) verify(headlineGenerator, never()).getImagesHeadline(sharedFileCount) - verifyPreviewHeadline(previewView, HEADLINE_FILES) - verifyPreviewMetadata(previewView, testMetadataText) + verifyPreviewHeadline(headlineRow, HEADLINE_FILES) + verifyPreviewMetadata(headlineRow, testMetadataText) testSubject.updatePreviewMetadata(createFileInfosWithMimeTypes("image/png", "image/jpg")) verify(headlineGenerator, times(1)).getFilesHeadline(sharedFileCount) verify(headlineGenerator, times(1)).getImagesHeadline(sharedFileCount) - verifyPreviewHeadline(previewView, HEADLINE_IMAGES) - verifyPreviewMetadata(previewView, testMetadataText) + verifyPreviewHeadline(headlineRow, HEADLINE_IMAGES) + verifyPreviewMetadata(headlineRow, testMetadataText) } @Test - fun test_uriMetadataIsMoreSpecificThanIntentMimeTypeExternalHeader_headlineGetsUpdated() { + fun test_uriMetadataIsMoreSpecificThanIntentMimeTypeHeader_headlineGetsUpdated() { val sharedFileCount = 2 val testSubject = FilesPlusTextContentPreviewUi( @@ -324,14 +239,13 @@ class FilesPlusTextContentPreviewUiTest { val gridLayout = layoutInflater.inflate(R.layout.chooser_grid_scrollable_preview, null, false) as ViewGroup - val externalHeaderView = - gridLayout.requireViewById(R.id.chooser_headline_row_container) + val headlineRow = gridLayout.requireViewById(R.id.chooser_headline_row_container) - assertWithMessage("External headline should not be inflated by default") - .that(externalHeaderView.findViewById(R.id.headline)) + assertWithMessage("Headline should not be inflated by default") + .that(headlineRow.findViewById(R.id.headline)) .isNull() - assertWithMessage("External metadata should not be inflated by default") - .that(externalHeaderView.findViewById(R.id.metadata)) + assertWithMessage("Metadata should not be inflated by default") + .that(headlineRow.findViewById(R.id.metadata)) .isNull() val previewView = @@ -339,59 +253,27 @@ class FilesPlusTextContentPreviewUiTest { context.resources, LayoutInflater.from(context), gridLayout, - externalHeaderView + headlineRow ) verify(headlineGenerator, times(1)).getFilesHeadline(sharedFileCount) verify(headlineGenerator, never()).getImagesHeadline(sharedFileCount) - verifyInternalHeadlineAbsence(previewView) - verifyPreviewHeadline(externalHeaderView, HEADLINE_FILES) - verifyPreviewMetadata(externalHeaderView, testMetadataText) + assertWithMessage("Preview parent should not be null").that(previewView).isNotNull() + verifyPreviewHeadline(headlineRow, HEADLINE_FILES) + verifyPreviewMetadata(headlineRow, testMetadataText) testSubject.updatePreviewMetadata(createFileInfosWithMimeTypes("image/png", "image/jpg")) verify(headlineGenerator, times(1)).getFilesHeadline(sharedFileCount) verify(headlineGenerator, times(1)).getImagesHeadline(sharedFileCount) - verifyPreviewHeadline(externalHeaderView, HEADLINE_IMAGES) - verifyPreviewMetadata(externalHeaderView, testMetadataText) + verifyPreviewHeadline(headlineRow, HEADLINE_IMAGES) + verifyPreviewMetadata(headlineRow, testMetadataText) } private fun testLoadingHeadline( intentMimeType: String, sharedFileCount: Int, loadedFileMetadata: List? = null, - ): ViewGroup? { - val testSubject = - FilesPlusTextContentPreviewUi( - testScope, - /*isSingleImage=*/ false, - sharedFileCount, - SHARED_TEXT, - intentMimeType, - actionFactory, - imageLoader, - DefaultMimeTypeClassifier, - headlineGenerator, - testMetadataText, - ) - val layoutInflater = LayoutInflater.from(context) - val gridLayout = - layoutInflater.inflate(R.layout.chooser_grid_scrollable_preview, null, false) - as ViewGroup - - loadedFileMetadata?.let(testSubject::updatePreviewMetadata) - return testSubject.display( - context.resources, - LayoutInflater.from(context), - gridLayout, - /*headlineViewParent=*/ null - ) - } - - private fun testLoadingExternalHeadline( - intentMimeType: String, - sharedFileCount: Int, - loadedFileMetadata: List? = null, ): Pair { val testSubject = FilesPlusTextContentPreviewUi( @@ -410,15 +292,14 @@ class FilesPlusTextContentPreviewUiTest { val gridLayout = layoutInflater.inflate(R.layout.chooser_grid_scrollable_preview, null, false) as ViewGroup - val externalHeaderView = - gridLayout.requireViewById(R.id.chooser_headline_row_container) + val headlineRow = gridLayout.requireViewById(R.id.chooser_headline_row_container) - assertWithMessage("External headline should not be inflated by default") - .that(externalHeaderView.findViewById(R.id.headline)) + assertWithMessage("Headline should not be inflated by default") + .that(headlineRow.findViewById(R.id.headline)) .isNull() - assertWithMessage("External metadata should not be inflated by default") - .that(externalHeaderView.findViewById(R.id.metadata)) + assertWithMessage("Metadata should not be inflated by default") + .that(headlineRow.findViewById(R.id.metadata)) .isNull() loadedFileMetadata?.let(testSubject::updatePreviewMetadata) @@ -426,8 +307,8 @@ class FilesPlusTextContentPreviewUiTest { context.resources, LayoutInflater.from(context), gridLayout, - externalHeaderView - ) to externalHeaderView + headlineRow + ) to headlineRow } private fun createFileInfosWithMimeTypes(vararg mimeTypes: String): List { @@ -457,13 +338,4 @@ class FilesPlusTextContentPreviewUiTest { private fun verifySharedText(previewView: ViewGroup?) { verifyTextViewText(previewView, R.id.content_preview_text, SHARED_TEXT) } - - private fun verifyInternalHeadlineAbsence(previewView: ViewGroup?) { - assertWithMessage("Preview parent should not be null").that(previewView).isNotNull() - assertWithMessage( - "Preview headline should not be inflated when an external headline is used" - ) - .that(previewView?.findViewById(R.id.headline)) - .isNull() - } } diff --git a/tests/unit/src/com/android/intentresolver/contentpreview/TextContentPreviewUiTest.kt b/tests/unit/src/com/android/intentresolver/contentpreview/TextContentPreviewUiTest.kt index 0416d71a..9a15f90a 100644 --- a/tests/unit/src/com/android/intentresolver/contentpreview/TextContentPreviewUiTest.kt +++ b/tests/unit/src/com/android/intentresolver/contentpreview/TextContentPreviewUiTest.kt @@ -44,9 +44,13 @@ class TextContentPreviewUiTest { private val actionFactory = object : ChooserContentPreviewUi.ActionFactory { override fun getEditButtonRunnable(): Runnable? = null + override fun getCopyButtonRunnable(): Runnable? = null + override fun createCustomActions(): List = emptyList() + override fun getModifyShareAction(): ActionRow.Action? = null + override fun getExcludeSharedTextAction(): Consumer = Consumer {} } private val imageLoader = mock() @@ -79,47 +83,21 @@ class TextContentPreviewUiTest { val gridLayout = layoutInflater.inflate(R.layout.chooser_grid_scrollable_preview, null, false) as ViewGroup + val headlineRow = gridLayout.requireViewById(R.id.chooser_headline_row_container) val previewView = testSubject.display( context.resources, layoutInflater, gridLayout, - /*headlineViewParent=*/ null + headlineRow, ) assertThat(previewView).isNotNull() - val headlineView = previewView?.findViewById(R.id.headline) - assertThat(headlineView).isNotNull() - assertThat(headlineView?.text).isEqualTo(text) - val metadataView = previewView?.findViewById(R.id.metadata) - assertThat(metadataView).isNotNull() - assertThat(metadataView?.text).isEqualTo(testMetadataText) - } - - @Test - fun test_displayWithExternalHeaderView_externalHeaderIsDisplayed() { - val layoutInflater = LayoutInflater.from(context) - val gridLayout = - layoutInflater.inflate(R.layout.chooser_grid_scrollable_preview, null, false) - as ViewGroup - val externalHeaderView = - gridLayout.requireViewById(R.id.chooser_headline_row_container) - - assertThat(externalHeaderView.findViewById(R.id.headline)).isNull() - assertThat(externalHeaderView.findViewById(R.id.metadata)).isNull() - - val previewView = - testSubject.display(context.resources, layoutInflater, gridLayout, externalHeaderView) - - assertThat(previewView).isNotNull() - assertThat(previewView.findViewById(R.id.headline)).isNull() - assertThat(previewView.findViewById(R.id.metadata)).isNull() - - val headlineView = externalHeaderView.findViewById(R.id.headline) + val headlineView = headlineRow.findViewById(R.id.headline) assertThat(headlineView).isNotNull() assertThat(headlineView?.text).isEqualTo(text) - val metadataView = externalHeaderView.findViewById(R.id.metadata) + val metadataView = headlineRow.findViewById(R.id.metadata) assertThat(metadataView).isNotNull() assertThat(metadataView?.text).isEqualTo(testMetadataText) } @@ -130,6 +108,7 @@ class TextContentPreviewUiTest { val gridLayout = layoutInflater.inflate(R.layout.chooser_grid_scrollable_preview, null, false) as ViewGroup + val headlineRow = gridLayout.requireViewById(R.id.chooser_headline_row_container) val albumSubject = TextContentPreviewUi( @@ -149,14 +128,14 @@ class TextContentPreviewUiTest { context.resources, layoutInflater, gridLayout, - /*headlineViewParent=*/ null + headlineRow, ) assertThat(previewView).isNotNull() - val headlineView = previewView?.findViewById(R.id.headline) + val headlineView = headlineRow.findViewById(R.id.headline) assertThat(headlineView).isNotNull() assertThat(headlineView?.text).isEqualTo(albumHeadline) - val metadataView = previewView?.findViewById(R.id.metadata) + val metadataView = headlineRow.findViewById(R.id.metadata) assertThat(metadataView).isNotNull() assertThat(metadataView?.text).isEqualTo(testMetadataText) } diff --git a/tests/unit/src/com/android/intentresolver/contentpreview/UnifiedContentPreviewUiTest.kt b/tests/unit/src/com/android/intentresolver/contentpreview/UnifiedContentPreviewUiTest.kt index 07575be0..98e6c381 100644 --- a/tests/unit/src/com/android/intentresolver/contentpreview/UnifiedContentPreviewUiTest.kt +++ b/tests/unit/src/com/android/intentresolver/contentpreview/UnifiedContentPreviewUiTest.kt @@ -67,233 +67,98 @@ class UnifiedContentPreviewUiTest { get() = getInstrumentation().context @Test - fun test_displayImagesWithoutUriMetadata_showImagesHeadline() { - testLoadingHeadline("image/*", files = null) { previewView -> + fun test_displayImagesWithoutUriMetadataHeader_showImagesHeadline() { + testLoadingHeadline("image/*", files = null) { headlineRow -> verify(headlineGenerator, times(1)).getImagesHeadline(2) - verifyPreviewHeadline(previewView, IMAGE_HEADLINE) - verifyPreviewMetadata(previewView, testMetadataText) + verifyPreviewHeadline(headlineRow, IMAGE_HEADLINE) + verifyPreviewMetadata(headlineRow, testMetadataText) } } @Test - fun test_displayImagesWithoutUriMetadataExternalHeader_showImagesHeadline() { - testLoadingExternalHeadline("image/*", files = null) { externalHeaderView -> - verify(headlineGenerator, times(1)).getImagesHeadline(2) - verifyPreviewHeadline(externalHeaderView, IMAGE_HEADLINE) - verifyPreviewMetadata(externalHeaderView, testMetadataText) - } - } - - @Test - fun test_displayVideosWithoutUriMetadata_showImagesHeadline() { - testLoadingHeadline("video/*", files = null) { previewView -> - verify(headlineGenerator, times(1)).getVideosHeadline(2) - verifyPreviewHeadline(previewView, VIDEO_HEADLINE) - verifyPreviewMetadata(previewView, testMetadataText) - } - } - - @Test - fun test_displayVideosWithoutUriMetadataExternalHeader_showImagesHeadline() { - testLoadingExternalHeadline("video/*", files = null) { externalHeaderView -> + fun test_displayVideosWithoutUriMetadataHeader_showImagesHeadline() { + testLoadingHeadline("video/*", files = null) { headlineRow -> verify(headlineGenerator, times(1)).getVideosHeadline(2) - verifyPreviewHeadline(externalHeaderView, VIDEO_HEADLINE) - verifyPreviewMetadata(externalHeaderView, testMetadataText) + verifyPreviewHeadline(headlineRow, VIDEO_HEADLINE) + verifyPreviewMetadata(headlineRow, testMetadataText) } } @Test - fun test_displayDocumentsWithoutUriMetadata_showImagesHeadline() { - testLoadingHeadline("application/pdf", files = null) { previewView -> + fun test_displayDocumentsWithoutUriMetadataHeader_showImagesHeadline() { + testLoadingHeadline("application/pdf", files = null) { headlineRow -> verify(headlineGenerator, times(1)).getFilesHeadline(2) - verifyPreviewHeadline(previewView, FILES_HEADLINE) - verifyPreviewMetadata(previewView, testMetadataText) + verifyPreviewHeadline(headlineRow, FILES_HEADLINE) + verifyPreviewMetadata(headlineRow, testMetadataText) } } @Test - fun test_displayDocumentsWithoutUriMetadataExternalHeader_showImagesHeadline() { - testLoadingExternalHeadline("application/pdf", files = null) { externalHeaderView -> + fun test_displayMixedContentWithoutUriMetadataHeader_showImagesHeadline() { + testLoadingHeadline("*/*", files = null) { headlineRow -> verify(headlineGenerator, times(1)).getFilesHeadline(2) - verifyPreviewHeadline(externalHeaderView, FILES_HEADLINE) - verifyPreviewMetadata(externalHeaderView, testMetadataText) - } - } - - @Test - fun test_displayMixedContentWithoutUriMetadata_showImagesHeadline() { - testLoadingHeadline("*/*", files = null) { previewView -> - verify(headlineGenerator, times(1)).getFilesHeadline(2) - verifyPreviewHeadline(previewView, FILES_HEADLINE) - verifyPreviewMetadata(previewView, testMetadataText) - } - } - - @Test - fun test_displayMixedContentWithoutUriMetadataExternalHeader_showImagesHeadline() { - testLoadingExternalHeadline("*/*", files = null) { externalHeader -> - verify(headlineGenerator, times(1)).getFilesHeadline(2) - verifyPreviewHeadline(externalHeader, FILES_HEADLINE) - verifyPreviewMetadata(externalHeader, testMetadataText) - } - } - - @Test - fun test_displayImagesWithUriMetadataSet_showImagesHeadline() { - val uri = Uri.parse("content://pkg.app/image.png") - val files = - listOf( - FileInfo.Builder(uri).withMimeType("image/png").build(), - FileInfo.Builder(uri).withMimeType("image/jpeg").build(), - ) - testLoadingHeadline("image/*", files) { preivewView -> - verify(headlineGenerator, times(1)).getImagesHeadline(2) - verifyPreviewHeadline(preivewView, IMAGE_HEADLINE) + verifyPreviewHeadline(headlineRow, FILES_HEADLINE) + verifyPreviewMetadata(headlineRow, testMetadataText) } } @Test - fun test_displayImagesWithUriMetadataSetExternalHeader_showImagesHeadline() { + fun test_displayImagesWithUriMetadataSetHeader_showImagesHeadline() { val uri = Uri.parse("content://pkg.app/image.png") val files = listOf( FileInfo.Builder(uri).withMimeType("image/png").build(), FileInfo.Builder(uri).withMimeType("image/jpeg").build(), ) - testLoadingExternalHeadline("image/*", files) { externalHeader -> + testLoadingHeadline("image/*", files) { headlineRow -> verify(headlineGenerator, times(1)).getImagesHeadline(2) - verifyPreviewHeadline(externalHeader, IMAGE_HEADLINE) + verifyPreviewHeadline(headlineRow, IMAGE_HEADLINE) } } @Test - fun test_displayVideosWithUriMetadataSet_showImagesHeadline() { + fun test_displayVideosWithUriMetadataSetHeader_showImagesHeadline() { val uri = Uri.parse("content://pkg.app/image.png") val files = listOf( FileInfo.Builder(uri).withMimeType("video/mp4").build(), FileInfo.Builder(uri).withMimeType("video/mp4").build(), ) - testLoadingHeadline("video/*", files) { previewView -> + testLoadingHeadline("video/*", files) { headlineRow -> verify(headlineGenerator, times(1)).getVideosHeadline(2) - verifyPreviewHeadline(previewView, VIDEO_HEADLINE) + verifyPreviewHeadline(headlineRow, VIDEO_HEADLINE) } } @Test - fun test_displayVideosWithUriMetadataSetExternalHeader_showImagesHeadline() { - val uri = Uri.parse("content://pkg.app/image.png") - val files = - listOf( - FileInfo.Builder(uri).withMimeType("video/mp4").build(), - FileInfo.Builder(uri).withMimeType("video/mp4").build(), - ) - testLoadingExternalHeadline("video/*", files) { externalHeader -> - verify(headlineGenerator, times(1)).getVideosHeadline(2) - verifyPreviewHeadline(externalHeader, VIDEO_HEADLINE) - } - } - - @Test - fun test_displayImagesAndVideosWithUriMetadataSet_showImagesHeadline() { + fun test_displayImagesAndVideosWithUriMetadataSetHeader_showImagesHeadline() { val uri = Uri.parse("content://pkg.app/image.png") val files = listOf( FileInfo.Builder(uri).withMimeType("image/png").build(), FileInfo.Builder(uri).withMimeType("video/mp4").build(), ) - testLoadingHeadline("*/*", files) { previewView -> + testLoadingHeadline("*/*", files) { headlineRow -> verify(headlineGenerator, times(1)).getFilesHeadline(2) - verifyPreviewHeadline(previewView, FILES_HEADLINE) + verifyPreviewHeadline(headlineRow, FILES_HEADLINE) } } @Test - fun test_displayImagesAndVideosWithUriMetadataSetExternalHeader_showImagesHeadline() { - val uri = Uri.parse("content://pkg.app/image.png") - val files = - listOf( - FileInfo.Builder(uri).withMimeType("image/png").build(), - FileInfo.Builder(uri).withMimeType("video/mp4").build(), - ) - testLoadingExternalHeadline("*/*", files) { externalHeader -> - verify(headlineGenerator, times(1)).getFilesHeadline(2) - verifyPreviewHeadline(externalHeader, FILES_HEADLINE) - } - } - - @Test - fun test_displayDocumentsWithUriMetadataSet_showImagesHeadline() { + fun test_displayDocumentsWithUriMetadataSetHeader_showImagesHeadline() { val uri = Uri.parse("content://pkg.app/image.png") val files = listOf( FileInfo.Builder(uri).withMimeType("application/pdf").build(), FileInfo.Builder(uri).withMimeType("application/pdf").build(), ) - testLoadingHeadline("application/pdf", files) { previewView -> + testLoadingHeadline("application/pdf", files) { headlineRow -> verify(headlineGenerator, times(1)).getFilesHeadline(2) - verifyPreviewHeadline(previewView, FILES_HEADLINE) - } - } - - @Test - fun test_displayDocumentsWithUriMetadataSetExternalHeader_showImagesHeadline() { - val uri = Uri.parse("content://pkg.app/image.png") - val files = - listOf( - FileInfo.Builder(uri).withMimeType("application/pdf").build(), - FileInfo.Builder(uri).withMimeType("application/pdf").build(), - ) - testLoadingExternalHeadline("application/pdf", files) { externalHeader -> - verify(headlineGenerator, times(1)).getFilesHeadline(2) - verifyPreviewHeadline(externalHeader, FILES_HEADLINE) + verifyPreviewHeadline(headlineRow, FILES_HEADLINE) } } private fun testLoadingHeadline( - intentMimeType: String, - files: List?, - verificationBlock: (ViewGroup?) -> Unit, - ) { - testScope.runTest { - val endMarker = FileInfo.Builder(Uri.EMPTY).build() - val emptySourceFlow = MutableSharedFlow(replay = 1) - val testSubject = - UnifiedContentPreviewUi( - testScope, - /*isSingleImage=*/ false, - intentMimeType, - actionFactory, - imageLoader, - DefaultMimeTypeClassifier, - object : TransitionElementStatusCallback { - override fun onTransitionElementReady(name: String) = Unit - override fun onAllTransitionElementsReady() = Unit - }, - files?.let { it.asFlow() } ?: emptySourceFlow.takeWhile { it !== endMarker }, - /*itemCount=*/ 2, - headlineGenerator, - testMetadataText, - ) - val layoutInflater = LayoutInflater.from(context) - val gridLayout = - layoutInflater.inflate(R.layout.chooser_grid_scrollable_preview, null, false) - as ViewGroup - - val previewView = - testSubject.display( - context.resources, - LayoutInflater.from(context), - gridLayout, - /*headlineViewParent=*/ null - ) - emptySourceFlow.tryEmit(endMarker) - - verificationBlock(previewView) - } - } - - private fun testLoadingExternalHeadline( intentMimeType: String, files: List?, verificationBlock: (View?) -> Unit, @@ -322,26 +187,20 @@ class UnifiedContentPreviewUiTest { val gridLayout = layoutInflater.inflate(R.layout.chooser_grid_scrollable_preview, null, false) as ViewGroup - val externalHeaderView = - gridLayout.requireViewById(R.id.chooser_headline_row_container) + val headlineRow = gridLayout.requireViewById(R.id.chooser_headline_row_container) - assertWithMessage("External headline should not be inflated by default") - .that(externalHeaderView.findViewById(R.id.headline)) + assertWithMessage("Headline row should not be inflated by default") + .that(headlineRow.findViewById(R.id.headline)) .isNull() - val previewView = - testSubject.display( - context.resources, - LayoutInflater.from(context), - gridLayout, - externalHeaderView, - ) - + testSubject.display( + context.resources, + LayoutInflater.from(context), + gridLayout, + headlineRow, + ) emptySourceFlow.tryEmit(endMarker) - - verifyInternalHeadlineAbsence(previewView) - verifyInternalMetadataAbsence(previewView) - verificationBlock(externalHeaderView) + verificationBlock(headlineRow) } } @@ -363,21 +222,4 @@ class UnifiedContentPreviewUiTest { private fun verifyPreviewMetadata(headerViewParent: View?, expectedText: CharSequence) { verifyTextViewText(headerViewParent, R.id.metadata, expectedText) } - - private fun verifyInternalHeadlineAbsence(previewView: ViewGroup?) { - assertWithMessage("Preview parent should not be null").that(previewView).isNotNull() - assertWithMessage( - "Preview headline should not be inflated when an external headline is used" - ) - .that(previewView?.findViewById(R.id.headline)) - .isNull() - } - private fun verifyInternalMetadataAbsence(previewView: ViewGroup?) { - assertWithMessage("Preview parent should not be null").that(previewView).isNotNull() - assertWithMessage( - "Preview metadata should not be inflated when an external metadata is used" - ) - .that(previewView?.findViewById(R.id.metadata)) - .isNull() - } } -- cgit v1.2.3-59-g8ed1b From d1b19e513ade6007688ff3afd55b531cff9219be Mon Sep 17 00:00:00 2001 From: Mark Renouf Date: Fri, 19 Apr 2024 14:27:39 -0400 Subject: Fix incorrect cross-profile share check. When this code was updated for new profiles infrastructure (ag/26444390) a mistake was made in the interpretation of (tabOwnerUserHandleForLaunch), mistaking it for 'tabOwner', ie: the user of the tab being presented. It is not. This value represents the user which launched share sheet. As a result, since this CL, no cross-profile "Blocked by IT" messages would appear when no targets are available for cross-profile sharing. The resulting UI would show an empty tab, or fall back to the "No Apps" UI message. While the UI has test coverage for this, the tests are run by forcing calls to the reponsible component to return true or false, without validation of the user ids provided. Bug: 335142494 Test: atest IntentResolver-tests-unit Flag: NONE Change-Id: I32f455611e6ae6307d49c86d480962e3426b54c3 --- .../intentresolver/ResolverListAdapter.java | 2 +- .../NoCrossProfileEmptyStateProvider.java | 7 +- .../NoCrossProfileEmptyStateProviderTest.kt | 156 +++++++++++++++++++++ 3 files changed, 161 insertions(+), 4 deletions(-) create mode 100644 tests/unit/src/com/android/intentresolver/emptystate/NoCrossProfileEmptyStateProviderTest.kt (limited to 'java/src') diff --git a/java/src/com/android/intentresolver/ResolverListAdapter.java b/java/src/com/android/intentresolver/ResolverListAdapter.java index 9843cf8d..2a8fcfa4 100644 --- a/java/src/com/android/intentresolver/ResolverListAdapter.java +++ b/java/src/com/android/intentresolver/ResolverListAdapter.java @@ -840,7 +840,7 @@ public class ResolverListAdapter extends BaseAdapter { userHandle); } - public final List getIntents() { + public List getIntents() { // TODO: immutable copy? return mIntents; } diff --git a/java/src/com/android/intentresolver/emptystate/NoCrossProfileEmptyStateProvider.java b/java/src/com/android/intentresolver/emptystate/NoCrossProfileEmptyStateProvider.java index e6d5d1c4..2b4a7ada 100644 --- a/java/src/com/android/intentresolver/emptystate/NoCrossProfileEmptyStateProvider.java +++ b/java/src/com/android/intentresolver/emptystate/NoCrossProfileEmptyStateProvider.java @@ -34,8 +34,8 @@ import com.android.intentresolver.shared.model.User; import java.util.List; /** - * 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. + * Empty state provider that informs about a lack of cross profile sharing. It will return + * an empty state in case there are no intents which can be forwarded to another profile. */ public class NoCrossProfileEmptyStateProvider implements EmptyStateProvider { @@ -79,7 +79,8 @@ public class NoCrossProfileEmptyStateProvider implements EmptyStateProvider { // 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)) { + if (launchedAsSameUser || anyCrossProfileAllowedIntents(adapter, + /* source = */ launchedAs.getHandle())) { return null; } diff --git a/tests/unit/src/com/android/intentresolver/emptystate/NoCrossProfileEmptyStateProviderTest.kt b/tests/unit/src/com/android/intentresolver/emptystate/NoCrossProfileEmptyStateProviderTest.kt new file mode 100644 index 00000000..fe3e844b --- /dev/null +++ b/tests/unit/src/com/android/intentresolver/emptystate/NoCrossProfileEmptyStateProviderTest.kt @@ -0,0 +1,156 @@ +/* + * Copyright (C) 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.intentresolver.emptystate + +import android.content.Intent +import com.android.intentresolver.ProfileHelper +import com.android.intentresolver.ResolverListAdapter +import com.android.intentresolver.annotation.JavaInterop +import com.android.intentresolver.data.repository.FakeUserRepository +import com.android.intentresolver.domain.interactor.UserInteractor +import com.android.intentresolver.inject.FakeIntentResolverFlags +import com.android.intentresolver.shared.model.User +import com.google.common.truth.Truth.assertThat +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import org.junit.Test +import org.mockito.ArgumentMatchers.anyInt +import org.mockito.ArgumentMatchers.anyList +import org.mockito.kotlin.argumentCaptor +import org.mockito.kotlin.doReturn +import org.mockito.kotlin.mock +import org.mockito.kotlin.never +import org.mockito.kotlin.same +import org.mockito.kotlin.times +import org.mockito.kotlin.verify + +@OptIn(JavaInterop::class) +class NoCrossProfileEmptyStateProviderTest { + + private val personalUser = User(0, User.Role.PERSONAL) + private val workUser = User(10, User.Role.WORK) + private val flags = FakeIntentResolverFlags() + private val personalBlocker = mock() + private val workBlocker = mock() + + private val userRepository = FakeUserRepository(listOf(personalUser, workUser)) + + private val personalIntents = listOf(Intent("PERSONAL")) + private val personalListAdapter = + mock { + on { userHandle } doReturn personalUser.handle + on { intents } doReturn personalIntents + } + private val workIntents = listOf(Intent("WORK")) + private val workListAdapter = + mock { + on { userHandle } doReturn workUser.handle + on { intents } doReturn workIntents + } + + // Pretend that no intent can ever be forwarded + val crossProfileIntentsChecker = + mock { + on { + hasCrossProfileIntents( + /* intents = */ anyList(), + /* source = */ anyInt(), + /* target = */ anyInt() + ) + } doReturn false + } + private val sourceUserId = argumentCaptor() + private val targetUserId = argumentCaptor() + + @Test + fun testPersonalToWork() { + val userInteractor = UserInteractor(userRepository, launchedAs = personalUser.handle) + + val profileHelper = + ProfileHelper( + userInteractor, + CoroutineScope(Dispatchers.Unconfined), + Dispatchers.Unconfined, + flags + ) + + val provider = + NoCrossProfileEmptyStateProvider( + /* profileHelper = */ profileHelper, + /* noWorkToPersonalEmptyState = */ personalBlocker, + /* noPersonalToWorkEmptyState = */ workBlocker, + /* crossProfileIntentsChecker = */ crossProfileIntentsChecker + ) + + // Personal to personal, not blocked + assertThat(provider.getEmptyState(personalListAdapter)).isNull() + // Not called because sourceUser == targetUser + verify(crossProfileIntentsChecker, never()) + .hasCrossProfileIntents(anyList(), anyInt(), anyInt()) + + // Personal to work, blocked + assertThat(provider.getEmptyState(workListAdapter)).isSameInstanceAs(workBlocker) + + verify(crossProfileIntentsChecker, times(1)) + .hasCrossProfileIntents( + same(workIntents), + sourceUserId.capture(), + targetUserId.capture() + ) + assertThat(sourceUserId.firstValue).isEqualTo(personalUser.id) + assertThat(targetUserId.firstValue).isEqualTo(workUser.id) + } + + @Test + fun testWorkToPersonal() { + val userInteractor = UserInteractor(userRepository, launchedAs = workUser.handle) + + val profileHelper = + ProfileHelper( + userInteractor, + CoroutineScope(Dispatchers.Unconfined), + Dispatchers.Unconfined, + flags + ) + + val provider = + NoCrossProfileEmptyStateProvider( + /* profileHelper = */ profileHelper, + /* noWorkToPersonalEmptyState = */ personalBlocker, + /* noPersonalToWorkEmptyState = */ workBlocker, + /* crossProfileIntentsChecker = */ crossProfileIntentsChecker + ) + + // Work to work, not blocked + assertThat(provider.getEmptyState(workListAdapter)).isNull() + // Not called because sourceUser == targetUser + verify(crossProfileIntentsChecker, never()) + .hasCrossProfileIntents(anyList(), anyInt(), anyInt()) + + // Work to personal, blocked + assertThat(provider.getEmptyState(personalListAdapter)).isSameInstanceAs(personalBlocker) + + verify(crossProfileIntentsChecker, times(1)) + .hasCrossProfileIntents( + same(personalIntents), + sourceUserId.capture(), + targetUserId.capture() + ) + assertThat(sourceUserId.firstValue).isEqualTo(workUser.id) + assertThat(targetUserId.firstValue).isEqualTo(personalUser.id) + } +} -- cgit v1.2.3-59-g8ed1b From ceeae6dcff1dddca15b8e3c2781dc1dc3381d1c8 Mon Sep 17 00:00:00 2001 From: Anna Zhuravleva Date: Wed, 17 Apr 2024 17:02:12 +0000 Subject: Block taking recents screenshot if private profile available Sharesheet shows separate tab when private profile is on. User can move sharesheet to the background (e.g. by launching other app using shortcut) and then lock private profile. Recents preview screen shows screenshot of the Sharesheet at the last moment it was in foreground (when the space was unlocked). As a result Recents preview can leak whether private profile exists on device. This change sets setting to show blank screen in the recents when private profile is avaialble. Bug: 333387696 Test: manual, https://buganizer.corp.google.com/issues/333387696#comment8 Change-Id: I8758cd0a311ce93a03d998c4ada8999a6d02246d --- java/src/com/android/intentresolver/ChooserActivity.java | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) (limited to 'java/src') diff --git a/java/src/com/android/intentresolver/ChooserActivity.java b/java/src/com/android/intentresolver/ChooserActivity.java index 7e2c9c5a..e68ee7f4 100644 --- a/java/src/com/android/intentresolver/ChooserActivity.java +++ b/java/src/com/android/intentresolver/ChooserActivity.java @@ -509,6 +509,8 @@ public class ChooserActivity extends Hilt_ChooserActivity implements mMaxTargetsPerRow, mFeatureFlags); + maybeDisableRecentsScreenshot(mProfiles, mProfileAvailability); + if (!configureContentView(mTargetDataLoader)) { mPersonalPackageMonitor = createPackageMonitor( mChooserMultiProfilePagerAdapter.getPersonalListAdapter()); @@ -647,6 +649,20 @@ public class ChooserActivity extends Hilt_ChooserActivity implements Tracer.INSTANCE.markLaunched(); } + private void maybeDisableRecentsScreenshot( + ProfileHelper profileHelper, ProfileAvailability profileAvailability) { + for (Profile profile : profileHelper.getProfiles()) { + if (profile.getType() == Profile.Type.PRIVATE) { + if (profileAvailability.isAvailable(profile)) { + // Show blank screen in Recent preview if private profile is available + // to not leak its presence. + setRecentsScreenshotEnabled(false); + } + return; + } + } + } + private void onChooserRequestChanged(ChooserRequest chooserRequest) { // intentional reference comarison if (mRequest == chooserRequest) { -- cgit v1.2.3-59-g8ed1b From 56e24a6036c82aaa110b5092243cc95f73836611 Mon Sep 17 00:00:00 2001 From: Matt Casey Date: Wed, 24 Apr 2024 20:50:05 +0000 Subject: Only show the shareoulsel card when the image is availble. Remove red outlines while load is in progress. Also provide a color for the background when no shareousel state is available. Bug: 336849655 Test: Visual verification with ShareTest Flag: None Change-Id: I84a41350621d828a45f037b17350e495d382afc1 --- .../payloadtoggle/ui/composable/ShareouselComposable.kt | 10 ++-------- 1 file changed, 2 insertions(+), 8 deletions(-) (limited to 'java/src') diff --git a/java/src/com/android/intentresolver/contentpreview/payloadtoggle/ui/composable/ShareouselComposable.kt b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/ui/composable/ShareouselComposable.kt index 0cb7306d..0a431c2a 100644 --- a/java/src/com/android/intentresolver/contentpreview/payloadtoggle/ui/composable/ShareouselComposable.kt +++ b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/ui/composable/ShareouselComposable.kt @@ -45,9 +45,7 @@ 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.ColorFilter -import androidx.compose.ui.graphics.RectangleShape import androidx.compose.ui.graphics.asImageBitmap import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.res.dimensionResource @@ -68,6 +66,7 @@ fun Shareousel(viewModel: ShareouselViewModel) { } else { Spacer( Modifier.height(dimensionResource(R.dimen.chooser_preview_image_height_tall) + 64.dp) + .background(MaterialTheme.colorScheme.surfaceContainer) ) } } @@ -130,12 +129,7 @@ private fun ShareouselCard(viewModel: ShareouselPreviewViewModel) { } ?: run { // TODO: look at ScrollableImagePreviewView.setLoading() - Box( - modifier = - Modifier.fillMaxHeight() - .aspectRatio(2f / 5f) - .border(1.dp, Color.Red, RectangleShape) - ) + Box(modifier = Modifier.fillMaxHeight().aspectRatio(2f / 5f)) } }, contentType = contentType, -- cgit v1.2.3-59-g8ed1b From 411f9e83c490df04e1e2befd7577923ea7093675 Mon Sep 17 00:00:00 2001 From: Joshua Trask Date: Thu, 18 Apr 2024 21:34:53 +0000 Subject: System action refinement. This is an important bugfix since Android API docs imply that app developers may start a share session with an "incomplete" payload and rely on refinement to replace the content post-selection. System actions will similarly want to use "complete" (i.e., potentially-refined) payloads. The approach is based on the observation that our existing refinement flow, despite historically operating in terms of `TargetInfo` objects, is really a function (async) from a list of one or more matching intents to a refined intent filter-matching at least one of the sources, and we can use the same mechanism to request a refinement of the "target intent" we originally used to derive the system actions and then simply re-derive them by applying the same logic to the refined intent. Per feedback on the earlier prototypes (ag/26681688 and ag/26765080), this implementation explicitly tracks the requested "type" of selection when we initiate a refinement flow so that we can handle the result by-cases when we later `consume()` the result. Bug: 331206205 Flag: com.android.intentresolver.refine_system_actions DEVELOPMENT Test: existing refinement tests + manually exercised w/ShareTest app Change-Id: I83e5a2e088387c495dec7e41fee0e311a82644f1 --- aconfig/FeatureFlags.aconfig | 10 ++ .../android/intentresolver/ChooserActivity.java | 130 +++++++++++++++++---- .../intentresolver/ChooserRefinementManager.java | 117 ++++++++++++++----- .../intentresolver/ChooserRefinementManagerTest.kt | 20 ++-- 4 files changed, 221 insertions(+), 56 deletions(-) (limited to 'java/src') diff --git a/aconfig/FeatureFlags.aconfig b/aconfig/FeatureFlags.aconfig index 583d8502..f61bce26 100644 --- a/aconfig/FeatureFlags.aconfig +++ b/aconfig/FeatureFlags.aconfig @@ -49,3 +49,13 @@ flag { description: "Enable private profile support" bug: "328029692" } + +flag { + name: "refine_system_actions" + namespace: "intentresolver" + description: "This flag enables sending system actions to the caller refinement flow" + bug: "331206205" + metadata { + purpose: PURPOSE_BUGFIX + } +} diff --git a/java/src/com/android/intentresolver/ChooserActivity.java b/java/src/com/android/intentresolver/ChooserActivity.java index 7e2c9c5a..814bf301 100644 --- a/java/src/com/android/intentresolver/ChooserActivity.java +++ b/java/src/com/android/intentresolver/ChooserActivity.java @@ -97,6 +97,7 @@ import androidx.recyclerview.widget.GridLayoutManager; import androidx.recyclerview.widget.RecyclerView; import androidx.viewpager.widget.ViewPager; +import com.android.intentresolver.ChooserRefinementManager.RefinementType; import com.android.intentresolver.chooser.DisplayResolveInfo; import com.android.intentresolver.chooser.MultiDisplayResolveInfo; import com.android.intentresolver.chooser.TargetInfo; @@ -568,23 +569,52 @@ public class ChooserActivity extends Hilt_ChooserActivity implements mRefinementManager = new ViewModelProvider(this).get(ChooserRefinementManager.class); mRefinementManager.getRefinementCompletion().observe(this, completion -> { if (completion.consume()) { - TargetInfo targetInfo = completion.getTargetInfo(); - // targetInfo is non-null if the refinement process was successful. - if (targetInfo != null) { - maybeRemoveSharedText(targetInfo); - - // We already block suspended targets from going to refinement, and we probably - // can't recover a Chooser session if that's the reason the refined target fails - // to launch now. Fire-and-forget the refined launch; ignore the return value - // and just make sure the Sharesheet session gets cleaned up regardless. - final ResolveInfo ri = targetInfo.getResolveInfo(); - final Intent intent1 = targetInfo.getResolvedIntent(); - - safelyStartActivity(targetInfo); - - // Rely on the ActivityManager to pop up a dialog regarding app suspension - // and return false - targetInfo.isSuspended(); + if (completion.getRefinedIntent() == null) { + finish(); + return; + } + + // Prepare to regenerate our "system actions" based on the refined intent. + // TODO: optimize if needed. `TARGET_INFO` cases don't require a new action + // factory at all. And if we break up `ChooserActionFactory`, we could avoid + // resolving a new editor intent unless we're handling an `EDIT_ACTION`. + ChooserActionFactory refinedActionFactory = + createChooserActionFactory(completion.getRefinedIntent()); + switch (completion.getType()) { + case TARGET_INFO: { + TargetInfo refinedTarget = completion + .getOriginalTargetInfo() + .tryToCloneWithAppliedRefinement( + completion.getRefinedIntent()); + if (refinedTarget == null) { + Log.e(TAG, "Failed to apply refinement to any matching source intent"); + } else { + maybeRemoveSharedText(refinedTarget); + + // We already block suspended targets from going to refinement, and we + // probably can't recover a Chooser session if that's the reason the + // refined target fails to launch now. Fire-and-forget the refined + // launch, and make sure Sharesheet gets cleaned up regardless of the + // outcome of that launch.launch; ignore + + safelyStartActivity(refinedTarget); + } + } + break; + + case COPY_ACTION: { + if (refinedActionFactory.getCopyButtonRunnable() != null) { + refinedActionFactory.getCopyButtonRunnable().run(); + } + } + break; + + case EDIT_ACTION: { + if (refinedActionFactory.getEditButtonRunnable() != null) { + refinedActionFactory.getEditButtonRunnable().run(); + } + } + break; } finish(); @@ -597,12 +627,15 @@ public class ChooserActivity extends Hilt_ChooserActivity implements mRequest.getTargetIntent(), mRequest.getAdditionalContentUri(), mChooserServiceFeatureFlags.chooserPayloadToggling()); + ChooserContentPreviewUi.ActionFactory actionFactory = + decorateActionFactoryWithRefinement( + createChooserActionFactory(mRequest.getTargetIntent())); mChooserContentPreviewUi = new ChooserContentPreviewUi( getCoroutineScope(getLifecycle()), previewViewModel.getPreviewDataProvider(), mRequest.getTargetIntent(), previewViewModel.getImageLoader(), - createChooserActionFactory(), + actionFactory, createModifyShareActionFactory(), mEnterTransitionAnimationDelegate, new HeadlineGeneratorImpl(this), @@ -2090,10 +2123,67 @@ public class ChooserActivity extends Hilt_ChooserActivity implements return PreviewViewModel.Companion.getFactory(); } - private ChooserActionFactory createChooserActionFactory() { + private ChooserContentPreviewUi.ActionFactory decorateActionFactoryWithRefinement( + ChooserContentPreviewUi.ActionFactory originalFactory) { + if (!mFeatureFlags.refineSystemActions()) { + return originalFactory; + } + + return new ChooserContentPreviewUi.ActionFactory() { + @Override + @Nullable + public Runnable getEditButtonRunnable() { + return () -> { + if (!mRefinementManager.maybeHandleSelection( + RefinementType.EDIT_ACTION, + List.of(mRequest.getTargetIntent()), + null, + mRequest.getRefinementIntentSender(), + getApplication(), + getMainThreadHandler())) { + originalFactory.getEditButtonRunnable().run(); + } + }; + } + + @Override + @Nullable + public Runnable getCopyButtonRunnable() { + return () -> { + if (!mRefinementManager.maybeHandleSelection( + RefinementType.COPY_ACTION, + List.of(mRequest.getTargetIntent()), + null, + mRequest.getRefinementIntentSender(), + getApplication(), + getMainThreadHandler())) { + originalFactory.getCopyButtonRunnable().run(); + } + }; + } + + @Override + public List createCustomActions() { + return originalFactory.createCustomActions(); + } + + @Override + @Nullable + public ActionRow.Action getModifyShareAction() { + return originalFactory.getModifyShareAction(); + } + + @Override + public Consumer getExcludeSharedTextAction() { + return originalFactory.getExcludeSharedTextAction(); + } + }; + } + + private ChooserActionFactory createChooserActionFactory(Intent targetIntent) { return new ChooserActionFactory( this, - mRequest.getTargetIntent(), + targetIntent, mRequest.getLaunchedFromPackage(), mRequest.getChooserActions(), mImageEditor, diff --git a/java/src/com/android/intentresolver/ChooserRefinementManager.java b/java/src/com/android/intentresolver/ChooserRefinementManager.java index 79484240..5c828a8e 100644 --- a/java/src/com/android/intentresolver/ChooserRefinementManager.java +++ b/java/src/com/android/intentresolver/ChooserRefinementManager.java @@ -58,23 +58,59 @@ public final class ChooserRefinementManager extends ViewModel { private boolean mConfigurationChangeInProgress = false; + /** + * The types of selections that may be sent to refinement. + * + * The refinement flow results in a refined intent, but the interpretation of that intent + * depends on the type of selection that prompted the refinement. + */ + public enum RefinementType { + TARGET_INFO, // A normal (`TargetInfo`) target. + + // System actions derived from the refined intent (from `ChooserActionFactory`). + COPY_ACTION, + EDIT_ACTION + } + /** * A token for the completion of a refinement process that can be consumed exactly once. */ public static class RefinementCompletion { private TargetInfo mTargetInfo; private boolean mConsumed; + private final RefinementType mType; - RefinementCompletion(TargetInfo targetInfo) { - mTargetInfo = targetInfo; + @Nullable + private final TargetInfo mOriginalTargetInfo; + + @Nullable + private final Intent mRefinedIntent; + + RefinementCompletion( + @Nullable RefinementType type, + @Nullable TargetInfo originalTargetInfo, + @Nullable Intent refinedIntent) { + mType = type; + mOriginalTargetInfo = originalTargetInfo; + mRefinedIntent = refinedIntent; + } + + public RefinementType getType() { + return mType; + } + + @Nullable + public TargetInfo getOriginalTargetInfo() { + return mOriginalTargetInfo; } /** * @return The output of the completed refinement process. Null if the process was aborted * or failed. */ - public TargetInfo getTargetInfo() { - return mTargetInfo; + @Nullable + public Intent getRefinedIntent() { + return mRefinedIntent; } /** @@ -105,14 +141,11 @@ public final class ChooserRefinementManager extends ViewModel { * @return true if the selection should wait for a now-started refinement flow, or false if it * can proceed by the default (non-refinement) logic. */ - public boolean maybeHandleSelection(TargetInfo selectedTarget, - IntentSender refinementIntentSender, Application application, Handler mainHandler) { - if (refinementIntentSender == null) { - return false; - } - if (selectedTarget.getAllSourceIntents().isEmpty()) { - return false; - } + public boolean maybeHandleSelection( + TargetInfo selectedTarget, + IntentSender refinementIntentSender, + Application application, + Handler mainHandler) { if (selectedTarget.isSuspended()) { // We expect all launches to fail for this target, so don't make the user go through the // refinement flow first. Besides, the default (non-refinement) handling displays a @@ -121,27 +154,57 @@ public final class ChooserRefinementManager extends ViewModel { return false; } + return maybeHandleSelection( + RefinementType.TARGET_INFO, + selectedTarget.getAllSourceIntents(), + selectedTarget, + refinementIntentSender, + application, + mainHandler); + } + + /** + * Delegate the user's selection of targets (with one or more matching {@code sourceIntents} to + * the refinement flow, if possible. + * @return true if the selection should wait for a now-started refinement flow, or false if it + * can proceed by the default (non-refinement) logic. + */ + public boolean maybeHandleSelection( + RefinementType refinementType, + List sourceIntents, + @Nullable TargetInfo originalTargetInfo, + IntentSender refinementIntentSender, + Application application, + Handler mainHandler) { + // Our requests have a non-null `originalTargetInfo` in exactly the + // cases when `refinementType == TARGET_INFO`. + assert ((originalTargetInfo == null) == (refinementType == RefinementType.TARGET_INFO)); + + if (refinementIntentSender == null) { + return false; + } + if (sourceIntents.isEmpty()) { + return false; + } + destroy(); // Terminate any prior sessions. mRefinementResultReceiver = new RefinementResultReceiver( + refinementType, refinedIntent -> { destroy(); - - TargetInfo refinedTarget = - selectedTarget.tryToCloneWithAppliedRefinement(refinedIntent); - if (refinedTarget != null) { - mRefinementCompletion.setValue(new RefinementCompletion(refinedTarget)); - } else { - Log.e(TAG, "Failed to apply refinement to any matching source intent"); - mRefinementCompletion.setValue(new RefinementCompletion(null)); - } + mRefinementCompletion.setValue( + new RefinementCompletion( + refinementType, originalTargetInfo, refinedIntent)); }, () -> { destroy(); - mRefinementCompletion.setValue(new RefinementCompletion(null)); + mRefinementCompletion.setValue( + new RefinementCompletion( + refinementType, originalTargetInfo, null)); }, mainHandler); - Intent refinementRequest = makeRefinementRequest(mRefinementResultReceiver, selectedTarget); + Intent refinementRequest = makeRefinementRequest(mRefinementResultReceiver, sourceIntents); try { refinementIntentSender.sendIntent(application, 0, refinementRequest, null, null); return true; @@ -167,7 +230,7 @@ public final class ChooserRefinementManager extends ViewModel { // into a valid Chooser session, so we'll treat it as a cancellation instead. Log.w(TAG, "Chooser resumed while awaiting refinement result; aborting"); destroy(); - mRefinementCompletion.setValue(new RefinementCompletion(null)); + mRefinementCompletion.setValue(new RefinementCompletion(null, null, null)); } } } @@ -187,9 +250,8 @@ public final class ChooserRefinementManager extends ViewModel { } private static Intent makeRefinementRequest( - RefinementResultReceiver resultReceiver, TargetInfo originalTarget) { + RefinementResultReceiver resultReceiver, List sourceIntents) { final Intent fillIn = new Intent(); - final List sourceIntents = originalTarget.getAllSourceIntents(); fillIn.putExtra(Intent.EXTRA_INTENT, sourceIntents.get(0)); final int sourceIntentCount = sourceIntents.size(); if (sourceIntentCount > 1) { @@ -204,16 +266,19 @@ public final class ChooserRefinementManager extends ViewModel { } private static class RefinementResultReceiver extends ResultReceiver { + private final RefinementType mType; private final Consumer mOnSelectionRefined; private final Runnable mOnRefinementCancelled; private boolean mDestroyed; RefinementResultReceiver( + RefinementType type, Consumer onSelectionRefined, Runnable onRefinementCancelled, Handler handler) { super(handler); + mType = type; mOnSelectionRefined = onSelectionRefined; mOnRefinementCancelled = onRefinementCancelled; } diff --git a/tests/unit/src/com/android/intentresolver/ChooserRefinementManagerTest.kt b/tests/unit/src/com/android/intentresolver/ChooserRefinementManagerTest.kt index 61ac0c21..16c917b0 100644 --- a/tests/unit/src/com/android/intentresolver/ChooserRefinementManagerTest.kt +++ b/tests/unit/src/com/android/intentresolver/ChooserRefinementManagerTest.kt @@ -29,8 +29,8 @@ import androidx.lifecycle.Observer import androidx.test.annotation.UiThreadTest import androidx.test.ext.junit.runners.AndroidJUnit4 import com.android.intentresolver.ChooserRefinementManager.RefinementCompletion +import com.android.intentresolver.ChooserRefinementManager.RefinementType import com.android.intentresolver.chooser.ImmutableTargetInfo -import com.android.intentresolver.chooser.TargetInfo import com.google.common.truth.Truth.assertThat import java.util.concurrent.CountDownLatch import java.util.concurrent.TimeUnit @@ -55,15 +55,15 @@ class ChooserRefinementManagerTest { object : Observer { val failureCountDown = CountDownLatch(1) val successCountDown = CountDownLatch(1) - var latestTargetInfo: TargetInfo? = null + var latestRefinedIntent: Intent? = null override fun onChanged(completion: RefinementCompletion) { if (completion.consume()) { - val targetInfo = completion.targetInfo - if (targetInfo == null) { + val refinedIntent = completion.refinedIntent + if (refinedIntent == null) { failureCountDown.countDown() } else { - latestTargetInfo = targetInfo + latestRefinedIntent = refinedIntent successCountDown.countDown() } } @@ -115,8 +115,7 @@ class ChooserRefinementManagerTest { receiver?.send(Activity.RESULT_OK, bundle) assertThat(completionObserver.successCountDown.await(1000, TimeUnit.MILLISECONDS)).isTrue() - assertThat(completionObserver.latestTargetInfo?.resolvedIntent?.action) - .isEqualTo(Intent.ACTION_VIEW) + assertThat(completionObserver.latestRefinedIntent?.action).isEqualTo(Intent.ACTION_VIEW) } @Test @@ -231,10 +230,11 @@ class ChooserRefinementManagerTest { @Test fun testRefinementCompletion() { - val refinementCompletion = RefinementCompletion(exampleTargetInfo) - assertThat(refinementCompletion.targetInfo).isEqualTo(exampleTargetInfo) + val refinementCompletion = + RefinementCompletion(RefinementType.TARGET_INFO, exampleTargetInfo, null) + assertThat(refinementCompletion.originalTargetInfo).isEqualTo(exampleTargetInfo) assertThat(refinementCompletion.consume()).isTrue() - assertThat(refinementCompletion.targetInfo).isEqualTo(exampleTargetInfo) + assertThat(refinementCompletion.originalTargetInfo).isEqualTo(exampleTargetInfo) // can only consume once. assertThat(refinementCompletion.consume()).isFalse() -- cgit v1.2.3-59-g8ed1b From 8bcb1b4b6811ad123f6c37068577fd85228d6674 Mon Sep 17 00:00:00 2001 From: Mark Renouf Date: Tue, 9 Apr 2024 09:42:30 -0400 Subject: [emptystate] Extract static inner classes to top level Test: NA Flag: EXEMPT refactor Bug: 300157408 Change-Id: I5b9750f3b7b6ca1679bbf741c1b5466601d6914b --- .../android/intentresolver/ChooserActivity.java | 2 +- .../android/intentresolver/ResolverActivity.java | 2 +- .../emptystate/DevicePolicyBlockerEmptyState.java | 85 ++++++++++++++++++++++ .../emptystate/NoAppsAvailableEmptyState.java | 55 ++++++++++++++ .../NoAppsAvailableEmptyStateProvider.java | 34 --------- .../NoCrossProfileEmptyStateProvider.java | 64 ---------------- .../emptystate/WorkProfileOffEmptyState.java | 57 +++++++++++++++ .../WorkProfilePausedEmptyStateProvider.java | 35 --------- 8 files changed, 199 insertions(+), 135 deletions(-) create mode 100644 java/src/com/android/intentresolver/emptystate/DevicePolicyBlockerEmptyState.java create mode 100644 java/src/com/android/intentresolver/emptystate/NoAppsAvailableEmptyState.java create mode 100644 java/src/com/android/intentresolver/emptystate/WorkProfileOffEmptyState.java (limited to 'java/src') diff --git a/java/src/com/android/intentresolver/ChooserActivity.java b/java/src/com/android/intentresolver/ChooserActivity.java index 56873302..ccf2a3dc 100644 --- a/java/src/com/android/intentresolver/ChooserActivity.java +++ b/java/src/com/android/intentresolver/ChooserActivity.java @@ -111,11 +111,11 @@ import com.android.intentresolver.data.repository.DevicePolicyResources; import com.android.intentresolver.domain.interactor.UserInteractor; import com.android.intentresolver.emptystate.CompositeEmptyStateProvider; import com.android.intentresolver.emptystate.CrossProfileIntentsChecker; +import com.android.intentresolver.emptystate.DevicePolicyBlockerEmptyState; import com.android.intentresolver.emptystate.EmptyState; import com.android.intentresolver.emptystate.EmptyStateProvider; import com.android.intentresolver.emptystate.NoAppsAvailableEmptyStateProvider; import com.android.intentresolver.emptystate.NoCrossProfileEmptyStateProvider; -import com.android.intentresolver.emptystate.NoCrossProfileEmptyStateProvider.DevicePolicyBlockerEmptyState; import com.android.intentresolver.emptystate.WorkProfilePausedEmptyStateProvider; import com.android.intentresolver.grid.ChooserGridAdapter; import com.android.intentresolver.icons.TargetDataLoader; diff --git a/java/src/com/android/intentresolver/ResolverActivity.java b/java/src/com/android/intentresolver/ResolverActivity.java index 4e763f94..1b08d957 100644 --- a/java/src/com/android/intentresolver/ResolverActivity.java +++ b/java/src/com/android/intentresolver/ResolverActivity.java @@ -94,11 +94,11 @@ import com.android.intentresolver.data.repository.DevicePolicyResources; import com.android.intentresolver.domain.interactor.UserInteractor; import com.android.intentresolver.emptystate.CompositeEmptyStateProvider; import com.android.intentresolver.emptystate.CrossProfileIntentsChecker; +import com.android.intentresolver.emptystate.DevicePolicyBlockerEmptyState; import com.android.intentresolver.emptystate.EmptyState; import com.android.intentresolver.emptystate.EmptyStateProvider; import com.android.intentresolver.emptystate.NoAppsAvailableEmptyStateProvider; import com.android.intentresolver.emptystate.NoCrossProfileEmptyStateProvider; -import com.android.intentresolver.emptystate.NoCrossProfileEmptyStateProvider.DevicePolicyBlockerEmptyState; import com.android.intentresolver.emptystate.WorkProfilePausedEmptyStateProvider; import com.android.intentresolver.icons.DefaultTargetDataLoader; import com.android.intentresolver.icons.TargetDataLoader; diff --git a/java/src/com/android/intentresolver/emptystate/DevicePolicyBlockerEmptyState.java b/java/src/com/android/intentresolver/emptystate/DevicePolicyBlockerEmptyState.java new file mode 100644 index 00000000..b627636e --- /dev/null +++ b/java/src/com/android/intentresolver/emptystate/DevicePolicyBlockerEmptyState.java @@ -0,0 +1,85 @@ +/* + * Copyright (C) 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.intentresolver.emptystate; + +import android.app.admin.DevicePolicyEventLogger; +import android.app.admin.DevicePolicyManager; +import android.content.Context; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.annotation.StringRes; + +/** + * Empty state that gets strings from the device policy manager and tracks events into + * event logger of the device policy events. + */ +public class DevicePolicyBlockerEmptyState implements EmptyState { + + @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/emptystate/NoAppsAvailableEmptyState.java b/java/src/com/android/intentresolver/emptystate/NoAppsAvailableEmptyState.java new file mode 100644 index 00000000..b03c730a --- /dev/null +++ b/java/src/com/android/intentresolver/emptystate/NoAppsAvailableEmptyState.java @@ -0,0 +1,55 @@ +/* + * Copyright (C) 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.intentresolver.emptystate; + +import android.app.admin.DevicePolicyEventLogger; +import android.stats.devicepolicy.nano.DevicePolicyEnums; + +import androidx.annotation.NonNull; + +public class NoAppsAvailableEmptyState implements EmptyState { + + @NonNull + private final String mTitle; + + @NonNull + private final String mMetricsCategory; + + private final boolean mIsPersonalProfile; + + public NoAppsAvailableEmptyState(@NonNull String title, @NonNull String metricsCategory, + boolean isPersonalProfile) { + mTitle = title; + mMetricsCategory = metricsCategory; + mIsPersonalProfile = isPersonalProfile; + } + + @NonNull + @Override + public String getTitle() { + return mTitle; + } + + @Override + public void onEmptyStateShown() { + DevicePolicyEventLogger.createEvent( + DevicePolicyEnums.RESOLVER_EMPTY_STATE_NO_APPS_RESOLVED) + .setStrings(mMetricsCategory) + .setBoolean(/*isPersonalProfile*/ mIsPersonalProfile) + .write(); + } +} diff --git a/java/src/com/android/intentresolver/emptystate/NoAppsAvailableEmptyStateProvider.java b/java/src/com/android/intentresolver/emptystate/NoAppsAvailableEmptyStateProvider.java index af9d56d1..cd1448e4 100644 --- a/java/src/com/android/intentresolver/emptystate/NoAppsAvailableEmptyStateProvider.java +++ b/java/src/com/android/intentresolver/emptystate/NoAppsAvailableEmptyStateProvider.java @@ -21,9 +21,7 @@ import static com.android.intentresolver.shared.model.Profile.Type.PERSONAL; import static java.util.Objects.requireNonNull; -import android.app.admin.DevicePolicyEventLogger; import android.os.UserHandle; -import android.stats.devicepolicy.nano.DevicePolicyEnums; import androidx.annotation.NonNull; @@ -81,36 +79,4 @@ public class NoAppsAvailableEmptyStateProvider implements EmptyStateProvider { } } - public static class NoAppsAvailableEmptyState implements EmptyState { - - @NonNull - private final String mTitle; - - @NonNull - private final String mMetricsCategory; - - private final boolean mIsPersonalProfile; - - public NoAppsAvailableEmptyState(@NonNull String title, @NonNull String metricsCategory, - boolean isPersonalProfile) { - mTitle = title; - mMetricsCategory = metricsCategory; - mIsPersonalProfile = isPersonalProfile; - } - - @NonNull - @Override - public String getTitle() { - return mTitle; - } - - @Override - public void onEmptyStateShown() { - DevicePolicyEventLogger.createEvent( - DevicePolicyEnums.RESOLVER_EMPTY_STATE_NO_APPS_RESOLVED) - .setStrings(mMetricsCategory) - .setBoolean(/*isPersonalProfile*/ mIsPersonalProfile) - .write(); - } - } } diff --git a/java/src/com/android/intentresolver/emptystate/NoCrossProfileEmptyStateProvider.java b/java/src/com/android/intentresolver/emptystate/NoCrossProfileEmptyStateProvider.java index 2b4a7ada..fa33928b 100644 --- a/java/src/com/android/intentresolver/emptystate/NoCrossProfileEmptyStateProvider.java +++ b/java/src/com/android/intentresolver/emptystate/NoCrossProfileEmptyStateProvider.java @@ -16,15 +16,10 @@ package com.android.intentresolver.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; import androidx.annotation.Nullable; -import androidx.annotation.StringRes; import com.android.intentresolver.ProfileHelper; import com.android.intentresolver.ResolverListAdapter; @@ -91,63 +86,4 @@ public class NoCrossProfileEmptyStateProvider implements EmptyStateProvider { return null; } - /** - * 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/emptystate/WorkProfileOffEmptyState.java b/java/src/com/android/intentresolver/emptystate/WorkProfileOffEmptyState.java new file mode 100644 index 00000000..e9de3221 --- /dev/null +++ b/java/src/com/android/intentresolver/emptystate/WorkProfileOffEmptyState.java @@ -0,0 +1,57 @@ +/* + * Copyright (C) 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.intentresolver.emptystate; + +import android.app.admin.DevicePolicyEventLogger; +import android.stats.devicepolicy.nano.DevicePolicyEnums; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +public class WorkProfileOffEmptyState implements EmptyState { + + private final String mTitle; + private final ClickListener mOnClick; + private final String mMetricsCategory; + + public WorkProfileOffEmptyState(String title, @NonNull ClickListener onClick, + @NonNull String metricsCategory) { + mTitle = title; + mOnClick = onClick; + mMetricsCategory = metricsCategory; + } + + @Nullable + @Override + public String getTitle() { + return mTitle; + } + + @Nullable + @Override + public ClickListener getButtonClickListener() { + return mOnClick; + } + + @Override + public void onEmptyStateShown() { + DevicePolicyEventLogger + .createEvent(DevicePolicyEnums.RESOLVER_EMPTY_STATE_WORK_APPS_DISABLED) + .setStrings(mMetricsCategory) + .write(); + } +} diff --git a/java/src/com/android/intentresolver/emptystate/WorkProfilePausedEmptyStateProvider.java b/java/src/com/android/intentresolver/emptystate/WorkProfilePausedEmptyStateProvider.java index cef88ce3..f78d1ca2 100644 --- a/java/src/com/android/intentresolver/emptystate/WorkProfilePausedEmptyStateProvider.java +++ b/java/src/com/android/intentresolver/emptystate/WorkProfilePausedEmptyStateProvider.java @@ -20,11 +20,9 @@ import static android.app.admin.DevicePolicyResources.Strings.Core.RESOLVER_WORK import static java.util.Objects.requireNonNull; -import android.app.admin.DevicePolicyEventLogger; import android.app.admin.DevicePolicyManager; import android.content.Context; import android.os.UserHandle; -import android.stats.devicepolicy.nano.DevicePolicyEnums; import androidx.annotation.NonNull; import androidx.annotation.Nullable; @@ -93,37 +91,4 @@ public class WorkProfilePausedEmptyStateProvider implements EmptyStateProvider { }, mMetricsCategory); } - public static class WorkProfileOffEmptyState implements EmptyState { - - private final String mTitle; - private final ClickListener mOnClick; - private final String mMetricsCategory; - - public WorkProfileOffEmptyState(String title, @NonNull ClickListener onClick, - @NonNull String metricsCategory) { - mTitle = title; - mOnClick = onClick; - mMetricsCategory = metricsCategory; - } - - @Nullable - @Override - public String getTitle() { - return mTitle; - } - - @Nullable - @Override - public ClickListener getButtonClickListener() { - return mOnClick; - } - - @Override - public void onEmptyStateShown() { - DevicePolicyEventLogger - .createEvent(DevicePolicyEnums.RESOLVER_EMPTY_STATE_WORK_APPS_DISABLED) - .setStrings(mMetricsCategory) - .write(); - } - } } -- cgit v1.2.3-59-g8ed1b From 5b6870398d1c573a946736f3fca8273678de46f2 Mon Sep 17 00:00:00 2001 From: Mark Renouf Date: Thu, 2 May 2024 16:06:13 -0400 Subject: Clean up onApplyWindowInsets and apply in all cases Profile pager empty state inset padding was only applied when 'hasWorkProfile' was true. This seems to have been an alias for "are tabs shown", however this is no longer correct. Instead the "empty content" padding should include system bar insets unconditionally. For a test case, enable 3-button nav, add a private profile and test sharing content which has no matching apps (ShareTest with no text). The empty state view is shown partially beneath the nav bar. Bug: 338447666 Test: manually; see change description Flag: com.android.intentresolver.fix_empty_state_padding Change-Id: I8a65214095de3517330d75e20da9bf0d35a82bac --- aconfig/FeatureFlags.aconfig | 6 +++++ .../android/intentresolver/ChooserActivity.java | 28 +++++++++------------- 2 files changed, 17 insertions(+), 17 deletions(-) (limited to 'java/src') diff --git a/aconfig/FeatureFlags.aconfig b/aconfig/FeatureFlags.aconfig index 4d787ea2..b7d9ea0d 100644 --- a/aconfig/FeatureFlags.aconfig +++ b/aconfig/FeatureFlags.aconfig @@ -52,3 +52,9 @@ flag { purpose: PURPOSE_BUGFIX } } +flag { + name: "fix_empty_state_padding" + namespace: "intentresolver" + description: "Always apply systemBar window insets regardless of profiles present" + bug: "338447666" +} diff --git a/java/src/com/android/intentresolver/ChooserActivity.java b/java/src/com/android/intentresolver/ChooserActivity.java index ccf2a3dc..1922c05c 100644 --- a/java/src/com/android/intentresolver/ChooserActivity.java +++ b/java/src/com/android/intentresolver/ChooserActivity.java @@ -1170,19 +1170,6 @@ public class ChooserActivity extends Hilt_ChooserActivity implements safelyStartActivityAsUser(cti, user, null); } - protected WindowInsets super_onApplyWindowInsets(View v, WindowInsets insets) { - mSystemWindowInsets = insets.getSystemWindowInsets(); - - mResolverDrawerLayout.setPadding(mSystemWindowInsets.left, mSystemWindowInsets.top, - mSystemWindowInsets.right, 0); - - // Need extra padding so the list can fully scroll up - // To accommodate for window insets - applyFooterView(mSystemWindowInsets.bottom); - - return insets.consumeSystemWindowInsets(); - } - @Override // ResolverListCommunicator public final void onHandlePackagesChanged(ResolverListAdapter listAdapter) { if (!mChooserMultiProfilePagerAdapter.onHandlePackagesChanged( @@ -2649,16 +2636,23 @@ public class ChooserActivity extends Hilt_ChooserActivity implements } protected WindowInsets onApplyWindowInsets(View v, WindowInsets insets) { - if (mProfiles.getWorkProfilePresent()) { + mSystemWindowInsets = insets.getInsets(WindowInsets.Type.systemBars()); + if (mFeatureFlags.fixEmptyStatePadding() || mProfiles.getWorkProfilePresent()) { mChooserMultiProfilePagerAdapter - .setEmptyStateBottomOffset(insets.getSystemWindowInsetBottom()); + .setEmptyStateBottomOffset(mSystemWindowInsets.bottom); } - WindowInsets result = super_onApplyWindowInsets(v, insets); + mResolverDrawerLayout.setPadding(mSystemWindowInsets.left, mSystemWindowInsets.top, + mSystemWindowInsets.right, 0); + + // Need extra padding so the list can fully scroll up + // To accommodate for window insets + applyFooterView(mSystemWindowInsets.bottom); + if (mResolverDrawerLayout != null) { mResolverDrawerLayout.requestLayout(); } - return result; + return WindowInsets.CONSUMED; } private void setHorizontalScrollingEnabled(boolean enabled) { -- cgit v1.2.3-59-g8ed1b From b772a24d9e639b482bec668e41ee5e69baa28d9a Mon Sep 17 00:00:00 2001 From: Mark Renouf Date: Tue, 9 Apr 2024 09:39:18 -0400 Subject: Private profile share policy delegates to parent profile This change enables the Private tab sharing to respect cross profile share policy and present an appropriate message when sharing is not available. When considering private profile as a source or target, the parent profile is substituded when checking whether any intents can be forwarded. Specialized subclasses of DevicePolicyBlockerEmptyState are eliminated in favor of creating instances as needed and returned by NoCrossProfileEmptyStateProvider. They are now initialized directly with the required content instead of resource Ids. Resource are moved to DevicePolicyResources where they are shared with ResolverActivity. When sharing content to Private from Work when blocked by policy the message will now correctly refer to 'Private apps'. Test: atest NoAppsAvailableEmptyStateProviderTest Test: manual, install TestDPC, setup private space; test verious permutations Flag: EXEMPT bugfix Bug: 324428064 Bug: 300157408 Change-Id: Ieb8e725191691ea92f2994a693086c2029452365 --- java/res/values/strings.xml | 6 + .../android/intentresolver/ChooserActivity.java | 45 +---- .../com/android/intentresolver/ProfileHelper.kt | 8 +- .../android/intentresolver/ResolverActivity.java | 41 +--- .../data/repository/DevicePolicyResources.kt | 22 ++- .../emptystate/CompositeEmptyStateProvider.java | 46 ----- .../emptystate/CompositeEmptyStateProvider.kt | 32 +++ .../intentresolver/emptystate/DefaultEmptyState.kt | 20 ++ .../emptystate/DevicePolicyBlockerEmptyState.java | 48 ++--- .../NoAppsAvailableEmptyStateProvider.java | 9 - .../NoCrossProfileEmptyStateProvider.java | 91 ++++++--- .../NoCrossProfileEmptyStateProviderTest.kt | 218 ++++++++++++++------- 12 files changed, 308 insertions(+), 278 deletions(-) delete mode 100644 java/src/com/android/intentresolver/emptystate/CompositeEmptyStateProvider.java create mode 100644 java/src/com/android/intentresolver/emptystate/CompositeEmptyStateProvider.kt create mode 100644 java/src/com/android/intentresolver/emptystate/DefaultEmptyState.kt (limited to 'java/src') diff --git a/java/res/values/strings.xml b/java/res/values/strings.xml index 17a514d7..32c61327 100644 --- a/java/res/values/strings.xml +++ b/java/res/values/strings.xml @@ -284,6 +284,12 @@ This content can\u2019t be opened with personal apps + + This content can\u2019t be shared with private apps + + + This content can\u2019t be opened with private apps + Work apps are paused diff --git a/java/src/com/android/intentresolver/ChooserActivity.java b/java/src/com/android/intentresolver/ChooserActivity.java index ccf2a3dc..cce614f4 100644 --- a/java/src/com/android/intentresolver/ChooserActivity.java +++ b/java/src/com/android/intentresolver/ChooserActivity.java @@ -17,14 +17,7 @@ package com.android.intentresolver; import static android.app.VoiceInteractor.PickOptionRequest.Option; -import static android.app.admin.DevicePolicyResources.Strings.Core.RESOLVER_CANT_ACCESS_PERSONAL; -import static android.app.admin.DevicePolicyResources.Strings.Core.RESOLVER_CANT_ACCESS_WORK; -import static android.app.admin.DevicePolicyResources.Strings.Core.RESOLVER_CANT_SHARE_WITH_PERSONAL; -import static android.app.admin.DevicePolicyResources.Strings.Core.RESOLVER_CANT_SHARE_WITH_WORK; -import static android.app.admin.DevicePolicyResources.Strings.Core.RESOLVER_CROSS_PROFILE_BLOCKED_TITLE; import static android.content.Intent.FLAG_ACTIVITY_NEW_TASK; -import static android.stats.devicepolicy.nano.DevicePolicyEnums.RESOLVER_EMPTY_STATE_NO_SHARING_TO_PERSONAL; -import static android.stats.devicepolicy.nano.DevicePolicyEnums.RESOLVER_EMPTY_STATE_NO_SHARING_TO_WORK; import static android.view.WindowManager.LayoutParams.SYSTEM_FLAG_HIDE_NON_SYSTEM_OVERLAY_WINDOWS; import static androidx.lifecycle.LifecycleKt.getCoroutineScope; @@ -111,8 +104,6 @@ import com.android.intentresolver.data.repository.DevicePolicyResources; import com.android.intentresolver.domain.interactor.UserInteractor; import com.android.intentresolver.emptystate.CompositeEmptyStateProvider; import com.android.intentresolver.emptystate.CrossProfileIntentsChecker; -import com.android.intentresolver.emptystate.DevicePolicyBlockerEmptyState; -import com.android.intentresolver.emptystate.EmptyState; import com.android.intentresolver.emptystate.EmptyStateProvider; import com.android.intentresolver.emptystate.NoAppsAvailableEmptyStateProvider; import com.android.intentresolver.emptystate.NoCrossProfileEmptyStateProvider; @@ -213,7 +204,7 @@ public class ChooserActivity extends Hilt_ChooserActivity implements private static final String TAB_TAG_WORK = "work"; private static final String LAST_SHOWN_TAB_KEY = "last_shown_tab_key"; - protected static final String METRICS_CATEGORY_CHOOSER = "intent_chooser"; + public static final String METRICS_CATEGORY_CHOOSER = "intent_chooser"; private int mLayoutId; private UserHandle mHeaderCreatorUser; @@ -1463,39 +1454,11 @@ public class ChooserActivity extends Hilt_ChooserActivity implements } protected EmptyStateProvider createBlockerEmptyStateProvider() { - final boolean isSendAction = mRequest.isSendActionTarget(); - - final EmptyState noWorkToPersonalEmptyState = - new DevicePolicyBlockerEmptyState( - /* context= */ this, - /* devicePolicyStringTitleId= */ RESOLVER_CROSS_PROFILE_BLOCKED_TITLE, - /* defaultTitleResource= */ R.string.resolver_cross_profile_blocked, - /* devicePolicyStringSubtitleId= */ - isSendAction ? RESOLVER_CANT_SHARE_WITH_PERSONAL : RESOLVER_CANT_ACCESS_PERSONAL, - /* defaultSubtitleResource= */ - isSendAction ? R.string.resolver_cant_share_with_personal_apps_explanation - : R.string.resolver_cant_access_personal_apps_explanation, - /* devicePolicyEventId= */ RESOLVER_EMPTY_STATE_NO_SHARING_TO_PERSONAL, - /* devicePolicyEventCategory= */ ResolverActivity.METRICS_CATEGORY_CHOOSER); - - final EmptyState noPersonalToWorkEmptyState = - new DevicePolicyBlockerEmptyState( - /* context= */ this, - /* devicePolicyStringTitleId= */ RESOLVER_CROSS_PROFILE_BLOCKED_TITLE, - /* defaultTitleResource= */ R.string.resolver_cross_profile_blocked, - /* devicePolicyStringSubtitleId= */ - isSendAction ? RESOLVER_CANT_SHARE_WITH_WORK : RESOLVER_CANT_ACCESS_WORK, - /* defaultSubtitleResource= */ - isSendAction ? R.string.resolver_cant_share_with_work_apps_explanation - : R.string.resolver_cant_access_work_apps_explanation, - /* devicePolicyEventId= */ RESOLVER_EMPTY_STATE_NO_SHARING_TO_WORK, - /* devicePolicyEventCategory= */ ResolverActivity.METRICS_CATEGORY_CHOOSER); - return new NoCrossProfileEmptyStateProvider( mProfiles, - noWorkToPersonalEmptyState, - noPersonalToWorkEmptyState, - createCrossProfileIntentsChecker()); + mDevicePolicyResources, + createCrossProfileIntentsChecker(), + mRequest.isSendActionTarget()); } private int findSelectedProfile() { diff --git a/java/src/com/android/intentresolver/ProfileHelper.kt b/java/src/com/android/intentresolver/ProfileHelper.kt index e1d912c3..53a873a3 100644 --- a/java/src/com/android/intentresolver/ProfileHelper.kt +++ b/java/src/com/android/intentresolver/ProfileHelper.kt @@ -80,12 +80,12 @@ constructor( launchedByUser.handle } - fun findProfileType(handle: UserHandle): Profile.Type? { - val matched = - profiles.firstOrNull { it.primary.handle == handle || it.clone?.handle == handle } - return matched?.type + fun findProfile(handle: UserHandle): Profile? { + return profiles.firstOrNull { it.primary.handle == handle || it.clone?.handle == handle } } + fun findProfileType(handle: UserHandle): Profile.Type? = findProfile(handle)?.type + // Name retained for ease of review, to be renamed later fun getQueryIntentsHandle(handle: UserHandle): UserHandle? { return if (isLaunchedAsCloneProfile && handle == personalHandle) { diff --git a/java/src/com/android/intentresolver/ResolverActivity.java b/java/src/com/android/intentresolver/ResolverActivity.java index 1b08d957..e79cb2d1 100644 --- a/java/src/com/android/intentresolver/ResolverActivity.java +++ b/java/src/com/android/intentresolver/ResolverActivity.java @@ -16,12 +16,7 @@ package com.android.intentresolver; -import static android.app.admin.DevicePolicyResources.Strings.Core.RESOLVER_CANT_ACCESS_PERSONAL; -import static android.app.admin.DevicePolicyResources.Strings.Core.RESOLVER_CANT_ACCESS_WORK; -import static android.app.admin.DevicePolicyResources.Strings.Core.RESOLVER_CROSS_PROFILE_BLOCKED_TITLE; import static android.content.Intent.FLAG_ACTIVITY_NEW_TASK; -import static android.stats.devicepolicy.nano.DevicePolicyEnums.RESOLVER_EMPTY_STATE_NO_SHARING_TO_PERSONAL; -import static android.stats.devicepolicy.nano.DevicePolicyEnums.RESOLVER_EMPTY_STATE_NO_SHARING_TO_WORK; import static android.view.WindowManager.LayoutParams.SYSTEM_FLAG_HIDE_NON_SYSTEM_OVERLAY_WINDOWS; import static androidx.lifecycle.LifecycleKt.getCoroutineScope; @@ -94,8 +89,6 @@ import com.android.intentresolver.data.repository.DevicePolicyResources; import com.android.intentresolver.domain.interactor.UserInteractor; import com.android.intentresolver.emptystate.CompositeEmptyStateProvider; import com.android.intentresolver.emptystate.CrossProfileIntentsChecker; -import com.android.intentresolver.emptystate.DevicePolicyBlockerEmptyState; -import com.android.intentresolver.emptystate.EmptyState; import com.android.intentresolver.emptystate.EmptyStateProvider; import com.android.intentresolver.emptystate.NoAppsAvailableEmptyStateProvider; import com.android.intentresolver.emptystate.NoCrossProfileEmptyStateProvider; @@ -184,7 +177,6 @@ public class ResolverActivity extends Hilt_ResolverActivity implements private Space mFooterSpacer = null; protected static final String METRICS_CATEGORY_RESOLVER = "intent_resolver"; - protected static final String METRICS_CATEGORY_CHOOSER = "intent_chooser"; /** Tracks if we should ignore future broadcasts telling us the work profile is enabled */ private final boolean mWorkProfileHasBeenEnabled = false; @@ -449,42 +441,17 @@ public class ResolverActivity extends Hilt_ResolverActivity implements } protected EmptyStateProvider createBlockerEmptyStateProvider() { - final boolean shouldShowNoCrossProfileIntentsEmptyState = getUser().equals(getIntentUser()); + boolean shouldShowNoCrossProfileIntentsEmptyState = getUser().equals(getIntentUser()); if (!shouldShowNoCrossProfileIntentsEmptyState) { // Implementation that doesn't show any blockers return new EmptyStateProvider() {}; } - - final EmptyState noWorkToPersonalEmptyState = - new DevicePolicyBlockerEmptyState( - /* context= */ this, - /* devicePolicyStringTitleId= */ RESOLVER_CROSS_PROFILE_BLOCKED_TITLE, - /* defaultTitleResource= */ R.string.resolver_cross_profile_blocked, - /* devicePolicyStringSubtitleId= */ RESOLVER_CANT_ACCESS_PERSONAL, - /* defaultSubtitleResource= */ - R.string.resolver_cant_access_personal_apps_explanation, - /* devicePolicyEventId= */ RESOLVER_EMPTY_STATE_NO_SHARING_TO_PERSONAL, - /* devicePolicyEventCategory= */ - ResolverActivity.METRICS_CATEGORY_RESOLVER); - - final EmptyState noPersonalToWorkEmptyState = - new DevicePolicyBlockerEmptyState( - /* context= */ this, - /* devicePolicyStringTitleId= */ RESOLVER_CROSS_PROFILE_BLOCKED_TITLE, - /* defaultTitleResource= */ R.string.resolver_cross_profile_blocked, - /* devicePolicyStringSubtitleId= */ RESOLVER_CANT_ACCESS_WORK, - /* defaultSubtitleResource= */ - R.string.resolver_cant_access_work_apps_explanation, - /* devicePolicyEventId= */ RESOLVER_EMPTY_STATE_NO_SHARING_TO_WORK, - /* devicePolicyEventCategory= */ - ResolverActivity.METRICS_CATEGORY_RESOLVER); - return new NoCrossProfileEmptyStateProvider( mProfiles, - noWorkToPersonalEmptyState, - noPersonalToWorkEmptyState, - createCrossProfileIntentsChecker()); + mDevicePolicyResources, + createCrossProfileIntentsChecker(), + /* isShare= */ false); } /** diff --git a/java/src/com/android/intentresolver/data/repository/DevicePolicyResources.kt b/java/src/com/android/intentresolver/data/repository/DevicePolicyResources.kt index 75faa068..eb35a358 100644 --- a/java/src/com/android/intentresolver/data/repository/DevicePolicyResources.kt +++ b/java/src/com/android/intentresolver/data/repository/DevicePolicyResources.kt @@ -27,13 +27,15 @@ import android.app.admin.DevicePolicyResources.Strings.Core.RESOLVER_WORK_PROFIL import android.app.admin.DevicePolicyResources.Strings.Core.RESOLVER_WORK_TAB import android.app.admin.DevicePolicyResources.Strings.Core.RESOLVER_WORK_TAB_ACCESSIBILITY import android.content.res.Resources +import androidx.annotation.OpenForTesting import com.android.intentresolver.R import com.android.intentresolver.inject.ApplicationOwned import javax.inject.Inject import javax.inject.Singleton +@OpenForTesting @Singleton -class DevicePolicyResources +open class DevicePolicyResources @Inject constructor( @ApplicationOwned private val resources: Resources, @@ -102,7 +104,7 @@ constructor( ) } - val crossProfileBlocked by lazy { + open val crossProfileBlocked by lazy { requireNotNull( policyResources.getString(RESOLVER_CROSS_PROFILE_BLOCKED_TITLE) { resources.getString(R.string.resolver_cross_profile_blocked) @@ -110,22 +112,30 @@ constructor( ) } - fun toPersonalBlockedByPolicyMessage(sendAction: Boolean): String { - return if (sendAction) { + open fun toPersonalBlockedByPolicyMessage(share: Boolean): String { + return if (share) { resources.getString(R.string.resolver_cant_share_with_personal_apps_explanation) } else { resources.getString(R.string.resolver_cant_access_personal_apps_explanation) } } - fun toWorkBlockedByPolicyMessage(sendAction: Boolean): String { - return if (sendAction) { + open fun toWorkBlockedByPolicyMessage(share: Boolean): String { + return if (share) { resources.getString(R.string.resolver_cant_share_with_work_apps_explanation) } else { resources.getString(R.string.resolver_cant_access_work_apps_explanation) } } + open fun toPrivateBlockedByPolicyMessage(share: Boolean): String { + return if (share) { + resources.getString(R.string.resolver_cant_share_with_private_apps_explanation) + } else { + resources.getString(R.string.resolver_cant_access_private_apps_explanation) + } + } + fun getWorkProfileNotSupportedMessage(launcherName: String): String { return requireNotNull( policyResources.getString( diff --git a/java/src/com/android/intentresolver/emptystate/CompositeEmptyStateProvider.java b/java/src/com/android/intentresolver/emptystate/CompositeEmptyStateProvider.java deleted file mode 100644 index 41422b66..00000000 --- a/java/src/com/android/intentresolver/emptystate/CompositeEmptyStateProvider.java +++ /dev/null @@ -1,46 +0,0 @@ -/* - * Copyright (C) 2023 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package com.android.intentresolver.emptystate; - -import android.annotation.Nullable; - -import com.android.intentresolver.ResolverListAdapter; - -/** - * Empty state provider that combines multiple providers. Providers earlier in the list have - * priority, that is if there is a provider that returns non-null empty state then all further - * providers will be ignored. - */ -public class CompositeEmptyStateProvider implements EmptyStateProvider { - - private final EmptyStateProvider[] mProviders; - - public CompositeEmptyStateProvider(EmptyStateProvider... providers) { - mProviders = providers; - } - - @Nullable - @Override - public EmptyState getEmptyState(ResolverListAdapter resolverListAdapter) { - for (EmptyStateProvider provider : mProviders) { - EmptyState emptyState = provider.getEmptyState(resolverListAdapter); - if (emptyState != null) { - return emptyState; - } - } - return null; - } -} diff --git a/java/src/com/android/intentresolver/emptystate/CompositeEmptyStateProvider.kt b/java/src/com/android/intentresolver/emptystate/CompositeEmptyStateProvider.kt new file mode 100644 index 00000000..05062a4b --- /dev/null +++ b/java/src/com/android/intentresolver/emptystate/CompositeEmptyStateProvider.kt @@ -0,0 +1,32 @@ +/* + * Copyright (C) 2023 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.android.intentresolver.emptystate + +import com.android.intentresolver.ResolverListAdapter + +/** + * Empty state provider that combines multiple providers. Providers earlier in the list have + * priority, that is if there is a provider that returns non-null empty state then all further + * providers will be ignored. + */ +class CompositeEmptyStateProvider( + private vararg val providers: EmptyStateProvider, +) : EmptyStateProvider { + + override fun getEmptyState(resolverListAdapter: ResolverListAdapter): EmptyState? { + return providers.firstNotNullOfOrNull { it.getEmptyState(resolverListAdapter) } + } +} diff --git a/java/src/com/android/intentresolver/emptystate/DefaultEmptyState.kt b/java/src/com/android/intentresolver/emptystate/DefaultEmptyState.kt new file mode 100644 index 00000000..ea1a03cc --- /dev/null +++ b/java/src/com/android/intentresolver/emptystate/DefaultEmptyState.kt @@ -0,0 +1,20 @@ +/* + * Copyright (C) 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.android.intentresolver.emptystate + +class DefaultEmptyState : EmptyState { + override fun useDefaultEmptyView() = true +} diff --git a/java/src/com/android/intentresolver/emptystate/DevicePolicyBlockerEmptyState.java b/java/src/com/android/intentresolver/emptystate/DevicePolicyBlockerEmptyState.java index b627636e..1cbc6175 100644 --- a/java/src/com/android/intentresolver/emptystate/DevicePolicyBlockerEmptyState.java +++ b/java/src/com/android/intentresolver/emptystate/DevicePolicyBlockerEmptyState.java @@ -17,40 +17,26 @@ package com.android.intentresolver.emptystate; import android.app.admin.DevicePolicyEventLogger; -import android.app.admin.DevicePolicyManager; -import android.content.Context; -import androidx.annotation.NonNull; import androidx.annotation.Nullable; -import androidx.annotation.StringRes; /** * Empty state that gets strings from the device policy manager and tracks events into * event logger of the device policy events. */ public class DevicePolicyBlockerEmptyState implements EmptyState { - - @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 String mTitle; + private final String mSubtitle; 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; + public DevicePolicyBlockerEmptyState( + String title, + String subtitle, + int devicePolicyEventId, + String devicePolicyEventCategory) { + mTitle = title; + mSubtitle = subtitle; mEventId = devicePolicyEventId; mEventCategory = devicePolicyEventCategory; } @@ -58,24 +44,22 @@ public class DevicePolicyBlockerEmptyState implements EmptyState { @Nullable @Override public String getTitle() { - return mContext.getSystemService(DevicePolicyManager.class).getResources().getString( - mDevicePolicyStringTitleId, - () -> mContext.getString(mDefaultTitleResource)); + return mTitle; } @Nullable @Override public String getSubtitle() { - return mContext.getSystemService(DevicePolicyManager.class).getResources().getString( - mDevicePolicyStringSubtitleId, - () -> mContext.getString(mDefaultSubtitleResource)); + return mSubtitle; } @Override public void onEmptyStateShown() { - DevicePolicyEventLogger.createEvent(mEventId) - .setStrings(mEventCategory) - .write(); + if (mEventId != -1) { + DevicePolicyEventLogger.createEvent(mEventId) + .setStrings(mEventCategory) + .write(); + } } @Override diff --git a/java/src/com/android/intentresolver/emptystate/NoAppsAvailableEmptyStateProvider.java b/java/src/com/android/intentresolver/emptystate/NoAppsAvailableEmptyStateProvider.java index cd1448e4..b3d3e343 100644 --- a/java/src/com/android/intentresolver/emptystate/NoAppsAvailableEmptyStateProvider.java +++ b/java/src/com/android/intentresolver/emptystate/NoAppsAvailableEmptyStateProvider.java @@ -70,13 +70,4 @@ public class NoAppsAvailableEmptyStateProvider implements EmptyStateProvider { ); } } - - - public static class DefaultEmptyState implements EmptyState { - @Override - public boolean useDefaultEmptyView() { - return true; - } - } - } diff --git a/java/src/com/android/intentresolver/emptystate/NoCrossProfileEmptyStateProvider.java b/java/src/com/android/intentresolver/emptystate/NoCrossProfileEmptyStateProvider.java index fa33928b..0cf2ea45 100644 --- a/java/src/com/android/intentresolver/emptystate/NoCrossProfileEmptyStateProvider.java +++ b/java/src/com/android/intentresolver/emptystate/NoCrossProfileEmptyStateProvider.java @@ -16,15 +16,21 @@ package com.android.intentresolver.emptystate; +import static android.stats.devicepolicy.DevicePolicyEnums.RESOLVER_EMPTY_STATE_NO_SHARING_TO_PERSONAL; +import static android.stats.devicepolicy.DevicePolicyEnums.RESOLVER_EMPTY_STATE_NO_SHARING_TO_WORK; + +import static com.android.intentresolver.ChooserActivity.METRICS_CATEGORY_CHOOSER; + +import static java.util.Objects.requireNonNull; + import android.content.Intent; -import android.os.UserHandle; import androidx.annotation.Nullable; import com.android.intentresolver.ProfileHelper; import com.android.intentresolver.ResolverListAdapter; +import com.android.intentresolver.data.repository.DevicePolicyResources; import com.android.intentresolver.shared.model.Profile; -import com.android.intentresolver.shared.model.User; import java.util.List; @@ -35,55 +41,78 @@ import java.util.List; public class NoCrossProfileEmptyStateProvider implements EmptyStateProvider { private final ProfileHelper mProfileHelper; - private final EmptyState mNoWorkToPersonalEmptyState; - private final EmptyState mNoPersonalToWorkEmptyState; + private final DevicePolicyResources mDevicePolicyResources; + private final boolean mIsShare; private final CrossProfileIntentsChecker mCrossProfileIntentsChecker; public NoCrossProfileEmptyStateProvider( ProfileHelper profileHelper, - EmptyState noWorkToPersonalEmptyState, - EmptyState noPersonalToWorkEmptyState, - CrossProfileIntentsChecker crossProfileIntentsChecker) { + DevicePolicyResources devicePolicyResources, + CrossProfileIntentsChecker crossProfileIntentsChecker, + boolean isShare) { mProfileHelper = profileHelper; - mNoWorkToPersonalEmptyState = noWorkToPersonalEmptyState; - mNoPersonalToWorkEmptyState = noPersonalToWorkEmptyState; + mDevicePolicyResources = devicePolicyResources; + mIsShare = isShare; mCrossProfileIntentsChecker = crossProfileIntentsChecker; } - private boolean anyCrossProfileAllowedIntents(ResolverListAdapter selected, UserHandle source) { - List intents = selected.getIntents(); - UserHandle target = selected.getUserHandle(); + private boolean hasCrossProfileIntents(List intents, Profile source, Profile target) { + if (source.getPrimary().getHandle().equals(target.getPrimary().getHandle())) { + return true; + } + // Note: Use of getPrimary() here also handles delegation of CLONE profile to parent. return mCrossProfileIntentsChecker.hasCrossProfileIntents(intents, - source.getIdentifier(), target.getIdentifier()); + source.getPrimary().getId(), target.getPrimary().getId()); } @Nullable @Override 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; + Profile launchedBy = mProfileHelper.getLaunchedAsProfile(); + Profile tabOwner = requireNonNull(mProfileHelper.findProfile(adapter.getUserHandle())); + + // When sharing into or out of Private profile, perform the check using the parent profile + // instead. (Hard-coded application of CROSS_PROFILE_CONTENT_SHARING_DELEGATE_FROM_PARENT) + + Profile effectiveSource = launchedBy; + Profile effectiveTarget = tabOwner; + + // Assumption baked into design: "Personal" profile is the parent of all other profiles. + if (launchedBy.getType() == Profile.Type.PRIVATE) { + effectiveSource = mProfileHelper.getPersonalProfile(); + } + + if (tabOwner.getType() == Profile.Type.PRIVATE) { + effectiveTarget = mProfileHelper.getPersonalProfile(); } - // Allow access to the tab when 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, - /* source = */ launchedAs.getHandle())) { + // Allow access to the tab when there is at least one target permitted to cross profiles. + if (hasCrossProfileIntents(adapter.getIntents(), effectiveSource, effectiveTarget)) { return null; } - switch (launchedAsProfile.getType()) { - case WORK: return mNoWorkToPersonalEmptyState; - case PERSONAL: return mNoPersonalToWorkEmptyState; + switch (tabOwner.getType()) { + case PERSONAL: + return new DevicePolicyBlockerEmptyState( + mDevicePolicyResources.getCrossProfileBlocked(), + mDevicePolicyResources.toPersonalBlockedByPolicyMessage(mIsShare), + RESOLVER_EMPTY_STATE_NO_SHARING_TO_PERSONAL, + METRICS_CATEGORY_CHOOSER); + + case WORK: + return new DevicePolicyBlockerEmptyState( + mDevicePolicyResources.getCrossProfileBlocked(), + mDevicePolicyResources.toWorkBlockedByPolicyMessage(mIsShare), + RESOLVER_EMPTY_STATE_NO_SHARING_TO_WORK, + METRICS_CATEGORY_CHOOSER); + + case PRIVATE: + return new DevicePolicyBlockerEmptyState( + mDevicePolicyResources.getCrossProfileBlocked(), + mDevicePolicyResources.toPrivateBlockedByPolicyMessage(mIsShare), + /* Suppress log event. TODO: Define a new metrics event for this? */ -1, + METRICS_CATEGORY_CHOOSER); } return null; } - } diff --git a/tests/unit/src/com/android/intentresolver/emptystate/NoCrossProfileEmptyStateProviderTest.kt b/tests/unit/src/com/android/intentresolver/emptystate/NoCrossProfileEmptyStateProviderTest.kt index fe3e844b..135ac064 100644 --- a/tests/unit/src/com/android/intentresolver/emptystate/NoCrossProfileEmptyStateProviderTest.kt +++ b/tests/unit/src/com/android/intentresolver/emptystate/NoCrossProfileEmptyStateProviderTest.kt @@ -20,34 +20,35 @@ import android.content.Intent import com.android.intentresolver.ProfileHelper import com.android.intentresolver.ResolverListAdapter import com.android.intentresolver.annotation.JavaInterop +import com.android.intentresolver.data.repository.DevicePolicyResources import com.android.intentresolver.data.repository.FakeUserRepository import com.android.intentresolver.domain.interactor.UserInteractor import com.android.intentresolver.inject.FakeIntentResolverFlags import com.android.intentresolver.shared.model.User import com.google.common.truth.Truth.assertThat +import com.google.common.truth.Truth.assertWithMessage import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import org.junit.Test -import org.mockito.ArgumentMatchers.anyInt -import org.mockito.ArgumentMatchers.anyList +import org.mockito.Mockito.never +import org.mockito.kotlin.any import org.mockito.kotlin.argumentCaptor import org.mockito.kotlin.doReturn import org.mockito.kotlin.mock -import org.mockito.kotlin.never import org.mockito.kotlin.same import org.mockito.kotlin.times import org.mockito.kotlin.verify +import org.mockito.verification.VerificationMode @OptIn(JavaInterop::class) class NoCrossProfileEmptyStateProviderTest { private val personalUser = User(0, User.Role.PERSONAL) private val workUser = User(10, User.Role.WORK) + private val privateUser = User(11, User.Role.PRIVATE) private val flags = FakeIntentResolverFlags() - private val personalBlocker = mock() - private val workBlocker = mock() - private val userRepository = FakeUserRepository(listOf(personalUser, workUser)) + private val userRepository = FakeUserRepository(listOf(personalUser, workUser, privateUser)) private val personalIntents = listOf(Intent("PERSONAL")) private val personalListAdapter = @@ -61,96 +62,169 @@ class NoCrossProfileEmptyStateProviderTest { on { userHandle } doReturn workUser.handle on { intents } doReturn workIntents } + private val privateIntents = listOf(Intent("PRIVATE")) + private val privateListAdapter = + mock { + on { userHandle } doReturn privateUser.handle + on { intents } doReturn privateIntents + } + + private val devicePolicyResources = + mock { + on { crossProfileBlocked } doReturn "Cross profile blocked" + on { toPersonalBlockedByPolicyMessage(any()) } doReturn "Blocked to Personal" + on { toWorkBlockedByPolicyMessage(any()) } doReturn "Blocked to Work" + on { toPrivateBlockedByPolicyMessage(any()) } doReturn "Blocked to Private" + } - // Pretend that no intent can ever be forwarded - val crossProfileIntentsChecker = + // If asked, no intent can ever be forwarded between any pair of users. + private val crossProfileIntentsChecker = mock { on { hasCrossProfileIntents( - /* intents = */ anyList(), - /* source = */ anyInt(), - /* target = */ anyInt() + /* intents = */ any(), + /* source = */ any(), + /* target = */ any() ) - } doReturn false + } doReturn false /* Never allow */ } - private val sourceUserId = argumentCaptor() - private val targetUserId = argumentCaptor() @Test - fun testPersonalToWork() { - val userInteractor = UserInteractor(userRepository, launchedAs = personalUser.handle) - - val profileHelper = - ProfileHelper( - userInteractor, - CoroutineScope(Dispatchers.Unconfined), - Dispatchers.Unconfined, - flags - ) + fun verifyTestSetup() { + assertThat(workListAdapter.userHandle).isEqualTo(workUser.handle) + assertThat(personalListAdapter.userHandle).isEqualTo(personalUser.handle) + assertThat(privateListAdapter.userHandle).isEqualTo(privateUser.handle) + } + + @Test + fun sameProfilePermitted() { + val profileHelper = createProfileHelper(launchedAs = workUser) val provider = NoCrossProfileEmptyStateProvider( - /* profileHelper = */ profileHelper, - /* noWorkToPersonalEmptyState = */ personalBlocker, - /* noPersonalToWorkEmptyState = */ workBlocker, - /* crossProfileIntentsChecker = */ crossProfileIntentsChecker + profileHelper, + devicePolicyResources, + crossProfileIntentsChecker, + /* isShare = */ true ) - // Personal to personal, not blocked - assertThat(provider.getEmptyState(personalListAdapter)).isNull() - // Not called because sourceUser == targetUser - verify(crossProfileIntentsChecker, never()) - .hasCrossProfileIntents(anyList(), anyInt(), anyInt()) - - // Personal to work, blocked - assertThat(provider.getEmptyState(workListAdapter)).isSameInstanceAs(workBlocker) - - verify(crossProfileIntentsChecker, times(1)) - .hasCrossProfileIntents( - same(workIntents), - sourceUserId.capture(), - targetUserId.capture() + // Work to work, not blocked + assertThat(provider.getEmptyState(workListAdapter)).isNull() + + crossProfileIntentsChecker.verifyCalled(never()) + } + + @Test + fun testPersonalToWork() { + val profileHelper = createProfileHelper(launchedAs = personalUser) + + val provider = + NoCrossProfileEmptyStateProvider( + profileHelper, + devicePolicyResources, + crossProfileIntentsChecker, + /* isShare = */ true ) - assertThat(sourceUserId.firstValue).isEqualTo(personalUser.id) - assertThat(targetUserId.firstValue).isEqualTo(workUser.id) + + val result = provider.getEmptyState(workListAdapter) + assertThat(result).isNotNull() + assertThat(result?.title).isEqualTo("Cross profile blocked") + assertThat(result?.subtitle).isEqualTo("Blocked to Work") + + crossProfileIntentsChecker.verifyCalled(times(1), workIntents, personalUser, workUser) } @Test fun testWorkToPersonal() { - val userInteractor = UserInteractor(userRepository, launchedAs = workUser.handle) - - val profileHelper = - ProfileHelper( - userInteractor, - CoroutineScope(Dispatchers.Unconfined), - Dispatchers.Unconfined, - flags + val profileHelper = createProfileHelper(launchedAs = workUser) + + val provider = + NoCrossProfileEmptyStateProvider( + profileHelper, + devicePolicyResources, + crossProfileIntentsChecker, + /* isShare = */ true ) + val result = provider.getEmptyState(personalListAdapter) + assertThat(result).isNotNull() + assertThat(result?.title).isEqualTo("Cross profile blocked") + assertThat(result?.subtitle).isEqualTo("Blocked to Personal") + + crossProfileIntentsChecker.verifyCalled(times(1), personalIntents, workUser, personalUser) + } + + @Test + fun testWorkToPrivate() { + val profileHelper = createProfileHelper(launchedAs = workUser) + val provider = NoCrossProfileEmptyStateProvider( - /* profileHelper = */ profileHelper, - /* noWorkToPersonalEmptyState = */ personalBlocker, - /* noPersonalToWorkEmptyState = */ workBlocker, - /* crossProfileIntentsChecker = */ crossProfileIntentsChecker + profileHelper, + devicePolicyResources, + crossProfileIntentsChecker, + /* isShare = */ true ) - // Work to work, not blocked - assertThat(provider.getEmptyState(workListAdapter)).isNull() - // Not called because sourceUser == targetUser - verify(crossProfileIntentsChecker, never()) - .hasCrossProfileIntents(anyList(), anyInt(), anyInt()) - - // Work to personal, blocked - assertThat(provider.getEmptyState(personalListAdapter)).isSameInstanceAs(personalBlocker) - - verify(crossProfileIntentsChecker, times(1)) - .hasCrossProfileIntents( - same(personalIntents), - sourceUserId.capture(), - targetUserId.capture() + val result = provider.getEmptyState(privateListAdapter) + assertThat(result).isNotNull() + assertThat(result?.title).isEqualTo("Cross profile blocked") + assertThat(result?.subtitle).isEqualTo("Blocked to Private") + + // effective target user is personalUser due to "delegate from parent" + crossProfileIntentsChecker.verifyCalled(times(1), privateIntents, workUser, personalUser) + } + + @Test + fun testPrivateToPersonal() { + val profileHelper = createProfileHelper(launchedAs = privateUser) + + val provider = + NoCrossProfileEmptyStateProvider( + profileHelper, + devicePolicyResources, + crossProfileIntentsChecker, + /* isShare = */ true ) - assertThat(sourceUserId.firstValue).isEqualTo(workUser.id) - assertThat(targetUserId.firstValue).isEqualTo(personalUser.id) + + // Private -> Personal is always allowed: + // Private delegates to the parent profile for policy; so personal->personal is allowed. + assertThat(provider.getEmptyState(personalListAdapter)).isNull() + + crossProfileIntentsChecker.verifyCalled(never()) + } + + private fun createProfileHelper(launchedAs: User): ProfileHelper { + val userInteractor = UserInteractor(userRepository, launchedAs = launchedAs.handle) + + return ProfileHelper( + userInteractor, + CoroutineScope(Dispatchers.Unconfined), + Dispatchers.Unconfined, + flags + ) + } + + private fun CrossProfileIntentsChecker.verifyCalled( + mode: VerificationMode, + list: List? = null, + sourceUser: User? = null, + targetUser: User? = null, + ) { + val sourceUserId = argumentCaptor() + val targetUserId = argumentCaptor() + + verify(this, mode) + .hasCrossProfileIntents(same(list), sourceUserId.capture(), targetUserId.capture()) + sourceUser?.apply { + assertWithMessage("hasCrossProfileIntents: source") + .that(sourceUserId.firstValue) + .isEqualTo(id) + } + targetUser?.apply { + assertWithMessage("hasCrossProfileIntents: target") + .that(targetUserId.firstValue) + .isEqualTo(id) + } } } -- cgit v1.2.3-59-g8ed1b From 729b5dd9fae069c1c34eff2d655620d6b7455c4e Mon Sep 17 00:00:00 2001 From: Andrey Epin Date: Wed, 24 Apr 2024 14:49:33 -0700 Subject: Do not animate target icons and labels after payload selection change. An shared icon cache is added to prevent icons reloading for the new list adapter instance. Bug: 325465291 Test: atest IntentResolver-tests-unit Test: atest IntentResolver-tests-activity Change-Id: Ifba9ec221dd6c52f4aff1d3ff94729b61c24981a --- .../android/intentresolver/ChooserActivity.java | 27 ++++---- .../android/intentresolver/ChooserListAdapter.java | 22 +++++-- .../android/intentresolver/ResolverActivity.java | 2 +- .../intentresolver/ResolverListAdapter.java | 15 +++-- .../intentresolver/chooser/DisplayResolveInfo.java | 1 + .../chooser/MultiDisplayResolveInfo.java | 2 + .../icons/CachingTargetDataLoader.kt | 76 ++++++++++++++++++++++ .../icons/DefaultTargetDataLoader.kt | 5 +- .../com/android/intentresolver/icons/LabelInfo.kt | 2 +- .../intentresolver/icons/TargetDataLoader.kt | 4 +- .../intentresolver/icons/TargetDataLoaderModule.kt | 6 ++ .../intentresolver/ResolverWrapperActivity.java | 5 +- .../intentresolver/ChooserListAdapterTest.kt | 2 +- 13 files changed, 136 insertions(+), 33 deletions(-) create mode 100644 java/src/com/android/intentresolver/icons/CachingTargetDataLoader.kt (limited to 'java/src') diff --git a/java/src/com/android/intentresolver/ChooserActivity.java b/java/src/com/android/intentresolver/ChooserActivity.java index 1922c05c..bd816a83 100644 --- a/java/src/com/android/intentresolver/ChooserActivity.java +++ b/java/src/com/android/intentresolver/ChooserActivity.java @@ -118,6 +118,7 @@ import com.android.intentresolver.emptystate.NoAppsAvailableEmptyStateProvider; import com.android.intentresolver.emptystate.NoCrossProfileEmptyStateProvider; import com.android.intentresolver.emptystate.WorkProfilePausedEmptyStateProvider; import com.android.intentresolver.grid.ChooserGridAdapter; +import com.android.intentresolver.icons.Caching; import com.android.intentresolver.icons.TargetDataLoader; import com.android.intentresolver.inject.Background; import com.android.intentresolver.logging.EventLog; @@ -175,6 +176,7 @@ import java.util.function.Consumer; import java.util.function.Supplier; import javax.inject.Inject; +import javax.inject.Provider; /** * The Chooser Activity handles intent resolution specifically for sharing intents - @@ -265,7 +267,11 @@ public class ChooserActivity extends Hilt_ChooserActivity implements @Inject @AppPredictionAvailable public boolean mAppPredictionAvailable; @Inject @ImageEditor public Optional mImageEditor; @Inject @NearbyShare public Optional mNearbyShare; - @Inject public TargetDataLoader mTargetDataLoader; + protected TargetDataLoader mTargetDataLoader; + @Inject public Provider mTargetDataLoaderProvider; + @Inject + @Caching + public Provider mCachingTargetDataLoaderProvider; @Inject public DevicePolicyResources mDevicePolicyResources; @Inject public ProfilePagerResources mProfilePagerResources; @Inject public PackageManager mPackageManager; @@ -340,6 +346,10 @@ public class ChooserActivity extends Hilt_ChooserActivity implements super.onCreate(savedInstanceState); Log.i(TAG, "onCreate"); + mTargetDataLoader = mChooserServiceFeatureFlags.chooserPayloadToggling() + ? mCachingTargetDataLoaderProvider.get() + : mTargetDataLoaderProvider.get(); + setTheme(R.style.Theme_DeviceDefault_Chooser); // Initializer is invoked when this function returns, via Lifecycle. @@ -776,6 +786,10 @@ public class ChooserActivity extends Hilt_ChooserActivity implements mRequest.getInitialIntents(), mMaxTargetsPerRow); mChooserMultiProfilePagerAdapter.setCurrentPage(currentPage); + for (int i = 0, count = mChooserMultiProfilePagerAdapter.getItemCount(); i < count; i++) { + mChooserMultiProfilePagerAdapter.getPageAdapterForIndex(i) + .getListAdapter().setAnimateItems(false); + } if (mPersonalPackageMonitor != null) { mPersonalPackageMonitor.unregister(); } @@ -1384,17 +1398,6 @@ public class ChooserActivity extends Hilt_ChooserActivity implements return context.getSharedPreferences(PINNED_SHARED_PREFS_NAME, MODE_PRIVATE); } - protected ChooserMultiProfilePagerAdapter createMultiProfilePagerAdapter() { - return createMultiProfilePagerAdapter( - /* context = */ this, - mProfilePagerResources, - mViewModel.getRequest().getValue(), - mProfiles, - mProfileAvailability, - mRequest.getInitialIntents(), - mMaxTargetsPerRow); - } - private ChooserMultiProfilePagerAdapter createMultiProfilePagerAdapter( Context context, ProfilePagerResources profilePagerResources, diff --git a/java/src/com/android/intentresolver/ChooserListAdapter.java b/java/src/com/android/intentresolver/ChooserListAdapter.java index 29b5698b..8b848e55 100644 --- a/java/src/com/android/intentresolver/ChooserListAdapter.java +++ b/java/src/com/android/intentresolver/ChooserListAdapter.java @@ -153,6 +153,8 @@ public class ChooserListAdapter extends ResolverListAdapter { } }; + private boolean mAnimateItems = true; + public ChooserListAdapter( Context context, List payloadIntents, @@ -308,6 +310,10 @@ public class ChooserListAdapter extends ResolverListAdapter { } } + public void setAnimateItems(boolean animateItems) { + mAnimateItems = animateItems; + } + @Override public void handlePackagesChanged() { if (mPackageChangeCallback != null) { @@ -371,18 +377,15 @@ public class ChooserListAdapter extends ResolverListAdapter { final CharSequence displayLabel = Objects.requireNonNullElse(info.getDisplayLabel(), ""); final CharSequence extendedInfo = Objects.requireNonNullElse(info.getExtendedInfo(), ""); holder.bindLabel(displayLabel, extendedInfo); - if (!TextUtils.isEmpty(displayLabel)) { + if (mAnimateItems && !TextUtils.isEmpty(displayLabel)) { mAnimationTracker.animateLabel(holder.text, info); } - if (!TextUtils.isEmpty(extendedInfo) && holder.text2.getVisibility() == View.VISIBLE) { + if (mAnimateItems + && !TextUtils.isEmpty(extendedInfo) + && holder.text2.getVisibility() == View.VISIBLE) { mAnimationTracker.animateLabel(holder.text2, info); } - holder.bindIcon(info); - if (info.hasDisplayIcon()) { - mAnimationTracker.animateIcon(holder.icon, info); - } - if (info.isSelectableTargetInfo()) { // direct share targets should append the application name for a better readout DisplayResolveInfo rInfo = info.getDisplayResolveInfo(); @@ -418,6 +421,11 @@ public class ChooserListAdapter extends ResolverListAdapter { } } + holder.bindIcon(info); + if (mAnimateItems && info.hasDisplayIcon()) { + mAnimationTracker.animateIcon(holder.icon, info); + } + if (info.isPlaceHolderTargetInfo()) { bindPlaceholder(holder); } diff --git a/java/src/com/android/intentresolver/ResolverActivity.java b/java/src/com/android/intentresolver/ResolverActivity.java index 1b08d957..927f59ef 100644 --- a/java/src/com/android/intentresolver/ResolverActivity.java +++ b/java/src/com/android/intentresolver/ResolverActivity.java @@ -1390,7 +1390,7 @@ public class ResolverActivity extends Hilt_ResolverActivity implements // Load the icon asynchronously ImageView icon = findViewById(com.android.internal.R.id.icon); - targetDataLoader.loadAppTargetIcon( + targetDataLoader.getOrLoadAppTargetIcon( otherProfileResolveInfo, inactiveAdapter.getUserHandle(), (drawable) -> { diff --git a/java/src/com/android/intentresolver/ResolverListAdapter.java b/java/src/com/android/intentresolver/ResolverListAdapter.java index 2a8fcfa4..5fd37d43 100644 --- a/java/src/com/android/intentresolver/ResolverListAdapter.java +++ b/java/src/com/android/intentresolver/ResolverListAdapter.java @@ -739,26 +739,31 @@ public class ResolverListAdapter extends BaseAdapter { holder.bindLabel("", ""); loadLabel(dri); } - holder.bindIcon(info); if (!dri.hasDisplayIcon()) { loadIcon(dri); } + holder.bindIcon(info); } } protected final void loadIcon(DisplayResolveInfo info) { if (mRequestedIcons.add(info)) { - mTargetDataLoader.loadAppTargetIcon( + Drawable icon = mTargetDataLoader.getOrLoadAppTargetIcon( info, getUserHandle(), - (drawable) -> onIconLoaded(info, drawable)); + (drawable) -> { + onIconLoaded(info, drawable); + notifyDataSetChanged(); + }); + if (icon != null) { + onIconLoaded(info, icon); + } } } private void onIconLoaded(DisplayResolveInfo displayResolveInfo, Drawable drawable) { if (!displayResolveInfo.hasDisplayIcon()) { displayResolveInfo.getDisplayIconHolder().setDisplayIcon(drawable); - notifyDataSetChanged(); } } @@ -822,7 +827,7 @@ public class ResolverListAdapter extends BaseAdapter { public void loadFilteredItemIconTaskAsync(@NonNull ImageView iconView) { final DisplayResolveInfo iconInfo = getFilteredItem(); if (iconInfo != null) { - mTargetDataLoader.loadAppTargetIcon( + mTargetDataLoader.getOrLoadAppTargetIcon( iconInfo, getUserHandle(), iconView::setImageDrawable); } } diff --git a/java/src/com/android/intentresolver/chooser/DisplayResolveInfo.java b/java/src/com/android/intentresolver/chooser/DisplayResolveInfo.java index 536f11ce..5e44c53e 100644 --- a/java/src/com/android/intentresolver/chooser/DisplayResolveInfo.java +++ b/java/src/com/android/intentresolver/chooser/DisplayResolveInfo.java @@ -196,6 +196,7 @@ public class DisplayResolveInfo implements TargetInfo { } @Override + @NonNull public ComponentName getResolvedComponentName() { return new ComponentName(mResolveInfo.activityInfo.packageName, mResolveInfo.activityInfo.name); diff --git a/java/src/com/android/intentresolver/chooser/MultiDisplayResolveInfo.java b/java/src/com/android/intentresolver/chooser/MultiDisplayResolveInfo.java index 4fe28384..95cb443e 100644 --- a/java/src/com/android/intentresolver/chooser/MultiDisplayResolveInfo.java +++ b/java/src/com/android/intentresolver/chooser/MultiDisplayResolveInfo.java @@ -23,6 +23,7 @@ import android.os.Bundle; import android.os.UserHandle; import android.util.Log; +import androidx.annotation.NonNull; import androidx.annotation.Nullable; import java.util.ArrayList; @@ -123,6 +124,7 @@ public class MultiDisplayResolveInfo extends DisplayResolveInfo { } @Override + @NonNull public ComponentName getResolvedComponentName() { if (hasSelected()) { return mTargetInfos.get(mSelected).getResolvedComponentName(); diff --git a/java/src/com/android/intentresolver/icons/CachingTargetDataLoader.kt b/java/src/com/android/intentresolver/icons/CachingTargetDataLoader.kt new file mode 100644 index 00000000..b3054231 --- /dev/null +++ b/java/src/com/android/intentresolver/icons/CachingTargetDataLoader.kt @@ -0,0 +1,76 @@ +/* + * Copyright 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.intentresolver.icons + +import android.content.ComponentName +import android.graphics.drawable.Drawable +import android.os.UserHandle +import androidx.collection.LruCache +import com.android.intentresolver.chooser.DisplayResolveInfo +import com.android.intentresolver.chooser.SelectableTargetInfo +import java.util.function.Consumer +import javax.annotation.concurrent.GuardedBy +import javax.inject.Qualifier + +@Qualifier @MustBeDocumented @Retention(AnnotationRetention.BINARY) annotation class Caching + +private typealias IconCache = LruCache + +class CachingTargetDataLoader( + private val targetDataLoader: TargetDataLoader, + private val cacheSize: Int = 100, +) : TargetDataLoader() { + @GuardedBy("self") private val perProfileIconCache = HashMap() + + override fun getOrLoadAppTargetIcon( + info: DisplayResolveInfo, + userHandle: UserHandle, + callback: Consumer + ): Drawable? { + val cacheKey = info.toCacheKey() + return getCachedAppIcon(cacheKey, userHandle) + ?: targetDataLoader.getOrLoadAppTargetIcon(info, userHandle) { drawable -> + getProfileIconCache(userHandle).put(cacheKey, drawable) + callback.accept(drawable) + } + } + + override fun loadDirectShareIcon( + info: SelectableTargetInfo, + userHandle: UserHandle, + callback: Consumer + ) = targetDataLoader.loadDirectShareIcon(info, userHandle, callback) + + override fun loadLabel(info: DisplayResolveInfo, callback: Consumer) = + targetDataLoader.loadLabel(info, callback) + + override fun getOrLoadLabel(info: DisplayResolveInfo) = targetDataLoader.getOrLoadLabel(info) + + private fun getCachedAppIcon(component: ComponentName, userHandle: UserHandle): Drawable? = + getProfileIconCache(userHandle)[component] + + private fun getProfileIconCache(userHandle: UserHandle): IconCache = + synchronized(perProfileIconCache) { + perProfileIconCache.getOrPut(userHandle) { IconCache(cacheSize) } + } + + private fun DisplayResolveInfo.toCacheKey() = + ComponentName( + resolveInfo.activityInfo.packageName, + resolveInfo.activityInfo.name, + ) +} diff --git a/java/src/com/android/intentresolver/icons/DefaultTargetDataLoader.kt b/java/src/com/android/intentresolver/icons/DefaultTargetDataLoader.kt index 054fbe71..1a724d73 100644 --- a/java/src/com/android/intentresolver/icons/DefaultTargetDataLoader.kt +++ b/java/src/com/android/intentresolver/icons/DefaultTargetDataLoader.kt @@ -62,11 +62,11 @@ class DefaultTargetDataLoader( ) } - override fun loadAppTargetIcon( + override fun getOrLoadAppTargetIcon( info: DisplayResolveInfo, userHandle: UserHandle, callback: Consumer, - ) { + ): Drawable? { val taskId = nextTaskId.getAndIncrement() LoadIconTask(context, info, userHandle, presentationFactory) { result -> removeTask(taskId) @@ -74,6 +74,7 @@ class DefaultTargetDataLoader( } .also { addTask(taskId, it) } .executeOnExecutor(executor) + return null } override fun loadDirectShareIcon( diff --git a/java/src/com/android/intentresolver/icons/LabelInfo.kt b/java/src/com/android/intentresolver/icons/LabelInfo.kt index a9c4cd77..4b60d607 100644 --- a/java/src/com/android/intentresolver/icons/LabelInfo.kt +++ b/java/src/com/android/intentresolver/icons/LabelInfo.kt @@ -16,4 +16,4 @@ package com.android.intentresolver.icons -class LabelInfo(val label: CharSequence?, val subLabel: CharSequence?) +data class LabelInfo(val label: CharSequence?, val subLabel: CharSequence?) diff --git a/java/src/com/android/intentresolver/icons/TargetDataLoader.kt b/java/src/com/android/intentresolver/icons/TargetDataLoader.kt index 07c62177..7789df44 100644 --- a/java/src/com/android/intentresolver/icons/TargetDataLoader.kt +++ b/java/src/com/android/intentresolver/icons/TargetDataLoader.kt @@ -25,11 +25,11 @@ import java.util.function.Consumer /** A target data loader contract. Added to support testing. */ abstract class TargetDataLoader { /** Load an app target icon */ - abstract fun loadAppTargetIcon( + abstract fun getOrLoadAppTargetIcon( info: DisplayResolveInfo, userHandle: UserHandle, callback: Consumer, - ) + ): Drawable? /** Load a shortcut icon */ abstract fun loadDirectShareIcon( diff --git a/java/src/com/android/intentresolver/icons/TargetDataLoaderModule.kt b/java/src/com/android/intentresolver/icons/TargetDataLoaderModule.kt index 32c040b8..9c0acb11 100644 --- a/java/src/com/android/intentresolver/icons/TargetDataLoaderModule.kt +++ b/java/src/com/android/intentresolver/icons/TargetDataLoaderModule.kt @@ -35,4 +35,10 @@ object TargetDataLoaderModule { @ActivityContext context: Context, @ActivityOwned lifecycle: Lifecycle, ): TargetDataLoader = DefaultTargetDataLoader(context, lifecycle, isAudioCaptureDevice = false) + + @Provides + @ActivityScoped + @Caching + fun cachingTargetDataLoader(targetDataLoader: TargetDataLoader): TargetDataLoader = + CachingTargetDataLoader(targetDataLoader) } diff --git a/tests/activity/src/com/android/intentresolver/ResolverWrapperActivity.java b/tests/activity/src/com/android/intentresolver/ResolverWrapperActivity.java index 30858c8e..b46d8bc3 100644 --- a/tests/activity/src/com/android/intentresolver/ResolverWrapperActivity.java +++ b/tests/activity/src/com/android/intentresolver/ResolverWrapperActivity.java @@ -171,11 +171,12 @@ public class ResolverWrapperActivity extends ResolverActivity { } @Override - public void loadAppTargetIcon( + @Nullable + public Drawable getOrLoadAppTargetIcon( @NonNull DisplayResolveInfo info, @NonNull UserHandle userHandle, @NonNull Consumer callback) { - mTargetDataLoader.loadAppTargetIcon(info, userHandle, callback); + return mTargetDataLoader.getOrLoadAppTargetIcon(info, userHandle, callback); } @Override diff --git a/tests/unit/src/com/android/intentresolver/ChooserListAdapterTest.kt b/tests/unit/src/com/android/intentresolver/ChooserListAdapterTest.kt index bbe8a29e..5ac4f2b0 100644 --- a/tests/unit/src/com/android/intentresolver/ChooserListAdapterTest.kt +++ b/tests/unit/src/com/android/intentresolver/ChooserListAdapterTest.kt @@ -140,7 +140,7 @@ class ChooserListAdapterTest { testSubject.onBindView(view, targetInfo, 0) - verify(mTargetDataLoader, times(1)).loadAppTargetIcon(any(), any(), any()) + verify(mTargetDataLoader, times(1)).getOrLoadAppTargetIcon(any(), any(), any()) } @Test -- cgit v1.2.3-59-g8ed1b From 2af47ea5c79b9653c673455267f8baac991fb9f0 Mon Sep 17 00:00:00 2001 From: Mark Renouf Date: Fri, 3 May 2024 11:18:46 -0400 Subject: Dismiss if restarting after private space is locked Share sheet only dismisses when it is moved to the background. When private space is set to lock automatically, the private space profile tab may remain visible, leading to disclosure risk. When restarting ChooserActivity, if the private tab was visible but the private profile has since become locked, the activity will be finished instead. Bug: 338125945 Flag: com.android.intentresolver.fix_private_space_locked_on_restart Test: manual; see description, screencast in bug Change-Id: I294bf62f0a73f6c1b73f598109814701215cdeb5 --- aconfig/FeatureFlags.aconfig | 11 +++++++++++ java/src/com/android/intentresolver/ChooserActivity.java | 9 +++++++++ java/src/com/android/intentresolver/ProfileAvailability.kt | 2 +- 3 files changed, 21 insertions(+), 1 deletion(-) (limited to 'java/src') diff --git a/aconfig/FeatureFlags.aconfig b/aconfig/FeatureFlags.aconfig index b7d9ea0d..cdf9eb29 100644 --- a/aconfig/FeatureFlags.aconfig +++ b/aconfig/FeatureFlags.aconfig @@ -52,9 +52,20 @@ flag { purpose: PURPOSE_BUGFIX } } + flag { name: "fix_empty_state_padding" namespace: "intentresolver" description: "Always apply systemBar window insets regardless of profiles present" bug: "338447666" } + +flag { + name: "fix_private_space_locked_on_restart" + namespace: "intentresolver" + description: "Dismiss Share sheet on restart if private space became locked while stopped" + bug: "338125945" + metadata { + purpose: PURPOSE_BUGFIX + } +} diff --git a/java/src/com/android/intentresolver/ChooserActivity.java b/java/src/com/android/intentresolver/ChooserActivity.java index 1922c05c..f01fd77c 100644 --- a/java/src/com/android/intentresolver/ChooserActivity.java +++ b/java/src/com/android/intentresolver/ChooserActivity.java @@ -416,6 +416,15 @@ public class ChooserActivity extends Hilt_ChooserActivity implements @Override protected final void onRestart() { super.onRestart(); + if (mFeatureFlags.fixPrivateSpaceLockedOnRestart()) { + if (mChooserMultiProfilePagerAdapter.hasPageForProfile(Profile.Type.PRIVATE.ordinal()) + && !mProfileAvailability.isAvailable(mProfiles.getPrivateProfile())) { + Log.d(TAG, "Exiting due to unavailable profile"); + finish(); + return; + } + } + if (!mRegistered) { mPersonalPackageMonitor.register( this, diff --git a/java/src/com/android/intentresolver/ProfileAvailability.kt b/java/src/com/android/intentresolver/ProfileAvailability.kt index c8e78552..43982727 100644 --- a/java/src/com/android/intentresolver/ProfileAvailability.kt +++ b/java/src/com/android/intentresolver/ProfileAvailability.kt @@ -47,7 +47,7 @@ class ProfileAvailability( /** Query current profile availability. An unavailable profile is one which is not active. */ @MainThread - fun isAvailable(profile: Profile): Boolean { + fun isAvailable(profile: Profile?): Boolean { return runBlocking(background) { userInteractor.availability.map { it[profile] == true }.first() } -- cgit v1.2.3-59-g8ed1b From 003c0cb9bd153cf3689b3c80cfd2228f351c716d Mon Sep 17 00:00:00 2001 From: Mark Renouf Date: Mon, 6 May 2024 19:22:09 +0000 Subject: Correct flag type Change-Id: I6416928fe2bd5531b67e53b4403f1213d832282d --- aconfig/FeatureFlags.aconfig | 10 ++++++++++ java/src/com/android/intentresolver/ChooserActivity.java | 2 +- 2 files changed, 11 insertions(+), 1 deletion(-) (limited to 'java/src') diff --git a/aconfig/FeatureFlags.aconfig b/aconfig/FeatureFlags.aconfig index cdf9eb29..a102328a 100644 --- a/aconfig/FeatureFlags.aconfig +++ b/aconfig/FeatureFlags.aconfig @@ -60,6 +60,16 @@ flag { bug: "338447666" } +flag { + name: "fix_empty_state_padding_bug" + namespace: "intentresolver" + description: "Always apply systemBar window insets regardless of profiles present" + bug: "338447666" + metadata { + purpose: PURPOSE_BUGFIX + } +} + flag { name: "fix_private_space_locked_on_restart" namespace: "intentresolver" diff --git a/java/src/com/android/intentresolver/ChooserActivity.java b/java/src/com/android/intentresolver/ChooserActivity.java index 8b78fc7e..9643b9f0 100644 --- a/java/src/com/android/intentresolver/ChooserActivity.java +++ b/java/src/com/android/intentresolver/ChooserActivity.java @@ -2612,7 +2612,7 @@ public class ChooserActivity extends Hilt_ChooserActivity implements protected WindowInsets onApplyWindowInsets(View v, WindowInsets insets) { mSystemWindowInsets = insets.getInsets(WindowInsets.Type.systemBars()); - if (mFeatureFlags.fixEmptyStatePadding() || mProfiles.getWorkProfilePresent()) { + if (mFeatureFlags.fixEmptyStatePaddingBug() || mProfiles.getWorkProfilePresent()) { mChooserMultiProfilePagerAdapter .setEmptyStateBottomOffset(mSystemWindowInsets.bottom); } -- cgit v1.2.3-59-g8ed1b From 90bddf71b63f5082c4ec9e697b0baaacb5f81ecd Mon Sep 17 00:00:00 2001 From: Andrey Epin Date: Thu, 9 May 2024 12:08:06 -0700 Subject: Add support for preview size columns in the additional content query. Add support for MediaStore WIDTH and HEIGHT columns in the additional content query reponse. Parse those columns if they are present but do not actually use the values (yet). Bug: 339679442 Test: atest IntentResolver-tests-unit Test: atest IntentResolver-tests-activity Change-Id: I2a3ebc2c166d1cb9203824b2ac1bf0f9c4ec76da --- .../contentpreview/UriMetadataHelpers.kt | 22 +++++ .../domain/cursor/PayloadToggleCursorResolver.kt | 27 ++++-- .../domain/interactor/CursorPreviewsInteractor.kt | 44 +++++---- .../domain/interactor/FetchPreviewsInteractor.kt | 3 +- .../payloadtoggle/domain/model/CursorRow.kt | 23 +++++ .../domain/cursor/CursorResolverKosmos.kt | 4 +- .../contentpreview/CursorReadSizeTest.kt | 71 ++++++++++++++ .../cursor/PayloadToggleCursorResolverTest.kt | 106 +++++++++++++++++++++ .../interactor/CursorPreviewsInteractorTest.kt | 6 +- .../interactor/FetchPreviewsInteractorTest.kt | 7 +- 10 files changed, 277 insertions(+), 36 deletions(-) create mode 100644 java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/model/CursorRow.kt create mode 100644 tests/unit/src/com/android/intentresolver/contentpreview/CursorReadSizeTest.kt create mode 100644 tests/unit/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/cursor/PayloadToggleCursorResolverTest.kt (limited to 'java/src') diff --git a/java/src/com/android/intentresolver/contentpreview/UriMetadataHelpers.kt b/java/src/com/android/intentresolver/contentpreview/UriMetadataHelpers.kt index 41638b1f..c532b9a5 100644 --- a/java/src/com/android/intentresolver/contentpreview/UriMetadataHelpers.kt +++ b/java/src/com/android/intentresolver/contentpreview/UriMetadataHelpers.kt @@ -23,9 +23,12 @@ import android.net.Uri import android.provider.DocumentsContract import android.provider.DocumentsContract.Document.FLAG_SUPPORTS_THUMBNAIL import android.provider.Downloads +import android.provider.MediaStore.MediaColumns.HEIGHT +import android.provider.MediaStore.MediaColumns.WIDTH import android.provider.OpenableColumns import android.text.TextUtils import android.util.Log +import android.util.Size import com.android.intentresolver.measurements.runTracing internal fun ContentInterface.getTypeSafe(uri: Uri): String? = @@ -83,6 +86,25 @@ internal fun Cursor.readPreviewUri(): Uri? = } .getOrNull() +fun Cursor.readSize(): Size? { + val widthIdx = columnNames.indexOf(WIDTH) + val heightIdx = columnNames.indexOf(HEIGHT) + return if (widthIdx < 0 || heightIdx < 0 || isNull(widthIdx) || isNull(heightIdx)) { + null + } else { + runCatching { + val width = getInt(widthIdx) + val height = getInt(heightIdx) + if (width >= 0 && height > 0) { + Size(width, height) + } else { + null + } + } + .getOrNull() + } +} + internal fun Cursor.readTitle(): String = runCatching { var nameColIndex = -1 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 index 3cf2af13..d9612696 100644 --- a/java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/cursor/PayloadToggleCursorResolver.kt +++ b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/cursor/PayloadToggleCursorResolver.kt @@ -16,11 +16,14 @@ package com.android.intentresolver.contentpreview.payloadtoggle.domain.cursor -import android.content.ContentResolver +import android.content.ContentInterface import android.content.Intent +import android.database.Cursor import android.net.Uri import android.service.chooser.AdditionalContentContract.Columns.URI import androidx.core.os.bundleOf +import com.android.intentresolver.contentpreview.payloadtoggle.domain.model.CursorRow +import com.android.intentresolver.contentpreview.readSize import com.android.intentresolver.inject.AdditionalContent import com.android.intentresolver.inject.ChooserIntent import com.android.intentresolver.util.cursor.CursorView @@ -37,23 +40,31 @@ import javax.inject.Qualifier class PayloadToggleCursorResolver @Inject constructor( - private val contentResolver: ContentResolver, + private val contentResolver: ContentInterface, @AdditionalContent private val cursorUri: Uri, @ChooserIntent private val chooserIntent: Intent, -) : CursorResolver { - override suspend fun getCursor(): CursorView? = withCancellationSignal { signal -> +) : CursorResolver { + override suspend fun getCursor(): CursorView? = withCancellationSignal { signal -> runCatching { contentResolver.query( cursorUri, - arrayOf(URI), + // TODO: uncomment to start using that data + arrayOf(URI /*, WIDTH, HEIGHT*/), bundleOf(Intent.EXTRA_INTENT to chooserIntent), signal, ) } .getOrNull() - ?.viewBy { - getString(0)?.let(Uri::parse)?.takeIf { it.authority != cursorUri.authority } + ?.viewBy { readUri()?.let { uri -> CursorRow(uri, readSize()) } } + } + + private fun Cursor.readUri(): Uri? { + val uriIdx = columnNames.indexOf(URI) + if (uriIdx < 0) return null + return runCatching { + getString(uriIdx)?.let(Uri::parse)?.takeIf { it.authority != cursorUri.authority } } + .getOrNull() } @Module @@ -61,7 +72,7 @@ constructor( interface Binding { @Binds @PayloadToggle - fun bind(cursorResolver: PayloadToggleCursorResolver): CursorResolver + fun bind(cursorResolver: PayloadToggleCursorResolver): CursorResolver } } 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 index f642f420..9d62ffa2 100644 --- a/java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/interactor/CursorPreviewsInteractor.kt +++ b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/interactor/CursorPreviewsInteractor.kt @@ -21,6 +21,7 @@ package com.android.intentresolver.contentpreview.payloadtoggle.domain.interacto import android.net.Uri import android.service.chooser.AdditionalContentContract.CursorExtraKeys.POSITION import com.android.intentresolver.contentpreview.UriMetadataReader +import com.android.intentresolver.contentpreview.payloadtoggle.domain.model.CursorRow import com.android.intentresolver.contentpreview.payloadtoggle.domain.model.LoadDirection import com.android.intentresolver.contentpreview.payloadtoggle.domain.model.LoadedWindow import com.android.intentresolver.contentpreview.payloadtoggle.domain.model.expandWindowLeft @@ -64,7 +65,7 @@ constructor( } /** Start reading data from [uriCursor], and listen for requests to load more. */ - suspend fun launch(uriCursor: CursorView, initialPreviews: Iterable) { + suspend fun launch(uriCursor: CursorView, initialPreviews: Iterable) { // 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. @@ -73,7 +74,7 @@ constructor( .asSequence() .mapIndexed { i, m -> Pair(m.uri, Pair(i, m)) } .toMap(ConcurrentHashMap()) - val pagedCursor: PagedCursor = uriCursor.paged(pageSize) + val pagedCursor: PagedCursor = uriCursor.paged(pageSize) val startPosition = uriCursor.extras?.getInt(POSITION, 0) ?: 0 val state = readInitialState(pagedCursor, startPosition, unclaimedRecords) processLoadRequests(state, pagedCursor, unclaimedRecords) @@ -82,7 +83,7 @@ constructor( /** Loop forever, processing any loading requests from the UI and updating local cache. */ private suspend fun processLoadRequests( initialState: CursorWindow, - pagedCursor: PagedCursor, + pagedCursor: PagedCursor, unclaimedRecords: MutableUnclaimedMap, ) { var state = initialState @@ -108,7 +109,7 @@ constructor( */ private suspend fun Flow.handleOneLoadRequest( state: CursorWindow, - pagedCursor: PagedCursor, + pagedCursor: PagedCursor, unclaimedRecords: MutableUnclaimedMap, ): CursorWindow = mapLatest { loadDirection -> @@ -127,7 +128,7 @@ constructor( * [startPosition]. */ private suspend fun readInitialState( - cursor: PagedCursor, + cursor: PagedCursor, startPosition: Int, unclaimedRecords: MutableUnclaimedMap, ): CursorWindow { @@ -138,13 +139,13 @@ constructor( if (!hasMoreLeft) { // First read the initial page; this might claim some unclaimed Uris val page = - cursor.getPageUris(startPageIdx)?.toPage(mutableMapOf(), unclaimedRecords) + cursor.getPageRows(startPageIdx)?.toPage(mutableMapOf(), unclaimedRecords) // Now that unclaimed Uris are up-to-date, add them first. putAllUnclaimedLeft(unclaimedRecords) // Then add the loaded page page?.let(::putAll) } else { - cursor.getPageUris(startPageIdx)?.toPage(this, unclaimedRecords) + cursor.getPageRows(startPageIdx)?.toPage(this, unclaimedRecords) } // Finally, add the remainder of the unclaimed Uris. if (!hasMoreRight) { @@ -162,7 +163,7 @@ constructor( } private suspend fun CursorWindow.loadMoreRight( - cursor: PagedCursor, + cursor: PagedCursor, unclaimedRecords: MutableUnclaimedMap, ): CursorWindow { val pageNum = lastLoadedPageNum + 1 @@ -181,7 +182,7 @@ constructor( } private suspend fun CursorWindow.loadMoreLeft( - cursor: PagedCursor, + cursor: PagedCursor, unclaimedRecords: MutableUnclaimedMap, ): CursorWindow { val pageNum = firstLoadedPageNum - 1 @@ -207,7 +208,7 @@ constructor( private suspend fun readPage( state: CursorWindow, - pagedCursor: PagedCursor, + pagedCursor: PagedCursor, pageNum: Int, unclaimedRecords: MutableUnclaimedMap, ): PreviewMap = @@ -216,30 +217,33 @@ constructor( private suspend fun M.readAndPutPage( state: CursorWindow, - pagedCursor: PagedCursor, + pagedCursor: PagedCursor, pageNum: Int, unclaimedRecords: MutableUnclaimedMap, ): M = pagedCursor - .getPageUris(pageNum) // TODO: what do we do if the load fails? - ?.filter { it !in state.merged } + .getPageRows(pageNum) // TODO: what do we do if the load fails? + ?.filter { it.uri !in state.merged } ?.toPage(this, unclaimedRecords) ?: this - private suspend fun Sequence.toPage( + private suspend fun Sequence.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) } + mapParallel(parallelism = 4) { row -> createPreviewModel(row, unclaimedRecords) } .associateByTo(destination) { it.uri } - private fun createPreviewModel(uri: Uri, unclaimedRecords: MutableUnclaimedMap): PreviewModel = - unclaimedRecords.remove(uri)?.second + private fun createPreviewModel( + row: CursorRow, + unclaimedRecords: MutableUnclaimedMap, + ): PreviewModel = + unclaimedRecords.remove(row.uri)?.second ?: PreviewModel( - uri = uri, - mimeType = uriMetadataReader.getMetadata(uri).mimeType, + uri = row.uri, + mimeType = uriMetadataReader.getMetadata(row.uri).mimeType, ) private fun M.putAllUnclaimedRight(unclaimed: UnclaimedMap): M = @@ -275,7 +279,7 @@ private fun M.putAllUnclaimedWhere( .map { it.key to it.value.second } .toMap(this) -private fun PagedCursor.getPageUris(pageNum: Int): Sequence? = +private fun PagedCursor.getPageRows(pageNum: Int): Sequence? = get(pageNum)?.filterNotNull() @Qualifier @MustBeDocumented @Retention(AnnotationRetention.RUNTIME) annotation class PageSize 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 index 9bc7ae63..927a3a84 100644 --- a/java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/interactor/FetchPreviewsInteractor.kt +++ b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/interactor/FetchPreviewsInteractor.kt @@ -21,6 +21,7 @@ import com.android.intentresolver.contentpreview.UriMetadataReader import com.android.intentresolver.contentpreview.payloadtoggle.data.repository.PreviewSelectionsRepository import com.android.intentresolver.contentpreview.payloadtoggle.domain.cursor.CursorResolver import com.android.intentresolver.contentpreview.payloadtoggle.domain.cursor.PayloadToggle +import com.android.intentresolver.contentpreview.payloadtoggle.domain.model.CursorRow import com.android.intentresolver.contentpreview.payloadtoggle.shared.model.PreviewModel import com.android.intentresolver.inject.ContentUris import com.android.intentresolver.inject.FocusedItemIndex @@ -39,7 +40,7 @@ constructor( @FocusedItemIndex private val focusedItemIdx: Int, @ContentUris private val selectedItems: List<@JvmSuppressWildcards Uri>, private val uriMetadataReader: UriMetadataReader, - @PayloadToggle private val cursorResolver: CursorResolver<@JvmSuppressWildcards Uri?>, + @PayloadToggle private val cursorResolver: CursorResolver<@JvmSuppressWildcards CursorRow?>, ) { suspend fun activate() = coroutineScope { val cursor = async { cursorResolver.getCursor() } diff --git a/java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/model/CursorRow.kt b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/model/CursorRow.kt new file mode 100644 index 00000000..f1d856ac --- /dev/null +++ b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/model/CursorRow.kt @@ -0,0 +1,23 @@ +/* + * Copyright 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.intentresolver.contentpreview.payloadtoggle.domain.model + +import android.net.Uri +import android.util.Size + +/** Represents additional content cursor row */ +data class CursorRow(val uri: Uri, val previewSize: Size?) diff --git a/tests/shared/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/cursor/CursorResolverKosmos.kt b/tests/shared/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/cursor/CursorResolverKosmos.kt index 10b89c71..d53210bd 100644 --- a/tests/shared/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/cursor/CursorResolverKosmos.kt +++ b/tests/shared/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/cursor/CursorResolverKosmos.kt @@ -16,13 +16,13 @@ package com.android.intentresolver.contentpreview.payloadtoggle.domain.cursor -import android.net.Uri import com.android.intentresolver.contentResolver +import com.android.intentresolver.contentpreview.payloadtoggle.domain.model.CursorRow import com.android.intentresolver.inject.additionalContentUri import com.android.intentresolver.inject.chooserIntent import com.android.systemui.kosmos.Kosmos -var Kosmos.payloadToggleCursorResolver: CursorResolver by +var Kosmos.payloadToggleCursorResolver: CursorResolver by Kosmos.Fixture { payloadToggleCursorResolverImpl } val Kosmos.payloadToggleCursorResolverImpl get() = diff --git a/tests/unit/src/com/android/intentresolver/contentpreview/CursorReadSizeTest.kt b/tests/unit/src/com/android/intentresolver/contentpreview/CursorReadSizeTest.kt new file mode 100644 index 00000000..0c346095 --- /dev/null +++ b/tests/unit/src/com/android/intentresolver/contentpreview/CursorReadSizeTest.kt @@ -0,0 +1,71 @@ +/* + * Copyright 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.intentresolver.contentpreview + +import android.database.MatrixCursor +import android.provider.MediaStore.MediaColumns.HEIGHT +import android.provider.MediaStore.MediaColumns.WIDTH +import android.util.Size +import com.google.common.truth.Truth.assertThat +import com.google.common.truth.Truth.assertWithMessage +import kotlinx.coroutines.test.runTest +import org.junit.Test + +class CursorReadSizeTest { + @Test + fun missingSizeColumns() { + val cursor = MatrixCursor(arrayOf("column")).apply { addRow(arrayOf("abc")) } + cursor.moveToFirst() + + assertThat(cursor.readSize()).isNull() + } + + @Test + fun testIncorrectSizeValues() = runTest { + val cursor = + MatrixCursor(arrayOf(WIDTH, HEIGHT)).apply { + addRow(arrayOf(null, null)) + addRow(arrayOf("100", null)) + addRow(arrayOf(null, "100")) + addRow(arrayOf("-100", "100")) + addRow(arrayOf("100", "-100")) + addRow(arrayOf("100", "abc")) + addRow(arrayOf("abc", "100")) + } + + var i = 0 + while (cursor.moveToNext()) { + i++ + assertWithMessage("Row $i").that(cursor.readSize()).isNull() + } + } + + @Test + fun testCorrectSizeValues() = runTest { + val cursor = + MatrixCursor(arrayOf(HEIGHT, WIDTH)).apply { + addRow(arrayOf("100", 0)) + addRow(arrayOf("100", "50")) + } + + cursor.moveToNext() + assertThat(cursor.readSize()).isEqualTo(Size(0, 100)) + + cursor.moveToNext() + assertThat(cursor.readSize()).isEqualTo(Size(50, 100)) + } +} diff --git a/tests/unit/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/cursor/PayloadToggleCursorResolverTest.kt b/tests/unit/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/cursor/PayloadToggleCursorResolverTest.kt new file mode 100644 index 00000000..9eaee233 --- /dev/null +++ b/tests/unit/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/cursor/PayloadToggleCursorResolverTest.kt @@ -0,0 +1,106 @@ +/* + * Copyright 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.intentresolver.contentpreview.payloadtoggle.domain.cursor + +import android.content.ContentInterface +import android.content.Intent +import android.database.MatrixCursor +import android.net.Uri +import android.provider.MediaStore.MediaColumns.HEIGHT +import android.provider.MediaStore.MediaColumns.WIDTH +import android.service.chooser.AdditionalContentContract.Columns.URI +import android.util.Size +import com.android.intentresolver.util.cursor.get +import com.google.common.truth.Truth.assertThat +import kotlinx.coroutines.test.runTest +import org.junit.Test +import org.mockito.kotlin.any +import org.mockito.kotlin.doReturn +import org.mockito.kotlin.eq +import org.mockito.kotlin.mock + +class PayloadToggleCursorResolverTest { + private val cursorUri = Uri.parse("content://org.pkg.app.extra") + private val chooserIntent = Intent() + + @Test + fun missingSizeColumns() = runTest { + val uri = createUri(1) + val sourceCursor = + MatrixCursor(arrayOf(URI)).apply { + addRow(arrayOf(uri.toString())) + addRow( + arrayOf( + cursorUri.buildUpon().appendPath("should-be-ignored.png").build().toString() + ) + ) + addRow(arrayOf(null)) + } + val fakeContentProvider = + mock { + on { query(eq(cursorUri), any(), any(), any()) } doReturn sourceCursor + } + val testSubject = + PayloadToggleCursorResolver( + fakeContentProvider, + cursorUri, + chooserIntent, + ) + + val cursor = testSubject.getCursor() + assertThat(cursor).isNotNull() + assertThat(cursor!!.count).isEqualTo(3) + cursor[0].let { row -> + assertThat(row).isNotNull() + assertThat(row!!.uri).isEqualTo(uri) + assertThat(row.previewSize).isNull() + } + assertThat(cursor[1]).isNull() + assertThat(cursor[2]).isNull() + } + + @Test + fun testCorrectSizeValues() = runTest { + val uri = createUri(1) + val sourceCursor = + MatrixCursor(arrayOf(URI, WIDTH, HEIGHT)).apply { + addRow(arrayOf(uri.toString(), "100", "50")) + } + val fakeContentProvider = + mock { + on { query(eq(cursorUri), any(), any(), any()) } doReturn sourceCursor + } + val testSubject = + PayloadToggleCursorResolver( + fakeContentProvider, + cursorUri, + chooserIntent, + ) + + val cursor = testSubject.getCursor() + assertThat(cursor).isNotNull() + assertThat(cursor!!.count).isEqualTo(1) + + cursor[0].let { row -> + assertThat(row).isNotNull() + assertThat(row!!.uri).isEqualTo(uri) + assertThat(row.previewSize).isEqualTo(Size(100, 50)) + } + } +} + +private fun createUri(id: Int) = Uri.parse("content://org.pkg/app/img-$id.png") diff --git a/tests/unit/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/interactor/CursorPreviewsInteractorTest.kt b/tests/unit/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/interactor/CursorPreviewsInteractorTest.kt index af6de833..81e6b77d 100644 --- 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 @@ -24,9 +24,11 @@ import androidx.core.os.bundleOf import com.android.intentresolver.contentpreview.FileInfo import com.android.intentresolver.contentpreview.UriMetadataReader import com.android.intentresolver.contentpreview.payloadtoggle.data.repository.cursorPreviewsRepository +import com.android.intentresolver.contentpreview.payloadtoggle.domain.model.CursorRow import com.android.intentresolver.contentpreview.payloadtoggle.shared.model.PreviewModel import com.android.intentresolver.contentpreview.uriMetadataReader import com.android.intentresolver.util.KosmosTestScope +import com.android.intentresolver.util.cursor.CursorView import com.android.intentresolver.util.cursor.viewBy import com.android.intentresolver.util.runTest import com.android.systemui.kosmos.Kosmos @@ -70,7 +72,7 @@ class CursorPreviewsInteractorTest { private val cursorRange: Iterable, private val cursorStartPosition: Int, ) { - val cursor = + val cursor: CursorView = MatrixCursor(arrayOf("uri")) .apply { extras = bundleOf("position" to cursorStartPosition) @@ -78,7 +80,7 @@ class CursorPreviewsInteractorTest { newRow().add("uri", uri(i).toString()) } } - .viewBy { getString(0)?.let(Uri::parse) } + .viewBy { getString(0)?.let { uriStr -> CursorRow(Uri.parse(uriStr), null) } } val initialPreviews: List = initialSelectionRange.map { i -> PreviewModel(uri = uri(i), mimeType = "image/bitmap") } 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 index f012fcc6..da73f4cf 100644 --- 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 @@ -26,6 +26,7 @@ import com.android.intentresolver.contentpreview.UriMetadataReader import com.android.intentresolver.contentpreview.payloadtoggle.data.repository.cursorPreviewsRepository import com.android.intentresolver.contentpreview.payloadtoggle.domain.cursor.CursorResolver import com.android.intentresolver.contentpreview.payloadtoggle.domain.cursor.payloadToggleCursorResolver +import com.android.intentresolver.contentpreview.payloadtoggle.domain.model.CursorRow import com.android.intentresolver.contentpreview.payloadtoggle.shared.model.PreviewModel import com.android.intentresolver.contentpreview.payloadtoggle.shared.model.PreviewsModel import com.android.intentresolver.contentpreview.uriMetadataReader @@ -74,12 +75,12 @@ class FetchPreviewsInteractorTest { private class FakeCursorResolver( private val cursorRange: Iterable, private val cursorStartPosition: Int, - ) : CursorResolver { + ) : CursorResolver { private val mutex = Mutex(locked = true) fun complete() = mutex.unlock() - override suspend fun getCursor(): CursorView = + override suspend fun getCursor(): CursorView = mutex.withLock { MatrixCursor(arrayOf("uri")) .apply { @@ -88,7 +89,7 @@ class FetchPreviewsInteractorTest { newRow().add("uri", uri(i).toString()) } } - .viewBy { getString(0)?.let(Uri::parse) } + .viewBy { getString(0)?.let(Uri::parse)?.let { CursorRow(it, null) } } } } -- cgit v1.2.3-59-g8ed1b From b5c0bd45601a0d9406a78090c3b56fca3abd30c2 Mon Sep 17 00:00:00 2001 From: Andrey Epin Date: Thu, 9 May 2024 15:09:21 -0700 Subject: Use preview uri for previews (instead of item uri) Fix: 339702714 Test: manual functionality testing (previes are get logaded). Test: atest IntentResolver-tests-unit Flag: aconfig intentresolver android.service.chooser.chooser_payload_toggling Nextfood Change-Id: I940feac51d088a3c00c53c54ed24b9584851b8da --- .../domain/interactor/CursorPreviewsInteractor.kt | 11 +++-- .../domain/interactor/FetchPreviewsInteractor.kt | 7 ++- .../payloadtoggle/shared/model/PreviewModel.kt | 6 +-- .../ui/viewmodel/ShareouselViewModel.kt | 2 +- .../interactor/CursorPreviewsInteractorTest.kt | 22 ++++++--- .../interactor/FetchPreviewsInteractorTest.kt | 30 +++++++++---- .../interactor/SelectablePreviewInteractorTest.kt | 27 ++++++++--- .../interactor/SelectablePreviewsInteractorTest.kt | 52 +++++++++++++++------- .../ui/viewmodel/ShareouselViewModelTest.kt | 23 +++++----- 9 files changed, 125 insertions(+), 55 deletions(-) (limited to 'java/src') 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 index 9d62ffa2..c7d29a72 100644 --- a/java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/interactor/CursorPreviewsInteractor.kt +++ b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/interactor/CursorPreviewsInteractor.kt @@ -241,10 +241,13 @@ constructor( unclaimedRecords: MutableUnclaimedMap, ): PreviewModel = unclaimedRecords.remove(row.uri)?.second - ?: PreviewModel( - uri = row.uri, - mimeType = uriMetadataReader.getMetadata(row.uri).mimeType, - ) + ?: uriMetadataReader.getMetadata(row.uri).let { metadata -> + PreviewModel( + uri = row.uri, + previewUri = metadata.previewUri, + mimeType = metadata.mimeType, + ) + } private fun M.putAllUnclaimedRight(unclaimed: UnclaimedMap): M = putAllUnclaimedWhere(unclaimed) { it >= focusedItemIdx } 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 index 927a3a84..c87504e1 100644 --- a/java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/interactor/FetchPreviewsInteractor.kt +++ b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/interactor/FetchPreviewsInteractor.kt @@ -60,7 +60,12 @@ constructor( // 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) + val metadata = uriMetadataReader.getMetadata(uri) + PreviewModel( + uri = uri, + previewUri = metadata.previewUri, + mimeType = metadata.mimeType, + ) } .toSet() } 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 index ff96a9f4..6b805391 100644 --- a/java/src/com/android/intentresolver/contentpreview/payloadtoggle/shared/model/PreviewModel.kt +++ b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/shared/model/PreviewModel.kt @@ -20,10 +20,10 @@ 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. - */ + /** Uri for this item; if this preview is selected, this will be shared with the target app. */ val uri: Uri, + /** Uri for the preview image. */ + val previewUri: Uri? = uri, /** Mimetype for the data [uri] points to. */ val mimeType: String?, ) 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 index 082581dc..cf118934 100644 --- a/java/src/com/android/intentresolver/contentpreview/payloadtoggle/ui/viewmodel/ShareouselViewModel.kt +++ b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/ui/viewmodel/ShareouselViewModel.kt @@ -113,7 +113,7 @@ object ShareouselViewModelModule { keySet.value?.maybeLoad(key) val previewInteractor = interactor.preview(key) ShareouselPreviewViewModel( - bitmap = flow { emit(imageLoader(key.uri)) }, + bitmap = flow { emit(key.previewUri?.let { imageLoader(it) }) }, contentType = flowOf(ContentType.Image), // TODO: convert from metadata isSelected = previewInteractor.isSelected, setSelected = previewInteractor::setSelected, 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 index 81e6b77d..9b786b74 100644 --- 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 @@ -53,7 +53,7 @@ class CursorPreviewsInteractorTest { this.pageSize = pageSize this.maxLoadedPages = maxLoadedPages uriMetadataReader = UriMetadataReader { - FileInfo.Builder(it).withMimeType("image/bitmap").build() + FileInfo.Builder(it).withPreviewUri(it).withMimeType("image/bitmap").build() } runTest { block( @@ -100,10 +100,22 @@ class CursorPreviewsInteractorTest { assertThat(cursorPreviewsRepository.previewsModel.value!!.loadMoreRight).isNull() assertThat(cursorPreviewsRepository.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"), + PreviewModel( + uri = Uri.fromParts("scheme0", "ssp0", "fragment0"), + mimeType = "image/bitmap" + ), + PreviewModel( + uri = Uri.fromParts("scheme1", "ssp1", "fragment1"), + mimeType = "image/bitmap" + ), + PreviewModel( + uri = Uri.fromParts("scheme2", "ssp2", "fragment2"), + mimeType = "image/bitmap" + ), + PreviewModel( + uri = Uri.fromParts("scheme3", "ssp3", "fragment3"), + mimeType = "image/bitmap" + ), ) .inOrder() } 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 index da73f4cf..c9f71f49 100644 --- 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 @@ -62,7 +62,7 @@ class FetchPreviewsInteractorTest { contentUris = initialSelection.map { uri(it) } this.focusedItemIndex = focusedItemIndex uriMetadataReader = UriMetadataReader { - FileInfo.Builder(it).withMimeType("image/bitmap").build() + FileInfo.Builder(it).withPreviewUri(it).withMimeType("image/bitmap").build() } this.pageSize = pageSize this.maxLoadedPages = maxLoadedPages @@ -104,12 +104,12 @@ class FetchPreviewsInteractorTest { previewModels = setOf( PreviewModel( - Uri.fromParts("scheme1", "ssp1", "fragment1"), - "image/bitmap", + uri = Uri.fromParts("scheme1", "ssp1", "fragment1"), + mimeType = "image/bitmap", ), PreviewModel( - Uri.fromParts("scheme2", "ssp2", "fragment2"), - "image/bitmap", + uri = Uri.fromParts("scheme2", "ssp2", "fragment2"), + mimeType = "image/bitmap", ), ), startIdx = 1, @@ -132,10 +132,22 @@ class FetchPreviewsInteractorTest { assertThat(previewsModel.value!!.loadMoreRight).isNull() assertThat(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"), + PreviewModel( + uri = Uri.fromParts("scheme0", "ssp0", "fragment0"), + mimeType = "image/bitmap" + ), + PreviewModel( + uri = Uri.fromParts("scheme1", "ssp1", "fragment1"), + mimeType = "image/bitmap" + ), + PreviewModel( + uri = Uri.fromParts("scheme2", "ssp2", "fragment2"), + mimeType = "image/bitmap" + ), + PreviewModel( + uri = Uri.fromParts("scheme3", "ssp3", "fragment3"), + mimeType = "image/bitmap" + ), ) .inOrder() } 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 index f8fc4911..a3c65570 100644 --- 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 @@ -39,7 +39,8 @@ class SelectablePreviewInteractorTest { targetIntentModifier = TargetIntentModifier { error("unexpected invocation") } val underTest = SelectablePreviewInteractor( - key = PreviewModel(Uri.fromParts("scheme", "ssp", "fragment"), null), + key = + PreviewModel(uri = Uri.fromParts("scheme", "ssp", "fragment"), mimeType = null), selectionInteractor = selectionInteractor, ) runCurrent() @@ -52,14 +53,23 @@ class SelectablePreviewInteractorTest { targetIntentModifier = TargetIntentModifier { error("unexpected invocation") } val underTest = SelectablePreviewInteractor( - key = PreviewModel(Uri.fromParts("scheme", "ssp", "fragment"), "image/bitmap"), + key = + PreviewModel( + uri = Uri.fromParts("scheme", "ssp", "fragment"), + mimeType = "image/bitmap" + ), selectionInteractor = selectionInteractor, ) assertThat(underTest.isSelected.first()).isFalse() previewSelectionsRepository.selections.value = - setOf(PreviewModel(Uri.fromParts("scheme", "ssp", "fragment"), "image/bitmap")) + setOf( + PreviewModel( + uri = Uri.fromParts("scheme", "ssp", "fragment"), + mimeType = "image/bitmap" + ) + ) runCurrent() assertThat(underTest.isSelected.first()).isTrue() @@ -71,7 +81,11 @@ class SelectablePreviewInteractorTest { targetIntentModifier = TargetIntentModifier { modifiedIntent } val underTest = SelectablePreviewInteractor( - key = PreviewModel(Uri.fromParts("scheme", "ssp", "fragment"), "image/bitmap"), + key = + PreviewModel( + uri = Uri.fromParts("scheme", "ssp", "fragment"), + mimeType = "image/bitmap" + ), selectionInteractor = selectionInteractor, ) @@ -80,7 +94,10 @@ class SelectablePreviewInteractorTest { assertThat(previewSelectionsRepository.selections.value) .containsExactly( - PreviewModel(Uri.fromParts("scheme", "ssp", "fragment"), "image/bitmap") + PreviewModel( + uri = Uri.fromParts("scheme", "ssp", "fragment"), + mimeType = "image/bitmap" + ) ) assertThat(chooserRequestRepository.chooserRequest.value.targetIntent) 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 index 5fa5cab4..be5ddfe5 100644 --- 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 @@ -41,12 +41,12 @@ class SelectablePreviewsInteractorTest { previewModels = setOf( PreviewModel( - Uri.fromParts("scheme", "ssp", "fragment"), - "image/bitmap", + uri = Uri.fromParts("scheme", "ssp", "fragment"), + mimeType = "image/bitmap", ), PreviewModel( - Uri.fromParts("scheme2", "ssp2", "fragment2"), - "image/bitmap", + uri = Uri.fromParts("scheme2", "ssp2", "fragment2"), + mimeType = "image/bitmap", ), ), startIdx = 0, @@ -55,7 +55,7 @@ class SelectablePreviewsInteractorTest { ) previewSelectionsRepository.selections.value = setOf( - PreviewModel(Uri.fromParts("scheme", "ssp", "fragment"), null), + PreviewModel(uri = Uri.fromParts("scheme", "ssp", "fragment"), mimeType = null), ) targetIntentModifier = TargetIntentModifier { error("unexpected invocation") } val underTest = selectablePreviewsInteractor @@ -64,8 +64,14 @@ class SelectablePreviewsInteractorTest { 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"), + PreviewModel( + uri = Uri.fromParts("scheme", "ssp", "fragment"), + mimeType = "image/bitmap" + ), + PreviewModel( + uri = Uri.fromParts("scheme2", "ssp2", "fragment2"), + mimeType = "image/bitmap" + ), ) .inOrder() assertThat(keySet.value!!.startIdx).isEqualTo(0) @@ -73,11 +79,15 @@ class SelectablePreviewsInteractorTest { assertThat(keySet.value!!.loadMoreRight).isNull() val firstModel = - underTest.preview(PreviewModel(Uri.fromParts("scheme", "ssp", "fragment"), null)) + underTest.preview( + PreviewModel(uri = Uri.fromParts("scheme", "ssp", "fragment"), mimeType = null) + ) assertThat(firstModel.isSelected.first()).isTrue() val secondModel = - underTest.preview(PreviewModel(Uri.fromParts("scheme2", "ssp2", "fragment2"), null)) + underTest.preview( + PreviewModel(uri = Uri.fromParts("scheme2", "ssp2", "fragment2"), mimeType = null) + ) assertThat(secondModel.isSelected.first()).isFalse() } @@ -85,14 +95,16 @@ class SelectablePreviewsInteractorTest { fun keySet_reflectsRepositoryUpdate() = runKosmosTest { previewSelectionsRepository.selections.value = setOf( - PreviewModel(Uri.fromParts("scheme", "ssp", "fragment"), null), + PreviewModel(uri = Uri.fromParts("scheme", "ssp", "fragment"), mimeType = null), ) targetIntentModifier = TargetIntentModifier { error("unexpected invocation") } val underTest = selectablePreviewsInteractor val previews = underTest.previews.stateIn(backgroundScope) val firstModel = - underTest.preview(PreviewModel(Uri.fromParts("scheme", "ssp", "fragment"), null)) + underTest.preview( + PreviewModel(uri = Uri.fromParts("scheme", "ssp", "fragment"), mimeType = null) + ) assertThat(previews.value).isNull() assertThat(firstModel.isSelected.first()).isTrue() @@ -104,12 +116,12 @@ class SelectablePreviewsInteractorTest { previewModels = setOf( PreviewModel( - Uri.fromParts("scheme", "ssp", "fragment"), - "image/bitmap", + uri = Uri.fromParts("scheme", "ssp", "fragment"), + mimeType = "image/bitmap", ), PreviewModel( - Uri.fromParts("scheme2", "ssp2", "fragment2"), - "image/bitmap", + uri = Uri.fromParts("scheme2", "ssp2", "fragment2"), + mimeType = "image/bitmap", ), ), startIdx = 5, @@ -122,8 +134,14 @@ class SelectablePreviewsInteractorTest { 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"), + PreviewModel( + uri = Uri.fromParts("scheme", "ssp", "fragment"), + mimeType = "image/bitmap" + ), + PreviewModel( + uri = Uri.fromParts("scheme2", "ssp2", "fragment2"), + mimeType = "image/bitmap" + ), ) .inOrder() assertThat(previews.value!!.startIdx).isEqualTo(5) 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 index 35ef6613..bd3d88f8 100644 --- 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 @@ -79,12 +79,12 @@ class ShareouselViewModelTest { previewSelectionsRepository.selections.value = setOf( PreviewModel( - Uri.fromParts("scheme", "ssp", "fragment"), - null, + uri = Uri.fromParts("scheme", "ssp", "fragment"), + mimeType = null, ), PreviewModel( - Uri.fromParts("scheme1", "ssp1", "fragment1"), - null, + uri = Uri.fromParts("scheme1", "ssp1", "fragment1"), + mimeType = null, ) ) runCurrent() @@ -114,12 +114,12 @@ class ShareouselViewModelTest { previewModels = setOf( PreviewModel( - Uri.fromParts("scheme", "ssp", "fragment"), - null, + uri = Uri.fromParts("scheme", "ssp", "fragment"), + mimeType = null, ), PreviewModel( - Uri.fromParts("scheme1", "ssp1", "fragment1"), - null, + uri = Uri.fromParts("scheme1", "ssp1", "fragment1"), + mimeType = null, ) ), startIdx = 1, @@ -141,7 +141,10 @@ class ShareouselViewModelTest { val previewVm = shareouselViewModel.preview( - PreviewModel(Uri.fromParts("scheme1", "ssp1", "fragment1"), null) + PreviewModel( + uri = Uri.fromParts("scheme1", "ssp1", "fragment1"), + mimeType = null + ) ) assertWithMessage("preview bitmap is null").that(previewVm.bitmap.first()).isNotNull() @@ -205,7 +208,7 @@ class ShareouselViewModelTest { this.pendingIntentSender = pendingIntentSender this.targetIntentModifier = targetIntentModifier previewSelectionsRepository.selections.value = - setOf(PreviewModel(Uri.fromParts("scheme", "ssp", "fragment"), null)) + setOf(PreviewModel(uri = Uri.fromParts("scheme", "ssp", "fragment"), mimeType = null)) payloadToggleImageLoader = FakeImageLoader( initialBitmaps = -- cgit v1.2.3-59-g8ed1b From 78359ff22fae3934e513ba8c498af7e8a48992fc Mon Sep 17 00:00:00 2001 From: Andrey Epin Date: Wed, 8 May 2024 16:30:50 -0700 Subject: Read image size from URI metadata Read preview sizes from both URI metdata, and the additional content proivider response (the latter gets priority, when present); use them to set aspect ratio for the item preview. If the size is missing, fallback to default aspect ratio, 1:1. Bug: 339679442 Test: atest IntentResolver-tests-unit Test: manual testing with ShareTest app Change-Id: Ia6072620a79b5df0b4b4bc9ebd11fb3961cb18a6 --- .../intentresolver/contentpreview/FileInfo.kt | 3 + .../contentpreview/UriMetadataReader.kt | 14 +++ .../domain/interactor/CursorPreviewsInteractor.kt | 4 + .../domain/interactor/FetchPreviewsInteractor.kt | 5 + .../domain/interactor/SizeExtensions.kt | 26 +++++ .../payloadtoggle/shared/model/PreviewModel.kt | 1 + .../ui/composable/ShareouselComposable.kt | 8 +- .../ui/viewmodel/ShareouselPreviewViewModel.kt | 1 + .../ui/viewmodel/ShareouselViewModel.kt | 1 + .../interactor/CursorPreviewsInteractorTest.kt | 107 +++++++++++++-------- .../interactor/FetchPreviewsInteractorTest.kt | 60 +++++++----- 11 files changed, 163 insertions(+), 67 deletions(-) create mode 100644 java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/interactor/SizeExtensions.kt (limited to 'java/src') diff --git a/java/src/com/android/intentresolver/contentpreview/FileInfo.kt b/java/src/com/android/intentresolver/contentpreview/FileInfo.kt index fe35365b..16a948df 100644 --- a/java/src/com/android/intentresolver/contentpreview/FileInfo.kt +++ b/java/src/com/android/intentresolver/contentpreview/FileInfo.kt @@ -22,8 +22,11 @@ class FileInfo private constructor(val uri: Uri, val previewUri: Uri?, val mimeT @VisibleForTesting(otherwise = VisibleForTesting.PACKAGE_PRIVATE) class Builder(val uri: Uri) { var previewUri: Uri? = null + @Synchronized get private set + var mimeType: String? = null + @Synchronized get private set @Synchronized fun withPreviewUri(uri: Uri?): Builder = apply { previewUri = uri } diff --git a/java/src/com/android/intentresolver/contentpreview/UriMetadataReader.kt b/java/src/com/android/intentresolver/contentpreview/UriMetadataReader.kt index b5361889..4e403c22 100644 --- a/java/src/com/android/intentresolver/contentpreview/UriMetadataReader.kt +++ b/java/src/com/android/intentresolver/contentpreview/UriMetadataReader.kt @@ -20,6 +20,8 @@ import android.content.ContentInterface import android.media.MediaMetadata import android.net.Uri import android.provider.DocumentsContract +import android.provider.MediaStore.MediaColumns +import android.util.Size import dagger.Binds import dagger.Module import dagger.Provides @@ -29,6 +31,7 @@ import javax.inject.Inject fun interface UriMetadataReader { fun getMetadata(uri: Uri): FileInfo + fun readPreviewSize(uri: Uri): Size? = null } class UriMetadataReaderImpl @@ -56,6 +59,8 @@ constructor( return builder.build() } + override fun readPreviewSize(uri: Uri): Size? = contentResolver.readPreviewSize(uri) + private fun ContentInterface.supportsImageType(uri: Uri): Boolean = getStreamTypesSafe(uri).firstOrNull { typeClassifier.isImageType(it) } != null @@ -73,6 +78,15 @@ constructor( null } } + + private fun ContentInterface.readPreviewSize(uri: Uri): Size? = + querySafe(uri, arrayOf(MediaColumns.WIDTH, MediaColumns.HEIGHT))?.use { cursor -> + if (cursor.moveToFirst()) { + cursor.readSize() + } else { + null + } + } } @Module 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 index c7d29a72..97b087e1 100644 --- a/java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/interactor/CursorPreviewsInteractor.kt +++ b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/interactor/CursorPreviewsInteractor.kt @@ -242,10 +242,14 @@ constructor( ): PreviewModel = unclaimedRecords.remove(row.uri)?.second ?: uriMetadataReader.getMetadata(row.uri).let { metadata -> + val size = + row.previewSize + ?: metadata.previewUri?.let { uriMetadataReader.readPreviewSize(it) } PreviewModel( uri = row.uri, previewUri = metadata.previewUri, mimeType = metadata.mimeType, + aspectRatio = size.aspectRatioOrDefault(1f), ) } 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 index c87504e1..80cd03d9 100644 --- a/java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/interactor/FetchPreviewsInteractor.kt +++ b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/interactor/FetchPreviewsInteractor.kt @@ -65,6 +65,11 @@ constructor( uri = uri, previewUri = metadata.previewUri, mimeType = metadata.mimeType, + aspectRatio = + metadata.previewUri?.let { + uriMetadataReader.readPreviewSize(it).aspectRatioOrDefault(1f) + } + ?: 1f, ) } .toSet() diff --git a/java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/interactor/SizeExtensions.kt b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/interactor/SizeExtensions.kt new file mode 100644 index 00000000..4cf10414 --- /dev/null +++ b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/interactor/SizeExtensions.kt @@ -0,0 +1,26 @@ +/* + * Copyright 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.intentresolver.contentpreview.payloadtoggle.domain.interactor + +import android.util.Size + +internal fun Size?.aspectRatioOrDefault(default: Float): Float = + when { + this == null -> default + width >= 0 && height > 0 -> width.toFloat() / height.toFloat() + else -> default + } diff --git a/java/src/com/android/intentresolver/contentpreview/payloadtoggle/shared/model/PreviewModel.kt b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/shared/model/PreviewModel.kt index 6b805391..85c70004 100644 --- a/java/src/com/android/intentresolver/contentpreview/payloadtoggle/shared/model/PreviewModel.kt +++ b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/shared/model/PreviewModel.kt @@ -26,4 +26,5 @@ data class PreviewModel( val previewUri: Uri? = uri, /** Mimetype for the data [uri] points to. */ val mimeType: String?, + val aspectRatio: Float = 1f, ) diff --git a/java/src/com/android/intentresolver/contentpreview/payloadtoggle/ui/composable/ShareouselComposable.kt b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/ui/composable/ShareouselComposable.kt index 0a431c2a..85ad6ab3 100644 --- a/java/src/com/android/intentresolver/contentpreview/payloadtoggle/ui/composable/ShareouselComposable.kt +++ b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/ui/composable/ShareouselComposable.kt @@ -115,11 +115,9 @@ private fun ShareouselCard(viewModel: ShareouselPreviewViewModel) { val scope = rememberCoroutineScope() ShareouselCard( image = { + // TODO: max ratio is actually equal to the viewport ratio + val aspectRatio = viewModel.aspectRatio.coerceIn(MIN_ASPECT_RATIO, MAX_ASPECT_RATIO) bitmap?.let { bitmap -> - val aspectRatio = - (bitmap.width.toFloat() / bitmap.height.toFloat()) - // TODO: max ratio is actually equal to the viewport ratio - .coerceIn(MIN_ASPECT_RATIO, MAX_ASPECT_RATIO) Image( bitmap = bitmap.asImageBitmap(), contentDescription = null, @@ -129,7 +127,7 @@ private fun ShareouselCard(viewModel: ShareouselPreviewViewModel) { } ?: run { // TODO: look at ScrollableImagePreviewView.setLoading() - Box(modifier = Modifier.fillMaxHeight().aspectRatio(2f / 5f)) + Box(modifier = Modifier.fillMaxHeight().aspectRatio(aspectRatio)) } }, contentType = contentType, 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 index a245b3e3..9827fcd4 100644 --- a/java/src/com/android/intentresolver/contentpreview/payloadtoggle/ui/viewmodel/ShareouselPreviewViewModel.kt +++ b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/ui/viewmodel/ShareouselPreviewViewModel.kt @@ -29,6 +29,7 @@ data class ShareouselPreviewViewModel( val isSelected: Flow, /** Sets whether this preview has been selected by the user. */ val setSelected: suspend (Boolean) -> Unit, + val aspectRatio: Float, ) /** Type of the content being previewed. */ 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 index cf118934..8b2dd818 100644 --- a/java/src/com/android/intentresolver/contentpreview/payloadtoggle/ui/viewmodel/ShareouselViewModel.kt +++ b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/ui/viewmodel/ShareouselViewModel.kt @@ -117,6 +117,7 @@ object ShareouselViewModelModule { contentType = flowOf(ContentType.Image), // TODO: convert from metadata isSelected = previewInteractor.isSelected, setSelected = previewInteractor::setSelected, + aspectRatio = key.aspectRatio, ) }, ) 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 index 9b786b74..ff699373 100644 --- 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 @@ -20,12 +20,16 @@ package com.android.intentresolver.contentpreview.payloadtoggle.domain.interacto import android.database.MatrixCursor import android.net.Uri +import android.provider.MediaStore.MediaColumns.HEIGHT +import android.provider.MediaStore.MediaColumns.WIDTH +import android.util.Size import androidx.core.os.bundleOf import com.android.intentresolver.contentpreview.FileInfo import com.android.intentresolver.contentpreview.UriMetadataReader import com.android.intentresolver.contentpreview.payloadtoggle.data.repository.cursorPreviewsRepository import com.android.intentresolver.contentpreview.payloadtoggle.domain.model.CursorRow import com.android.intentresolver.contentpreview.payloadtoggle.shared.model.PreviewModel +import com.android.intentresolver.contentpreview.readSize import com.android.intentresolver.contentpreview.uriMetadataReader import com.android.intentresolver.util.KosmosTestScope import com.android.intentresolver.util.cursor.CursorView @@ -46,21 +50,32 @@ class CursorPreviewsInteractorTest { cursorStartPosition: Int = cursor.count() / 2, pageSize: Int = 16, maxLoadedPages: Int = 3, + cursorSizes: Map = emptyMap(), + metadatSizes: Map = emptyMap(), block: KosmosTestScope.(TestDeps) -> Unit, ) { + val metadataUriToSize = metadatSizes.mapKeys { uri(it.key) } with(Kosmos()) { this.focusedItemIndex = focusedItemIndex this.pageSize = pageSize this.maxLoadedPages = maxLoadedPages - uriMetadataReader = UriMetadataReader { - FileInfo.Builder(it).withPreviewUri(it).withMimeType("image/bitmap").build() - } + uriMetadataReader = + object : UriMetadataReader { + override fun getMetadata(uri: Uri): FileInfo = + FileInfo.Builder(uri) + .withPreviewUri(uri) + .withMimeType("image/bitmap") + .build() + + override fun readPreviewSize(uri: Uri): Size? = metadataUriToSize[uri] + } runTest { block( TestDeps( initialSelection, cursor, cursorStartPosition, + cursorSizes, ) ) } @@ -71,54 +86,66 @@ class CursorPreviewsInteractorTest { initialSelectionRange: Iterable, private val cursorRange: Iterable, private val cursorStartPosition: Int, + private val cursorSizes: Map, ) { val cursor: CursorView = - MatrixCursor(arrayOf("uri")) + MatrixCursor(arrayOf("uri", WIDTH, HEIGHT)) .apply { extras = bundleOf("position" to cursorStartPosition) for (i in cursorRange) { - newRow().add("uri", uri(i).toString()) + val size = cursorSizes[i] + addRow( + arrayOf( + uri(i).toString(), + size?.width?.toString(), + size?.height?.toString(), + ) + ) } } - .viewBy { getString(0)?.let { uriStr -> CursorRow(Uri.parse(uriStr), null) } } + .viewBy { getString(0)?.let { uriStr -> CursorRow(Uri.parse(uriStr), readSize()) } } val initialPreviews: List = 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 { - cursorPreviewsInteractor.launch(deps.cursor, deps.initialPreviews) - } - runCurrent() + fun initialCursorLoad() = + runTestWithDeps( + cursorSizes = mapOf(0 to (200 x 100)), + metadatSizes = mapOf(0 to (300 x 100), 3 to (400 x 100)) + ) { deps -> + backgroundScope.launch { + cursorPreviewsInteractor.launch(deps.cursor, deps.initialPreviews) + } + runCurrent() - assertThat(cursorPreviewsRepository.previewsModel.value).isNotNull() - assertThat(cursorPreviewsRepository.previewsModel.value!!.startIdx).isEqualTo(0) - assertThat(cursorPreviewsRepository.previewsModel.value!!.loadMoreLeft).isNull() - assertThat(cursorPreviewsRepository.previewsModel.value!!.loadMoreRight).isNull() - assertThat(cursorPreviewsRepository.previewsModel.value!!.previewModels) - .containsExactly( - PreviewModel( - uri = Uri.fromParts("scheme0", "ssp0", "fragment0"), - mimeType = "image/bitmap" - ), - PreviewModel( - uri = Uri.fromParts("scheme1", "ssp1", "fragment1"), - mimeType = "image/bitmap" - ), - PreviewModel( - uri = Uri.fromParts("scheme2", "ssp2", "fragment2"), - mimeType = "image/bitmap" - ), - PreviewModel( - uri = Uri.fromParts("scheme3", "ssp3", "fragment3"), - mimeType = "image/bitmap" - ), - ) - .inOrder() - } + assertThat(cursorPreviewsRepository.previewsModel.value).isNotNull() + assertThat(cursorPreviewsRepository.previewsModel.value!!.startIdx).isEqualTo(0) + assertThat(cursorPreviewsRepository.previewsModel.value!!.loadMoreLeft).isNull() + assertThat(cursorPreviewsRepository.previewsModel.value!!.loadMoreRight).isNull() + assertThat(cursorPreviewsRepository.previewsModel.value!!.previewModels) + .containsExactly( + PreviewModel( + uri = Uri.fromParts("scheme0", "ssp0", "fragment0"), + mimeType = "image/bitmap", + aspectRatio = 2f, + ), + PreviewModel( + uri = Uri.fromParts("scheme1", "ssp1", "fragment1"), + mimeType = "image/bitmap" + ), + PreviewModel( + uri = Uri.fromParts("scheme2", "ssp2", "fragment2"), + mimeType = "image/bitmap" + ), + PreviewModel( + uri = Uri.fromParts("scheme3", "ssp3", "fragment3"), + mimeType = "image/bitmap", + aspectRatio = 4f, + ), + ) + .inOrder() + } @Test fun loadMoreLeft_evictRight() = @@ -294,3 +321,7 @@ class CursorPreviewsInteractorTest { assertThat(cursorPreviewsRepository.previewsModel.value!!.loadMoreLeft).isNull() } } + +private fun uri(index: Int) = Uri.fromParts("scheme$index", "ssp$index", "fragment$index") + +private infix fun Int.x(height: Int) = Size(this, height) diff --git a/tests/unit/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/interactor/FetchPreviewsInteractorTest.kt b/tests/unit/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/interactor/FetchPreviewsInteractorTest.kt index c9f71f49..735bcb1d 100644 --- 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 @@ -20,6 +20,7 @@ package com.android.intentresolver.contentpreview.payloadtoggle.domain.interacto import android.database.MatrixCursor import android.net.Uri +import android.util.Size import androidx.core.os.bundleOf import com.android.intentresolver.contentpreview.FileInfo import com.android.intentresolver.contentpreview.UriMetadataReader @@ -53,17 +54,26 @@ class FetchPreviewsInteractorTest { cursorStartPosition: Int = cursor.count() / 2, pageSize: Int = 16, maxLoadedPages: Int = 3, + previewSizes: Map = emptyMap(), block: KosmosTestScope.() -> Unit, ) { + val previewUriToSize = previewSizes.mapKeys { uri(it.key) } with(Kosmos()) { fakeCursorResolver = FakeCursorResolver(cursorRange = cursor, cursorStartPosition = cursorStartPosition) payloadToggleCursorResolver = fakeCursorResolver contentUris = initialSelection.map { uri(it) } this.focusedItemIndex = focusedItemIndex - uriMetadataReader = UriMetadataReader { - FileInfo.Builder(it).withPreviewUri(it).withMimeType("image/bitmap").build() - } + uriMetadataReader = + object : UriMetadataReader { + override fun getMetadata(uri: Uri): FileInfo = + FileInfo.Builder(uri) + .withPreviewUri(uri) + .withMimeType("image/bitmap") + .build() + + override fun readPreviewSize(uri: Uri): Size? = previewUriToSize[uri] + } this.pageSize = pageSize this.maxLoadedPages = maxLoadedPages runKosmosTest { block() } @@ -94,30 +104,32 @@ class FetchPreviewsInteractorTest { } @Test - fun setsInitialPreviews() = runTest { - backgroundScope.launch { fetchPreviewsInteractor.activate() } - runCurrent() + fun setsInitialPreviews() = + runTest(previewSizes = mapOf(1 to Size(100, 50))) { + backgroundScope.launch { fetchPreviewsInteractor.activate() } + runCurrent() - assertThat(cursorPreviewsRepository.previewsModel.value) - .isEqualTo( - PreviewsModel( - previewModels = - setOf( - PreviewModel( - uri = Uri.fromParts("scheme1", "ssp1", "fragment1"), - mimeType = "image/bitmap", - ), - PreviewModel( - uri = Uri.fromParts("scheme2", "ssp2", "fragment2"), - mimeType = "image/bitmap", + assertThat(cursorPreviewsRepository.previewsModel.value) + .isEqualTo( + PreviewsModel( + previewModels = + setOf( + PreviewModel( + uri = Uri.fromParts("scheme1", "ssp1", "fragment1"), + mimeType = "image/bitmap", + aspectRatio = 2f + ), + PreviewModel( + uri = Uri.fromParts("scheme2", "ssp2", "fragment2"), + mimeType = "image/bitmap", + ), ), - ), - startIdx = 1, - loadMoreLeft = null, - loadMoreRight = null, + startIdx = 1, + loadMoreLeft = null, + loadMoreRight = null, + ) ) - ) - } + } @Test fun lookupCursorFromContentResolver() = runTest { -- cgit v1.2.3-59-g8ed1b From 49f45a029f030a455ff2d2dc3093bf6151af8842 Mon Sep 17 00:00:00 2001 From: Govinda Wasserman Date: Tue, 21 May 2024 09:12:47 -0400 Subject: Excludes shareousel image previews from back gesture Test: Manual test using share test BUG: 339715288 FIX: 339715288 Change-Id: Ida81b7eaa304c06a13059c224d8e9b2b5d468334 --- .../contentpreview/payloadtoggle/ui/composable/ShareouselComposable.kt | 2 ++ 1 file changed, 2 insertions(+) (limited to 'java/src') diff --git a/java/src/com/android/intentresolver/contentpreview/payloadtoggle/ui/composable/ShareouselComposable.kt b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/ui/composable/ShareouselComposable.kt index 85ad6ab3..38b9c6da 100644 --- a/java/src/com/android/intentresolver/contentpreview/payloadtoggle/ui/composable/ShareouselComposable.kt +++ b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/ui/composable/ShareouselComposable.kt @@ -35,6 +35,7 @@ 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.foundation.systemGestureExclusion import androidx.compose.material3.AssistChip import androidx.compose.material3.AssistChipDefaults import androidx.compose.material3.LocalContentColor @@ -98,6 +99,7 @@ private fun PreviewCarousel( modifier = Modifier.fillMaxWidth() .height(dimensionResource(R.dimen.chooser_preview_image_height_tall)) + .systemGestureExclusion() ) { items(previews.previewModels.toList(), key = { it.uri }) { model -> ShareouselCard(viewModel.preview(model)) -- cgit v1.2.3-59-g8ed1b From 97492540d111a1164c74a306b4d98aea11e543f3 Mon Sep 17 00:00:00 2001 From: Govinda Wasserman Date: Thu, 16 May 2024 16:36:56 -0400 Subject: Add ImageLoader with improved caching and cancelling BUG: 339261453 FIX: 339261453 Test: atest CachingImagePreviewImageLoaderTest Test: atest IntentResolver-tests-activity Test: manual test using ShareTest Change-Id: I316a9f683a26f2f6d8599e0da4a45ca3f4e740ab --- .../CachingImagePreviewImageLoader.kt | 110 ++++++++ .../contentpreview/ImageLoaderModule.kt | 6 + .../contentpreview/ThumbnailLoader.kt | 40 +++ .../ui/viewmodel/ShareouselViewModel.kt | 141 +++++------ .../intentresolver/ChooserActivityTest.java | 15 ++ .../contentpreview/FakeThumbnailLoader.kt | 36 +++ .../CachingImagePreviewImageLoaderTest.kt | 278 +++++++++++++++++++++ 7 files changed, 548 insertions(+), 78 deletions(-) create mode 100644 java/src/com/android/intentresolver/contentpreview/CachingImagePreviewImageLoader.kt create mode 100644 java/src/com/android/intentresolver/contentpreview/ThumbnailLoader.kt create mode 100644 tests/shared/src/com/android/intentresolver/contentpreview/FakeThumbnailLoader.kt create mode 100644 tests/unit/src/com/android/intentresolver/contentpreview/CachingImagePreviewImageLoaderTest.kt (limited to 'java/src') diff --git a/java/src/com/android/intentresolver/contentpreview/CachingImagePreviewImageLoader.kt b/java/src/com/android/intentresolver/contentpreview/CachingImagePreviewImageLoader.kt new file mode 100644 index 00000000..ce064cdf --- /dev/null +++ b/java/src/com/android/intentresolver/contentpreview/CachingImagePreviewImageLoader.kt @@ -0,0 +1,110 @@ +/* + * Copyright (C) 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.intentresolver.contentpreview + +import android.graphics.Bitmap +import android.net.Uri +import android.util.Log +import androidx.core.util.lruCache +import com.android.intentresolver.inject.Background +import com.android.intentresolver.inject.ViewModelOwned +import java.util.function.Consumer +import javax.inject.Inject +import javax.inject.Qualifier +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Deferred +import kotlinx.coroutines.async +import kotlinx.coroutines.ensureActive +import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Semaphore +import kotlinx.coroutines.sync.withPermit +import kotlinx.coroutines.withContext + +@Qualifier +@MustBeDocumented +@Retention(AnnotationRetention.BINARY) +annotation class PreviewMaxConcurrency + +/** + * Implementation of [ImageLoader]. + * + * Allows for cached or uncached loading of images and limits the number of concurrent requests. + * Requests are automatically cancelled when they are evicted from the cache. If image loading fails + * or the request is cancelled (e.g. by eviction), the returned [Bitmap] will be null. + */ +class CachingImagePreviewImageLoader +@Inject +constructor( + @ViewModelOwned private val scope: CoroutineScope, + @Background private val bgDispatcher: CoroutineDispatcher, + private val thumbnailLoader: ThumbnailLoader, + @PreviewCacheSize cacheSize: Int, + @PreviewMaxConcurrency maxConcurrency: Int, +) : ImageLoader { + + private val semaphore = Semaphore(maxConcurrency) + + private val cache = + lruCache( + maxSize = cacheSize, + create = { uri: Uri -> scope.async { loadUncachedImage(uri) } }, + onEntryRemoved = { evicted: Boolean, _, oldValue: Deferred, _ -> + // If removed due to eviction, cancel the coroutine, otherwise it is the + // responsibility + // of the caller of [cache.remove] to cancel the removed entry when done with it. + if (evicted) { + oldValue.cancel() + } + } + ) + + override fun loadImage(callerScope: CoroutineScope, uri: Uri, callback: Consumer) { + callerScope.launch { callback.accept(loadCachedImage(uri)) } + } + + override fun prePopulate(uris: List) { + uris.take(cache.maxSize()).map { cache[it] } + } + + override suspend fun invoke(uri: Uri, caching: Boolean): Bitmap? { + return if (caching) { + loadCachedImage(uri) + } else { + loadUncachedImage(uri) + } + } + + private suspend fun loadUncachedImage(uri: Uri): Bitmap? = + withContext(bgDispatcher) { + runCatching { semaphore.withPermit { thumbnailLoader.invoke(uri) } } + .onFailure { + ensureActive() + Log.d(TAG, "Failed to load preview for $uri", it) + } + .getOrNull() + } + + private suspend fun loadCachedImage(uri: Uri): Bitmap? = + // [Deferred#await] is called in a [runCatching] block to catch + // [CancellationExceptions]s so that they don't cancel the calling coroutine/scope. + runCatching { cache[uri].await() }.getOrNull() + + companion object { + private const val TAG = "CachingImgPrevLoader" + } +} diff --git a/java/src/com/android/intentresolver/contentpreview/ImageLoaderModule.kt b/java/src/com/android/intentresolver/contentpreview/ImageLoaderModule.kt index b861a24a..7035f765 100644 --- a/java/src/com/android/intentresolver/contentpreview/ImageLoaderModule.kt +++ b/java/src/com/android/intentresolver/contentpreview/ImageLoaderModule.kt @@ -33,6 +33,10 @@ interface ImageLoaderModule { @ActivityRetainedScoped fun imageLoader(previewImageLoader: ImagePreviewImageLoader): ImageLoader + @Binds + @ActivityRetainedScoped + fun thumbnailLoader(thumbnailLoader: ThumbnailLoaderImpl): ThumbnailLoader + companion object { @Provides @ThumbnailSize @@ -40,5 +44,7 @@ interface ImageLoaderModule { resources.getDimensionPixelSize(R.dimen.chooser_preview_image_max_dimen) @Provides @PreviewCacheSize fun cacheSize() = 16 + + @Provides @PreviewMaxConcurrency fun maxConcurrency() = 4 } } diff --git a/java/src/com/android/intentresolver/contentpreview/ThumbnailLoader.kt b/java/src/com/android/intentresolver/contentpreview/ThumbnailLoader.kt new file mode 100644 index 00000000..9f1d50da --- /dev/null +++ b/java/src/com/android/intentresolver/contentpreview/ThumbnailLoader.kt @@ -0,0 +1,40 @@ +/* + * Copyright (C) 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.intentresolver.contentpreview + +import android.content.ContentResolver +import android.graphics.Bitmap +import android.net.Uri +import android.util.Size +import javax.inject.Inject + +/** Interface for objects that can attempt load a [Bitmap] from a [Uri]. */ +interface ThumbnailLoader : suspend (Uri) -> Bitmap? + +/** Default implementation of [ThumbnailLoader]. */ +class ThumbnailLoaderImpl +@Inject +constructor( + private val contentResolver: ContentResolver, + @ThumbnailSize thumbnailSize: Int, +) : ThumbnailLoader { + + private val size = Size(thumbnailSize, thumbnailSize) + + override suspend fun invoke(uri: Uri): Bitmap = + contentResolver.loadThumbnail(uri, size, /* signal = */ null) +} diff --git a/java/src/com/android/intentresolver/contentpreview/payloadtoggle/ui/viewmodel/ShareouselViewModel.kt b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/ui/viewmodel/ShareouselViewModel.kt index 8b2dd818..1b9c231b 100644 --- a/java/src/com/android/intentresolver/contentpreview/payloadtoggle/ui/viewmodel/ShareouselViewModel.kt +++ b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/ui/viewmodel/ShareouselViewModel.kt @@ -15,11 +15,9 @@ */ package com.android.intentresolver.contentpreview.payloadtoggle.ui.viewmodel -import android.content.Context -import com.android.intentresolver.R +import com.android.intentresolver.contentpreview.CachingImagePreviewImageLoader 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.ChooserRequestInteractor import com.android.intentresolver.contentpreview.payloadtoggle.domain.interactor.CustomActionsInteractor @@ -27,14 +25,12 @@ import com.android.intentresolver.contentpreview.payloadtoggle.domain.interactor 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.Binds 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 @@ -42,7 +38,6 @@ 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( @@ -63,80 +58,70 @@ data class ShareouselViewModel( @Module @InstallIn(ViewModelComponent::class) -object ShareouselViewModelModule { - @Provides - fun create( - interactor: SelectablePreviewsInteractor, - @PayloadToggle imageLoader: ImageLoader, - actionsInteractor: CustomActionsInteractor, - headlineGenerator: HeadlineGenerator, - selectionInteractor: SelectionInteractor, - chooserRequestInteractor: ChooserRequestInteractor, - // 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) - } - }, - metadataText = chooserRequestInteractor.metadataText, - previews = keySet, - actions = - actionsInteractor.customActions.map { actions -> - actions.mapIndexedNotNull { i, model -> - val icon = model.icon - val label = model.label - if (icon == null && label.isBlank()) { - null - } else { - ActionChipViewModel( - label = label.toString(), - icon = model.icon, - onClicked = { model.performAction(i) }, - ) +interface ShareouselViewModelModule { + + @Binds @PayloadToggle fun imageLoader(imageLoader: CachingImagePreviewImageLoader): ImageLoader + + companion object { + @Provides + fun create( + interactor: SelectablePreviewsInteractor, + @PayloadToggle imageLoader: ImageLoader, + actionsInteractor: CustomActionsInteractor, + headlineGenerator: HeadlineGenerator, + selectionInteractor: SelectionInteractor, + chooserRequestInteractor: ChooserRequestInteractor, + // 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) } - } + }, + metadataText = chooserRequestInteractor.metadataText, + previews = keySet, + actions = + actionsInteractor.customActions.map { actions -> + actions.mapIndexedNotNull { i, model -> + val icon = model.icon + val label = model.label + if (icon == null && label.isBlank()) { + null + } else { + ActionChipViewModel( + label = label.toString(), + icon = model.icon, + onClicked = { model.performAction(i) }, + ) + } + } + }, + preview = { key -> + keySet.value?.maybeLoad(key) + val previewInteractor = interactor.preview(key) + ShareouselPreviewViewModel( + bitmap = flow { emit(key.previewUri?.let { imageLoader(it) }) }, + contentType = flowOf(ContentType.Image), // TODO: convert from metadata + isSelected = previewInteractor.isSelected, + setSelected = previewInteractor::setSelected, + aspectRatio = key.aspectRatio, + ) }, - preview = { key -> - keySet.value?.maybeLoad(key) - val previewInteractor = interactor.preview(key) - ShareouselPreviewViewModel( - bitmap = flow { emit(key.previewUri?.let { imageLoader(it) }) }, - contentType = flowOf(ContentType.Image), // TODO: convert from metadata - isSelected = previewInteractor.isSelected, - setSelected = previewInteractor::setSelected, - aspectRatio = key.aspectRatio, - ) - }, - ) + ) + } } - - @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) { diff --git a/tests/activity/src/com/android/intentresolver/ChooserActivityTest.java b/tests/activity/src/com/android/intentresolver/ChooserActivityTest.java index 66f7650d..a16e201b 100644 --- a/tests/activity/src/com/android/intentresolver/ChooserActivityTest.java +++ b/tests/activity/src/com/android/intentresolver/ChooserActivityTest.java @@ -117,8 +117,12 @@ import androidx.test.platform.app.InstrumentationRegistry; import androidx.test.rule.ActivityTestRule; import com.android.intentresolver.chooser.DisplayResolveInfo; +import com.android.intentresolver.contentpreview.FakeThumbnailLoader; import com.android.intentresolver.contentpreview.ImageLoader; import com.android.intentresolver.contentpreview.ImageLoaderModule; +import com.android.intentresolver.contentpreview.PreviewCacheSize; +import com.android.intentresolver.contentpreview.PreviewMaxConcurrency; +import com.android.intentresolver.contentpreview.ThumbnailLoader; import com.android.intentresolver.data.repository.FakeUserRepository; import com.android.intentresolver.data.repository.UserRepository; import com.android.intentresolver.data.repository.UserRepositoryModule; @@ -267,6 +271,17 @@ public class ChooserActivityTest { @BindValue final ImageLoader mImageLoader = mFakeImageLoader; + @BindValue + @PreviewCacheSize + int mPreviewCacheSize = 16; + + @BindValue + @PreviewMaxConcurrency + int mPreviewMaxConcurrency = 4; + + @BindValue + ThumbnailLoader mThumbnailLoader = new FakeThumbnailLoader(); + @Before public void setUp() { // TODO: use the other form of `adoptShellPermissionIdentity()` where we explicitly list the diff --git a/tests/shared/src/com/android/intentresolver/contentpreview/FakeThumbnailLoader.kt b/tests/shared/src/com/android/intentresolver/contentpreview/FakeThumbnailLoader.kt new file mode 100644 index 00000000..d3fdf17d --- /dev/null +++ b/tests/shared/src/com/android/intentresolver/contentpreview/FakeThumbnailLoader.kt @@ -0,0 +1,36 @@ +/* + * Copyright (C) 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.intentresolver.contentpreview + +import android.graphics.Bitmap +import android.net.Uri + +/** Fake implementation of [ThumbnailLoader] for use in testing. */ +class FakeThumbnailLoader : ThumbnailLoader { + + val fakeInvoke = mutableMapOf Bitmap?>() + val invokeCalls = mutableListOf() + var unfinishedInvokeCount = 0 + + override suspend fun invoke(uri: Uri): Bitmap? { + invokeCalls.add(uri) + unfinishedInvokeCount++ + val result = fakeInvoke[uri]?.invoke() + unfinishedInvokeCount-- + return result + } +} diff --git a/tests/unit/src/com/android/intentresolver/contentpreview/CachingImagePreviewImageLoaderTest.kt b/tests/unit/src/com/android/intentresolver/contentpreview/CachingImagePreviewImageLoaderTest.kt new file mode 100644 index 00000000..331f9f64 --- /dev/null +++ b/tests/unit/src/com/android/intentresolver/contentpreview/CachingImagePreviewImageLoaderTest.kt @@ -0,0 +1,278 @@ +/* + * Copyright (C) 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.intentresolver.contentpreview + +import android.graphics.Bitmap +import android.net.Uri +import com.google.common.truth.Truth.assertThat +import kotlin.math.ceil +import kotlin.math.roundToInt +import kotlin.time.Duration.Companion.milliseconds +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.delay +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.advanceTimeBy +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import org.junit.Test + +@OptIn(ExperimentalCoroutinesApi::class) +class CachingImagePreviewImageLoaderTest { + + private val testDispatcher = StandardTestDispatcher() + private val testScope = TestScope(testDispatcher) + private val testJobTime = 100.milliseconds + private val testCacheSize = 4 + private val testMaxConcurrency = 2 + private val testTimeToFillCache = + testJobTime * ceil((testCacheSize).toFloat() / testMaxConcurrency.toFloat()).roundToInt() + private val testUris = + List(5) { Uri.fromParts("TestScheme$it", "TestSsp$it", "TestFragment$it") } + private val testTimeToLoadAllUris = + testJobTime * ceil((testUris.size).toFloat() / testMaxConcurrency.toFloat()).roundToInt() + private val testBitmap = Bitmap.createBitmap(10, 10, Bitmap.Config.ALPHA_8) + private val fakeThumbnailLoader = + FakeThumbnailLoader().apply { + testUris.forEach { + fakeInvoke[it] = { + delay(testJobTime) + testBitmap + } + } + } + + private val imageLoader = + CachingImagePreviewImageLoader( + scope = testScope.backgroundScope, + bgDispatcher = testDispatcher, + thumbnailLoader = fakeThumbnailLoader, + cacheSize = testCacheSize, + maxConcurrency = testMaxConcurrency, + ) + + @Test + fun loadImage_notCached_callsThumbnailLoader() = + testScope.runTest { + // Arrange + var result: Bitmap? = null + + // Act + imageLoader.loadImage(testScope, testUris[0]) { result = it } + advanceTimeBy(testJobTime) + runCurrent() + + // Assert + assertThat(fakeThumbnailLoader.invokeCalls).containsExactly(testUris[0]) + assertThat(result).isSameInstanceAs(testBitmap) + } + + @Test + fun loadImage_cached_usesCachedValue() = + testScope.runTest { + // Arrange + imageLoader.loadImage(testScope, testUris[0]) {} + advanceTimeBy(testJobTime) + runCurrent() + fakeThumbnailLoader.invokeCalls.clear() + var result: Bitmap? = null + + // Act + imageLoader.loadImage(testScope, testUris[0]) { result = it } + advanceTimeBy(testJobTime) + runCurrent() + + // Assert + assertThat(fakeThumbnailLoader.invokeCalls).isEmpty() + assertThat(result).isSameInstanceAs(testBitmap) + } + + @Test + fun loadImage_error_returnsNull() = + testScope.runTest { + // Arrange + fakeThumbnailLoader.fakeInvoke[testUris[0]] = { + delay(testJobTime) + throw RuntimeException("Test exception") + } + var result: Bitmap? = testBitmap + + // Act + imageLoader.loadImage(testScope, testUris[0]) { result = it } + advanceTimeBy(testJobTime) + runCurrent() + + // Assert + assertThat(fakeThumbnailLoader.invokeCalls).containsExactly(testUris[0]) + assertThat(result).isNull() + } + + @Test + fun loadImage_uncached_limitsConcurrency() = + testScope.runTest { + // Arrange + val results = mutableListOf() + assertThat(testUris.size).isGreaterThan(testMaxConcurrency) + + // Act + testUris.take(testMaxConcurrency + 1).forEach { uri -> + imageLoader.loadImage(testScope, uri) { results.add(it) } + } + + // Assert + assertThat(results).isEmpty() + advanceTimeBy(testJobTime) + runCurrent() + assertThat(results).hasSize(testMaxConcurrency) + advanceTimeBy(testJobTime) + runCurrent() + assertThat(results).hasSize(testMaxConcurrency + 1) + assertThat(results) + .containsExactlyElementsIn(List(testMaxConcurrency + 1) { testBitmap }) + } + + @Test + fun loadImage_cacheEvicted_cancelsLoadAndReturnsNull() = + testScope.runTest { + // Arrange + val results = MutableList(testUris.size) { null } + assertThat(testUris.size).isGreaterThan(testCacheSize) + + // Act + imageLoader.loadImage(testScope, testUris[0]) { results[0] = it } + runCurrent() + testUris.indices.drop(1).take(testCacheSize).forEach { i -> + imageLoader.loadImage(testScope, testUris[i]) { results[i] = it } + } + advanceTimeBy(testTimeToFillCache) + runCurrent() + + // Assert + assertThat(fakeThumbnailLoader.invokeCalls).containsExactlyElementsIn(testUris) + assertThat(results) + .containsExactlyElementsIn( + List(testUris.size) { index -> if (index == 0) null else testBitmap } + ) + .inOrder() + assertThat(fakeThumbnailLoader.unfinishedInvokeCount).isEqualTo(1) + } + + @Test + fun prePopulate_fillsCache() = + testScope.runTest { + // Arrange + val fullCacheUris = testUris.take(testCacheSize) + assertThat(fullCacheUris).hasSize(testCacheSize) + + // Act + imageLoader.prePopulate(fullCacheUris) + advanceTimeBy(testTimeToFillCache) + runCurrent() + + // Assert + assertThat(fakeThumbnailLoader.invokeCalls).containsExactlyElementsIn(fullCacheUris) + + // Act + fakeThumbnailLoader.invokeCalls.clear() + imageLoader.prePopulate(fullCacheUris) + advanceTimeBy(testTimeToFillCache) + runCurrent() + + // Assert + assertThat(fakeThumbnailLoader.invokeCalls).isEmpty() + } + + @Test + fun prePopulate_greaterThanCacheSize_fillsCacheThenDropsRemaining() = + testScope.runTest { + // Arrange + assertThat(testUris.size).isGreaterThan(testCacheSize) + + // Act + imageLoader.prePopulate(testUris) + advanceTimeBy(testTimeToLoadAllUris) + runCurrent() + + // Assert + assertThat(fakeThumbnailLoader.invokeCalls) + .containsExactlyElementsIn(testUris.take(testCacheSize)) + + // Act + fakeThumbnailLoader.invokeCalls.clear() + imageLoader.prePopulate(testUris) + advanceTimeBy(testTimeToLoadAllUris) + runCurrent() + + // Assert + assertThat(fakeThumbnailLoader.invokeCalls).isEmpty() + } + + @Test + fun prePopulate_fewerThatCacheSize_loadsTheGiven() = + testScope.runTest { + // Arrange + val unfilledCacheUris = testUris.take(testMaxConcurrency) + assertThat(unfilledCacheUris.size).isLessThan(testCacheSize) + + // Act + imageLoader.prePopulate(unfilledCacheUris) + advanceTimeBy(testJobTime) + runCurrent() + + // Assert + assertThat(fakeThumbnailLoader.invokeCalls).containsExactlyElementsIn(unfilledCacheUris) + + // Act + fakeThumbnailLoader.invokeCalls.clear() + imageLoader.prePopulate(unfilledCacheUris) + advanceTimeBy(testJobTime) + runCurrent() + + // Assert + assertThat(fakeThumbnailLoader.invokeCalls).isEmpty() + } + + @Test + fun invoke_uncached_alwaysCallsTheThumbnailLoader() = + testScope.runTest { + // Arrange + + // Act + imageLoader.invoke(testUris[0], caching = false) + imageLoader.invoke(testUris[0], caching = false) + advanceTimeBy(testJobTime) + runCurrent() + + // Assert + assertThat(fakeThumbnailLoader.invokeCalls).containsExactly(testUris[0], testUris[0]) + } + + @Test + fun invoke_cached_usesTheCacheWhenPossible() = + testScope.runTest { + // Arrange + + // Act + imageLoader.invoke(testUris[0], caching = true) + imageLoader.invoke(testUris[0], caching = true) + advanceTimeBy(testJobTime) + runCurrent() + + // Assert + assertThat(fakeThumbnailLoader.invokeCalls).containsExactly(testUris[0]) + } +} -- cgit v1.2.3-59-g8ed1b From ccaaf8c3a2e491942f9f7d760ba15d5f07fb80d5 Mon Sep 17 00:00:00 2001 From: Matt Casey Date: Wed, 22 May 2024 13:52:13 +0000 Subject: Don't allow the only selected item to be unselected. Bug: 341923156 Test: atest SelectionInteractorTest Flag: None Change-Id: I5a67d6ad6b628ac5fa73c70e2ca2b8c6d04724ae --- .../domain/interactor/SelectionInteractor.kt | 4 +- .../domain/interactor/SelectionInteractorTest.kt | 67 ++++++++++++++++++++++ 2 files changed, 70 insertions(+), 1 deletion(-) create mode 100644 tests/unit/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/interactor/SelectionInteractorTest.kt (limited to 'java/src') 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 index a570f36e..802e58a2 100644 --- a/java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/interactor/SelectionInteractor.kt +++ b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/interactor/SelectionInteractor.kt @@ -44,7 +44,9 @@ constructor( } fun unselect(model: PreviewModel) { - updateChooserRequest(selectionsRepo.selections.updateAndGet { it - model }) + if (selectionsRepo.selections.value.size > 1) { + updateChooserRequest(selectionsRepo.selections.updateAndGet { it - model }) + } } private fun updateChooserRequest(selections: Set) { diff --git a/tests/unit/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/interactor/SelectionInteractorTest.kt b/tests/unit/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/interactor/SelectionInteractorTest.kt new file mode 100644 index 00000000..a64807b7 --- /dev/null +++ b/tests/unit/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/interactor/SelectionInteractorTest.kt @@ -0,0 +1,67 @@ +/* + * Copyright (C) 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.intentresolver.contentpreview.payloadtoggle.domain.interactor + +import android.content.Intent +import android.net.Uri +import com.android.intentresolver.contentpreview.payloadtoggle.data.repository.previewSelectionsRepository +import com.android.intentresolver.contentpreview.payloadtoggle.shared.model.PreviewModel +import com.android.intentresolver.util.runKosmosTest +import com.google.common.truth.Truth.assertThat +import org.junit.Test + +class SelectionInteractorTest { + @Test + fun singleSelection_removalPrevented() = runKosmosTest { + val initialPreview = + PreviewModel(uri = Uri.fromParts("scheme", "ssp", "fragment"), mimeType = null) + previewSelectionsRepository.selections.value = setOf(initialPreview) + + val underTest = + SelectionInteractor( + previewSelectionsRepository, + { Intent() }, + updateTargetIntentInteractor + ) + + assertThat(underTest.selections.value).isEqualTo(setOf(initialPreview)) + + // Shouldn't do anything! + underTest.unselect(initialPreview) + + assertThat(underTest.selections.value).isEqualTo(setOf(initialPreview)) + } + + @Test + fun multipleSelections_removalAllowed() = runKosmosTest { + val first = PreviewModel(uri = Uri.fromParts("scheme", "ssp", "fragment"), mimeType = null) + val second = + PreviewModel(uri = Uri.fromParts("scheme2", "ssp2", "fragment2"), mimeType = null) + previewSelectionsRepository.selections.value = setOf(first, second) + + val underTest = + SelectionInteractor( + previewSelectionsRepository, + { Intent() }, + updateTargetIntentInteractor + ) + + underTest.unselect(first) + + assertThat(underTest.selections.value).isEqualTo(setOf(second)) + } +} -- cgit v1.2.3-59-g8ed1b From 5129b5fe7cee819811d0fd166176e54ca139cd82 Mon Sep 17 00:00:00 2001 From: Matt Casey Date: Wed, 22 May 2024 17:46:27 +0000 Subject: Content description fixes for shareousel - Remove hard-coded descriptions. - Describe items based upon content type. - Switch from clickable to toggleable to get accessibility state descriptions. Bug: 328791503 Test: Manual testing with talkback. Flag: android.service.chooser.chooser_payload_toggling Change-Id: Ia5fe1b08015a1f7a9bd0a386b5318f871dde33be --- java/res/values/strings.xml | 12 +++++++++++- .../ui/composable/ShareouselCardComposable.kt | 4 ++-- .../payloadtoggle/ui/composable/ShareouselComposable.kt | 17 +++++++++++++++-- 3 files changed, 28 insertions(+), 5 deletions(-) (limited to 'java/src') diff --git a/java/res/values/strings.xml b/java/res/values/strings.xml index 32c61327..c026ee59 100644 --- a/java/res/values/strings.xml +++ b/java/res/values/strings.xml @@ -322,7 +322,17 @@ Include link - Pinned + + + Selectable image + + Selectable video + + Selectable item diff --git a/java/src/com/android/intentresolver/contentpreview/payloadtoggle/ui/composable/ShareouselCardComposable.kt b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/ui/composable/ShareouselCardComposable.kt index f33558c7..0efaa3bb 100644 --- a/java/src/com/android/intentresolver/contentpreview/payloadtoggle/ui/composable/ShareouselCardComposable.kt +++ b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/ui/composable/ShareouselCardComposable.kt @@ -58,7 +58,7 @@ fun ShareouselCard( private fun AnimationIcon(modifier: Modifier = Modifier) { Icon( painterResource(id = R.drawable.ic_play_circle_filled_24px), - "animating", + contentDescription = null, // Video attribute described at a higher level. tint = Color.White, modifier = Modifier.size(20.dp).then(modifier) ) @@ -71,7 +71,7 @@ private fun SelectionIcon(selected: Boolean, modifier: Modifier = Modifier) { Icon( painter = painterResource(id = R.drawable.checkbox), tint = Color.White, - contentDescription = "selected", + contentDescription = null, modifier = Modifier.shadow( elevation = 50.dp, diff --git a/java/src/com/android/intentresolver/contentpreview/payloadtoggle/ui/composable/ShareouselComposable.kt b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/ui/composable/ShareouselComposable.kt index 85ad6ab3..32aa7eee 100644 --- a/java/src/com/android/intentresolver/contentpreview/payloadtoggle/ui/composable/ShareouselComposable.kt +++ b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/ui/composable/ShareouselComposable.kt @@ -18,7 +18,6 @@ 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 import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -34,6 +33,7 @@ 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.selection.toggleable import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.AssistChip import androidx.compose.material3.AssistChipDefaults @@ -49,6 +49,9 @@ import androidx.compose.ui.graphics.ColorFilter import androidx.compose.ui.graphics.asImageBitmap import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.res.dimensionResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.android.intentresolver.R @@ -113,6 +116,12 @@ private fun ShareouselCard(viewModel: ShareouselPreviewViewModel) { viewModel.contentType.collectAsStateWithLifecycle(initialValue = ContentType.Image) val borderColor = MaterialTheme.colorScheme.primary val scope = rememberCoroutineScope() + val contentDescription = + when (contentType) { + ContentType.Image -> stringResource(R.string.selectable_image) + ContentType.Video -> stringResource(R.string.selectable_video) + else -> stringResource(R.string.selectable_item) + } ShareouselCard( image = { // TODO: max ratio is actually equal to the viewport ratio @@ -140,8 +149,12 @@ private fun ShareouselCard(viewModel: ShareouselPreviewViewModel) { shape = RoundedCornerShape(size = 12.dp), ) } + .semantics { this.contentDescription = contentDescription } .clip(RoundedCornerShape(size = 12.dp)) - .clickable { scope.launch { viewModel.setSelected(!selected) } }, + .toggleable( + value = selected, + onValueChange = { scope.launch { viewModel.setSelected(it) } }, + ) ) } -- cgit v1.2.3-59-g8ed1b From 65fb9fe3c687800a57629cf8a85a2f69cb42eb71 Mon Sep 17 00:00:00 2001 From: Matt Casey Date: Wed, 22 May 2024 20:11:26 +0000 Subject: Make check mark color onPrimary Provide sufficient contrast. Bug: 329022504 Flag: android.service.chooser.chooser_payload_toggling Test: Manual testing with accessiblity scanner Change-Id: Ic82231b37c23e59c8a1a03055f6196a7a49abbb5 --- .../payloadtoggle/ui/composable/ShareouselCardComposable.kt | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) (limited to 'java/src') diff --git a/java/src/com/android/intentresolver/contentpreview/payloadtoggle/ui/composable/ShareouselCardComposable.kt b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/ui/composable/ShareouselCardComposable.kt index 0efaa3bb..c2330ad8 100644 --- a/java/src/com/android/intentresolver/contentpreview/payloadtoggle/ui/composable/ShareouselCardComposable.kt +++ b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/ui/composable/ShareouselCardComposable.kt @@ -70,7 +70,7 @@ private fun SelectionIcon(selected: Boolean, modifier: Modifier = Modifier) { val bgColor = MaterialTheme.colorScheme.primary Icon( painter = painterResource(id = R.drawable.checkbox), - tint = Color.White, + tint = MaterialTheme.colorScheme.onPrimary, contentDescription = null, modifier = Modifier.shadow( @@ -92,10 +92,14 @@ private fun SelectionIcon(selected: Boolean, modifier: Modifier = Modifier) { spotColor = Color(0x40000000), ambientColor = Color(0x40000000), ) - .border(width = 2.dp, color = Color(0xFFFFFFFF), shape = CircleShape) + .border( + width = 2.dp, + color = MaterialTheme.colorScheme.onPrimary, + shape = CircleShape + ) .clip(CircleShape) .size(20.dp) - .background(color = Color(0x7DC4C4C4)) + .background(color = MaterialTheme.colorScheme.onPrimary.copy(alpha = 0.5f)) .then(modifier) ) } -- cgit v1.2.3-59-g8ed1b From 1dc90519a76292138c346a5622ac5571713fc030 Mon Sep 17 00:00:00 2001 From: Govinda Wasserman Date: Wed, 22 May 2024 09:41:34 -0400 Subject: Preload full set of preview image metadata pages This is the first step in mitigating the bottleneck of slow metadata loading by proactively loading pages to the max window size. Test: atest CursorPreviewsInteractorTest FetchPreviewsInteractorTest Test: manual test using ShareTest with high metadata latency BUG: 341923886 Flag: android.service.chooser.chooser_payload_toggling Change-Id: I1192e7bdf7db77573990745dd5b05d7ef86d7f20 --- .../domain/interactor/CursorPreviewsInteractor.kt | 36 ++++++- .../interactor/CursorPreviewsInteractorTest.kt | 117 +++++---------------- .../interactor/FetchPreviewsInteractorTest.kt | 65 +----------- 3 files changed, 61 insertions(+), 157 deletions(-) (limited to 'java/src') 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 index 97b087e1..f02834e0 100644 --- a/java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/interactor/CursorPreviewsInteractor.kt +++ b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/interactor/CursorPreviewsInteractor.kt @@ -76,10 +76,42 @@ constructor( .toMap(ConcurrentHashMap()) val pagedCursor: PagedCursor = uriCursor.paged(pageSize) val startPosition = uriCursor.extras?.getInt(POSITION, 0) ?: 0 - val state = readInitialState(pagedCursor, startPosition, unclaimedRecords) + val state = + loadToMaxPages( + initialState = readInitialState(pagedCursor, startPosition, unclaimedRecords), + pagedCursor = pagedCursor, + unclaimedRecords = unclaimedRecords, + ) processLoadRequests(state, pagedCursor, unclaimedRecords) } + private suspend fun loadToMaxPages( + initialState: CursorWindow, + pagedCursor: PagedCursor, + unclaimedRecords: MutableUnclaimedMap, + ): CursorWindow { + var state = initialState + val startPageNum = state.firstLoadedPageNum + while ((state.hasMoreLeft || state.hasMoreRight) && state.numLoadedPages < maxLoadedPages) { + interactor.setPreviews( + previewsByKey = state.merged.values.toSet(), + startIndex = startPageNum, + hasMoreLeft = state.hasMoreLeft, + hasMoreRight = state.hasMoreRight, + ) + val loadedLeft = startPageNum - state.firstLoadedPageNum + val loadedRight = state.lastLoadedPageNum - startPageNum + state = + when { + state.hasMoreLeft && loadedLeft < loadedRight -> + state.loadMoreLeft(pagedCursor, unclaimedRecords) + state.hasMoreRight -> state.loadMoreRight(pagedCursor, unclaimedRecords) + else -> state.loadMoreLeft(pagedCursor, unclaimedRecords) + } + } + return state + } + /** Loop forever, processing any loading requests from the UI and updating local cache. */ private suspend fun processLoadRequests( initialState: CursorWindow, @@ -301,5 +333,5 @@ annotation class MaxLoadedPages object ShareouselConstants { @Provides @PageSize fun pageSize(): Int = 16 - @Provides @MaxLoadedPages fun maxLoadedPages(): Int = 3 + @Provides @MaxLoadedPages fun maxLoadedPages(): Int = 8 } 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 index ff699373..45e456e0 100644 --- 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 @@ -111,8 +111,12 @@ class CursorPreviewsInteractorTest { @Test fun initialCursorLoad() = runTestWithDeps( + cursor = (0 until 10), + cursorStartPosition = 2, cursorSizes = mapOf(0 to (200 x 100)), - metadatSizes = mapOf(0 to (300 x 100), 3 to (400 x 100)) + metadatSizes = mapOf(0 to (300 x 100), 3 to (400 x 100)), + pageSize = 2, + maxLoadedPages = 3, ) { deps -> backgroundScope.launch { cursorPreviewsInteractor.launch(deps.cursor, deps.initialPreviews) @@ -120,31 +124,27 @@ class CursorPreviewsInteractorTest { runCurrent() assertThat(cursorPreviewsRepository.previewsModel.value).isNotNull() - assertThat(cursorPreviewsRepository.previewsModel.value!!.startIdx).isEqualTo(0) - assertThat(cursorPreviewsRepository.previewsModel.value!!.loadMoreLeft).isNull() - assertThat(cursorPreviewsRepository.previewsModel.value!!.loadMoreRight).isNull() - assertThat(cursorPreviewsRepository.previewsModel.value!!.previewModels) - .containsExactly( - PreviewModel( - uri = Uri.fromParts("scheme0", "ssp0", "fragment0"), - mimeType = "image/bitmap", - aspectRatio = 2f, - ), - PreviewModel( - uri = Uri.fromParts("scheme1", "ssp1", "fragment1"), - mimeType = "image/bitmap" - ), - PreviewModel( - uri = Uri.fromParts("scheme2", "ssp2", "fragment2"), - mimeType = "image/bitmap" - ), - PreviewModel( - uri = Uri.fromParts("scheme3", "ssp3", "fragment3"), - mimeType = "image/bitmap", - aspectRatio = 4f, - ), - ) - .inOrder() + with(cursorPreviewsRepository.previewsModel.value!!) { + assertThat(previewModels) + .containsExactlyElementsIn( + List(6) { + PreviewModel( + uri = Uri.fromParts("scheme$it", "ssp$it", "fragment$it"), + mimeType = "image/bitmap", + aspectRatio = + when (it) { + 0 -> 2f + 3 -> 4f + else -> 1f + } + ) + } + ) + .inOrder() + assertThat(startIdx).isEqualTo(0) + assertThat(loadMoreLeft).isNull() + assertThat(loadMoreRight).isNotNull() + } } @Test @@ -180,39 +180,6 @@ class CursorPreviewsInteractorTest { assertThat(cursorPreviewsRepository.previewsModel.value!!.loadMoreLeft).isNull() } - @Test - fun loadMoreLeft_keepRight() = - runTestWithDeps( - initialSelection = listOf(24), - cursor = (0 until 48), - pageSize = 16, - maxLoadedPages = 2, - ) { deps -> - backgroundScope.launch { - cursorPreviewsInteractor.launch(deps.cursor, deps.initialPreviews) - } - runCurrent() - - assertThat(cursorPreviewsRepository.previewsModel.value).isNotNull() - assertThat(cursorPreviewsRepository.previewsModel.value!!.previewModels).hasSize(16) - assertThat(cursorPreviewsRepository.previewsModel.value!!.previewModels.first().uri) - .isEqualTo(Uri.fromParts("scheme16", "ssp16", "fragment16")) - assertThat(cursorPreviewsRepository.previewsModel.value!!.previewModels.last().uri) - .isEqualTo(Uri.fromParts("scheme31", "ssp31", "fragment31")) - assertThat(cursorPreviewsRepository.previewsModel.value!!.loadMoreLeft).isNotNull() - - cursorPreviewsRepository.previewsModel.value!!.loadMoreLeft!!.invoke() - runCurrent() - - assertThat(cursorPreviewsRepository.previewsModel.value).isNotNull() - assertThat(cursorPreviewsRepository.previewsModel.value!!.previewModels).hasSize(32) - assertThat(cursorPreviewsRepository.previewsModel.value!!.previewModels.first().uri) - .isEqualTo(Uri.fromParts("scheme0", "ssp0", "fragment0")) - assertThat(cursorPreviewsRepository.previewsModel.value!!.previewModels.last().uri) - .isEqualTo(Uri.fromParts("scheme31", "ssp31", "fragment31")) - assertThat(cursorPreviewsRepository.previewsModel.value!!.loadMoreLeft).isNull() - } - @Test fun loadMoreRight_evictLeft() = runTestWithDeps( @@ -245,38 +212,6 @@ class CursorPreviewsInteractorTest { .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 { - cursorPreviewsInteractor.launch(deps.cursor, deps.initialPreviews) - } - runCurrent() - - assertThat(cursorPreviewsRepository.previewsModel.value).isNotNull() - assertThat(cursorPreviewsRepository.previewsModel.value!!.previewModels).hasSize(16) - assertThat(cursorPreviewsRepository.previewsModel.value!!.previewModels.first().uri) - .isEqualTo(Uri.fromParts("scheme16", "ssp16", "fragment16")) - assertThat(cursorPreviewsRepository.previewsModel.value!!.previewModels.last().uri) - .isEqualTo(Uri.fromParts("scheme31", "ssp31", "fragment31")) - assertThat(cursorPreviewsRepository.previewsModel.value!!.loadMoreRight).isNotNull() - - cursorPreviewsRepository.previewsModel.value!!.loadMoreRight!!.invoke() - runCurrent() - - assertThat(cursorPreviewsRepository.previewsModel.value).isNotNull() - assertThat(cursorPreviewsRepository.previewsModel.value!!.previewModels).hasSize(32) - assertThat(cursorPreviewsRepository.previewsModel.value!!.previewModels.first().uri) - .isEqualTo(Uri.fromParts("scheme16", "ssp16", "fragment16")) - assertThat(cursorPreviewsRepository.previewsModel.value!!.previewModels.last().uri) - .isEqualTo(Uri.fromParts("scheme47", "ssp47", "fragment47")) - } - @Test fun noMoreRight_appendUnclaimedFromInitialSelection() = runTestWithDeps( 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 index 735bcb1d..146892d0 100644 --- 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 @@ -53,7 +53,7 @@ class FetchPreviewsInteractorTest { cursor: Iterable = (0 until 4), cursorStartPosition: Int = cursor.count() / 2, pageSize: Int = 16, - maxLoadedPages: Int = 3, + maxLoadedPages: Int = 8, previewSizes: Map = emptyMap(), block: KosmosTestScope.() -> Unit, ) { @@ -201,38 +201,6 @@ class FetchPreviewsInteractorTest { } } - @Test - fun loadMoreLeft_keepRight() = - runTest( - initialSelection = listOf(24), - cursor = (0 until 48), - pageSize = 16, - maxLoadedPages = 2, - ) { - backgroundScope.launch { fetchPreviewsInteractor.activate() } - fakeCursorResolver.complete() - runCurrent() - - assertThat(cursorPreviewsRepository.previewsModel.value).isNotNull() - assertThat(cursorPreviewsRepository.previewsModel.value!!.previewModels).hasSize(16) - assertThat(cursorPreviewsRepository.previewsModel.value!!.previewModels.first().uri) - .isEqualTo(Uri.fromParts("scheme16", "ssp16", "fragment16")) - assertThat(cursorPreviewsRepository.previewsModel.value!!.previewModels.last().uri) - .isEqualTo(Uri.fromParts("scheme31", "ssp31", "fragment31")) - assertThat(cursorPreviewsRepository.previewsModel.value!!.loadMoreLeft).isNotNull() - - cursorPreviewsRepository.previewsModel.value!!.loadMoreLeft!!.invoke() - runCurrent() - - assertThat(cursorPreviewsRepository.previewsModel.value).isNotNull() - assertThat(cursorPreviewsRepository.previewsModel.value!!.previewModels).hasSize(32) - assertThat(cursorPreviewsRepository.previewsModel.value!!.previewModels.first().uri) - .isEqualTo(Uri.fromParts("scheme0", "ssp0", "fragment0")) - assertThat(cursorPreviewsRepository.previewsModel.value!!.previewModels.last().uri) - .isEqualTo(Uri.fromParts("scheme31", "ssp31", "fragment31")) - assertThat(cursorPreviewsRepository.previewsModel.value!!.loadMoreLeft).isNull() - } - @Test fun loadMoreRight_evictLeft() = runTest( @@ -264,37 +232,6 @@ class FetchPreviewsInteractorTest { .isEqualTo(Uri.fromParts("scheme47", "ssp47", "fragment47")) } - @Test - fun loadMoreRight_keepLeft() = - runTest( - initialSelection = listOf(24), - cursor = (0 until 48), - pageSize = 16, - maxLoadedPages = 2, - ) { - backgroundScope.launch { fetchPreviewsInteractor.activate() } - fakeCursorResolver.complete() - runCurrent() - - assertThat(cursorPreviewsRepository.previewsModel.value).isNotNull() - assertThat(cursorPreviewsRepository.previewsModel.value!!.previewModels).hasSize(16) - assertThat(cursorPreviewsRepository.previewsModel.value!!.previewModels.first().uri) - .isEqualTo(Uri.fromParts("scheme16", "ssp16", "fragment16")) - assertThat(cursorPreviewsRepository.previewsModel.value!!.previewModels.last().uri) - .isEqualTo(Uri.fromParts("scheme31", "ssp31", "fragment31")) - assertThat(cursorPreviewsRepository.previewsModel.value!!.loadMoreRight).isNotNull() - - cursorPreviewsRepository.previewsModel.value!!.loadMoreRight!!.invoke() - runCurrent() - - assertThat(cursorPreviewsRepository.previewsModel.value).isNotNull() - assertThat(cursorPreviewsRepository.previewsModel.value!!.previewModels).hasSize(32) - assertThat(cursorPreviewsRepository.previewsModel.value!!.previewModels.first().uri) - .isEqualTo(Uri.fromParts("scheme16", "ssp16", "fragment16")) - assertThat(cursorPreviewsRepository.previewsModel.value!!.previewModels.last().uri) - .isEqualTo(Uri.fromParts("scheme47", "ssp47", "fragment47")) - } - @Test fun noMoreRight_appendUnclaimedFromInitialSelection() = runTest( -- cgit v1.2.3-59-g8ed1b From 7bd1caf07c36f117bccd8bd462e3b9491b91acd5 Mon Sep 17 00:00:00 2001 From: Matt Casey Date: Wed, 22 May 2024 20:46:57 +0000 Subject: Honor content types in shareousel Test: atest ShareouselViewModelTest Test: Manual testing with ShareTest Flag: android.service.chooser.chooser_payload_toggling Bug: 341923652 Change-Id: I94b6a7273a708e20d145f4b56e91c41fc120ce58 --- .../domain/interactor/SelectionInteractor.kt | 30 ++++++++++++ .../payloadtoggle/shared/ContentType.kt | 24 ++++++++++ .../ui/composable/ShareouselCardComposable.kt | 2 +- .../ui/composable/ShareouselComposable.kt | 8 ++-- .../ui/viewmodel/ShareouselPreviewViewModel.kt | 10 +--- .../ui/viewmodel/ShareouselViewModel.kt | 18 +++++-- .../interactor/PayloadToggleInteractorKosmos.kt | 2 + .../domain/interactor/SelectionInteractorTest.kt | 7 ++- .../ui/viewmodel/ShareouselViewModelTest.kt | 56 ++++++++++++++++++---- 9 files changed, 128 insertions(+), 29 deletions(-) create mode 100644 java/src/com/android/intentresolver/contentpreview/payloadtoggle/shared/ContentType.kt (limited to 'java/src') 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 index 802e58a2..e99aa50c 100644 --- a/java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/interactor/SelectionInteractor.kt +++ b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/interactor/SelectionInteractor.kt @@ -16,8 +16,10 @@ package com.android.intentresolver.contentpreview.payloadtoggle.domain.interactor +import com.android.intentresolver.contentpreview.MimeTypeClassifier import com.android.intentresolver.contentpreview.payloadtoggle.data.repository.PreviewSelectionsRepository import com.android.intentresolver.contentpreview.payloadtoggle.domain.intent.TargetIntentModifier +import com.android.intentresolver.contentpreview.payloadtoggle.shared.ContentType import com.android.intentresolver.contentpreview.payloadtoggle.shared.model.PreviewModel import javax.inject.Inject import kotlinx.coroutines.flow.Flow @@ -31,6 +33,7 @@ constructor( private val selectionsRepo: PreviewSelectionsRepository, private val targetIntentModifier: TargetIntentModifier, private val updateTargetIntentInteractor: UpdateTargetIntentInteractor, + private val mimeTypeClassifier: MimeTypeClassifier, ) { /** Set of selected previews. */ val selections: StateFlow> @@ -39,6 +42,8 @@ constructor( /** Amount of selected previews. */ val amountSelected: Flow = selectionsRepo.selections.map { it.size } + val aggregateContentType: Flow = selections.map { aggregateContentType(it) } + fun select(model: PreviewModel) { updateChooserRequest(selectionsRepo.selections.updateAndGet { it + model }) } @@ -53,4 +58,29 @@ constructor( val intent = targetIntentModifier.intentFromSelection(selections) updateTargetIntentInteractor.updateTargetIntent(intent) } + + private fun aggregateContentType( + items: Set, + ): ContentType { + if (items.isEmpty()) { + return ContentType.Other + } + + var allImages = true + var allVideos = true + for (item in items) { + allImages = allImages && mimeTypeClassifier.isImageType(item.mimeType) + allVideos = allVideos && mimeTypeClassifier.isVideoType(item.mimeType) + + if (!allImages && !allVideos) { + break + } + } + + return when { + allImages -> ContentType.Image + allVideos -> ContentType.Video + else -> ContentType.Other + } + } } diff --git a/java/src/com/android/intentresolver/contentpreview/payloadtoggle/shared/ContentType.kt b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/shared/ContentType.kt new file mode 100644 index 00000000..3ef6d98f --- /dev/null +++ b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/shared/ContentType.kt @@ -0,0 +1,24 @@ +/* + * Copyright (C) 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.intentresolver.contentpreview.payloadtoggle.shared + +/** Type of the content being previewed. */ +enum class ContentType { + Image, + Video, + Other +} diff --git a/java/src/com/android/intentresolver/contentpreview/payloadtoggle/ui/composable/ShareouselCardComposable.kt b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/ui/composable/ShareouselCardComposable.kt index c2330ad8..a0be1a9b 100644 --- a/java/src/com/android/intentresolver/contentpreview/payloadtoggle/ui/composable/ShareouselCardComposable.kt +++ b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/ui/composable/ShareouselCardComposable.kt @@ -33,7 +33,7 @@ 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 +import com.android.intentresolver.contentpreview.payloadtoggle.shared.ContentType @Composable fun ShareouselCard( diff --git a/java/src/com/android/intentresolver/contentpreview/payloadtoggle/ui/composable/ShareouselComposable.kt b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/ui/composable/ShareouselComposable.kt index 36c94b59..c25b0154 100644 --- a/java/src/com/android/intentresolver/contentpreview/payloadtoggle/ui/composable/ShareouselComposable.kt +++ b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/ui/composable/ShareouselComposable.kt @@ -56,8 +56,8 @@ import androidx.compose.ui.semantics.semantics import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.android.intentresolver.R +import com.android.intentresolver.contentpreview.payloadtoggle.shared.ContentType import com.android.intentresolver.contentpreview.payloadtoggle.shared.model.PreviewsModel -import com.android.intentresolver.contentpreview.payloadtoggle.ui.viewmodel.ContentType import com.android.intentresolver.contentpreview.payloadtoggle.ui.viewmodel.ShareouselPreviewViewModel import com.android.intentresolver.contentpreview.payloadtoggle.ui.viewmodel.ShareouselViewModel import kotlinx.coroutines.launch @@ -114,12 +114,10 @@ private fun PreviewCarousel( private fun ShareouselCard(viewModel: ShareouselPreviewViewModel) { val bitmap by viewModel.bitmap.collectAsStateWithLifecycle(initialValue = null) val selected by viewModel.isSelected.collectAsStateWithLifecycle(initialValue = false) - val contentType by - viewModel.contentType.collectAsStateWithLifecycle(initialValue = ContentType.Image) val borderColor = MaterialTheme.colorScheme.primary val scope = rememberCoroutineScope() val contentDescription = - when (contentType) { + when (viewModel.contentType) { ContentType.Image -> stringResource(R.string.selectable_image) ContentType.Video -> stringResource(R.string.selectable_video) else -> stringResource(R.string.selectable_item) @@ -141,7 +139,7 @@ private fun ShareouselCard(viewModel: ShareouselPreviewViewModel) { Box(modifier = Modifier.fillMaxHeight().aspectRatio(aspectRatio)) } }, - contentType = contentType, + contentType = viewModel.contentType, selected = selected, modifier = Modifier.thenIf(selected) { 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 index 9827fcd4..540229c9 100644 --- a/java/src/com/android/intentresolver/contentpreview/payloadtoggle/ui/viewmodel/ShareouselPreviewViewModel.kt +++ b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/ui/viewmodel/ShareouselPreviewViewModel.kt @@ -17,6 +17,7 @@ package com.android.intentresolver.contentpreview.payloadtoggle.ui.viewmodel import android.graphics.Bitmap +import com.android.intentresolver.contentpreview.payloadtoggle.shared.ContentType import kotlinx.coroutines.flow.Flow /** An individual preview within Shareousel. */ @@ -24,17 +25,10 @@ data class ShareouselPreviewViewModel( /** Image to be shared. */ val bitmap: Flow, /** Type of data to be shared. */ - val contentType: Flow, + val contentType: ContentType, /** Whether this preview has been selected by the user. */ val isSelected: Flow, /** Sets whether this preview has been selected by the user. */ val setSelected: suspend (Boolean) -> Unit, val aspectRatio: Float, ) - -/** 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 index 1b9c231b..4eda3fa9 100644 --- a/java/src/com/android/intentresolver/contentpreview/payloadtoggle/ui/viewmodel/ShareouselViewModel.kt +++ b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/ui/viewmodel/ShareouselViewModel.kt @@ -18,11 +18,13 @@ package com.android.intentresolver.contentpreview.payloadtoggle.ui.viewmodel import com.android.intentresolver.contentpreview.CachingImagePreviewImageLoader import com.android.intentresolver.contentpreview.HeadlineGenerator import com.android.intentresolver.contentpreview.ImageLoader +import com.android.intentresolver.contentpreview.MimeTypeClassifier import com.android.intentresolver.contentpreview.payloadtoggle.domain.cursor.PayloadToggle import com.android.intentresolver.contentpreview.payloadtoggle.domain.interactor.ChooserRequestInteractor import com.android.intentresolver.contentpreview.payloadtoggle.domain.interactor.CustomActionsInteractor import com.android.intentresolver.contentpreview.payloadtoggle.domain.interactor.SelectablePreviewsInteractor import com.android.intentresolver.contentpreview.payloadtoggle.domain.interactor.SelectionInteractor +import com.android.intentresolver.contentpreview.payloadtoggle.shared.ContentType import com.android.intentresolver.contentpreview.payloadtoggle.shared.model.PreviewModel import com.android.intentresolver.contentpreview.payloadtoggle.shared.model.PreviewsModel import com.android.intentresolver.inject.ViewModelOwned @@ -35,9 +37,9 @@ 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.flow.zip /** A dynamic carousel of selectable previews within share sheet. */ data class ShareouselViewModel( @@ -71,6 +73,7 @@ interface ShareouselViewModelModule { headlineGenerator: HeadlineGenerator, selectionInteractor: SelectionInteractor, chooserRequestInteractor: ChooserRequestInteractor, + mimeTypeClassifier: MimeTypeClassifier, // TODO: remove if possible @ViewModelOwned scope: CoroutineScope, ): ShareouselViewModel { @@ -82,8 +85,9 @@ interface ShareouselViewModelModule { ) return ShareouselViewModel( headline = - selectionInteractor.amountSelected.map { numItems -> - val contentType = ContentType.Image // TODO: convert from metadata + selectionInteractor.aggregateContentType.zip( + selectionInteractor.amountSelected + ) { contentType, numItems -> when (contentType) { ContentType.Other -> headlineGenerator.getFilesHeadline(numItems) ContentType.Image -> headlineGenerator.getImagesHeadline(numItems) @@ -111,9 +115,15 @@ interface ShareouselViewModelModule { preview = { key -> keySet.value?.maybeLoad(key) val previewInteractor = interactor.preview(key) + val contentType = + when { + mimeTypeClassifier.isImageType(key.mimeType) -> ContentType.Image + mimeTypeClassifier.isVideoType(key.mimeType) -> ContentType.Video + else -> ContentType.Other + } ShareouselPreviewViewModel( bitmap = flow { emit(key.previewUri?.let { imageLoader(it) }) }, - contentType = flowOf(ContentType.Image), // TODO: convert from metadata + contentType = contentType, isSelected = previewInteractor.isSelected, setSelected = previewInteractor::setSelected, aspectRatio = key.aspectRatio, diff --git a/tests/shared/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/interactor/PayloadToggleInteractorKosmos.kt b/tests/shared/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/interactor/PayloadToggleInteractorKosmos.kt index 659c178c..8f7c59de 100644 --- a/tests/shared/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/interactor/PayloadToggleInteractorKosmos.kt +++ b/tests/shared/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/interactor/PayloadToggleInteractorKosmos.kt @@ -20,6 +20,7 @@ import com.android.intentresolver.backgroundDispatcher import com.android.intentresolver.contentResolver import com.android.intentresolver.contentpreview.HeadlineGenerator import com.android.intentresolver.contentpreview.ImageLoader +import com.android.intentresolver.contentpreview.mimetypeClassifier import com.android.intentresolver.contentpreview.payloadtoggle.data.repository.activityResultRepository import com.android.intentresolver.contentpreview.payloadtoggle.data.repository.cursorPreviewsRepository import com.android.intentresolver.contentpreview.payloadtoggle.data.repository.pendingSelectionCallbackRepository @@ -97,6 +98,7 @@ val Kosmos.selectionInteractor selectionsRepo = previewSelectionsRepository, targetIntentModifier = targetIntentModifier, updateTargetIntentInteractor = updateTargetIntentInteractor, + mimeTypeClassifier = mimetypeClassifier, ) val Kosmos.setCursorPreviewsInteractor diff --git a/tests/unit/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/interactor/SelectionInteractorTest.kt b/tests/unit/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/interactor/SelectionInteractorTest.kt index a64807b7..708e6cc6 100644 --- a/tests/unit/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/interactor/SelectionInteractorTest.kt +++ b/tests/unit/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/interactor/SelectionInteractorTest.kt @@ -18,6 +18,7 @@ package com.android.intentresolver.contentpreview.payloadtoggle.domain.interacto import android.content.Intent import android.net.Uri +import com.android.intentresolver.contentpreview.mimetypeClassifier import com.android.intentresolver.contentpreview.payloadtoggle.data.repository.previewSelectionsRepository import com.android.intentresolver.contentpreview.payloadtoggle.shared.model.PreviewModel import com.android.intentresolver.util.runKosmosTest @@ -35,7 +36,8 @@ class SelectionInteractorTest { SelectionInteractor( previewSelectionsRepository, { Intent() }, - updateTargetIntentInteractor + updateTargetIntentInteractor, + mimetypeClassifier, ) assertThat(underTest.selections.value).isEqualTo(setOf(initialPreview)) @@ -57,7 +59,8 @@ class SelectionInteractorTest { SelectionInteractor( previewSelectionsRepository, { Intent() }, - updateTargetIntentInteractor + updateTargetIntentInteractor, + mimetypeClassifier ) underTest.unselect(first) 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 index bd3d88f8..fb3e9a3f 100644 --- 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 @@ -25,6 +25,7 @@ import android.graphics.drawable.Icon import android.net.Uri import com.android.intentresolver.FakeImageLoader import com.android.intentresolver.contentpreview.HeadlineGenerator +import com.android.intentresolver.contentpreview.mimetypeClassifier import com.android.intentresolver.contentpreview.payloadtoggle.data.model.CustomActionModel import com.android.intentresolver.contentpreview.payloadtoggle.data.repository.activityResultRepository import com.android.intentresolver.contentpreview.payloadtoggle.data.repository.cursorPreviewsRepository @@ -39,6 +40,7 @@ import com.android.intentresolver.contentpreview.payloadtoggle.domain.interactor import com.android.intentresolver.contentpreview.payloadtoggle.domain.interactor.payloadToggleImageLoader import com.android.intentresolver.contentpreview.payloadtoggle.domain.interactor.selectablePreviewsInteractor import com.android.intentresolver.contentpreview.payloadtoggle.domain.interactor.selectionInteractor +import com.android.intentresolver.contentpreview.payloadtoggle.shared.ContentType import com.android.intentresolver.contentpreview.payloadtoggle.shared.model.PreviewModel import com.android.intentresolver.contentpreview.payloadtoggle.shared.model.PreviewsModel import com.android.intentresolver.data.model.ChooserRequest @@ -68,29 +70,64 @@ class ShareouselViewModelTest { actionsInteractor = customActionsInteractor, headlineGenerator = headlineGenerator, chooserRequestInteractor = chooserRequestInteractor, + mimeTypeClassifier = mimetypeClassifier, selectionInteractor = selectionInteractor, scope = viewModelScope, ) } @Test - fun headline() = runTest { - assertThat(shareouselViewModel.headline.first()).isEqualTo("IMAGES: 1") + fun headline_images() = runTest { + assertThat(shareouselViewModel.headline.first()).isEqualTo("FILES: 1") previewSelectionsRepository.selections.value = setOf( PreviewModel( uri = Uri.fromParts("scheme", "ssp", "fragment"), - mimeType = null, + mimeType = "image/png", ), PreviewModel( uri = Uri.fromParts("scheme1", "ssp1", "fragment1"), - mimeType = null, + mimeType = "image/jpeg", ) ) runCurrent() assertThat(shareouselViewModel.headline.first()).isEqualTo("IMAGES: 2") } + @Test + fun headline_videos() = runTest { + previewSelectionsRepository.selections.value = + setOf( + PreviewModel( + uri = Uri.fromParts("scheme", "ssp", "fragment"), + mimeType = "video/mpeg", + ), + PreviewModel( + uri = Uri.fromParts("scheme1", "ssp1", "fragment1"), + mimeType = "video/mpeg", + ) + ) + runCurrent() + assertThat(shareouselViewModel.headline.first()).isEqualTo("VIDEOS: 2") + } + + @Test + fun headline_mixed() = runTest { + previewSelectionsRepository.selections.value = + setOf( + PreviewModel( + uri = Uri.fromParts("scheme", "ssp", "fragment"), + mimeType = "image/jpeg", + ), + PreviewModel( + uri = Uri.fromParts("scheme1", "ssp1", "fragment1"), + mimeType = "video/mpeg", + ) + ) + runCurrent() + assertThat(shareouselViewModel.headline.first()).isEqualTo("FILES: 2") + } + @Test fun metadataText() = runTest { val request = @@ -115,11 +152,11 @@ class ShareouselViewModelTest { setOf( PreviewModel( uri = Uri.fromParts("scheme", "ssp", "fragment"), - mimeType = null, + mimeType = "image/png", ), PreviewModel( uri = Uri.fromParts("scheme1", "ssp1", "fragment1"), - mimeType = null, + mimeType = "video/mpeg", ) ), startIdx = 1, @@ -143,12 +180,13 @@ class ShareouselViewModelTest { shareouselViewModel.preview( PreviewModel( uri = Uri.fromParts("scheme1", "ssp1", "fragment1"), - mimeType = null + mimeType = "video/mpeg" ) ) assertWithMessage("preview bitmap is null").that(previewVm.bitmap.first()).isNotNull() assertThat(previewVm.isSelected.first()).isFalse() + assertThat(previewVm.contentType).isEqualTo(ContentType.Video) previewVm.setSelected(true) @@ -234,9 +272,9 @@ class ShareouselViewModelTest { override fun getFilesWithTextHeadline(text: CharSequence, count: Int): String = error("not supported") - override fun getVideosHeadline(count: Int): String = error("not supported") + override fun getVideosHeadline(count: Int): String = "VIDEOS: $count" - override fun getFilesHeadline(count: Int): String = error("not supported") + override fun getFilesHeadline(count: Int): String = "FILES: $count" } // instantiate the view model, and then runCurrent() so that it is fully hydrated before // starting the test -- cgit v1.2.3-59-g8ed1b From 26930b3207d85a07c26b722dcab81a8513ddebe0 Mon Sep 17 00:00:00 2001 From: Govinda Wasserman Date: Wed, 22 May 2024 16:35:33 -0400 Subject: Switch cursor fetching to use index instead of Uri Test: all existing tests pass BUG: 341923886 Flag: android.service.chooser.chooser_payload_toggling Change-Id: Ifab830654666f13137adef04c336d581cc7dda9d --- .../data/repository/PreviewSelectionsRepository.kt | 2 +- .../domain/interactor/CursorPreviewsInteractor.kt | 4 ++-- .../domain/interactor/FetchPreviewsInteractor.kt | 7 +++---- .../payloadtoggle/domain/interactor/SelectionInteractor.kt | 8 ++++---- .../domain/interactor/SetCursorPreviewsInteractor.kt | 6 +++--- .../payloadtoggle/shared/model/PreviewsModel.kt | 2 +- .../payloadtoggle/ui/composable/ShareouselComposable.kt | 5 ++--- .../payloadtoggle/ui/viewmodel/ShareouselViewModel.kt | 14 +++++++------- .../domain/interactor/FetchPreviewsInteractorTest.kt | 2 +- .../domain/interactor/SelectablePreviewInteractorTest.kt | 2 +- .../domain/interactor/SelectablePreviewsInteractorTest.kt | 10 +++++----- .../domain/interactor/SelectionInteractorTest.kt | 10 +++++----- .../domain/interactor/SetCursorPreviewsInteractorTest.kt | 8 ++++---- .../payloadtoggle/ui/viewmodel/ShareouselViewModelTest.kt | 13 +++++++------ 14 files changed, 46 insertions(+), 47 deletions(-) (limited to 'java/src') 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 index 9aecc981..48c06192 100644 --- a/java/src/com/android/intentresolver/contentpreview/payloadtoggle/data/repository/PreviewSelectionsRepository.kt +++ b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/data/repository/PreviewSelectionsRepository.kt @@ -24,5 +24,5 @@ import kotlinx.coroutines.flow.MutableStateFlow /** Stores set of selected previews. */ @ViewModelScoped class PreviewSelectionsRepository @Inject constructor() { - val selections = MutableStateFlow(emptySet()) + val selections = MutableStateFlow(emptyList()) } 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 index f02834e0..a0fc11c3 100644 --- a/java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/interactor/CursorPreviewsInteractor.kt +++ b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/interactor/CursorPreviewsInteractor.kt @@ -94,7 +94,7 @@ constructor( val startPageNum = state.firstLoadedPageNum while ((state.hasMoreLeft || state.hasMoreRight) && state.numLoadedPages < maxLoadedPages) { interactor.setPreviews( - previewsByKey = state.merged.values.toSet(), + previews = state.merged.values.toList(), startIndex = startPageNum, hasMoreLeft = state.hasMoreLeft, hasMoreRight = state.hasMoreRight, @@ -126,7 +126,7 @@ constructor( // those. val loadingState: Flow = interactor.setPreviews( - previewsByKey = state.merged.values.toSet(), + previews = state.merged.values.toList(), startIndex = 0, // TODO: actually track this as the window changes? hasMoreLeft = state.hasMoreLeft, hasMoreRight = state.hasMoreRight, 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 index 80cd03d9..388cbc7e 100644 --- a/java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/interactor/FetchPreviewsInteractor.kt +++ b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/interactor/FetchPreviewsInteractor.kt @@ -44,10 +44,10 @@ constructor( ) { suspend fun activate() = coroutineScope { val cursor = async { cursorResolver.getCursor() } - val initialPreviewMap: Set = getInitialPreviews() + val initialPreviewMap = getInitialPreviews() selectionRepository.selections.value = initialPreviewMap setCursorPreviews.setPreviews( - previewsByKey = initialPreviewMap, + previews = initialPreviewMap, startIndex = focusedItemIdx, hasMoreLeft = false, hasMoreRight = false, @@ -55,7 +55,7 @@ constructor( cursorInteractor.launch(cursor.await() ?: return@coroutineScope, initialPreviewMap) } - private suspend fun getInitialPreviews(): Set = + private suspend fun getInitialPreviews(): List = selectedItems // Restrict parallelism so as to not overload the metadata reader; anecdotally, too // many parallel queries causes failures. @@ -72,5 +72,4 @@ constructor( ?: 1f, ) } - .toSet() } 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 index e99aa50c..13af92cb 100644 --- a/java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/interactor/SelectionInteractor.kt +++ b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/interactor/SelectionInteractor.kt @@ -35,8 +35,8 @@ constructor( private val updateTargetIntentInteractor: UpdateTargetIntentInteractor, private val mimeTypeClassifier: MimeTypeClassifier, ) { - /** Set of selected previews. */ - val selections: StateFlow> + /** List of selected previews. */ + val selections: StateFlow> get() = selectionsRepo.selections /** Amount of selected previews. */ @@ -54,13 +54,13 @@ constructor( } } - private fun updateChooserRequest(selections: Set) { + private fun updateChooserRequest(selections: List) { val intent = targetIntentModifier.intentFromSelection(selections) updateTargetIntentInteractor.updateTargetIntent(intent) } private fun aggregateContentType( - items: Set, + items: List, ): ContentType { if (items.isEmpty()) { return ContentType.Other diff --git a/java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/interactor/SetCursorPreviewsInteractor.kt b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/interactor/SetCursorPreviewsInteractor.kt index 21a599fa..437bc942 100644 --- a/java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/interactor/SetCursorPreviewsInteractor.kt +++ b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/interactor/SetCursorPreviewsInteractor.kt @@ -29,9 +29,9 @@ import kotlinx.coroutines.flow.asStateFlow class SetCursorPreviewsInteractor @Inject constructor(private val previewsRepo: CursorPreviewsRepository) { - /** Stores new [previewsByKey], and returns a flow of load requests triggered by Shareousel. */ + /** Stores new [previews], and returns a flow of load requests triggered by Shareousel. */ fun setPreviews( - previewsByKey: Set, + previews: List, startIndex: Int, hasMoreLeft: Boolean, hasMoreRight: Boolean, @@ -39,7 +39,7 @@ constructor(private val previewsRepo: CursorPreviewsRepository) { val loadingState = MutableStateFlow(null) previewsRepo.previewsModel.value = PreviewsModel( - previewModels = previewsByKey, + previewModels = previews, startIdx = startIndex, loadMoreLeft = if (hasMoreLeft) { 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 index 0ac99bd3..1d3eb4b4 100644 --- a/java/src/com/android/intentresolver/contentpreview/payloadtoggle/shared/model/PreviewsModel.kt +++ b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/shared/model/PreviewsModel.kt @@ -19,7 +19,7 @@ package com.android.intentresolver.contentpreview.payloadtoggle.shared.model /** A dataset of previews for Shareousel. */ data class PreviewsModel( /** All available [PreviewModel]s. */ - val previewModels: Set, + val previewModels: List, /** Index into [previewModels] that should be initially displayed to the user. */ val startIdx: Int, /** diff --git a/java/src/com/android/intentresolver/contentpreview/payloadtoggle/ui/composable/ShareouselComposable.kt b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/ui/composable/ShareouselComposable.kt index c25b0154..02d997ae 100644 --- a/java/src/com/android/intentresolver/contentpreview/payloadtoggle/ui/composable/ShareouselComposable.kt +++ b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/ui/composable/ShareouselComposable.kt @@ -30,7 +30,6 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyRow -import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.selection.toggleable @@ -104,8 +103,8 @@ private fun PreviewCarousel( .height(dimensionResource(R.dimen.chooser_preview_image_height_tall)) .systemGestureExclusion() ) { - items(previews.previewModels.toList(), key = { it.uri }) { model -> - ShareouselCard(viewModel.preview(model)) + itemsIndexed(previews.previewModels, key = { _, model -> model.uri }) { index, model -> + ShareouselCard(viewModel.preview(index, model)) } } } 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 index 4eda3fa9..9d53b92a 100644 --- a/java/src/com/android/intentresolver/contentpreview/payloadtoggle/ui/viewmodel/ShareouselViewModel.kt +++ b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/ui/viewmodel/ShareouselViewModel.kt @@ -55,7 +55,7 @@ data class ShareouselViewModel( /** List of action chips presented underneath Shareousel. */ val actions: Flow>, /** Creates a [ShareouselPreviewViewModel] for a [PreviewModel] present in [previews]. */ - val preview: (key: PreviewModel) -> ShareouselPreviewViewModel, + val preview: (index: Int, key: PreviewModel) -> ShareouselPreviewViewModel, ) @Module @@ -112,8 +112,8 @@ interface ShareouselViewModelModule { } } }, - preview = { key -> - keySet.value?.maybeLoad(key) + preview = { index, key -> + keySet.value?.maybeLoad(index) val previewInteractor = interactor.preview(key) val contentType = when { @@ -134,9 +134,9 @@ interface ShareouselViewModelModule { } } -private fun PreviewsModel.maybeLoad(key: PreviewModel) { - when (key) { - previewModels.firstOrNull() -> loadMoreLeft?.invoke() - previewModels.lastOrNull() -> loadMoreRight?.invoke() +private fun PreviewsModel.maybeLoad(index: Int) { + when (index) { + previewModels.indices.firstOrNull() -> loadMoreLeft?.invoke() + previewModels.indices.lastOrNull() -> loadMoreRight?.invoke() } } 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 index 146892d0..0009c7af 100644 --- 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 @@ -113,7 +113,7 @@ class FetchPreviewsInteractorTest { .isEqualTo( PreviewsModel( previewModels = - setOf( + listOf( PreviewModel( uri = Uri.fromParts("scheme1", "ssp1", "fragment1"), mimeType = "image/bitmap", 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 index a3c65570..0275a9c3 100644 --- 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 @@ -64,7 +64,7 @@ class SelectablePreviewInteractorTest { assertThat(underTest.isSelected.first()).isFalse() previewSelectionsRepository.selections.value = - setOf( + listOf( PreviewModel( uri = Uri.fromParts("scheme", "ssp", "fragment"), mimeType = "image/bitmap" 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 index be5ddfe5..b6c212b7 100644 --- 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 @@ -39,7 +39,7 @@ class SelectablePreviewsInteractorTest { cursorPreviewsRepository.previewsModel.value = PreviewsModel( previewModels = - setOf( + listOf( PreviewModel( uri = Uri.fromParts("scheme", "ssp", "fragment"), mimeType = "image/bitmap", @@ -54,7 +54,7 @@ class SelectablePreviewsInteractorTest { loadMoreRight = null, ) previewSelectionsRepository.selections.value = - setOf( + listOf( PreviewModel(uri = Uri.fromParts("scheme", "ssp", "fragment"), mimeType = null), ) targetIntentModifier = TargetIntentModifier { error("unexpected invocation") } @@ -94,7 +94,7 @@ class SelectablePreviewsInteractorTest { @Test fun keySet_reflectsRepositoryUpdate() = runKosmosTest { previewSelectionsRepository.selections.value = - setOf( + listOf( PreviewModel(uri = Uri.fromParts("scheme", "ssp", "fragment"), mimeType = null), ) targetIntentModifier = TargetIntentModifier { error("unexpected invocation") } @@ -114,7 +114,7 @@ class SelectablePreviewsInteractorTest { cursorPreviewsRepository.previewsModel.value = PreviewsModel( previewModels = - setOf( + listOf( PreviewModel( uri = Uri.fromParts("scheme", "ssp", "fragment"), mimeType = "image/bitmap", @@ -128,7 +128,7 @@ class SelectablePreviewsInteractorTest { loadMoreLeft = null, loadMoreRight = { loadRequested = true }, ) - previewSelectionsRepository.selections.value = emptySet() + previewSelectionsRepository.selections.value = emptyList() runCurrent() assertThat(previews.value).isNotNull() diff --git a/tests/unit/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/interactor/SelectionInteractorTest.kt b/tests/unit/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/interactor/SelectionInteractorTest.kt index 708e6cc6..a50efebf 100644 --- a/tests/unit/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/interactor/SelectionInteractorTest.kt +++ b/tests/unit/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/interactor/SelectionInteractorTest.kt @@ -30,7 +30,7 @@ class SelectionInteractorTest { fun singleSelection_removalPrevented() = runKosmosTest { val initialPreview = PreviewModel(uri = Uri.fromParts("scheme", "ssp", "fragment"), mimeType = null) - previewSelectionsRepository.selections.value = setOf(initialPreview) + previewSelectionsRepository.selections.value = listOf(initialPreview) val underTest = SelectionInteractor( @@ -40,12 +40,12 @@ class SelectionInteractorTest { mimetypeClassifier, ) - assertThat(underTest.selections.value).isEqualTo(setOf(initialPreview)) + assertThat(underTest.selections.value).containsExactly(initialPreview) // Shouldn't do anything! underTest.unselect(initialPreview) - assertThat(underTest.selections.value).isEqualTo(setOf(initialPreview)) + assertThat(underTest.selections.value).containsExactly(initialPreview) } @Test @@ -53,7 +53,7 @@ class SelectionInteractorTest { val first = PreviewModel(uri = Uri.fromParts("scheme", "ssp", "fragment"), mimeType = null) val second = PreviewModel(uri = Uri.fromParts("scheme2", "ssp2", "fragment2"), mimeType = null) - previewSelectionsRepository.selections.value = setOf(first, second) + previewSelectionsRepository.selections.value = listOf(first, second) val underTest = SelectionInteractor( @@ -65,6 +65,6 @@ class SelectionInteractorTest { underTest.unselect(first) - assertThat(underTest.selections.value).isEqualTo(setOf(second)) + assertThat(underTest.selections.value).containsExactly(second) } } 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 index 5aac7b55..0708dbbb 100644 --- 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 @@ -34,8 +34,8 @@ class SetCursorPreviewsInteractorTest { fun setPreviews_noAdditionalData() = runKosmosTest { val loadState = setCursorPreviewsInteractor.setPreviews( - previewsByKey = - setOf( + previews = + listOf( PreviewModel( uri = Uri.fromParts("scheme", "ssp", "fragment"), mimeType = null, @@ -69,8 +69,8 @@ class SetCursorPreviewsInteractorTest { val loadState = setCursorPreviewsInteractor .setPreviews( - previewsByKey = - setOf( + previews = + listOf( PreviewModel( uri = Uri.fromParts("scheme", "ssp", "fragment"), mimeType = null, 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 index fb3e9a3f..5884d178 100644 --- 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 @@ -80,7 +80,7 @@ class ShareouselViewModelTest { fun headline_images() = runTest { assertThat(shareouselViewModel.headline.first()).isEqualTo("FILES: 1") previewSelectionsRepository.selections.value = - setOf( + listOf( PreviewModel( uri = Uri.fromParts("scheme", "ssp", "fragment"), mimeType = "image/png", @@ -97,7 +97,7 @@ class ShareouselViewModelTest { @Test fun headline_videos() = runTest { previewSelectionsRepository.selections.value = - setOf( + listOf( PreviewModel( uri = Uri.fromParts("scheme", "ssp", "fragment"), mimeType = "video/mpeg", @@ -114,7 +114,7 @@ class ShareouselViewModelTest { @Test fun headline_mixed() = runTest { previewSelectionsRepository.selections.value = - setOf( + listOf( PreviewModel( uri = Uri.fromParts("scheme", "ssp", "fragment"), mimeType = "image/jpeg", @@ -149,7 +149,7 @@ class ShareouselViewModelTest { cursorPreviewsRepository.previewsModel.value = PreviewsModel( previewModels = - setOf( + listOf( PreviewModel( uri = Uri.fromParts("scheme", "ssp", "fragment"), mimeType = "image/png", @@ -177,7 +177,8 @@ class ShareouselViewModelTest { .inOrder() val previewVm = - shareouselViewModel.preview( + shareouselViewModel.preview.invoke( + /* index = */ 1, PreviewModel( uri = Uri.fromParts("scheme1", "ssp1", "fragment1"), mimeType = "video/mpeg" @@ -246,7 +247,7 @@ class ShareouselViewModelTest { this.pendingIntentSender = pendingIntentSender this.targetIntentModifier = targetIntentModifier previewSelectionsRepository.selections.value = - setOf(PreviewModel(uri = Uri.fromParts("scheme", "ssp", "fragment"), mimeType = null)) + listOf(PreviewModel(uri = Uri.fromParts("scheme", "ssp", "fragment"), mimeType = null)) payloadToggleImageLoader = FakeImageLoader( initialBitmaps = -- cgit v1.2.3-59-g8ed1b From 3c1b61595d2bde70587136e95769865dd32f9e2e Mon Sep 17 00:00:00 2001 From: Matt Casey Date: Tue, 28 May 2024 15:34:34 +0000 Subject: Add file icon to shareousel for file shares. Bug: 341923652 Test: Manual test with ShareTest Flag: android.service.chooser.chooser_payload_toggling Change-Id: If796d5d6d373cab8bb5ca97448f484a2683fd3c3 --- .../ui/composable/ShareouselCardComposable.kt | 20 +++++++++++++++----- 1 file changed, 15 insertions(+), 5 deletions(-) (limited to 'java/src') diff --git a/java/src/com/android/intentresolver/contentpreview/payloadtoggle/ui/composable/ShareouselCardComposable.kt b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/ui/composable/ShareouselCardComposable.kt index a0be1a9b..197d6858 100644 --- a/java/src/com/android/intentresolver/contentpreview/payloadtoggle/ui/composable/ShareouselCardComposable.kt +++ b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/ui/composable/ShareouselCardComposable.kt @@ -47,18 +47,28 @@ fun ShareouselCard( val topButtonPadding = 12.dp Box(modifier = Modifier.padding(topButtonPadding).matchParentSize()) { SelectionIcon(selected, modifier = Modifier.align(Alignment.TopStart)) - if (contentType == ContentType.Video) { - AnimationIcon(modifier = Modifier.align(Alignment.TopEnd)) + when (contentType) { + ContentType.Video -> + TypeIcon( + R.drawable.ic_play_circle_filled_24px, + modifier = Modifier.align(Alignment.TopEnd) + ) + ContentType.Other -> + TypeIcon( + R.drawable.chooser_file_generic, + modifier = Modifier.align(Alignment.TopEnd) + ) + ContentType.Image -> Unit // No additional icon needed. } } } } @Composable -private fun AnimationIcon(modifier: Modifier = Modifier) { +private fun TypeIcon(drawableResource: Int, modifier: Modifier = Modifier) { Icon( - painterResource(id = R.drawable.ic_play_circle_filled_24px), - contentDescription = null, // Video attribute described at a higher level. + painterResource(id = drawableResource), + contentDescription = null, // Type attribute described at a higher level. tint = Color.White, modifier = Modifier.size(20.dp).then(modifier) ) -- cgit v1.2.3-59-g8ed1b From 971f4321406cc7ae4346f9ecbb220a0095a4151f Mon Sep 17 00:00:00 2001 From: Govinda Wasserman Date: Fri, 24 May 2024 18:27:16 -0400 Subject: Cursor continuously fetches new pages as the user scrolls Whenever the user gets more than half a page away from the center, the cursor will fetch the next page in that direction. Test: manual test with Share Test Test: atest IntentResolver-tests-unit BUG: 341923886 FIX: 341923886 Flag: android.service.chooser.chooser_payload_toggling Change-Id: Ifc9f651ccf028f25af8adfb7bc359977803d540a --- .../domain/interactor/CursorPreviewsInteractor.kt | 16 ++++++++++++++++ .../domain/interactor/FetchPreviewsInteractor.kt | 2 ++ .../interactor/SetCursorPreviewsInteractor.kt | 4 ++++ .../payloadtoggle/shared/model/PreviewsModel.kt | 10 ++++++++++ .../ui/composable/ShareouselComposable.kt | 22 +++++++++++++++++++++- .../ui/viewmodel/ShareouselViewModel.kt | 13 +++++++------ .../interactor/CursorPreviewsInteractorTest.kt | 2 ++ .../interactor/FetchPreviewsInteractorTest.kt | 2 ++ .../interactor/SelectablePreviewsInteractorTest.kt | 4 ++++ .../interactor/SetCursorPreviewsInteractorTest.kt | 4 ++++ .../ui/viewmodel/ShareouselViewModelTest.kt | 6 ++++-- 11 files changed, 76 insertions(+), 9 deletions(-) (limited to 'java/src') 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 index a0fc11c3..fa600c86 100644 --- a/java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/interactor/CursorPreviewsInteractor.kt +++ b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/interactor/CursorPreviewsInteractor.kt @@ -43,6 +43,8 @@ import dagger.hilt.components.SingletonComponent import java.util.concurrent.ConcurrentHashMap import javax.inject.Inject import javax.inject.Qualifier +import kotlin.math.max +import kotlin.math.min import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.filterNotNull @@ -93,11 +95,14 @@ constructor( var state = initialState val startPageNum = state.firstLoadedPageNum while ((state.hasMoreLeft || state.hasMoreRight) && state.numLoadedPages < maxLoadedPages) { + val (leftTriggerIndex, rightTriggerIndex) = state.triggerIndices() interactor.setPreviews( previews = state.merged.values.toList(), startIndex = startPageNum, hasMoreLeft = state.hasMoreLeft, hasMoreRight = state.hasMoreRight, + leftTriggerIndex = leftTriggerIndex, + rightTriggerIndex = rightTriggerIndex, ) val loadedLeft = startPageNum - state.firstLoadedPageNum val loadedRight = state.lastLoadedPageNum - startPageNum @@ -120,6 +125,8 @@ constructor( ) { var state = initialState while (true) { + val (leftTriggerIndex, rightTriggerIndex) = state.triggerIndices() + // Design note: in order to prevent load requests from the UI when it was displaying a // previously-published dataset being accidentally associated with a recently-published // one, we generate a new Flow of load requests for each dataset and only listen to @@ -130,6 +137,8 @@ constructor( startIndex = 0, // TODO: actually track this as the window changes? hasMoreLeft = state.hasMoreLeft, hasMoreRight = state.hasMoreRight, + leftTriggerIndex = leftTriggerIndex, + rightTriggerIndex = rightTriggerIndex, ) state = loadingState.handleOneLoadRequest(state, pagedCursor, unclaimedRecords) } @@ -238,6 +247,13 @@ constructor( } } + private fun CursorWindow.triggerIndices(): Pair { + val totalIndices = numLoadedPages * pageSize + val midIndex = totalIndices / 2 + val halfPage = pageSize / 2 + return max(midIndex - halfPage, 0) to min(midIndex + halfPage, totalIndices - 1) + } + private suspend fun readPage( state: CursorWindow, pagedCursor: PagedCursor, 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 index 388cbc7e..c9c9a9b3 100644 --- a/java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/interactor/FetchPreviewsInteractor.kt +++ b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/interactor/FetchPreviewsInteractor.kt @@ -51,6 +51,8 @@ constructor( startIndex = focusedItemIdx, hasMoreLeft = false, hasMoreRight = false, + leftTriggerIndex = initialPreviewMap.indices.first(), + rightTriggerIndex = initialPreviewMap.indices.last(), ) cursorInteractor.launch(cursor.await() ?: return@coroutineScope, initialPreviewMap) } 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 index 437bc942..124e2a3d 100644 --- a/java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/interactor/SetCursorPreviewsInteractor.kt +++ b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/interactor/SetCursorPreviewsInteractor.kt @@ -35,6 +35,8 @@ constructor(private val previewsRepo: CursorPreviewsRepository) { startIndex: Int, hasMoreLeft: Boolean, hasMoreRight: Boolean, + leftTriggerIndex: Int, + rightTriggerIndex: Int ): Flow { val loadingState = MutableStateFlow(null) previewsRepo.previewsModel.value = @@ -53,6 +55,8 @@ constructor(private val previewsRepo: CursorPreviewsRepository) { } else { null }, + leftTriggerIndex = leftTriggerIndex, + rightTriggerIndex = rightTriggerIndex, ) return loadingState.asStateFlow() } 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 index 1d3eb4b4..ae8bd1eb 100644 --- a/java/src/com/android/intentresolver/contentpreview/payloadtoggle/shared/model/PreviewsModel.kt +++ b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/shared/model/PreviewsModel.kt @@ -32,4 +32,14 @@ data class PreviewsModel( * indicates that there is no more data to load in that direction. */ val loadMoreRight: (() -> Unit)?, + /** + * Index into [previewModels] where any attempted access less than or equal to it should trigger + * a window shift left. + */ + val leftTriggerIndex: Int, + /** + * Index into [previewModels] where any attempted access greater than or equal to it should + * trigger a window shift right. + */ + val rightTriggerIndex: Int, ) diff --git a/java/src/com/android/intentresolver/contentpreview/payloadtoggle/ui/composable/ShareouselComposable.kt b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/ui/composable/ShareouselComposable.kt index 02d997ae..8e2626bf 100644 --- a/java/src/com/android/intentresolver/contentpreview/payloadtoggle/ui/composable/ShareouselComposable.kt +++ b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/ui/composable/ShareouselComposable.kt @@ -41,7 +41,9 @@ import androidx.compose.material3.LocalContentColor import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip @@ -59,6 +61,7 @@ import com.android.intentresolver.contentpreview.payloadtoggle.shared.ContentTyp import com.android.intentresolver.contentpreview.payloadtoggle.shared.model.PreviewsModel import com.android.intentresolver.contentpreview.payloadtoggle.ui.viewmodel.ShareouselPreviewViewModel import com.android.intentresolver.contentpreview.payloadtoggle.ui.viewmodel.ShareouselViewModel +import kotlin.math.abs import kotlinx.coroutines.launch @Composable @@ -104,7 +107,24 @@ private fun PreviewCarousel( .systemGestureExclusion() ) { itemsIndexed(previews.previewModels, key = { _, model -> model.uri }) { index, model -> - ShareouselCard(viewModel.preview(index, model)) + + // Index if this is the element in the center of the viewing area, otherwise null + val previewIndex by remember { + derivedStateOf { + carouselState.layoutInfo.visibleItemsInfo + .firstOrNull { it.index == index } + ?.let { + val viewportCenter = carouselState.layoutInfo.viewportEndOffset / 2 + val halfPreviewWidth = it.size / 2 + val previewCenter = it.offset + halfPreviewWidth + val previewDistanceToViewportCenter = + abs(previewCenter - viewportCenter) + if (previewDistanceToViewportCenter <= halfPreviewWidth) index else null + } + } + } + + ShareouselCard(viewModel.preview(model, previewIndex)) } } } 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 index 9d53b92a..c3ad7b6c 100644 --- a/java/src/com/android/intentresolver/contentpreview/payloadtoggle/ui/viewmodel/ShareouselViewModel.kt +++ b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/ui/viewmodel/ShareouselViewModel.kt @@ -55,7 +55,7 @@ data class ShareouselViewModel( /** List of action chips presented underneath Shareousel. */ val actions: Flow>, /** Creates a [ShareouselPreviewViewModel] for a [PreviewModel] present in [previews]. */ - val preview: (index: Int, key: PreviewModel) -> ShareouselPreviewViewModel, + val preview: (key: PreviewModel, index: Int?) -> ShareouselPreviewViewModel, ) @Module @@ -112,7 +112,7 @@ interface ShareouselViewModelModule { } } }, - preview = { index, key -> + preview = { key, index -> keySet.value?.maybeLoad(index) val previewInteractor = interactor.preview(key) val contentType = @@ -134,9 +134,10 @@ interface ShareouselViewModelModule { } } -private fun PreviewsModel.maybeLoad(index: Int) { - when (index) { - previewModels.indices.firstOrNull() -> loadMoreLeft?.invoke() - previewModels.indices.lastOrNull() -> loadMoreRight?.invoke() +private fun PreviewsModel.maybeLoad(index: Int?) { + when { + index == null -> {} + index <= leftTriggerIndex -> loadMoreLeft?.invoke() + index >= rightTriggerIndex -> loadMoreRight?.invoke() } } 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 index 45e456e0..0036e803 100644 --- 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 @@ -144,6 +144,8 @@ class CursorPreviewsInteractorTest { assertThat(startIdx).isEqualTo(0) assertThat(loadMoreLeft).isNull() assertThat(loadMoreRight).isNotNull() + assertThat(leftTriggerIndex).isEqualTo(2) + assertThat(rightTriggerIndex).isEqualTo(4) } } 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 index 0009c7af..d04c984f 100644 --- 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 @@ -127,6 +127,8 @@ class FetchPreviewsInteractorTest { startIdx = 1, loadMoreLeft = null, loadMoreRight = null, + leftTriggerIndex = 0, + rightTriggerIndex = 1, ) ) } 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 index b6c212b7..14b9c49c 100644 --- 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 @@ -52,6 +52,8 @@ class SelectablePreviewsInteractorTest { startIdx = 0, loadMoreLeft = null, loadMoreRight = null, + leftTriggerIndex = 0, + rightTriggerIndex = 1, ) previewSelectionsRepository.selections.value = listOf( @@ -127,6 +129,8 @@ class SelectablePreviewsInteractorTest { startIdx = 5, loadMoreLeft = null, loadMoreRight = { loadRequested = true }, + leftTriggerIndex = 0, + rightTriggerIndex = 1, ) previewSelectionsRepository.selections.value = emptyList() runCurrent() 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 index 0708dbbb..a165b41e 100644 --- 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 @@ -44,6 +44,8 @@ class SetCursorPreviewsInteractorTest { startIndex = 100, hasMoreLeft = false, hasMoreRight = false, + leftTriggerIndex = 0, + rightTriggerIndex = 0, ) assertThat(loadState.first()).isNull() @@ -79,6 +81,8 @@ class SetCursorPreviewsInteractorTest { startIndex = 100, hasMoreLeft = true, hasMoreRight = true, + leftTriggerIndex = 0, + rightTriggerIndex = 0, ) .stateIn(backgroundScope) 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 index 5884d178..ec4a9c3e 100644 --- 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 @@ -162,6 +162,8 @@ class ShareouselViewModelTest { startIdx = 1, loadMoreLeft = null, loadMoreRight = null, + leftTriggerIndex = 0, + rightTriggerIndex = 1, ) runCurrent() @@ -178,11 +180,11 @@ class ShareouselViewModelTest { val previewVm = shareouselViewModel.preview.invoke( - /* index = */ 1, PreviewModel( uri = Uri.fromParts("scheme1", "ssp1", "fragment1"), mimeType = "video/mpeg" - ) + ), + /* index = */ 1, ) assertWithMessage("preview bitmap is null").that(previewVm.bitmap.first()).isNotNull() -- cgit v1.2.3-59-g8ed1b From 797c10869157ed583225d82ebda0c9e52f4ff56a Mon Sep 17 00:00:00 2001 From: Andrey Epin Date: Wed, 29 May 2024 14:41:37 -0700 Subject: Fix shared element animation for partially visible image When launching an image editor, do not run the shared element transition animation for a paritally visible image. Fix: 339583191 Test: atest IntentResolver-tests-unit Test: atest IntentResolver-tests-activity Test: manual testing Flag: com.android.intentresolver.fix_partial_image_edit_transition Change-Id: Idbcf3712d4d13966e656387b803d56486fa8e1a2 --- aconfig/FeatureFlags.aconfig | 10 ++++++++++ .../com/android/intentresolver/ChooserActionFactory.java | 14 ++++++++++---- java/src/com/android/intentresolver/ChooserActivity.java | 3 ++- .../com/android/intentresolver/widget/ViewExtensions.kt | 7 +++++++ .../com/android/intentresolver/ChooserActionFactoryTest.kt | 6 ++++++ 5 files changed, 35 insertions(+), 5 deletions(-) (limited to 'java/src') diff --git a/aconfig/FeatureFlags.aconfig b/aconfig/FeatureFlags.aconfig index a102328a..71974cf8 100644 --- a/aconfig/FeatureFlags.aconfig +++ b/aconfig/FeatureFlags.aconfig @@ -70,6 +70,16 @@ flag { } } +flag { + name: "fix_partial_image_edit_transition" + namespace: "intentresolver" + description: "Do not run the shared element transition animation for a partially visible image" + bug: "339583191" + metadata { + purpose: PURPOSE_BUGFIX + } +} + flag { name: "fix_private_space_locked_on_restart" namespace: "intentresolver" diff --git a/java/src/com/android/intentresolver/ChooserActionFactory.java b/java/src/com/android/intentresolver/ChooserActionFactory.java index 79998fbc..4dff2177 100644 --- a/java/src/com/android/intentresolver/ChooserActionFactory.java +++ b/java/src/com/android/intentresolver/ChooserActionFactory.java @@ -16,6 +16,8 @@ package com.android.intentresolver; +import static com.android.intentresolver.widget.ViewExtensionsKt.isFullyVisible; + import android.app.Activity; import android.app.ActivityOptions; import android.app.PendingIntent; @@ -129,7 +131,8 @@ public final class ChooserActionFactory implements ChooserContentPreviewUi.Actio ActionActivityStarter activityStarter, @Nullable ShareResultSender shareResultSender, Consumer finishCallback, - ClipboardManager clipboardManager) { + ClipboardManager clipboardManager, + FeatureFlags featureFlags) { this( context, makeCopyButtonRunnable( @@ -145,7 +148,8 @@ public final class ChooserActionFactory implements ChooserContentPreviewUi.Actio imageEditor), firstVisibleImageQuery, activityStarter, - log), + log, + featureFlags.fixPartialImageEditTransition()), chooserActions, onUpdateSharedTextIsExcluded, log, @@ -333,7 +337,8 @@ public final class ChooserActionFactory implements ChooserContentPreviewUi.Actio @Nullable TargetInfo editSharingTarget, Callable firstVisibleImageQuery, ActionActivityStarter activityStarter, - EventLog log) { + EventLog log, + boolean requireFullVisibility) { if (editSharingTarget == null) return null; return () -> { // Log share completion via edit. @@ -344,7 +349,8 @@ public final class ChooserActionFactory implements ChooserContentPreviewUi.Actio firstImageView = firstVisibleImageQuery.call(); } catch (Exception e) { /* ignore */ } // Action bar is user-independent; always start as primary. - if (firstImageView == null) { + if (firstImageView == null + || (requireFullVisibility && !isFullyVisible(firstImageView))) { activityStarter.safelyStartActivityAsPersonalProfileUser(editSharingTarget); } else { activityStarter.safelyStartActivityAsPersonalProfileUserWithSharedElementTransition( diff --git a/java/src/com/android/intentresolver/ChooserActivity.java b/java/src/com/android/intentresolver/ChooserActivity.java index 9643b9f0..1b2e2a3f 100644 --- a/java/src/com/android/intentresolver/ChooserActivity.java +++ b/java/src/com/android/intentresolver/ChooserActivity.java @@ -2198,7 +2198,8 @@ public class ChooserActivity extends Hilt_ChooserActivity implements }, mShareResultSender, this::finishWithStatus, - mClipboardManager); + mClipboardManager, + mFeatureFlags); } private Supplier createModifyShareActionFactory() { diff --git a/java/src/com/android/intentresolver/widget/ViewExtensions.kt b/java/src/com/android/intentresolver/widget/ViewExtensions.kt index d19933f5..64aa9352 100644 --- a/java/src/com/android/intentresolver/widget/ViewExtensions.kt +++ b/java/src/com/android/intentresolver/widget/ViewExtensions.kt @@ -16,6 +16,7 @@ package com.android.intentresolver.widget +import android.graphics.Rect import android.util.Log import android.view.View import androidx.core.view.OneShotPreDrawListener @@ -42,3 +43,9 @@ internal suspend fun View.waitForPreDraw(): Unit = suspendCancellableCoroutine { ) continuation.invokeOnCancellation { callback.removeListener() } } + +internal fun View.isFullyVisible(): Boolean { + val rect = Rect() + val isVisible = getLocalVisibleRect(rect) + return isVisible && rect.width() == width && rect.height() == height +} diff --git a/tests/unit/src/com/android/intentresolver/ChooserActionFactoryTest.kt b/tests/unit/src/com/android/intentresolver/ChooserActionFactoryTest.kt index 8dfbdbdd..c8e17de4 100644 --- a/tests/unit/src/com/android/intentresolver/ChooserActionFactoryTest.kt +++ b/tests/unit/src/com/android/intentresolver/ChooserActionFactoryTest.kt @@ -69,6 +69,8 @@ class ChooserActionFactoryTest { latestReturn = resultCode } } + private val featureFlags = + FakeFeatureFlagsImpl().apply { setFlag(Flags.FLAG_FIX_PARTIAL_IMAGE_EDIT_TRANSITION, true) } @Before fun setup() { @@ -119,6 +121,7 @@ class ChooserActionFactoryTest { /* shareResultSender = */ null, /* finishCallback = */ {}, /* clipboardManager = */ mock(), + /* featureFlags = */ featureFlags, ) assertThat(testSubject.copyButtonRunnable).isNull() } @@ -140,6 +143,7 @@ class ChooserActionFactoryTest { /* shareResultSender = */ null, /* finishCallback = */ {}, /* clipboardManager = */ mock(), + /* featureFlags = */ featureFlags, ) assertThat(testSubject.copyButtonRunnable).isNull() } @@ -162,6 +166,7 @@ class ChooserActionFactoryTest { /* shareResultSender = */ resultSender, /* finishCallback = */ {}, /* clipboardManager = */ mock(), + /* featureFlags = */ featureFlags, ) assertThat(testSubject.copyButtonRunnable).isNotNull() @@ -194,6 +199,7 @@ class ChooserActionFactoryTest { /* shareResultSender = */ null, /* finishCallback = */ resultConsumer, /* clipboardManager = */ mock(), + /* featureFlags = */ featureFlags, ) } } -- cgit v1.2.3-59-g8ed1b From 5c9c3a7462dc45907ee30516f43aff68ada3d06d Mon Sep 17 00:00:00 2001 From: Mark Renouf Date: Fri, 24 May 2024 09:34:52 -0400 Subject: Disable sharing when device is under active FRP lock Prevent FRP bypass scenarios involving share intents This CL includes a cleanup of our Settings abstraction to cover Global, Secure and System, and updates existing tests. Bug: 327645387 Test: atest IntentResolver-tests-unit Test: atest ChooserActivityTest#chooserDisabledWhileDeviceFrpLocked Flag: EXEMPT refactor Change-Id: I928b6ea68aa8d6d710dc51eb70acd2cc2ec682c3 --- .../com/android/intentresolver/ChooserHelper.kt | 9 +++ .../com/android/intentresolver/SecureSettings.kt | 27 ------- .../intentresolver/platform/NearbyShareModule.kt | 2 +- .../platform/PlatformSecureSettings.kt | 46 ----------- .../intentresolver/platform/SecureSettings.kt | 41 ---------- .../platform/SecureSettingsModule.kt | 30 ------- .../intentresolver/platform/SettingsImpl.kt | 59 ++++++++++++++ .../intentresolver/platform/SettingsModule.kt | 33 ++++++++ .../intentresolver/platform/SettingsProxy.kt | 92 ++++++++++++++++++++++ .../intentresolver/ChooserActivityTest.java | 15 ++++ .../intentresolver/platform/FakeSettingsModule.kt | 33 ++++++++ .../intentresolver/platform/FakeSecureSettings.kt | 60 -------------- .../intentresolver/platform/FakeSettings.kt | 43 ++++++++++ .../platform/FakeSecureSettingsTest.kt | 77 ------------------ .../intentresolver/platform/FakeSettingsTest.kt | 81 +++++++++++++++++++ .../platform/NearbyShareModuleTest.kt | 6 +- 16 files changed, 369 insertions(+), 285 deletions(-) delete mode 100644 java/src/com/android/intentresolver/SecureSettings.kt delete mode 100644 java/src/com/android/intentresolver/platform/PlatformSecureSettings.kt delete mode 100644 java/src/com/android/intentresolver/platform/SecureSettings.kt delete mode 100644 java/src/com/android/intentresolver/platform/SecureSettingsModule.kt create mode 100644 java/src/com/android/intentresolver/platform/SettingsImpl.kt create mode 100644 java/src/com/android/intentresolver/platform/SettingsModule.kt create mode 100644 java/src/com/android/intentresolver/platform/SettingsProxy.kt create mode 100644 tests/activity/src/com/android/intentresolver/platform/FakeSettingsModule.kt delete mode 100644 tests/shared/src/com/android/intentresolver/platform/FakeSecureSettings.kt create mode 100644 tests/shared/src/com/android/intentresolver/platform/FakeSettings.kt delete mode 100644 tests/unit/src/com/android/intentresolver/platform/FakeSecureSettingsTest.kt create mode 100644 tests/unit/src/com/android/intentresolver/platform/FakeSettingsTest.kt (limited to 'java/src') diff --git a/java/src/com/android/intentresolver/ChooserHelper.kt b/java/src/com/android/intentresolver/ChooserHelper.kt index 6317ee1d..312911a6 100644 --- a/java/src/com/android/intentresolver/ChooserHelper.kt +++ b/java/src/com/android/intentresolver/ChooserHelper.kt @@ -18,6 +18,7 @@ package com.android.intentresolver import android.app.Activity import android.os.UserHandle +import android.provider.Settings import android.util.Log import androidx.activity.ComponentActivity import androidx.activity.viewModels @@ -30,6 +31,7 @@ import com.android.intentresolver.annotation.JavaInterop import com.android.intentresolver.contentpreview.payloadtoggle.data.repository.ActivityResultRepository import com.android.intentresolver.contentpreview.payloadtoggle.data.repository.PendingSelectionCallbackRepository import com.android.intentresolver.data.model.ChooserRequest +import com.android.intentresolver.platform.GlobalSettings import com.android.intentresolver.ui.viewmodel.ChooserViewModel import com.android.intentresolver.validation.Invalid import com.android.intentresolver.validation.Valid @@ -84,6 +86,7 @@ constructor( hostActivity: Activity, private val activityResultRepo: ActivityResultRepository, private val pendingSelectionCallbackRepo: PendingSelectionCallbackRepository, + private val globalSettings: GlobalSettings, ) : DefaultLifecycleObserver { // This is guaranteed by Hilt, since only a ComponentActivity is injectable. private val activity: ComponentActivity = hostActivity as ComponentActivity @@ -124,6 +127,12 @@ constructor( return } + if (globalSettings.getBooleanOrNull(Settings.Global.SECURE_FRP_MODE) == true) { + Log.e(TAG, "Sharing disabled due to active FRP lock.") + activity.finish() + return + } + when (val request = viewModel.initialRequest) { is Valid -> initializeActivity(request) is Invalid -> reportErrorsAndFinish(request) diff --git a/java/src/com/android/intentresolver/SecureSettings.kt b/java/src/com/android/intentresolver/SecureSettings.kt deleted file mode 100644 index 1e938895..00000000 --- a/java/src/com/android/intentresolver/SecureSettings.kt +++ /dev/null @@ -1,27 +0,0 @@ -/* - * Copyright (C) 2023 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.android.intentresolver - -import android.content.ContentResolver -import android.provider.Settings - -/** 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/platform/NearbyShareModule.kt b/java/src/com/android/intentresolver/platform/NearbyShareModule.kt index 6cb30b41..1e4b5241 100644 --- a/java/src/com/android/intentresolver/platform/NearbyShareModule.kt +++ b/java/src/com/android/intentresolver/platform/NearbyShareModule.kt @@ -41,7 +41,7 @@ object NearbyShareModule { fun nearbyShareComponent(@ApplicationOwned resources: Resources, settings: SecureSettings) = Optional.ofNullable( ComponentName.unflattenFromString( - settings.getString(NEARBY_SHARING_COMPONENT)?.ifEmpty { null } + settings.getStringOrNull(NEARBY_SHARING_COMPONENT)?.ifEmpty { null } ?: resources.getString(R.string.config_defaultNearbySharingComponent), ) ) diff --git a/java/src/com/android/intentresolver/platform/PlatformSecureSettings.kt b/java/src/com/android/intentresolver/platform/PlatformSecureSettings.kt deleted file mode 100644 index 0c802c97..00000000 --- a/java/src/com/android/intentresolver/platform/PlatformSecureSettings.kt +++ /dev/null @@ -1,46 +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.platform - -import android.content.ContentResolver -import android.provider.Settings -import javax.inject.Inject - -/** - * Implements [SecureSettings] backed by Settings.Secure and a ContentResolver. - * - * These methods make Binder calls and may block, so use on the Main thread should be avoided. - */ -class PlatformSecureSettings @Inject constructor(private val resolver: ContentResolver) : - SecureSettings { - - override fun getString(name: String): String? { - return Settings.Secure.getString(resolver, name) - } - - override fun getInt(name: String): Int? { - return runCatching { Settings.Secure.getInt(resolver, name) }.getOrNull() - } - - override fun getLong(name: String): Long? { - return runCatching { Settings.Secure.getLong(resolver, name) }.getOrNull() - } - - override fun getFloat(name: String): Float? { - return runCatching { Settings.Secure.getFloat(resolver, name) }.getOrNull() - } -} diff --git a/java/src/com/android/intentresolver/platform/SecureSettings.kt b/java/src/com/android/intentresolver/platform/SecureSettings.kt deleted file mode 100644 index 8a1dc531..00000000 --- a/java/src/com/android/intentresolver/platform/SecureSettings.kt +++ /dev/null @@ -1,41 +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.platform - -import android.provider.Settings.SettingNotFoundException - -/** - * A component which provides access to values from [android.provider.Settings.Secure]. - * - * All methods return nullable types instead of throwing [SettingNotFoundException] which yields - * cleaner, more idiomatic Kotlin code: - * - * // apply a default: val foo = settings.getInt(FOO) ?: DEFAULT_FOO - * - * // assert if missing: val required = settings.getInt(REQUIRED_VALUE) ?: error("required value - * missing") - */ -interface SecureSettings { - - fun getString(name: String): String? - - fun getInt(name: String): Int? - - fun getLong(name: String): Long? - - fun getFloat(name: String): Float? -} diff --git a/java/src/com/android/intentresolver/platform/SecureSettingsModule.kt b/java/src/com/android/intentresolver/platform/SecureSettingsModule.kt deleted file mode 100644 index fa3ee4fe..00000000 --- a/java/src/com/android/intentresolver/platform/SecureSettingsModule.kt +++ /dev/null @@ -1,30 +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.platform - -import dagger.Binds -import dagger.Module -import dagger.Reusable -import dagger.hilt.InstallIn -import dagger.hilt.components.SingletonComponent - -@Module -@InstallIn(SingletonComponent::class) -interface SecureSettingsModule { - - @Binds @Reusable fun secureSettings(settings: PlatformSecureSettings): SecureSettings -} diff --git a/java/src/com/android/intentresolver/platform/SettingsImpl.kt b/java/src/com/android/intentresolver/platform/SettingsImpl.kt new file mode 100644 index 00000000..c7ff3521 --- /dev/null +++ b/java/src/com/android/intentresolver/platform/SettingsImpl.kt @@ -0,0 +1,59 @@ +/* + * Copyright (C) 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.intentresolver.platform + +import android.content.ContentResolver +import android.provider.Settings +import javax.inject.Inject + +object SettingsImpl { + /** An implementation of GlobalSettings which forwards to [Settings.Global] */ + class Global @Inject constructor(private val contentResolver: ContentResolver) : + GlobalSettings { + override fun getStringOrNull(name: String): String? { + return Settings.Global.getString(contentResolver, name) + } + + override fun putString(name: String, value: String): Boolean { + return Settings.Global.putString(contentResolver, name, value) + } + } + + /** An implementation of SecureSettings which forwards to [Settings.Secure] */ + class Secure @Inject constructor(private val contentResolver: ContentResolver) : + SecureSettings { + override fun getStringOrNull(name: String): String? { + return Settings.Secure.getString(contentResolver, name) + } + + override fun putString(name: String, value: String): Boolean { + return Settings.Secure.putString(contentResolver, name, value) + } + } + + /** An implementation of SystemSettings which forwards to [Settings.System] */ + class System @Inject constructor(private val contentResolver: ContentResolver) : + SystemSettings { + override fun getStringOrNull(name: String): String? { + return Settings.System.getString(contentResolver, name) + } + + override fun putString(name: String, value: String): Boolean { + return Settings.System.putString(contentResolver, name, value) + } + } +} diff --git a/java/src/com/android/intentresolver/platform/SettingsModule.kt b/java/src/com/android/intentresolver/platform/SettingsModule.kt new file mode 100644 index 00000000..3d5c50da --- /dev/null +++ b/java/src/com/android/intentresolver/platform/SettingsModule.kt @@ -0,0 +1,33 @@ +/* + * Copyright (C) 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.intentresolver.platform + +import dagger.Binds +import dagger.Module +import dagger.Reusable +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent + +@Module +@InstallIn(SingletonComponent::class) +interface SettingsModule { + @Binds @Reusable fun globalSettings(settings: SettingsImpl.Global): GlobalSettings + + @Binds @Reusable fun secureSettings(settings: SettingsImpl.Secure): SecureSettings + + @Binds @Reusable fun systemSettings(settings: SettingsImpl.System): SystemSettings +} diff --git a/java/src/com/android/intentresolver/platform/SettingsProxy.kt b/java/src/com/android/intentresolver/platform/SettingsProxy.kt new file mode 100644 index 00000000..d97a0414 --- /dev/null +++ b/java/src/com/android/intentresolver/platform/SettingsProxy.kt @@ -0,0 +1,92 @@ +/* + * Copyright (C) 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.intentresolver.platform + +/** A proxy to Settings.Global */ +interface GlobalSettings : SettingsProxy + +/** A proxy to Settings.Secure */ +interface SecureSettings : SettingsProxy + +/** A proxy to Settings.System */ +interface SystemSettings : SettingsProxy + +/** A generic Settings proxy interface */ +sealed interface SettingsProxy { + + /** Returns the String value set for the given settings key, or null if no value exists. */ + fun getStringOrNull(name: String): String? + + /** + * Writes a new string value for the given settings key. + * + * @return true if the value did not previously exist or was modified + */ + fun putString(name: String, value: String): Boolean + + /** + * Returns the Int value for the given settings key or null if no value exists or it cannot be + * interpreted as an Int. + */ + fun getIntOrNull(name: String): Int? = getStringOrNull(name)?.toIntOrNull() + + /** + * Writes a new int value for the given settings key. + * + * @return true if the value did not previously exist or was modified + */ + fun putInt(name: String, value: Int): Boolean = putString(name, value.toString()) + + /** + * Returns the Boolean value for the given settings key or null if no value exists or it cannot + * be interpreted as a Boolean. + */ + fun getBooleanOrNull(name: String): Boolean? = getIntOrNull(name)?.let { it != 0 } + + /** + * Writes a new Boolean value for the given settings key. + * + * @return true if the value did not previously exist or was modified + */ + fun putBoolean(name: String, value: Boolean): Boolean = putInt(name, if (value) 1 else 0) + + /** + * Returns the Long value for the given settings key or null if no value exists or it cannot be + * interpreted as a Long. + */ + fun getLongOrNull(name: String): Long? = getStringOrNull(name)?.toLongOrNull() + + /** + * Writes a new Long value for the given settings key. + * + * @return true if the value did not previously exist or was modified + */ + fun putLong(name: String, value: Long): Boolean = putString(name, value.toString()) + + /** + * Returns the Float value for the given settings key or null if no value exists or it cannot be + * interpreted as a Float. + */ + fun getFloatOrNull(name: String): Float? = getStringOrNull(name)?.toFloatOrNull() + + /** + * Writes a new float value for the given settings key. + * + * @return true if the value did not previously exist or was modified + */ + fun putFloat(name: String, value: Float): Boolean = putString(name, value.toString()) +} diff --git a/tests/activity/src/com/android/intentresolver/ChooserActivityTest.java b/tests/activity/src/com/android/intentresolver/ChooserActivityTest.java index 66f7650d..24b7fb12 100644 --- a/tests/activity/src/com/android/intentresolver/ChooserActivityTest.java +++ b/tests/activity/src/com/android/intentresolver/ChooserActivityTest.java @@ -91,6 +91,7 @@ import android.os.UserHandle; import android.platform.test.flag.junit.CheckFlagsRule; import android.platform.test.flag.junit.DeviceFlagsValueProvider; import android.provider.DeviceConfig; +import android.provider.Settings; import android.service.chooser.ChooserAction; import android.service.chooser.ChooserTarget; import android.text.Spannable; @@ -130,6 +131,7 @@ import com.android.intentresolver.logging.EventLog; import com.android.intentresolver.logging.FakeEventLog; import com.android.intentresolver.platform.AppPredictionAvailable; import com.android.intentresolver.platform.AppPredictionModule; +import com.android.intentresolver.platform.GlobalSettings; import com.android.intentresolver.platform.ImageEditor; import com.android.intentresolver.platform.ImageEditorModule; import com.android.intentresolver.shared.model.User; @@ -233,6 +235,9 @@ public class ChooserActivityTest { @ApplicationContext Context mContext; + @Inject + GlobalSettings mGlobalSettings; + /** An arbitrary pre-installed activity that handles this type of intent. */ @BindValue @ImageEditor @@ -2754,6 +2759,16 @@ public class ChooserActivityTest { assertThat(activity.getCurrentUserHandle(), is(PERSONAL_USER_HANDLE)); } + @Test + public void chooserDisabledWhileDeviceFrpLocked() { + mGlobalSettings.putBoolean(Settings.Global.SECURE_FRP_MODE, true); + Intent viewIntent = createSendTextIntent(); + ChooserWrapperActivity activity = mActivityRule.launchActivity( + Intent.createChooser(viewIntent, "chooser test")); + waitForIdle(); + assertTrue(activity.isFinishing()); + } + private Intent createChooserIntent(Intent intent, Intent[] initialIntents) { Intent chooserIntent = new Intent(); chooserIntent.setAction(Intent.ACTION_CHOOSER); diff --git a/tests/activity/src/com/android/intentresolver/platform/FakeSettingsModule.kt b/tests/activity/src/com/android/intentresolver/platform/FakeSettingsModule.kt new file mode 100644 index 00000000..9295f054 --- /dev/null +++ b/tests/activity/src/com/android/intentresolver/platform/FakeSettingsModule.kt @@ -0,0 +1,33 @@ +/* + * Copyright (C) 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.intentresolver.platform + +import dagger.Module +import dagger.Provides +import dagger.hilt.components.SingletonComponent +import dagger.hilt.testing.TestInstallIn +import javax.inject.Singleton + +@Module +@TestInstallIn(components = [SingletonComponent::class], replaces = [SettingsModule::class]) +object FakeSettingsModule { + @Provides @Singleton fun secureSettings(): SecureSettings = FakeSettings() + + @Provides @Singleton fun systemSettings(): SystemSettings = FakeSettings() + + @Provides @Singleton fun globalSettings(): GlobalSettings = FakeSettings() +} diff --git a/tests/shared/src/com/android/intentresolver/platform/FakeSecureSettings.kt b/tests/shared/src/com/android/intentresolver/platform/FakeSecureSettings.kt deleted file mode 100644 index 862be76f..00000000 --- a/tests/shared/src/com/android/intentresolver/platform/FakeSecureSettings.kt +++ /dev/null @@ -1,60 +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.platform - -/** - * Creates a SecureSettings instance with predefined values: - * - * val settings = fakeSecureSettings { - * putString("stringValue", "example") - * putInt("intValue", 42) - * } - */ -fun fakeSecureSettings(block: FakeSecureSettings.Builder.() -> Unit): SecureSettings { - return FakeSecureSettings.Builder().apply(block).build() -} - -/** An in memory implementation of [SecureSettings]. */ -class FakeSecureSettings private constructor(private val map: Map) : - SecureSettings { - - override fun getString(name: String): String? = map[name] - override fun getInt(name: String): Int? = getString(name)?.toIntOrNull() - override fun getLong(name: String): Long? = getString(name)?.toLongOrNull() - override fun getFloat(name: String): Float? = getString(name)?.toFloatOrNull() - - class Builder { - private val map = mutableMapOf() - - fun putString(name: String, value: String) { - map[name] = value - } - fun putInt(name: String, value: Int) { - map[name] = value.toString() - } - fun putLong(name: String, value: Long) { - map[name] = value.toString() - } - fun putFloat(name: String, value: Float) { - map[name] = value.toString() - } - - fun build(): SecureSettings { - return FakeSecureSettings(map.toMap()) - } - } -} diff --git a/tests/shared/src/com/android/intentresolver/platform/FakeSettings.kt b/tests/shared/src/com/android/intentresolver/platform/FakeSettings.kt new file mode 100644 index 00000000..55cd7127 --- /dev/null +++ b/tests/shared/src/com/android/intentresolver/platform/FakeSettings.kt @@ -0,0 +1,43 @@ +/* + * Copyright (C) 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.intentresolver.platform + +/** + * Creates a Settings instance with predefined values: + * + * val settings: SecureSettings = fakeSettings { + * putString("stringValue", "example") + * putInt("intValue", 42) + * } + */ +inline fun fakeSettings(block: SettingsProxy.() -> Unit): T { + return FakeSettings(mutableMapOf()).apply(block) as T +} + +/** A memory-only implementation of [SettingsProxy]. */ +class FakeSettings( + private val map: MutableMap, +) : GlobalSettings, SecureSettings, SystemSettings { + constructor() : this(mutableMapOf()) + + override fun getStringOrNull(name: String): String? = map[name] + + override fun putString(name: String, value: String): Boolean { + map[name] = value + return true + } +} diff --git a/tests/unit/src/com/android/intentresolver/platform/FakeSecureSettingsTest.kt b/tests/unit/src/com/android/intentresolver/platform/FakeSecureSettingsTest.kt deleted file mode 100644 index fd74b50a..00000000 --- a/tests/unit/src/com/android/intentresolver/platform/FakeSecureSettingsTest.kt +++ /dev/null @@ -1,77 +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.platform - -import com.google.common.truth.Truth.assertThat - -class FakeSecureSettingsTest { - - private val secureSettings = fakeSecureSettings { - putInt(intKey, intVal) - putString(stringKey, stringVal) - putFloat(floatKey, floatVal) - putLong(longKey, longVal) - } - - fun testExpectedValues_returned() { - assertThat(secureSettings.getInt(intKey)).isEqualTo(intVal) - assertThat(secureSettings.getString(stringKey)).isEqualTo(stringVal) - assertThat(secureSettings.getFloat(floatKey)).isEqualTo(floatVal) - assertThat(secureSettings.getLong(longKey)).isEqualTo(longVal) - } - - fun testUndefinedValues_returnNull() { - assertThat(secureSettings.getInt("unknown")).isNull() - assertThat(secureSettings.getString("unknown")).isNull() - assertThat(secureSettings.getFloat("unknown")).isNull() - assertThat(secureSettings.getLong("unknown")).isNull() - } - - /** - * FakeSecureSettings models the real secure settings by storing values in String form. The - * value is returned if/when it can be parsed from the string value, otherwise null. - */ - fun testMismatchedTypes() { - assertThat(secureSettings.getString(intKey)).isEqualTo(intVal.toString()) - assertThat(secureSettings.getString(floatKey)).isEqualTo(floatVal.toString()) - assertThat(secureSettings.getString(longKey)).isEqualTo(longVal.toString()) - - assertThat(secureSettings.getInt(stringKey)).isNull() - assertThat(secureSettings.getLong(stringKey)).isNull() - assertThat(secureSettings.getFloat(stringKey)).isNull() - - assertThat(secureSettings.getInt(longKey)).isNull() - assertThat(secureSettings.getFloat(longKey)).isNull() // TODO: verify Long.MAX > Float.MAX ? - - assertThat(secureSettings.getLong(floatKey)).isNull() // TODO: or is Float.MAX > Long.MAX? - assertThat(secureSettings.getInt(floatKey)).isNull() - } - - companion object Data { - const val intKey = "int" - const val intVal = Int.MAX_VALUE - - const val stringKey = "string" - const val stringVal = "String" - - const val floatKey = "float" - const val floatVal = Float.MAX_VALUE - - const val longKey = "long" - const val longVal = Long.MAX_VALUE - } -} diff --git a/tests/unit/src/com/android/intentresolver/platform/FakeSettingsTest.kt b/tests/unit/src/com/android/intentresolver/platform/FakeSettingsTest.kt new file mode 100644 index 00000000..82daca55 --- /dev/null +++ b/tests/unit/src/com/android/intentresolver/platform/FakeSettingsTest.kt @@ -0,0 +1,81 @@ +/* + * Copyright (C) 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.intentresolver.platform + +import com.google.common.truth.Truth.assertThat +import org.junit.Test + +class FakeSettingsTest { + + private val settings: FakeSettings = fakeSettings { + putInt(intKey, intVal) + putString(stringKey, stringVal) + putFloat(floatKey, floatVal) + putLong(longKey, longVal) + } + + @Test + fun testExpectedValues_returned() { + assertThat(settings.getIntOrNull(intKey)).isEqualTo(intVal) + assertThat(settings.getStringOrNull(stringKey)).isEqualTo(stringVal) + assertThat(settings.getFloatOrNull(floatKey)).isEqualTo(floatVal) + assertThat(settings.getLongOrNull(longKey)).isEqualTo(longVal) + } + + @Test + fun testUndefinedValues_returnNull() { + assertThat(settings.getIntOrNull("unknown")).isNull() + assertThat(settings.getStringOrNull("unknown")).isNull() + assertThat(settings.getFloatOrNull("unknown")).isNull() + assertThat(settings.getLongOrNull("unknown")).isNull() + } + + /** + * FakeSecureSettings models the real secure settings by storing values in String form. The + * value is returned if/when it can be parsed from the string value, otherwise null. + */ + @Test + fun testMismatchedTypes() { + assertThat(settings.getStringOrNull(intKey)).isEqualTo(intVal.toString()) + assertThat(settings.getStringOrNull(floatKey)).isEqualTo(floatVal.toString()) + assertThat(settings.getStringOrNull(longKey)).isEqualTo(longVal.toString()) + + assertThat(settings.getIntOrNull(stringKey)).isNull() + assertThat(settings.getLongOrNull(stringKey)).isNull() + assertThat(settings.getFloatOrNull(stringKey)).isNull() + + assertThat(settings.getIntOrNull(longKey)).isNull() + assertThat(settings.getFloatOrNull(longKey)).isWithin(0.00001f).of(Long.MAX_VALUE.toFloat()) + + assertThat(settings.getLongOrNull(floatKey)).isNull() + assertThat(settings.getIntOrNull(floatKey)).isNull() + } + + companion object Data { + const val intKey = "int" + const val intVal = Int.MAX_VALUE + + const val stringKey = "string" + const val stringVal = "String" + + const val floatKey = "float" + const val floatVal = Float.MAX_VALUE + + const val longKey = "long" + const val longVal = Long.MAX_VALUE + } +} diff --git a/tests/unit/src/com/android/intentresolver/platform/NearbyShareModuleTest.kt b/tests/unit/src/com/android/intentresolver/platform/NearbyShareModuleTest.kt index 71ef2919..6e5c97c2 100644 --- a/tests/unit/src/com/android/intentresolver/platform/NearbyShareModuleTest.kt +++ b/tests/unit/src/com/android/intentresolver/platform/NearbyShareModuleTest.kt @@ -49,7 +49,7 @@ class NearbyShareModuleTest { @Test fun valueIsAbsent_whenUnset() { - val secureSettings = fakeSecureSettings {} + val secureSettings: SecureSettings = fakeSettings {} val resources = context.fakeResources { addOverride(R.string.config_defaultNearbySharingComponent, "") } @@ -59,7 +59,7 @@ class NearbyShareModuleTest { @Test fun defaultValue_readFromResources() { - val secureSettings = fakeSecureSettings {} + val secureSettings: SecureSettings = fakeSettings {} val resources = context.fakeResources { addOverride( @@ -76,7 +76,7 @@ class NearbyShareModuleTest { @Test fun secureSettings_overridesDefault() { - val secureSettings = fakeSecureSettings { + val secureSettings: SecureSettings = fakeSettings { putString(Settings.Secure.NEARBY_SHARING_COMPONENT, "com.example/.BComponent") } val resources = -- cgit v1.2.3-59-g8ed1b From 7ba95b9cfbcef84efbcc0ccc3ac596ab08d320e3 Mon Sep 17 00:00:00 2001 From: Mark Renouf Date: Wed, 29 May 2024 21:46:09 -0400 Subject: Suppress ShareResult for launch of Editor component An edit action is reported explicitly as a ShareResult with type=EDIT. This change suppresses dispatch of a ShareResult with type 'COMPONENT_SELECTED' type when launching the editor component. Test: CTS-V Bug: NONE Flag: EXEMPT bugfix Change-Id: Ie13464cba88191dc26d8e5d9758541fd6c75017c --- java/src/com/android/intentresolver/ChooserActionFactory.java | 4 +++- java/src/com/android/intentresolver/ChooserActivity.java | 6 +++++- 2 files changed, 8 insertions(+), 2 deletions(-) (limited to 'java/src') diff --git a/java/src/com/android/intentresolver/ChooserActionFactory.java b/java/src/com/android/intentresolver/ChooserActionFactory.java index 79998fbc..d6153b36 100644 --- a/java/src/com/android/intentresolver/ChooserActionFactory.java +++ b/java/src/com/android/intentresolver/ChooserActionFactory.java @@ -88,7 +88,9 @@ public final class ChooserActionFactory implements ChooserContentPreviewUi.Actio // Boolean extra used to inform the editor that it may want to customize the editing experience // for the sharesheet editing flow. - private static final String EDIT_SOURCE = "edit_source"; + // Note: EDIT_SOURCE is also used as a signal to avoid sending a 'Component Selected' + // ShareResult for this intent when sent via ChooserActivity#safelyStartActivityAsUser + static final String EDIT_SOURCE = "edit_source"; private static final String EDIT_SOURCE_SHARESHEET = "sharesheet"; private static final String CHIP_LABEL_METADATA_KEY = "android.service.chooser.chip_label"; diff --git a/java/src/com/android/intentresolver/ChooserActivity.java b/java/src/com/android/intentresolver/ChooserActivity.java index 9643b9f0..66810187 100644 --- a/java/src/com/android/intentresolver/ChooserActivity.java +++ b/java/src/com/android/intentresolver/ChooserActivity.java @@ -22,6 +22,7 @@ import static android.view.WindowManager.LayoutParams.SYSTEM_FLAG_HIDE_NON_SYSTE import static androidx.lifecycle.LifecycleKt.getCoroutineScope; +import static com.android.intentresolver.ChooserActionFactory.EDIT_SOURCE; import static com.android.intentresolver.ext.CreationExtrasExtKt.addDefaultArgs; import static com.android.intentresolver.profiles.MultiProfilePagerAdapter.PROFILE_PERSONAL; import static com.android.intentresolver.profiles.MultiProfilePagerAdapter.PROFILE_WORK; @@ -1079,7 +1080,10 @@ public class ChooserActivity extends Hilt_ChooserActivity implements } try { if (cti.startAsCaller(this, options, user.getIdentifier())) { - maybeSendShareResult(cti); + // Prevent sending a second chooser result when starting the edit action intent. + if (!cti.getTargetIntent().hasExtra(EDIT_SOURCE)) { + maybeSendShareResult(cti); + } maybeLogCrossProfileTargetLaunch(cti, user); } } catch (RuntimeException e) { -- cgit v1.2.3-59-g8ed1b From 41bb0f4081f43e84cf4120b79ad4957311b4896b Mon Sep 17 00:00:00 2001 From: Mark Renouf Date: Wed, 29 May 2024 22:00:09 -0400 Subject: Add logcat to all finish() call sites For future troubleshooting aid and ongoing investigation of b/334179669 (sharesheet dismisses unexpectedly during GTS-I). The theory is that Sharesheet is incorrectly invoking finish at some point after switching tabs within the test, but there is no way to identify the code path taken to calling finish(). Bug: 334179669 Flag: EXEMPT bugfix Test: compile Change-Id: I6b81b87506ce1ecff795772f9d1283ce533552a3 --- .../com/android/intentresolver/ChooserActionFactory.java | 2 ++ java/src/com/android/intentresolver/ChooserActivity.java | 14 ++++++++++++++ 2 files changed, 16 insertions(+) (limited to 'java/src') diff --git a/java/src/com/android/intentresolver/ChooserActionFactory.java b/java/src/com/android/intentresolver/ChooserActionFactory.java index d6153b36..dae1ab52 100644 --- a/java/src/com/android/intentresolver/ChooserActionFactory.java +++ b/java/src/com/android/intentresolver/ChooserActionFactory.java @@ -257,6 +257,7 @@ public final class ChooserActionFactory implements ChooserContentPreviewUi.Actio clipboardManager.setPrimaryClipAsPackage(clipData, referrerPackageName); log.logActionSelected(EventLog.SELECTION_TYPE_COPY); + Log.d(TAG, "finish due to copy clicked"); finishCallback.accept(Activity.RESULT_OK); }; } @@ -395,6 +396,7 @@ public final class ChooserActionFactory implements ChooserContentPreviewUi.Actio if (shareResultSender != null) { shareResultSender.onActionSelected(ShareAction.APPLICATION_DEFINED); } + Log.d(TAG, "finish due to custom action clicked"); finishCallback.accept(Activity.RESULT_OK); } ); diff --git a/java/src/com/android/intentresolver/ChooserActivity.java b/java/src/com/android/intentresolver/ChooserActivity.java index 66810187..7353ff37 100644 --- a/java/src/com/android/intentresolver/ChooserActivity.java +++ b/java/src/com/android/intentresolver/ChooserActivity.java @@ -393,6 +393,7 @@ public class ChooserActivity extends Hilt_ChooserActivity implements // so we will now finish ourself since being no longer visible, // the user probably can't get back to us. if (!isChangingConfigurations()) { + Log.d(TAG, "finishing in onStop"); finish(); } } @@ -725,6 +726,9 @@ public class ChooserActivity extends Hilt_ChooserActivity implements } private void onAppTargetsLoaded(ResolverListAdapter listAdapter) { + Log.d(TAG, "onAppTargetsLoaded(" + + "listAdapter.userHandle=" + listAdapter.getUserHandle() + ")"); + if (mChooserMultiProfilePagerAdapter == null) { return; } @@ -860,6 +864,7 @@ public class ChooserActivity extends Hilt_ChooserActivity implements final TargetInfo target = mChooserMultiProfilePagerAdapter.getActiveListAdapter() .targetInfoForPosition(0, false); if (shouldAutoLaunchSingleChoice(target)) { + Log.d(TAG, "auto launching " + target + " and finishing."); safelyStartActivity(target); finish(); return true; @@ -928,6 +933,7 @@ public class ChooserActivity extends Hilt_ChooserActivity implements .setStrings(getMetricsCategory()) .write(); safelyStartActivity(activeProfileTarget); + Log.d(TAG, "auto launching! " + activeProfileTarget); finish(); return true; } @@ -1194,6 +1200,7 @@ public class ChooserActivity extends Hilt_ChooserActivity implements (ChooserListAdapter) listAdapter, mProfileAvailability.getWaitingToEnableProfile())) { // We no longer have any items... just finish the activity. + Log.d(TAG, "onHandlePackagesChanged(): returned false, finishing"); finish(); } } @@ -1761,6 +1768,7 @@ public class ChooserActivity extends Hilt_ChooserActivity implements mChooserMultiProfilePagerAdapter.getActiveListAdapter().hasFilteredItem() ? MetricsEvent.ACTION_HIDE_APP_DISAMBIG_APP_FEATURED : MetricsEvent.ACTION_HIDE_APP_DISAMBIG_NONE_FEATURED); + Log.d(TAG, "onTargetSelected() returned true, finishing! " + target); finish(); } } @@ -2183,6 +2191,8 @@ public class ChooserActivity extends Hilt_ChooserActivity implements targetInfo, mProfiles.getPersonalHandle() ); + Log.d(TAG, "safelyStartActivityAsPersonalProfileUser(" + + targetInfo + "): finishing!"); finish(); } @@ -2218,6 +2228,7 @@ public class ChooserActivity extends Hilt_ChooserActivity implements if (status != null) { setResult(status); } + Log.d(TAG, "finishWithStatus: result=" + status); finish(); } @@ -2360,6 +2371,8 @@ public class ChooserActivity extends Hilt_ChooserActivity implements } protected void onListRebuilt(ResolverListAdapter listAdapter, boolean rebuildComplete) { + Log.d(TAG, "onListRebuilt(listAdapter.userHandle=" + listAdapter.getUserHandle() + ", " + + "rebuildComplete=" + rebuildComplete + ")"); setupScrollListener(); maybeSetupGlobalLayoutListener(); @@ -2375,6 +2388,7 @@ public class ChooserActivity extends Hilt_ChooserActivity implements //TODO: move this block inside ChooserListAdapter (should be called when // ResolverListAdapter#mPostListReadyRunnable is executed. if (chooserListAdapter.getDisplayResolveInfoCount() == 0) { + Log.d(TAG, "getDisplayResolveInfoCount() == 0"); if (rebuildComplete && mChooserServiceFeatureFlags.chooserPayloadToggling()) { onAppTargetsLoaded(listAdapter); } -- cgit v1.2.3-59-g8ed1b From df8d829a146df9d044dfe044058d22ec3ca9a2b7 Mon Sep 17 00:00:00 2001 From: Andrey Epin Date: Thu, 30 May 2024 14:40:03 -0700 Subject: Cache shortcut icons Cache icons for shortcuts loaded from the system. Bug: 325465291 Test: atest IntentResolver-tests-unit Test: atest IntentResolver-tests-activity Test: manual payload selection change testing Flag: android.service.chooser.chooser_payload_toggling Change-Id: I19bad04c88e636b17d48876069de47363e3a24ae --- .../android/intentresolver/ChooserListAdapter.java | 14 ++- .../icons/CachingTargetDataLoader.kt | 36 +++++-- .../icons/DefaultTargetDataLoader.kt | 5 +- .../icons/LoadDirectShareIconTask.java | 5 +- .../intentresolver/icons/TargetDataLoader.kt | 4 +- .../intentresolver/ResolverWrapperActivity.java | 5 +- .../intentresolver/ChooserListAdapterTest.kt | 4 +- .../icons/CachingTargetDataLoaderTest.kt | 117 +++++++++++++++++++++ 8 files changed, 170 insertions(+), 20 deletions(-) create mode 100644 tests/unit/src/com/android/intentresolver/icons/CachingTargetDataLoaderTest.kt (limited to 'java/src') diff --git a/java/src/com/android/intentresolver/ChooserListAdapter.java b/java/src/com/android/intentresolver/ChooserListAdapter.java index 8b848e55..ff0c40d7 100644 --- a/java/src/com/android/intentresolver/ChooserListAdapter.java +++ b/java/src/com/android/intentresolver/ChooserListAdapter.java @@ -479,17 +479,23 @@ public class ChooserListAdapter extends ResolverListAdapter { private void loadDirectShareIcon(SelectableTargetInfo info) { if (mRequestedIcons.add(info)) { - mTargetDataLoader.loadDirectShareIcon( + Drawable icon = mTargetDataLoader.getOrLoadDirectShareIcon( info, getUserHandle(), - (drawable) -> onDirectShareIconLoaded(info, drawable)); + (drawable) -> onDirectShareIconLoaded(info, drawable, true)); + if (icon != null) { + onDirectShareIconLoaded(info, icon, false); + } } } - private void onDirectShareIconLoaded(SelectableTargetInfo mTargetInfo, Drawable icon) { + private void onDirectShareIconLoaded( + SelectableTargetInfo mTargetInfo, @Nullable Drawable icon, boolean notify) { if (icon != null && !mTargetInfo.hasDisplayIcon()) { mTargetInfo.getDisplayIconHolder().setDisplayIcon(icon); - notifyDataSetChanged(); + if (notify) { + notifyDataSetChanged(); + } } } diff --git a/java/src/com/android/intentresolver/icons/CachingTargetDataLoader.kt b/java/src/com/android/intentresolver/icons/CachingTargetDataLoader.kt index b3054231..8474b4c3 100644 --- a/java/src/com/android/intentresolver/icons/CachingTargetDataLoader.kt +++ b/java/src/com/android/intentresolver/icons/CachingTargetDataLoader.kt @@ -28,7 +28,7 @@ import javax.inject.Qualifier @Qualifier @MustBeDocumented @Retention(AnnotationRetention.BINARY) annotation class Caching -private typealias IconCache = LruCache +private typealias IconCache = LruCache class CachingTargetDataLoader( private val targetDataLoader: TargetDataLoader, @@ -49,18 +49,27 @@ class CachingTargetDataLoader( } } - override fun loadDirectShareIcon( + override fun getOrLoadDirectShareIcon( info: SelectableTargetInfo, userHandle: UserHandle, callback: Consumer - ) = targetDataLoader.loadDirectShareIcon(info, userHandle, callback) + ): Drawable? { + val cacheKey = info.toCacheKey() + return cacheKey?.let { getCachedAppIcon(it, userHandle) } + ?: targetDataLoader.getOrLoadDirectShareIcon(info, userHandle) { drawable -> + if (cacheKey != null) { + getProfileIconCache(userHandle).put(cacheKey, drawable) + } + callback.accept(drawable) + } + } override fun loadLabel(info: DisplayResolveInfo, callback: Consumer) = targetDataLoader.loadLabel(info, callback) override fun getOrLoadLabel(info: DisplayResolveInfo) = targetDataLoader.getOrLoadLabel(info) - private fun getCachedAppIcon(component: ComponentName, userHandle: UserHandle): Drawable? = + private fun getCachedAppIcon(component: String, userHandle: UserHandle): Drawable? = getProfileIconCache(userHandle)[component] private fun getProfileIconCache(userHandle: UserHandle): IconCache = @@ -70,7 +79,20 @@ class CachingTargetDataLoader( private fun DisplayResolveInfo.toCacheKey() = ComponentName( - resolveInfo.activityInfo.packageName, - resolveInfo.activityInfo.name, - ) + resolveInfo.activityInfo.packageName, + resolveInfo.activityInfo.name, + ) + .flattenToString() + + private fun SelectableTargetInfo.toCacheKey(): String? = + if (chooserTargetIcon != null) { + // do not cache icons for caller-provided targets + null + } else { + buildString { + append(chooserTargetComponentName?.flattenToString() ?: "") + append("|") + append(directShareShortcutInfo?.id ?: "") + } + } } diff --git a/java/src/com/android/intentresolver/icons/DefaultTargetDataLoader.kt b/java/src/com/android/intentresolver/icons/DefaultTargetDataLoader.kt index 1a724d73..e7392f58 100644 --- a/java/src/com/android/intentresolver/icons/DefaultTargetDataLoader.kt +++ b/java/src/com/android/intentresolver/icons/DefaultTargetDataLoader.kt @@ -77,11 +77,11 @@ class DefaultTargetDataLoader( return null } - override fun loadDirectShareIcon( + override fun getOrLoadDirectShareIcon( info: SelectableTargetInfo, userHandle: UserHandle, callback: Consumer, - ) { + ): Drawable? { val taskId = nextTaskId.getAndIncrement() LoadDirectShareIconTask( context.createContextAsUser(userHandle, 0), @@ -93,6 +93,7 @@ class DefaultTargetDataLoader( } .also { addTask(taskId, it) } .executeOnExecutor(executor) + return null } override fun loadLabel(info: DisplayResolveInfo, callback: Consumer) { diff --git a/java/src/com/android/intentresolver/icons/LoadDirectShareIconTask.java b/java/src/com/android/intentresolver/icons/LoadDirectShareIconTask.java index 0f135d63..e2c0362d 100644 --- a/java/src/com/android/intentresolver/icons/LoadDirectShareIconTask.java +++ b/java/src/com/android/intentresolver/icons/LoadDirectShareIconTask.java @@ -57,7 +57,7 @@ class LoadDirectShareIconTask extends BaseLoadIconTask { @Override protected Drawable doInBackground(Void... voids) { - Drawable drawable; + Drawable drawable = null; Trace.beginSection("shortcut-icon"); try { final Icon icon = mTargetInfo.getChooserTargetIcon(); @@ -70,6 +70,8 @@ class LoadDirectShareIconTask extends BaseLoadIconTask { } else { Log.e(TAG, "Failed to load shortcut icon for " + mTargetInfo.getChooserTargetComponentName() + "; no access"); + } + if (drawable == null) { drawable = loadIconPlaceholder(); } } catch (Exception e) { @@ -86,6 +88,7 @@ class LoadDirectShareIconTask extends BaseLoadIconTask { } @WorkerThread + @Nullable private Drawable getChooserTargetIconDrawable( Context context, @Nullable Icon icon, diff --git a/java/src/com/android/intentresolver/icons/TargetDataLoader.kt b/java/src/com/android/intentresolver/icons/TargetDataLoader.kt index 7789df44..935b527a 100644 --- a/java/src/com/android/intentresolver/icons/TargetDataLoader.kt +++ b/java/src/com/android/intentresolver/icons/TargetDataLoader.kt @@ -32,11 +32,11 @@ abstract class TargetDataLoader { ): Drawable? /** Load a shortcut icon */ - abstract fun loadDirectShareIcon( + abstract fun getOrLoadDirectShareIcon( info: SelectableTargetInfo, userHandle: UserHandle, callback: Consumer, - ) + ): Drawable? /** Load target label */ abstract fun loadLabel(info: DisplayResolveInfo, callback: Consumer) diff --git a/tests/activity/src/com/android/intentresolver/ResolverWrapperActivity.java b/tests/activity/src/com/android/intentresolver/ResolverWrapperActivity.java index b46d8bc3..22633085 100644 --- a/tests/activity/src/com/android/intentresolver/ResolverWrapperActivity.java +++ b/tests/activity/src/com/android/intentresolver/ResolverWrapperActivity.java @@ -180,11 +180,12 @@ public class ResolverWrapperActivity extends ResolverActivity { } @Override - public void loadDirectShareIcon( + @Nullable + public Drawable getOrLoadDirectShareIcon( @NonNull SelectableTargetInfo info, @NonNull UserHandle userHandle, @NonNull Consumer callback) { - mTargetDataLoader.loadDirectShareIcon(info, userHandle, callback); + return mTargetDataLoader.getOrLoadDirectShareIcon(info, userHandle, callback); } @Override diff --git a/tests/unit/src/com/android/intentresolver/ChooserListAdapterTest.kt b/tests/unit/src/com/android/intentresolver/ChooserListAdapterTest.kt index 5ac4f2b0..bad3b18c 100644 --- a/tests/unit/src/com/android/intentresolver/ChooserListAdapterTest.kt +++ b/tests/unit/src/com/android/intentresolver/ChooserListAdapterTest.kt @@ -101,7 +101,7 @@ class ChooserListAdapterTest { val targetInfo = createSelectableTargetInfo() testSubject.onBindView(view, targetInfo, 0) - verify(mTargetDataLoader, times(1)).loadDirectShareIcon(any(), any(), any()) + verify(mTargetDataLoader, times(1)).getOrLoadDirectShareIcon(any(), any(), any()) } @Test @@ -117,7 +117,7 @@ class ChooserListAdapterTest { testSubject.onBindView(view, targetInfo, 0) - verify(mTargetDataLoader, times(1)).loadDirectShareIcon(any(), any(), any()) + verify(mTargetDataLoader, times(1)).getOrLoadDirectShareIcon(any(), any(), any()) } @Test diff --git a/tests/unit/src/com/android/intentresolver/icons/CachingTargetDataLoaderTest.kt b/tests/unit/src/com/android/intentresolver/icons/CachingTargetDataLoaderTest.kt new file mode 100644 index 00000000..a36b512b --- /dev/null +++ b/tests/unit/src/com/android/intentresolver/icons/CachingTargetDataLoaderTest.kt @@ -0,0 +1,117 @@ +/* + * Copyright 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.intentresolver.icons + +import android.content.ComponentName +import android.content.Context +import android.content.Intent +import android.content.pm.ShortcutInfo +import android.graphics.Bitmap +import android.graphics.drawable.BitmapDrawable +import android.graphics.drawable.Drawable +import android.graphics.drawable.Icon +import android.os.UserHandle +import com.android.intentresolver.chooser.SelectableTargetInfo +import java.util.function.Consumer +import org.junit.Test +import org.mockito.kotlin.any +import org.mockito.kotlin.doAnswer +import org.mockito.kotlin.doReturn +import org.mockito.kotlin.eq +import org.mockito.kotlin.mock +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever + +class CachingTargetDataLoaderTest { + private val userHandle = UserHandle.of(1) + + @Test + fun doNotCacheCallerProvidedShortcuts() { + val callerTarget = + SelectableTargetInfo.newSelectableTargetInfo( + /* sourceInfo = */ null, + /* backupResolveInfo = */ null, + /* resolvedIntent = */ Intent(), + /* chooserTargetComponentName =*/ ComponentName("package", "Activity"), + "chooserTargetUninitializedTitle", + /* chooserTargetIcon =*/ Icon.createWithContentUri("content://package/icon.png"), + /* chooserTargetIntentExtras =*/ null, + /* modifiedScore =*/ 1f, + /* shortcutInfo = */ null, + /* appTarget = */ null, + /* referrerFillInIntent = */ Intent(), + ) as SelectableTargetInfo + + val targetDataLoader = + mock { + on { getOrLoadDirectShareIcon(eq(callerTarget), eq(userHandle), any()) } doReturn + null + } + val testSubject = CachingTargetDataLoader(targetDataLoader) + val callback = Consumer {} + + testSubject.getOrLoadDirectShareIcon(callerTarget, userHandle, callback) + testSubject.getOrLoadDirectShareIcon(callerTarget, userHandle, callback) + + verify(targetDataLoader) { + 2 * { getOrLoadDirectShareIcon(eq(callerTarget), eq(userHandle), any()) } + } + } + + @Test + fun serviceShortcutsAreCached() { + val context = + mock { + on { userId } doReturn 1 + on { packageName } doReturn "package" + } + val targetInfo = + SelectableTargetInfo.newSelectableTargetInfo( + /* sourceInfo = */ null, + /* backupResolveInfo = */ null, + /* resolvedIntent = */ Intent(), + /* chooserTargetComponentName =*/ ComponentName("package", "Activity"), + "chooserTargetUninitializedTitle", + /* chooserTargetIcon =*/ null, + /* chooserTargetIntentExtras =*/ null, + /* modifiedScore =*/ 1f, + /* shortcutInfo = */ ShortcutInfo.Builder(context, "1").build(), + /* appTarget = */ null, + /* referrerFillInIntent = */ Intent(), + ) as SelectableTargetInfo + + val targetDataLoader = mock() + doAnswer { + val callback = it.arguments[2] as Consumer + callback.accept(BitmapDrawable(createBitmap())) + null + } + .whenever(targetDataLoader) + .getOrLoadDirectShareIcon(eq(targetInfo), eq(userHandle), any()) + val testSubject = CachingTargetDataLoader(targetDataLoader) + val callback = Consumer {} + + testSubject.getOrLoadDirectShareIcon(targetInfo, userHandle, callback) + testSubject.getOrLoadDirectShareIcon(targetInfo, userHandle, callback) + + verify(targetDataLoader) { + 1 * { getOrLoadDirectShareIcon(eq(targetInfo), eq(userHandle), any()) } + } + } +} + +private fun createBitmap() = Bitmap.createBitmap(100, 100, Bitmap.Config.ARGB_8888) -- cgit v1.2.3-59-g8ed1b From 2d2581f66b9344ef24db769cdafebdb6c650c697 Mon Sep 17 00:00:00 2001 From: Matt Casey Date: Tue, 4 Jun 2024 20:41:31 +0000 Subject: Add placeholder color background Fix: 344961968 Test: Manual test with sharetest Flag: android.service.chooser.chooser_payload_toggling Change-Id: Ice94c67754b95b9ac5bb71230bbbba52be72eb3f --- .../payloadtoggle/ui/composable/ShareouselComposable.kt | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) (limited to 'java/src') diff --git a/java/src/com/android/intentresolver/contentpreview/payloadtoggle/ui/composable/ShareouselComposable.kt b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/ui/composable/ShareouselComposable.kt index 8e2626bf..f4966d46 100644 --- a/java/src/com/android/intentresolver/contentpreview/payloadtoggle/ui/composable/ShareouselComposable.kt +++ b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/ui/composable/ShareouselComposable.kt @@ -155,7 +155,12 @@ private fun ShareouselCard(viewModel: ShareouselPreviewViewModel) { } ?: run { // TODO: look at ScrollableImagePreviewView.setLoading() - Box(modifier = Modifier.fillMaxHeight().aspectRatio(aspectRatio)) + Box( + modifier = + Modifier.fillMaxHeight() + .aspectRatio(aspectRatio) + .background(color = MaterialTheme.colorScheme.surfaceContainerHigh) + ) } }, contentType = viewModel.contentType, -- cgit v1.2.3-59-g8ed1b From 466184452d186a4838564d97f518ac4c2fa9288a Mon Sep 17 00:00:00 2001 From: Matt Casey Date: Wed, 5 Jun 2024 14:38:44 +0000 Subject: Hide check and border UI until image load attempt completes Represent the bitmap load state with a nullable bitmap along with a bool indicating whether the load attempt is complete. Bug: 344961968 Test: atest ShareouselViewModelTest Test: Manual testing with ShareTest and high latency / load failure Flag: android.service.chooser.chooser_payload_toggling Change-Id: I4fd049977d9a0c837ad52fc0a3b5931d481072c3 --- .../ui/composable/ShareouselCardComposable.kt | 33 +++++++++-------- .../ui/composable/ShareouselComposable.kt | 12 ++++-- .../ui/viewmodel/ShareouselPreviewViewModel.kt | 3 +- .../ui/viewmodel/ShareouselViewModel.kt | 9 ++++- .../ui/viewmodel/ShareouselViewModelTest.kt | 43 +++++++++++++++++++++- 5 files changed, 79 insertions(+), 21 deletions(-) (limited to 'java/src') diff --git a/java/src/com/android/intentresolver/contentpreview/payloadtoggle/ui/composable/ShareouselCardComposable.kt b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/ui/composable/ShareouselCardComposable.kt index 197d6858..71d16da9 100644 --- a/java/src/com/android/intentresolver/contentpreview/payloadtoggle/ui/composable/ShareouselCardComposable.kt +++ b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/ui/composable/ShareouselCardComposable.kt @@ -40,25 +40,28 @@ fun ShareouselCard( image: @Composable () -> Unit, contentType: ContentType, selected: Boolean, + loadComplete: Boolean, modifier: Modifier = Modifier, ) { Box(modifier) { image() - val topButtonPadding = 12.dp - Box(modifier = Modifier.padding(topButtonPadding).matchParentSize()) { - SelectionIcon(selected, modifier = Modifier.align(Alignment.TopStart)) - when (contentType) { - ContentType.Video -> - TypeIcon( - R.drawable.ic_play_circle_filled_24px, - modifier = Modifier.align(Alignment.TopEnd) - ) - ContentType.Other -> - TypeIcon( - R.drawable.chooser_file_generic, - modifier = Modifier.align(Alignment.TopEnd) - ) - ContentType.Image -> Unit // No additional icon needed. + if (loadComplete) { + val topButtonPadding = 12.dp + Box(modifier = Modifier.padding(topButtonPadding).matchParentSize()) { + SelectionIcon(selected, modifier = Modifier.align(Alignment.TopStart)) + when (contentType) { + ContentType.Video -> + TypeIcon( + R.drawable.ic_play_circle_filled_24px, + modifier = Modifier.align(Alignment.TopEnd) + ) + ContentType.Other -> + TypeIcon( + R.drawable.chooser_file_generic, + modifier = Modifier.align(Alignment.TopEnd) + ) + ContentType.Image -> Unit // No additional icon needed. + } } } } diff --git a/java/src/com/android/intentresolver/contentpreview/payloadtoggle/ui/composable/ShareouselComposable.kt b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/ui/composable/ShareouselComposable.kt index f4966d46..a40a9c50 100644 --- a/java/src/com/android/intentresolver/contentpreview/payloadtoggle/ui/composable/ShareouselComposable.kt +++ b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/ui/composable/ShareouselComposable.kt @@ -57,6 +57,8 @@ import androidx.compose.ui.semantics.semantics import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.android.intentresolver.R +import com.android.intentresolver.contentpreview.payloadtoggle.domain.model.ValueUpdate +import com.android.intentresolver.contentpreview.payloadtoggle.domain.model.getOrDefault import com.android.intentresolver.contentpreview.payloadtoggle.shared.ContentType import com.android.intentresolver.contentpreview.payloadtoggle.shared.model.PreviewsModel import com.android.intentresolver.contentpreview.payloadtoggle.ui.viewmodel.ShareouselPreviewViewModel @@ -131,7 +133,8 @@ private fun PreviewCarousel( @Composable private fun ShareouselCard(viewModel: ShareouselPreviewViewModel) { - val bitmap by viewModel.bitmap.collectAsStateWithLifecycle(initialValue = null) + val bitmapLoadState by + viewModel.bitmapLoadState.collectAsStateWithLifecycle(initialValue = ValueUpdate.Absent) val selected by viewModel.isSelected.collectAsStateWithLifecycle(initialValue = false) val borderColor = MaterialTheme.colorScheme.primary val scope = rememberCoroutineScope() @@ -141,11 +144,13 @@ private fun ShareouselCard(viewModel: ShareouselPreviewViewModel) { ContentType.Video -> stringResource(R.string.selectable_video) else -> stringResource(R.string.selectable_item) } + // Image load is complete (but may have failed) + val loadComplete = bitmapLoadState is ValueUpdate.Value ShareouselCard( image = { // TODO: max ratio is actually equal to the viewport ratio val aspectRatio = viewModel.aspectRatio.coerceIn(MIN_ASPECT_RATIO, MAX_ASPECT_RATIO) - bitmap?.let { bitmap -> + bitmapLoadState.getOrDefault(null)?.let { bitmap -> Image( bitmap = bitmap.asImageBitmap(), contentDescription = null, @@ -164,9 +169,10 @@ private fun ShareouselCard(viewModel: ShareouselPreviewViewModel) { } }, contentType = viewModel.contentType, + loadComplete = loadComplete, selected = selected, modifier = - Modifier.thenIf(selected) { + Modifier.thenIf(selected && loadComplete) { Modifier.border( width = 4.dp, color = borderColor, 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 index 540229c9..1acdcf7a 100644 --- a/java/src/com/android/intentresolver/contentpreview/payloadtoggle/ui/viewmodel/ShareouselPreviewViewModel.kt +++ b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/ui/viewmodel/ShareouselPreviewViewModel.kt @@ -17,13 +17,14 @@ package com.android.intentresolver.contentpreview.payloadtoggle.ui.viewmodel import android.graphics.Bitmap +import com.android.intentresolver.contentpreview.payloadtoggle.domain.model.ValueUpdate import com.android.intentresolver.contentpreview.payloadtoggle.shared.ContentType import kotlinx.coroutines.flow.Flow /** An individual preview within Shareousel. */ data class ShareouselPreviewViewModel( /** Image to be shared. */ - val bitmap: Flow, + val bitmapLoadState: Flow>, /** Type of data to be shared. */ val contentType: ContentType, /** Whether this preview has been selected by the user. */ 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 index c3ad7b6c..19acb318 100644 --- a/java/src/com/android/intentresolver/contentpreview/payloadtoggle/ui/viewmodel/ShareouselViewModel.kt +++ b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/ui/viewmodel/ShareouselViewModel.kt @@ -24,6 +24,7 @@ import com.android.intentresolver.contentpreview.payloadtoggle.domain.interactor import com.android.intentresolver.contentpreview.payloadtoggle.domain.interactor.CustomActionsInteractor import com.android.intentresolver.contentpreview.payloadtoggle.domain.interactor.SelectablePreviewsInteractor import com.android.intentresolver.contentpreview.payloadtoggle.domain.interactor.SelectionInteractor +import com.android.intentresolver.contentpreview.payloadtoggle.domain.model.ValueUpdate import com.android.intentresolver.contentpreview.payloadtoggle.shared.ContentType import com.android.intentresolver.contentpreview.payloadtoggle.shared.model.PreviewModel import com.android.intentresolver.contentpreview.payloadtoggle.shared.model.PreviewsModel @@ -122,7 +123,13 @@ interface ShareouselViewModelModule { else -> ContentType.Other } ShareouselPreviewViewModel( - bitmap = flow { emit(key.previewUri?.let { imageLoader(it) }) }, + bitmapLoadState = + flow { + emit( + key.previewUri?.let { ValueUpdate.Value(imageLoader(it)) } + ?: ValueUpdate.Absent + ) + }, contentType = contentType, isSelected = previewInteractor.isSelected, setSelected = previewInteractor::setSelected, 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 index ec4a9c3e..35f5c0b0 100644 --- 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 @@ -40,6 +40,7 @@ import com.android.intentresolver.contentpreview.payloadtoggle.domain.interactor import com.android.intentresolver.contentpreview.payloadtoggle.domain.interactor.payloadToggleImageLoader import com.android.intentresolver.contentpreview.payloadtoggle.domain.interactor.selectablePreviewsInteractor import com.android.intentresolver.contentpreview.payloadtoggle.domain.interactor.selectionInteractor +import com.android.intentresolver.contentpreview.payloadtoggle.domain.model.ValueUpdate import com.android.intentresolver.contentpreview.payloadtoggle.shared.ContentType import com.android.intentresolver.contentpreview.payloadtoggle.shared.model.PreviewModel import com.android.intentresolver.contentpreview.payloadtoggle.shared.model.PreviewsModel @@ -187,7 +188,9 @@ class ShareouselViewModelTest { /* index = */ 1, ) - assertWithMessage("preview bitmap is null").that(previewVm.bitmap.first()).isNotNull() + assertWithMessage("preview bitmap is null") + .that((previewVm.bitmapLoadState.first() as ValueUpdate.Value).value) + .isNotNull() assertThat(previewVm.isSelected.first()).isFalse() assertThat(previewVm.contentType).isEqualTo(ContentType.Video) @@ -198,6 +201,44 @@ class ShareouselViewModelTest { .contains(Uri.fromParts("scheme1", "ssp1", "fragment1")) } + @Test + fun previews_wontLoad() = + runTest(targetIntentModifier = { Intent() }) { + cursorPreviewsRepository.previewsModel.value = + PreviewsModel( + previewModels = + listOf( + PreviewModel( + uri = Uri.fromParts("scheme", "ssp", "fragment"), + mimeType = "image/png", + ), + PreviewModel( + uri = Uri.fromParts("scheme1", "ssp1", "fragment1"), + mimeType = "video/mpeg", + ) + ), + startIdx = 1, + loadMoreLeft = null, + loadMoreRight = null, + leftTriggerIndex = 0, + rightTriggerIndex = 1, + ) + runCurrent() + + val previewVm = + shareouselViewModel.preview.invoke( + PreviewModel( + uri = Uri.fromParts("scheme", "ssp", "fragment"), + mimeType = "video/mpeg" + ), + /* index = */ 1, + ) + + assertWithMessage("preview bitmap is not null") + .that((previewVm.bitmapLoadState.first() as ValueUpdate.Value).value) + .isNull() + } + @Test fun actions() { runTest { -- cgit v1.2.3-59-g8ed1b From 02c6c6bd7d820a45b3d13104a41ef19673881a71 Mon Sep 17 00:00:00 2001 From: Matt Casey Date: Wed, 5 Jun 2024 19:54:53 +0000 Subject: Fade in shareousel items as they load Switched to a StateFlow with the intiial cached value when it exists so that we don't fade in when we immediately have the bitmap. Bug: 344961968 Test: atest ShareoulselViewModelTest Test: Manual test with ShareTest and slow fade times. Flag: android.service.chooser.chooser_payload_toggling Change-Id: I14c9c82343e7e8a9330695121eebe17c65f1dcb9 --- .../CachingImagePreviewImageLoader.kt | 5 ++ .../intentresolver/contentpreview/ImageLoader.kt | 3 + .../ui/composable/ShareouselCardComposable.kt | 33 ++++----- .../ui/composable/ShareouselComposable.kt | 85 ++++++++++++---------- .../ui/viewmodel/ShareouselPreviewViewModel.kt | 3 +- .../ui/viewmodel/ShareouselViewModel.kt | 20 +++-- .../ui/viewmodel/ShareouselViewModelTest.kt | 6 ++ 7 files changed, 91 insertions(+), 64 deletions(-) (limited to 'java/src') diff --git a/java/src/com/android/intentresolver/contentpreview/CachingImagePreviewImageLoader.kt b/java/src/com/android/intentresolver/contentpreview/CachingImagePreviewImageLoader.kt index ce064cdf..2e2aa938 100644 --- a/java/src/com/android/intentresolver/contentpreview/CachingImagePreviewImageLoader.kt +++ b/java/src/com/android/intentresolver/contentpreview/CachingImagePreviewImageLoader.kt @@ -28,6 +28,7 @@ import javax.inject.Qualifier import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Deferred +import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.async import kotlinx.coroutines.ensureActive import kotlinx.coroutines.launch @@ -104,6 +105,10 @@ constructor( // [CancellationExceptions]s so that they don't cancel the calling coroutine/scope. runCatching { cache[uri].await() }.getOrNull() + @OptIn(ExperimentalCoroutinesApi::class) + override fun getCachedBitmap(uri: Uri): Bitmap? = + kotlin.runCatching { cache[uri].getCompleted() }.getOrNull() + companion object { private const val TAG = "CachingImgPrevLoader" } diff --git a/java/src/com/android/intentresolver/contentpreview/ImageLoader.kt b/java/src/com/android/intentresolver/contentpreview/ImageLoader.kt index 629651a3..81913a8e 100644 --- a/java/src/com/android/intentresolver/contentpreview/ImageLoader.kt +++ b/java/src/com/android/intentresolver/contentpreview/ImageLoader.kt @@ -35,6 +35,9 @@ interface ImageLoader : suspend (Uri) -> Bitmap?, suspend (Uri, Boolean) -> Bitm /** Prepopulate the image loader cache. */ fun prePopulate(uris: List) + /** Returns a bitmap for the given URI if it's already cached, otherwise null */ + fun getCachedBitmap(uri: Uri): Bitmap? = null + /** Load preview image; caching is allowed. */ override suspend fun invoke(uri: Uri) = invoke(uri, true) diff --git a/java/src/com/android/intentresolver/contentpreview/payloadtoggle/ui/composable/ShareouselCardComposable.kt b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/ui/composable/ShareouselCardComposable.kt index 71d16da9..197d6858 100644 --- a/java/src/com/android/intentresolver/contentpreview/payloadtoggle/ui/composable/ShareouselCardComposable.kt +++ b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/ui/composable/ShareouselCardComposable.kt @@ -40,28 +40,25 @@ fun ShareouselCard( image: @Composable () -> Unit, contentType: ContentType, selected: Boolean, - loadComplete: Boolean, modifier: Modifier = Modifier, ) { Box(modifier) { image() - if (loadComplete) { - val topButtonPadding = 12.dp - Box(modifier = Modifier.padding(topButtonPadding).matchParentSize()) { - SelectionIcon(selected, modifier = Modifier.align(Alignment.TopStart)) - when (contentType) { - ContentType.Video -> - TypeIcon( - R.drawable.ic_play_circle_filled_24px, - modifier = Modifier.align(Alignment.TopEnd) - ) - ContentType.Other -> - TypeIcon( - R.drawable.chooser_file_generic, - modifier = Modifier.align(Alignment.TopEnd) - ) - ContentType.Image -> Unit // No additional icon needed. - } + val topButtonPadding = 12.dp + Box(modifier = Modifier.padding(topButtonPadding).matchParentSize()) { + SelectionIcon(selected, modifier = Modifier.align(Alignment.TopStart)) + when (contentType) { + ContentType.Video -> + TypeIcon( + R.drawable.ic_play_circle_filled_24px, + modifier = Modifier.align(Alignment.TopEnd) + ) + ContentType.Other -> + TypeIcon( + R.drawable.chooser_file_generic, + modifier = Modifier.align(Alignment.TopEnd) + ) + ContentType.Image -> Unit // No additional icon needed. } } } diff --git a/java/src/com/android/intentresolver/contentpreview/payloadtoggle/ui/composable/ShareouselComposable.kt b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/ui/composable/ShareouselComposable.kt index a40a9c50..c63055d2 100644 --- a/java/src/com/android/intentresolver/contentpreview/payloadtoggle/ui/composable/ShareouselComposable.kt +++ b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/ui/composable/ShareouselComposable.kt @@ -15,6 +15,7 @@ */ package com.android.intentresolver.contentpreview.payloadtoggle.ui.composable +import androidx.compose.animation.Crossfade import androidx.compose.foundation.Image import androidx.compose.foundation.background import androidx.compose.foundation.border @@ -126,15 +127,14 @@ private fun PreviewCarousel( } } - ShareouselCard(viewModel.preview(model, previewIndex)) + ShareouselCard(viewModel.preview(model, previewIndex, rememberCoroutineScope())) } } } @Composable private fun ShareouselCard(viewModel: ShareouselPreviewViewModel) { - val bitmapLoadState by - viewModel.bitmapLoadState.collectAsStateWithLifecycle(initialValue = ValueUpdate.Absent) + val bitmapLoadState by viewModel.bitmapLoadState.collectAsStateWithLifecycle() val selected by viewModel.isSelected.collectAsStateWithLifecycle(initialValue = false) val borderColor = MaterialTheme.colorScheme.primary val scope = rememberCoroutineScope() @@ -144,47 +144,56 @@ private fun ShareouselCard(viewModel: ShareouselPreviewViewModel) { ContentType.Video -> stringResource(R.string.selectable_video) else -> stringResource(R.string.selectable_item) } - // Image load is complete (but may have failed) - val loadComplete = bitmapLoadState is ValueUpdate.Value - ShareouselCard( - image = { - // TODO: max ratio is actually equal to the viewport ratio - val aspectRatio = viewModel.aspectRatio.coerceIn(MIN_ASPECT_RATIO, MAX_ASPECT_RATIO) - bitmapLoadState.getOrDefault(null)?.let { bitmap -> - Image( - bitmap = bitmap.asImageBitmap(), - contentDescription = null, - contentScale = ContentScale.Crop, - modifier = Modifier.aspectRatio(aspectRatio), - ) - } - ?: run { - // TODO: look at ScrollableImagePreviewView.setLoading() - Box( - modifier = - Modifier.fillMaxHeight() - .aspectRatio(aspectRatio) - .background(color = MaterialTheme.colorScheme.surfaceContainerHigh) - ) - } - }, - contentType = viewModel.contentType, - loadComplete = loadComplete, - selected = selected, + Crossfade( + targetState = bitmapLoadState, modifier = - Modifier.thenIf(selected && loadComplete) { - Modifier.border( - width = 4.dp, - color = borderColor, - shape = RoundedCornerShape(size = 12.dp), - ) - } - .semantics { this.contentDescription = contentDescription } + Modifier.semantics { this.contentDescription = contentDescription } .clip(RoundedCornerShape(size = 12.dp)) .toggleable( value = selected, onValueChange = { scope.launch { viewModel.setSelected(it) } }, ) + ) { state -> + // TODO: max ratio is actually equal to the viewport ratio + val aspectRatio = viewModel.aspectRatio.coerceIn(MIN_ASPECT_RATIO, MAX_ASPECT_RATIO) + if (state is ValueUpdate.Value) { + state.getOrDefault(null).let { bitmap -> + ShareouselCard( + image = { + bitmap?.let { + Image( + bitmap = bitmap.asImageBitmap(), + contentDescription = null, + contentScale = ContentScale.Crop, + modifier = Modifier.aspectRatio(aspectRatio), + ) + } ?: PlaceholderBox(aspectRatio) + }, + contentType = viewModel.contentType, + selected = selected, + modifier = + Modifier.thenIf(selected) { + Modifier.border( + width = 4.dp, + color = borderColor, + shape = RoundedCornerShape(size = 12.dp), + ) + } + ) + } + } else { + PlaceholderBox(aspectRatio) + } + } +} + +@Composable +private fun PlaceholderBox(aspectRatio: Float) { + Box( + modifier = + Modifier.fillMaxHeight() + .aspectRatio(aspectRatio) + .background(color = MaterialTheme.colorScheme.surfaceContainerHigh) ) } 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 index 1acdcf7a..de435290 100644 --- a/java/src/com/android/intentresolver/contentpreview/payloadtoggle/ui/viewmodel/ShareouselPreviewViewModel.kt +++ b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/ui/viewmodel/ShareouselPreviewViewModel.kt @@ -20,11 +20,12 @@ import android.graphics.Bitmap import com.android.intentresolver.contentpreview.payloadtoggle.domain.model.ValueUpdate import com.android.intentresolver.contentpreview.payloadtoggle.shared.ContentType import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.StateFlow /** An individual preview within Shareousel. */ data class ShareouselPreviewViewModel( /** Image to be shared. */ - val bitmapLoadState: Flow>, + val bitmapLoadState: StateFlow>, /** Type of data to be shared. */ val contentType: ContentType, /** Whether this preview has been selected by the user. */ 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 index 19acb318..d0b89860 100644 --- a/java/src/com/android/intentresolver/contentpreview/payloadtoggle/ui/viewmodel/ShareouselViewModel.kt +++ b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/ui/viewmodel/ShareouselViewModel.kt @@ -56,7 +56,8 @@ data class ShareouselViewModel( /** List of action chips presented underneath Shareousel. */ val actions: Flow>, /** Creates a [ShareouselPreviewViewModel] for a [PreviewModel] present in [previews]. */ - val preview: (key: PreviewModel, index: Int?) -> ShareouselPreviewViewModel, + val preview: + (key: PreviewModel, index: Int?, scope: CoroutineScope) -> ShareouselPreviewViewModel, ) @Module @@ -113,7 +114,7 @@ interface ShareouselViewModelModule { } } }, - preview = { key, index -> + preview = { key, index, previewScope -> keySet.value?.maybeLoad(index) val previewInteractor = interactor.preview(key) val contentType = @@ -122,14 +123,19 @@ interface ShareouselViewModelModule { mimeTypeClassifier.isVideoType(key.mimeType) -> ContentType.Video else -> ContentType.Other } + val initialBitmapValue = + key.previewUri?.let { + imageLoader.getCachedBitmap(it)?.let { ValueUpdate.Value(it) } + } ?: ValueUpdate.Absent ShareouselPreviewViewModel( bitmapLoadState = flow { - emit( - key.previewUri?.let { ValueUpdate.Value(imageLoader(it)) } - ?: ValueUpdate.Absent - ) - }, + emit( + key.previewUri?.let { ValueUpdate.Value(imageLoader(it)) } + ?: ValueUpdate.Absent + ) + } + .stateIn(previewScope, SharingStarted.Eagerly, initialBitmapValue), contentType = contentType, isSelected = previewInteractor.isSelected, setSelected = previewInteractor::setSelected, 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 index 35f5c0b0..a26b4288 100644 --- 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 @@ -186,8 +186,11 @@ class ShareouselViewModelTest { mimeType = "video/mpeg" ), /* index = */ 1, + viewModelScope, ) + runCurrent() + assertWithMessage("preview bitmap is null") .that((previewVm.bitmapLoadState.first() as ValueUpdate.Value).value) .isNotNull() @@ -232,8 +235,11 @@ class ShareouselViewModelTest { mimeType = "video/mpeg" ), /* index = */ 1, + viewModelScope, ) + runCurrent() + assertWithMessage("preview bitmap is not null") .that((previewVm.bitmapLoadState.first() as ValueUpdate.Value).value) .isNull() -- cgit v1.2.3-59-g8ed1b From 1975528de9f1abcbfcebd4a4dadbf9858e9fe764 Mon Sep 17 00:00:00 2001 From: Andrey Epin Date: Tue, 4 Jun 2024 10:46:04 -0700 Subject: Shareousel: Maintain cursor order for shated items Add a position property to PreviewModel class to track relative order of items. For each item, the initial value is artificial and derived from the order of the initially shared items and is updated upon reading the additional items cursor. Upon sharing, If the selection has not change, the items will be shared in their original order; If the selection has changed, the order of the items will be affected by the observed items order in the cursor. Fix: 329683774 Test: manual testing Test: atest IntentResolver-tests-unit Test: atest IntentResolver-tests-activity Flag: android.service.chooser.chooser_payload_toggling Change-Id: Ie552887702cde75cb1a05ed3ec5415f4f4a5c8dc --- .../data/repository/PreviewSelectionsRepository.kt | 3 +- .../domain/cursor/PayloadToggleCursorResolver.kt | 2 +- .../domain/interactor/CursorPreviewsInteractor.kt | 32 ++++++++----- .../domain/interactor/FetchPreviewsInteractor.kt | 14 ++++-- .../interactor/SelectablePreviewInteractor.kt | 2 +- .../domain/interactor/SelectionInteractor.kt | 30 ++++++++---- .../payloadtoggle/domain/model/CursorRow.kt | 2 +- .../payloadtoggle/shared/model/PreviewModel.kt | 4 ++ .../intentresolver/util/ParallelIteration.kt | 21 +++++++++ .../interactor/PayloadToggleInteractorKosmos.kt | 1 + .../cursor/PayloadToggleCursorResolverTest.kt | 32 +++++++++++++ .../interactor/CursorPreviewsInteractorTest.kt | 45 ++++++++++++++++-- .../interactor/FetchPreviewsInteractorTest.kt | 32 +++++++++---- .../interactor/SelectablePreviewInteractorTest.kt | 29 ++++++------ .../interactor/SelectablePreviewsInteractorTest.kt | 54 ++++++++++++++++------ .../domain/interactor/SelectionInteractorTest.kt | 30 ++++++++---- .../interactor/SetCursorPreviewsInteractorTest.kt | 3 ++ .../ui/viewmodel/ShareouselViewModelTest.kt | 31 +++++++++---- 18 files changed, 282 insertions(+), 85 deletions(-) (limited to 'java/src') 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 index 48c06192..81c56d1e 100644 --- a/java/src/com/android/intentresolver/contentpreview/payloadtoggle/data/repository/PreviewSelectionsRepository.kt +++ b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/data/repository/PreviewSelectionsRepository.kt @@ -16,6 +16,7 @@ package com.android.intentresolver.contentpreview.payloadtoggle.data.repository +import android.net.Uri import com.android.intentresolver.contentpreview.payloadtoggle.shared.model.PreviewModel import dagger.hilt.android.scopes.ViewModelScoped import javax.inject.Inject @@ -24,5 +25,5 @@ import kotlinx.coroutines.flow.MutableStateFlow /** Stores set of selected previews. */ @ViewModelScoped class PreviewSelectionsRepository @Inject constructor() { - val selections = MutableStateFlow(emptyList()) + val selections = MutableStateFlow(emptyMap()) } 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 index d9612696..148310e6 100644 --- a/java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/cursor/PayloadToggleCursorResolver.kt +++ b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/cursor/PayloadToggleCursorResolver.kt @@ -55,7 +55,7 @@ constructor( ) } .getOrNull() - ?.viewBy { readUri()?.let { uri -> CursorRow(uri, readSize()) } } + ?.viewBy { readUri()?.let { uri -> CursorRow(uri, readSize(), position) } } } private fun Cursor.readUri(): Uri? { 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 index fa600c86..a475263c 100644 --- a/java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/interactor/CursorPreviewsInteractor.kt +++ b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/interactor/CursorPreviewsInteractor.kt @@ -56,6 +56,7 @@ class CursorPreviewsInteractor @Inject constructor( private val interactor: SetCursorPreviewsInteractor, + private val selectionInteractor: SelectionInteractor, @FocusedItemIndex private val focusedItemIdx: Int, private val uriMetadataReader: UriMetadataReader, @PageSize private val pageSize: Int, @@ -287,19 +288,26 @@ constructor( private fun createPreviewModel( row: CursorRow, unclaimedRecords: MutableUnclaimedMap, - ): PreviewModel = - unclaimedRecords.remove(row.uri)?.second - ?: uriMetadataReader.getMetadata(row.uri).let { metadata -> - val size = - row.previewSize - ?: metadata.previewUri?.let { uriMetadataReader.readPreviewSize(it) } - PreviewModel( - uri = row.uri, - previewUri = metadata.previewUri, - mimeType = metadata.mimeType, - aspectRatio = size.aspectRatioOrDefault(1f), - ) + ): PreviewModel = uriMetadataReader.getMetadata(row.uri).let { metadata -> + val size = + row.previewSize + ?: metadata.previewUri?.let { uriMetadataReader.readPreviewSize(it) } + PreviewModel( + uri = row.uri, + previewUri = metadata.previewUri, + mimeType = metadata.mimeType, + aspectRatio = size.aspectRatioOrDefault(1f), + order = row.position, + ) + }.also { updated -> + if (unclaimedRecords.remove(row.uri) != null) { + // unclaimedRecords contains initially shared (and thus selected) items with unknown + // cursor position. Update selection records when any of those items is encountered + // in the cursor to maintain proper selection order should other items also be + // selected. + selectionInteractor.updateSelection(updated) } + } private fun M.putAllUnclaimedRight(unclaimed: UnclaimedMap): M = putAllUnclaimedWhere(unclaimed) { it >= focusedItemIdx } 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 index c9c9a9b3..50086a23 100644 --- a/java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/interactor/FetchPreviewsInteractor.kt +++ b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/interactor/FetchPreviewsInteractor.kt @@ -25,7 +25,7 @@ import com.android.intentresolver.contentpreview.payloadtoggle.domain.model.Curs 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 com.android.intentresolver.util.mapParallelIndexed import javax.inject.Inject import kotlinx.coroutines.async import kotlinx.coroutines.coroutineScope @@ -45,7 +45,7 @@ constructor( suspend fun activate() = coroutineScope { val cursor = async { cursorResolver.getCursor() } val initialPreviewMap = getInitialPreviews() - selectionRepository.selections.value = initialPreviewMap + selectionRepository.selections.value = initialPreviewMap.associateBy { it.uri } setCursorPreviews.setPreviews( previews = initialPreviewMap, startIndex = focusedItemIdx, @@ -61,7 +61,7 @@ constructor( selectedItems // Restrict parallelism so as to not overload the metadata reader; anecdotally, too // many parallel queries causes failures. - .mapParallel(parallelism = 4) { uri -> + .mapParallelIndexed(parallelism = 4) { index, uri -> val metadata = uriMetadataReader.getMetadata(uri) PreviewModel( uri = uri, @@ -70,8 +70,12 @@ constructor( aspectRatio = metadata.previewUri?.let { uriMetadataReader.readPreviewSize(it).aspectRatioOrDefault(1f) - } - ?: 1f, + } ?: 1f, + order = when { + index < focusedItemIdx -> Int.MIN_VALUE + index + index == focusedItemIdx -> 0 + else -> Int.MAX_VALUE - selectedItems.size + index + 1 + } ) } } diff --git a/java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/interactor/SelectablePreviewInteractor.kt b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/interactor/SelectablePreviewInteractor.kt index 55a995f5..d52a71a1 100644 --- a/java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/interactor/SelectablePreviewInteractor.kt +++ b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/interactor/SelectablePreviewInteractor.kt @@ -29,7 +29,7 @@ class SelectablePreviewInteractor( val uri: Uri = key.uri /** Whether or not this preview is selected by the user. */ - val isSelected: Flow = selectionInteractor.selections.map { key in it } + val isSelected: Flow = selectionInteractor.selections.map { key.uri in it } /** Sets whether this preview is selected by the user. */ fun setSelected(isSelected: Boolean) { 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 index 13af92cb..97d9fa66 100644 --- a/java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/interactor/SelectionInteractor.kt +++ b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/interactor/SelectionInteractor.kt @@ -16,6 +16,7 @@ package com.android.intentresolver.contentpreview.payloadtoggle.domain.interactor +import android.net.Uri import com.android.intentresolver.contentpreview.MimeTypeClassifier import com.android.intentresolver.contentpreview.payloadtoggle.data.repository.PreviewSelectionsRepository import com.android.intentresolver.contentpreview.payloadtoggle.domain.intent.TargetIntentModifier @@ -23,8 +24,9 @@ import com.android.intentresolver.contentpreview.payloadtoggle.shared.ContentTyp 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.distinctUntilChanged import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.update import kotlinx.coroutines.flow.updateAndGet class SelectionInteractor @@ -36,31 +38,41 @@ constructor( private val mimeTypeClassifier: MimeTypeClassifier, ) { /** List of selected previews. */ - val selections: StateFlow> - get() = selectionsRepo.selections + val selections: Flow> = + selectionsRepo.selections.map { it.keys }.distinctUntilChanged() /** Amount of selected previews. */ val amountSelected: Flow = selectionsRepo.selections.map { it.size } - val aggregateContentType: Flow = selections.map { aggregateContentType(it) } + val aggregateContentType: Flow = + selectionsRepo.selections.map { aggregateContentType(it.values) } + + fun updateSelection(model: PreviewModel) { + selectionsRepo.selections.update { + if (it.containsKey(model.uri)) it + (model.uri to model) else it + } + } fun select(model: PreviewModel) { - updateChooserRequest(selectionsRepo.selections.updateAndGet { it + model }) + updateChooserRequest( + selectionsRepo.selections.updateAndGet { it + (model.uri to model) }.values + ) } fun unselect(model: PreviewModel) { if (selectionsRepo.selections.value.size > 1) { - updateChooserRequest(selectionsRepo.selections.updateAndGet { it - model }) + updateChooserRequest(selectionsRepo.selections.updateAndGet { it - model.uri }.values) } } - private fun updateChooserRequest(selections: List) { - val intent = targetIntentModifier.intentFromSelection(selections) + private fun updateChooserRequest(selections: Collection) { + val sorted = selections.sortedBy { it.order } + val intent = targetIntentModifier.intentFromSelection(sorted) updateTargetIntentInteractor.updateTargetIntent(intent) } private fun aggregateContentType( - items: List, + items: Collection, ): ContentType { if (items.isEmpty()) { return ContentType.Other diff --git a/java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/model/CursorRow.kt b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/model/CursorRow.kt index f1d856ac..aae29102 100644 --- a/java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/model/CursorRow.kt +++ b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/model/CursorRow.kt @@ -20,4 +20,4 @@ import android.net.Uri import android.util.Size /** Represents additional content cursor row */ -data class CursorRow(val uri: Uri, val previewSize: Size?) +data class CursorRow(val uri: Uri, val previewSize: Size?, val position: Int) 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 index 85c70004..8a479156 100644 --- a/java/src/com/android/intentresolver/contentpreview/payloadtoggle/shared/model/PreviewModel.kt +++ b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/shared/model/PreviewModel.kt @@ -27,4 +27,8 @@ data class PreviewModel( /** Mimetype for the data [uri] points to. */ val mimeType: String?, val aspectRatio: Float = 1f, + /** + * Relative item position in the list that is used to determine items order in the target intent + */ + val order: Int, ) diff --git a/java/src/com/android/intentresolver/util/ParallelIteration.kt b/java/src/com/android/intentresolver/util/ParallelIteration.kt index 70c46c47..745bcdbf 100644 --- a/java/src/com/android/intentresolver/util/ParallelIteration.kt +++ b/java/src/com/android/intentresolver/util/ParallelIteration.kt @@ -48,3 +48,24 @@ private suspend fun Iterable.mapParallel(block: suspend (A) -> B): Lis } .awaitAll() } + +suspend fun Iterable.mapParallelIndexed( + parallelism: Int? = null, + block: suspend (Int, A) -> B, +): List = + parallelism?.let { permits -> + withSemaphore(permits = permits) { + mapParallelIndexed { idx, item -> withPermit { block(idx, item) } } + } + } ?: mapParallelIndexed(block) + +private suspend fun Iterable.mapParallelIndexed(block: suspend (Int, A) -> B): List = + coroutineScope { + mapIndexed { index, item -> + async { + yield() + block(index, item) + } + } + .awaitAll() + } diff --git a/tests/shared/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/interactor/PayloadToggleInteractorKosmos.kt b/tests/shared/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/interactor/PayloadToggleInteractorKosmos.kt index 8f7c59de..cb88cd9e 100644 --- a/tests/shared/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/interactor/PayloadToggleInteractorKosmos.kt +++ b/tests/shared/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/interactor/PayloadToggleInteractorKosmos.kt @@ -48,6 +48,7 @@ val Kosmos.cursorPreviewsInteractor get() = CursorPreviewsInteractor( interactor = setCursorPreviewsInteractor, + selectionInteractor = selectionInteractor, focusedItemIdx = focusedItemIndex, uriMetadataReader = uriMetadataReader, pageSize = pageSize, diff --git a/tests/unit/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/cursor/PayloadToggleCursorResolverTest.kt b/tests/unit/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/cursor/PayloadToggleCursorResolverTest.kt index 9eaee233..5d81ec2a 100644 --- a/tests/unit/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/cursor/PayloadToggleCursorResolverTest.kt +++ b/tests/unit/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/cursor/PayloadToggleCursorResolverTest.kt @@ -26,6 +26,7 @@ import android.service.chooser.AdditionalContentContract.Columns.URI import android.util.Size import com.android.intentresolver.util.cursor.get import com.google.common.truth.Truth.assertThat +import com.google.common.truth.Truth.assertWithMessage import kotlinx.coroutines.test.runTest import org.junit.Test import org.mockito.kotlin.any @@ -101,6 +102,37 @@ class PayloadToggleCursorResolverTest { assertThat(row.previewSize).isEqualTo(Size(100, 50)) } } + + @Test + fun testRowPositionValues() = runTest { + val rowCount = 10 + val sourceCursor = + MatrixCursor(arrayOf(URI)).apply { + for (i in 1..rowCount) { + addRow(arrayOf(createUri(i).toString())) + } + } + val fakeContentProvider = + mock { + on { query(eq(cursorUri), any(), any(), any()) } doReturn sourceCursor + } + val testSubject = + PayloadToggleCursorResolver( + fakeContentProvider, + cursorUri, + chooserIntent, + ) + + val cursor = testSubject.getCursor() + assertThat(cursor).isNotNull() + assertThat(cursor!!.count).isEqualTo(rowCount) + for (i in 0 until rowCount) { + cursor[i].let { row -> + assertWithMessage("Row $i").that(row).isNotNull() + assertWithMessage("Row $i").that(row!!.position).isEqualTo(i) + } + } + } } private fun createUri(id: Int) = Uri.parse("content://org.pkg/app/img-$id.png") diff --git a/tests/unit/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/interactor/CursorPreviewsInteractorTest.kt b/tests/unit/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/interactor/CursorPreviewsInteractorTest.kt index 0036e803..48e43190 100644 --- 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 @@ -27,6 +27,9 @@ import androidx.core.os.bundleOf import com.android.intentresolver.contentpreview.FileInfo import com.android.intentresolver.contentpreview.UriMetadataReader import com.android.intentresolver.contentpreview.payloadtoggle.data.repository.cursorPreviewsRepository +import com.android.intentresolver.contentpreview.payloadtoggle.data.repository.previewSelectionsRepository +import com.android.intentresolver.contentpreview.payloadtoggle.domain.intent.TargetIntentModifier +import com.android.intentresolver.contentpreview.payloadtoggle.domain.intent.targetIntentModifier import com.android.intentresolver.contentpreview.payloadtoggle.domain.model.CursorRow import com.android.intentresolver.contentpreview.payloadtoggle.shared.model.PreviewModel import com.android.intentresolver.contentpreview.readSize @@ -59,6 +62,7 @@ class CursorPreviewsInteractorTest { this.focusedItemIndex = focusedItemIndex this.pageSize = pageSize this.maxLoadedPages = maxLoadedPages + this.targetIntentModifier = TargetIntentModifier { error("unexpected invocation") } uriMetadataReader = object : UriMetadataReader { override fun getMetadata(uri: Uri): FileInfo = @@ -103,9 +107,15 @@ class CursorPreviewsInteractorTest { ) } } - .viewBy { getString(0)?.let { uriStr -> CursorRow(Uri.parse(uriStr), readSize()) } } + .viewBy { + getString(0)?.let { uriStr -> + CursorRow(Uri.parse(uriStr), readSize(), position) + } + } val initialPreviews: List = - initialSelectionRange.map { i -> PreviewModel(uri = uri(i), mimeType = "image/bitmap") } + initialSelectionRange.map { i -> + PreviewModel(uri = uri(i), mimeType = "image/bitmap", order = i) + } } @Test @@ -136,7 +146,8 @@ class CursorPreviewsInteractorTest { 0 -> 2f 3 -> 4f else -> 1f - } + }, + order = it, ) } ) @@ -257,6 +268,34 @@ class CursorPreviewsInteractorTest { .isEqualTo(Uri.fromParts("scheme24", "ssp24", "fragment24")) assertThat(cursorPreviewsRepository.previewsModel.value!!.loadMoreLeft).isNull() } + + @Test + fun unclaimedRecordsGotUpdatedInSelectionInteractor() = + runTestWithDeps( + initialSelection = listOf(1), + focusedItemIndex = 0, + cursor = listOf(0, 1), + cursorStartPosition = 1, + ) { deps -> + previewSelectionsRepository.selections.value = + PreviewModel( + uri = uri(1), + mimeType = "image/png", + order = 0, + ).let { mapOf(it.uri to it) } + backgroundScope.launch { + cursorPreviewsInteractor.launch(deps.cursor, deps.initialPreviews) + } + runCurrent() + + assertThat(previewSelectionsRepository.selections.value.values).containsExactly( + PreviewModel( + uri = uri(1), + mimeType = "image/bitmap", + order = 1, + ) + ) + } } private fun uri(index: Int) = Uri.fromParts("scheme$index", "ssp$index", "fragment$index") diff --git a/tests/unit/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/interactor/FetchPreviewsInteractorTest.kt b/tests/unit/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/interactor/FetchPreviewsInteractorTest.kt index d04c984f..27c98dc0 100644 --- 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 @@ -27,6 +27,8 @@ import com.android.intentresolver.contentpreview.UriMetadataReader import com.android.intentresolver.contentpreview.payloadtoggle.data.repository.cursorPreviewsRepository import com.android.intentresolver.contentpreview.payloadtoggle.domain.cursor.CursorResolver import com.android.intentresolver.contentpreview.payloadtoggle.domain.cursor.payloadToggleCursorResolver +import com.android.intentresolver.contentpreview.payloadtoggle.domain.intent.TargetIntentModifier +import com.android.intentresolver.contentpreview.payloadtoggle.domain.intent.targetIntentModifier import com.android.intentresolver.contentpreview.payloadtoggle.domain.model.CursorRow import com.android.intentresolver.contentpreview.payloadtoggle.shared.model.PreviewModel import com.android.intentresolver.contentpreview.payloadtoggle.shared.model.PreviewsModel @@ -76,6 +78,7 @@ class FetchPreviewsInteractorTest { } this.pageSize = pageSize this.maxLoadedPages = maxLoadedPages + this.targetIntentModifier = TargetIntentModifier { error("unexpected invocation") } runKosmosTest { block() } } } @@ -99,13 +102,15 @@ class FetchPreviewsInteractorTest { newRow().add("uri", uri(i).toString()) } } - .viewBy { getString(0)?.let(Uri::parse)?.let { CursorRow(it, null) } } + .viewBy { getString(0)?.let(Uri::parse)?.let { CursorRow(it, null, position) } } } } @Test fun setsInitialPreviews() = - runTest(previewSizes = mapOf(1 to Size(100, 50))) { + runTest( + initialSelection = (1..3), + previewSizes = mapOf(1 to Size(100, 50))) { backgroundScope.launch { fetchPreviewsInteractor.activate() } runCurrent() @@ -117,18 +122,25 @@ class FetchPreviewsInteractorTest { PreviewModel( uri = Uri.fromParts("scheme1", "ssp1", "fragment1"), mimeType = "image/bitmap", - aspectRatio = 2f + aspectRatio = 2f, + order = Int.MIN_VALUE, ), PreviewModel( uri = Uri.fromParts("scheme2", "ssp2", "fragment2"), mimeType = "image/bitmap", + order = 0, + ), + PreviewModel( + uri = Uri.fromParts("scheme3", "ssp3", "fragment3"), + mimeType = "image/bitmap", + order = Int.MAX_VALUE, ), ), startIdx = 1, loadMoreLeft = null, loadMoreRight = null, leftTriggerIndex = 0, - rightTriggerIndex = 1, + rightTriggerIndex = 2, ) ) } @@ -148,19 +160,23 @@ class FetchPreviewsInteractorTest { .containsExactly( PreviewModel( uri = Uri.fromParts("scheme0", "ssp0", "fragment0"), - mimeType = "image/bitmap" + mimeType = "image/bitmap", + order = 0, ), PreviewModel( uri = Uri.fromParts("scheme1", "ssp1", "fragment1"), - mimeType = "image/bitmap" + mimeType = "image/bitmap", + order = 1, ), PreviewModel( uri = Uri.fromParts("scheme2", "ssp2", "fragment2"), - mimeType = "image/bitmap" + mimeType = "image/bitmap", + order = 2, ), PreviewModel( uri = Uri.fromParts("scheme3", "ssp3", "fragment3"), - mimeType = "image/bitmap" + mimeType = "image/bitmap", + order = 3, ), ) .inOrder() 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 index 0275a9c3..f329b8a7 100644 --- 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 @@ -40,7 +40,11 @@ class SelectablePreviewInteractorTest { val underTest = SelectablePreviewInteractor( key = - PreviewModel(uri = Uri.fromParts("scheme", "ssp", "fragment"), mimeType = null), + PreviewModel( + uri = Uri.fromParts("scheme", "ssp", "fragment"), + mimeType = null, + order = 0, + ), selectionInteractor = selectionInteractor, ) runCurrent() @@ -56,7 +60,8 @@ class SelectablePreviewInteractorTest { key = PreviewModel( uri = Uri.fromParts("scheme", "ssp", "fragment"), - mimeType = "image/bitmap" + mimeType = "image/bitmap", + order = 0, ), selectionInteractor = selectionInteractor, ) @@ -64,12 +69,12 @@ class SelectablePreviewInteractorTest { assertThat(underTest.isSelected.first()).isFalse() previewSelectionsRepository.selections.value = - listOf( - PreviewModel( + PreviewModel( uri = Uri.fromParts("scheme", "ssp", "fragment"), - mimeType = "image/bitmap" + mimeType = "image/bitmap", + order = 0, ) - ) + .let { mapOf(it.uri to it) } runCurrent() assertThat(underTest.isSelected.first()).isTrue() @@ -84,7 +89,8 @@ class SelectablePreviewInteractorTest { key = PreviewModel( uri = Uri.fromParts("scheme", "ssp", "fragment"), - mimeType = "image/bitmap" + mimeType = "image/bitmap", + order = 0, ), selectionInteractor = selectionInteractor, ) @@ -92,13 +98,8 @@ class SelectablePreviewInteractorTest { underTest.setSelected(true) runCurrent() - assertThat(previewSelectionsRepository.selections.value) - .containsExactly( - PreviewModel( - uri = Uri.fromParts("scheme", "ssp", "fragment"), - mimeType = "image/bitmap" - ) - ) + assertThat(previewSelectionsRepository.selections.value.keys) + .containsExactly(Uri.fromParts("scheme", "ssp", "fragment")) assertThat(chooserRequestRepository.chooserRequest.value.targetIntent) .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 index 14b9c49c..c50d2d3f 100644 --- 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 @@ -43,10 +43,12 @@ class SelectablePreviewsInteractorTest { PreviewModel( uri = Uri.fromParts("scheme", "ssp", "fragment"), mimeType = "image/bitmap", + order = 0, ), PreviewModel( uri = Uri.fromParts("scheme2", "ssp2", "fragment2"), mimeType = "image/bitmap", + order = 1, ), ), startIdx = 0, @@ -56,9 +58,12 @@ class SelectablePreviewsInteractorTest { rightTriggerIndex = 1, ) previewSelectionsRepository.selections.value = - listOf( - PreviewModel(uri = Uri.fromParts("scheme", "ssp", "fragment"), mimeType = null), - ) + PreviewModel( + uri = Uri.fromParts("scheme", "ssp", "fragment"), + mimeType = null, + order = 0, + ) + .let { mapOf(it.uri to it) } targetIntentModifier = TargetIntentModifier { error("unexpected invocation") } val underTest = selectablePreviewsInteractor val keySet = underTest.previews.stateIn(backgroundScope) @@ -68,11 +73,13 @@ class SelectablePreviewsInteractorTest { .containsExactly( PreviewModel( uri = Uri.fromParts("scheme", "ssp", "fragment"), - mimeType = "image/bitmap" + mimeType = "image/bitmap", + order = 0, ), PreviewModel( uri = Uri.fromParts("scheme2", "ssp2", "fragment2"), - mimeType = "image/bitmap" + mimeType = "image/bitmap", + order = 1, ), ) .inOrder() @@ -82,13 +89,21 @@ class SelectablePreviewsInteractorTest { val firstModel = underTest.preview( - PreviewModel(uri = Uri.fromParts("scheme", "ssp", "fragment"), mimeType = null) + PreviewModel( + uri = Uri.fromParts("scheme", "ssp", "fragment"), + mimeType = null, + order = 0, + ) ) assertThat(firstModel.isSelected.first()).isTrue() val secondModel = underTest.preview( - PreviewModel(uri = Uri.fromParts("scheme2", "ssp2", "fragment2"), mimeType = null) + PreviewModel( + uri = Uri.fromParts("scheme2", "ssp2", "fragment2"), + mimeType = null, + order = 1, + ) ) assertThat(secondModel.isSelected.first()).isFalse() } @@ -96,16 +111,23 @@ class SelectablePreviewsInteractorTest { @Test fun keySet_reflectsRepositoryUpdate() = runKosmosTest { previewSelectionsRepository.selections.value = - listOf( - PreviewModel(uri = Uri.fromParts("scheme", "ssp", "fragment"), mimeType = null), - ) + PreviewModel( + uri = Uri.fromParts("scheme", "ssp", "fragment"), + mimeType = null, + order = 0, + ) + .let { mapOf(it.uri to it) } targetIntentModifier = TargetIntentModifier { error("unexpected invocation") } val underTest = selectablePreviewsInteractor val previews = underTest.previews.stateIn(backgroundScope) val firstModel = underTest.preview( - PreviewModel(uri = Uri.fromParts("scheme", "ssp", "fragment"), mimeType = null) + PreviewModel( + uri = Uri.fromParts("scheme", "ssp", "fragment"), + mimeType = null, + order = 0, + ) ) assertThat(previews.value).isNull() @@ -120,10 +142,12 @@ class SelectablePreviewsInteractorTest { PreviewModel( uri = Uri.fromParts("scheme", "ssp", "fragment"), mimeType = "image/bitmap", + order = 0, ), PreviewModel( uri = Uri.fromParts("scheme2", "ssp2", "fragment2"), mimeType = "image/bitmap", + order = 1, ), ), startIdx = 5, @@ -132,7 +156,7 @@ class SelectablePreviewsInteractorTest { leftTriggerIndex = 0, rightTriggerIndex = 1, ) - previewSelectionsRepository.selections.value = emptyList() + previewSelectionsRepository.selections.value = emptyMap() runCurrent() assertThat(previews.value).isNotNull() @@ -140,11 +164,13 @@ class SelectablePreviewsInteractorTest { .containsExactly( PreviewModel( uri = Uri.fromParts("scheme", "ssp", "fragment"), - mimeType = "image/bitmap" + mimeType = "image/bitmap", + order = 0, ), PreviewModel( uri = Uri.fromParts("scheme2", "ssp2", "fragment2"), - mimeType = "image/bitmap" + mimeType = "image/bitmap", + order = 1, ), ) .inOrder() diff --git a/tests/unit/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/interactor/SelectionInteractorTest.kt b/tests/unit/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/interactor/SelectionInteractorTest.kt index a50efebf..87db243d 100644 --- a/tests/unit/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/interactor/SelectionInteractorTest.kt +++ b/tests/unit/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/interactor/SelectionInteractorTest.kt @@ -23,14 +23,19 @@ import com.android.intentresolver.contentpreview.payloadtoggle.data.repository.p import com.android.intentresolver.contentpreview.payloadtoggle.shared.model.PreviewModel import com.android.intentresolver.util.runKosmosTest import com.google.common.truth.Truth.assertThat +import kotlinx.coroutines.flow.first import org.junit.Test class SelectionInteractorTest { @Test fun singleSelection_removalPrevented() = runKosmosTest { val initialPreview = - PreviewModel(uri = Uri.fromParts("scheme", "ssp", "fragment"), mimeType = null) - previewSelectionsRepository.selections.value = listOf(initialPreview) + PreviewModel( + uri = Uri.fromParts("scheme", "ssp", "fragment"), + mimeType = null, + order = 0 + ) + previewSelectionsRepository.selections.value = mapOf(initialPreview.uri to initialPreview) val underTest = SelectionInteractor( @@ -40,20 +45,29 @@ class SelectionInteractorTest { mimetypeClassifier, ) - assertThat(underTest.selections.value).containsExactly(initialPreview) + assertThat(underTest.selections.first()).containsExactly(initialPreview.uri) // Shouldn't do anything! underTest.unselect(initialPreview) - assertThat(underTest.selections.value).containsExactly(initialPreview) + assertThat(underTest.selections.first()).containsExactly(initialPreview.uri) } @Test fun multipleSelections_removalAllowed() = runKosmosTest { - val first = PreviewModel(uri = Uri.fromParts("scheme", "ssp", "fragment"), mimeType = null) + val first = + PreviewModel( + uri = Uri.fromParts("scheme", "ssp", "fragment"), + mimeType = null, + order = 0 + ) val second = - PreviewModel(uri = Uri.fromParts("scheme2", "ssp2", "fragment2"), mimeType = null) - previewSelectionsRepository.selections.value = listOf(first, second) + PreviewModel( + uri = Uri.fromParts("scheme2", "ssp2", "fragment2"), + mimeType = null, + order = 1 + ) + previewSelectionsRepository.selections.value = listOf(first, second).associateBy { it.uri } val underTest = SelectionInteractor( @@ -65,6 +79,6 @@ class SelectionInteractorTest { underTest.unselect(first) - assertThat(underTest.selections.value).containsExactly(second) + assertThat(underTest.selections.first()).containsExactly(second.uri) } } diff --git a/tests/unit/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/interactor/SetCursorPreviewsInteractorTest.kt b/tests/unit/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/interactor/SetCursorPreviewsInteractorTest.kt index a165b41e..748459cb 100644 --- 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 @@ -39,6 +39,7 @@ class SetCursorPreviewsInteractorTest { PreviewModel( uri = Uri.fromParts("scheme", "ssp", "fragment"), mimeType = null, + order = 0, ) ), startIndex = 100, @@ -60,6 +61,7 @@ class SetCursorPreviewsInteractorTest { PreviewModel( uri = Uri.fromParts("scheme", "ssp", "fragment"), mimeType = null, + order = 0 ) ) .inOrder() @@ -76,6 +78,7 @@ class SetCursorPreviewsInteractorTest { PreviewModel( uri = Uri.fromParts("scheme", "ssp", "fragment"), mimeType = null, + order = 0, ) ), startIndex = 100, 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 index a26b4288..bb67e084 100644 --- 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 @@ -85,12 +85,14 @@ class ShareouselViewModelTest { PreviewModel( uri = Uri.fromParts("scheme", "ssp", "fragment"), mimeType = "image/png", + order = 0, ), PreviewModel( uri = Uri.fromParts("scheme1", "ssp1", "fragment1"), mimeType = "image/jpeg", + order = 1, ) - ) + ).associateBy { it.uri } runCurrent() assertThat(shareouselViewModel.headline.first()).isEqualTo("IMAGES: 2") } @@ -102,12 +104,14 @@ class ShareouselViewModelTest { PreviewModel( uri = Uri.fromParts("scheme", "ssp", "fragment"), mimeType = "video/mpeg", + order = 0, ), PreviewModel( uri = Uri.fromParts("scheme1", "ssp1", "fragment1"), mimeType = "video/mpeg", + order = 1, ) - ) + ).associateBy { it.uri } runCurrent() assertThat(shareouselViewModel.headline.first()).isEqualTo("VIDEOS: 2") } @@ -119,12 +123,14 @@ class ShareouselViewModelTest { PreviewModel( uri = Uri.fromParts("scheme", "ssp", "fragment"), mimeType = "image/jpeg", + order = 0, ), PreviewModel( uri = Uri.fromParts("scheme1", "ssp1", "fragment1"), mimeType = "video/mpeg", + order = 1, ) - ) + ).associateBy { it.uri } runCurrent() assertThat(shareouselViewModel.headline.first()).isEqualTo("FILES: 2") } @@ -154,10 +160,12 @@ class ShareouselViewModelTest { PreviewModel( uri = Uri.fromParts("scheme", "ssp", "fragment"), mimeType = "image/png", + order = 0, ), PreviewModel( uri = Uri.fromParts("scheme1", "ssp1", "fragment1"), mimeType = "video/mpeg", + order = 1, ) ), startIdx = 1, @@ -183,7 +191,8 @@ class ShareouselViewModelTest { shareouselViewModel.preview.invoke( PreviewModel( uri = Uri.fromParts("scheme1", "ssp1", "fragment1"), - mimeType = "video/mpeg" + mimeType = "video/mpeg", + order = 0, ), /* index = */ 1, viewModelScope, @@ -199,8 +208,7 @@ class ShareouselViewModelTest { previewVm.setSelected(true) - assertThat(previewSelectionsRepository.selections.value) - .comparingElementsUsingTransform("has uri of") { model: PreviewModel -> model.uri } + assertThat(previewSelectionsRepository.selections.value.keys) .contains(Uri.fromParts("scheme1", "ssp1", "fragment1")) } @@ -214,10 +222,12 @@ class ShareouselViewModelTest { PreviewModel( uri = Uri.fromParts("scheme", "ssp", "fragment"), mimeType = "image/png", + order = 0, ), PreviewModel( uri = Uri.fromParts("scheme1", "ssp1", "fragment1"), mimeType = "video/mpeg", + order = 1, ) ), startIdx = 1, @@ -232,7 +242,8 @@ class ShareouselViewModelTest { shareouselViewModel.preview.invoke( PreviewModel( uri = Uri.fromParts("scheme", "ssp", "fragment"), - mimeType = "video/mpeg" + mimeType = "video/mpeg", + order = 1, ), /* index = */ 1, viewModelScope, @@ -296,7 +307,11 @@ class ShareouselViewModelTest { this.pendingIntentSender = pendingIntentSender this.targetIntentModifier = targetIntentModifier previewSelectionsRepository.selections.value = - listOf(PreviewModel(uri = Uri.fromParts("scheme", "ssp", "fragment"), mimeType = null)) + PreviewModel( + uri = Uri.fromParts("scheme", "ssp", "fragment"), + mimeType = null, + order = 0, + ).let { mapOf(it.uri to it) } payloadToggleImageLoader = FakeImageLoader( initialBitmaps = -- cgit v1.2.3-59-g8ed1b From c996f37829fbe06a9f700b3bd1cc797ff1fda6c7 Mon Sep 17 00:00:00 2001 From: Mark Renouf Date: Tue, 11 Jun 2024 14:49:54 -0400 Subject: Do not auto-dismiss on package change with no targets This behavior is obsolete and confusing, removing this path. Package changes will still reload the resultsm, but no dismiss will happen when there are no results. Bug: 334179669 Flag: EXEMPT bugfix Test: Manually by sending an intent with no matches and installing a package while the Share UI is open. Change-Id: Ic654794baf14d039a6155e37b9d3ef0b61b10f71 --- java/src/com/android/intentresolver/ChooserActivity.java | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) (limited to 'java/src') diff --git a/java/src/com/android/intentresolver/ChooserActivity.java b/java/src/com/android/intentresolver/ChooserActivity.java index 670512ac..a5516fde 100644 --- a/java/src/com/android/intentresolver/ChooserActivity.java +++ b/java/src/com/android/intentresolver/ChooserActivity.java @@ -1196,13 +1196,9 @@ public class ChooserActivity extends Hilt_ChooserActivity implements @Override // ResolverListCommunicator public final void onHandlePackagesChanged(ResolverListAdapter listAdapter) { - if (!mChooserMultiProfilePagerAdapter.onHandlePackagesChanged( + mChooserMultiProfilePagerAdapter.onHandlePackagesChanged( (ChooserListAdapter) listAdapter, - mProfileAvailability.getWaitingToEnableProfile())) { - // We no longer have any items... just finish the activity. - Log.d(TAG, "onHandlePackagesChanged(): returned false, finishing"); - finish(); - } + mProfileAvailability.getWaitingToEnableProfile()); } final Option optionForChooserTarget(TargetInfo target, int index) { -- cgit v1.2.3-59-g8ed1b From 9532f1f5f73496b86c43b88da5b29dbc1af5fbdd Mon Sep 17 00:00:00 2001 From: Mark Renouf Date: Tue, 11 Jun 2024 15:12:41 -0400 Subject: Restore share restriction string customizatiom Use DevicePolicyResources for these messages. Bug: 344992620 Test: GTS-Interactive Flag: EXEMPT bugfix Change-Id: Id5f4b6fb362efca616dab9171f027d90f846dc7a --- .../data/repository/DevicePolicyResources.kt | 30 +++++++++++++++------- 1 file changed, 21 insertions(+), 9 deletions(-) (limited to 'java/src') diff --git a/java/src/com/android/intentresolver/data/repository/DevicePolicyResources.kt b/java/src/com/android/intentresolver/data/repository/DevicePolicyResources.kt index eb35a358..7fb3c4cd 100644 --- a/java/src/com/android/intentresolver/data/repository/DevicePolicyResources.kt +++ b/java/src/com/android/intentresolver/data/repository/DevicePolicyResources.kt @@ -18,6 +18,10 @@ package com.android.intentresolver.data.repository import android.app.admin.DevicePolicyManager import android.app.admin.DevicePolicyResources.Strings.Core.FORWARD_INTENT_TO_PERSONAL import android.app.admin.DevicePolicyResources.Strings.Core.FORWARD_INTENT_TO_WORK +import android.app.admin.DevicePolicyResources.Strings.Core.RESOLVER_CANT_ACCESS_PERSONAL +import android.app.admin.DevicePolicyResources.Strings.Core.RESOLVER_CANT_ACCESS_WORK +import android.app.admin.DevicePolicyResources.Strings.Core.RESOLVER_CANT_SHARE_WITH_PERSONAL +import android.app.admin.DevicePolicyResources.Strings.Core.RESOLVER_CANT_SHARE_WITH_WORK import android.app.admin.DevicePolicyResources.Strings.Core.RESOLVER_CROSS_PROFILE_BLOCKED_TITLE import android.app.admin.DevicePolicyResources.Strings.Core.RESOLVER_NO_PERSONAL_APPS import android.app.admin.DevicePolicyResources.Strings.Core.RESOLVER_NO_WORK_APPS @@ -39,7 +43,7 @@ open class DevicePolicyResources @Inject constructor( @ApplicationOwned private val resources: Resources, - devicePolicyManager: DevicePolicyManager + devicePolicyManager: DevicePolicyManager, ) { private val policyResources = devicePolicyManager.resources @@ -113,19 +117,27 @@ constructor( } open fun toPersonalBlockedByPolicyMessage(share: Boolean): String { - return if (share) { - resources.getString(R.string.resolver_cant_share_with_personal_apps_explanation) + return requireNotNull(if (share) { + policyResources.getString(RESOLVER_CANT_SHARE_WITH_PERSONAL) { + resources.getString(R.string.resolver_cant_share_with_personal_apps_explanation) + } } else { - resources.getString(R.string.resolver_cant_access_personal_apps_explanation) - } + policyResources.getString(RESOLVER_CANT_ACCESS_PERSONAL) { + resources.getString(R.string.resolver_cant_access_personal_apps_explanation) + } + }) } open fun toWorkBlockedByPolicyMessage(share: Boolean): String { - return if (share) { - resources.getString(R.string.resolver_cant_share_with_work_apps_explanation) + return requireNotNull(if (share) { + policyResources.getString(RESOLVER_CANT_SHARE_WITH_WORK) { + resources.getString(R.string.resolver_cant_share_with_work_apps_explanation) + } } else { - resources.getString(R.string.resolver_cant_access_work_apps_explanation) - } + policyResources.getString(RESOLVER_CANT_ACCESS_WORK) { + resources.getString(R.string.resolver_cant_access_work_apps_explanation) + } + }) } open fun toPrivateBlockedByPolicyMessage(share: Boolean): String { -- cgit v1.2.3-59-g8ed1b From 1c5659e691b022038f31961363e92ca55068d600 Mon Sep 17 00:00:00 2001 From: Matt Casey Date: Wed, 12 Jun 2024 17:22:56 +0000 Subject: Prefetch shareousel items Based upon the DefaultLazyListPrefetchStrategy impl, but prefetching more things. Bug: 344961968 Test: Manual test with ShareTest and slow load times Flag: android.service.chooser.chooser_payload_toggling Change-Id: I28ef69a360ce6e02f9ff95e4aab98365b380de0d --- .../ui/composable/ShareouselComposable.kt | 8 +- .../ShareouselLazyListPrefetchStrategy.kt | 120 +++++++++++++++++++++ 2 files changed, 127 insertions(+), 1 deletion(-) create mode 100644 java/src/com/android/intentresolver/contentpreview/payloadtoggle/ui/composable/ShareouselLazyListPrefetchStrategy.kt (limited to 'java/src') diff --git a/java/src/com/android/intentresolver/contentpreview/payloadtoggle/ui/composable/ShareouselComposable.kt b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/ui/composable/ShareouselComposable.kt index c63055d2..0940baa0 100644 --- a/java/src/com/android/intentresolver/contentpreview/payloadtoggle/ui/composable/ShareouselComposable.kt +++ b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/ui/composable/ShareouselComposable.kt @@ -16,6 +16,7 @@ package com.android.intentresolver.contentpreview.payloadtoggle.ui.composable import androidx.compose.animation.Crossfade +import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.Image import androidx.compose.foundation.background import androidx.compose.foundation.border @@ -92,13 +93,18 @@ private fun Shareousel(viewModel: ShareouselViewModel, keySet: PreviewsModel) { } } +@OptIn(ExperimentalFoundationApi::class) @Composable private fun PreviewCarousel( previews: PreviewsModel, viewModel: ShareouselViewModel, ) { val centerIdx = previews.startIdx - val carouselState = rememberLazyListState(initialFirstVisibleItemIndex = centerIdx) + val carouselState = + rememberLazyListState( + initialFirstVisibleItemIndex = centerIdx, + prefetchStrategy = remember { ShareouselLazyListPrefetchStrategy() } + ) // TODO: start item needs to be centered, check out ScalingLazyColumn impl or see if // HorizontalPager works for our use-case LazyRow( diff --git a/java/src/com/android/intentresolver/contentpreview/payloadtoggle/ui/composable/ShareouselLazyListPrefetchStrategy.kt b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/ui/composable/ShareouselLazyListPrefetchStrategy.kt new file mode 100644 index 00000000..e47700f1 --- /dev/null +++ b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/ui/composable/ShareouselLazyListPrefetchStrategy.kt @@ -0,0 +1,120 @@ +/* + * Copyright (C) 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.intentresolver.contentpreview.payloadtoggle.ui.composable + +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.lazy.LazyListItemInfo +import androidx.compose.foundation.lazy.LazyListLayoutInfo +import androidx.compose.foundation.lazy.LazyListPrefetchScope +import androidx.compose.foundation.lazy.LazyListPrefetchStrategy +import androidx.compose.foundation.lazy.layout.LazyLayoutPrefetchState +import androidx.compose.foundation.lazy.layout.NestedPrefetchScope + +/** Prefetch strategy to fetch items ahead and behind the current scroll position. */ +@OptIn(ExperimentalFoundationApi::class) +class ShareouselLazyListPrefetchStrategy( + private val lookAhead: Int = 4, + private val lookBackward: Int = 1 +) : LazyListPrefetchStrategy { + // Map of index -> prefetch handle + private val prefetchHandles: MutableMap = + mutableMapOf() + + private var prefetchRange = IntRange.EMPTY + + private enum class ScrollDirection { + UNKNOWN, // The user hasn't scrolled in either direction yet. + FORWARD, + BACKWARD, + } + + private var scrollDirection: ScrollDirection = ScrollDirection.UNKNOWN + + override fun LazyListPrefetchScope.onScroll(delta: Float, layoutInfo: LazyListLayoutInfo) { + if (layoutInfo.visibleItemsInfo.isNotEmpty()) { + scrollDirection = if (delta < 0) ScrollDirection.FORWARD else ScrollDirection.BACKWARD + updatePrefetchSet(layoutInfo.visibleItemsInfo) + } + + if (scrollDirection == ScrollDirection.FORWARD) { + val lastItem = layoutInfo.visibleItemsInfo.last() + val spacing = layoutInfo.mainAxisItemSpacing + val distanceToPrefetchItem = + lastItem.offset + lastItem.size + spacing - layoutInfo.viewportEndOffset + // if in the next frame we will get the same delta will we reach the item? + if (distanceToPrefetchItem < -delta) { + prefetchHandles.get(lastItem.index + 1)?.markAsUrgent() + } + } else { + val firstItem = layoutInfo.visibleItemsInfo.first() + val distanceToPrefetchItem = layoutInfo.viewportStartOffset - firstItem.offset + // if in the next frame we will get the same delta will we reach the item? + if (distanceToPrefetchItem < delta) { + prefetchHandles.get(firstItem.index - 1)?.markAsUrgent() + } + } + } + + override fun LazyListPrefetchScope.onVisibleItemsUpdated(layoutInfo: LazyListLayoutInfo) { + if (layoutInfo.visibleItemsInfo.isNotEmpty()) { + updatePrefetchSet(layoutInfo.visibleItemsInfo) + } + } + + override fun NestedPrefetchScope.onNestedPrefetch(firstVisibleItemIndex: Int) {} + + private fun getVisibleRange(visibleItems: List) = + if (visibleItems.isEmpty()) IntRange.EMPTY + else IntRange(visibleItems.first().index, visibleItems.last().index) + + /** Update prefetchRange based upon the visible item range and scroll direction. */ + private fun updatePrefetchRange(visibleRange: IntRange) { + prefetchRange = + when (scrollDirection) { + // Prefetch in both directions + ScrollDirection.UNKNOWN -> + visibleRange.first - lookAhead / 2..visibleRange.last + lookAhead / 2 + ScrollDirection.FORWARD -> + visibleRange.first - lookBackward..visibleRange.last + lookAhead + ScrollDirection.BACKWARD -> + visibleRange.first - lookAhead..visibleRange.last + lookBackward + } + } + + private fun LazyListPrefetchScope.updatePrefetchSet(visibleItems: List) { + val visibleRange = getVisibleRange(visibleItems) + updatePrefetchRange(visibleRange) + updatePrefetchOperations(visibleRange) + } + + private fun LazyListPrefetchScope.updatePrefetchOperations(visibleItemsRange: IntRange) { + // Remove any fetches outside of the prefetch range or inside the visible range + prefetchHandles + .filterKeys { it !in prefetchRange || it in visibleItemsRange } + .forEach { + it.value.cancel() + prefetchHandles.remove(it.key) + } + + // Ensure all non-visible items in the range are being prefetched + prefetchRange.forEach { + if (it !in visibleItemsRange && !prefetchHandles.containsKey(it)) { + prefetchHandles[it] = schedulePrefetch(it) + } + } + } +} -- cgit v1.2.3-59-g8ed1b From 72ea64aa101e763d69b2426a802dae3743ea3fb9 Mon Sep 17 00:00:00 2001 From: Govinda Wasserman Date: Fri, 14 Jun 2024 15:15:09 -0400 Subject: Creates 16 dp offset for first and last items. This lines up item with text above and ensures the user can tell if there are items to the left of it. Test: manual testing BUG: 341925364 Flag: android.service.chooser.chooser_payload_toggling Change-Id: I62d2b67843b373a894d88df5e2c7fc4ea9d510e0 --- .../contentpreview/payloadtoggle/ui/composable/ShareouselComposable.kt | 2 ++ 1 file changed, 2 insertions(+) (limited to 'java/src') diff --git a/java/src/com/android/intentresolver/contentpreview/payloadtoggle/ui/composable/ShareouselComposable.kt b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/ui/composable/ShareouselComposable.kt index c63055d2..6af02523 100644 --- a/java/src/com/android/intentresolver/contentpreview/payloadtoggle/ui/composable/ShareouselComposable.kt +++ b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/ui/composable/ShareouselComposable.kt @@ -22,6 +22,7 @@ import androidx.compose.foundation.border import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.aspectRatio import androidx.compose.foundation.layout.fillMaxHeight @@ -104,6 +105,7 @@ private fun PreviewCarousel( LazyRow( state = carouselState, horizontalArrangement = Arrangement.spacedBy(4.dp), + contentPadding = PaddingValues(start = 16.dp, end = 16.dp), modifier = Modifier.fillMaxWidth() .height(dimensionResource(R.dimen.chooser_preview_image_height_tall)) -- cgit v1.2.3-59-g8ed1b