diff options
163 files changed, 6382 insertions, 3480 deletions
@@ -69,6 +69,11 @@ android_library { "androidx.lifecycle_lifecycle-viewmodel-compose", "androidx.lifecycle_lifecycle-runtime-compose", ], + javacflags: [ + "-Adagger.fastInit=enabled", + "-Adagger.explicitBindingConflictsWithInject=ERROR", + "-Adagger.strictMultibindingValidation=enabled", + ], } java_defaults { diff --git a/aconfig/FeatureFlags.aconfig b/aconfig/FeatureFlags.aconfig index 04883baf..583d8502 100644 --- a/aconfig/FeatureFlags.aconfig +++ b/aconfig/FeatureFlags.aconfig @@ -47,5 +47,5 @@ flag { name: "enable_private_profile" namespace: "intentresolver" description: "Enable private profile support" - bug: "311348033" + bug: "328029692" } diff --git a/java/res/values/styles.xml b/java/res/values/styles.xml index 0ccab4c0..143009d0 100644 --- a/java/res/values/styles.xml +++ b/java/res/values/styles.xml @@ -45,7 +45,7 @@ <style name="Theme.DeviceDefault.Chooser" parent="Theme.DeviceDefault.Resolver"> <item name="*android:iconfactoryIconSize">@dimen/chooser_icon_size</item> <item name="*android:iconfactoryBadgeSize">@dimen/chooser_badge_size</item> - <item name="android:windowLayoutInDisplayCutoutMode">shortEdges</item> + <item name="android:windowLayoutInDisplayCutoutMode">always</item> </style> <style name="TextAppearance.ChooserDefault" diff --git a/java/src/com/android/intentresolver/ChooserActivity.java b/java/src/com/android/intentresolver/ChooserActivity.java index 039fad56..9557b25b 100644 --- a/java/src/com/android/intentresolver/ChooserActivity.java +++ b/java/src/com/android/intentresolver/ChooserActivity.java @@ -305,16 +305,16 @@ public class ChooserActivity extends Hilt_ChooserActivity implements .get(BasePreviewViewModel.class); previewViewModel.init( mChooserRequest.getTargetIntent(), - getIntent(), /*additionalContentUri = */ null, - /*focusedItemIdx = */ 0, /*isPayloadTogglingEnabled = */ false); + final ChooserActionFactory chooserActionFactory = createChooserActionFactory(); mChooserContentPreviewUi = new ChooserContentPreviewUi( getCoroutineScope(getLifecycle()), previewViewModel.getPreviewDataProvider(), mChooserRequest.getTargetIntent(), previewViewModel.getImageLoader(), - createChooserActionFactory(), + chooserActionFactory, + chooserActionFactory::getModifyShareAction, mEnterTransitionAnimationDelegate, new HeadlineGeneratorImpl(this), ContentTypeHint.NONE, diff --git a/java/src/com/android/intentresolver/EnterTransitionAnimationDelegate.kt b/java/src/com/android/intentresolver/EnterTransitionAnimationDelegate.kt index b1178aa5..6a4fe65a 100644 --- a/java/src/com/android/intentresolver/EnterTransitionAnimationDelegate.kt +++ b/java/src/com/android/intentresolver/EnterTransitionAnimationDelegate.kt @@ -21,14 +21,14 @@ import androidx.activity.ComponentActivity import androidx.lifecycle.lifecycleScope import com.android.intentresolver.widget.ImagePreviewView.TransitionElementStatusCallback import com.android.internal.annotations.VisibleForTesting +import java.util.function.Supplier import kotlinx.coroutines.Job import kotlinx.coroutines.delay import kotlinx.coroutines.launch -import java.util.function.Supplier /** - * A helper class to track app's readiness for the scene transition animation. - * The app is ready when both the image is laid out and the drawer offset is calculated. + * A helper class to track app's readiness for the scene transition animation. The app is ready when + * both the image is laid out and the drawer offset is calculated. */ @VisibleForTesting(visibility = VisibleForTesting.Visibility.PACKAGE) class EnterTransitionAnimationDelegate( @@ -45,21 +45,22 @@ class EnterTransitionAnimationDelegate( activity.setEnterSharedElementCallback( object : SharedElementCallback() { override fun onMapSharedElements( - names: MutableList<String>, sharedElements: MutableMap<String, View> + names: MutableList<String>, + sharedElements: MutableMap<String, View> ) { - this@EnterTransitionAnimationDelegate.onMapSharedElements( - names, sharedElements - ) + this@EnterTransitionAnimationDelegate.onMapSharedElements(names, sharedElements) } - }) + } + ) } fun postponeTransition() { activity.postponeEnterTransition() - timeoutJob = activity.lifecycleScope.launch { - delay(activity.resources.getInteger(R.integer.config_shortAnimTime).toLong()) - onTimeout() - } + timeoutJob = + activity.lifecycleScope.launch { + delay(activity.resources.getInteger(R.integer.config_shortAnimTime).toLong()) + onTimeout() + } } private fun onTimeout() { @@ -110,8 +111,14 @@ class EnterTransitionAnimationDelegate( override fun onLayoutChange( v: View, - left: Int, top: Int, right: Int, bottom: Int, - oldLeft: Int, oldTop: Int, oldRight: Int, oldBottom: Int + left: Int, + top: Int, + right: Int, + bottom: Int, + oldLeft: Int, + oldTop: Int, + oldRight: Int, + oldBottom: Int ) { v.removeOnLayoutChangeListener(this) startPostponedEnterTransition() diff --git a/java/src/com/android/intentresolver/ItemRevealAnimationTracker.kt b/java/src/com/android/intentresolver/ItemRevealAnimationTracker.kt index d3e07c6b..7deb0d10 100644 --- a/java/src/com/android/intentresolver/ItemRevealAnimationTracker.kt +++ b/java/src/com/android/intentresolver/ItemRevealAnimationTracker.kt @@ -37,9 +37,7 @@ internal class ItemRevealAnimationTracker { fun animateLabel(view: View, info: TargetInfo) = animateView(view, info, labelProgress) private fun animateView(view: View, info: TargetInfo, map: MutableMap<TargetInfo, Record>) { - val record = map.getOrPut(info) { - Record() - } + val record = map.getOrPut(info) { Record() } if ((view.animation as? RevealAnimation)?.record === record) return view.clearAnimation() diff --git a/java/src/com/android/intentresolver/SecureSettings.kt b/java/src/com/android/intentresolver/SecureSettings.kt index a4853fd8..1e938895 100644 --- a/java/src/com/android/intentresolver/SecureSettings.kt +++ b/java/src/com/android/intentresolver/SecureSettings.kt @@ -19,9 +19,7 @@ package com.android.intentresolver import android.content.ContentResolver import android.provider.Settings -/** - * A proxy class for secure settings, for easier testing. - */ +/** A proxy class for secure settings, for easier testing. */ open class SecureSettings { open fun getString(resolver: ContentResolver, name: String): String? { return Settings.Secure.getString(resolver, name) diff --git a/java/src/com/android/intentresolver/ShortcutSelectionLogic.java b/java/src/com/android/intentresolver/ShortcutSelectionLogic.java index efaaf894..12465184 100644 --- a/java/src/com/android/intentresolver/ShortcutSelectionLogic.java +++ b/java/src/com/android/intentresolver/ShortcutSelectionLogic.java @@ -30,13 +30,19 @@ import androidx.annotation.Nullable; import com.android.intentresolver.chooser.DisplayResolveInfo; import com.android.intentresolver.chooser.SelectableTargetInfo; import com.android.intentresolver.chooser.TargetInfo; +import com.android.intentresolver.v2.ui.AppShortcutLimit; +import com.android.intentresolver.v2.ui.EnforceShortcutLimit; import java.util.Collections; import java.util.Comparator; import java.util.List; import java.util.Map; -class ShortcutSelectionLogic { +import javax.inject.Inject; +import javax.inject.Singleton; + +@Singleton +public class ShortcutSelectionLogic { private static final String TAG = "ShortcutSelectionLogic"; private static final boolean DEBUG = false; private static final float PINNED_SHORTCUT_TARGET_SCORE_BOOST = 1000.f; @@ -49,9 +55,10 @@ class ShortcutSelectionLogic { private final Comparator<ChooserTarget> mBaseTargetComparator = (lhs, rhs) -> Float.compare(rhs.getScore(), lhs.getScore()); - ShortcutSelectionLogic( - int maxShortcutTargetsPerApp, - boolean applySharingAppLimits) { + @Inject + public ShortcutSelectionLogic( + @AppShortcutLimit int maxShortcutTargetsPerApp, + @EnforceShortcutLimit boolean applySharingAppLimits) { mMaxShortcutTargetsPerApp = maxShortcutTargetsPerApp; mApplySharingAppLimits = applySharingAppLimits; } @@ -78,7 +85,7 @@ class ShortcutSelectionLogic { + targets.size() + " targets"); } - if (targets.size() == 0) { + if (targets.isEmpty()) { return false; } Collections.sort(targets, mBaseTargetComparator); diff --git a/java/src/com/android/intentresolver/contentpreview/BasePreviewViewModel.kt b/java/src/com/android/intentresolver/contentpreview/BasePreviewViewModel.kt index 21c909ea..dc36e584 100644 --- a/java/src/com/android/intentresolver/contentpreview/BasePreviewViewModel.kt +++ b/java/src/com/android/intentresolver/contentpreview/BasePreviewViewModel.kt @@ -25,14 +25,11 @@ import androidx.lifecycle.ViewModel abstract class BasePreviewViewModel : ViewModel() { @get:MainThread abstract val previewDataProvider: PreviewDataProvider @get:MainThread abstract val imageLoader: ImageLoader - abstract val payloadToggleInteractor: PayloadToggleInteractor? @MainThread abstract fun init( targetIntent: Intent, - chooserIntent: Intent, additionalContentUri: Uri?, - focusedItemIdx: Int, isPayloadTogglingEnabled: Boolean, ) } diff --git a/java/src/com/android/intentresolver/contentpreview/ChooserContentPreviewUi.java b/java/src/com/android/intentresolver/contentpreview/ChooserContentPreviewUi.java index 6f201ad5..67458697 100644 --- a/java/src/com/android/intentresolver/contentpreview/ChooserContentPreviewUi.java +++ b/java/src/com/android/intentresolver/contentpreview/ChooserContentPreviewUi.java @@ -39,6 +39,7 @@ import com.android.intentresolver.widget.ImagePreviewView.TransitionElementStatu import java.util.List; import java.util.function.Consumer; +import java.util.function.Supplier; import kotlinx.coroutines.CoroutineScope; @@ -77,7 +78,9 @@ public final class ChooserContentPreviewUi { * Provides a share modification action, if any. */ @Nullable - ActionRow.Action getModifyShareAction(); + default ActionRow.Action getModifyShareAction() { + return null; + } /** * <p> @@ -93,6 +96,9 @@ public final class ChooserContentPreviewUi { @VisibleForTesting final ContentPreviewUi mContentPreviewUi; + private final Supplier</*@Nullable*/ActionRow.Action> mModifyShareActionFactory; + @Nullable + private View mHeadlineParent; public ChooserContentPreviewUi( CoroutineScope scope, @@ -100,6 +106,7 @@ public final class ChooserContentPreviewUi { Intent targetIntent, ImageLoader imageLoader, ActionFactory actionFactory, + Supplier</*@Nullable*/ActionRow.Action> modifyShareActionFactory, TransitionElementStatusCallback transitionElementStatusCallback, HeadlineGenerator headlineGenerator, ContentTypeHint contentTypeHint, @@ -108,6 +115,7 @@ public final class ChooserContentPreviewUi { boolean isPayloadTogglingEnabled) { mScope = scope; mIsPayloadTogglingEnabled = isPayloadTogglingEnabled; + mModifyShareActionFactory = modifyShareActionFactory; mContentPreviewUi = createContentPreview( previewData, targetIntent, @@ -162,7 +170,7 @@ public final class ChooserContentPreviewUi { if (previewType == CONTENT_PREVIEW_PAYLOAD_SELECTION && mIsPayloadTogglingEnabled) { transitionElementStatusCallback.onAllTransitionElementsReady(); // TODO - return new ShareouselContentPreviewUi(actionFactory); + return new ShareouselContentPreviewUi(); } boolean isSingleImageShare = previewData.getUriCount() == 1 @@ -220,7 +228,24 @@ public final class ChooserContentPreviewUi { ViewGroup parent, @Nullable View headlineViewParent) { - return mContentPreviewUi.display(resources, layoutInflater, parent, headlineViewParent); + ViewGroup layout = + mContentPreviewUi.display(resources, layoutInflater, parent, headlineViewParent); + mHeadlineParent = headlineViewParent == null ? layout : headlineViewParent; + if (mHeadlineParent != null) { + ContentPreviewUi.displayModifyShareAction( + mHeadlineParent, mModifyShareActionFactory.get()); + } + return layout; + } + + /** + * Update Modify Share Action, if it is inflated. + */ + public void updateModifyShareAction() { + if (mHeadlineParent != null) { + ContentPreviewUi.displayModifyShareAction( + mHeadlineParent, mModifyShareActionFactory.get()); + } } private static TextContentPreviewUi createTextPreview( diff --git a/java/src/com/android/intentresolver/contentpreview/ContentPreviewUi.java b/java/src/com/android/intentresolver/contentpreview/ContentPreviewUi.java index b0fb278e..71d5fc0b 100644 --- a/java/src/com/android/intentresolver/contentpreview/ContentPreviewUi.java +++ b/java/src/com/android/intentresolver/contentpreview/ContentPreviewUi.java @@ -98,16 +98,19 @@ public abstract class ContentPreviewUi { } } - protected static void displayModifyShareAction( - View layout, ChooserContentPreviewUi.ActionFactory actionFactory) { - ActionRow.Action modifyShareAction = actionFactory.getModifyShareAction(); - if (modifyShareAction != null && layout != null) { - TextView modifyShareView = layout.findViewById(R.id.reselection_action); - if (modifyShareView != null) { - modifyShareView.setText(modifyShareAction.getLabel()); - modifyShareView.setVisibility(View.VISIBLE); - modifyShareView.setOnClickListener(view -> modifyShareAction.getOnClicked().run()); - } + static void displayModifyShareAction( + View layout, @Nullable ActionRow.Action modifyShareAction) { + TextView modifyShareView = + layout == null ? null : layout.findViewById(R.id.reselection_action); + if (modifyShareView == null) { + return; + } + if (modifyShareAction != null) { + modifyShareView.setText(modifyShareAction.getLabel()); + modifyShareView.setVisibility(View.VISIBLE); + modifyShareView.setOnClickListener(view -> modifyShareAction.getOnClicked().run()); + } else { + modifyShareView.setVisibility(View.GONE); } } diff --git a/java/src/com/android/intentresolver/contentpreview/CursorUriReader.kt b/java/src/com/android/intentresolver/contentpreview/CursorUriReader.kt deleted file mode 100644 index 6a12f56c..00000000 --- a/java/src/com/android/intentresolver/contentpreview/CursorUriReader.kt +++ /dev/null @@ -1,147 +0,0 @@ -/* - * Copyright 2024 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.android.intentresolver.contentpreview - -import android.content.ContentInterface -import android.content.Intent -import android.database.Cursor -import android.database.MatrixCursor -import android.net.Uri -import android.os.Bundle -import android.os.CancellationSignal -import android.service.chooser.AdditionalContentContract.Columns -import android.service.chooser.AdditionalContentContract.CursorExtraKeys -import android.util.Log -import android.util.SparseArray -import kotlinx.coroutines.CancellationException -import kotlinx.coroutines.coroutineScope - -private const val TAG = ContentPreviewUi.TAG - -/** - * A bi-directional cursor reader. Reads URI from the [cursor] starting from the given [startPos], - * filters items by [predicate]. - */ -class CursorUriReader( - private val cursor: Cursor, - startPos: Int, - private val pageSize: Int, - private val predicate: (Uri) -> Boolean, -) : PayloadToggleInteractor.CursorReader { - override val count = cursor.count - // Unread ranges are: - // - left: [0, leftPos); - // - right: [rightPos, count) - // i.e. read range is: [leftPos, rightPos) - private var rightPos = startPos.coerceIn(0, count) - private var leftPos = rightPos - - override val hasMoreBefore - get() = leftPos > 0 - - override val hasMoreAfter - get() = rightPos < count - - override fun readPageAfter(): SparseArray<Uri> { - if (!hasMoreAfter) return SparseArray() - if (!cursor.moveToPosition(rightPos)) { - rightPos = count - Log.w(TAG, "Failed to move the cursor to position $rightPos, stop reading the cursor") - return SparseArray() - } - val result = SparseArray<Uri>(pageSize) - do { - cursor - .getString(0) - ?.let(Uri::parse) - ?.takeIf { predicate(it) } - ?.let { uri -> result.append(rightPos, uri) } - rightPos++ - } while (result.size() < pageSize && cursor.moveToNext()) - maybeCloseCursor() - return result - } - - override fun readPageBefore(): SparseArray<Uri> { - if (!hasMoreBefore) return SparseArray() - val startPos = maxOf(0, leftPos - pageSize) - if (!cursor.moveToPosition(startPos)) { - leftPos = 0 - Log.w(TAG, "Failed to move the cursor to position $startPos, stop reading cursor") - return SparseArray() - } - val result = SparseArray<Uri>(leftPos - startPos) - for (pos in startPos until leftPos) { - cursor - .getString(0) - ?.let(Uri::parse) - ?.takeIf { predicate(it) } - ?.let { uri -> result.append(pos, uri) } - if (!cursor.moveToNext()) break - } - leftPos = startPos - maybeCloseCursor() - return result - } - - private fun maybeCloseCursor() { - if (!hasMoreBefore && !hasMoreAfter) { - close() - } - } - - override fun close() { - cursor.close() - } - - companion object { - suspend fun createCursorReader( - contentResolver: ContentInterface, - uri: Uri, - chooserIntent: Intent - ): CursorUriReader { - val cancellationSignal = CancellationSignal() - val cursor = - try { - coroutineScope { - runCatching { - contentResolver.query( - uri, - arrayOf(Columns.URI), - Bundle().apply { - putParcelable(Intent.EXTRA_INTENT, chooserIntent) - }, - cancellationSignal - ) - } - .getOrNull() - ?: MatrixCursor(arrayOf(Columns.URI)) - } - } catch (e: CancellationException) { - cancellationSignal.cancel() - throw e - } - return CursorUriReader( - cursor, - cursor.extras?.getInt(CursorExtraKeys.POSITION, 0) ?: 0, - 128, - ) { - it.authority != uri.authority - } - } - } -} diff --git a/java/src/com/android/intentresolver/contentpreview/FileContentPreviewUi.java b/java/src/com/android/intentresolver/contentpreview/FileContentPreviewUi.java index d4eea8b9..d127d929 100644 --- a/java/src/com/android/intentresolver/contentpreview/FileContentPreviewUi.java +++ b/java/src/com/android/intentresolver/contentpreview/FileContentPreviewUi.java @@ -77,10 +77,7 @@ class FileContentPreviewUi extends ContentPreviewUi { LayoutInflater layoutInflater, ViewGroup parent, @Nullable View headlineViewParent) { - ViewGroup layout = displayInternal(resources, layoutInflater, parent, headlineViewParent); - displayModifyShareAction( - headlineViewParent == null ? layout : headlineViewParent, mActionFactory); - return layout; + return displayInternal(resources, layoutInflater, parent, headlineViewParent); } private ViewGroup displayInternal( diff --git a/java/src/com/android/intentresolver/contentpreview/FilesPlusTextContentPreviewUi.java b/java/src/com/android/intentresolver/contentpreview/FilesPlusTextContentPreviewUi.java index 6832c5c4..4758534d 100644 --- a/java/src/com/android/intentresolver/contentpreview/FilesPlusTextContentPreviewUi.java +++ b/java/src/com/android/intentresolver/contentpreview/FilesPlusTextContentPreviewUi.java @@ -109,10 +109,7 @@ class FilesPlusTextContentPreviewUi extends ContentPreviewUi { LayoutInflater layoutInflater, ViewGroup parent, @Nullable View headlineViewParent) { - ViewGroup layout = displayInternal(layoutInflater, parent, headlineViewParent); - displayModifyShareAction( - headlineViewParent == null ? layout : headlineViewParent, mActionFactory); - return layout; + return displayInternal(layoutInflater, parent, headlineViewParent); } public void updatePreviewMetadata(List<FileInfo> files) { diff --git a/java/src/com/android/intentresolver/contentpreview/HeadlineGeneratorImpl.kt b/java/src/com/android/intentresolver/contentpreview/HeadlineGeneratorImpl.kt index 6e126822..e92d9bc6 100644 --- a/java/src/com/android/intentresolver/contentpreview/HeadlineGeneratorImpl.kt +++ b/java/src/com/android/intentresolver/contentpreview/HeadlineGeneratorImpl.kt @@ -20,6 +20,12 @@ import android.content.Context import android.util.PluralsMessageFormatter import androidx.annotation.StringRes import com.android.intentresolver.R +import dagger.Binds +import dagger.Module +import dagger.hilt.InstallIn +import dagger.hilt.android.qualifiers.ApplicationContext +import dagger.hilt.components.SingletonComponent +import javax.inject.Inject private const val PLURALS_COUNT = "count" @@ -27,7 +33,11 @@ private const val PLURALS_COUNT = "count" * HeadlineGenerator generates the text to show at the top of the sharesheet as a brief description * of the content being shared. */ -class HeadlineGeneratorImpl(private val context: Context) : HeadlineGenerator { +class HeadlineGeneratorImpl +@Inject +constructor( + @ApplicationContext private val context: Context, +) : HeadlineGenerator { override fun getTextHeadline(text: CharSequence): String { return context.getString( getTemplateResource(text, R.string.sharing_link, R.string.sharing_text) @@ -100,3 +110,9 @@ class HeadlineGeneratorImpl(private val context: Context) : HeadlineGenerator { return if (text.toString().isHttpUri()) linkResource else nonLinkResource } } + +@Module +@InstallIn(SingletonComponent::class) +interface HeadlineGeneratorModule { + @Binds fun bind(impl: HeadlineGeneratorImpl): HeadlineGenerator +} diff --git a/java/src/com/android/intentresolver/contentpreview/ImageLoaderModule.kt b/java/src/com/android/intentresolver/contentpreview/ImageLoaderModule.kt new file mode 100644 index 00000000..b861a24a --- /dev/null +++ b/java/src/com/android/intentresolver/contentpreview/ImageLoaderModule.kt @@ -0,0 +1,44 @@ +/* + * Copyright 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.intentresolver.contentpreview + +import android.content.res.Resources +import com.android.intentresolver.R +import com.android.intentresolver.inject.ApplicationOwned +import dagger.Binds +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.android.components.ActivityRetainedComponent +import dagger.hilt.android.scopes.ActivityRetainedScoped + +@Module +@InstallIn(ActivityRetainedComponent::class) +interface ImageLoaderModule { + @Binds + @ActivityRetainedScoped + fun imageLoader(previewImageLoader: ImagePreviewImageLoader): ImageLoader + + companion object { + @Provides + @ThumbnailSize + fun thumbnailSize(@ApplicationOwned resources: Resources): Int = + resources.getDimensionPixelSize(R.dimen.chooser_preview_image_max_dimen) + + @Provides @PreviewCacheSize fun cacheSize() = 16 + } +} diff --git a/java/src/com/android/intentresolver/contentpreview/ImagePreviewImageLoader.kt b/java/src/com/android/intentresolver/contentpreview/ImagePreviewImageLoader.kt index 572ccf0b..fab7203e 100644 --- a/java/src/com/android/intentresolver/contentpreview/ImagePreviewImageLoader.kt +++ b/java/src/com/android/intentresolver/contentpreview/ImagePreviewImageLoader.kt @@ -24,17 +24,31 @@ import android.util.Size import androidx.annotation.GuardedBy import androidx.annotation.VisibleForTesting import androidx.collection.LruCache +import com.android.intentresolver.inject.Background import java.util.function.Consumer +import javax.inject.Inject +import javax.inject.Qualifier import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.CoroutineExceptionHandler +import kotlinx.coroutines.CoroutineName import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Deferred +import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.isActive import kotlinx.coroutines.launch import kotlinx.coroutines.sync.Semaphore private const val TAG = "ImagePreviewImageLoader" +@Qualifier @MustBeDocumented @Retention(AnnotationRetention.BINARY) annotation class ThumbnailSize + +@Qualifier +@MustBeDocumented +@Retention(AnnotationRetention.BINARY) +annotation class PreviewCacheSize + /** * Implements preview image loading for the content preview UI. Provides requests deduplication, * image caching, and a limit on the number of parallel loadings. @@ -52,6 +66,26 @@ constructor( private val contentResolverSemaphore: Semaphore, ) : ImageLoader { + @Inject + constructor( + @Background dispatcher: CoroutineDispatcher, + @ThumbnailSize thumbnailSize: Int, + contentResolver: ContentResolver, + @PreviewCacheSize cacheSize: Int, + ) : this( + CoroutineScope( + SupervisorJob() + + dispatcher + + CoroutineExceptionHandler { _, exception -> + Log.w(TAG, "Uncaught exception in ImageLoader", exception) + } + + CoroutineName("ImageLoader") + ), + thumbnailSize, + contentResolver, + cacheSize, + ) + constructor( scope: CoroutineScope, thumbnailSize: Int, diff --git a/java/src/com/android/intentresolver/contentpreview/IsHttpUri.kt b/java/src/com/android/intentresolver/contentpreview/IsHttpUri.kt index 80232537..ac002ab6 100644 --- a/java/src/com/android/intentresolver/contentpreview/IsHttpUri.kt +++ b/java/src/com/android/intentresolver/contentpreview/IsHttpUri.kt @@ -15,13 +15,16 @@ */ @file:JvmName("HttpUriMatcher") + package com.android.intentresolver.contentpreview import java.net.URI internal fun String.isHttpUri() = - kotlin.runCatching { - URI(this).scheme.takeIf { scheme -> - "http".compareTo(scheme, true) == 0 || "https".compareTo(scheme, true) == 0 + kotlin + .runCatching { + URI(this).scheme.takeIf { scheme -> + "http".compareTo(scheme, true) == 0 || "https".compareTo(scheme, true) == 0 + } } - }.getOrNull() != null + .getOrNull() != null diff --git a/java/src/com/android/intentresolver/contentpreview/PayloadToggleInteractor.kt b/java/src/com/android/intentresolver/contentpreview/PayloadToggleInteractor.kt deleted file mode 100644 index eda5c4ca..00000000 --- a/java/src/com/android/intentresolver/contentpreview/PayloadToggleInteractor.kt +++ /dev/null @@ -1,382 +0,0 @@ -/* - * Copyright 2024 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.android.intentresolver.contentpreview - -import android.content.Intent -import android.content.IntentSender -import android.net.Uri -import android.service.chooser.ChooserAction -import android.service.chooser.ChooserTarget -import android.util.Log -import android.util.SparseArray -import java.io.Closeable -import java.util.LinkedList -import java.util.concurrent.atomic.AtomicBoolean -import java.util.concurrent.atomic.AtomicReference -import kotlinx.coroutines.CompletableDeferred -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.Job -import kotlinx.coroutines.awaitCancellation -import kotlinx.coroutines.channels.BufferOverflow.DROP_LATEST -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.MutableSharedFlow -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.filter -import kotlinx.coroutines.flow.flow -import kotlinx.coroutines.flow.map -import kotlinx.coroutines.launch - -private const val TAG = "PayloadToggleInteractor" - -@OptIn(ExperimentalCoroutinesApi::class) -class PayloadToggleInteractor( - // TODO: a single-thread dispatcher is currently expected. iterate on the synchronization logic. - private val scope: CoroutineScope, - private val initiallySharedUris: List<Uri>, - private val focusedUriIdx: Int, - private val mimeTypeClassifier: MimeTypeClassifier, - private val cursorReaderProvider: suspend () -> CursorReader, - private val uriMetadataReader: (Uri) -> FileInfo, - private val targetIntentModifier: (List<Item>) -> Intent, - private val selectionCallback: (Intent) -> ShareouselUpdate?, -) { - private var cursorDataRef = CompletableDeferred<CursorData?>() - private val records = LinkedList<Record>() - private val prevPageLoadingGate = AtomicBoolean(true) - private val nextPageLoadingGate = AtomicBoolean(true) - private val notifySelectionJobRef = AtomicReference<Job?>() - private val emptyState = - State( - emptyList(), - hasMoreItemsBefore = false, - hasMoreItemsAfter = false, - allowSelectionChange = false - ) - - private val stateFlowSource = MutableStateFlow(emptyState) - - val customActions = - MutableSharedFlow<List<ChooserAction>>(replay = 1, onBufferOverflow = DROP_LATEST) - - val stateFlow: Flow<State> - get() = stateFlowSource.filter { it !== emptyState } - - val targetPosition: Flow<Int> = stateFlow.map { it.targetPos } - val previewKeys: Flow<List<Item>> = stateFlow.map { it.items } - - fun getKey(item: Any): Int = (item as Item).key - - fun selected(key: Item): Flow<Boolean> = (key as Record).isSelected - - fun previewUri(key: Item): Flow<Uri?> = flow { emit(key.previewUri) } - - fun previewInteractor(key: Any): PayloadTogglePreviewInteractor { - val state = stateFlowSource.value - if (state === emptyState) { - Log.wtf(TAG, "Requesting item preview before any item has been published") - } else { - if (state.hasMoreItemsBefore && key === state.items.firstOrNull()) { - loadMorePreviousItems() - } - if (state.hasMoreItemsAfter && key == state.items.lastOrNull()) { - loadMoreNextItems() - } - } - return PayloadTogglePreviewInteractor(key as Item, this) - } - - init { - scope - .launch { awaitCancellation() } - .invokeOnCompletion { - cursorDataRef.cancel() - runCatching { - if (cursorDataRef.isCompleted && !cursorDataRef.isCancelled) { - cursorDataRef.getCompleted() - } else { - null - } - } - .getOrNull() - ?.reader - ?.close() - } - } - - fun start() { - scope.launch { - val cursorReader = cursorReaderProvider() - val selectedItems = - initiallySharedUris.map { uri -> - val fileInfo = uriMetadataReader(uri) - Record( - 0, // artificial key for the pending record, it should not be used anywhere - uri, - fileInfo.previewUri, - fileInfo.mimeType, - ) - } - val cursorData = - CursorData( - cursorReader, - SelectionTracker(selectedItems, focusedUriIdx, cursorReader.count) { uri }, - ) - if (cursorDataRef.complete(cursorData)) { - doLoadMorePreviousItems() - val startPos = records.size - doLoadMoreNextItems() - prevPageLoadingGate.set(false) - nextPageLoadingGate.set(false) - publishSnapshot(startPos) - } else { - cursorReader.close() - } - } - } - - fun loadMorePreviousItems() { - invokeAsyncIfNotRunning(prevPageLoadingGate) { - doLoadMorePreviousItems() - publishSnapshot() - } - } - - fun loadMoreNextItems() { - invokeAsyncIfNotRunning(nextPageLoadingGate) { - doLoadMoreNextItems() - publishSnapshot() - } - } - - fun setSelected(item: Item, isSelected: Boolean) { - val record = item as Record - scope.launch { - val (_, selectionTracker) = waitForCursorData() ?: return@launch - if (selectionTracker.setItemSelection(record.key, record, isSelected)) { - val targetIntent = targetIntentModifier(selectionTracker.getSelection()) - val newJob = scope.launch { notifySelectionChanged(targetIntent) } - notifySelectionJobRef.getAndSet(newJob)?.cancel() - record.isSelected.value = selectionTracker.isItemSelected(record.key) - } - } - } - - private fun invokeAsyncIfNotRunning(guardingFlag: AtomicBoolean, block: suspend () -> Unit) { - if (guardingFlag.compareAndSet(false, true)) { - scope.launch { block() }.invokeOnCompletion { guardingFlag.set(false) } - } - } - - private suspend fun doLoadMorePreviousItems() { - val (reader, selectionTracker) = waitForCursorData() ?: return - if (!reader.hasMoreBefore) return - - val newItems = reader.readPageBefore().toItems() - selectionTracker.onStartItemsAdded(newItems) - for (i in newItems.size() - 1 downTo 0) { - records.add( - 0, - (newItems.valueAt(i) as Record).apply { - isSelected.value = selectionTracker.isItemSelected(key) - } - ) - } - if (!reader.hasMoreBefore && !reader.hasMoreAfter) { - val pendingItems = selectionTracker.getPendingItems() - val newRecords = - pendingItems.foldIndexed(SparseArray<Item>()) { idx, acc, item -> - assert(item is Record) { "Unexpected pending item type: ${item.javaClass}" } - val rec = item as Record - val key = idx - pendingItems.size - acc.append( - key, - Record( - key, - rec.uri, - rec.previewUri, - rec.mimeType, - rec.mimeType?.mimeTypeToItemType() ?: ItemType.File - ) - ) - acc - } - - selectionTracker.onStartItemsAdded(newRecords) - for (i in (newRecords.size() - 1) downTo 0) { - records.add(0, (newRecords.valueAt(i) as Record).apply { isSelected.value = true }) - } - } - } - - private suspend fun doLoadMoreNextItems() { - val (reader, selectionTracker) = waitForCursorData() ?: return - if (!reader.hasMoreAfter) return - - val newItems = reader.readPageAfter().toItems() - selectionTracker.onEndItemsAdded(newItems) - for (i in 0 until newItems.size()) { - val key = newItems.keyAt(i) - records.add( - (newItems.valueAt(i) as Record).apply { - isSelected.value = selectionTracker.isItemSelected(key) - } - ) - } - if (!reader.hasMoreBefore && !reader.hasMoreAfter) { - val items = - selectionTracker.getPendingItems().let { items -> - items.foldIndexed(SparseArray<Item>(items.size)) { i, acc, item -> - val key = reader.count + i - val record = item as Record - acc.append( - key, - Record(key, record.uri, record.previewUri, record.mimeType, record.type) - ) - acc - } - } - selectionTracker.onEndItemsAdded(items) - for (i in 0 until items.size()) { - records.add((items.valueAt(i) as Record).apply { isSelected.value = true }) - } - } - } - - private fun SparseArray<Uri>.toItems(): SparseArray<Item> { - val items = SparseArray<Item>(size()) - for (i in 0 until size()) { - val key = keyAt(i) - val uri = valueAt(i) - val fileInfo = uriMetadataReader(uri) - items.append( - key, - Record( - key, - uri, - fileInfo.previewUri, - fileInfo.mimeType, - fileInfo.mimeType?.mimeTypeToItemType() ?: ItemType.File - ) - ) - } - return items - } - - private suspend fun waitForCursorData() = cursorDataRef.await() - - private fun notifySelectionChanged(targetIntent: Intent) { - selectionCallback(targetIntent)?.customActions?.let { customActions.tryEmit(it) } - } - - private suspend fun publishSnapshot(startPos: Int = -1) { - val (reader, _) = waitForCursorData() ?: return - // TODO: publish a view into the list as it can only grow on each side thus a view won't be - // invalidated - val items = ArrayList<Item>(records) - stateFlowSource.emit( - State( - items, - reader.hasMoreBefore, - reader.hasMoreAfter, - allowSelectionChange = true, - targetPos = startPos, - ) - ) - } - - private fun String.mimeTypeToItemType(): ItemType = - when { - mimeTypeClassifier.isImageType(this) -> ItemType.Image - mimeTypeClassifier.isVideoType(this) -> ItemType.Video - else -> ItemType.File - } - - class State( - val items: List<Item>, - val hasMoreItemsBefore: Boolean, - val hasMoreItemsAfter: Boolean, - val allowSelectionChange: Boolean, - val targetPos: Int = -1, - ) - - sealed interface Item { - val key: Int - val uri: Uri - val previewUri: Uri? - val mimeType: String? - val type: ItemType - } - - enum class ItemType { - Image, - Video, - File, - } - - private class Record( - override val key: Int, - override val uri: Uri, - override val previewUri: Uri? = uri, - override val mimeType: String?, - override val type: ItemType = ItemType.Image, - ) : Item { - val isSelected = MutableStateFlow(false) - } - - data class ShareouselUpdate( - // for all properties, null value means no change - val customActions: List<ChooserAction>? = null, - val modifyShareAction: ChooserAction? = null, - val alternateIntents: List<Intent>? = null, - val callerTargets: List<ChooserTarget>? = null, - val refinementIntentSender: IntentSender? = null, - ) - - private data class CursorData( - val reader: CursorReader, - val selectionTracker: SelectionTracker<Item>, - ) - - interface CursorReader : Closeable { - val count: Int - val hasMoreBefore: Boolean - val hasMoreAfter: Boolean - - fun readPageAfter(): SparseArray<Uri> - - fun readPageBefore(): SparseArray<Uri> - } -} - -class PayloadTogglePreviewInteractor( - private val item: PayloadToggleInteractor.Item, - private val interactor: PayloadToggleInteractor, -) { - fun setSelected(selected: Boolean) { - interactor.setSelected(item, selected) - } - - val previewUri: Flow<Uri?> - get() = interactor.previewUri(item) - - val selected: Flow<Boolean> - get() = interactor.selected(item) - - val key - get() = item.key -} diff --git a/java/src/com/android/intentresolver/contentpreview/PreviewViewModel.kt b/java/src/com/android/intentresolver/contentpreview/PreviewViewModel.kt index d694c6ff..6a729945 100644 --- a/java/src/com/android/intentresolver/contentpreview/PreviewViewModel.kt +++ b/java/src/com/android/intentresolver/contentpreview/PreviewViewModel.kt @@ -28,10 +28,8 @@ import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewmodel.CreationExtras import com.android.intentresolver.R import com.android.intentresolver.inject.Background -import java.util.concurrent.Executors import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.asCoroutineDispatcher import kotlinx.coroutines.plus /** A view model for the preview logic */ @@ -42,9 +40,7 @@ class PreviewViewModel( @Background private val dispatcher: CoroutineDispatcher = Dispatchers.IO, ) : BasePreviewViewModel() { private var targetIntent: Intent? = null - private var chooserIntent: Intent? = null private var additionalContentUri: Uri? = null - private var focusedItemIdx: Int = 0 private var isPayloadTogglingEnabled = false override val previewDataProvider by lazy { @@ -67,59 +63,19 @@ class PreviewViewModel( ) } - override val payloadToggleInteractor: PayloadToggleInteractor? by lazy { - val targetIntent = requireNotNull(targetIntent) { "Not initialized" } - // TODO: replace with flags injection - if (!isPayloadTogglingEnabled) return@lazy null - createPayloadToggleInteractor( - additionalContentUri ?: return@lazy null, - targetIntent, - chooserIntent ?: return@lazy null, - ) - .apply { start() } - } - // TODO: make the view model injectable and inject these dependencies instead @MainThread override fun init( targetIntent: Intent, - chooserIntent: Intent, additionalContentUri: Uri?, - focusedItemIdx: Int, isPayloadTogglingEnabled: Boolean, ) { if (this.targetIntent != null) return this.targetIntent = targetIntent - this.chooserIntent = chooserIntent this.additionalContentUri = additionalContentUri - this.focusedItemIdx = focusedItemIdx this.isPayloadTogglingEnabled = isPayloadTogglingEnabled } - private fun createPayloadToggleInteractor( - contentProviderUri: Uri, - targetIntent: Intent, - chooserIntent: Intent, - ): PayloadToggleInteractor { - return PayloadToggleInteractor( - // TODO: update PayloadToggleInteractor to support multiple threads - viewModelScope + Executors.newSingleThreadScheduledExecutor().asCoroutineDispatcher(), - previewDataProvider.uris, - maxOf(0, minOf(focusedItemIdx, previewDataProvider.uriCount - 1)), - DefaultMimeTypeClassifier, - { - CursorUriReader.createCursorReader( - contentResolver, - contentProviderUri, - chooserIntent - ) - }, - UriMetadataReader(contentResolver, DefaultMimeTypeClassifier), - TargetIntentModifier(targetIntent, getUri = { uri }, getMimeType = { mimeType }), - SelectionChangeCallback(contentProviderUri, chooserIntent, contentResolver) - ) - } - companion object { val Factory: ViewModelProvider.Factory = object : ViewModelProvider.Factory { diff --git a/java/src/com/android/intentresolver/contentpreview/SelectionChangeCallback.kt b/java/src/com/android/intentresolver/contentpreview/SelectionChangeCallback.kt deleted file mode 100644 index 6b33e1cd..00000000 --- a/java/src/com/android/intentresolver/contentpreview/SelectionChangeCallback.kt +++ /dev/null @@ -1,97 +0,0 @@ -/* - * Copyright 2024 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.android.intentresolver.contentpreview - -import android.content.ContentInterface -import android.content.Intent -import android.content.Intent.EXTRA_CHOOSER_MODIFY_SHARE_ACTION -import android.content.Intent.EXTRA_CHOOSER_REFINEMENT_INTENT_SENDER -import android.content.Intent.EXTRA_CHOOSER_TARGETS -import android.content.Intent.EXTRA_INTENT -import android.content.IntentSender -import android.net.Uri -import android.os.Bundle -import android.service.chooser.AdditionalContentContract.MethodNames.ON_SELECTION_CHANGED -import android.service.chooser.ChooserAction -import android.service.chooser.ChooserTarget -import com.android.intentresolver.contentpreview.PayloadToggleInteractor.ShareouselUpdate -import com.android.intentresolver.v2.ui.viewmodel.readAlternateIntents -import com.android.intentresolver.v2.ui.viewmodel.readChooserActions -import com.android.intentresolver.v2.validation.Invalid -import com.android.intentresolver.v2.validation.Valid -import com.android.intentresolver.v2.validation.ValidationResult -import com.android.intentresolver.v2.validation.log -import com.android.intentresolver.v2.validation.types.array -import com.android.intentresolver.v2.validation.types.value -import com.android.intentresolver.v2.validation.validateFrom - -private const val TAG = "SelectionChangeCallback" - -/** - * Encapsulates payload change callback invocation to the sharing app; handles callback arguments - * and result format mapping. - */ -class SelectionChangeCallback( - private val uri: Uri, - private val chooserIntent: Intent, - private val contentResolver: ContentInterface, -) : (Intent) -> ShareouselUpdate? { - fun onSelectionChanged(targetIntent: Intent): ShareouselUpdate? = - contentResolver - .call( - requireNotNull(uri.authority) { "URI authority can not be null" }, - ON_SELECTION_CHANGED, - uri.toString(), - Bundle().apply { - putParcelable( - EXTRA_INTENT, - Intent(chooserIntent).apply { putExtra(EXTRA_INTENT, targetIntent) } - ) - } - ) - ?.let { bundle -> - return when (val result = readCallbackResponse(bundle)) { - is Valid -> result.value - is Invalid -> { - result.errors.forEach { it.log(TAG) } - null - } - } - } - - override fun invoke(targetIntent: Intent) = onSelectionChanged(targetIntent) - - private fun readCallbackResponse(bundle: Bundle): ValidationResult<ShareouselUpdate> { - return validateFrom(bundle::get) { - val customActions = readChooserActions() - val modifyShareAction = - optional(value<ChooserAction>(EXTRA_CHOOSER_MODIFY_SHARE_ACTION)) - val alternateIntents = readAlternateIntents() - val callerTargets = optional(array<ChooserTarget>(EXTRA_CHOOSER_TARGETS)) - val refinementIntentSender = - optional(value<IntentSender>(EXTRA_CHOOSER_REFINEMENT_INTENT_SENDER)) - - ShareouselUpdate( - customActions, - modifyShareAction, - alternateIntents, - callerTargets, - refinementIntentSender, - ) - } - } -} diff --git a/java/src/com/android/intentresolver/contentpreview/SelectionTracker.kt b/java/src/com/android/intentresolver/contentpreview/SelectionTracker.kt deleted file mode 100644 index c9431731..00000000 --- a/java/src/com/android/intentresolver/contentpreview/SelectionTracker.kt +++ /dev/null @@ -1,175 +0,0 @@ -/* - * Copyright 2024 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.android.intentresolver.contentpreview - -import android.net.Uri -import android.util.SparseArray -import android.util.SparseIntArray -import androidx.core.util.containsKey -import androidx.core.util.isNotEmpty - -/** - * Tracks selected items (including those that has not been read frm the cursor) and their relative - * order. - */ -class SelectionTracker<Item>( - selectedItems: List<Item>, - private val focusedItemIdx: Int, - private val cursorCount: Int, - private val getUri: Item.() -> Uri, -) { - /** Contains selected items keys. */ - private val selections = SparseArray<Item>(selectedItems.size) - - /** - * A set of initially selected items that has not yet been observed by the lazy read of the - * cursor and thus has unknown key (cursor position). Initially, all [selectedItems] are put in - * this map with items at the index less than [focusedItemIdx] with negative keys (to the left - * of all cursor items) and items at the index more or equal to [focusedItemIdx] with keys more - * or equal to [cursorCount] (to the right of all cursor items) in their relative order. Upon - * reading the cursor, [onEndItemsAdded]/[onStartItemsAdded], all pending items from that - * collection in the corresponding direction get their key assigned and gets removed from the - * map. Items that were missing from the cursor get removed from the map by - * [getPendingItems] + [onStartItemsAdded]/[onEndItemsAdded] combination. - */ - private val pendingKeys = HashMap<Uri, SparseIntArray>() - - init { - selectedItems.forEachIndexed { i, item -> - // all items before focusedItemIdx gets "positioned" before all the cursor items - // and all the reset after all the cursor items in their relative order. - // Also see the comments to pendingKeys property. - val key = - if (i < focusedItemIdx) { - i - focusedItemIdx - } else { - i + cursorCount - focusedItemIdx - } - selections.append(key, item) - pendingKeys.getOrPut(item.getUri()) { SparseIntArray(1) }.append(key, key) - } - } - - /** Update selections based on the set of items read from the end of the cursor */ - fun onEndItemsAdded(items: SparseArray<Item>) { - for (i in 0 until items.size()) { - val item = items.valueAt(i) - pendingKeys[item.getUri()] - // if only one pending (unmatched) item with this URI is left, removed this URI - ?.also { - if (it.size() <= 1) { - pendingKeys.remove(item.getUri()) - } - } - // a safeguard, we should not observe empty arrays at this point - ?.takeIf { it.isNotEmpty() } - // pick a matching pending items from the right side - ?.let { pendingUriPositions -> - val key = items.keyAt(i) - val insertPos = - pendingUriPositions - .findBestKeyPosition(key) - .coerceIn(0, pendingUriPositions.size() - 1) - // select next pending item from the right, if not such item exists then - // the data is inconsistent and we pick the closes one from the left - val keyPlaceholder = pendingUriPositions.keyAt(insertPos) - pendingUriPositions.removeAt(insertPos) - selections.remove(keyPlaceholder) - selections[key] = item - } - } - } - - /** Update selections based on the set of items read from the head of the cursor */ - fun onStartItemsAdded(items: SparseArray<Item>) { - for (i in (items.size() - 1) downTo 0) { - val item = items.valueAt(i) - pendingKeys[item.getUri()] - // if only one pending (unmatched) item with this URI is left, removed this URI - ?.also { - if (it.size() <= 1) { - pendingKeys.remove(item.getUri()) - } - } - // a safeguard, we should not observe empty arrays at this point - ?.takeIf { it.isNotEmpty() } - // pick a matching pending items from the left side - ?.let { pendingUriPositions -> - val key = items.keyAt(i) - val insertPos = - pendingUriPositions - .findBestKeyPosition(key) - .coerceIn(1, pendingUriPositions.size()) - // select next pending item from the left, if not such item exists then - // the data is inconsistent and we pick the closes one from the right - val keyPlaceholder = pendingUriPositions.keyAt(insertPos - 1) - pendingUriPositions.removeAt(insertPos - 1) - selections.remove(keyPlaceholder) - selections[key] = item - } - } - } - - /** Updated selection status for the given item */ - fun setItemSelection(key: Int, item: Item, isSelected: Boolean): Boolean { - val idx = selections.indexOfKey(key) - if (isSelected && idx < 0) { - selections[key] = item - return true - } - if (!isSelected && idx >= 0 && selections.size() > 1) { - selections.removeAt(idx) - return true - } - return false - } - - /** Return selection status for the given item */ - fun isItemSelected(key: Int): Boolean = selections.containsKey(key) - - fun getSelection(): List<Item> = - buildList(selections.size()) { - for (i in 0 until selections.size()) { - add(selections.valueAt(i)) - } - } - - /** Return all selected items that has not yet been read from the cursor */ - fun getPendingItems(): List<Item> = - if (pendingKeys.isEmpty()) { - emptyList() - } else { - buildList { - for (i in 0 until selections.size()) { - val item = selections.valueAt(i) ?: continue - if (isPending(item, selections.keyAt(i))) { - add(item) - } - } - } - } - - private fun isPending(item: Item, key: Int): Boolean { - val keys = pendingKeys[item.getUri()] ?: return false - return keys.containsKey(key) - } - - private fun SparseIntArray.findBestKeyPosition(key: Int): Int = - // undocumented, but indexOfKey behaves in the same was as - // java.util.Collections#binarySearch() - indexOfKey(key).let { if (it < 0) it.inv() else it } -} diff --git a/java/src/com/android/intentresolver/contentpreview/ShareouselContentPreviewUi.kt b/java/src/com/android/intentresolver/contentpreview/ShareouselContentPreviewUi.kt index 82c09986..3530ede1 100644 --- a/java/src/com/android/intentresolver/contentpreview/ShareouselContentPreviewUi.kt +++ b/java/src/com/android/intentresolver/contentpreview/ShareouselContentPreviewUi.kt @@ -22,32 +22,20 @@ import android.view.ViewGroup import android.widget.TextView import androidx.annotation.VisibleForTesting import androidx.compose.foundation.isSystemInDarkTheme -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.height import androidx.compose.material3.MaterialTheme import androidx.compose.material3.dynamicDarkColorScheme import androidx.compose.material3.dynamicLightColorScheme import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue -import androidx.compose.ui.Modifier import androidx.compose.ui.platform.ComposeView import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.res.dimensionResource -import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewmodel.compose.viewModel import com.android.intentresolver.R -import com.android.intentresolver.contentpreview.ChooserContentPreviewUi.ActionFactory -import com.android.intentresolver.contentpreview.shareousel.ui.composable.Shareousel -import com.android.intentresolver.contentpreview.shareousel.ui.viewmodel.ShareouselViewModel -import com.android.intentresolver.contentpreview.shareousel.ui.viewmodel.toShareouselViewModel +import com.android.intentresolver.contentpreview.payloadtoggle.ui.composable.Shareousel +import com.android.intentresolver.contentpreview.payloadtoggle.ui.viewmodel.ShareouselViewModel +import com.android.intentresolver.v2.ui.viewmodel.ChooserViewModel @VisibleForTesting(otherwise = VisibleForTesting.PACKAGE_PRIVATE) -class ShareouselContentPreviewUi( - private val actionFactory: ActionFactory, -) : ContentPreviewUi() { +class ShareouselContentPreviewUi : ContentPreviewUi() { override fun getType(): Int = ContentPreviewType.CONTENT_PREVIEW_IMAGE @@ -56,76 +44,45 @@ class ShareouselContentPreviewUi( layoutInflater: LayoutInflater, parent: ViewGroup, headlineViewParent: View?, - ): ViewGroup { - return displayInternal(parent, headlineViewParent).also { layout -> - displayModifyShareAction(headlineViewParent ?: layout, actionFactory) - } - } + ): ViewGroup = displayInternal(parent, headlineViewParent) - private fun displayInternal( - parent: ViewGroup, - headlineViewParent: View?, - ): ViewGroup { + private fun displayInternal(parent: ViewGroup, headlineViewParent: View?): ViewGroup { if (headlineViewParent != null) { inflateHeadline(headlineViewParent) } - val composeView = - ComposeView(parent.context).apply { - setContent { - val vm: BasePreviewViewModel = viewModel() - val interactor = - requireNotNull(vm.payloadToggleInteractor) { "Should not be null" } + return ComposeView(parent.context).apply { + setContent { + val vm: ChooserViewModel = viewModel() + val viewModel: ShareouselViewModel = vm.shareouselViewModel - var viewModel by remember { mutableStateOf<ShareouselViewModel?>(null) } - LaunchedEffect(Unit) { - viewModel = - interactor.toShareouselViewModel( - vm.imageLoader, - actionFactory, - vm.viewModelScope - ) - } + headlineViewParent?.let { + LaunchedEffect(viewModel) { bindHeadline(viewModel, headlineViewParent) } + } - headlineViewParent?.let { - viewModel?.let { viewModel -> - LaunchedEffect(viewModel) { - viewModel.headline.collect { headline -> - headlineViewParent - .findViewById<TextView>(R.id.headline) - ?.apply { - if (headline.isNotBlank()) { - text = headline - visibility = View.VISIBLE - } else { - visibility = View.GONE - } - } - } - } - } - } + MaterialTheme( + colorScheme = + if (isSystemInDarkTheme()) { + dynamicDarkColorScheme(LocalContext.current) + } else { + dynamicLightColorScheme(LocalContext.current) + }, + ) { + Shareousel(viewModel) + } + } + } + } - viewModel?.let { viewModel -> - MaterialTheme( - colorScheme = - if (isSystemInDarkTheme()) { - dynamicDarkColorScheme(LocalContext.current) - } else { - dynamicLightColorScheme(LocalContext.current) - }, - ) { - Shareousel(viewModel = viewModel) - } - } - ?: run { - Spacer( - Modifier.height( - dimensionResource(R.dimen.chooser_preview_image_height_tall) - ) - ) - } + private suspend fun bindHeadline(viewModel: ShareouselViewModel, headlineViewParent: View) { + viewModel.headline.collect { headline -> + headlineViewParent.findViewById<TextView>(R.id.headline)?.apply { + if (headline.isNotBlank()) { + text = headline + visibility = View.VISIBLE + } else { + visibility = View.GONE } } - return composeView + } } } diff --git a/java/src/com/android/intentresolver/contentpreview/TextContentPreviewUi.java b/java/src/com/android/intentresolver/contentpreview/TextContentPreviewUi.java index fbdc5853..a7ae81b0 100644 --- a/java/src/com/android/intentresolver/contentpreview/TextContentPreviewUi.java +++ b/java/src/com/android/intentresolver/contentpreview/TextContentPreviewUi.java @@ -83,10 +83,7 @@ class TextContentPreviewUi extends ContentPreviewUi { LayoutInflater layoutInflater, ViewGroup parent, @Nullable View headlineViewParent) { - ViewGroup layout = displayInternal(layoutInflater, parent, headlineViewParent); - displayModifyShareAction( - headlineViewParent == null ? layout : headlineViewParent, mActionFactory); - return layout; + return displayInternal(layoutInflater, parent, headlineViewParent); } private ViewGroup displayInternal( diff --git a/java/src/com/android/intentresolver/contentpreview/UnifiedContentPreviewUi.java b/java/src/com/android/intentresolver/contentpreview/UnifiedContentPreviewUi.java index 0974c79b..b248e429 100644 --- a/java/src/com/android/intentresolver/contentpreview/UnifiedContentPreviewUi.java +++ b/java/src/com/android/intentresolver/contentpreview/UnifiedContentPreviewUi.java @@ -94,10 +94,7 @@ class UnifiedContentPreviewUi extends ContentPreviewUi { LayoutInflater layoutInflater, ViewGroup parent, @Nullable View headlineViewParent) { - ViewGroup layout = displayInternal(layoutInflater, parent, headlineViewParent); - displayModifyShareAction( - headlineViewParent == null ? layout : headlineViewParent, mActionFactory); - return layout; + return displayInternal(layoutInflater, parent, headlineViewParent); } private void setFiles(List<FileInfo> files) { diff --git a/java/src/com/android/intentresolver/contentpreview/UriMetadataReader.kt b/java/src/com/android/intentresolver/contentpreview/UriMetadataReader.kt index 45515e25..b5361889 100644 --- a/java/src/com/android/intentresolver/contentpreview/UriMetadataReader.kt +++ b/java/src/com/android/intentresolver/contentpreview/UriMetadataReader.kt @@ -20,12 +20,24 @@ import android.content.ContentInterface import android.media.MediaMetadata import android.net.Uri import android.provider.DocumentsContract +import dagger.Binds +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import javax.inject.Inject -class UriMetadataReader( +fun interface UriMetadataReader { + fun getMetadata(uri: Uri): FileInfo +} + +class UriMetadataReaderImpl +@Inject +constructor( private val contentResolver: ContentInterface, private val typeClassifier: MimeTypeClassifier, -) : (Uri) -> FileInfo { - fun getMetadata(uri: Uri): FileInfo { +) : UriMetadataReader { + override fun getMetadata(uri: Uri): FileInfo { val builder = FileInfo.Builder(uri) val mimeType = contentResolver.getTypeSafe(uri) builder.withMimeType(mimeType) @@ -44,8 +56,6 @@ class UriMetadataReader( return builder.build() } - override fun invoke(uri: Uri): FileInfo = getMetadata(uri) - private fun ContentInterface.supportsImageType(uri: Uri): Boolean = getStreamTypesSafe(uri).firstOrNull { typeClassifier.isImageType(it) } != null @@ -64,3 +74,14 @@ class UriMetadataReader( } } } + +@Module +@InstallIn(SingletonComponent::class) +interface UriMetadataReaderModule { + + @Binds fun bind(impl: UriMetadataReaderImpl): UriMetadataReader + + companion object { + @Provides fun classifier(): MimeTypeClassifier = DefaultMimeTypeClassifier + } +} diff --git a/java/src/com/android/intentresolver/contentpreview/payloadtoggle/data/model/CustomActionModel.kt b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/data/model/CustomActionModel.kt new file mode 100644 index 00000000..b7945005 --- /dev/null +++ b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/data/model/CustomActionModel.kt @@ -0,0 +1,29 @@ +/* + * Copyright (C) 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.intentresolver.contentpreview.payloadtoggle.data.model + +import android.graphics.drawable.Icon + +/** Data model for a custom action the user can take. */ +data class CustomActionModel( + /** Label presented to the user identifying this action. */ + val label: CharSequence, + /** Icon presented to the user for this action. */ + val icon: Icon, + /** When invoked, performs this action. */ + val performAction: () -> Unit, +) diff --git a/java/src/com/android/intentresolver/contentpreview/payloadtoggle/data/repository/ActivityResultRepository.kt b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/data/repository/ActivityResultRepository.kt new file mode 100644 index 00000000..c3bb88c8 --- /dev/null +++ b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/data/repository/ActivityResultRepository.kt @@ -0,0 +1,28 @@ +/* + * Copyright (C) 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.intentresolver.contentpreview.payloadtoggle.data.repository + +import dagger.hilt.android.scopes.ActivityRetainedScoped +import javax.inject.Inject +import kotlinx.coroutines.flow.MutableStateFlow + +/** Tracks the result of the current activity. */ +@ActivityRetainedScoped +class ActivityResultRepository @Inject constructor() { + /** The result of the current activity, or `null` if the activity is still active. */ + val activityResult = MutableStateFlow<Int?>(null) +} diff --git a/java/src/com/android/intentresolver/contentpreview/payloadtoggle/data/repository/CursorPreviewsRepository.kt b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/data/repository/CursorPreviewsRepository.kt new file mode 100644 index 00000000..b104d4bf --- /dev/null +++ b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/data/repository/CursorPreviewsRepository.kt @@ -0,0 +1,32 @@ +/* + * Copyright (C) 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.intentresolver.contentpreview.payloadtoggle.data.repository + +import com.android.intentresolver.contentpreview.payloadtoggle.shared.model.PreviewsModel +import dagger.hilt.android.scopes.ActivityRetainedScoped +import javax.inject.Inject +import kotlinx.coroutines.flow.MutableStateFlow + +/** + * Stores previews for Shareousel UI that have been cached locally from a remote + * [android.database.Cursor]. + */ +@ActivityRetainedScoped +class CursorPreviewsRepository @Inject constructor() { + /** Previews available for display within Shareousel. */ + val previewsModel = MutableStateFlow<PreviewsModel?>(null) +} diff --git a/java/src/com/android/intentresolver/contentpreview/payloadtoggle/data/repository/PendingSelectionCallbackRepository.kt b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/data/repository/PendingSelectionCallbackRepository.kt new file mode 100644 index 00000000..1745cd9c --- /dev/null +++ b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/data/repository/PendingSelectionCallbackRepository.kt @@ -0,0 +1,32 @@ +/* + * Copyright (C) 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.intentresolver.contentpreview.payloadtoggle.data.repository + +import android.content.Intent +import dagger.hilt.android.scopes.ActivityRetainedScoped +import javax.inject.Inject +import kotlinx.coroutines.flow.MutableStateFlow + +/** Tracks active async communication with sharing app to notify of target intent update. */ +@ActivityRetainedScoped +class PendingSelectionCallbackRepository @Inject constructor() { + /** + * The target [Intent] that is has an active update request with the sharing app, or `null` if + * there is no active request. + */ + val pendingTargetIntent: MutableStateFlow<Intent?> = MutableStateFlow(null) +} diff --git a/java/src/com/android/intentresolver/contentpreview/payloadtoggle/data/repository/PreviewSelectionsRepository.kt b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/data/repository/PreviewSelectionsRepository.kt new file mode 100644 index 00000000..9aecc981 --- /dev/null +++ b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/data/repository/PreviewSelectionsRepository.kt @@ -0,0 +1,28 @@ +/* + * Copyright (C) 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.intentresolver.contentpreview.payloadtoggle.data.repository + +import com.android.intentresolver.contentpreview.payloadtoggle.shared.model.PreviewModel +import dagger.hilt.android.scopes.ViewModelScoped +import javax.inject.Inject +import kotlinx.coroutines.flow.MutableStateFlow + +/** Stores set of selected previews. */ +@ViewModelScoped +class PreviewSelectionsRepository @Inject constructor() { + val selections = MutableStateFlow(emptySet<PreviewModel>()) +} diff --git a/java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/cursor/CursorResolver.kt b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/cursor/CursorResolver.kt new file mode 100644 index 00000000..3aa0d567 --- /dev/null +++ b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/cursor/CursorResolver.kt @@ -0,0 +1,24 @@ +/* + * Copyright (C) 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.intentresolver.contentpreview.payloadtoggle.domain.cursor + +import com.android.intentresolver.util.cursor.CursorView + +/** Asynchronously retrieves a [CursorView]. */ +fun interface CursorResolver<out T> { + suspend fun getCursor(): CursorView<T>? +} diff --git a/java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/cursor/PayloadToggleCursorResolver.kt b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/cursor/PayloadToggleCursorResolver.kt new file mode 100644 index 00000000..3cf2af13 --- /dev/null +++ b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/cursor/PayloadToggleCursorResolver.kt @@ -0,0 +1,69 @@ +/* + * Copyright (C) 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.intentresolver.contentpreview.payloadtoggle.domain.cursor + +import android.content.ContentResolver +import android.content.Intent +import android.net.Uri +import android.service.chooser.AdditionalContentContract.Columns.URI +import androidx.core.os.bundleOf +import com.android.intentresolver.inject.AdditionalContent +import com.android.intentresolver.inject.ChooserIntent +import com.android.intentresolver.util.cursor.CursorView +import com.android.intentresolver.util.cursor.viewBy +import com.android.intentresolver.util.withCancellationSignal +import dagger.Binds +import dagger.Module +import dagger.hilt.InstallIn +import dagger.hilt.android.components.ViewModelComponent +import javax.inject.Inject +import javax.inject.Qualifier + +/** [CursorResolver] for the [CursorView] underpinning Shareousel. */ +class PayloadToggleCursorResolver +@Inject +constructor( + private val contentResolver: ContentResolver, + @AdditionalContent private val cursorUri: Uri, + @ChooserIntent private val chooserIntent: Intent, +) : CursorResolver<Uri?> { + override suspend fun getCursor(): CursorView<Uri?>? = withCancellationSignal { signal -> + runCatching { + contentResolver.query( + cursorUri, + arrayOf(URI), + bundleOf(Intent.EXTRA_INTENT to chooserIntent), + signal, + ) + } + .getOrNull() + ?.viewBy { + getString(0)?.let(Uri::parse)?.takeIf { it.authority != cursorUri.authority } + } + } + + @Module + @InstallIn(ViewModelComponent::class) + interface Binding { + @Binds + @PayloadToggle + fun bind(cursorResolver: PayloadToggleCursorResolver): CursorResolver<Uri?> + } +} + +/** [CursorResolver] for the [CursorView] underpinning Shareousel. */ +@Qualifier @MustBeDocumented @Retention(AnnotationRetention.RUNTIME) annotation class PayloadToggle diff --git a/java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/intent/CustomActionPendingIntentSender.kt b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/intent/CustomActionPendingIntentSender.kt new file mode 100644 index 00000000..faad5bbf --- /dev/null +++ b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/intent/CustomActionPendingIntentSender.kt @@ -0,0 +1,64 @@ +/* + * Copyright (C) 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.intentresolver.contentpreview.payloadtoggle.domain.intent + +import android.app.ActivityOptions +import android.app.PendingIntent +import android.content.Context +import com.android.intentresolver.R +import dagger.Binds +import dagger.Module +import dagger.hilt.InstallIn +import dagger.hilt.android.qualifiers.ApplicationContext +import dagger.hilt.components.SingletonComponent +import javax.inject.Inject +import javax.inject.Qualifier + +/** [PendingIntentSender] for Shareousel custom actions. */ +class CustomActionPendingIntentSender +@Inject +constructor( + @ApplicationContext private val context: Context, +) : PendingIntentSender { + override fun send(pendingIntent: PendingIntent) { + pendingIntent.send( + /* context = */ null, + /* code = */ 0, + /* intent = */ null, + /* onFinished = */ null, + /* handler = */ null, + /* requiredPermission = */ null, + /* options = */ ActivityOptions.makeCustomAnimation( + context, + R.anim.slide_in_right, + R.anim.slide_out_left, + ) + .toBundle() + ) + } + + @Module + @InstallIn(SingletonComponent::class) + interface Binding { + @Binds + @CustomAction + fun bindSender(sender: CustomActionPendingIntentSender): PendingIntentSender + } +} + +/** [PendingIntentSender] for Shareousel custom actions. */ +@Qualifier @MustBeDocumented @Retention(AnnotationRetention.RUNTIME) annotation class CustomAction diff --git a/java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/intent/InitialCustomActionsModule.kt b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/intent/InitialCustomActionsModule.kt new file mode 100644 index 00000000..d75884d5 --- /dev/null +++ b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/intent/InitialCustomActionsModule.kt @@ -0,0 +1,55 @@ +/* + * Copyright (C) 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.intentresolver.contentpreview.payloadtoggle.domain.intent + +import android.app.PendingIntent +import android.service.chooser.ChooserAction +import android.util.Log +import com.android.intentresolver.contentpreview.payloadtoggle.data.model.CustomActionModel +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.android.components.ViewModelComponent + +@Module +@InstallIn(ViewModelComponent::class) +object InitialCustomActionsModule { + @Provides + fun initialCustomActionModels( + chooserActions: List<ChooserAction>, + @CustomAction pendingIntentSender: PendingIntentSender, + ): List<CustomActionModel> = chooserActions.map { it.toCustomActionModel(pendingIntentSender) } +} + +/** + * Returns a [CustomActionModel] that sends this [ChooserAction]'s + * [PendingIntent][ChooserAction.getAction]. + */ +fun ChooserAction.toCustomActionModel(pendingIntentSender: PendingIntentSender) = + CustomActionModel( + label = label, + icon = icon, + performAction = { + try { + pendingIntentSender.send(action) + } catch (_: PendingIntent.CanceledException) { + Log.d(TAG, "Custom action, $label, has been cancelled") + } + } + ) + +private const val TAG = "CustomShareActions" diff --git a/java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/intent/PendingIntentSender.kt b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/intent/PendingIntentSender.kt new file mode 100644 index 00000000..23ba31ba --- /dev/null +++ b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/intent/PendingIntentSender.kt @@ -0,0 +1,24 @@ +/* + * Copyright (C) 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.intentresolver.contentpreview.payloadtoggle.domain.intent + +import android.app.PendingIntent + +/** Sends [PendingIntent]s. */ +fun interface PendingIntentSender { + fun send(pendingIntent: PendingIntent) +} diff --git a/java/src/com/android/intentresolver/contentpreview/TargetIntentModifier.kt b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/intent/TargetIntentModifier.kt index 58da5bc4..4a2a6932 100644 --- a/java/src/com/android/intentresolver/contentpreview/TargetIntentModifier.kt +++ b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/intent/TargetIntentModifier.kt @@ -1,5 +1,5 @@ /* - * Copyright 2024 The Android Open Source Project + * Copyright (C) 2024 The Android Open Source Project * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -14,7 +14,7 @@ * limitations under the License. */ -package com.android.intentresolver.contentpreview +package com.android.intentresolver.contentpreview.payloadtoggle.domain.intent import android.content.ClipData import android.content.ClipDescription.compareMimeTypes @@ -23,29 +23,38 @@ import android.content.Intent.ACTION_SEND import android.content.Intent.ACTION_SEND_MULTIPLE import android.content.Intent.EXTRA_STREAM import android.net.Uri +import com.android.intentresolver.contentpreview.payloadtoggle.shared.model.PreviewModel +import com.android.intentresolver.inject.TargetIntent +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.android.components.ViewModelComponent /** Modifies target intent based on current payload selection. */ -class TargetIntentModifier<Item>( +fun interface TargetIntentModifier<Item> { + fun intentFromSelection(selection: Collection<Item>): Intent +} + +class TargetIntentModifierImpl<Item>( private val originalTargetIntent: Intent, private val getUri: Item.() -> Uri, private val getMimeType: Item.() -> String?, -) : (List<Item>) -> Intent { - fun onSelectionChanged(selection: List<Item>): Intent { - val uris = ArrayList<Uri>(selection.size) - var targetMimeType: String? = null - for (item in selection) { - targetMimeType = updateMimeType(item.getMimeType(), targetMimeType) - uris.add(item.getUri()) - } - val action = if (uris.size == 1) ACTION_SEND else ACTION_SEND_MULTIPLE +) : TargetIntentModifier<Item> { + override fun intentFromSelection(selection: Collection<Item>): Intent { + val uris = selection.mapTo(ArrayList()) { it.getUri() } + val targetMimeType = + selection.fold(null) { target: String?, item: Item -> + updateMimeType(item.getMimeType(), target) + } return Intent(originalTargetIntent).apply { - this.action = action - this.type = targetMimeType - if (action == ACTION_SEND) { - putExtra(EXTRA_STREAM, uris[0]) + if (selection.size == 1) { + action = ACTION_SEND + putExtra(EXTRA_STREAM, selection.first().getUri()) } else { + action = ACTION_SEND_MULTIPLE putParcelableArrayListExtra(EXTRA_STREAM, uris) } + type = targetMimeType if (uris.isNotEmpty()) { clipData = ClipData("", arrayOf(targetMimeType), ClipData.Item(uris[0])).also { @@ -70,6 +79,14 @@ class TargetIntentModifier<Item>( } return "*/*" } +} - override fun invoke(selection: List<Item>): Intent = onSelectionChanged(selection) +@Module +@InstallIn(ViewModelComponent::class) +object TargetIntentModifierModule { + @Provides + fun targetIntentModifier( + @TargetIntent targetIntent: Intent, + ): TargetIntentModifier<PreviewModel> = + TargetIntentModifierImpl(targetIntent, { uri }, { mimeType }) } diff --git a/java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/interactor/ChooserRequestInteractor.kt b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/interactor/ChooserRequestInteractor.kt new file mode 100644 index 00000000..61c04ac1 --- /dev/null +++ b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/interactor/ChooserRequestInteractor.kt @@ -0,0 +1,38 @@ +/* + * Copyright (C) 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.intentresolver.contentpreview.payloadtoggle.domain.interactor + +import android.content.Intent +import com.android.intentresolver.contentpreview.payloadtoggle.data.model.CustomActionModel +import com.android.intentresolver.v2.data.repository.ChooserRequestRepository +import javax.inject.Inject +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.flow.map + +/** Stores the target intent of the share sheet, and custom actions derived from the intent. */ +class ChooserRequestInteractor +@Inject +constructor( + private val repository: ChooserRequestRepository, +) { + val targetIntent: Flow<Intent> + get() = repository.chooserRequest.map { it.targetIntent } + + val customActions: Flow<List<CustomActionModel>> + get() = repository.customActions.asSharedFlow() +} diff --git a/java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/interactor/CursorPreviewsInteractor.kt b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/interactor/CursorPreviewsInteractor.kt new file mode 100644 index 00000000..f642f420 --- /dev/null +++ b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/interactor/CursorPreviewsInteractor.kt @@ -0,0 +1,294 @@ +/* + * Copyright (C) 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +@file:OptIn(ExperimentalCoroutinesApi::class) + +package com.android.intentresolver.contentpreview.payloadtoggle.domain.interactor + +import android.net.Uri +import android.service.chooser.AdditionalContentContract.CursorExtraKeys.POSITION +import com.android.intentresolver.contentpreview.UriMetadataReader +import com.android.intentresolver.contentpreview.payloadtoggle.domain.model.LoadDirection +import com.android.intentresolver.contentpreview.payloadtoggle.domain.model.LoadedWindow +import com.android.intentresolver.contentpreview.payloadtoggle.domain.model.expandWindowLeft +import com.android.intentresolver.contentpreview.payloadtoggle.domain.model.expandWindowRight +import com.android.intentresolver.contentpreview.payloadtoggle.domain.model.numLoadedPages +import com.android.intentresolver.contentpreview.payloadtoggle.domain.model.shiftWindowLeft +import com.android.intentresolver.contentpreview.payloadtoggle.domain.model.shiftWindowRight +import com.android.intentresolver.contentpreview.payloadtoggle.shared.model.PreviewModel +import com.android.intentresolver.inject.FocusedItemIndex +import com.android.intentresolver.util.cursor.CursorView +import com.android.intentresolver.util.cursor.PagedCursor +import com.android.intentresolver.util.cursor.get +import com.android.intentresolver.util.cursor.paged +import com.android.intentresolver.util.mapParallel +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import java.util.concurrent.ConcurrentHashMap +import javax.inject.Inject +import javax.inject.Qualifier +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.filterNotNull +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.mapLatest + +/** Queries data from a remote cursor, and caches it locally for presentation in Shareousel. */ +class CursorPreviewsInteractor +@Inject +constructor( + private val interactor: SetCursorPreviewsInteractor, + @FocusedItemIndex private val focusedItemIdx: Int, + private val uriMetadataReader: UriMetadataReader, + @PageSize private val pageSize: Int, + @MaxLoadedPages private val maxLoadedPages: Int, +) { + + init { + check(pageSize > 0) { "pageSize must be greater than zero" } + } + + /** Start reading data from [uriCursor], and listen for requests to load more. */ + suspend fun launch(uriCursor: CursorView<Uri?>, initialPreviews: Iterable<PreviewModel>) { + // Unclaimed values from the initial selection set. Entries will be removed as the cursor is + // read, and any still present are inserted at the start / end of the cursor when it is + // reached by the user. + val unclaimedRecords: MutableUnclaimedMap = + initialPreviews + .asSequence() + .mapIndexed { i, m -> Pair(m.uri, Pair(i, m)) } + .toMap(ConcurrentHashMap()) + val pagedCursor: PagedCursor<Uri?> = uriCursor.paged(pageSize) + val startPosition = uriCursor.extras?.getInt(POSITION, 0) ?: 0 + val state = readInitialState(pagedCursor, startPosition, unclaimedRecords) + processLoadRequests(state, pagedCursor, unclaimedRecords) + } + + /** Loop forever, processing any loading requests from the UI and updating local cache. */ + private suspend fun processLoadRequests( + initialState: CursorWindow, + pagedCursor: PagedCursor<Uri?>, + unclaimedRecords: MutableUnclaimedMap, + ) { + var state = initialState + while (true) { + // Design note: in order to prevent load requests from the UI when it was displaying a + // previously-published dataset being accidentally associated with a recently-published + // one, we generate a new Flow of load requests for each dataset and only listen to + // those. + val loadingState: Flow<LoadDirection?> = + interactor.setPreviews( + previewsByKey = state.merged.values.toSet(), + startIndex = 0, // TODO: actually track this as the window changes? + hasMoreLeft = state.hasMoreLeft, + hasMoreRight = state.hasMoreRight, + ) + state = loadingState.handleOneLoadRequest(state, pagedCursor, unclaimedRecords) + } + } + + /** + * Suspends until a single loading request has been handled, returning the new [CursorWindow] + * with the loaded data incorporated. + */ + private suspend fun Flow<LoadDirection?>.handleOneLoadRequest( + state: CursorWindow, + pagedCursor: PagedCursor<Uri?>, + unclaimedRecords: MutableUnclaimedMap, + ): CursorWindow = + mapLatest { loadDirection -> + loadDirection?.let { + when (loadDirection) { + LoadDirection.Left -> state.loadMoreLeft(pagedCursor, unclaimedRecords) + LoadDirection.Right -> state.loadMoreRight(pagedCursor, unclaimedRecords) + } + } + } + .filterNotNull() + .first() + + /** + * Returns the initial [CursorWindow], with a single page loaded that contains the given + * [startPosition]. + */ + private suspend fun readInitialState( + cursor: PagedCursor<Uri?>, + startPosition: Int, + unclaimedRecords: MutableUnclaimedMap, + ): CursorWindow { + val startPageIdx = startPosition / pageSize + val hasMoreLeft = startPageIdx > 0 + val hasMoreRight = startPageIdx < cursor.count - 1 + val page: PreviewMap = buildMap { + if (!hasMoreLeft) { + // First read the initial page; this might claim some unclaimed Uris + val page = + cursor.getPageUris(startPageIdx)?.toPage(mutableMapOf(), unclaimedRecords) + // Now that unclaimed Uris are up-to-date, add them first. + putAllUnclaimedLeft(unclaimedRecords) + // Then add the loaded page + page?.let(::putAll) + } else { + cursor.getPageUris(startPageIdx)?.toPage(this, unclaimedRecords) + } + // Finally, add the remainder of the unclaimed Uris. + if (!hasMoreRight) { + putAllUnclaimedRight(unclaimedRecords) + } + } + return CursorWindow( + firstLoadedPageNum = startPageIdx, + lastLoadedPageNum = startPageIdx, + pages = listOf(page.keys), + merged = page, + hasMoreLeft = hasMoreLeft, + hasMoreRight = hasMoreRight, + ) + } + + private suspend fun CursorWindow.loadMoreRight( + cursor: PagedCursor<Uri?>, + unclaimedRecords: MutableUnclaimedMap, + ): CursorWindow { + val pageNum = lastLoadedPageNum + 1 + val hasMoreRight = pageNum < cursor.count - 1 + val newPage: PreviewMap = buildMap { + readAndPutPage(this@loadMoreRight, cursor, pageNum, unclaimedRecords) + if (!hasMoreRight) { + putAllUnclaimedRight(unclaimedRecords) + } + } + return if (numLoadedPages < maxLoadedPages) { + expandWindowRight(newPage, hasMoreRight) + } else { + shiftWindowRight(newPage, hasMoreRight) + } + } + + private suspend fun CursorWindow.loadMoreLeft( + cursor: PagedCursor<Uri?>, + unclaimedRecords: MutableUnclaimedMap, + ): CursorWindow { + val pageNum = firstLoadedPageNum - 1 + val hasMoreLeft = pageNum > 0 + val newPage: PreviewMap = buildMap { + if (!hasMoreLeft) { + // First read the page; this might claim some unclaimed Uris + val page = readPage(this@loadMoreLeft, cursor, pageNum, unclaimedRecords) + // Now that unclaimed URIs are up-to-date, add them first + putAllUnclaimedLeft(unclaimedRecords) + // Then add the loaded page + putAll(page) + } else { + readAndPutPage(this@loadMoreLeft, cursor, pageNum, unclaimedRecords) + } + } + return if (numLoadedPages < maxLoadedPages) { + expandWindowLeft(newPage, hasMoreLeft) + } else { + shiftWindowLeft(newPage, hasMoreLeft) + } + } + + private suspend fun readPage( + state: CursorWindow, + pagedCursor: PagedCursor<Uri?>, + pageNum: Int, + unclaimedRecords: MutableUnclaimedMap, + ): PreviewMap = + mutableMapOf<Uri, PreviewModel>() + .readAndPutPage(state, pagedCursor, pageNum, unclaimedRecords) + + private suspend fun <M : MutablePreviewMap> M.readAndPutPage( + state: CursorWindow, + pagedCursor: PagedCursor<Uri?>, + pageNum: Int, + unclaimedRecords: MutableUnclaimedMap, + ): M = + pagedCursor + .getPageUris(pageNum) // TODO: what do we do if the load fails? + ?.filter { it !in state.merged } + ?.toPage(this, unclaimedRecords) + ?: this + + private suspend fun <M : MutablePreviewMap> Sequence<Uri>.toPage( + destination: M, + unclaimedRecords: MutableUnclaimedMap, + ): M = + // Restrict parallelism so as to not overload the metadata reader; anecdotally, too + // many parallel queries causes failures. + mapParallel(parallelism = 4) { uri -> createPreviewModel(uri, unclaimedRecords) } + .associateByTo(destination) { it.uri } + + private fun createPreviewModel(uri: Uri, unclaimedRecords: MutableUnclaimedMap): PreviewModel = + unclaimedRecords.remove(uri)?.second + ?: PreviewModel( + uri = uri, + mimeType = uriMetadataReader.getMetadata(uri).mimeType, + ) + + private fun <M : MutablePreviewMap> M.putAllUnclaimedRight(unclaimed: UnclaimedMap): M = + putAllUnclaimedWhere(unclaimed) { it >= focusedItemIdx } + + private fun <M : MutablePreviewMap> M.putAllUnclaimedLeft(unclaimed: UnclaimedMap): M = + putAllUnclaimedWhere(unclaimed) { it < focusedItemIdx } +} + +private typealias CursorWindow = LoadedWindow<Uri, PreviewModel> + +/** + * Values from the initial selection set that have not yet appeared within the Cursor. These values + * are appended to the start/end of the cursor dataset, depending on their position relative to the + * initially focused value. + */ +private typealias UnclaimedMap = Map<Uri, Pair<Int, PreviewModel>> + +/** Mutable version of [UnclaimedMap]. */ +private typealias MutableUnclaimedMap = MutableMap<Uri, Pair<Int, PreviewModel>> + +private typealias MutablePreviewMap = MutableMap<Uri, PreviewModel> + +private typealias PreviewMap = Map<Uri, PreviewModel> + +private fun <M : MutablePreviewMap> M.putAllUnclaimedWhere( + unclaimedRecords: UnclaimedMap, + predicate: (Int) -> Boolean, +): M = + unclaimedRecords + .asSequence() + .filter { predicate(it.value.first) } + .map { it.key to it.value.second } + .toMap(this) + +private fun PagedCursor<Uri?>.getPageUris(pageNum: Int): Sequence<Uri>? = + get(pageNum)?.filterNotNull() + +@Qualifier @MustBeDocumented @Retention(AnnotationRetention.RUNTIME) annotation class PageSize + +@Qualifier +@MustBeDocumented +@Retention(AnnotationRetention.RUNTIME) +annotation class MaxLoadedPages + +@Module +@InstallIn(SingletonComponent::class) +object ShareouselConstants { + @Provides @PageSize fun pageSize(): Int = 16 + + @Provides @MaxLoadedPages fun maxLoadedPages(): Int = 3 +} diff --git a/java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/interactor/CustomActionsInteractor.kt b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/interactor/CustomActionsInteractor.kt new file mode 100644 index 00000000..e973e844 --- /dev/null +++ b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/interactor/CustomActionsInteractor.kt @@ -0,0 +1,66 @@ +/* + * Copyright (C) 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.intentresolver.contentpreview.payloadtoggle.domain.interactor + +import android.app.Activity +import android.content.ContentResolver +import android.content.pm.PackageManager +import com.android.intentresolver.contentpreview.payloadtoggle.data.model.CustomActionModel +import com.android.intentresolver.contentpreview.payloadtoggle.data.repository.ActivityResultRepository +import com.android.intentresolver.contentpreview.payloadtoggle.domain.model.ActionModel +import com.android.intentresolver.icon.toComposeIcon +import com.android.intentresolver.inject.Background +import com.android.intentresolver.logging.EventLog +import javax.inject.Inject +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.conflate +import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.flow.map + +class CustomActionsInteractor +@Inject +constructor( + private val activityResultRepo: ActivityResultRepository, + @Background private val bgDispatcher: CoroutineDispatcher, + private val contentResolver: ContentResolver, + private val eventLog: EventLog, + private val packageManager: PackageManager, + private val chooserRequestInteractor: ChooserRequestInteractor, +) { + /** List of [ActionModel] that can be presented in Shareousel. */ + val customActions: Flow<List<ActionModel>> + get() = + chooserRequestInteractor.customActions + .map { actions -> + actions.map { action -> + ActionModel( + label = action.label, + icon = action.icon.toComposeIcon(packageManager, contentResolver), + performAction = { index -> performAction(action, index) }, + ) + } + } + .flowOn(bgDispatcher) + .conflate() + + private fun performAction(action: CustomActionModel, index: Int) { + action.performAction() + eventLog.logCustomActionSelected(index) + activityResultRepo.activityResult.value = Activity.RESULT_OK + } +} diff --git a/java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/interactor/FetchPreviewsInteractor.kt b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/interactor/FetchPreviewsInteractor.kt new file mode 100644 index 00000000..9bc7ae63 --- /dev/null +++ b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/interactor/FetchPreviewsInteractor.kt @@ -0,0 +1,65 @@ +/* + * Copyright (C) 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.intentresolver.contentpreview.payloadtoggle.domain.interactor + +import android.net.Uri +import com.android.intentresolver.contentpreview.UriMetadataReader +import com.android.intentresolver.contentpreview.payloadtoggle.data.repository.PreviewSelectionsRepository +import com.android.intentresolver.contentpreview.payloadtoggle.domain.cursor.CursorResolver +import com.android.intentresolver.contentpreview.payloadtoggle.domain.cursor.PayloadToggle +import com.android.intentresolver.contentpreview.payloadtoggle.shared.model.PreviewModel +import com.android.intentresolver.inject.ContentUris +import com.android.intentresolver.inject.FocusedItemIndex +import com.android.intentresolver.util.mapParallel +import javax.inject.Inject +import kotlinx.coroutines.async +import kotlinx.coroutines.coroutineScope + +/** Populates the data displayed in Shareousel. */ +class FetchPreviewsInteractor +@Inject +constructor( + private val setCursorPreviews: SetCursorPreviewsInteractor, + private val selectionRepository: PreviewSelectionsRepository, + private val cursorInteractor: CursorPreviewsInteractor, + @FocusedItemIndex private val focusedItemIdx: Int, + @ContentUris private val selectedItems: List<@JvmSuppressWildcards Uri>, + private val uriMetadataReader: UriMetadataReader, + @PayloadToggle private val cursorResolver: CursorResolver<@JvmSuppressWildcards Uri?>, +) { + suspend fun activate() = coroutineScope { + val cursor = async { cursorResolver.getCursor() } + val initialPreviewMap: Set<PreviewModel> = getInitialPreviews() + selectionRepository.selections.value = initialPreviewMap + setCursorPreviews.setPreviews( + previewsByKey = initialPreviewMap, + startIndex = focusedItemIdx, + hasMoreLeft = false, + hasMoreRight = false, + ) + cursorInteractor.launch(cursor.await() ?: return@coroutineScope, initialPreviewMap) + } + + private suspend fun getInitialPreviews(): Set<PreviewModel> = + selectedItems + // Restrict parallelism so as to not overload the metadata reader; anecdotally, too + // many parallel queries causes failures. + .mapParallel(parallelism = 4) { uri -> + PreviewModel(uri = uri, mimeType = uriMetadataReader.getMetadata(uri).mimeType) + } + .toSet() +} diff --git a/java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/interactor/ProcessTargetIntentUpdatesInteractor.kt b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/interactor/ProcessTargetIntentUpdatesInteractor.kt new file mode 100644 index 00000000..04416a3d --- /dev/null +++ b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/interactor/ProcessTargetIntentUpdatesInteractor.kt @@ -0,0 +1,42 @@ +/* + * Copyright (C) 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.intentresolver.contentpreview.payloadtoggle.domain.interactor + +import com.android.intentresolver.contentpreview.payloadtoggle.data.repository.PendingSelectionCallbackRepository +import com.android.intentresolver.contentpreview.payloadtoggle.domain.update.SelectionChangeCallback +import javax.inject.Inject +import kotlinx.coroutines.flow.collectLatest + +/** Communicates with the sharing application to notify of changes to the target intent. */ +class ProcessTargetIntentUpdatesInteractor +@Inject +constructor( + private val selectionCallback: SelectionChangeCallback, + private val repository: PendingSelectionCallbackRepository, + private val chooserRequestInteractor: UpdateChooserRequestInteractor, +) { + /** Listen for events and update state. */ + suspend fun activate() { + repository.pendingTargetIntent.collectLatest { targetIntent -> + targetIntent ?: return@collectLatest + selectionCallback.onSelectionChanged(targetIntent)?.let { update -> + chooserRequestInteractor.applyUpdate(update) + } + repository.pendingTargetIntent.compareAndSet(targetIntent, null) + } + } +} diff --git a/java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/interactor/SelectablePreviewInteractor.kt b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/interactor/SelectablePreviewInteractor.kt new file mode 100644 index 00000000..55a995f5 --- /dev/null +++ b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/interactor/SelectablePreviewInteractor.kt @@ -0,0 +1,42 @@ +/* + * Copyright (C) 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.intentresolver.contentpreview.payloadtoggle.domain.interactor + +import android.net.Uri +import com.android.intentresolver.contentpreview.payloadtoggle.shared.model.PreviewModel +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map + +/** An individual preview in Shareousel. */ +class SelectablePreviewInteractor( + private val key: PreviewModel, + private val selectionInteractor: SelectionInteractor, +) { + val uri: Uri = key.uri + + /** Whether or not this preview is selected by the user. */ + val isSelected: Flow<Boolean> = selectionInteractor.selections.map { key in it } + + /** Sets whether this preview is selected by the user. */ + fun setSelected(isSelected: Boolean) { + if (isSelected) { + selectionInteractor.select(key) + } else { + selectionInteractor.unselect(key) + } + } +} diff --git a/java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/interactor/SelectablePreviewsInteractor.kt b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/interactor/SelectablePreviewsInteractor.kt new file mode 100644 index 00000000..a578d0e2 --- /dev/null +++ b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/interactor/SelectablePreviewsInteractor.kt @@ -0,0 +1,40 @@ +/* + * Copyright (C) 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.intentresolver.contentpreview.payloadtoggle.domain.interactor + +import com.android.intentresolver.contentpreview.payloadtoggle.data.repository.CursorPreviewsRepository +import com.android.intentresolver.contentpreview.payloadtoggle.shared.model.PreviewModel +import com.android.intentresolver.contentpreview.payloadtoggle.shared.model.PreviewsModel +import javax.inject.Inject +import kotlinx.coroutines.flow.Flow + +class SelectablePreviewsInteractor +@Inject +constructor( + private val previewsRepo: CursorPreviewsRepository, + private val selectionInteractor: SelectionInteractor, +) { + /** Keys of previews available for display in Shareousel. */ + val previews: Flow<PreviewsModel?> + get() = previewsRepo.previewsModel + + /** + * Returns a [SelectablePreviewInteractor] that can be used to interact with the individual + * preview associated with [key]. + */ + fun preview(key: PreviewModel) = SelectablePreviewInteractor(key, selectionInteractor) +} diff --git a/java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/interactor/SelectionInteractor.kt b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/interactor/SelectionInteractor.kt new file mode 100644 index 00000000..a570f36e --- /dev/null +++ b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/interactor/SelectionInteractor.kt @@ -0,0 +1,54 @@ +/* + * Copyright (C) 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.intentresolver.contentpreview.payloadtoggle.domain.interactor + +import com.android.intentresolver.contentpreview.payloadtoggle.data.repository.PreviewSelectionsRepository +import com.android.intentresolver.contentpreview.payloadtoggle.domain.intent.TargetIntentModifier +import com.android.intentresolver.contentpreview.payloadtoggle.shared.model.PreviewModel +import javax.inject.Inject +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.updateAndGet + +class SelectionInteractor +@Inject +constructor( + private val selectionsRepo: PreviewSelectionsRepository, + private val targetIntentModifier: TargetIntentModifier<PreviewModel>, + private val updateTargetIntentInteractor: UpdateTargetIntentInteractor, +) { + /** Set of selected previews. */ + val selections: StateFlow<Set<PreviewModel>> + get() = selectionsRepo.selections + + /** Amount of selected previews. */ + val amountSelected: Flow<Int> = selectionsRepo.selections.map { it.size } + + fun select(model: PreviewModel) { + updateChooserRequest(selectionsRepo.selections.updateAndGet { it + model }) + } + + fun unselect(model: PreviewModel) { + updateChooserRequest(selectionsRepo.selections.updateAndGet { it - model }) + } + + private fun updateChooserRequest(selections: Set<PreviewModel>) { + val intent = targetIntentModifier.intentFromSelection(selections) + updateTargetIntentInteractor.updateTargetIntent(intent) + } +} diff --git a/java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/interactor/SetCursorPreviewsInteractor.kt b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/interactor/SetCursorPreviewsInteractor.kt new file mode 100644 index 00000000..21a599fa --- /dev/null +++ b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/interactor/SetCursorPreviewsInteractor.kt @@ -0,0 +1,59 @@ +/* + * Copyright (C) 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.intentresolver.contentpreview.payloadtoggle.domain.interactor + +import com.android.intentresolver.contentpreview.payloadtoggle.data.repository.CursorPreviewsRepository +import com.android.intentresolver.contentpreview.payloadtoggle.domain.model.LoadDirection +import com.android.intentresolver.contentpreview.payloadtoggle.shared.model.PreviewModel +import com.android.intentresolver.contentpreview.payloadtoggle.shared.model.PreviewsModel +import javax.inject.Inject +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow + +/** Updates [CursorPreviewsRepository] with new previews. */ +class SetCursorPreviewsInteractor +@Inject +constructor(private val previewsRepo: CursorPreviewsRepository) { + /** Stores new [previewsByKey], and returns a flow of load requests triggered by Shareousel. */ + fun setPreviews( + previewsByKey: Set<PreviewModel>, + startIndex: Int, + hasMoreLeft: Boolean, + hasMoreRight: Boolean, + ): Flow<LoadDirection?> { + val loadingState = MutableStateFlow<LoadDirection?>(null) + previewsRepo.previewsModel.value = + PreviewsModel( + previewModels = previewsByKey, + startIdx = startIndex, + loadMoreLeft = + if (hasMoreLeft) { + ({ loadingState.value = LoadDirection.Left }) + } else { + null + }, + loadMoreRight = + if (hasMoreRight) { + ({ loadingState.value = LoadDirection.Right }) + } else { + null + }, + ) + return loadingState.asStateFlow() + } +} diff --git a/java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/interactor/UpdateChooserRequestInteractor.kt b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/interactor/UpdateChooserRequestInteractor.kt new file mode 100644 index 00000000..9e48cd28 --- /dev/null +++ b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/interactor/UpdateChooserRequestInteractor.kt @@ -0,0 +1,62 @@ +/* + * Copyright (C) 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.intentresolver.contentpreview.payloadtoggle.domain.interactor + +import android.content.Intent +import com.android.intentresolver.contentpreview.payloadtoggle.domain.intent.CustomAction +import com.android.intentresolver.contentpreview.payloadtoggle.domain.intent.PendingIntentSender +import com.android.intentresolver.contentpreview.payloadtoggle.domain.intent.toCustomActionModel +import com.android.intentresolver.contentpreview.payloadtoggle.domain.model.ShareouselUpdate +import com.android.intentresolver.contentpreview.payloadtoggle.domain.model.getOrDefault +import com.android.intentresolver.contentpreview.payloadtoggle.domain.model.onValue +import com.android.intentresolver.v2.data.repository.ChooserRequestRepository +import javax.inject.Inject +import kotlinx.coroutines.flow.update + +/** Updates the tracked chooser request. */ +class UpdateChooserRequestInteractor +@Inject +constructor( + private val repository: ChooserRequestRepository, + @CustomAction private val pendingIntentSender: PendingIntentSender, +) { + fun applyUpdate(update: ShareouselUpdate) { + repository.chooserRequest.update { current -> + current.copy( + callerChooserTargets = + update.callerTargets.getOrDefault(current.callerChooserTargets), + modifyShareAction = + update.modifyShareAction.getOrDefault(current.modifyShareAction), + additionalTargets = update.alternateIntents.getOrDefault(current.additionalTargets), + chosenComponentSender = + update.resultIntentSender.getOrDefault(current.chosenComponentSender), + refinementIntentSender = + update.refinementIntentSender.getOrDefault(current.refinementIntentSender), + metadataText = update.metadataText.getOrDefault(current.metadataText), + chooserActions = update.customActions.getOrDefault(current.chooserActions), + ) + } + update.customActions.onValue { actions -> + repository.customActions.value = + actions.map { it.toCustomActionModel(pendingIntentSender) } + } + } + + fun setTargetIntent(targetIntent: Intent) { + repository.chooserRequest.update { it.copy(targetIntent = targetIntent) } + } +} diff --git a/java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/interactor/UpdateTargetIntentInteractor.kt b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/interactor/UpdateTargetIntentInteractor.kt new file mode 100644 index 00000000..429e34e9 --- /dev/null +++ b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/interactor/UpdateTargetIntentInteractor.kt @@ -0,0 +1,37 @@ +/* + * Copyright (C) 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.intentresolver.contentpreview.payloadtoggle.domain.interactor + +import android.content.Intent +import com.android.intentresolver.contentpreview.payloadtoggle.data.repository.PendingSelectionCallbackRepository +import javax.inject.Inject + +class UpdateTargetIntentInteractor +@Inject +constructor( + private val repository: PendingSelectionCallbackRepository, + private val chooserRequestInteractor: UpdateChooserRequestInteractor, +) { + /** + * Updates the target intent for the chooser. This will kick off an asynchronous IPC with the + * sharing application, so that it can react to the new intent. + */ + fun updateTargetIntent(targetIntent: Intent) { + chooserRequestInteractor.setTargetIntent(targetIntent) + repository.pendingTargetIntent.value = targetIntent + } +} diff --git a/java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/model/ActionModel.kt b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/model/ActionModel.kt new file mode 100644 index 00000000..f69365d7 --- /dev/null +++ b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/model/ActionModel.kt @@ -0,0 +1,31 @@ +/* + * Copyright (C) 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.intentresolver.contentpreview.payloadtoggle.domain.model + +import com.android.intentresolver.icon.ComposeIcon + +/** An action that the user can take, provided by the sharing application. */ +data class ActionModel( + /** Text shown for this action in the UI. */ + val label: CharSequence, + /** An optional [ComposeIcon] that will be displayed in the UI with this action. */ + val icon: ComposeIcon?, + /** + * Performs the action. The argument indicates the index in the UI that this action is shown. + */ + val performAction: (index: Int) -> Unit, +) diff --git a/java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/model/LoadDirection.kt b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/model/LoadDirection.kt new file mode 100644 index 00000000..23510f15 --- /dev/null +++ b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/model/LoadDirection.kt @@ -0,0 +1,23 @@ +/* + * Copyright (C) 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.intentresolver.contentpreview.payloadtoggle.domain.model + +/** Specifies which side of the dataset is being loaded. */ +enum class LoadDirection { + Left, + Right, +} diff --git a/java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/model/LoadedWindow.kt b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/model/LoadedWindow.kt new file mode 100644 index 00000000..e2e69852 --- /dev/null +++ b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/model/LoadedWindow.kt @@ -0,0 +1,102 @@ +/* + * Copyright (C) 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.intentresolver.contentpreview.payloadtoggle.domain.model + +/** A window of data loaded from a cursor. */ +data class LoadedWindow<K, V>( + /** First cursor page index loaded within this window. */ + val firstLoadedPageNum: Int, + /** Last cursor page index loaded within this window. */ + val lastLoadedPageNum: Int, + /** Keys of cursor data within this window, grouped by loaded page. */ + val pages: List<Set<K>>, + /** Merged set of all cursor data within this window. */ + val merged: Map<K, V>, + /** Is there more data to the left of this window? */ + val hasMoreLeft: Boolean, + /** Is there more data to the right of this window? */ + val hasMoreRight: Boolean, +) + +/** Number of loaded pages stored within this [LoadedWindow]. */ +val LoadedWindow<*, *>.numLoadedPages: Int + get() = (lastLoadedPageNum - firstLoadedPageNum) + 1 + +/** Inserts [newPage] to the right, and removes the leftmost page from the window. */ +fun <K, V> LoadedWindow<K, V>.shiftWindowRight( + newPage: Map<K, V>, + hasMore: Boolean, +): LoadedWindow<K, V> = + LoadedWindow( + firstLoadedPageNum = firstLoadedPageNum + 1, + lastLoadedPageNum = lastLoadedPageNum + 1, + pages = pages.drop(1) + listOf(newPage.keys), + merged = + buildMap { + putAll(merged) + pages.first().forEach(::remove) + putAll(newPage) + }, + hasMoreLeft = true, + hasMoreRight = hasMore, + ) + +/** Inserts [newPage] to the right, increasing the size of the window to accommodate it. */ +fun <K, V> LoadedWindow<K, V>.expandWindowRight( + newPage: Map<K, V>, + hasMore: Boolean, +): LoadedWindow<K, V> = + LoadedWindow( + firstLoadedPageNum = firstLoadedPageNum, + lastLoadedPageNum = lastLoadedPageNum + 1, + pages = pages + listOf(newPage.keys), + merged = merged + newPage, + hasMoreLeft = hasMoreLeft, + hasMoreRight = hasMore, + ) + +/** Inserts [newPage] to the left, and removes the rightmost page from the window. */ +fun <K, V> LoadedWindow<K, V>.shiftWindowLeft( + newPage: Map<K, V>, + hasMore: Boolean, +): LoadedWindow<K, V> = + LoadedWindow( + firstLoadedPageNum = firstLoadedPageNum - 1, + lastLoadedPageNum = lastLoadedPageNum - 1, + pages = listOf(newPage.keys) + pages.dropLast(1), + merged = + buildMap { + putAll(newPage) + putAll(merged - pages.last()) + }, + hasMoreLeft = hasMore, + hasMoreRight = true, + ) + +/** Inserts [newPage] to the left, increasing the size olf the window to accommodate it. */ +fun <K, V> LoadedWindow<K, V>.expandWindowLeft( + newPage: Map<K, V>, + hasMore: Boolean, +): LoadedWindow<K, V> = + LoadedWindow( + firstLoadedPageNum = firstLoadedPageNum - 1, + lastLoadedPageNum = lastLoadedPageNum, + pages = listOf(newPage.keys) + pages, + merged = newPage + merged, + hasMoreLeft = hasMore, + hasMoreRight = hasMoreRight, + ) diff --git a/java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/model/ShareouselUpdate.kt b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/model/ShareouselUpdate.kt new file mode 100644 index 00000000..821e88a5 --- /dev/null +++ b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/model/ShareouselUpdate.kt @@ -0,0 +1,34 @@ +/* + * Copyright 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.intentresolver.contentpreview.payloadtoggle.domain.model + +import android.content.Intent +import android.content.IntentSender +import android.service.chooser.ChooserAction +import android.service.chooser.ChooserTarget + +/** Sharing session updates provided by the sharing app from the payload change callback */ +data class ShareouselUpdate( + // for all properties, null value means no change + val customActions: ValueUpdate<List<ChooserAction>> = ValueUpdate.Absent, + val modifyShareAction: ValueUpdate<ChooserAction?> = ValueUpdate.Absent, + val alternateIntents: ValueUpdate<List<Intent>> = ValueUpdate.Absent, + val callerTargets: ValueUpdate<List<ChooserTarget>> = ValueUpdate.Absent, + val refinementIntentSender: ValueUpdate<IntentSender?> = ValueUpdate.Absent, + val resultIntentSender: ValueUpdate<IntentSender?> = ValueUpdate.Absent, + val metadataText: ValueUpdate<CharSequence?> = ValueUpdate.Absent, +) diff --git a/java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/model/ValueUpdate.kt b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/model/ValueUpdate.kt new file mode 100644 index 00000000..bad4eebe --- /dev/null +++ b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/model/ValueUpdate.kt @@ -0,0 +1,37 @@ +/* + * Copyright 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.intentresolver.contentpreview.payloadtoggle.domain.model + +/** Represents an either updated value or the absence of it */ +sealed interface ValueUpdate<out T> { + data class Value<T>(val value: T) : ValueUpdate<T> + data object Absent : ValueUpdate<Nothing> +} + +/** Return encapsulated value if this instance represent Value or `default` if Absent */ +fun <T> ValueUpdate<T>.getOrDefault(default: T): T = + when (this) { + is ValueUpdate.Value -> value + is ValueUpdate.Absent -> default + } + +/** Executes the `block` with encapsulated value if this instance represents Value */ +inline fun <T> ValueUpdate<T>.onValue(block: (T) -> Unit) { + if (this is ValueUpdate.Value) { + block(value) + } +} diff --git a/java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/update/SelectionChangeCallback.kt b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/update/SelectionChangeCallback.kt new file mode 100644 index 00000000..20af264a --- /dev/null +++ b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/update/SelectionChangeCallback.kt @@ -0,0 +1,173 @@ +/* + * Copyright (C) 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.intentresolver.contentpreview.payloadtoggle.domain.update + +import android.content.ContentInterface +import android.content.Intent +import android.content.Intent.EXTRA_ALTERNATE_INTENTS +import android.content.Intent.EXTRA_CHOOSER_CUSTOM_ACTIONS +import android.content.Intent.EXTRA_CHOOSER_MODIFY_SHARE_ACTION +import android.content.Intent.EXTRA_CHOOSER_REFINEMENT_INTENT_SENDER +import android.content.Intent.EXTRA_CHOOSER_RESULT_INTENT_SENDER +import android.content.Intent.EXTRA_CHOOSER_TARGETS +import android.content.Intent.EXTRA_INTENT +import android.content.Intent.EXTRA_METADATA_TEXT +import android.content.IntentSender +import android.net.Uri +import android.os.Bundle +import android.service.chooser.AdditionalContentContract.MethodNames.ON_SELECTION_CHANGED +import android.service.chooser.ChooserAction +import android.service.chooser.ChooserTarget +import com.android.intentresolver.contentpreview.payloadtoggle.domain.model.ShareouselUpdate +import com.android.intentresolver.contentpreview.payloadtoggle.domain.model.ValueUpdate +import com.android.intentresolver.inject.AdditionalContent +import com.android.intentresolver.inject.ChooserIntent +import com.android.intentresolver.inject.ChooserServiceFlags +import com.android.intentresolver.v2.ui.viewmodel.readAlternateIntents +import com.android.intentresolver.v2.ui.viewmodel.readChooserActions +import com.android.intentresolver.v2.validation.Invalid +import com.android.intentresolver.v2.validation.Valid +import com.android.intentresolver.v2.validation.ValidationResult +import com.android.intentresolver.v2.validation.log +import com.android.intentresolver.v2.validation.types.array +import com.android.intentresolver.v2.validation.types.value +import com.android.intentresolver.v2.validation.validateFrom +import dagger.Binds +import dagger.Module +import dagger.hilt.InstallIn +import dagger.hilt.android.components.ViewModelComponent +import javax.inject.Inject +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock + +private const val TAG = "SelectionChangeCallback" + +/** + * Encapsulates payload change callback invocation to the sharing app; handles callback arguments + * and result format mapping. + */ +fun interface SelectionChangeCallback { + suspend fun onSelectionChanged(targetIntent: Intent): ShareouselUpdate? +} + +class SelectionChangeCallbackImpl +@Inject +constructor( + @AdditionalContent private val uri: Uri, + @ChooserIntent private val chooserIntent: Intent, + private val contentResolver: ContentInterface, + private val flags: ChooserServiceFlags, +) : SelectionChangeCallback { + private val mutex = Mutex() + + override suspend fun onSelectionChanged(targetIntent: Intent): ShareouselUpdate? = + mutex + .withLock { + contentResolver.call( + requireNotNull(uri.authority) { "URI authority can not be null" }, + ON_SELECTION_CHANGED, + uri.toString(), + Bundle().apply { + putParcelable( + EXTRA_INTENT, + Intent(chooserIntent).apply { putExtra(EXTRA_INTENT, targetIntent) } + ) + } + ) + } + ?.let { bundle -> + return when (val result = readCallbackResponse(bundle, flags)) { + is Valid -> { + result.warnings.forEach { it.log(TAG) } + result.value + } + is Invalid -> { + result.errors.forEach { it.log(TAG) } + null + } + } + } +} + +private fun readCallbackResponse( + bundle: Bundle, + flags: ChooserServiceFlags +): ValidationResult<ShareouselUpdate> { + return validateFrom(bundle::get) { + // An error is treated as an empty collection or null as the presence of a value indicates + // an intention to change the old value implying that the old value is obsolete (and should + // not be used). + val customActions = + bundle.readValueUpdate(EXTRA_CHOOSER_CUSTOM_ACTIONS) { + readChooserActions() ?: emptyList() + } + val modifyShareAction = + bundle.readValueUpdate(EXTRA_CHOOSER_MODIFY_SHARE_ACTION) { key -> + optional(value<ChooserAction>(key)) + } + val alternateIntents = + bundle.readValueUpdate(EXTRA_ALTERNATE_INTENTS) { + readAlternateIntents() ?: emptyList() + } + val callerTargets = + bundle.readValueUpdate(EXTRA_CHOOSER_TARGETS) { key -> + optional(array<ChooserTarget>(key)) ?: emptyList() + } + val refinementIntentSender = + bundle.readValueUpdate(EXTRA_CHOOSER_REFINEMENT_INTENT_SENDER) { key -> + optional(value<IntentSender>(key)) + } + val resultIntentSender = + bundle.readValueUpdate(EXTRA_CHOOSER_RESULT_INTENT_SENDER) { key -> + optional(value<IntentSender>(key)) + } + val metadataText = + if (flags.enableSharesheetMetadataExtra()) { + bundle.readValueUpdate(EXTRA_METADATA_TEXT) { key -> + optional(value<CharSequence>(key)) + } + } else { + ValueUpdate.Absent + } + + ShareouselUpdate( + customActions, + modifyShareAction, + alternateIntents, + callerTargets, + refinementIntentSender, + resultIntentSender, + metadataText, + ) + } +} + +private inline fun <reified T> Bundle.readValueUpdate( + key: String, + block: (String) -> T +): ValueUpdate<T> = + if (containsKey(key)) { + ValueUpdate.Value(block(key)) + } else { + ValueUpdate.Absent + } + +@Module +@InstallIn(ViewModelComponent::class) +interface SelectionChangeCallbackModule { + @Binds fun bind(impl: SelectionChangeCallbackImpl): SelectionChangeCallback +} diff --git a/java/src/com/android/intentresolver/contentpreview/payloadtoggle/shared/model/PreviewModel.kt b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/shared/model/PreviewModel.kt new file mode 100644 index 00000000..ff96a9f4 --- /dev/null +++ b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/shared/model/PreviewModel.kt @@ -0,0 +1,29 @@ +/* + * Copyright (C) 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.intentresolver.contentpreview.payloadtoggle.shared.model + +import android.net.Uri + +/** An individual preview presented in Shareousel. */ +data class PreviewModel( + /** + * Uri for this preview; if this preview is selected, this will be shared with the target app. + */ + val uri: Uri, + /** Mimetype for the data [uri] points to. */ + val mimeType: String?, +) diff --git a/java/src/com/android/intentresolver/contentpreview/payloadtoggle/shared/model/PreviewsModel.kt b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/shared/model/PreviewsModel.kt new file mode 100644 index 00000000..0ac99bd3 --- /dev/null +++ b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/shared/model/PreviewsModel.kt @@ -0,0 +1,35 @@ +/* + * Copyright (C) 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.intentresolver.contentpreview.payloadtoggle.shared.model + +/** A dataset of previews for Shareousel. */ +data class PreviewsModel( + /** All available [PreviewModel]s. */ + val previewModels: Set<PreviewModel>, + /** Index into [previewModels] that should be initially displayed to the user. */ + val startIdx: Int, + /** + * Signals that more data should be loaded to the left of this dataset. A `null` value indicates + * that there is no more data to load in that direction. + */ + val loadMoreLeft: (() -> Unit)?, + /** + * Signals that more data should be loaded to the right of this dataset. A `null` value + * indicates that there is no more data to load in that direction. + */ + val loadMoreRight: (() -> Unit)?, +) diff --git a/java/src/com/android/intentresolver/contentpreview/shareousel/ui/composable/ComposeIconComposable.kt b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/ui/composable/ComposeIconComposable.kt index 87fb7618..38138225 100644 --- a/java/src/com/android/intentresolver/contentpreview/shareousel/ui/composable/ComposeIconComposable.kt +++ b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/ui/composable/ComposeIconComposable.kt @@ -13,7 +13,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package com.android.intentresolver.contentpreview.shareousel.ui.composable +package com.android.intentresolver.contentpreview.payloadtoggle.ui.composable import android.content.Context import android.content.ContextWrapper @@ -21,6 +21,7 @@ import android.content.res.Resources import androidx.compose.foundation.Image import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.asImageBitmap import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.painterResource @@ -30,10 +31,11 @@ import com.android.intentresolver.icon.ComposeIcon import com.android.intentresolver.icon.ResourceIcon @Composable -fun Image(icon: ComposeIcon) { +fun Image(icon: ComposeIcon, modifier: Modifier = Modifier) { when (icon) { - is AdaptiveIcon -> Image(icon.wrapped) - is BitmapIcon -> Image(icon.bitmap.asImageBitmap(), contentDescription = null) + is AdaptiveIcon -> Image(icon.wrapped, modifier) + is BitmapIcon -> + Image(icon.bitmap.asImageBitmap(), contentDescription = null, modifier = modifier) is ResourceIcon -> { val localContext = LocalContext.current val wrappedContext: Context = @@ -41,7 +43,7 @@ fun Image(icon: ComposeIcon) { override fun getResources(): Resources = icon.res } CompositionLocalProvider(LocalContext provides wrappedContext) { - Image(painterResource(icon.resId), contentDescription = null) + Image(painterResource(icon.resId), contentDescription = null, modifier = modifier) } } } diff --git a/java/src/com/android/intentresolver/contentpreview/shareousel/ui/composable/ShareouselCardComposable.kt b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/ui/composable/ShareouselCardComposable.kt index dc96e3c1..f33558c7 100644 --- a/java/src/com/android/intentresolver/contentpreview/shareousel/ui/composable/ShareouselCardComposable.kt +++ b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/ui/composable/ShareouselCardComposable.kt @@ -13,7 +13,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package com.android.intentresolver.contentpreview.shareousel.ui.composable +package com.android.intentresolver.contentpreview.payloadtoggle.ui.composable import androidx.compose.foundation.background import androidx.compose.foundation.border @@ -33,10 +33,12 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.res.painterResource import androidx.compose.ui.unit.dp import com.android.intentresolver.R +import com.android.intentresolver.contentpreview.payloadtoggle.ui.viewmodel.ContentType @Composable fun ShareouselCard( image: @Composable () -> Unit, + contentType: ContentType, selected: Boolean, modifier: Modifier = Modifier, ) { @@ -45,7 +47,9 @@ fun ShareouselCard( val topButtonPadding = 12.dp Box(modifier = Modifier.padding(topButtonPadding).matchParentSize()) { SelectionIcon(selected, modifier = Modifier.align(Alignment.TopStart)) - AnimationIcon(modifier = Modifier.align(Alignment.TopEnd)) + if (contentType == ContentType.Video) { + AnimationIcon(modifier = Modifier.align(Alignment.TopEnd)) + } } } } diff --git a/java/src/com/android/intentresolver/contentpreview/shareousel/ui/composable/ShareouselComposable.kt b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/ui/composable/ShareouselComposable.kt index 5cf35297..feb6f3a8 100644 --- a/java/src/com/android/intentresolver/contentpreview/shareousel/ui/composable/ShareouselComposable.kt +++ b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/ui/composable/ShareouselComposable.kt @@ -13,9 +13,10 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package com.android.intentresolver.contentpreview.shareousel.ui.composable +package com.android.intentresolver.contentpreview.payloadtoggle.ui.composable import androidx.compose.foundation.Image +import androidx.compose.foundation.background import androidx.compose.foundation.border import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement @@ -23,10 +24,14 @@ import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size import androidx.compose.foundation.lazy.LazyRow import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.AssistChip @@ -34,65 +39,78 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue +import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.RectangleShape import androidx.compose.ui.graphics.asImageBitmap import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.res.dimensionResource import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.android.intentresolver.R -import com.android.intentresolver.contentpreview.shareousel.ui.viewmodel.ShareouselImageViewModel -import com.android.intentresolver.contentpreview.shareousel.ui.viewmodel.ShareouselViewModel +import com.android.intentresolver.contentpreview.payloadtoggle.shared.model.PreviewsModel +import com.android.intentresolver.contentpreview.payloadtoggle.ui.viewmodel.ContentType +import com.android.intentresolver.contentpreview.payloadtoggle.ui.viewmodel.ShareouselPreviewViewModel +import com.android.intentresolver.contentpreview.payloadtoggle.ui.viewmodel.ShareouselViewModel +import kotlinx.coroutines.launch @Composable fun Shareousel(viewModel: ShareouselViewModel) { - val centerIdx = viewModel.centerIndex.value - val carouselState = rememberLazyListState(initialFirstVisibleItemIndex = centerIdx) - val previewKeys by viewModel.previewKeys.collectAsStateWithLifecycle() - Column { - // TODO: item needs to be centered, check out ScalingLazyColumn impl or see if - // HorizontalPager works for our use-case - LazyRow( - state = carouselState, - horizontalArrangement = Arrangement.spacedBy(4.dp), - modifier = - Modifier.fillMaxWidth() - .height(dimensionResource(R.dimen.chooser_preview_image_height_tall)) - ) { - items(previewKeys, key = viewModel.previewRowKey) { key -> - ShareouselCard(viewModel.previewForKey(key)) - } - } - Spacer(modifier = Modifier.height(8.dp)) + val keySet = viewModel.previews.collectAsStateWithLifecycle(null).value + if (keySet != null) { + Shareousel(viewModel, keySet) + } else { + Spacer( + Modifier.height(dimensionResource(R.dimen.chooser_preview_image_height_tall) + 64.dp) + ) + } +} - val actions by viewModel.actions.collectAsStateWithLifecycle(initialValue = emptyList()) - LazyRow( - horizontalArrangement = Arrangement.spacedBy(4.dp), - ) { - items(actions) { actionViewModel -> - ShareouselAction( - label = actionViewModel.label, - onClick = actionViewModel.onClick, - ) { - actionViewModel.icon?.let { Image(it) } - } - } - } +@Composable +private fun Shareousel(viewModel: ShareouselViewModel, keySet: PreviewsModel) { + Column( + modifier = + Modifier.background(MaterialTheme.colorScheme.surfaceContainer) + .padding(vertical = 16.dp), + ) { + PreviewCarousel(keySet, viewModel) + Spacer(Modifier.height(16.dp)) + ActionCarousel(viewModel) } } -private const val MIN_ASPECT_RATIO = 0.4f -private const val MAX_ASPECT_RATIO = 2.5f +@Composable +private fun PreviewCarousel( + previews: PreviewsModel, + viewModel: ShareouselViewModel, +) { + val centerIdx = previews.startIdx + val carouselState = rememberLazyListState(initialFirstVisibleItemIndex = centerIdx) + // TODO: start item needs to be centered, check out ScalingLazyColumn impl or see if + // HorizontalPager works for our use-case + LazyRow( + state = carouselState, + horizontalArrangement = Arrangement.spacedBy(4.dp), + modifier = + Modifier.fillMaxWidth() + .height(dimensionResource(R.dimen.chooser_preview_image_height_tall)) + ) { + items(previews.previewModels.toList(), key = { it.uri }) { model -> + ShareouselCard(viewModel.preview(model)) + } + } +} @Composable -private fun ShareouselCard(viewModel: ShareouselImageViewModel) { +private fun ShareouselCard(viewModel: ShareouselPreviewViewModel) { val bitmap by viewModel.bitmap.collectAsStateWithLifecycle(initialValue = null) val selected by viewModel.isSelected.collectAsStateWithLifecycle(initialValue = false) - val contentDescription by - viewModel.contentDescription.collectAsStateWithLifecycle(initialValue = null) + val contentType by + viewModel.contentType.collectAsStateWithLifecycle(initialValue = ContentType.Image) val borderColor = MaterialTheme.colorScheme.primary - + val scope = rememberCoroutineScope() ShareouselCard( image = { bitmap?.let { bitmap -> @@ -102,31 +120,55 @@ private fun ShareouselCard(viewModel: ShareouselImageViewModel) { .coerceIn(MIN_ASPECT_RATIO, MAX_ASPECT_RATIO) Image( bitmap = bitmap.asImageBitmap(), - contentDescription = contentDescription, + contentDescription = null, contentScale = ContentScale.Crop, modifier = Modifier.aspectRatio(aspectRatio), ) } ?: run { // TODO: look at ScrollableImagePreviewView.setLoading() - Box(modifier = Modifier.aspectRatio(2f / 5f)) + Box( + modifier = + Modifier.fillMaxHeight() + .aspectRatio(2f / 5f) + .border(1.dp, Color.Red, RectangleShape) + ) } }, + contentType = contentType, selected = selected, modifier = Modifier.thenIf(selected) { Modifier.border( width = 4.dp, color = borderColor, - shape = RoundedCornerShape(size = 12.dp) + shape = RoundedCornerShape(size = 12.dp), ) } .clip(RoundedCornerShape(size = 12.dp)) - .clickable { viewModel.setSelected(!selected) }, + .clickable { scope.launch { viewModel.setSelected(!selected) } }, ) } @Composable +private fun ActionCarousel(viewModel: ShareouselViewModel) { + val actions by viewModel.actions.collectAsStateWithLifecycle(initialValue = emptyList()) + LazyRow( + horizontalArrangement = Arrangement.spacedBy(4.dp), + modifier = Modifier.height(32.dp), + ) { + itemsIndexed(actions) { idx, actionViewModel -> + ShareouselAction( + label = actionViewModel.label, + onClick = { actionViewModel.onClicked() }, + ) { + actionViewModel.icon?.let { Image(icon = it, modifier = Modifier.size(16.dp)) } + } + } + } +} + +@Composable private fun ShareouselAction( label: String, onClick: () -> Unit, @@ -143,3 +185,6 @@ private fun ShareouselAction( inline fun Modifier.thenIf(condition: Boolean, crossinline factory: () -> Modifier): Modifier = if (condition) this.then(factory()) else this + +private const val MIN_ASPECT_RATIO = 0.4f +private const val MAX_ASPECT_RATIO = 2.5f diff --git a/java/src/com/android/intentresolver/contentpreview/payloadtoggle/ui/viewmodel/ActionChipViewModel.kt b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/ui/viewmodel/ActionChipViewModel.kt new file mode 100644 index 00000000..728c573b --- /dev/null +++ b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/ui/viewmodel/ActionChipViewModel.kt @@ -0,0 +1,29 @@ +/* + * Copyright (C) 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.intentresolver.contentpreview.payloadtoggle.ui.viewmodel + +import com.android.intentresolver.icon.ComposeIcon + +/** An action chip presented to the user underneath Shareousel. */ +data class ActionChipViewModel( + /** Text label. */ + val label: String, + /** Optional icon, displayed next to the text label. */ + val icon: ComposeIcon?, + /** Handles user clicks on this action in the UI. */ + val onClicked: () -> Unit, +) diff --git a/java/src/com/android/intentresolver/contentpreview/payloadtoggle/ui/viewmodel/ShareouselPreviewViewModel.kt b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/ui/viewmodel/ShareouselPreviewViewModel.kt new file mode 100644 index 00000000..a245b3e3 --- /dev/null +++ b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/ui/viewmodel/ShareouselPreviewViewModel.kt @@ -0,0 +1,39 @@ +/* + * Copyright (C) 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.intentresolver.contentpreview.payloadtoggle.ui.viewmodel + +import android.graphics.Bitmap +import kotlinx.coroutines.flow.Flow + +/** An individual preview within Shareousel. */ +data class ShareouselPreviewViewModel( + /** Image to be shared. */ + val bitmap: Flow<Bitmap?>, + /** Type of data to be shared. */ + val contentType: Flow<ContentType>, + /** Whether this preview has been selected by the user. */ + val isSelected: Flow<Boolean>, + /** Sets whether this preview has been selected by the user. */ + val setSelected: suspend (Boolean) -> Unit, +) + +/** Type of the content being previewed. */ +enum class ContentType { + Image, + Video, + Other +} diff --git a/java/src/com/android/intentresolver/contentpreview/payloadtoggle/ui/viewmodel/ShareouselViewModel.kt b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/ui/viewmodel/ShareouselViewModel.kt new file mode 100644 index 00000000..6eccaffa --- /dev/null +++ b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/ui/viewmodel/ShareouselViewModel.kt @@ -0,0 +1,141 @@ +/* + * Copyright (C) 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.android.intentresolver.contentpreview.payloadtoggle.ui.viewmodel + +import android.content.Context +import com.android.intentresolver.R +import com.android.intentresolver.contentpreview.HeadlineGenerator +import com.android.intentresolver.contentpreview.ImageLoader +import com.android.intentresolver.contentpreview.ImagePreviewImageLoader +import com.android.intentresolver.contentpreview.payloadtoggle.domain.cursor.PayloadToggle +import com.android.intentresolver.contentpreview.payloadtoggle.domain.interactor.CustomActionsInteractor +import com.android.intentresolver.contentpreview.payloadtoggle.domain.interactor.SelectablePreviewsInteractor +import com.android.intentresolver.contentpreview.payloadtoggle.domain.interactor.SelectionInteractor +import com.android.intentresolver.contentpreview.payloadtoggle.shared.model.PreviewModel +import com.android.intentresolver.contentpreview.payloadtoggle.shared.model.PreviewsModel +import com.android.intentresolver.inject.Background +import com.android.intentresolver.inject.ViewModelOwned +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.android.components.ViewModelComponent +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.plus + +/** A dynamic carousel of selectable previews within share sheet. */ +data class ShareouselViewModel( + /** Text displayed at the top of the share sheet when Shareousel is present. */ + val headline: Flow<String>, + /** + * Previews which are available for presentation within Shareousel. Use [preview] to create a + * [ShareouselPreviewViewModel] for a given [PreviewModel]. + */ + val previews: Flow<PreviewsModel?>, + /** List of action chips presented underneath Shareousel. */ + val actions: Flow<List<ActionChipViewModel>>, + /** Creates a [ShareouselPreviewViewModel] for a [PreviewModel] present in [previews]. */ + val preview: (key: PreviewModel) -> ShareouselPreviewViewModel, +) + +@Module +@InstallIn(ViewModelComponent::class) +object ShareouselViewModelModule { + @Provides + fun create( + interactor: SelectablePreviewsInteractor, + @PayloadToggle imageLoader: ImageLoader, + actionsInteractor: CustomActionsInteractor, + headlineGenerator: HeadlineGenerator, + selectionInteractor: SelectionInteractor, + // TODO: remove if possible + @ViewModelOwned scope: CoroutineScope, + ): ShareouselViewModel { + val keySet = + interactor.previews.stateIn( + scope, + SharingStarted.Eagerly, + initialValue = null, + ) + return ShareouselViewModel( + headline = + selectionInteractor.amountSelected.map { numItems -> + val contentType = ContentType.Image // TODO: convert from metadata + when (contentType) { + ContentType.Other -> headlineGenerator.getFilesHeadline(numItems) + ContentType.Image -> headlineGenerator.getImagesHeadline(numItems) + ContentType.Video -> headlineGenerator.getVideosHeadline(numItems) + } + }, + previews = keySet, + actions = + actionsInteractor.customActions.map { actions -> + actions.mapIndexedNotNull { i, model -> + val icon = model.icon + val label = model.label + if (icon == null && label.isBlank()) { + null + } else { + ActionChipViewModel( + label = label.toString(), + icon = model.icon, + onClicked = { model.performAction(i) }, + ) + } + } + }, + preview = { key -> + keySet.value?.maybeLoad(key) + val previewInteractor = interactor.preview(key) + ShareouselPreviewViewModel( + bitmap = flow { emit(imageLoader(key.uri)) }, + contentType = flowOf(ContentType.Image), // TODO: convert from metadata + isSelected = previewInteractor.isSelected, + setSelected = previewInteractor::setSelected, + ) + }, + ) + } + + @Provides + @PayloadToggle + fun imageLoader( + @ViewModelOwned viewModelScope: CoroutineScope, + @Background coroutineDispatcher: CoroutineDispatcher, + @ApplicationContext context: Context, + ): ImageLoader = + ImagePreviewImageLoader( + viewModelScope + coroutineDispatcher, + thumbnailSize = + context.resources.getDimensionPixelSize(R.dimen.chooser_preview_image_max_dimen), + context.contentResolver, + cacheSize = 16, + ) +} + +private fun PreviewsModel.maybeLoad(key: PreviewModel) { + when (key) { + previewModels.firstOrNull() -> loadMoreLeft?.invoke() + previewModels.lastOrNull() -> loadMoreRight?.invoke() + } +} diff --git a/java/src/com/android/intentresolver/contentpreview/shareousel/ui/viewmodel/ShareouselViewModel.kt b/java/src/com/android/intentresolver/contentpreview/shareousel/ui/viewmodel/ShareouselViewModel.kt deleted file mode 100644 index 18ee2539..00000000 --- a/java/src/com/android/intentresolver/contentpreview/shareousel/ui/viewmodel/ShareouselViewModel.kt +++ /dev/null @@ -1,90 +0,0 @@ -/* - * Copyright (C) 2024 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package com.android.intentresolver.contentpreview.shareousel.ui.viewmodel - -import android.graphics.Bitmap -import androidx.core.graphics.drawable.toBitmap -import com.android.intentresolver.contentpreview.ChooserContentPreviewUi.ActionFactory -import com.android.intentresolver.contentpreview.ImageLoader -import com.android.intentresolver.contentpreview.MutableActionFactory -import com.android.intentresolver.contentpreview.PayloadToggleInteractor -import com.android.intentresolver.icon.BitmapIcon -import com.android.intentresolver.icon.ComposeIcon -import com.android.intentresolver.widget.ActionRow.Action -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.flow -import kotlinx.coroutines.flow.map -import kotlinx.coroutines.flow.stateIn - -data class ShareouselViewModel( - val headline: Flow<String>, - val previewKeys: StateFlow<List<Any>>, - val actions: Flow<List<ActionChipViewModel>>, - val centerIndex: StateFlow<Int>, - val previewForKey: (key: Any) -> ShareouselImageViewModel, - val previewRowKey: (Any) -> Any -) - -data class ActionChipViewModel(val label: String, val icon: ComposeIcon?, val onClick: () -> Unit) - -data class ShareouselImageViewModel( - val bitmap: Flow<Bitmap?>, - val contentDescription: Flow<String>, - val isSelected: Flow<Boolean>, - val setSelected: (Boolean) -> Unit, -) - -suspend fun PayloadToggleInteractor.toShareouselViewModel( - imageLoader: ImageLoader, - actionFactory: ActionFactory, - scope: CoroutineScope, -): ShareouselViewModel { - return ShareouselViewModel( - headline = MutableStateFlow("Shareousel"), - previewKeys = previewKeys.stateIn(scope), - actions = - if (actionFactory is MutableActionFactory) { - actionFactory.customActionsFlow.map { actions -> - actions.map { it.toActionChipViewModel() } - } - } else { - flow { - emit(actionFactory.createCustomActions().map { it.toActionChipViewModel() }) - } - }, - centerIndex = targetPosition.stateIn(scope), - previewForKey = { key -> - val previewInteractor = previewInteractor(key) - ShareouselImageViewModel( - bitmap = previewInteractor.previewUri.map { uri -> uri?.let { imageLoader(uri) } }, - contentDescription = MutableStateFlow(""), - isSelected = previewInteractor.selected, - setSelected = { isSelected -> previewInteractor.setSelected(isSelected) }, - ) - }, - previewRowKey = { getKey(it) }, - ) -} - -private fun Action.toActionChipViewModel() = - ActionChipViewModel( - label?.toString() ?: "", - icon?.let { BitmapIcon(it.toBitmap()) }, - onClick = { onClicked.run() } - ) diff --git a/java/src/com/android/intentresolver/grid/ChooserGridAdapter.java b/java/src/com/android/intentresolver/grid/ChooserGridAdapter.java index 036b686b..ba76a4a0 100644 --- a/java/src/com/android/intentresolver/grid/ChooserGridAdapter.java +++ b/java/src/com/android/intentresolver/grid/ChooserGridAdapter.java @@ -40,7 +40,6 @@ import com.android.intentresolver.ChooserListAdapter; import com.android.intentresolver.FeatureFlags; import com.android.intentresolver.R; import com.android.intentresolver.ResolverListAdapter.ViewHolder; -import com.android.internal.annotations.VisibleForTesting; import com.google.android.collect.Lists; @@ -50,7 +49,6 @@ import com.google.android.collect.Lists; * row level by this adapter but not on the item level. Individual targets within the row are * handled by {@link ChooserListAdapter} */ -@VisibleForTesting public final class ChooserGridAdapter extends RecyclerView.Adapter<RecyclerView.ViewHolder> { /** diff --git a/java/src/com/android/intentresolver/inject/ActivityModelModule.kt b/java/src/com/android/intentresolver/inject/ActivityModelModule.kt new file mode 100644 index 00000000..ff2bb14b --- /dev/null +++ b/java/src/com/android/intentresolver/inject/ActivityModelModule.kt @@ -0,0 +1,127 @@ +/* + * Copyright (C) 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.intentresolver.inject + +import android.content.Intent +import android.net.Uri +import android.service.chooser.ChooserAction +import androidx.lifecycle.SavedStateHandle +import com.android.intentresolver.util.ownedByCurrentUser +import com.android.intentresolver.v2.data.model.ChooserRequest +import com.android.intentresolver.v2.ui.model.ActivityModel +import com.android.intentresolver.v2.ui.viewmodel.readChooserRequest +import com.android.intentresolver.v2.validation.Valid +import com.android.intentresolver.v2.validation.ValidationResult +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.android.components.ViewModelComponent +import dagger.hilt.android.scopes.ViewModelScoped +import javax.inject.Qualifier + +@Module +@InstallIn(ViewModelComponent::class) +object ActivityModelModule { + @Provides + fun provideActivityModel(savedStateHandle: SavedStateHandle): ActivityModel = + requireNotNull(savedStateHandle[ActivityModel.ACTIVITY_MODEL_KEY]) { + "ActivityModel missing in SavedStateHandle! (${ActivityModel.ACTIVITY_MODEL_KEY})" + } + + @Provides + @ChooserIntent + fun chooserIntent(activityModel: ActivityModel): Intent = activityModel.intent + + @Provides + @ViewModelScoped + fun provideInitialRequest( + activityModel: ActivityModel, + flags: ChooserServiceFlags, + ): ValidationResult<ChooserRequest> = readChooserRequest(activityModel, flags) + + @Provides + fun provideChooserRequest( + initialRequest: ValidationResult<ChooserRequest>, + ): ChooserRequest = + requireNotNull((initialRequest as? Valid)?.value) { + "initialRequest is Invalid, no chooser request available" + } + + @Provides + @TargetIntent + fun targetIntent(chooserReq: ValidationResult<ChooserRequest>): Intent = + requireNotNull((chooserReq as? Valid)?.value?.targetIntent) { "no target intent available" } + + @Provides + fun customActions(chooserReq: ValidationResult<ChooserRequest>): List<ChooserAction> = + requireNotNull((chooserReq as? Valid)?.value?.chooserActions) { + "no chooser actions available" + } + + @Provides + @ViewModelScoped + @ContentUris + fun selectedUris(chooserRequest: ValidationResult<ChooserRequest>): List<Uri> = + requireNotNull((chooserRequest as? Valid)?.value?.targetIntent?.contentUris?.toList()) { + "no selected uris available" + } + + @Provides + @FocusedItemIndex + fun focusedItemIndex(chooserReq: ValidationResult<ChooserRequest>): Int = + requireNotNull((chooserReq as? Valid)?.value?.focusedItemPosition) { + "no focused item position available" + } + + @Provides + @AdditionalContent + fun additionalContentUri(chooserReq: ValidationResult<ChooserRequest>): Uri = + requireNotNull((chooserReq as? Valid)?.value?.additionalContentUri) { + "no additional content uri available" + } +} + +@Qualifier +@MustBeDocumented +@Retention(AnnotationRetention.RUNTIME) +annotation class FocusedItemIndex + +@Qualifier +@MustBeDocumented +@Retention(AnnotationRetention.RUNTIME) +annotation class AdditionalContent + +@Qualifier @MustBeDocumented @Retention(AnnotationRetention.RUNTIME) annotation class ChooserIntent + +@Qualifier @MustBeDocumented @Retention(AnnotationRetention.RUNTIME) annotation class ContentUris + +@Qualifier @MustBeDocumented @Retention(AnnotationRetention.RUNTIME) annotation class TargetIntent + +private val Intent.contentUris: Sequence<Uri> + get() = sequence { + if (Intent.ACTION_SEND == action) { + getParcelableExtra(Intent.EXTRA_STREAM, Uri::class.java) + ?.takeIf { it.ownedByCurrentUser } + ?.let { yield(it) } + } else { + getParcelableArrayListExtra(Intent.EXTRA_STREAM, Uri::class.java)?.forEach { uri -> + if (uri.ownedByCurrentUser) { + yield(uri) + } + } + } + } diff --git a/java/src/com/android/intentresolver/inject/Qualifiers.kt b/java/src/com/android/intentresolver/inject/Qualifiers.kt index 157e8f76..f267328b 100644 --- a/java/src/com/android/intentresolver/inject/Qualifiers.kt +++ b/java/src/com/android/intentresolver/inject/Qualifiers.kt @@ -23,6 +23,11 @@ import javax.inject.Qualifier @Qualifier @MustBeDocumented @Retention(AnnotationRetention.RUNTIME) +annotation class ViewModelOwned + +@Qualifier +@MustBeDocumented +@Retention(AnnotationRetention.RUNTIME) annotation class ApplicationOwned @Qualifier diff --git a/java/src/com/android/intentresolver/inject/SystemServices.kt b/java/src/com/android/intentresolver/inject/SystemServices.kt index 32894d43..c09598e0 100644 --- a/java/src/com/android/intentresolver/inject/SystemServices.kt +++ b/java/src/com/android/intentresolver/inject/SystemServices.kt @@ -17,13 +17,19 @@ package com.android.intentresolver.inject import android.app.ActivityManager import android.app.admin.DevicePolicyManager +import android.app.prediction.AppPredictionManager import android.content.ClipboardManager +import android.content.ContentInterface +import android.content.ContentResolver import android.content.Context import android.content.pm.LauncherApps import android.content.pm.ShortcutManager import android.os.UserManager import android.view.WindowManager import androidx.core.content.getSystemService +import com.android.intentresolver.v2.data.repository.UserScopedService +import com.android.intentresolver.v2.data.repository.UserScopedServiceImpl +import dagger.Binds import dagger.Module import dagger.Provides import dagger.hilt.InstallIn @@ -52,9 +58,13 @@ class ClipboardManagerModule { @Module @InstallIn(SingletonComponent::class) -class ContentResolverModule { - @Provides - fun contentResolver(@ApplicationContext ctx: Context) = requireNotNull(ctx.contentResolver) +interface ContentResolverModule { + @Binds fun bindContentInterface(cr: ContentResolver): ContentInterface + + companion object { + @Provides + fun contentResolver(@ApplicationContext ctx: Context) = requireNotNull(ctx.contentResolver) + } } @Module @@ -81,10 +91,29 @@ class PackageManagerModule { @Module @InstallIn(SingletonComponent::class) +class PredictionManagerModule { + @Provides + fun scopedPredictionManager( + @ApplicationContext ctx: Context, + ): UserScopedService<AppPredictionManager> { + return UserScopedServiceImpl(ctx, AppPredictionManager::class) + } +} + +@Module +@InstallIn(SingletonComponent::class) class ShortcutManagerModule { @Provides - fun shortcutManager(@ApplicationContext ctx: Context): ShortcutManager = - ctx.requireSystemService() + fun shortcutManager(@ApplicationContext ctx: Context): ShortcutManager { + return ctx.requireSystemService() + } + + @Provides + fun scopedShortcutManager( + @ApplicationContext ctx: Context, + ): UserScopedService<ShortcutManager> { + return UserScopedServiceImpl(ctx, ShortcutManager::class) + } } @Module @@ -92,6 +121,11 @@ class ShortcutManagerModule { class UserManagerModule { @Provides fun userManager(@ApplicationContext ctx: Context): UserManager = ctx.requireSystemService() + + @Provides + fun scopedUserManager(@ApplicationContext ctx: Context): UserScopedService<UserManager> { + return UserScopedServiceImpl(ctx, UserManager::class) + } } @Module diff --git a/java/src/com/android/intentresolver/inject/ViewModelCoroutineScopeModule.kt b/java/src/com/android/intentresolver/inject/ViewModelCoroutineScopeModule.kt new file mode 100644 index 00000000..4dda2653 --- /dev/null +++ b/java/src/com/android/intentresolver/inject/ViewModelCoroutineScopeModule.kt @@ -0,0 +1,42 @@ +/* + * Copyright (C) 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.intentresolver.inject + +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.android.ViewModelLifecycle +import dagger.hilt.android.components.ViewModelComponent +import dagger.hilt.android.scopes.ViewModelScoped +import kotlin.coroutines.CoroutineContext +import kotlin.coroutines.EmptyCoroutineContext +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.cancel + +@Module +@InstallIn(ViewModelComponent::class) +object ViewModelCoroutineScopeModule { + @Provides + @ViewModelScoped + @ViewModelOwned + fun viewModelScope(@Main dispatcher: CoroutineDispatcher, lifecycle: ViewModelLifecycle) = + lifecycle.asCoroutineScope(dispatcher) +} + +fun ViewModelLifecycle.asCoroutineScope(context: CoroutineContext = EmptyCoroutineContext) = + CoroutineScope(context).also { addOnClearedListener { it.cancel() } } diff --git a/java/src/com/android/intentresolver/logging/EventLogImpl.java b/java/src/com/android/intentresolver/logging/EventLogImpl.java index 84029e76..39d23865 100644 --- a/java/src/com/android/intentresolver/logging/EventLogImpl.java +++ b/java/src/com/android/intentresolver/logging/EventLogImpl.java @@ -379,7 +379,9 @@ public class EventLogImpl implements EventLog { @UiEvent(doc = "Sharesheet app share ranking timed out.") SHARESHEET_APP_SHARE_RANKING_TIMEOUT(831), @UiEvent(doc = "Sharesheet empty direct share row.") - SHARESHEET_EMPTY_DIRECT_SHARE_ROW(828); + SHARESHEET_EMPTY_DIRECT_SHARE_ROW(828), + @UiEvent(doc = "Shareousel payload item toggled") + SHARESHEET_PAYLOAD_TOGGLED(1662); private final int mId; SharesheetStandardEvent(int id) { diff --git a/java/src/com/android/intentresolver/logging/EventLogModule.kt b/java/src/com/android/intentresolver/logging/EventLogModule.kt index eba8ecc8..73af7d37 100644 --- a/java/src/com/android/intentresolver/logging/EventLogModule.kt +++ b/java/src/com/android/intentresolver/logging/EventLogModule.kt @@ -24,14 +24,14 @@ import dagger.Binds import dagger.Module import dagger.Provides import dagger.hilt.InstallIn -import dagger.hilt.android.components.ActivityComponent -import dagger.hilt.android.scopes.ActivityScoped +import dagger.hilt.android.components.ActivityRetainedComponent +import dagger.hilt.android.scopes.ActivityRetainedScoped @Module -@InstallIn(ActivityComponent::class) +@InstallIn(ActivityRetainedComponent::class) interface EventLogModule { - @Binds @ActivityScoped fun eventLog(value: EventLogImpl): EventLog + @Binds @ActivityRetainedScoped fun eventLog(value: EventLogImpl): EventLog companion object { @Provides diff --git a/java/src/com/android/intentresolver/model/ResolverRankerServiceResolverComparator.java b/java/src/com/android/intentresolver/model/ResolverRankerServiceResolverComparator.java index f3804154..963091b5 100644 --- a/java/src/com/android/intentresolver/model/ResolverRankerServiceResolverComparator.java +++ b/java/src/com/android/intentresolver/model/ResolverRankerServiceResolverComparator.java @@ -28,6 +28,7 @@ import android.content.pm.PackageManager; import android.content.pm.PackageManager.NameNotFoundException; import android.content.pm.ResolveInfo; import android.metrics.LogMaker; +import android.os.Handler; import android.os.IBinder; import android.os.Message; import android.os.RemoteException; @@ -48,6 +49,7 @@ import com.android.internal.logging.nano.MetricsProto.MetricsEvent; import com.google.android.collect.Lists; +import java.lang.ref.WeakReference; import java.text.Collator; import java.util.ArrayList; import java.util.Comparator; @@ -392,20 +394,7 @@ public class ResolverRankerServiceResolverComparator extends AbstractResolverCom } public final IResolverRankerResult resolverRankerResult = - new IResolverRankerResult.Stub() { - @Override - public void sendResult(List<ResolverTarget> targets) throws RemoteException { - if (DEBUG) { - Log.d(TAG, "Sending Result back to Resolver: " + targets); - } - synchronized (mLock) { - final Message msg = Message.obtain(); - msg.what = RANKER_SERVICE_RESULT; - msg.obj = targets; - mHandler.sendMessage(msg); - } - } - }; + new ResolverRankerResultCallback(mLock, mHandler); @Override public void onServiceConnected(ComponentName name, IBinder service) { @@ -437,6 +426,32 @@ public class ResolverRankerServiceResolverComparator extends AbstractResolverCom } } + private static class ResolverRankerResultCallback extends IResolverRankerResult.Stub { + private final Object mLock; + private final WeakReference<Handler> mHandlerRef; + + private ResolverRankerResultCallback(Object lock, Handler handler) { + mLock = lock; + mHandlerRef = new WeakReference<>(handler); + } + + @Override + public void sendResult(List<ResolverTarget> targets) throws RemoteException { + if (DEBUG) { + Log.d(TAG, "Sending Result back to Resolver: " + targets); + } + synchronized (mLock) { + final Message msg = Message.obtain(); + msg.what = RANKER_SERVICE_RESULT; + msg.obj = targets; + Handler handler = mHandlerRef.get(); + if (handler != null) { + handler.sendMessage(msg); + } + } + } + } + @Override void beforeCompute() { super.beforeCompute(); diff --git a/java/src/com/android/intentresolver/shortcuts/AppPredictorFactory.kt b/java/src/com/android/intentresolver/shortcuts/AppPredictorFactory.kt index e544e064..c7bd0336 100644 --- a/java/src/com/android/intentresolver/shortcuts/AppPredictorFactory.kt +++ b/java/src/com/android/intentresolver/shortcuts/AppPredictorFactory.kt @@ -31,12 +31,13 @@ private const val SHARED_TEXT_KEY = "shared_text" /** * A factory to create an AppPredictor instance for a profile, if available. + * * @param context, application context - * @param sharedText, a shared text associated with the Chooser's target intent - * (see [android.content.Intent.EXTRA_TEXT]). - * Will be mapped to app predictor's "shared_text" parameter. - * @param targetIntentFilter, an IntentFilter to match direct share targets against. - * Will be mapped app predictor's "intent_filter" parameter. + * @param sharedText, a shared text associated with the Chooser's target intent (see + * [android.content.Intent.EXTRA_TEXT]). Will be mapped to app predictor's "shared_text" + * parameter. + * @param targetIntentFilter, an IntentFilter to match direct share targets against. Will be mapped + * app predictor's "intent_filter" parameter. */ class AppPredictorFactory( private val context: Context, @@ -50,16 +51,19 @@ class AppPredictorFactory( fun create(userHandle: UserHandle): AppPredictor? { if (!appPredictionAvailable) return null val contextAsUser = context.createContextAsUser(userHandle, 0 /* flags */) - val extras = Bundle().apply { - putParcelable(APP_PREDICTION_INTENT_FILTER_KEY, targetIntentFilter) - putString(SHARED_TEXT_KEY, sharedText) - } - val appPredictionContext = AppPredictionContext.Builder(contextAsUser) - .setUiSurface(APP_PREDICTION_SHARE_UI_SURFACE) - .setPredictedTargetCount(APP_PREDICTION_SHARE_TARGET_QUERY_PACKAGE_LIMIT) - .setExtras(extras) - .build() - return contextAsUser.getSystemService(AppPredictionManager::class.java) + val extras = + Bundle().apply { + putParcelable(APP_PREDICTION_INTENT_FILTER_KEY, targetIntentFilter) + putString(SHARED_TEXT_KEY, sharedText) + } + val appPredictionContext = + AppPredictionContext.Builder(contextAsUser) + .setUiSurface(APP_PREDICTION_SHARE_UI_SURFACE) + .setPredictedTargetCount(APP_PREDICTION_SHARE_TARGET_QUERY_PACKAGE_LIMIT) + .setExtras(extras) + .build() + return contextAsUser + .getSystemService(AppPredictionManager::class.java) ?.createAppPredictionSession(appPredictionContext) } } diff --git a/java/src/com/android/intentresolver/util/CancellationSignalUtils.kt b/java/src/com/android/intentresolver/util/CancellationSignalUtils.kt new file mode 100644 index 00000000..e89cb5ca --- /dev/null +++ b/java/src/com/android/intentresolver/util/CancellationSignalUtils.kt @@ -0,0 +1,41 @@ +/* + * Copyright (C) 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.intentresolver.util + +import android.os.CancellationSignal +import kotlinx.coroutines.CoroutineStart +import kotlinx.coroutines.awaitCancellation +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.launch + +/** + * Invokes [block] with a [CancellationSignal] that is bound to this coroutine's lifetime; if this + * coroutine is cancelled, then [CancellationSignal.cancel] is promptly invoked. + */ +suspend fun <R> withCancellationSignal(block: suspend (signal: CancellationSignal) -> R): R = + coroutineScope { + val signal = CancellationSignal() + val signalJob = + launch(start = CoroutineStart.UNDISPATCHED) { + try { + awaitCancellation() + } finally { + signal.cancel() + } + } + block(signal).also { signalJob.cancel() } + } diff --git a/java/src/com/android/intentresolver/util/Flow.kt b/java/src/com/android/intentresolver/util/Flow.kt index 1155b9fe..598379f3 100644 --- a/java/src/com/android/intentresolver/util/Flow.kt +++ b/java/src/com/android/intentresolver/util/Flow.kt @@ -31,7 +31,6 @@ import kotlinx.coroutines.launch * latest value is emitted. * * Example: - * * ```kotlin * flow { * emit(1) // t=0ms @@ -70,10 +69,11 @@ fun <T> Flow<T>.throttle(periodMs: Long): Flow<T> = channelFlow { // We create delayJob to allow cancellation during the delay period delayJob = launch { delay(timeUntilNextEmit) - sendJob = outerScope.launch(start = CoroutineStart.UNDISPATCHED) { - send(it) - previousEmitTimeMs = SystemClock.elapsedRealtime() - } + sendJob = + outerScope.launch(start = CoroutineStart.UNDISPATCHED) { + send(it) + previousEmitTimeMs = SystemClock.elapsedRealtime() + } } } else { send(it) diff --git a/java/src/com/android/intentresolver/util/ParallelIteration.kt b/java/src/com/android/intentresolver/util/ParallelIteration.kt new file mode 100644 index 00000000..70c46c47 --- /dev/null +++ b/java/src/com/android/intentresolver/util/ParallelIteration.kt @@ -0,0 +1,50 @@ +/* + * Copyright (C) 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.intentresolver.util + +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.sync.withPermit +import kotlinx.coroutines.yield + +/** Like [Iterable.map] but executes each [block] invocation in a separate coroutine. */ +suspend fun <A, B> Iterable<A>.mapParallel( + parallelism: Int? = null, + block: suspend (A) -> B, +): List<B> = + parallelism?.let { permits -> + withSemaphore(permits = permits) { mapParallel { withPermit { block(it) } } } + } + ?: mapParallel(block) + +/** Like [Iterable.map] but executes each [block] invocation in a separate coroutine. */ +suspend fun <A, B> Sequence<A>.mapParallel( + parallelism: Int? = null, + block: suspend (A) -> B, +): List<B> = asIterable().mapParallel(parallelism, block) + +private suspend fun <A, B> Iterable<A>.mapParallel(block: suspend (A) -> B): List<B> = + coroutineScope { + map { + async { + yield() + block(it) + } + } + .awaitAll() + } diff --git a/java/src/com/android/intentresolver/util/SyncUtils.kt b/java/src/com/android/intentresolver/util/SyncUtils.kt new file mode 100644 index 00000000..eaebc6ea --- /dev/null +++ b/java/src/com/android/intentresolver/util/SyncUtils.kt @@ -0,0 +1,33 @@ +/* + * Copyright (C) 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.intentresolver.util + +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.Semaphore + +/** + * Constructs a [Semaphore] for usage within [block], useful for launching a lot of work in parallel + * that needs some synchronization. + */ +inline fun <R> withSemaphore(permits: Int, block: Semaphore.() -> R): R = + Semaphore(permits).run(block) + +/** + * Constructs a [Mutex] for usage within [block], useful for launching a lot of work in parallel + * that needs some synchronization. + */ +inline fun <R> withMutex(block: Mutex.() -> R): R = Mutex().run(block) diff --git a/java/src/com/android/intentresolver/util/cursor/CursorView.kt b/java/src/com/android/intentresolver/util/cursor/CursorView.kt new file mode 100644 index 00000000..eca7d335 --- /dev/null +++ b/java/src/com/android/intentresolver/util/cursor/CursorView.kt @@ -0,0 +1,59 @@ +/* + * Copyright (C) 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.intentresolver.util.cursor + +import android.database.Cursor + +/** A [Cursor] that holds values of [E] for each row. */ +interface CursorView<out E> : Cursor { + /** + * Reads the current row from this [CursorView]. A result of `null` indicates that the row could + * not be read / value could not be produced. + */ + fun readRow(): E? +} + +/** + * Returns a [CursorView] from the given [Cursor], and a function [readRow] used to produce the + * value for a single row. + */ +fun <E> Cursor.viewBy(readRow: Cursor.() -> E): CursorView<E> = + object : CursorView<E>, Cursor by this@viewBy { + override fun readRow(): E? = immobilized().readRow() + } + +/** Returns a [CursorView] that begins (index 0) at [newStartIndex] of the given cursor. */ +fun <E> CursorView<E>.startAt(newStartIndex: Int): CursorView<E> = + object : CursorView<E>, Cursor by (this@startAt as Cursor).startAt(newStartIndex) { + override fun readRow(): E? = this@startAt.readRow() + } + +/** Returns a [CursorView] that is truncated to contain only [count] elements. */ +fun <E> CursorView<E>.limit(count: Int): CursorView<E> = + object : CursorView<E>, Cursor by (this@limit as Cursor).limit(count) { + override fun readRow(): E? = this@limit.readRow() + } + +/** Retrieves a single row at index [idx] from the [CursorView]. */ +operator fun <E> CursorView<E>.get(idx: Int): E? = if (moveToPosition(idx)) readRow() else null + +/** Returns a [Sequence] that iterates over the [CursorView] returning each row. */ +fun <E> CursorView<E>.asSequence(): Sequence<E?> = sequence { + for (i in 0 until count) { + yield(get(i)) + } +} diff --git a/java/src/com/android/intentresolver/util/cursor/Cursors.kt b/java/src/com/android/intentresolver/util/cursor/Cursors.kt new file mode 100644 index 00000000..ce768f3b --- /dev/null +++ b/java/src/com/android/intentresolver/util/cursor/Cursors.kt @@ -0,0 +1,87 @@ +/* + * Copyright (C) 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.intentresolver.util.cursor + +import android.database.Cursor +import android.database.CursorWrapper + +/** Returns a Cursor that is truncated to contain only [count] elements. */ +fun Cursor.limit(count: Int): Cursor = + object : CursorWrapper(this) { + override fun getCount(): Int = minOf(count, super.getCount()) + + override fun getPosition(): Int = super.getPosition().coerceAtMost(count) + + override fun moveToLast(): Boolean = super.moveToPosition(getCount() - 1) + + override fun isFirst(): Boolean = getCount() != 0 && super.isFirst() + + override fun isLast(): Boolean = getCount() != 0 && super.getPosition() == getCount() - 1 + + override fun isAfterLast(): Boolean = getCount() == 0 || super.getPosition() >= getCount() + + override fun isBeforeFirst(): Boolean = getCount() == 0 || super.isBeforeFirst() + + override fun moveToNext(): Boolean = super.moveToNext() && position < getCount() + + override fun moveToPosition(position: Int): Boolean = + super.moveToPosition(position) && position < getCount() + } + +/** Returns a Cursor that begins (index 0) at [newStartIndex] of the given Cursor. */ +fun Cursor.startAt(newStartIndex: Int): Cursor = + object : CursorWrapper(this) { + override fun getCount(): Int = (super.getCount() - newStartIndex).coerceAtLeast(0) + + override fun getPosition(): Int = (super.getPosition() - newStartIndex).coerceAtLeast(-1) + + override fun moveToFirst(): Boolean = super.moveToPosition(newStartIndex) + + override fun moveToNext(): Boolean = super.moveToNext() && position < count + + override fun moveToPrevious(): Boolean = super.moveToPrevious() && position >= 0 + + override fun moveToPosition(position: Int): Boolean = + super.moveToPosition(position + newStartIndex) && position >= 0 + + override fun isFirst(): Boolean = count != 0 && super.getPosition() == newStartIndex + + override fun isLast(): Boolean = count != 0 && super.isLast() + + override fun isBeforeFirst(): Boolean = count == 0 || super.getPosition() < newStartIndex + + override fun isAfterLast(): Boolean = count == 0 || super.isAfterLast() + } + +/** Returns a read-only non-movable view into the given Cursor. */ +fun Cursor.immobilized(): Cursor = + object : CursorWrapper(this) { + private val unsupported: Nothing + get() = error("unsupported") + + override fun moveToFirst(): Boolean = unsupported + + override fun moveToLast(): Boolean = unsupported + + override fun move(offset: Int): Boolean = unsupported + + override fun moveToPosition(position: Int): Boolean = unsupported + + override fun moveToNext(): Boolean = unsupported + + override fun moveToPrevious(): Boolean = unsupported + } diff --git a/java/src/com/android/intentresolver/util/cursor/PagedCursor.kt b/java/src/com/android/intentresolver/util/cursor/PagedCursor.kt new file mode 100644 index 00000000..6e4318dc --- /dev/null +++ b/java/src/com/android/intentresolver/util/cursor/PagedCursor.kt @@ -0,0 +1,52 @@ +/* + * Copyright (C) 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.intentresolver.util.cursor + +import android.database.Cursor + +/** A [CursorView] that produces chunks/pages from an underlying cursor. */ +interface PagedCursor<out E> : CursorView<Sequence<E?>> { + /** The configured size of each page produced by this cursor. */ + val pageSize: Int +} + +/** Returns a [PagedCursor] that produces pages of data from the given [CursorView]. */ +fun <E> CursorView<E>.paged(pageSize: Int): PagedCursor<E> = + object : PagedCursor<E>, Cursor by this@paged { + + init { + check(pageSize > 0) { "pageSize must be greater than 0" } + } + + override val pageSize: Int = pageSize + + override fun getCount(): Int = + this@paged.count.let { it / pageSize + minOf(1, it % pageSize) } + + override fun getPosition(): Int = + (this@paged.position / pageSize).let { if (this@paged.position < 0) it - 1 else it } + + override fun moveToNext(): Boolean = moveToPosition(position + 1) + + override fun moveToPrevious(): Boolean = moveToPosition(position - 1) + + override fun moveToPosition(position: Int): Boolean = + this@paged.moveToPosition(position * pageSize) + + override fun readRow(): Sequence<E?> = + this@paged.startAt(position * pageSize).limit(pageSize).asSequence() + } diff --git a/java/src/com/android/intentresolver/v2/ChooserActionFactory.java b/java/src/com/android/intentresolver/v2/ChooserActionFactory.java index 9077a18d..efd5bfd1 100644 --- a/java/src/com/android/intentresolver/v2/ChooserActionFactory.java +++ b/java/src/com/android/intentresolver/v2/ChooserActionFactory.java @@ -102,7 +102,6 @@ public final class ChooserActionFactory implements ChooserContentPreviewUi.Actio @Nullable private Runnable mCopyButtonRunnable; private Runnable mEditButtonRunnable; private final ImmutableList<ChooserAction> mCustomActions; - @Nullable private final ChooserAction mModifyShareAction; private final Consumer<Boolean> mExcludeSharedTextAction; @Nullable private final ShareResultSender mShareResultSender; private final Consumer</* @Nullable */ Integer> mFinishCallback; @@ -124,7 +123,6 @@ public final class ChooserActionFactory implements ChooserContentPreviewUi.Actio Intent targetIntent, String referrerPackageName, List<ChooserAction> chooserActions, - @Nullable ChooserAction modifyShareAction, Optional<ComponentName> imageEditor, EventLog log, Consumer<Boolean> onUpdateSharedTextIsExcluded, @@ -150,7 +148,6 @@ public final class ChooserActionFactory implements ChooserContentPreviewUi.Actio activityStarter, log), chooserActions, - modifyShareAction, onUpdateSharedTextIsExcluded, log, shareResultSender, @@ -164,7 +161,6 @@ public final class ChooserActionFactory implements ChooserContentPreviewUi.Actio @Nullable Runnable copyButtonRunnable, Runnable editButtonRunnable, List<ChooserAction> customActions, - @Nullable ChooserAction modifyShareAction, Consumer<Boolean> onUpdateSharedTextIsExcluded, EventLog log, @Nullable ShareResultSender shareResultSender, @@ -173,7 +169,6 @@ public final class ChooserActionFactory implements ChooserContentPreviewUi.Actio mCopyButtonRunnable = copyButtonRunnable; mEditButtonRunnable = editButtonRunnable; mCustomActions = ImmutableList.copyOf(customActions); - mModifyShareAction = modifyShareAction; mExcludeSharedTextAction = onUpdateSharedTextIsExcluded; mLog = log; mShareResultSender = shareResultSender; @@ -212,7 +207,11 @@ public final class ChooserActionFactory implements ChooserContentPreviewUi.Actio for (int i = 0; i < mCustomActions.size(); i++) { final int position = i; ActionRow.Action actionRow = createCustomAction( - mCustomActions.get(i), () -> logCustomAction(position)); + mContext, + mCustomActions.get(i), + () -> logCustomAction(position), + mShareResultSender, + mFinishCallback); if (actionRow != null) { actions.add(actionRow); } @@ -221,15 +220,6 @@ public final class ChooserActionFactory implements ChooserContentPreviewUi.Actio } /** - * Provides a share modification action, if any. - */ - @Override - @Nullable - public ActionRow.Action getModifyShareAction() { - return createCustomAction(mModifyShareAction, this::logModifyShareAction); - } - - /** * <p> * Creates an exclude-text action that can be called when the user changes shared text * status in the Media + Text preview. @@ -360,11 +350,16 @@ public final class ChooserActionFactory implements ChooserContentPreviewUi.Actio } @Nullable - ActionRow.Action createCustomAction(@Nullable ChooserAction action, Runnable loggingRunnable) { + static ActionRow.Action createCustomAction( + Context context, + @Nullable ChooserAction action, + Runnable loggingRunnable, + ShareResultSender shareResultSender, + Consumer</* @Nullable */ Integer> finishCallback) { if (action == null) { return null; } - Drawable icon = action.getIcon().loadDrawable(mContext); + Drawable icon = action.getIcon().loadDrawable(context); if (icon == null && TextUtils.isEmpty(action.getLabel())) { return null; } @@ -381,7 +376,7 @@ public final class ChooserActionFactory implements ChooserContentPreviewUi.Actio null, null, ActivityOptions.makeCustomAnimation( - mContext, + context, R.anim.slide_in_right, R.anim.slide_out_left) .toBundle()); @@ -391,10 +386,10 @@ public final class ChooserActionFactory implements ChooserContentPreviewUi.Actio if (loggingRunnable != null) { loggingRunnable.run(); } - if (mShareResultSender != null) { - mShareResultSender.onActionSelected(ShareAction.APPLICATION_DEFINED); + if (shareResultSender != null) { + shareResultSender.onActionSelected(ShareAction.APPLICATION_DEFINED); } - mFinishCallback.accept(Activity.RESULT_OK); + finishCallback.accept(Activity.RESULT_OK); } ); } @@ -402,8 +397,4 @@ public final class ChooserActionFactory implements ChooserContentPreviewUi.Actio void logCustomAction(int position) { mLog.logCustomActionSelected(position); } - - private void logModifyShareAction() { - mLog.logActionSelected(EventLog.SELECTION_TYPE_MODIFY_SHARE); - } } diff --git a/java/src/com/android/intentresolver/v2/ChooserActivity.java b/java/src/com/android/intentresolver/v2/ChooserActivity.java index 8387212a..d624c9e4 100644 --- a/java/src/com/android/intentresolver/v2/ChooserActivity.java +++ b/java/src/com/android/intentresolver/v2/ChooserActivity.java @@ -29,15 +29,13 @@ import static android.view.WindowManager.LayoutParams.SYSTEM_FLAG_HIDE_NON_SYSTE import static androidx.lifecycle.LifecycleKt.getCoroutineScope; -import static com.android.intentresolver.contentpreview.ContentPreviewType.CONTENT_PREVIEW_PAYLOAD_SELECTION; import static com.android.intentresolver.v2.ext.CreationExtrasExtKt.addDefaultArgs; +import static com.android.intentresolver.v2.profiles.MultiProfilePagerAdapter.PROFILE_PERSONAL; +import static com.android.intentresolver.v2.profiles.MultiProfilePagerAdapter.PROFILE_WORK; import static com.android.intentresolver.v2.ui.model.ActivityModel.ACTIVITY_MODEL_KEY; -import static com.android.internal.annotations.VisibleForTesting.Visibility.PROTECTED; import static com.android.internal.util.LatencyTracker.ACTION_LOAD_SHARE_SHEET; -import static java.util.Collections.emptyList; import static java.util.Objects.requireNonNull; -import static java.util.Objects.requireNonNullElse; import android.app.ActivityManager; import android.app.ActivityOptions; @@ -57,22 +55,18 @@ import android.content.IntentFilter; import android.content.IntentSender; import android.content.SharedPreferences; import android.content.pm.ActivityInfo; -import android.content.pm.ApplicationInfo; import android.content.pm.PackageManager; import android.content.pm.ResolveInfo; import android.content.pm.ShortcutInfo; -import android.content.pm.UserInfo; import android.content.res.Configuration; import android.database.Cursor; import android.graphics.Insets; import android.net.Uri; -import android.os.Build; import android.os.Bundle; import android.os.StrictMode; import android.os.SystemClock; import android.os.Trace; import android.os.UserHandle; -import android.os.UserManager; import android.service.chooser.ChooserTarget; import android.stats.devicepolicy.DevicePolicyEnums; import android.text.TextUtils; @@ -103,7 +97,6 @@ import androidx.recyclerview.widget.GridLayoutManager; import androidx.recyclerview.widget.RecyclerView; import androidx.viewpager.widget.ViewPager; -import com.android.intentresolver.AnnotatedUserHandles; import com.android.intentresolver.ChooserGridLayoutManager; import com.android.intentresolver.ChooserListAdapter; import com.android.intentresolver.ChooserRefinementManager; @@ -118,14 +111,12 @@ import com.android.intentresolver.ResolverListAdapter; import com.android.intentresolver.ResolverListController; import com.android.intentresolver.ResolverViewPager; import com.android.intentresolver.StartsSelectedItem; -import com.android.intentresolver.WorkProfileAvailabilityManager; import com.android.intentresolver.chooser.DisplayResolveInfo; import com.android.intentresolver.chooser.MultiDisplayResolveInfo; import com.android.intentresolver.chooser.TargetInfo; import com.android.intentresolver.contentpreview.BasePreviewViewModel; import com.android.intentresolver.contentpreview.ChooserContentPreviewUi; import com.android.intentresolver.contentpreview.HeadlineGeneratorImpl; -import com.android.intentresolver.contentpreview.PayloadToggleInteractor; import com.android.intentresolver.contentpreview.PreviewViewModel; import com.android.intentresolver.emptystate.CompositeEmptyStateProvider; import com.android.intentresolver.emptystate.CrossProfileIntentsChecker; @@ -140,7 +131,9 @@ import com.android.intentresolver.model.AppPredictionServiceResolverComparator; import com.android.intentresolver.model.ResolverRankerServiceResolverComparator; import com.android.intentresolver.shortcuts.AppPredictorFactory; import com.android.intentresolver.shortcuts.ShortcutLoader; +import com.android.intentresolver.v2.data.model.ChooserRequest; import com.android.intentresolver.v2.data.repository.DevicePolicyResources; +import com.android.intentresolver.v2.domain.interactor.UserInteractor; import com.android.intentresolver.v2.emptystate.NoAppsAvailableEmptyStateProvider; import com.android.intentresolver.v2.emptystate.NoCrossProfileEmptyStateProvider; import com.android.intentresolver.v2.emptystate.NoCrossProfileEmptyStateProvider.DevicePolicyBlockerEmptyState; @@ -149,17 +142,18 @@ import com.android.intentresolver.v2.platform.AppPredictionAvailable; import com.android.intentresolver.v2.platform.ImageEditor; import com.android.intentresolver.v2.platform.NearbyShare; import com.android.intentresolver.v2.profiles.ChooserMultiProfilePagerAdapter; -import com.android.intentresolver.v2.profiles.MultiProfilePagerAdapter; import com.android.intentresolver.v2.profiles.MultiProfilePagerAdapter.ProfileType; import com.android.intentresolver.v2.profiles.OnProfileSelectedListener; import com.android.intentresolver.v2.profiles.OnSwitchOnWorkSelectedListener; import com.android.intentresolver.v2.profiles.TabConfig; +import com.android.intentresolver.v2.shared.model.Profile; import com.android.intentresolver.v2.ui.ActionTitle; +import com.android.intentresolver.v2.ui.ProfilePagerResources; import com.android.intentresolver.v2.ui.ShareResultSender; import com.android.intentresolver.v2.ui.ShareResultSenderFactory; import com.android.intentresolver.v2.ui.model.ActivityModel; -import com.android.intentresolver.v2.ui.model.ChooserRequest; import com.android.intentresolver.v2.ui.viewmodel.ChooserViewModel; +import com.android.intentresolver.widget.ActionRow; import com.android.intentresolver.widget.ImagePreviewView; import com.android.intentresolver.widget.ResolverDrawerLayout; import com.android.internal.annotations.VisibleForTesting; @@ -173,7 +167,6 @@ import com.google.common.collect.ImmutableList; import dagger.hilt.android.AndroidEntryPoint; import kotlin.Pair; -import kotlin.Unit; import java.util.ArrayList; import java.util.Arrays; @@ -188,6 +181,7 @@ import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; import java.util.concurrent.atomic.AtomicLong; import java.util.function.Consumer; +import java.util.function.Supplier; import javax.inject.Inject; @@ -213,7 +207,6 @@ public class ChooserActivity extends Hilt_ChooserActivity implements /** * Transition name for the first image preview. * To be used for shared element transition into this activity. - * @hide */ public static final String FIRST_IMAGE_PREVIEW_TRANSITION_NAME = "screenshot_preview_image"; @@ -233,14 +226,11 @@ public class ChooserActivity extends Hilt_ChooserActivity implements private int mLayoutId; private UserHandle mHeaderCreatorUser; - protected static final int PROFILE_PERSONAL = MultiProfilePagerAdapter.PROFILE_PERSONAL; - protected static final int PROFILE_WORK = MultiProfilePagerAdapter.PROFILE_WORK; private boolean mRegistered; private PackageMonitor mPersonalPackageMonitor; private PackageMonitor mWorkPackageMonitor; protected View mProfileView; - protected ActivityLogic mLogic; protected ResolverDrawerLayout mResolverDrawerLayout; protected ChooserMultiProfilePagerAdapter mChooserMultiProfilePagerAdapter; protected final LatencyTracker mLatencyTracker = getLatencyTracker(); @@ -274,6 +264,7 @@ public class ChooserActivity extends Hilt_ChooserActivity implements private static final int SCROLL_STATUS_SCROLLING_VERTICAL = 1; private static final int SCROLL_STATUS_SCROLLING_HORIZONTAL = 2; + @Inject public UserInteractor mUserInteractor; @Inject public ChooserHelper mChooserHelper; @Inject public FeatureFlags mFeatureFlags; @Inject public android.service.chooser.FeatureFlags mChooserServiceFeatureFlags; @@ -283,12 +274,17 @@ public class ChooserActivity extends Hilt_ChooserActivity implements @Inject @NearbyShare public Optional<ComponentName> mNearbyShare; @Inject public TargetDataLoader mTargetDataLoader; @Inject public DevicePolicyResources mDevicePolicyResources; + @Inject public ProfilePagerResources mProfilePagerResources; @Inject public PackageManager mPackageManager; @Inject public ClipboardManager mClipboardManager; @Inject public IntentForwarding mIntentForwarding; @Inject public ShareResultSenderFactory mShareResultSenderFactory; - @Nullable - private ShareResultSender mShareResultSender; + + private ActivityModel mActivityModel; + private ChooserRequest mRequest; + private ProfileHelper mProfiles; + private ProfileAvailability mProfileAvailability; + @Nullable private ShareResultSender mShareResultSender; private ChooserRefinementManager mRefinementManager; @@ -339,15 +335,6 @@ public class ChooserActivity extends Hilt_ChooserActivity implements } private ChooserViewModel mViewModel; - private ActivityModel mActivityModel; - - @VisibleForTesting - protected ChooserActivityLogic createActivityLogic() { - return new ChooserActivityLogic( - TAG, - /* activity = */ this, - this::onWorkProfileStatusUpdated); - } @NonNull @Override @@ -358,45 +345,23 @@ public class ChooserActivity extends Hilt_ChooserActivity implements } @Override - protected final void onCreate(Bundle savedInstanceState) { + protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); Log.i(TAG, "onCreate"); - mViewModel = new ViewModelProvider(this).get(ChooserViewModel.class); - mActivityModel = mViewModel.getActivityModel(); - - int callerUid = mActivityModel.getLaunchedFromUid(); - if (callerUid < 0 || UserHandle.isIsolated(callerUid)) { - Log.e(TAG, "Can't start a resolver from uid " + callerUid); - finish(); - } setTheme(R.style.Theme_DeviceDefault_Chooser); - Tracer.INSTANCE.markLaunched(); - if (!mViewModel.init()) { - finish(); - return; - } - // The post-create callback is invoked when this function returns, via Lifecycle. - mChooserHelper.setPostCreateCallback(this::init); - - IntentSender chosenComponentSender = - mViewModel.getChooserRequest().getChosenComponentSender(); - if (chosenComponentSender != null) { - mShareResultSender = mShareResultSenderFactory - .create(mActivityModel.getLaunchedFromUid(), chosenComponentSender); + // Initializer is invoked when this function returns, via Lifecycle. + mChooserHelper.setInitializer(this::initializeWith); + if (mChooserServiceFeatureFlags.chooserPayloadToggling()) { + mChooserHelper.setOnChooserRequestChanged(this::onChooserRequestChanged); } - mLogic = createActivityLogic(); } @Override protected final void onStart() { super.onStart(); - this.getWindow().addSystemFlags(SYSTEM_FLAG_HIDE_NON_SYSTEM_OVERLAY_WINDOWS); - if (hasWorkProfile()) { - mLogic.getWorkProfileAvailabilityManager().registerWorkProfileStateReceiver(this); - } } @Override @@ -437,7 +402,6 @@ public class ChooserActivity extends Hilt_ChooserActivity implements finish(); } } - mLogic.getWorkProfileAvailabilityManager().unregisterWorkProfileStateReceiver(this); if (mRefinementManager != null) { mRefinementManager.onActivityStop(isChangingConfigurations()); @@ -465,9 +429,9 @@ public class ChooserActivity extends Hilt_ChooserActivity implements mPersonalPackageMonitor.register( this, getMainLooper(), - requireAnnotatedUserHandles().personalProfileUserHandle, + mProfiles.getPersonalHandle(), false); - if (hasWorkProfile()) { + if (mProfiles.getWorkProfilePresent()) { if (mWorkPackageMonitor == null) { mWorkPackageMonitor = createPackageMonitor( mChooserMultiProfilePagerAdapter.getWorkListAdapter()); @@ -475,24 +439,23 @@ public class ChooserActivity extends Hilt_ChooserActivity implements mWorkPackageMonitor.register( this, getMainLooper(), - requireAnnotatedUserHandles().workProfileUserHandle, + mProfiles.getWorkHandle(), false); } mRegistered = true; } - WorkProfileAvailabilityManager workProfileAvailabilityManager = - mLogic.getWorkProfileAvailabilityManager(); - if (hasWorkProfile() && workProfileAvailabilityManager.isWaitingToEnableWorkProfile()) { - if (workProfileAvailabilityManager.isQuietModeEnabled()) { - workProfileAvailabilityManager.markWorkProfileEnabledBroadcastReceived(); - } - } mChooserMultiProfilePagerAdapter.getActiveListAdapter().handlePackagesChanged(); } @Override - protected final void onDestroy() { + protected void onDestroy() { super.onDestroy(); + if (!isChangingConfigurations() && mPickOptionRequest != null) { + mPickOptionRequest.cancel(); + } + if (mChooserMultiProfilePagerAdapter != null) { + mChooserMultiProfilePagerAdapter.destroy(); + } if (isFinishing()) { mLatencyTracker.onActionCancel(ACTION_LOAD_SHARE_SHEET); @@ -503,52 +466,76 @@ public class ChooserActivity extends Hilt_ChooserActivity implements destroyProfileRecords(); } - private void init() { + /** DO NOT CALL. Only for use from ChooserHelper as a callback. */ + private void initializeWith(InitialState initialState) { + Log.d(TAG, "initializeWith: " + initialState); + + mViewModel = new ViewModelProvider(this).get(ChooserViewModel.class); + mRequest = mViewModel.getRequest().getValue(); + mActivityModel = mViewModel.getActivityModel(); + + mProfiles = new ProfileHelper( + mUserInteractor, + mFeatureFlags, + initialState.getProfiles(), + initialState.getLaunchedAs()); + + mProfileAvailability = new ProfileAvailability( + getCoroutineScope(getLifecycle()), + mUserInteractor, + initialState.getAvailability()); + + mProfileAvailability.setOnProfileStatusChange(this::onWorkProfileStatusUpdated); + mIntentReceivedTime.set(System.currentTimeMillis()); mLatencyTracker.onActionStart(ACTION_LOAD_SHARE_SHEET); mPinnedSharedPrefs = getPinnedSharedPrefs(this); + updateShareResultSender(); + mMaxTargetsPerRow = getResources().getInteger(R.integer.config_chooser_max_targets_per_row); mShouldDisplayLandscape = shouldDisplayLandscape(getResources().getConfiguration().orientation); - ChooserRequest chooserRequest = mViewModel.getChooserRequest(); - setRetainInOnStop(chooserRequest.shouldRetainInOnStop()); + setRetainInOnStop(mRequest.shouldRetainInOnStop()); createProfileRecords( new AppPredictorFactory( this, - Objects.toString(chooserRequest.getSharedText(), null), - chooserRequest.getShareTargetFilter(), + Objects.toString(mRequest.getSharedText(), null), + mRequest.getShareTargetFilter(), mAppPredictionAvailable ), - chooserRequest.getShareTargetFilter() + mRequest.getShareTargetFilter() ); - Intent intent = mViewModel.getChooserRequest().getTargetIntent(); - List<Intent> initialIntents = mViewModel.getChooserRequest().getInitialIntents(); mChooserMultiProfilePagerAdapter = createMultiProfilePagerAdapter( - requireNonNullElse(initialIntents, emptyList()).toArray(new Intent[0]), - /* resolutionList = */ null, - false - ); + /* context = */ this, + mProfilePagerResources, + mRequest, + mProfiles, + mProfileAvailability, + mRequest.getInitialIntents(), + mMaxTargetsPerRow, + mFeatureFlags); + if (!configureContentView(mTargetDataLoader)) { mPersonalPackageMonitor = createPackageMonitor( mChooserMultiProfilePagerAdapter.getPersonalListAdapter()); mPersonalPackageMonitor.register( this, getMainLooper(), - requireAnnotatedUserHandles().personalProfileUserHandle, + mProfiles.getPersonalHandle(), false ); - if (hasWorkProfile()) { + if (mProfiles.getWorkProfilePresent()) { mWorkPackageMonitor = createPackageMonitor( mChooserMultiProfilePagerAdapter.getWorkListAdapter()); mWorkPackageMonitor.register( this, getMainLooper(), - requireAnnotatedUserHandles().workProfileUserHandle, + mProfiles.getWorkHandle(), false ); } @@ -576,6 +563,8 @@ public class ChooserActivity extends Hilt_ChooserActivity implements mResolverDrawerLayout = rdl; } + + Intent intent = mRequest.getTargetIntent(); final Set<String> categories = intent.getCategories(); MetricsLogger.action(this, mChooserMultiProfilePagerAdapter.getActiveListAdapter().hasFilteredItem() @@ -616,38 +605,20 @@ public class ChooserActivity extends Hilt_ChooserActivity implements new ViewModelProvider(this, createPreviewViewModelFactory()) .get(BasePreviewViewModel.class); previewViewModel.init( - chooserRequest.getTargetIntent(), - mActivityModel.getIntent(), - chooserRequest.getAdditionalContentUri(), - chooserRequest.getFocusedItemPosition(), + mRequest.getTargetIntent(), + mRequest.getAdditionalContentUri(), mChooserServiceFeatureFlags.chooserPayloadToggling()); - ChooserActionFactory chooserActionFactory = createChooserActionFactory(); - ChooserContentPreviewUi.ActionFactory actionFactory = chooserActionFactory; - if (previewViewModel.getPreviewDataProvider().getPreviewType() - == CONTENT_PREVIEW_PAYLOAD_SELECTION - && mChooserServiceFeatureFlags.chooserPayloadToggling()) { - PayloadToggleInteractor payloadToggleInteractor = - previewViewModel.getPayloadToggleInteractor(); - if (payloadToggleInteractor != null) { - ChooserMutableActionFactory mutableActionFactory = - new ChooserMutableActionFactory(chooserActionFactory); - actionFactory = mutableActionFactory; - JavaFlowHelper.collect( - getCoroutineScope(getLifecycle()), - payloadToggleInteractor.getCustomActions(), - mutableActionFactory::updateCustomActions); - } - } mChooserContentPreviewUi = new ChooserContentPreviewUi( getCoroutineScope(getLifecycle()), previewViewModel.getPreviewDataProvider(), - chooserRequest.getTargetIntent(), + mRequest.getTargetIntent(), previewViewModel.getImageLoader(), - actionFactory, + createChooserActionFactory(), + createModifyShareActionFactory(), mEnterTransitionAnimationDelegate, new HeadlineGeneratorImpl(this), - chooserRequest.getContentTypeHint(), - chooserRequest.getMetadataText(), + mRequest.getContentTypeHint(), + mRequest.getMetadataText(), mChooserServiceFeatureFlags.chooserPayloadToggling()); updateStickyContentPreview(); if (shouldShowStickyContentPreview() @@ -659,7 +630,7 @@ public class ChooserActivity extends Hilt_ChooserActivity implements mChooserShownTime = System.currentTimeMillis(); final long systemCost = mChooserShownTime - mIntentReceivedTime.get(); getEventLog().logChooserActivityShown( - isWorkProfile(), chooserRequest.getTargetType(), systemCost); + isWorkProfile(), mRequest.getTargetType(), systemCost); if (mResolverDrawerLayout != null) { mResolverDrawerLayout.addOnLayoutChangeListener(this::handleLayoutChange); @@ -673,29 +644,119 @@ public class ChooserActivity extends Hilt_ChooserActivity implements Log.d(TAG, "System Time Cost is " + systemCost); } getEventLog().logShareStarted( - chooserRequest.getReferrerPackage(), - chooserRequest.getTargetType(), - chooserRequest.getCallerChooserTargets().size(), - chooserRequest.getInitialIntents().size(), + mRequest.getReferrerPackage(), + mRequest.getTargetType(), + mRequest.getCallerChooserTargets().size(), + mRequest.getInitialIntents().size(), isWorkProfile(), mChooserContentPreviewUi.getPreferredContentPreview(), - chooserRequest.getTargetAction(), - chooserRequest.getChooserActions().size(), - chooserRequest.getModifyShareAction() != null + mRequest.getTargetAction(), + mRequest.getChooserActions().size(), + mRequest.getModifyShareAction() != null ); mEnterTransitionAnimationDelegate.postponeTransition(); + Tracer.INSTANCE.markLaunched(); + } + + private void onChooserRequestChanged(ChooserRequest chooserRequest) { + // intentional reference comarison + if (mRequest == chooserRequest) { + return; + } + boolean recreateAdapters = shouldUpdateAdapters(mRequest, chooserRequest); + mRequest = chooserRequest; + updateShareResultSender(); + mChooserContentPreviewUi.updateModifyShareAction(); + if (recreateAdapters) { + recreatePagerAdapter(); + } } - private void restore(@Nullable Bundle savedInstanceState) { - if (savedInstanceState != null) { - // onRestoreInstanceState - //resetButtonBar(); - ViewPager viewPager = findViewById(com.android.internal.R.id.profile_pager); - if (viewPager != null) { - viewPager.setCurrentItem(savedInstanceState.getInt(LAST_SHOWN_TAB_KEY)); + private void updateShareResultSender() { + IntentSender chosenComponentSender = mRequest.getChosenComponentSender(); + if (chosenComponentSender != null) { + mShareResultSender = mShareResultSenderFactory.create( + mViewModel.getActivityModel().getLaunchedFromUid(), chosenComponentSender); + } else { + mShareResultSender = null; + } + } + + private boolean shouldUpdateAdapters( + ChooserRequest oldChooserRequest, ChooserRequest newChooserRequest) { + Intent oldTargetIntent = oldChooserRequest.getTargetIntent(); + Intent newTargetIntent = newChooserRequest.getTargetIntent(); + List<Intent> oldAltIntents = oldChooserRequest.getAdditionalTargets(); + List<Intent> newAltIntents = newChooserRequest.getAdditionalTargets(); + + // TODO: a workaround for the unnecessary target reloading caused by multiple flow updates - + // an artifact of the current implementation; revisit. + return !oldTargetIntent.equals(newTargetIntent) || !oldAltIntents.equals(newAltIntents); + } + + private void recreatePagerAdapter() { + if (!mChooserServiceFeatureFlags.chooserPayloadToggling()) { + return; + } + destroyProfileRecords(); + createProfileRecords( + new AppPredictorFactory( + this, + Objects.toString(mRequest.getSharedText(), null), + mRequest.getShareTargetFilter(), + mAppPredictionAvailable + ), + mRequest.getShareTargetFilter() + ); + + if (mChooserMultiProfilePagerAdapter != null) { + mChooserMultiProfilePagerAdapter.destroy(); + } + mChooserMultiProfilePagerAdapter = createMultiProfilePagerAdapter( + /* context = */ this, + mProfilePagerResources, + mRequest, + mProfiles, + mProfileAvailability, + mRequest.getInitialIntents(), + mMaxTargetsPerRow, + mFeatureFlags); + mChooserMultiProfilePagerAdapter.setupViewPager( + requireViewById(com.android.internal.R.id.profile_pager)); + if (mPersonalPackageMonitor != null) { + mPersonalPackageMonitor.unregister(); + } + mPersonalPackageMonitor = createPackageMonitor( + mChooserMultiProfilePagerAdapter.getPersonalListAdapter()); + mPersonalPackageMonitor.register( + this, + getMainLooper(), + mProfiles.getPersonalHandle(), + false); + if (mProfiles.getWorkProfilePresent()) { + if (mWorkPackageMonitor != null) { + mWorkPackageMonitor.unregister(); } + mWorkPackageMonitor = createPackageMonitor( + mChooserMultiProfilePagerAdapter.getWorkListAdapter()); + mWorkPackageMonitor.register( + this, + getMainLooper(), + mProfiles.getWorkHandle(), + false); } + postRebuildList( + mChooserMultiProfilePagerAdapter.rebuildTabs( + mProfiles.getWorkProfilePresent() + || mProfiles.getPrivateProfilePresent())); + } + @Override + protected void onRestoreInstanceState(@NonNull Bundle savedInstanceState) { + ViewPager viewPager = findViewById(com.android.internal.R.id.profile_pager); + if (viewPager != null) { + viewPager.setCurrentItem(savedInstanceState.getInt(LAST_SHOWN_TAB_KEY)); + } mChooserMultiProfilePagerAdapter.clearInactiveProfileCache(); } @@ -728,7 +789,6 @@ public class ChooserActivity extends Hilt_ChooserActivity implements return false; } - private boolean isTwoPagePersonalAndWorkConfiguration() { return (mChooserMultiProfilePagerAdapter.getCount() == 2) && mChooserMultiProfilePagerAdapter.hasPageForProfile(PROFILE_PERSONAL) @@ -786,7 +846,7 @@ public class ChooserActivity extends Hilt_ChooserActivity implements DevicePolicyEventLogger .createEvent(DevicePolicyEnums.RESOLVER_AUTOLAUNCH_CROSS_PROFILE_TARGET) .setBoolean(activeListAdapter.getUserHandle() - .equals(requireAnnotatedUserHandles().personalProfileUserHandle)) + .equals(mProfiles.getPersonalHandle())) .setStrings(getMetricsCategory()) .write(); safelyStartActivity(activeProfileTarget); @@ -870,7 +930,7 @@ public class ChooserActivity extends Hilt_ChooserActivity implements && !listAdapter.getUserHandle().equals(mHeaderCreatorUser)) { return; } - if (!hasWorkProfile() + if (!mProfiles.getWorkProfilePresent() && listAdapter.getCount() == 0 && listAdapter.getPlaceholderCount() == 0) { final TextView titleView = findViewById(com.android.internal.R.id.title); if (titleView != null) { @@ -878,10 +938,10 @@ public class ChooserActivity extends Hilt_ChooserActivity implements } } - CharSequence title = mViewModel.getChooserRequest().getTitle() != null - ? mViewModel.getChooserRequest().getTitle() - : getTitleForAction(mViewModel.getChooserRequest().getTargetIntent(), - mViewModel.getChooserRequest().getDefaultTitleResource()); + CharSequence title = mRequest.getTitle() != null + ? mRequest.getTitle() + : getTitleForAction(mRequest.getTargetIntent(), + mRequest.getDefaultTitleResource()); if (!TextUtils.isEmpty(title)) { final TextView titleView = findViewById(com.android.internal.R.id.title); @@ -936,7 +996,7 @@ public class ChooserActivity extends Hilt_ChooserActivity implements // If needed, show that intent is forwarded // from managed profile to owner or other way around. String profileSwitchMessage = mIntentForwarding.forwardMessageFor( - mViewModel.getChooserRequest().getTargetIntent()); + mRequest.getTargetIntent()); if (profileSwitchMessage != null) { Toast.makeText(this, profileSwitchMessage, Toast.LENGTH_LONG).show(); } @@ -954,22 +1014,17 @@ public class ChooserActivity extends Hilt_ChooserActivity implements } private void maybeLogCrossProfileTargetLaunch(TargetInfo cti, UserHandle currentUserHandle) { - if (!hasWorkProfile() || currentUserHandle.equals(getUser())) { + if (!mProfiles.getWorkProfilePresent() || currentUserHandle.equals(getUser())) { return; } DevicePolicyEventLogger .createEvent(DevicePolicyEnums.RESOLVER_CROSS_PROFILE_TARGET_OPENED) - .setBoolean( - currentUserHandle.equals( - requireAnnotatedUserHandles().personalProfileUserHandle)) + .setBoolean(currentUserHandle.equals(mProfiles.getPersonalHandle())) .setStrings(getMetricsCategory(), cti.isInDirectShareMetricsCategory() ? "direct_share" : "other_target") .write(); } - private boolean hasWorkProfile() { - return requireAnnotatedUserHandles().workProfileUserHandle != null; - } private LatencyTracker getLatencyTracker() { return LatencyTracker.getInstance(this); } @@ -989,13 +1044,16 @@ public class ChooserActivity extends Hilt_ChooserActivity implements } protected final EmptyStateProvider createEmptyStateProvider( - @Nullable UserHandle workProfileUserHandle) { - final EmptyStateProvider blockerEmptyStateProvider = createBlockerEmptyStateProvider(); + ProfileHelper profileHelper, + ProfileAvailability profileAvailability) { + EmptyStateProvider blockerEmptyStateProvider = createBlockerEmptyStateProvider(); - final EmptyStateProvider workProfileOffEmptyStateProvider = - new WorkProfilePausedEmptyStateProvider(this, workProfileUserHandle, - mLogic.getWorkProfileAvailabilityManager(), - /* onSwitchOnWorkSelectedListener= */ + EmptyStateProvider workProfileOffEmptyStateProvider = + new WorkProfilePausedEmptyStateProvider( + this, + profileHelper, + profileAvailability, + /* onSwitchOnWorkSelectedListener = */ () -> { if (mOnSwitchOnWorkSelectedListener != null) { mOnSwitchOnWorkSelectedListener.onSwitchOnWorkSelected(); @@ -1003,12 +1061,12 @@ public class ChooserActivity extends Hilt_ChooserActivity implements }, getMetricsCategory()); - final EmptyStateProvider noAppsEmptyStateProvider = new NoAppsAvailableEmptyStateProvider( + EmptyStateProvider noAppsEmptyStateProvider = new NoAppsAvailableEmptyStateProvider( this, - workProfileUserHandle, - requireAnnotatedUserHandles().personalProfileUserHandle, + profileHelper.getWorkHandle(), + profileHelper.getPersonalHandle(), getMetricsCategory(), - requireAnnotatedUserHandles().tabOwnerUserHandleForLaunch + profileHelper.getTabOwnerUserHandleForLaunch() ); // Return composite provider, the order matters (the higher, the more priority) @@ -1019,74 +1077,23 @@ public class ChooserActivity extends Hilt_ChooserActivity implements ); } - private boolean supportsManagedProfiles(ResolveInfo resolveInfo) { - try { - ApplicationInfo appInfo = mPackageManager.getApplicationInfo( - resolveInfo.activityInfo.packageName, 0 /* default flags */); - return appInfo.targetSdkVersion >= Build.VERSION_CODES.LOLLIPOP; - } catch (PackageManager.NameNotFoundException e) { - return false; - } - } - - private boolean hasManagedProfile() { - UserManager userManager = (UserManager) getSystemService(Context.USER_SERVICE); - if (userManager == null) { - return false; - } - - try { - List<UserInfo> profiles = userManager.getProfiles(getUserId()); - for (UserInfo userInfo : profiles) { - if (userInfo != null && userInfo.isManagedProfile()) { - return true; - } - } - } catch (SecurityException e) { - return false; - } - return false; - } - - /** - * Returns the {@link UserHandle} to use when querying resolutions for intents in a - * {@link ResolverListController} configured for the provided {@code userHandle}. - */ - protected final UserHandle getQueryIntentsUser(UserHandle userHandle) { - return requireAnnotatedUserHandles().getQueryIntentsUser(userHandle); - } - - protected final boolean isLaunchedAsCloneProfile() { - UserHandle launchUser = requireAnnotatedUserHandles().userHandleSharesheetLaunchedAs; - UserHandle cloneUser = requireAnnotatedUserHandles().cloneProfileUserHandle; - return hasCloneProfile() && launchUser.equals(cloneUser); - } - - private boolean hasCloneProfile() { - return requireAnnotatedUserHandles().cloneProfileUserHandle != null; - } - /** * Returns the {@link List} of {@link UserHandle} to pass on to the * {@link ResolverRankerServiceResolverComparator} as per the provided {@code userHandle}. */ - @VisibleForTesting(visibility = PROTECTED) - public final List<UserHandle> getResolverRankerServiceUserHandleList(UserHandle userHandle) { + private List<UserHandle> getResolverRankerServiceUserHandleList(UserHandle userHandle) { return getResolverRankerServiceUserHandleListInternal(userHandle); } - - @VisibleForTesting - protected List<UserHandle> getResolverRankerServiceUserHandleListInternal( - UserHandle userHandle) { + private List<UserHandle> getResolverRankerServiceUserHandleListInternal(UserHandle userHandle) { List<UserHandle> userList = new ArrayList<>(); userList.add(userHandle); // Add clonedProfileUserHandle to the list only if we are: // a. Building the Personal Tab. // b. CloneProfile exists on the device. - if (userHandle.equals(requireAnnotatedUserHandles().personalProfileUserHandle) - && hasCloneProfile()) { - userList.add(requireAnnotatedUserHandles().cloneProfileUserHandle); + if (userHandle.equals(mProfiles.getPersonalHandle()) + && mProfiles.getCloneUserPresent()) { + userList.add(mProfiles.getCloneHandle()); } return userList; } @@ -1118,7 +1125,7 @@ public class ChooserActivity extends Hilt_ChooserActivity implements public final void onHandlePackagesChanged(ResolverListAdapter listAdapter) { if (!mChooserMultiProfilePagerAdapter.onHandlePackagesChanged( (ChooserListAdapter) listAdapter, - mLogic.getWorkProfileAvailabilityManager().isWaitingToEnableWorkProfile())) { + mProfileAvailability.getWaitingToEnableProfile())) { // We no longer have any items... just finish the activity. finish(); } @@ -1164,7 +1171,8 @@ public class ChooserActivity extends Hilt_ChooserActivity implements Trace.beginSection("configureContentView"); // We partially rebuild the inactive adapter to determine if we should auto launch // isTabLoaded will be true here if the empty state screen is shown instead of the list. - boolean rebuildCompleted = mChooserMultiProfilePagerAdapter.rebuildTabs(hasWorkProfile()); + boolean rebuildCompleted = mChooserMultiProfilePagerAdapter.rebuildTabs( + mProfiles.getWorkProfilePresent()); mLayoutId = mFeatureFlags.scrollablePreview() ? R.layout.chooser_grid_scrollable_preview @@ -1199,7 +1207,7 @@ public class ChooserActivity extends Hilt_ChooserActivity implements stub.setVisibility(View.VISIBLE); TextView textView = (TextView) LayoutInflater.from(this).inflate( R.layout.resolver_different_item_header, null, false); - if (hasWorkProfile()) { + if (mProfiles.getWorkProfilePresent()) { textView.setGravity(Gravity.CENTER); } stub.addView(textView); @@ -1228,7 +1236,10 @@ public class ChooserActivity extends Hilt_ChooserActivity implements setupViewVisibilities(); - if (hasWorkProfile()) { + if (mProfiles.getWorkProfilePresent() + || (mProfiles.getPrivateProfilePresent() + && mProfileAvailability.isAvailable( + requireNonNull(mProfiles.getPrivateProfile())))) { setupProfileTabs(); } @@ -1256,8 +1267,7 @@ public class ChooserActivity extends Hilt_ChooserActivity implements } }); mOnSwitchOnWorkSelectedListener = () -> { - final View workTab = - tabHost.getTabWidget().getChildAt( + View workTab = tabHost.getTabWidget().getChildAt( mChooserMultiProfilePagerAdapter.getPageNumberForProfile(PROFILE_WORK)); workTab.setFocusable(true); workTab.setFocusableInTouchMode(true); @@ -1265,35 +1275,27 @@ public class ChooserActivity extends Hilt_ChooserActivity implements }; } - public void super_onConfigurationChanged(Configuration newConfig) { - super.onConfigurationChanged(newConfig); - mChooserMultiProfilePagerAdapter.getActiveListAdapter().handlePackagesChanged(); - - if (mSystemWindowInsets != null) { - mResolverDrawerLayout.setPadding(mSystemWindowInsets.left, mSystemWindowInsets.top, - mSystemWindowInsets.right, 0); - } - } - ////////////////////////////////////////////////////////////////////////////////////////////// ////////////////////////////////////////////////////////////////////////////////////////////// - private AnnotatedUserHandles requireAnnotatedUserHandles() { - return requireNonNull(mLogic.getAnnotatedUserHandles()); - } - private void createProfileRecords( AppPredictorFactory factory, IntentFilter targetIntentFilter) { - UserHandle mainUserHandle = requireAnnotatedUserHandles().personalProfileUserHandle; + UserHandle mainUserHandle = mProfiles.getPersonalHandle(); ProfileRecord record = createProfileRecord(mainUserHandle, targetIntentFilter, factory); if (record.shortcutLoader == null) { Tracer.INSTANCE.endLaunchToShortcutTrace(); } - UserHandle workUserHandle = requireAnnotatedUserHandles().workProfileUserHandle; + UserHandle workUserHandle = mProfiles.getWorkHandle(); if (workUserHandle != null) { createProfileRecord(workUserHandle, targetIntentFilter, factory); } + + UserHandle privateUserHandle = mProfiles.getPrivateHandle(); + if (privateUserHandle != null && mProfileAvailability.isAvailable( + requireNonNull(mProfiles.getPrivateProfile()))) { + createProfileRecord(privateUserHandle, targetIntentFilter, factory); + } } private ProfileRecord createProfileRecord( @@ -1333,26 +1335,80 @@ public class ChooserActivity extends Hilt_ChooserActivity implements callback); } - static SharedPreferences getPinnedSharedPrefs(Context context) { + private SharedPreferences getPinnedSharedPrefs(Context context) { return context.getSharedPreferences(PINNED_SHARED_PREFS_NAME, MODE_PRIVATE); } - protected ChooserMultiProfilePagerAdapter createMultiProfilePagerAdapter( - Intent[] initialIntents, - List<ResolveInfo> rList, - boolean filterLastUsed) { - if (hasWorkProfile()) { - mChooserMultiProfilePagerAdapter = createChooserMultiProfilePagerAdapterForTwoProfiles( - initialIntents, rList, filterLastUsed); - } else { - mChooserMultiProfilePagerAdapter = createChooserMultiProfilePagerAdapterForOneProfile( - initialIntents, rList, filterLastUsed); + protected ChooserMultiProfilePagerAdapter createMultiProfilePagerAdapter() { + return createMultiProfilePagerAdapter( + /* context = */ this, + mProfilePagerResources, + mViewModel.getRequest().getValue(), + mProfiles, + mProfileAvailability, + mRequest.getInitialIntents(), + mMaxTargetsPerRow, + mFeatureFlags); + } + + private ChooserMultiProfilePagerAdapter createMultiProfilePagerAdapter( + Context context, + ProfilePagerResources profilePagerResources, + ChooserRequest request, + ProfileHelper profileHelper, + ProfileAvailability profileAvailability, + List<Intent> initialIntents, + int maxTargetsPerRow, + FeatureFlags featureFlags) { + Log.d(TAG, "createMultiProfilePagerAdapter"); + + Profile launchedAs = profileHelper.getLaunchedAsProfile(); + + Intent[] initialIntentArray = initialIntents.toArray(new Intent[0]); + List<Intent> payloadIntents = request.getPayloadIntents(); + + List<TabConfig<ChooserGridAdapter>> tabs = new ArrayList<>(); + for (Profile profile : profileHelper.getProfiles()) { + if (profile.getType() == Profile.Type.PRIVATE + && !profileAvailability.isAvailable(profile)) { + continue; + } + ChooserGridAdapter adapter = createChooserGridAdapter( + context, + payloadIntents, + profile.equals(launchedAs) ? initialIntentArray : null, + profile.getPrimary().getHandle() + ); + tabs.add(new TabConfig<>( + /* profile = */ profile.getType().ordinal(), + profilePagerResources.profileTabLabel(profile.getType()), + profilePagerResources.profileTabAccessibilityLabel(profile.getType()), + /* tabTag = */ profile.getType().name(), + adapter)); } - return mChooserMultiProfilePagerAdapter; + + EmptyStateProvider emptyStateProvider = + createEmptyStateProvider(profileHelper, profileAvailability); + + Supplier<Boolean> workProfileQuietModeChecker = + () -> !(profileHelper.getWorkProfilePresent() + && profileAvailability.isAvailable( + requireNonNull(profileHelper.getWorkProfile()))); + + return new ChooserMultiProfilePagerAdapter( + /* context */ this, + ImmutableList.copyOf(tabs), + emptyStateProvider, + workProfileQuietModeChecker, + launchedAs.getType().ordinal(), + profileHelper.getWorkHandle(), + profileHelper.getCloneHandle(), + maxTargetsPerRow, + featureFlags); } protected EmptyStateProvider createBlockerEmptyStateProvider() { - final boolean isSendAction = mViewModel.getChooserRequest().isSendActionTarget(); + final boolean isSendAction = mRequest.isSendActionTarget(); final EmptyState noWorkToPersonalEmptyState = new DevicePolicyBlockerEmptyState( @@ -1381,90 +1437,14 @@ public class ChooserActivity extends Hilt_ChooserActivity implements /* devicePolicyEventCategory= */ ResolverActivity.METRICS_CATEGORY_CHOOSER); return new NoCrossProfileEmptyStateProvider( - requireAnnotatedUserHandles().personalProfileUserHandle, + mProfiles, noWorkToPersonalEmptyState, noPersonalToWorkEmptyState, - createCrossProfileIntentsChecker(), - requireAnnotatedUserHandles().tabOwnerUserHandleForLaunch); - } - - private ChooserMultiProfilePagerAdapter createChooserMultiProfilePagerAdapterForOneProfile( - Intent[] initialIntents, - List<ResolveInfo> rList, - boolean filterLastUsed) { - ChooserGridAdapter adapter = createChooserGridAdapter( - /* context */ this, - mViewModel.getChooserRequest().getPayloadIntents(), - initialIntents, - rList, - filterLastUsed, - /* userHandle */ requireAnnotatedUserHandles().personalProfileUserHandle - ); - return new ChooserMultiProfilePagerAdapter( - /* context */ this, - ImmutableList.of( - new TabConfig<>( - PROFILE_PERSONAL, - mDevicePolicyResources.getPersonalTabLabel(), - mDevicePolicyResources.getPersonalTabAccessibilityLabel(), - TAB_TAG_PERSONAL, - adapter)), - createEmptyStateProvider(/* workProfileUserHandle= */ null), - /* workProfileQuietModeChecker= */ () -> false, - /* defaultProfile= */ PROFILE_PERSONAL, - /* workProfileUserHandle= */ null, - requireAnnotatedUserHandles().cloneProfileUserHandle, - mMaxTargetsPerRow, - mFeatureFlags); - } - - private ChooserMultiProfilePagerAdapter createChooserMultiProfilePagerAdapterForTwoProfiles( - Intent[] initialIntents, - List<ResolveInfo> rList, - boolean filterLastUsed) { - int selectedProfile = findSelectedProfile(); - ChooserGridAdapter personalAdapter = createChooserGridAdapter( - /* context */ this, - mViewModel.getChooserRequest().getPayloadIntents(), - selectedProfile == PROFILE_PERSONAL ? initialIntents : null, - rList, - filterLastUsed, - /* userHandle */ requireAnnotatedUserHandles().personalProfileUserHandle - ); - ChooserGridAdapter workAdapter = createChooserGridAdapter( - /* context */ this, - mViewModel.getChooserRequest().getPayloadIntents(), - selectedProfile == PROFILE_WORK ? initialIntents : null, - rList, - filterLastUsed, - /* userHandle */ requireAnnotatedUserHandles().workProfileUserHandle - ); - return new ChooserMultiProfilePagerAdapter( - /* context */ this, - ImmutableList.of( - new TabConfig<>( - PROFILE_PERSONAL, - mDevicePolicyResources.getPersonalTabLabel(), - mDevicePolicyResources.getPersonalTabAccessibilityLabel(), - TAB_TAG_PERSONAL, - personalAdapter), - new TabConfig<>( - PROFILE_WORK, - mDevicePolicyResources.getWorkTabLabel(), - mDevicePolicyResources.getWorkTabAccessibilityLabel(), - TAB_TAG_WORK, - workAdapter)), - createEmptyStateProvider(requireAnnotatedUserHandles().workProfileUserHandle), - () -> mLogic.getWorkProfileAvailabilityManager().isQuietModeEnabled(), - selectedProfile, - requireAnnotatedUserHandles().workProfileUserHandle, - requireAnnotatedUserHandles().cloneProfileUserHandle, - mMaxTargetsPerRow, - mFeatureFlags); + createCrossProfileIntentsChecker()); } private int findSelectedProfile() { - return getProfileForUser(requireAnnotatedUserHandles().tabOwnerUserHandleForLaunch); + return mProfiles.getLaunchedAsProfileType().ordinal(); } /** @@ -1472,9 +1452,8 @@ public class ChooserActivity extends Hilt_ChooserActivity implements * @return true if it is work profile, false if it is parent profile (or no work profile is * set up) */ - protected boolean isWorkProfile() { - return getSystemService(UserManager.class) - .getUserInfo(UserHandle.myUserId()).isManagedProfile(); + private boolean isWorkProfile() { + return mProfiles.getLaunchedAsProfileType() == Profile.Type.WORK; } //@Override @@ -1512,7 +1491,13 @@ public class ChooserActivity extends Hilt_ChooserActivity implements @Override public void onConfigurationChanged(Configuration newConfig) { - super_onConfigurationChanged(newConfig); + super.onConfigurationChanged(newConfig); + mChooserMultiProfilePagerAdapter.getActiveListAdapter().handlePackagesChanged(); + + if (mSystemWindowInsets != null) { + mResolverDrawerLayout.setPadding(mSystemWindowInsets.left, mSystemWindowInsets.top, + mSystemWindowInsets.right, 0); + } ViewPager viewPager = findViewById(com.android.internal.R.id.profile_pager); if (viewPager.isLayoutRtl()) { mChooserMultiProfilePagerAdapter.setupViewPager(viewPager); @@ -1545,7 +1530,7 @@ public class ChooserActivity extends Hilt_ChooserActivity implements } private void updateTabPadding() { - if (hasWorkProfile()) { + if (mProfiles.getWorkProfilePresent()) { View tabs = findViewById(com.android.internal.R.id.tabs); float iconSize = getResources().getDimension(R.dimen.chooser_icon_size); // The entire width consists of icons or padding. Divide the item padding in half to get @@ -1612,12 +1597,10 @@ public class ChooserActivity extends Hilt_ChooserActivity implements @Override // ResolverListCommunicator public Intent getReplacementIntent(ActivityInfo aInfo, Intent defIntent) { - ChooserRequest chooserRequest = mViewModel.getChooserRequest(); - Intent result = defIntent; - if (chooserRequest.getReplacementExtras() != null) { + if (mRequest.getReplacementExtras() != null) { final Bundle replExtras = - chooserRequest.getReplacementExtras().getBundle(aInfo.packageName); + mRequest.getReplacementExtras().getBundle(aInfo.packageName); if (replExtras != null) { result = new Intent(defIntent); result.putExtras(replExtras); @@ -1646,13 +1629,12 @@ public class ChooserActivity extends Hilt_ChooserActivity implements } private void addCallerChooserTargets() { - ChooserRequest chooserRequest = mViewModel.getChooserRequest(); - if (!chooserRequest.getCallerChooserTargets().isEmpty()) { + if (!mRequest.getCallerChooserTargets().isEmpty()) { // Send the caller's chooser targets only to the default profile. if (mChooserMultiProfilePagerAdapter.getActiveProfile() == findSelectedProfile()) { mChooserMultiProfilePagerAdapter.getActiveListAdapter().addServiceResults( /* origTarget */ null, - new ArrayList<>(chooserRequest.getCallerChooserTargets()), + new ArrayList<>(mRequest.getCallerChooserTargets()), TARGET_TYPE_DEFAULT, /* directShareShortcutInfoCache */ Collections.emptyMap(), /* directShareAppTargetCache */ Collections.emptyMap()); @@ -1670,8 +1652,9 @@ public class ChooserActivity extends Hilt_ChooserActivity implements return false; } - return mActivityModel.getIntent().getBooleanExtra(Intent.EXTRA_AUTO_LAUNCH_SINGLE_CHOICE, - true); + // TODO: migrate to ChooserRequest + return mViewModel.getActivityModel().getIntent() + .getBooleanExtra(Intent.EXTRA_AUTO_LAUNCH_SINGLE_CHOICE, true); } private void showTargetDetails(TargetInfo targetInfo) { @@ -1688,7 +1671,7 @@ public class ChooserActivity extends Hilt_ChooserActivity implements boolean isShortcutPinned = targetInfo.isSelectableTargetInfo() && targetInfo.isPinned(); IntentFilter intentFilter; intentFilter = targetInfo.isSelectableTargetInfo() - ? mViewModel.getChooserRequest().getShareTargetFilter() : null; + ? mRequest.getShareTargetFilter() : null; String shortcutTitle = targetInfo.isSelectableTargetInfo() ? targetInfo.getDisplayLabel().toString() : null; String shortcutIdKey = targetInfo.getDirectShareShortcutId(); @@ -1708,7 +1691,7 @@ public class ChooserActivity extends Hilt_ChooserActivity implements protected boolean onTargetSelected(TargetInfo target) { if (mRefinementManager.maybeHandleSelection( target, - mViewModel.getChooserRequest().getRefinementIntentSender(), + mRequest.getRefinementIntentSender(), getApplication(), getMainThreadHandler())) { return false; @@ -1782,7 +1765,7 @@ public class ChooserActivity extends Hilt_ChooserActivity implements targetInfo.getResolveInfo().activityInfo.processName, which, /* directTargetAlsoRanked= */ getRankedPosition(targetInfo), - mViewModel.getChooserRequest().getCallerChooserTargets().size(), + mRequest.getCallerChooserTargets().size(), targetInfo.getHashedTargetIdForMetrics(this), targetInfo.isPinned(), mIsSuccessfullySelected, @@ -1861,7 +1844,7 @@ public class ChooserActivity extends Hilt_ChooserActivity implements if (info != null) { sendClickToAppPredictor(info); final ResolveInfo ri = info.getResolveInfo(); - Intent targetIntent = mViewModel.getChooserRequest().getTargetIntent(); + Intent targetIntent = mRequest.getTargetIntent(); if (ri != null && ri.activityInfo != null && targetIntent != null) { ChooserListAdapter currentListAdapter = mChooserMultiProfilePagerAdapter.getActiveListAdapter(); @@ -1889,7 +1872,7 @@ public class ChooserActivity extends Hilt_ChooserActivity implements if (targetIntent == null) { return; } - Intent originalTargetIntent = new Intent(mViewModel.getChooserRequest().getTargetIntent()); + Intent originalTargetIntent = new Intent(mRequest.getTargetIntent()); // Our TargetInfo implementations add associated component to the intent, let's do the same // for the sake of the comparison below. if (targetIntent.getComponent() != null) { @@ -1959,7 +1942,7 @@ public class ChooserActivity extends Hilt_ChooserActivity implements ProfileRecord record = getProfileRecord(userHandle); // We cannot use APS service when clone profile is present as APS service cannot sort // cross profile targets as of now. - return ((record == null) || (requireAnnotatedUserHandles().cloneProfileUserHandle != null)) + return ((record == null) || (mProfiles.getCloneUserPresent())) ? null : record.appPredictor; } @@ -1967,25 +1950,21 @@ public class ChooserActivity extends Hilt_ChooserActivity implements return mEventLog; } - @VisibleForTesting - public ChooserGridAdapter createChooserGridAdapter( + private ChooserGridAdapter createChooserGridAdapter( Context context, List<Intent> payloadIntents, Intent[] initialIntents, - List<ResolveInfo> rList, - boolean filterLastUsed, UserHandle userHandle) { - ChooserRequest request = mViewModel.getChooserRequest(); ChooserListAdapter chooserListAdapter = createChooserListAdapter( context, payloadIntents, initialIntents, - rList, - filterLastUsed, + /* TODO: not used, remove. rList= */ null, + /* TODO: not used, remove. filterLastUsed= */ false, createListController(userHandle), userHandle, - request.getTargetIntent(), - request.getReferrerFillInIntent(), + mRequest.getTargetIntent(), + mRequest.getReferrerFillInIntent(), mMaxTargetsPerRow ); @@ -1994,7 +1973,7 @@ public class ChooserActivity extends Hilt_ChooserActivity implements new ChooserGridAdapter.ChooserActivityDelegate() { @Override public boolean shouldShowTabs() { - return hasWorkProfile(); + return mProfiles.getWorkProfilePresent(); } @Override @@ -2039,9 +2018,7 @@ public class ChooserActivity extends Hilt_ChooserActivity implements Intent targetIntent, Intent referrerFillInIntent, int maxTargetsPerRow) { - UserHandle initialIntentsUserSpace = isLaunchedAsCloneProfile() - && userHandle.equals(requireAnnotatedUserHandles().personalProfileUserHandle) - ? requireAnnotatedUserHandles().cloneProfileUserHandle : userHandle; + UserHandle initialIntentsUserSpace = mProfiles.getQueryIntentsHandle(userHandle); return new ChooserListAdapter( context, payloadIntents, @@ -2067,19 +2044,18 @@ public class ChooserActivity extends Hilt_ChooserActivity implements mFeatureFlags); } - protected Unit onWorkProfileStatusUpdated() { - UserHandle workUser = requireAnnotatedUserHandles().workProfileUserHandle; + private void onWorkProfileStatusUpdated() { + UserHandle workUser = mProfiles.getWorkHandle(); ProfileRecord record = workUser == null ? null : getProfileRecord(workUser); if (record != null && record.shortcutLoader != null) { record.shortcutLoader.reset(); } if (mChooserMultiProfilePagerAdapter.getCurrentUserHandle().equals( - requireAnnotatedUserHandles().workProfileUserHandle)) { + mProfiles.getWorkHandle())) { mChooserMultiProfilePagerAdapter.rebuildActiveTab(true); } else { mChooserMultiProfilePagerAdapter.clearInactiveProfileCache(); } - return Unit.INSTANCE; } @VisibleForTesting @@ -2089,8 +2065,8 @@ public class ChooserActivity extends Hilt_ChooserActivity implements if (appPredictor != null) { resolverComparator = new AppPredictionServiceResolverComparator( this, - mViewModel.getChooserRequest().getTargetIntent(), - mViewModel.getChooserRequest().getLaunchedFromPackage(), + mRequest.getTargetIntent(), + mRequest.getLaunchedFromPackage(), appPredictor, userHandle, getEventLog(), @@ -2100,8 +2076,8 @@ public class ChooserActivity extends Hilt_ChooserActivity implements resolverComparator = new ResolverRankerServiceResolverComparator( this, - mViewModel.getChooserRequest().getTargetIntent(), - mViewModel.getChooserRequest().getReferrerPackage(), + mRequest.getTargetIntent(), + mRequest.getReferrerPackage(), null, getEventLog(), getResolverRankerServiceUserHandleList(userHandle), @@ -2111,12 +2087,12 @@ public class ChooserActivity extends Hilt_ChooserActivity implements return new ChooserListController( this, mPackageManager, - mViewModel.getChooserRequest().getTargetIntent(), - mViewModel.getChooserRequest().getReferrerPackage(), - requireAnnotatedUserHandles().userIdOfCallingApp, + mRequest.getTargetIntent(), + mRequest.getReferrerPackage(), + mViewModel.getActivityModel().getLaunchedFromUid(), resolverComparator, - getQueryIntentsUser(userHandle), - mViewModel.getChooserRequest().getFilteredComponentNames(), + mProfiles.getQueryIntentsHandle(userHandle), + mRequest.getFilteredComponentNames(), mPinnedSharedPrefs); } @@ -2126,13 +2102,11 @@ public class ChooserActivity extends Hilt_ChooserActivity implements } private ChooserActionFactory createChooserActionFactory() { - ChooserRequest request = mViewModel.getChooserRequest(); return new ChooserActionFactory( this, - request.getTargetIntent(), - request.getLaunchedFromPackage(), - request.getChooserActions(), - request.getModifyShareAction(), + mRequest.getTargetIntent(), + mRequest.getLaunchedFromPackage(), + mRequest.getChooserActions(), mImageEditor, getEventLog(), (isExcluded) -> mExcludeSharedText = isExcluded, @@ -2142,7 +2116,7 @@ public class ChooserActivity extends Hilt_ChooserActivity implements public void safelyStartActivityAsPersonalProfileUser(TargetInfo targetInfo) { safelyStartActivityAsUser( targetInfo, - requireAnnotatedUserHandles().personalProfileUserHandle + mProfiles.getPersonalHandle() ); finish(); } @@ -2154,7 +2128,7 @@ public class ChooserActivity extends Hilt_ChooserActivity implements ChooserActivity.this, sharedElement, sharedElementName); safelyStartActivityAsUser( targetInfo, - requireAnnotatedUserHandles().personalProfileUserHandle, + mProfiles.getPersonalHandle(), options.toBundle()); // Can't finish right away because the shared element transition may not // be ready to start. @@ -2162,15 +2136,26 @@ public class ChooserActivity extends Hilt_ChooserActivity implements } }, mShareResultSender, - (status) -> { - if (status != null) { - setResult(status); - } - finish(); - }, + this::finishWithStatus, mClipboardManager); } + private Supplier<ActionRow.Action> createModifyShareActionFactory() { + return () -> ChooserActionFactory.createCustomAction( + ChooserActivity.this, + mRequest.getModifyShareAction(), + () -> getEventLog().logActionSelected(EventLog.SELECTION_TYPE_MODIFY_SHARE), + mShareResultSender, + this::finishWithStatus); + } + + private void finishWithStatus(@Nullable Integer status) { + if (status != null) { + setResult(status); + } + finish(); + } + /* * Need to dynamically adjust how many icons can fit per row before we add them, * which also means setting the correct offset to initially show the content @@ -2262,7 +2247,7 @@ public class ChooserActivity extends Hilt_ChooserActivity implements offset += stickyContentPreview.getHeight(); } - if (hasWorkProfile()) { + if (mProfiles.getWorkProfilePresent()) { offset += findViewById(com.android.internal.R.id.tabs).getHeight(); } @@ -2306,19 +2291,6 @@ public class ChooserActivity extends Hilt_ChooserActivity implements .shouldShowEmptyStateScreenInAnyInactiveAdapter(); } - /** - * Returns {@link #PROFILE_WORK}, if the given user handle matches work user handle. - * Returns {@link #PROFILE_PERSONAL}, otherwise. - **/ - private int getProfileForUser(UserHandle currentUserHandle) { - if (currentUserHandle.equals(requireAnnotatedUserHandles().workProfileUserHandle)) { - return PROFILE_WORK; - } - // We return personal profile, as it is the default when there is no work profile, personal - // profile represents rootUser, clonedUser & secondaryUser, covering all use cases. - return PROFILE_PERSONAL; - } - protected void onListRebuilt(ResolverListAdapter listAdapter, boolean rebuildComplete) { setupScrollListener(); maybeSetupGlobalLayoutListener(); @@ -2403,8 +2375,8 @@ public class ChooserActivity extends Hilt_ChooserActivity implements if (mResolverDrawerLayout == null) { return; } - int elevatedViewResId = hasWorkProfile() ? - com.android.internal.R.id.tabs : com.android.internal.R.id.chooser_header; + int elevatedViewResId = mProfiles.getWorkProfilePresent() + ? com.android.internal.R.id.tabs : com.android.internal.R.id.chooser_header; final View elevatedView = mResolverDrawerLayout.findViewById(elevatedViewResId); final float defaultElevation = elevatedView.getElevation(); final float chooserHeaderScrollElevation = @@ -2442,7 +2414,7 @@ public class ChooserActivity extends Hilt_ChooserActivity implements } private void maybeSetupGlobalLayoutListener() { - if (hasWorkProfile()) { + if (mProfiles.getWorkProfilePresent()) { return; } final View recyclerView = mChooserMultiProfilePagerAdapter.getActiveAdapterView(); @@ -2476,9 +2448,10 @@ public class ChooserActivity extends Hilt_ChooserActivity implements if (!shouldShowContentPreview()) { return false; } - boolean isEmpty = mChooserMultiProfilePagerAdapter.getListAdapterForUserHandle( - UserHandle.of(UserHandle.myUserId())).getCount() == 0; - return (mFeatureFlags.scrollablePreview() || hasWorkProfile()) + ResolverListAdapter adapter = mChooserMultiProfilePagerAdapter.getListAdapterForUserHandle( + UserHandle.of(UserHandle.myUserId())); + boolean isEmpty = adapter == null || adapter.getCount() == 0; + return (mFeatureFlags.scrollablePreview() || mProfiles.getWorkProfilePresent()) && (!isEmpty || shouldShowContentPreviewWhenEmpty()); } @@ -2497,8 +2470,7 @@ public class ChooserActivity extends Hilt_ChooserActivity implements * @return true if we want to show the content preview area */ protected boolean shouldShowContentPreview() { - ChooserRequest chooserRequest = mViewModel.getChooserRequest(); - return (chooserRequest != null) && chooserRequest.isSendActionTarget(); + return mRequest.isSendActionTarget(); } private void updateStickyContentPreview() { @@ -2549,7 +2521,7 @@ public class ChooserActivity extends Hilt_ChooserActivity implements protected void onProfileTabSelected(int currentPage) { setupViewVisibilities(); maybeLogProfileChange(); - if (hasWorkProfile()) { + if (mProfiles.getWorkProfilePresent()) { // The device policy logger is only concerned with sessions that include a work profile. DevicePolicyEventLogger .createEvent(DevicePolicyEnums.RESOLVER_SWITCH_TABS) @@ -2568,7 +2540,7 @@ public class ChooserActivity extends Hilt_ChooserActivity implements } protected WindowInsets onApplyWindowInsets(View v, WindowInsets insets) { - if (hasWorkProfile()) { + if (mProfiles.getWorkProfilePresent()) { mChooserMultiProfilePagerAdapter .setEmptyStateBottomOffset(insets.getSystemWindowInsetBottom()); } diff --git a/java/src/com/android/intentresolver/v2/ChooserActivityLogic.kt b/java/src/com/android/intentresolver/v2/ChooserActivityLogic.kt deleted file mode 100644 index 84b7d9a9..00000000 --- a/java/src/com/android/intentresolver/v2/ChooserActivityLogic.kt +++ /dev/null @@ -1,23 +0,0 @@ -package com.android.intentresolver.v2 - -import androidx.activity.ComponentActivity -import androidx.annotation.OpenForTesting - -/** - * Activity logic for [ChooserActivity]. - * - * TODO: Make this class no longer open once [ChooserActivity] no longer needs to cast to access - * [chooserRequest]. For now, this class being open is better than using reflection there. - */ -@OpenForTesting -open class ChooserActivityLogic( - tag: String, - activity: ComponentActivity, - onWorkProfileStatusUpdated: () -> Unit, -) : - ActivityLogic, - CommonActivityLogic by CommonActivityLogicImpl( - tag, - activity, - onWorkProfileStatusUpdated, - ) diff --git a/java/src/com/android/intentresolver/v2/ChooserHelper.kt b/java/src/com/android/intentresolver/v2/ChooserHelper.kt index 17bc2731..503e46d8 100644 --- a/java/src/com/android/intentresolver/v2/ChooserHelper.kt +++ b/java/src/com/android/intentresolver/v2/ChooserHelper.kt @@ -17,11 +17,59 @@ package com.android.intentresolver.v2 import android.app.Activity +import android.os.UserHandle +import android.util.Log import androidx.activity.ComponentActivity +import androidx.activity.viewModels import androidx.lifecycle.DefaultLifecycleObserver +import androidx.lifecycle.Lifecycle import androidx.lifecycle.LifecycleOwner +import androidx.lifecycle.lifecycleScope +import androidx.lifecycle.repeatOnLifecycle +import com.android.intentresolver.contentpreview.payloadtoggle.data.repository.ActivityResultRepository +import com.android.intentresolver.inject.Background +import com.android.intentresolver.v2.annotation.JavaInterop +import com.android.intentresolver.v2.data.model.ChooserRequest +import com.android.intentresolver.v2.domain.interactor.UserInteractor +import com.android.intentresolver.v2.shared.model.Profile +import com.android.intentresolver.v2.ui.viewmodel.ChooserViewModel +import com.android.intentresolver.v2.validation.Invalid +import com.android.intentresolver.v2.validation.Valid +import com.android.intentresolver.v2.validation.log import dagger.hilt.android.scopes.ActivityScoped +import java.util.function.Consumer import javax.inject.Inject +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.flow.filterNotNull +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking + +private const val TAG: String = "ChooserHelper" + +/** + * Provides initial values to ChooserActivity and completes initialization from onCreate. + * + * This information is collected and provided on behalf of ChooserActivity to eliminate the need for + * suspending functions within remaining synchronous startup code. + */ +@JavaInterop +fun interface ChooserInitializer { + /** @param initialState the initial state to provide to initialization */ + fun initializeWith(initialState: InitialState) +} + +/** + * A parameter object for Initialize which contains all the values which are required "early", on + * the main thread and outside of any coroutines. This supports code which expects to be called by + * the system on the main thread only. (This includes everything originally called from onCreate). + */ +@JavaInterop +data class InitialState( + val profiles: List<Profile>, + val availability: Map<Profile, Boolean>, + val launchedAs: Profile +) /** * __Purpose__ @@ -30,52 +78,125 @@ import javax.inject.Inject * * __Incoming References__ * - * For use by ChooserActivity only; must not be accessed by any code outside of ChooserActivity. - * This prevents circular dependencies and coupling, and maintains unidirectional flow. This is - * important for maintaining a migration path towards healthier architecture. + * ChooserHelper must not expose any properties or functions directly back to ChooserActivity. If a + * value or operation is required by ChooserActivity, then it must be added to ChooserInitializer + * (or a new interface as appropriate) with ChooserActivity supplying a callback to receive it at + * the appropriate point. This enforces unidirectional control flow. * * __Outgoing References__ * * _ChooserActivity_ * * This class must only reference it's host as Activity/ComponentActivity; no down-cast to - * [ChooserActivity]. Other components should be passed in and not pulled from other places. This - * prevents circular dependencies from forming. + * [ChooserActivity]. Other components should be created here or supplied via Injection, and not + * referenced directly within ChooserActivity. This prevents circular dependencies from forming. If + * necessary, during cleanup the dependency can be supplied back to ChooserActivity as described + * above in 'Incoming References', see [ChooserInitializer]. * * _Elsewhere_ * * Where possible, Singleton and ActivityScoped dependencies should be injected here instead of * referenced from an existing location. If not available for injection, the value should be - * constructed here, then provided to where it is needed. If existing objects from ChooserActivity - * are required, supply a factory interface which satisfies the necessary dependencies and use it - * during construction. + * constructed here, then provided to where it is needed. */ - @ActivityScoped -class ChooserHelper @Inject constructor( +@JavaInterop +class ChooserHelper +@Inject +constructor( hostActivity: Activity, + private val userInteractor: UserInteractor, + private val activityResultRepo: ActivityResultRepository, + @Background private val background: CoroutineDispatcher, ) : DefaultLifecycleObserver { // This is guaranteed by Hilt, since only a ComponentActivity is injectable. private val activity: ComponentActivity = hostActivity as ComponentActivity + private val viewModel by activity.viewModels<ChooserViewModel>() + + private lateinit var activityInitializer: ChooserInitializer - private var activityPostCreate: Runnable? = null + var onChooserRequestChanged: Consumer<ChooserRequest> = Consumer {} init { activity.lifecycle.addObserver(this) } /** - * Provides a optional callback to setup state which is not yet possible to do without circular - * dependencies or by moving more code. + * Set the initialization hook for the host activity. + * + * This _must_ be called from [ChooserActivity.onCreate]. */ - fun setPostCreateCallback(onPostCreate: Runnable) { - activityPostCreate = onPostCreate + fun setInitializer(initializer: ChooserInitializer) { + check(activity.lifecycle.currentState == Lifecycle.State.INITIALIZED) { + "setInitializer must be called before onCreate returns" + } + activityInitializer = initializer } - /** - * Invoked by Lifecycle, after Activity.onCreate() _returns_. - */ + /** Invoked by Lifecycle, after [ChooserActivity.onCreate] _returns_. */ override fun onCreate(owner: LifecycleOwner) { - activityPostCreate?.run() + Log.i(TAG, "CREATE") + Log.i(TAG, "${viewModel.activityModel}") + + val callerUid: Int = viewModel.activityModel.launchedFromUid + if (callerUid < 0 || UserHandle.isIsolated(callerUid)) { + Log.e(TAG, "Can't start a chooser from uid $callerUid") + activity.finish() + return + } + + when (val request = viewModel.initialRequest) { + is Valid -> initializeActivity(request) + is Invalid -> reportErrorsAndFinish(request) + } + + activity.lifecycleScope.launch { + activity.setResult(activityResultRepo.activityResult.filterNotNull().first()) + activity.finish() + } + + activity.lifecycleScope.launch { + activity.lifecycle.repeatOnLifecycle(Lifecycle.State.STARTED) { + viewModel.request.collect { onChooserRequestChanged.accept(it) } + } + } + } + + override fun onStart(owner: LifecycleOwner) { + Log.i(TAG, "START") + } + + override fun onResume(owner: LifecycleOwner) { + Log.i(TAG, "RESUME") + } + + override fun onPause(owner: LifecycleOwner) { + Log.i(TAG, "PAUSE") + } + + override fun onStop(owner: LifecycleOwner) { + Log.i(TAG, "STOP") + } + + override fun onDestroy(owner: LifecycleOwner) { + Log.i(TAG, "DESTROY") + } + + private fun reportErrorsAndFinish(request: Invalid<ChooserRequest>) { + request.errors.forEach { it.log(TAG) } + activity.finish() + } + + private fun initializeActivity(request: Valid<ChooserRequest>) { + request.warnings.forEach { it.log(TAG) } + + val initialState = + runBlocking(background) { + val initialProfiles = userInteractor.profiles.first() + val initialAvailability = userInteractor.availability.first() + val launchedAsProfile = userInteractor.launchedAsProfile.first() + InitialState(initialProfiles, initialAvailability, launchedAsProfile) + } + activityInitializer.initializeWith(initialState) } -}
\ No newline at end of file +} diff --git a/java/src/com/android/intentresolver/v2/ChooserMutableActionFactory.kt b/java/src/com/android/intentresolver/v2/ChooserMutableActionFactory.kt deleted file mode 100644 index 2f8ccf77..00000000 --- a/java/src/com/android/intentresolver/v2/ChooserMutableActionFactory.kt +++ /dev/null @@ -1,54 +0,0 @@ -/* - * Copyright 2024 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.android.intentresolver.v2 - -import android.service.chooser.ChooserAction -import com.android.intentresolver.contentpreview.ChooserContentPreviewUi -import com.android.intentresolver.contentpreview.MutableActionFactory -import com.android.intentresolver.widget.ActionRow -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.MutableStateFlow - -/** A wrapper around [ChooserActionFactory] that provides observable custom actions */ -class ChooserMutableActionFactory( - private val actionFactory: ChooserActionFactory, -) : MutableActionFactory, ChooserContentPreviewUi.ActionFactory by actionFactory { - private val customActions = - MutableStateFlow<List<ActionRow.Action>>(actionFactory.createCustomActions()) - - override val customActionsFlow: Flow<List<ActionRow.Action>> - get() = customActions - - override fun updateCustomActions(actions: List<ChooserAction>) { - customActions.tryEmit(mapChooserActions(actions)) - } - - override fun createCustomActions(): List<ActionRow.Action> = customActions.value - - private fun mapChooserActions(chooserActions: List<ChooserAction>): List<ActionRow.Action> = - buildList(chooserActions.size) { - chooserActions.forEachIndexed { i, chooserAction -> - val actionRow = - actionFactory.createCustomAction(chooserAction) { - actionFactory.logCustomAction(i) - } - if (actionRow != null) { - add(actionRow) - } - } - } -} diff --git a/java/src/com/android/intentresolver/v2/JavaFlowHelper.kt b/java/src/com/android/intentresolver/v2/JavaFlowHelper.kt index c6c977f6..3c4bddd1 100644 --- a/java/src/com/android/intentresolver/v2/JavaFlowHelper.kt +++ b/java/src/com/android/intentresolver/v2/JavaFlowHelper.kt @@ -18,11 +18,13 @@ package com.android.intentresolver.v2 +import com.android.intentresolver.v2.annotation.JavaInterop import java.util.function.Consumer import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Job import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.launch +@JavaInterop fun <T> collect(scope: CoroutineScope, flow: Flow<T>, collector: Consumer<T>): Job = scope.launch { flow.collect { collector.accept(it) } } diff --git a/java/src/com/android/intentresolver/v2/ProfileAvailability.kt b/java/src/com/android/intentresolver/v2/ProfileAvailability.kt index 4d689724..ddb57991 100644 --- a/java/src/com/android/intentresolver/v2/ProfileAvailability.kt +++ b/java/src/com/android/intentresolver/v2/ProfileAvailability.kt @@ -16,33 +16,34 @@ package com.android.intentresolver.v2 +import com.android.intentresolver.v2.annotation.JavaInterop import com.android.intentresolver.v2.domain.interactor.UserInteractor import com.android.intentresolver.v2.shared.model.Profile -import kotlin.time.Duration.Companion.seconds import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Job -import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted -import kotlinx.coroutines.flow.collect import kotlinx.coroutines.flow.filter import kotlinx.coroutines.flow.first -import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch -import kotlinx.coroutines.withTimeout /** Provides availability status for profiles */ +@JavaInterop class ProfileAvailability( private val scope: CoroutineScope, - private val userInteractor: UserInteractor + private val userInteractor: UserInteractor, + initialState: Map<Profile, Boolean> ) { private val availability = - userInteractor.availability.stateIn(scope, SharingStarted.Eagerly, mapOf()) + userInteractor.availability.stateIn(scope, SharingStarted.Eagerly, initialState) /** Used by WorkProfilePausedEmptyStateProvider */ var waitingToEnableProfile = false private set + /** Set by ChooserActivity to call onWorkProfileStatusUpdated */ + var onProfileStatusChange: Runnable? = null + private var waitJob: Job? = null /** Query current profile availability. An unavailable profile is one which is not active. */ fun isAvailable(profile: Profile) = availability.value[profile] ?: false @@ -61,14 +62,14 @@ class ProfileAvailability( waitingToEnableProfile = true waitJob?.cancel() - val job = scope.launch { - // Wait for the profile to become available - // Wait for the profile to be enabled, then clear this flag - userInteractor.availability.filter { it[profile] == true }.first() - waitingToEnableProfile = false - } + val job = + scope.launch { + // Wait for the profile to become available + availability.filter { it[profile] == true }.first() + } job.invokeOnCompletion { waitingToEnableProfile = false + onProfileStatusChange?.run() } waitJob = job } @@ -76,4 +77,4 @@ class ProfileAvailability( // Apply the change scope.launch { userInteractor.updateState(profile, enableProfile) } } -}
\ No newline at end of file +} diff --git a/java/src/com/android/intentresolver/v2/ProfileHelper.kt b/java/src/com/android/intentresolver/v2/ProfileHelper.kt index 784096b4..8a8e6b54 100644 --- a/java/src/com/android/intentresolver/v2/ProfileHelper.kt +++ b/java/src/com/android/intentresolver/v2/ProfileHelper.kt @@ -1,42 +1,47 @@ /* -* Copyright (C) 2024 The Android Open Source Project -* -* Licensed under the Apache License, Version 2.0 (the "License"); -* you may not use this file except in compliance with the License. -* You may obtain a copy of the License at -* -* http://www.apache.org/licenses/LICENSE-2.0 -* -* Unless required by applicable law or agreed to in writing, software -* distributed under the License is distributed on an "AS IS" BASIS, -* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -* See the License for the specific language governing permissions and -* limitations under the License. -*/ + * Copyright (C) 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ package com.android.intentresolver.v2 import android.os.UserHandle import com.android.intentresolver.inject.IntentResolverFlags +import com.android.intentresolver.v2.annotation.JavaInterop import com.android.intentresolver.v2.domain.interactor.UserInteractor import com.android.intentresolver.v2.shared.model.Profile import com.android.intentresolver.v2.shared.model.User import javax.inject.Inject -class ProfileHelper @Inject constructor( +@JavaInterop +class ProfileHelper +@Inject +constructor( interactor: UserInteractor, private val flags: IntentResolverFlags, - profiles: List<Profile>, - launchedAsProfile: Profile, + val profiles: List<Profile>, + val launchedAsProfile: Profile, ) { private val launchedByHandle: UserHandle = interactor.launchedAs // Map UserHandle back to a user within launchedByProfile - private val launchedByUser = when (launchedByHandle) { - launchedAsProfile.primary.handle -> launchedAsProfile.primary - launchedAsProfile.clone?.handle -> launchedAsProfile.clone - else -> error("launchedByUser must be a member of launchedByProfile") - } + private val launchedByUser = + when (launchedByHandle) { + launchedAsProfile.primary.handle -> launchedAsProfile.primary + launchedAsProfile.clone?.handle -> launchedAsProfile.clone + else -> error("launchedByUser must be a member of launchedByProfile") + } val launchedAsProfileType: Profile.Type = launchedAsProfile.type val personalProfile = profiles.single { it.type == Profile.Type.PERSONAL } @@ -45,7 +50,7 @@ class ProfileHelper @Inject constructor( val personalHandle = personalProfile.primary.handle val workHandle = workProfile?.primary?.handle - val privateHandle = privateProfile?.primary?.handle?.takeIf { flags.enablePrivateProfile() } + val privateHandle = privateProfile?.primary?.handle val cloneHandle = personalProfile.clone?.handle val isLaunchedAsCloneProfile = launchedByUser == launchedAsProfile.clone @@ -55,12 +60,19 @@ class ProfileHelper @Inject constructor( val privateProfilePresent = privateProfile != null // Name retained for ease of review, to be renamed later - val tabOwnerUserHandleForLaunch = if (launchedByUser.role == User.Role.CLONE) { - // When started by clone user, return the profile owner instead - launchedAsProfile.primary.handle - } else { - // Otherwise the launched user is used - launchedByUser.handle + val tabOwnerUserHandleForLaunch = + if (launchedByUser.role == User.Role.CLONE) { + // When started by clone user, return the profile owner instead + launchedAsProfile.primary.handle + } else { + // Otherwise the launched user is used + launchedByUser.handle + } + + fun findProfileType(handle: UserHandle): Profile.Type? { + val matched = + profiles.firstOrNull { it.primary.handle == handle || it.clone?.handle == handle } + return matched?.type } // Name retained for ease of review, to be renamed later diff --git a/java/src/com/android/intentresolver/v2/ResolverActivity.java b/java/src/com/android/intentresolver/v2/ResolverActivity.java index a9d9f8b1..4e694c3a 100644 --- a/java/src/com/android/intentresolver/v2/ResolverActivity.java +++ b/java/src/com/android/intentresolver/v2/ResolverActivity.java @@ -102,8 +102,8 @@ import com.android.intentresolver.icons.TargetDataLoader; import com.android.intentresolver.model.ResolverRankerServiceResolverComparator; import com.android.intentresolver.v2.data.repository.DevicePolicyResources; import com.android.intentresolver.v2.emptystate.NoAppsAvailableEmptyStateProvider; -import com.android.intentresolver.v2.emptystate.NoCrossProfileEmptyStateProvider; import com.android.intentresolver.v2.emptystate.NoCrossProfileEmptyStateProvider.DevicePolicyBlockerEmptyState; +import com.android.intentresolver.v2.emptystate.ResolverNoCrossProfileEmptyStateProvider; import com.android.intentresolver.v2.emptystate.ResolverWorkProfilePausedEmptyStateProvider; import com.android.intentresolver.v2.profiles.MultiProfilePagerAdapter; import com.android.intentresolver.v2.profiles.MultiProfilePagerAdapter.ProfileType; @@ -238,7 +238,7 @@ public class ResolverActivity extends Hilt_ResolverActivity implements } @Override - protected final void onCreate(Bundle savedInstanceState) { + protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); setTheme(R.style.Theme_DeviceDefault_Resolver); mActivityModel = createActivityModel(); @@ -407,7 +407,7 @@ public class ResolverActivity extends Hilt_ResolverActivity implements /* devicePolicyEventCategory= */ ResolverActivity.METRICS_CATEGORY_RESOLVER); - return new NoCrossProfileEmptyStateProvider( + return new ResolverNoCrossProfileEmptyStateProvider( requireAnnotatedUserHandles().personalProfileUserHandle, noWorkToPersonalEmptyState, noPersonalToWorkEmptyState, diff --git a/java/src/com/android/intentresolver/v2/annotation/JavaInterop.kt b/java/src/com/android/intentresolver/v2/annotation/JavaInterop.kt index 15c5018a..a813358e 100644 --- a/java/src/com/android/intentresolver/v2/annotation/JavaInterop.kt +++ b/java/src/com/android/intentresolver/v2/annotation/JavaInterop.kt @@ -21,6 +21,8 @@ package com.android.intentresolver.v2.annotation * * The goal is to prevent usage from Kotlin when a more idiomatic alternative is available. */ -@RequiresOptIn("This is a a property, function or class specifically supporting Java " + - "interoperability. Usage from Kotlin should be limited to interactions with Java.") +@RequiresOptIn( + "This is a a property, function or class specifically supporting Java " + + "interoperability. Usage from Kotlin should be limited to interactions with Java." +) annotation class JavaInterop diff --git a/java/src/com/android/intentresolver/v2/ui/model/ChooserRequest.kt b/java/src/com/android/intentresolver/v2/data/model/ChooserRequest.kt index 4f3cf3cd..7c9c8613 100644 --- a/java/src/com/android/intentresolver/v2/ui/model/ChooserRequest.kt +++ b/java/src/com/android/intentresolver/v2/data/model/ChooserRequest.kt @@ -13,7 +13,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package com.android.intentresolver.v2.ui.model +package com.android.intentresolver.v2.data.model import android.content.ComponentName import android.content.Intent diff --git a/java/src/com/android/intentresolver/v2/data/repository/ChooserRequestRepository.kt b/java/src/com/android/intentresolver/v2/data/repository/ChooserRequestRepository.kt new file mode 100644 index 00000000..d23e07ee --- /dev/null +++ b/java/src/com/android/intentresolver/v2/data/repository/ChooserRequestRepository.kt @@ -0,0 +1,39 @@ +/* + * Copyright (C) 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.intentresolver.v2.data.repository + +import com.android.intentresolver.contentpreview.payloadtoggle.data.model.CustomActionModel +import com.android.intentresolver.v2.data.model.ChooserRequest +import dagger.hilt.android.scopes.ViewModelScoped +import javax.inject.Inject +import kotlinx.coroutines.flow.MutableStateFlow + +@ViewModelScoped +class ChooserRequestRepository +@Inject +constructor( + initialRequest: ChooserRequest, + initialActions: List<CustomActionModel>, +) { + /** All information from the sharing application pertaining to the chooser. */ + val chooserRequest: MutableStateFlow<ChooserRequest> = MutableStateFlow(initialRequest) + + /** Custom actions from the sharing app to be presented in the chooser. */ + // NOTE: this could be derived directly from chooserRequest, but that would require working + // directly with PendingIntents, which complicates testing. + val customActions: MutableStateFlow<List<CustomActionModel>> = MutableStateFlow(initialActions) +} diff --git a/java/src/com/android/intentresolver/v2/data/repository/UserInfoExt.kt b/java/src/com/android/intentresolver/v2/data/repository/UserInfoExt.kt index a0b2d1ef..a61d6d0d 100644 --- a/java/src/com/android/intentresolver/v2/data/repository/UserInfoExt.kt +++ b/java/src/com/android/intentresolver/v2/data/repository/UserInfoExt.kt @@ -1,3 +1,19 @@ +/* + * Copyright (C) 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + package com.android.intentresolver.v2.data.repository import android.content.pm.UserInfo diff --git a/java/src/com/android/intentresolver/v2/data/repository/UserRepository.kt b/java/src/com/android/intentresolver/v2/data/repository/UserRepository.kt index b57609e5..40672249 100644 --- a/java/src/com/android/intentresolver/v2/data/repository/UserRepository.kt +++ b/java/src/com/android/intentresolver/v2/data/repository/UserRepository.kt @@ -1,3 +1,19 @@ +/* + * Copyright (C) 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + package com.android.intentresolver.v2.data.repository import android.content.Context @@ -12,6 +28,7 @@ import android.content.Intent.EXTRA_QUIET_MODE import android.content.Intent.EXTRA_USER import android.content.IntentFilter import android.content.pm.UserInfo +import android.os.Build import android.os.UserHandle import android.os.UserManager import android.util.Log @@ -20,7 +37,6 @@ import com.android.intentresolver.inject.Background import com.android.intentresolver.inject.Main import com.android.intentresolver.inject.ProfileParent import com.android.intentresolver.v2.data.broadcastFlow -import com.android.intentresolver.v2.data.repository.UserRepositoryImpl.UserEvent import com.android.intentresolver.v2.shared.model.User import dagger.hilt.android.qualifiers.ApplicationContext import javax.inject.Inject @@ -57,7 +73,7 @@ interface UserRepository { * stopping a profile user (along with their many associated processes). * * If successful, the change will be applied after the call returns and can be observed using - * [UserRepository.isAvailable] for the given user. + * [UserRepository.availability] for the given user. * * No actions are taken if the user is already in requested state. * @@ -68,9 +84,9 @@ interface UserRepository { private const val TAG = "UserRepository" -private data class UserWithState(val user: User, val available: Boolean) +internal data class UserWithState(val user: User, val available: Boolean) -private typealias UserStates = List<UserWithState> +internal typealias UserStates = List<UserWithState> /** Tracks and publishes state for the parent user and associated profiles. */ class UserRepositoryImpl @@ -98,13 +114,21 @@ constructor( background ) - data class UserEvent(val action: String, val user: UserHandle, val quietMode: Boolean = false) + private fun debugLog(msg: () -> String) { + if (Build.IS_USERDEBUG || Build.IS_ENG) { + Log.d(TAG, msg()) + } + } + + private fun errorLog(msg: String, caught: Throwable? = null) { + Log.e(TAG, msg, caught) + } /** * An exception which indicates that an inconsistency exists between the user state map and the * rest of the system. */ - internal class UserStateException( + private class UserStateException( override val message: String, val event: UserEvent, override val cause: Throwable? = null @@ -113,35 +137,34 @@ constructor( private val sharingScope = CoroutineScope(scope.coroutineContext + backgroundDispatcher) private val usersWithState: Flow<UserStates> = userEvents - .onStart { emit(UserEvent(INITIALIZE, profileParent)) } - .onEach { Log.i(TAG, "userEvent: $it") } - .runningFold<UserEvent, UserStates>(emptyList()) { users, event -> - try { - // Handle an action by performing some operation, then returning a new map - when (event.action) { - INITIALIZE -> createNewUserStates(profileParent) - ACTION_PROFILE_ADDED -> handleProfileAdded(event, users) - ACTION_PROFILE_REMOVED -> handleProfileRemoved(event, users) - ACTION_MANAGED_PROFILE_UNAVAILABLE, - ACTION_MANAGED_PROFILE_AVAILABLE, - ACTION_PROFILE_AVAILABLE, - ACTION_PROFILE_UNAVAILABLE -> handleAvailability(event, users) - else -> { - Log.w(TAG, "Unhandled event: $event)") - users - } - } - } catch (e: UserStateException) { - Log.e(TAG, "An error occurred handling an event: ${e.event}", e) - Log.e(TAG, "Attempting to recover...") - createNewUserStates(profileParent) - } - } + .onStart { emit(Initialize) } + .onEach { debugLog { "userEvent: $it" } } + .runningFold(emptyList(), ::handleEvent) .distinctUntilChanged() - .onEach { Log.i(TAG, "userStateList: $it") } + .onEach { debugLog { "userStateList: $it" } } .stateIn(sharingScope, SharingStarted.Eagerly, emptyList()) .filterNot { it.isEmpty() } + private suspend fun handleEvent(users: UserStates, event: UserEvent): UserStates { + return try { + // Handle an action by performing some operation, then returning a new map + when (event) { + is Initialize -> createNewUserStates(profileParent) + is ProfileAdded -> handleProfileAdded(event, users) + is ProfileRemoved -> handleProfileRemoved(event, users) + is AvailabilityChange -> handleAvailability(event, users) + is UnknownEvent -> { + debugLog { "Unhandled event: $event)" } + users + } + } + } catch (e: UserStateException) { + errorLog("An error occurred handling an event: ${e.event}") + errorLog("Attempting to recover...", e) + createNewUserStates(profileParent) + } + } + override val users: Flow<List<User>> = usersWithState.map { userStateMap -> userStateMap.map { it.user } }.distinctUntilChanged() @@ -151,9 +174,8 @@ constructor( .distinctUntilChanged() override suspend fun requestState(user: User, available: Boolean) { - require(user.type == User.Type.PROFILE) { "Only profile users are supported" } return withContext(backgroundDispatcher) { - Log.i(TAG, "requestQuietModeEnabled: ${!available} for user $user") + debugLog { "requestQuietModeEnabled: ${!available} for user $user" } userManager.requestQuietModeEnabled(/* enableQuietMode = */ !available, user.handle) } } @@ -161,28 +183,28 @@ constructor( private fun List<UserWithState>.update(handle: UserHandle, user: UserWithState) = filter { it.user.id != handle.identifier } + user - private fun handleAvailability(event: UserEvent, current: UserStates): UserStates { + private fun handleAvailability(event: AvailabilityChange, current: UserStates): UserStates { val userEntry = current.firstOrNull { it.user.id == event.user.identifier } ?: throw UserStateException("User was not present in the map", event) return current.update(event.user, userEntry.copy(available = !event.quietMode)) } - private fun handleProfileRemoved(event: UserEvent, current: UserStates): UserStates { + private fun handleProfileRemoved(event: ProfileRemoved, current: UserStates): UserStates { if (!current.any { it.user.id == event.user.identifier }) { throw UserStateException("User was not present in the map", event) } return current.filter { it.user.id != event.user.identifier } } - private suspend fun handleProfileAdded(event: UserEvent, current: UserStates): UserStates { + private suspend fun handleProfileAdded(event: ProfileAdded, current: UserStates): UserStates { val user = try { requireNotNull(readUser(event.user)) } catch (e: Exception) { throw UserStateException("Failed to read user from UserManager", event, e) } - return current + UserWithState(user, !event.quietMode) + return current + UserWithState(user, true) } private suspend fun createNewUserStates(user: UserHandle): UserStates { @@ -209,29 +231,64 @@ constructor( } } +/** A Model representing changes to profiles and availability */ +sealed interface UserEvent + +/** Used as a an initial value to trigger a fetch of all profile data. */ +data object Initialize : UserEvent + +/** A profile was added to the profile group. */ +data class ProfileAdded( + /** The handle for the added profile. */ + val user: UserHandle, +) : UserEvent + +/** A profile was removed from the profile group. */ +data class ProfileRemoved( + /** The handle for the removed profile. */ + val user: UserHandle, +) : UserEvent + +/** A profile has changed availability. */ +data class AvailabilityChange( + /** THe handle for the profile with availability change. */ + val user: UserHandle, + /** The new quietMode state. */ + val quietMode: Boolean = false, +) : UserEvent + +/** An unhandled event, logged and ignored. */ +data class UnknownEvent( + /** The broadcast intent action received */ + val action: String?, +) : UserEvent + /** Used with [broadcastFlow] to transform a UserManager broadcast action into a [UserEvent]. */ -private fun Intent.toUserEvent(): UserEvent? { +private fun Intent.toUserEvent(): UserEvent { val action = action val user = extras?.getParcelable(EXTRA_USER, UserHandle::class.java) - val quietMode = extras?.getBoolean(EXTRA_QUIET_MODE, false) ?: false - return if (user == null || action == null) { - null - } else { - UserEvent(action, user, quietMode) + val quietMode = extras?.getBoolean(EXTRA_QUIET_MODE, false) + return when (action) { + ACTION_PROFILE_ADDED -> ProfileAdded(requireNotNull(user)) + ACTION_PROFILE_REMOVED -> ProfileRemoved(requireNotNull(user)) + ACTION_MANAGED_PROFILE_UNAVAILABLE, + ACTION_MANAGED_PROFILE_AVAILABLE, + ACTION_PROFILE_AVAILABLE, + ACTION_PROFILE_UNAVAILABLE -> + AvailabilityChange(requireNotNull(user), requireNotNull(quietMode)) + else -> UnknownEvent(action) } } -const val INITIALIZE = "INITIALIZE" - private fun createFilter(actions: Iterable<String>): IntentFilter { return IntentFilter().apply { actions.forEach(::addAction) } } -private fun UserInfo?.isAvailable(): Boolean { +internal fun UserInfo?.isAvailable(): Boolean { return this?.isQuietModeEnabled != true } -private fun userBroadcastFlow(context: Context, profileParent: UserHandle): Flow<UserEvent> { +internal fun userBroadcastFlow(context: Context, profileParent: UserHandle): Flow<UserEvent> { val userActions = setOf( ACTION_PROFILE_ADDED, diff --git a/java/src/com/android/intentresolver/v2/data/repository/UserRepositoryModule.kt b/java/src/com/android/intentresolver/v2/data/repository/UserRepositoryModule.kt index a84342f4..ad4faa17 100644 --- a/java/src/com/android/intentresolver/v2/data/repository/UserRepositoryModule.kt +++ b/java/src/com/android/intentresolver/v2/data/repository/UserRepositoryModule.kt @@ -1,3 +1,19 @@ +/* + * Copyright (C) 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + package com.android.intentresolver.v2.data.repository import android.content.Context diff --git a/java/src/com/android/intentresolver/v2/data/repository/UserScopedService.kt b/java/src/com/android/intentresolver/v2/data/repository/UserScopedService.kt index 3553744a..65a48a55 100644 --- a/java/src/com/android/intentresolver/v2/data/repository/UserScopedService.kt +++ b/java/src/com/android/intentresolver/v2/data/repository/UserScopedService.kt @@ -1,46 +1,67 @@ +/* + * Copyright (C) 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + package com.android.intentresolver.v2.data.repository import android.content.Context +import android.os.UserHandle import androidx.core.content.getSystemService -import com.android.intentresolver.v2.shared.model.User +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlin.reflect.KClass /** - * Provides cached instances of a [system service][Context.getSystemService] created with + * Provides instances of a [system service][Context.getSystemService] created with * [the context of a specified user][Context.createContextAsUser]. * - * System services which have only `@UserHandleAware` APIs operate on the user id available from + * Some services which have only `@UserHandleAware` APIs operate on the user id available from * [Context.getUser], the context used to retrieve the service. This utility helps adapt a per-user * API model to work in multi-user manner. * * Example usage: * ``` - * val usageStats = userScopedService<UsageStatsManager>(context) + * @Provides + * fun scopedUserManager(@ApplicationContext ctx: Context): UserScopedService<UserManager> { + * return UserScopedServiceImpl(ctx, UserManager::class) + * } * - * fun getStatsForUser( - * user: User, - * from: Long, - * to: Long - * ): UsageStats { - * return usageStats.forUser(user) - * .queryUsageStats(INTERVAL_BEST, from, to) - * } + * class MyUserHelper @Inject constructor( + * private val userMgr: UserScopedService<UserManager>, + * ) { + * fun isPrivateProfile(user: UserHandle): UserManager { + * return userMgr.forUser(user).isPrivateProfile() + * } + * } * ``` */ -interface UserScopedService<T> { - fun forUser(user: User): T +fun interface UserScopedService<T> { + /** Create a service instance for the given user. */ + fun forUser(user: UserHandle): T } -inline fun <reified T> userScopedService(context: Context): UserScopedService<T> { - return object : UserScopedService<T> { - private val map = mutableMapOf<User, T>() - - override fun forUser(user: User): T { - return synchronized(this) { - map.getOrPut(user) { - val userContext = context.createContextAsUser(user.handle, 0) - requireNotNull(userContext.getSystemService()) - } +class UserScopedServiceImpl<T : Any>( + @ApplicationContext private val context: Context, + private val serviceType: KClass<T>, +) : UserScopedService<T> { + override fun forUser(user: UserHandle): T { + val context = + if (context.user == user) { + context + } else { + context.createContextAsUser(user, 0) } - } + return requireNotNull(context.getSystemService(serviceType.java)) } } diff --git a/java/src/com/android/intentresolver/v2/domain/interactor/UserInteractor.kt b/java/src/com/android/intentresolver/v2/domain/interactor/UserInteractor.kt index 72b604c2..69374f88 100644 --- a/java/src/com/android/intentresolver/v2/domain/interactor/UserInteractor.kt +++ b/java/src/com/android/intentresolver/v2/domain/interactor/UserInteractor.kt @@ -71,9 +71,7 @@ constructor( */ val availability: Flow<Map<Profile, Boolean>> = combine(profiles, userRepository.availability) { profiles, availability -> - profiles.associateWith { - availability.getOrDefault(it.primary, false) - } + profiles.associateWith { availability.getOrDefault(it.primary, false) } } /** diff --git a/java/src/com/android/intentresolver/v2/emptystate/NoCrossProfileEmptyStateProvider.java b/java/src/com/android/intentresolver/v2/emptystate/NoCrossProfileEmptyStateProvider.java index b744c589..d52015bf 100644 --- a/java/src/com/android/intentresolver/v2/emptystate/NoCrossProfileEmptyStateProvider.java +++ b/java/src/com/android/intentresolver/v2/emptystate/NoCrossProfileEmptyStateProvider.java @@ -19,6 +19,7 @@ package com.android.intentresolver.v2.emptystate; import android.app.admin.DevicePolicyEventLogger; import android.app.admin.DevicePolicyManager; import android.content.Context; +import android.content.Intent; import android.os.UserHandle; import androidx.annotation.NonNull; @@ -29,6 +30,11 @@ import com.android.intentresolver.ResolverListAdapter; import com.android.intentresolver.emptystate.CrossProfileIntentsChecker; import com.android.intentresolver.emptystate.EmptyState; import com.android.intentresolver.emptystate.EmptyStateProvider; +import com.android.intentresolver.v2.ProfileHelper; +import com.android.intentresolver.v2.shared.model.Profile; +import com.android.intentresolver.v2.shared.model.User; + +import java.util.List; /** * Empty state provider that does not allow cross profile sharing, it will return a blocker @@ -36,45 +42,56 @@ import com.android.intentresolver.emptystate.EmptyStateProvider; */ public class NoCrossProfileEmptyStateProvider implements EmptyStateProvider { - private final UserHandle mPersonalProfileUserHandle; + private final ProfileHelper mProfileHelper; private final EmptyState mNoWorkToPersonalEmptyState; private final EmptyState mNoPersonalToWorkEmptyState; private final CrossProfileIntentsChecker mCrossProfileIntentsChecker; - private final UserHandle mTabOwnerUserHandleForLaunch; - public NoCrossProfileEmptyStateProvider(UserHandle personalUserHandle, + public NoCrossProfileEmptyStateProvider( + ProfileHelper profileHelper, EmptyState noWorkToPersonalEmptyState, EmptyState noPersonalToWorkEmptyState, - CrossProfileIntentsChecker crossProfileIntentsChecker, - UserHandle tabOwnerUserHandleForLaunch) { - mPersonalProfileUserHandle = personalUserHandle; + CrossProfileIntentsChecker crossProfileIntentsChecker) { + mProfileHelper = profileHelper; mNoWorkToPersonalEmptyState = noWorkToPersonalEmptyState; mNoPersonalToWorkEmptyState = noPersonalToWorkEmptyState; mCrossProfileIntentsChecker = crossProfileIntentsChecker; - mTabOwnerUserHandleForLaunch = tabOwnerUserHandleForLaunch; + } + + private boolean anyCrossProfileAllowedIntents(ResolverListAdapter selected, UserHandle source) { + List<Intent> intents = selected.getIntents(); + UserHandle target = selected.getUserHandle(); + return mCrossProfileIntentsChecker.hasCrossProfileIntents(intents, + source.getIdentifier(), target.getIdentifier()); } @Nullable @Override - public EmptyState getEmptyState(ResolverListAdapter resolverListAdapter) { - boolean shouldShowBlocker = - !mTabOwnerUserHandleForLaunch.equals(resolverListAdapter.getUserHandle()) - && !mCrossProfileIntentsChecker - .hasCrossProfileIntents(resolverListAdapter.getIntents(), - mTabOwnerUserHandleForLaunch.getIdentifier(), - resolverListAdapter.getUserHandle().getIdentifier()); - - if (!shouldShowBlocker) { + public EmptyState getEmptyState(ResolverListAdapter adapter) { + Profile launchedAsProfile = mProfileHelper.getLaunchedAsProfile(); + User launchedAs = mProfileHelper.getLaunchedAsProfile().getPrimary(); + UserHandle tabOwnerHandle = adapter.getUserHandle(); + boolean launchedAsSameUser = launchedAs.getHandle().equals(tabOwnerHandle); + Profile.Type tabOwnerType = mProfileHelper.findProfileType(tabOwnerHandle); + + // Not applicable for private profile. + if (launchedAsProfile.getType() == Profile.Type.PRIVATE + || tabOwnerType == Profile.Type.PRIVATE) { return null; } - if (resolverListAdapter.getUserHandle().equals(mPersonalProfileUserHandle)) { - return mNoWorkToPersonalEmptyState; - } else { - return mNoPersonalToWorkEmptyState; + // Allow access to the tab when launched by the same user as the tab owner + // or when there is at least one target which is permitted for cross-profile. + if (launchedAsSameUser || anyCrossProfileAllowedIntents(adapter, tabOwnerHandle)) { + return null; } - } + switch (launchedAsProfile.getType()) { + case WORK: return mNoWorkToPersonalEmptyState; + case PERSONAL: return mNoPersonalToWorkEmptyState; + } + return null; + } /** * Empty state that gets strings from the device policy manager and tracks events into diff --git a/java/src/com/android/intentresolver/v2/emptystate/ResolverNoCrossProfileEmptyStateProvider.java b/java/src/com/android/intentresolver/v2/emptystate/ResolverNoCrossProfileEmptyStateProvider.java new file mode 100644 index 00000000..f133c31d --- /dev/null +++ b/java/src/com/android/intentresolver/v2/emptystate/ResolverNoCrossProfileEmptyStateProvider.java @@ -0,0 +1,138 @@ +/* + * Copyright (C) 2022 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.intentresolver.v2.emptystate; + +import android.app.admin.DevicePolicyEventLogger; +import android.app.admin.DevicePolicyManager; +import android.content.Context; +import android.os.UserHandle; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.annotation.StringRes; + +import com.android.intentresolver.ResolverListAdapter; +import com.android.intentresolver.emptystate.CrossProfileIntentsChecker; +import com.android.intentresolver.emptystate.EmptyState; +import com.android.intentresolver.emptystate.EmptyStateProvider; + +/** + * Empty state provider that does not allow cross profile sharing, it will return a blocker + * in case if the profile of the current tab is not the same as the profile of the calling app. + */ +public class ResolverNoCrossProfileEmptyStateProvider implements EmptyStateProvider { + + private final UserHandle mPersonalProfileUserHandle; + private final EmptyState mNoWorkToPersonalEmptyState; + private final EmptyState mNoPersonalToWorkEmptyState; + private final CrossProfileIntentsChecker mCrossProfileIntentsChecker; + private final UserHandle mTabOwnerUserHandleForLaunch; + + public ResolverNoCrossProfileEmptyStateProvider(UserHandle personalUserHandle, + EmptyState noWorkToPersonalEmptyState, + EmptyState noPersonalToWorkEmptyState, + CrossProfileIntentsChecker crossProfileIntentsChecker, + UserHandle tabOwnerUserHandleForLaunch) { + mPersonalProfileUserHandle = personalUserHandle; + mNoWorkToPersonalEmptyState = noWorkToPersonalEmptyState; + mNoPersonalToWorkEmptyState = noPersonalToWorkEmptyState; + mCrossProfileIntentsChecker = crossProfileIntentsChecker; + mTabOwnerUserHandleForLaunch = tabOwnerUserHandleForLaunch; + } + + @Nullable + @Override + public EmptyState getEmptyState(ResolverListAdapter resolverListAdapter) { + boolean shouldShowBlocker = + !mTabOwnerUserHandleForLaunch.equals(resolverListAdapter.getUserHandle()) + && !mCrossProfileIntentsChecker + .hasCrossProfileIntents(resolverListAdapter.getIntents(), + mTabOwnerUserHandleForLaunch.getIdentifier(), + resolverListAdapter.getUserHandle().getIdentifier()); + + if (!shouldShowBlocker) { + return null; + } + + if (resolverListAdapter.getUserHandle().equals(mPersonalProfileUserHandle)) { + return mNoWorkToPersonalEmptyState; + } else { + return mNoPersonalToWorkEmptyState; + } + } + + + /** + * Empty state that gets strings from the device policy manager and tracks events into + * event logger of the device policy events. + */ + public static class DevicePolicyBlockerEmptyState implements EmptyState { + + @NonNull + private final Context mContext; + private final String mDevicePolicyStringTitleId; + @StringRes + private final int mDefaultTitleResource; + private final String mDevicePolicyStringSubtitleId; + @StringRes + private final int mDefaultSubtitleResource; + private final int mEventId; + @NonNull + private final String mEventCategory; + + public DevicePolicyBlockerEmptyState(@NonNull Context context, + String devicePolicyStringTitleId, @StringRes int defaultTitleResource, + String devicePolicyStringSubtitleId, @StringRes int defaultSubtitleResource, + int devicePolicyEventId, @NonNull String devicePolicyEventCategory) { + mContext = context; + mDevicePolicyStringTitleId = devicePolicyStringTitleId; + mDefaultTitleResource = defaultTitleResource; + mDevicePolicyStringSubtitleId = devicePolicyStringSubtitleId; + mDefaultSubtitleResource = defaultSubtitleResource; + mEventId = devicePolicyEventId; + mEventCategory = devicePolicyEventCategory; + } + + @Nullable + @Override + public String getTitle() { + return mContext.getSystemService(DevicePolicyManager.class).getResources().getString( + mDevicePolicyStringTitleId, + () -> mContext.getString(mDefaultTitleResource)); + } + + @Nullable + @Override + public String getSubtitle() { + return mContext.getSystemService(DevicePolicyManager.class).getResources().getString( + mDevicePolicyStringSubtitleId, + () -> mContext.getString(mDefaultSubtitleResource)); + } + + @Override + public void onEmptyStateShown() { + DevicePolicyEventLogger.createEvent(mEventId) + .setStrings(mEventCategory) + .write(); + } + + @Override + public boolean shouldSkipDataRebuild() { + return true; + } + } +} diff --git a/java/src/com/android/intentresolver/v2/emptystate/WorkProfilePausedEmptyStateProvider.java b/java/src/com/android/intentresolver/v2/emptystate/WorkProfilePausedEmptyStateProvider.java index a6fee3ec..af13f8fe 100644 --- a/java/src/com/android/intentresolver/v2/emptystate/WorkProfilePausedEmptyStateProvider.java +++ b/java/src/com/android/intentresolver/v2/emptystate/WorkProfilePausedEmptyStateProvider.java @@ -18,6 +18,8 @@ package com.android.intentresolver.v2.emptystate; import static android.app.admin.DevicePolicyResources.Strings.Core.RESOLVER_WORK_PAUSED_TITLE; +import static java.util.Objects.requireNonNull; + import android.app.admin.DevicePolicyEventLogger; import android.app.admin.DevicePolicyManager; import android.content.Context; @@ -30,9 +32,11 @@ import androidx.annotation.Nullable; import com.android.intentresolver.MultiProfilePagerAdapter.OnSwitchOnWorkSelectedListener; import com.android.intentresolver.R; import com.android.intentresolver.ResolverListAdapter; -import com.android.intentresolver.WorkProfileAvailabilityManager; import com.android.intentresolver.emptystate.EmptyState; import com.android.intentresolver.emptystate.EmptyStateProvider; +import com.android.intentresolver.v2.ProfileAvailability; +import com.android.intentresolver.v2.ProfileHelper; +import com.android.intentresolver.v2.shared.model.Profile; /** * Chooser/ResolverActivity empty state provider that returns empty state which is shown when @@ -40,20 +44,20 @@ import com.android.intentresolver.emptystate.EmptyStateProvider; */ public class WorkProfilePausedEmptyStateProvider implements EmptyStateProvider { - private final UserHandle mWorkProfileUserHandle; - private final WorkProfileAvailabilityManager mWorkProfileAvailability; + private final ProfileHelper mProfileHelper; + private final ProfileAvailability mProfileAvailability; private final String mMetricsCategory; private final OnSwitchOnWorkSelectedListener mOnSwitchOnWorkSelectedListener; private final Context mContext; public WorkProfilePausedEmptyStateProvider(@NonNull Context context, - @Nullable UserHandle workProfileUserHandle, - @NonNull WorkProfileAvailabilityManager workProfileAvailability, + ProfileHelper profileHelper, + ProfileAvailability profileAvailability, @Nullable OnSwitchOnWorkSelectedListener onSwitchOnWorkSelectedListener, @NonNull String metricsCategory) { mContext = context; - mWorkProfileUserHandle = workProfileUserHandle; - mWorkProfileAvailability = workProfileAvailability; + mProfileHelper = profileHelper; + mProfileAvailability = profileAvailability; mMetricsCategory = metricsCategory; mOnSwitchOnWorkSelectedListener = onSwitchOnWorkSelectedListener; } @@ -61,22 +65,33 @@ public class WorkProfilePausedEmptyStateProvider implements EmptyStateProvider { @Nullable @Override public EmptyState getEmptyState(ResolverListAdapter resolverListAdapter) { - if (!resolverListAdapter.getUserHandle().equals(mWorkProfileUserHandle) - || !mWorkProfileAvailability.isQuietModeEnabled() - || resolverListAdapter.getCount() == 0) { + UserHandle userHandle = resolverListAdapter.getUserHandle(); + if (!mProfileHelper.getWorkProfilePresent()) { + return null; + } + Profile workProfile = requireNonNull(mProfileHelper.getWorkProfile()); + + // Policy: only show the "Work profile paused" state when: + // * provided list adapter is from the work profile + // * the list adapter is not empty + // * work profile quiet mode is _enabled_ (unavailable) + + if (!userHandle.equals(workProfile.getPrimary().getHandle()) + || resolverListAdapter.getCount() == 0 + || mProfileAvailability.isAvailable(workProfile)) { return null; } - final String title = mContext.getSystemService(DevicePolicyManager.class) + String title = mContext.getSystemService(DevicePolicyManager.class) .getResources().getString(RESOLVER_WORK_PAUSED_TITLE, () -> mContext.getString(R.string.resolver_turn_on_work_apps)); - return new WorkProfileOffEmptyState(title, (tab) -> { + return new WorkProfileOffEmptyState(title, /* EmptyState.ClickListener */ (tab) -> { tab.showSpinner(); if (mOnSwitchOnWorkSelectedListener != null) { mOnSwitchOnWorkSelectedListener.onSwitchOnWorkSelected(); } - mWorkProfileAvailability.requestQuietModeEnabled(false); + mProfileAvailability.requestQuietModeState(workProfile, false); }, mMetricsCategory); } diff --git a/java/src/com/android/intentresolver/v2/platform/AppPredictionModule.kt b/java/src/com/android/intentresolver/v2/platform/AppPredictionModule.kt index 9ca9d871..090fab6b 100644 --- a/java/src/com/android/intentresolver/v2/platform/AppPredictionModule.kt +++ b/java/src/com/android/intentresolver/v2/platform/AppPredictionModule.kt @@ -18,7 +18,6 @@ package com.android.intentresolver.v2.platform import android.content.pm.PackageManager import dagger.Module import dagger.Provides -import dagger.Reusable import dagger.hilt.InstallIn import dagger.hilt.components.SingletonComponent import javax.inject.Qualifier @@ -33,13 +32,11 @@ annotation class AppPredictionAvailable @InstallIn(SingletonComponent::class) object AppPredictionModule { - /** - * Eventually replaced with: Optional<AppPredictionRepository>, etc. - */ + /** Eventually replaced with: Optional<AppPredictionRepository>, etc. */ @Provides @Singleton @AppPredictionAvailable fun isAppPredictionAvailable(packageManager: PackageManager): Boolean { return packageManager.appPredictionServicePackageName != null } -}
\ No newline at end of file +} diff --git a/java/src/com/android/intentresolver/v2/profiles/ChooserMultiProfilePagerAdapter.java b/java/src/com/android/intentresolver/v2/profiles/ChooserMultiProfilePagerAdapter.java index 0ee9d141..c078c43f 100644 --- a/java/src/com/android/intentresolver/v2/profiles/ChooserMultiProfilePagerAdapter.java +++ b/java/src/com/android/intentresolver/v2/profiles/ChooserMultiProfilePagerAdapter.java @@ -151,6 +151,16 @@ public class ChooserMultiProfilePagerAdapter extends MultiProfilePagerAdapter< } } + /** Cleanup system resources */ + public void destroy() { + for (int i = 0, count = getItemCount(); i < count; i++) { + ChooserGridAdapter adapter = getPageAdapterForIndex(i); + if (adapter != null) { + adapter.getListAdapter().onDestroy(); + } + } + } + private static class BottomPaddingOverrideSupplier implements Supplier<Optional<Integer>> { private final Context mContext; private int mBottomOffset; diff --git a/java/src/com/android/intentresolver/v2/profiles/MultiProfilePagerAdapter.java b/java/src/com/android/intentresolver/v2/profiles/MultiProfilePagerAdapter.java index 43785db3..341e7043 100644 --- a/java/src/com/android/intentresolver/v2/profiles/MultiProfilePagerAdapter.java +++ b/java/src/com/android/intentresolver/v2/profiles/MultiProfilePagerAdapter.java @@ -15,7 +15,6 @@ */ package com.android.intentresolver.v2.profiles; -import android.annotation.IntDef; import android.annotation.Nullable; import android.os.Trace; import android.os.UserHandle; @@ -32,6 +31,7 @@ import androidx.viewpager.widget.ViewPager; import com.android.intentresolver.ResolverListAdapter; import com.android.intentresolver.emptystate.EmptyState; import com.android.intentresolver.emptystate.EmptyStateProvider; +import com.android.intentresolver.v2.shared.model.Profile; import com.android.internal.annotations.VisibleForTesting; import com.google.common.collect.ImmutableList; @@ -61,10 +61,11 @@ public class MultiProfilePagerAdapter< SinglePageAdapterT, ListAdapterT extends ResolverListAdapter> extends PagerAdapter { - public static final int PROFILE_PERSONAL = 0; - public static final int PROFILE_WORK = 1; + public static final int PROFILE_PERSONAL = Profile.Type.PERSONAL.ordinal(); + public static final int PROFILE_WORK = Profile.Type.WORK.ordinal(); - @IntDef({PROFILE_PERSONAL, PROFILE_WORK}) + // Removed, must be constants. This is only used for linting anyway. + // @IntDef({PROFILE_PERSONAL, PROFILE_WORK}) public @interface ProfileType {} private final Function<SinglePageAdapterT, ListAdapterT> mListAdapterExtractor; @@ -244,6 +245,7 @@ public class MultiProfilePagerAdapter< Runnable onTabChangeListener, OnProfileSelectedListener clientOnProfileSelectedListener) { tabHost.setup(); + tabHost.getTabWidget().removeAllViews(); viewPager.setSaveEnabled(false); for (int pageNumber = 0; pageNumber < getItemCount(); ++pageNumber) { diff --git a/java/src/com/android/intentresolver/v2/shared/model/User.kt b/java/src/com/android/intentresolver/v2/shared/model/User.kt index 97db3280..46279ad0 100644 --- a/java/src/com/android/intentresolver/v2/shared/model/User.kt +++ b/java/src/com/android/intentresolver/v2/shared/model/User.kt @@ -18,8 +18,6 @@ package com.android.intentresolver.v2.shared.model import android.annotation.UserIdInt import android.os.UserHandle -import com.android.intentresolver.v2.shared.model.User.Type.FULL -import com.android.intentresolver.v2.shared.model.User.Type.PROFILE /** * A User represents the owner of a distinct set of content. @@ -45,21 +43,10 @@ data class User( ) { val handle: UserHandle = UserHandle.of(id) - val type: Type - get() = role.type - - enum class Type { - FULL, - PROFILE - } - - enum class Role( - /** The type of the role user. */ - val type: Type - ) { - PERSONAL(FULL), - PRIVATE(PROFILE), - WORK(PROFILE), - CLONE(PROFILE) + enum class Role { + PERSONAL, + PRIVATE, + WORK, + CLONE } } diff --git a/java/src/com/android/intentresolver/v2/ui/ProfilePagerResources.kt b/java/src/com/android/intentresolver/v2/ui/ProfilePagerResources.kt index 1cd72ba5..ca7ae0fc 100644 --- a/java/src/com/android/intentresolver/v2/ui/ProfilePagerResources.kt +++ b/java/src/com/android/intentresolver/v2/ui/ProfilePagerResources.kt @@ -17,11 +17,11 @@ package com.android.intentresolver.v2.ui import android.content.res.Resources +import com.android.intentresolver.R import com.android.intentresolver.inject.ApplicationOwned import com.android.intentresolver.v2.data.repository.DevicePolicyResources import com.android.intentresolver.v2.shared.model.Profile import javax.inject.Inject -import com.android.intentresolver.R class ProfilePagerResources @Inject @@ -50,4 +50,4 @@ constructor( Profile.Type.PRIVATE -> privateTabAccessibilityLabel } } -}
\ No newline at end of file +} diff --git a/java/src/com/android/intentresolver/v2/ui/ShortcutPolicyModule.kt b/java/src/com/android/intentresolver/v2/ui/ShortcutPolicyModule.kt new file mode 100644 index 00000000..5e098cd5 --- /dev/null +++ b/java/src/com/android/intentresolver/v2/ui/ShortcutPolicyModule.kt @@ -0,0 +1,94 @@ +/* + * Copyright (C) 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.intentresolver.v2.ui + +import android.content.res.Resources +import android.provider.DeviceConfig +import com.android.intentresolver.R +import com.android.intentresolver.inject.ApplicationOwned +import com.android.internal.config.sysui.SystemUiDeviceConfigFlags +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import javax.inject.Qualifier +import javax.inject.Singleton + +@Qualifier +@MustBeDocumented +@Retention(AnnotationRetention.RUNTIME) +annotation class AppShortcutLimit + +@Qualifier +@MustBeDocumented +@Retention(AnnotationRetention.RUNTIME) +annotation class EnforceShortcutLimit + +@Qualifier +@MustBeDocumented +@Retention(AnnotationRetention.RUNTIME) +annotation class ShortcutRowLimit + +@Module +@InstallIn(SingletonComponent::class) +object ShortcutPolicyModule { + /** + * Defines the limit for the number of shortcut targets provided for any single app. + * + * This value applies to both results from Shortcut-service and app-provided targets on a + * per-package basis. + */ + @Provides + @Singleton + @AppShortcutLimit + fun appShortcutLimit(@ApplicationOwned resources: Resources): Int { + return resources.getInteger(R.integer.config_maxShortcutTargetsPerApp) + } + + /** + * Once this value is no longer necessary it should be replaced in tests with simply replacing + * [AppShortcutLimit]: + * ``` + * @BindValue + * @AppShortcutLimit + * var shortcutLimit = Int.MAX_VALUE + * ``` + */ + @Provides + @Singleton + @EnforceShortcutLimit + fun applyShortcutLimit(): Boolean { + return DeviceConfig.getBoolean( + DeviceConfig.NAMESPACE_SYSTEMUI, + SystemUiDeviceConfigFlags.APPLY_SHARING_APP_LIMITS_IN_SYSUI, + true + ) + } + + /** + * Defines the limit for the number of shortcuts presented within the direct share row. + * + * This value applies to all displayed direct share targets, including those from Shortcut + * service as well as app-provided targets. + */ + @Provides + @Singleton + @ShortcutRowLimit + fun shortcutRowLimit(@ApplicationOwned resources: Resources): Int { + return resources.getInteger(R.integer.config_chooser_max_targets_per_row) + } +} diff --git a/java/src/com/android/intentresolver/v2/ui/model/ActivityModel.kt b/java/src/com/android/intentresolver/v2/ui/model/ActivityModel.kt index 07b17435..67c2a25e 100644 --- a/java/src/com/android/intentresolver/v2/ui/model/ActivityModel.kt +++ b/java/src/com/android/intentresolver/v2/ui/model/ActivityModel.kt @@ -20,6 +20,7 @@ import android.content.Intent import android.net.Uri import android.os.Parcel import android.os.Parcelable +import com.android.intentresolver.v2.data.model.ANDROID_APP_SCHEME import com.android.intentresolver.v2.ext.readParcelable import com.android.intentresolver.v2.ext.requireParcelable import java.util.Objects diff --git a/java/src/com/android/intentresolver/v2/ui/model/ResolverRequest.kt b/java/src/com/android/intentresolver/v2/ui/model/ResolverRequest.kt index a4f74ca9..44010caf 100644 --- a/java/src/com/android/intentresolver/v2/ui/model/ResolverRequest.kt +++ b/java/src/com/android/intentresolver/v2/ui/model/ResolverRequest.kt @@ -19,8 +19,8 @@ package com.android.intentresolver.v2.ui.model import android.content.Intent import android.content.pm.ResolveInfo import android.os.UserHandle -import com.android.intentresolver.v2.shared.model.Profile import com.android.intentresolver.v2.ext.isHomeIntent +import com.android.intentresolver.v2.shared.model.Profile /** All of the things that are consumed from an incoming Intent Resolution request (+Extras). */ data class ResolverRequest( diff --git a/java/src/com/android/intentresolver/v2/ui/viewmodel/ChooserRequestReader.kt b/java/src/com/android/intentresolver/v2/ui/viewmodel/ChooserRequestReader.kt index 91eed408..a25fcbea 100644 --- a/java/src/com/android/intentresolver/v2/ui/viewmodel/ChooserRequestReader.kt +++ b/java/src/com/android/intentresolver/v2/ui/viewmodel/ChooserRequestReader.kt @@ -44,10 +44,10 @@ import com.android.intentresolver.ContentTypeHint import com.android.intentresolver.R import com.android.intentresolver.inject.ChooserServiceFlags import com.android.intentresolver.util.hasValidIcon +import com.android.intentresolver.v2.data.model.ChooserRequest import com.android.intentresolver.v2.ext.hasSendAction import com.android.intentresolver.v2.ext.ifMatch import com.android.intentresolver.v2.ui.model.ActivityModel -import com.android.intentresolver.v2.ui.model.ChooserRequest import com.android.intentresolver.v2.validation.Validation import com.android.intentresolver.v2.validation.ValidationResult import com.android.intentresolver.v2.validation.types.IntentOrUri @@ -65,10 +65,10 @@ internal fun Intent.maybeAddSendActionFlags() = } fun readChooserRequest( - launch: ActivityModel, + model: ActivityModel, flags: ChooserServiceFlags ): ValidationResult<ChooserRequest> { - val extras = launch.intent.extras ?: Bundle() + val extras = model.intent.extras ?: Bundle() @Suppress("DEPRECATION") return validateFrom(extras::get) { val targetIntent = required(IntentOrUri(EXTRA_INTENT)).maybeAddSendActionFlags() @@ -154,12 +154,12 @@ fun readChooserRequest( isSendActionTarget = isSendAction, targetType = targetIntent.type, launchedFromPackage = - requireNotNull(launch.launchedFromPackage) { + requireNotNull(model.launchedFromPackage) { "launch.fromPackage was null, See Activity.getLaunchedFromPackage()" }, title = customTitle, defaultTitleResource = defaultTitleResource, - referrer = launch.referrer, + referrer = model.referrer, filteredComponentNames = filteredComponents, callerChooserTargets = callerChooserTargets, chooserActions = chooserActions, diff --git a/java/src/com/android/intentresolver/v2/ui/viewmodel/ChooserViewModel.kt b/java/src/com/android/intentresolver/v2/ui/viewmodel/ChooserViewModel.kt index 8ed2fa29..e39329b1 100644 --- a/java/src/com/android/intentresolver/v2/ui/viewmodel/ChooserViewModel.kt +++ b/java/src/com/android/intentresolver/v2/ui/viewmodel/ChooserViewModel.kt @@ -18,16 +18,26 @@ package com.android.intentresolver.v2.ui.viewmodel import android.util.Log import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.android.intentresolver.contentpreview.payloadtoggle.domain.interactor.FetchPreviewsInteractor +import com.android.intentresolver.contentpreview.payloadtoggle.domain.interactor.ProcessTargetIntentUpdatesInteractor +import com.android.intentresolver.contentpreview.payloadtoggle.ui.viewmodel.ShareouselViewModel +import com.android.intentresolver.inject.Background import com.android.intentresolver.inject.ChooserServiceFlags +import com.android.intentresolver.v2.data.model.ChooserRequest +import com.android.intentresolver.v2.data.repository.ChooserRequestRepository import com.android.intentresolver.v2.ui.model.ActivityModel import com.android.intentresolver.v2.ui.model.ActivityModel.Companion.ACTIVITY_MODEL_KEY -import com.android.intentresolver.v2.ui.model.ChooserRequest import com.android.intentresolver.v2.validation.Invalid import com.android.intentresolver.v2.validation.Valid import com.android.intentresolver.v2.validation.ValidationResult -import com.android.intentresolver.v2.validation.log +import dagger.Lazy import dagger.hilt.android.lifecycle.HiltViewModel import javax.inject.Inject +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch private const val TAG = "ChooserViewModel" @@ -36,7 +46,18 @@ class ChooserViewModel @Inject constructor( args: SavedStateHandle, - flags: ChooserServiceFlags, + private val shareouselViewModelProvider: Lazy<ShareouselViewModel>, + private val processUpdatesInteractor: Lazy<ProcessTargetIntentUpdatesInteractor>, + private val fetchPreviewsInteractor: Lazy<FetchPreviewsInteractor>, + @Background private val bgDispatcher: CoroutineDispatcher, + private val flags: ChooserServiceFlags, + /** + * Provided only for the express purpose of early exit in the event of an invalid request. + * + * Note: [request] can only be safely accessed after checking if this value is [Valid]. + */ + val initialRequest: ValidationResult<ChooserRequest>, + private val chooserRequestRepository: Lazy<ChooserRequestRepository>, ) : ViewModel() { /** Parcelable-only references provided from the creating Activity */ @@ -45,23 +66,29 @@ constructor( "ActivityModel missing in SavedStateHandle! ($ACTIVITY_MODEL_KEY)" } - /** The result of reading and validating the inputs provided in savedState. */ - private val status: ValidationResult<ChooserRequest> = readChooserRequest(activityModel, flags) - - val chooserRequest: ChooserRequest by lazy { - when (status) { - is Valid -> status.value - is Invalid -> error(status.errors) + val shareouselViewModel: ShareouselViewModel by lazy { + // TODO: consolidate this logic, this would require a consolidated preview view model but + // for now just postpone starting the payload selection preview machinery until it's needed + assert(flags.chooserPayloadToggling()) { + "An attempt to use payload selection preview with the disabled flag" } + + viewModelScope.launch(bgDispatcher) { processUpdatesInteractor.get().activate() } + viewModelScope.launch(bgDispatcher) { fetchPreviewsInteractor.get().activate() } + shareouselViewModelProvider.get() } - fun init(): Boolean { - Log.i(TAG, "viewModel init") - if (status is Invalid) { - status.errors.forEach { finding -> finding.log(TAG) } - return false + /** + * A [StateFlow] of [ChooserRequest]. + * + * Note: Only safe to access after checking if [initialRequest] is [Valid]. + */ + val request: StateFlow<ChooserRequest> + get() = chooserRequestRepository.get().chooserRequest.asStateFlow() + + init { + if (initialRequest is Invalid) { + Log.w(TAG, "initialRequest is Invalid, initialization failed") } - Log.i(TAG, "request = $chooserRequest") - return true } } diff --git a/java/src/com/android/intentresolver/v2/validation/types/IntentOrUri.kt b/java/src/com/android/intentresolver/v2/validation/types/IntentOrUri.kt index 050bd895..fc51ba1e 100644 --- a/java/src/com/android/intentresolver/v2/validation/types/IntentOrUri.kt +++ b/java/src/com/android/intentresolver/v2/validation/types/IntentOrUri.kt @@ -31,7 +31,6 @@ class IntentOrUri(override val key: String) : Validator<Intent> { source: (String) -> Any?, importance: Importance ): ValidationResult<Intent> { - return when (val value = source(key)) { // An intent, return it. is Intent -> Valid(value) @@ -41,10 +40,11 @@ class IntentOrUri(override val key: String) : Validator<Intent> { is Uri -> Valid(Intent.parseUri(value.toString(), Intent.URI_INTENT_SCHEME)) // No value present. - null -> when (importance) { - Importance.WARNING -> Invalid() // No warnings if optional, but missing - Importance.CRITICAL -> Invalid(NoValue(key, importance, Intent::class)) - } + null -> + when (importance) { + Importance.WARNING -> Invalid() // No warnings if optional, but missing + Importance.CRITICAL -> Invalid(NoValue(key, importance, Intent::class)) + } // Some other type. else -> { diff --git a/java/src/com/android/intentresolver/v2/validation/types/ParceledArray.kt b/java/src/com/android/intentresolver/v2/validation/types/ParceledArray.kt index 78adfd36..b68d972f 100644 --- a/java/src/com/android/intentresolver/v2/validation/types/ParceledArray.kt +++ b/java/src/com/android/intentresolver/v2/validation/types/ParceledArray.kt @@ -15,7 +15,6 @@ */ package com.android.intentresolver.v2.validation.types -import android.content.Intent import com.android.intentresolver.v2.validation.Importance import com.android.intentresolver.v2.validation.Invalid import com.android.intentresolver.v2.validation.NoValue @@ -36,13 +35,13 @@ class ParceledArray<T : Any>( source: (String) -> Any?, importance: Importance ): ValidationResult<List<T>> { - return when (val value: Any? = source(key)) { // No value present. - null -> when (importance) { - Importance.WARNING -> Invalid() // No warnings if optional, but missing - Importance.CRITICAL -> Invalid(NoValue(key, importance, elementType)) - } + null -> + when (importance) { + Importance.WARNING -> Invalid() // No warnings if optional, but missing + Importance.CRITICAL -> Invalid(NoValue(key, importance, elementType)) + } // A parcel does not transfer the element type information for parcelable // arrays. This leads to a restored type of Array<Parcelable>, which is // incompatible with Array<T : Parcelable>. diff --git a/java/src/com/android/intentresolver/v2/validation/types/SimpleValue.kt b/java/src/com/android/intentresolver/v2/validation/types/SimpleValue.kt index 0105541d..0badebc4 100644 --- a/java/src/com/android/intentresolver/v2/validation/types/SimpleValue.kt +++ b/java/src/com/android/intentresolver/v2/validation/types/SimpleValue.kt @@ -37,21 +37,24 @@ class SimpleValue<T : Any>( expected.isInstance(value) -> return Valid(expected.cast(value)) // No value is present. - value == null -> when (importance) { - Importance.WARNING -> Invalid() // No warnings if optional, but missing - Importance.CRITICAL -> Invalid(NoValue(key, importance, expected)) - } + value == null -> + when (importance) { + Importance.WARNING -> Invalid() // No warnings if optional, but missing + Importance.CRITICAL -> Invalid(NoValue(key, importance, expected)) + } // The value is some other type. else -> - Invalid(listOf( - ValueIsWrongType( - key, - importance, - actualType = value::class, - allowedTypes = listOf(expected) + Invalid( + listOf( + ValueIsWrongType( + key, + importance, + actualType = value::class, + allowedTypes = listOf(expected) + ) ) - )) + ) } } } diff --git a/java/src/com/android/intentresolver/widget/ActionRow.kt b/java/src/com/android/intentresolver/widget/ActionRow.kt index 6764d3ae..c1f03751 100644 --- a/java/src/com/android/intentresolver/widget/ActionRow.kt +++ b/java/src/com/android/intentresolver/widget/ActionRow.kt @@ -22,7 +22,9 @@ import android.graphics.drawable.Drawable interface ActionRow { fun setActions(actions: List<Action>) - class Action @JvmOverloads constructor( + class Action + @JvmOverloads + constructor( // TODO: apparently, IDs set to this field are used in unit tests only; evaluate whether we // get rid of them val id: Int = ID_NULL, diff --git a/java/src/com/android/intentresolver/widget/ImagePreviewView.kt b/java/src/com/android/intentresolver/widget/ImagePreviewView.kt index 3f0458ee..55418c49 100644 --- a/java/src/com/android/intentresolver/widget/ImagePreviewView.kt +++ b/java/src/com/android/intentresolver/widget/ImagePreviewView.kt @@ -24,15 +24,16 @@ interface ImagePreviewView { /** * [ImagePreviewView] progressively prepares views for shared element transition and reports - * each successful preparation with [onTransitionElementReady] call followed by - * closing [onAllTransitionElementsReady] invocation. Thus the overall invocation pattern is - * zero or more [onTransitionElementReady] calls followed by the final - * [onAllTransitionElementsReady] call. + * each successful preparation with [onTransitionElementReady] call followed by closing + * [onAllTransitionElementsReady] invocation. Thus the overall invocation pattern is zero or + * more [onTransitionElementReady] calls followed by the final [onAllTransitionElementsReady] + * call. */ interface TransitionElementStatusCallback { /** - * Invoked when a view for a shared transition animation element is ready i.e. the image - * is loaded and the view is laid out. + * Invoked when a view for a shared transition animation element is ready i.e. the image is + * loaded and the view is laid out. + * * @param name shared element name. */ fun onTransitionElementReady(name: String) diff --git a/java/src/com/android/intentresolver/widget/RecyclerViewExtensions.kt b/java/src/com/android/intentresolver/widget/RecyclerViewExtensions.kt index a7906001..a8aa633b 100644 --- a/java/src/com/android/intentresolver/widget/RecyclerViewExtensions.kt +++ b/java/src/com/android/intentresolver/widget/RecyclerViewExtensions.kt @@ -26,10 +26,10 @@ internal val RecyclerView.areAllChildrenVisible: Boolean val first = getChildAt(0) val last = getChildAt(count - 1) val itemCount = adapter?.itemCount ?: 0 - return getChildAdapterPosition(first) == 0 - && getChildAdapterPosition(last) == itemCount - 1 - && isFullyVisible(first) - && isFullyVisible(last) + return getChildAdapterPosition(first) == 0 && + getChildAdapterPosition(last) == itemCount - 1 && + isFullyVisible(first) && + isFullyVisible(last) } private fun RecyclerView.isFullyVisible(view: View): Boolean = diff --git a/java/src/com/android/intentresolver/widget/ViewExtensions.kt b/java/src/com/android/intentresolver/widget/ViewExtensions.kt index 11b7c146..d19933f5 100644 --- a/java/src/com/android/intentresolver/widget/ViewExtensions.kt +++ b/java/src/com/android/intentresolver/widget/ViewExtensions.kt @@ -19,21 +19,26 @@ package com.android.intentresolver.widget import android.util.Log import android.view.View import androidx.core.view.OneShotPreDrawListener -import kotlinx.coroutines.suspendCancellableCoroutine import java.util.concurrent.atomic.AtomicBoolean +import kotlinx.coroutines.suspendCancellableCoroutine internal suspend fun View.waitForPreDraw(): Unit = suspendCancellableCoroutine { continuation -> val isResumed = AtomicBoolean(false) - val callback = OneShotPreDrawListener.add( - this, - Runnable { - if (isResumed.compareAndSet(false, true)) { - continuation.resumeWith(Result.success(Unit)) - } else { - // it's not really expected but in some unknown corner-case let's not crash - Log.e("waitForPreDraw", "An attempt to resume a completed coroutine", Exception()) + val callback = + OneShotPreDrawListener.add( + this, + Runnable { + if (isResumed.compareAndSet(false, true)) { + continuation.resumeWith(Result.success(Unit)) + } else { + // it's not really expected but in some unknown corner-case let's not crash + Log.e( + "waitForPreDraw", + "An attempt to resume a completed coroutine", + Exception() + ) + } } - } - ) + ) continuation.invokeOnCancellation { callback.removeListener() } } diff --git a/tests/activity/Android.bp b/tests/activity/Android.bp index f69caf0e..32077f98 100644 --- a/tests/activity/Android.bp +++ b/tests/activity/Android.bp @@ -15,6 +15,7 @@ // package { + default_team: "trendy_team_capture_and_share", default_applicable_licenses: ["Android-Apache-2.0"], } @@ -53,6 +54,7 @@ android_test { "junit", "kotlinx_coroutines_test", "mockito-target-minus-junit4", + "mockito-kotlin2", "testables", "truth", "truth-java8-extension", diff --git a/tests/activity/src/com/android/intentresolver/ResolverActivityTest.java b/tests/activity/src/com/android/intentresolver/ResolverActivityTest.java index dde2f980..05d397a2 100644 --- a/tests/activity/src/com/android/intentresolver/ResolverActivityTest.java +++ b/tests/activity/src/com/android/intentresolver/ResolverActivityTest.java @@ -49,7 +49,7 @@ import android.view.View; import android.widget.RelativeLayout; import android.widget.TextView; -import androidx.test.InstrumentationRegistry; +import androidx.test.platform.app.InstrumentationRegistry; import androidx.test.espresso.Espresso; import androidx.test.espresso.NoMatchingViewException; import androidx.test.rule.ActivityTestRule; diff --git a/tests/activity/src/com/android/intentresolver/UnbundledChooserActivityTest.java b/tests/activity/src/com/android/intentresolver/UnbundledChooserActivityTest.java index f597d7f2..4077295c 100644 --- a/tests/activity/src/com/android/intentresolver/UnbundledChooserActivityTest.java +++ b/tests/activity/src/com/android/intentresolver/UnbundledChooserActivityTest.java @@ -119,6 +119,7 @@ import androidx.test.rule.ActivityTestRule; import com.android.intentresolver.chooser.DisplayResolveInfo; import com.android.intentresolver.contentpreview.ImageLoader; +import com.android.intentresolver.ext.RecyclerViewExt; import com.android.intentresolver.logging.EventLog; import com.android.intentresolver.logging.FakeEventLog; import com.android.intentresolver.shortcuts.ShortcutLoader; @@ -800,7 +801,7 @@ public class UnbundledChooserActivityTest { Uri uri = createTestContentProviderUri("image/png", null); Intent sendIntent = createSendImageIntent(uri); ChooserActivityOverrideData.getInstance().imageLoader = - new TestPreviewImageLoader(Collections.emptyMap()); + new FakeImageLoader(Collections.emptyMap()); sendIntent.putExtra(Intent.EXTRA_TEXT, "https://google.com/search?q=google"); List<ResolvedComponentInfo> resolvedComponentInfos = Arrays.asList( @@ -935,6 +936,7 @@ public class UnbundledChooserActivityTest { throw exception; } RecyclerView recyclerView = (RecyclerView) view; + RecyclerViewExt.endAnimations(recyclerView); assertThat(recyclerView.getAdapter().getItemCount(), is(1)); assertThat(recyclerView.getChildCount(), is(1)); View imageView = recyclerView.getChildAt(0); @@ -958,7 +960,7 @@ public class UnbundledChooserActivityTest { Intent sendIntent = createSendUriIntentWithPreview(uris); ChooserActivityOverrideData.getInstance().imageLoader = - new TestPreviewImageLoader(Collections.emptyMap()); + new FakeImageLoader(Collections.emptyMap()); List<ResolvedComponentInfo> resolvedComponentInfos = createResolvedComponentsForTest(2); @@ -1076,7 +1078,7 @@ public class UnbundledChooserActivityTest { bitmaps.put(imgTwoUri, createWideBitmap(Color.GREEN)); bitmaps.put(docUri, createWideBitmap(Color.BLUE)); ChooserActivityOverrideData.getInstance().imageLoader = - new TestPreviewImageLoader(bitmaps); + new FakeImageLoader(bitmaps); List<ResolvedComponentInfo> resolvedComponentInfos = createResolvedComponentsForTest(2); setupResolverControllers(resolvedComponentInfos); @@ -1094,6 +1096,7 @@ public class UnbundledChooserActivityTest { throw exception; } RecyclerView recyclerView = (RecyclerView) view; + RecyclerViewExt.endAnimations(recyclerView); assertThat(recyclerView.getChildCount()).isAtLeast(1); // the first view is a preview View imageView = recyclerView.getChildAt(0).findViewById(R.id.image); @@ -3122,6 +3125,6 @@ public class UnbundledChooserActivityTest { } private static ImageLoader createImageLoader(Uri uri, Bitmap bitmap) { - return new TestPreviewImageLoader(Collections.singletonMap(uri, bitmap)); + return new FakeImageLoader(Collections.singletonMap(uri, bitmap)); } } diff --git a/tests/activity/src/com/android/intentresolver/UnbundledChooserActivityWorkProfileTest.java b/tests/activity/src/com/android/intentresolver/UnbundledChooserActivityWorkProfileTest.java index da879f74..12def1de 100644 --- a/tests/activity/src/com/android/intentresolver/UnbundledChooserActivityWorkProfileTest.java +++ b/tests/activity/src/com/android/intentresolver/UnbundledChooserActivityWorkProfileTest.java @@ -44,7 +44,7 @@ import android.companion.DeviceFilter; import android.content.Intent; import android.os.UserHandle; -import androidx.test.InstrumentationRegistry; +import androidx.test.platform.app.InstrumentationRegistry; import androidx.test.espresso.NoMatchingViewException; import androidx.test.rule.ActivityTestRule; diff --git a/java/src/com/android/intentresolver/contentpreview/MutableActionFactory.kt b/tests/activity/src/com/android/intentresolver/ext/RecyclerViewExt.kt index 1cc1a6a6..90acaa60 100644 --- a/java/src/com/android/intentresolver/contentpreview/MutableActionFactory.kt +++ b/tests/activity/src/com/android/intentresolver/ext/RecyclerViewExt.kt @@ -14,16 +14,15 @@ * limitations under the License. */ -package com.android.intentresolver.contentpreview +@file:JvmName("RecyclerViewExt") -import android.service.chooser.ChooserAction -import com.android.intentresolver.widget.ActionRow -import kotlinx.coroutines.flow.Flow +package com.android.intentresolver.ext -interface MutableActionFactory { - /** A flow of custom actions */ - val customActionsFlow: Flow<List<ActionRow.Action>> +import androidx.recyclerview.widget.RecyclerView - /** Update custom actions */ - fun updateCustomActions(actions: List<ChooserAction>) +/** Ends active RecyclerView animations, if any */ +fun RecyclerView.endAnimations() { + if (isAnimating) { + itemAnimator?.endAnimations() + } } diff --git a/tests/activity/src/com/android/intentresolver/logging/TestEventLogModule.kt b/tests/activity/src/com/android/intentresolver/logging/TestEventLogModule.kt index cd808af4..d1dea7c3 100644 --- a/tests/activity/src/com/android/intentresolver/logging/TestEventLogModule.kt +++ b/tests/activity/src/com/android/intentresolver/logging/TestEventLogModule.kt @@ -21,16 +21,16 @@ import com.android.internal.logging.InstanceIdSequence import dagger.Binds import dagger.Module import dagger.Provides -import dagger.hilt.android.components.ActivityComponent -import dagger.hilt.android.scopes.ActivityScoped +import dagger.hilt.android.components.ActivityRetainedComponent +import dagger.hilt.android.scopes.ActivityRetainedScoped import dagger.hilt.testing.TestInstallIn /** Binds a [FakeEventLog] as [EventLog] in tests. */ @Module -@TestInstallIn(components = [ActivityComponent::class], replaces = [EventLogModule::class]) +@TestInstallIn(components = [ActivityRetainedComponent::class], replaces = [EventLogModule::class]) interface TestEventLogModule { - @Binds @ActivityScoped fun fakeEventLog(impl: FakeEventLog): EventLog + @Binds @ActivityRetainedScoped fun fakeEventLog(impl: FakeEventLog): EventLog companion object { @Provides diff --git a/tests/activity/src/com/android/intentresolver/v2/ChooserActivityOverrideData.java b/tests/activity/src/com/android/intentresolver/v2/ChooserActivityOverrideData.java index d6ee706a..1f3f6429 100644 --- a/tests/activity/src/com/android/intentresolver/v2/ChooserActivityOverrideData.java +++ b/tests/activity/src/com/android/intentresolver/v2/ChooserActivityOverrideData.java @@ -25,8 +25,6 @@ import android.content.res.Resources; import android.database.Cursor; import android.os.UserHandle; -import com.android.intentresolver.AnnotatedUserHandles; -import com.android.intentresolver.WorkProfileAvailabilityManager; import com.android.intentresolver.chooser.TargetInfo; import com.android.intentresolver.contentpreview.ImageLoader; import com.android.intentresolver.emptystate.CrossProfileIntentsChecker; @@ -61,13 +59,10 @@ public class ChooserActivityOverrideData { public Cursor resolverCursor; public boolean resolverForceException; public ImageLoader imageLoader; - public int alternateProfileSetting; public Resources resources; - public AnnotatedUserHandles annotatedUserHandles; public boolean hasCrossProfileIntents; public boolean isQuietModeEnabled; public Integer myUserId; - public WorkProfileAvailabilityManager mWorkProfileAvailability; public CrossProfileIntentsChecker mCrossProfileIntentsChecker; public void reset() { @@ -78,42 +73,11 @@ public class ChooserActivityOverrideData { resolverForceException = false; resolverListController = mock(ChooserListController.class); workResolverListController = mock(ChooserListController.class); - alternateProfileSetting = 0; resources = null; - annotatedUserHandles = AnnotatedUserHandles.newBuilder() - .setUserIdOfCallingApp(1234) // Must be non-negative. - .setUserHandleSharesheetLaunchedAs(UserHandle.SYSTEM) - .setPersonalProfileUserHandle(UserHandle.SYSTEM) - .build(); hasCrossProfileIntents = true; isQuietModeEnabled = false; myUserId = null; - mWorkProfileAvailability = new WorkProfileAvailabilityManager(null, null, null) { - @Override - public boolean isQuietModeEnabled() { - return isQuietModeEnabled; - } - - @Override - public boolean isWorkProfileUserUnlocked() { - return true; - } - - @Override - public void requestQuietModeEnabled(boolean enabled) { - isQuietModeEnabled = enabled; - } - - @Override - public void markWorkProfileEnabledBroadcastReceived() {} - - @Override - public boolean isWaitingToEnableWorkProfile() { - return false; - } - }; shortcutLoaderFactory = ((userHandle, resultConsumer) -> null); - mCrossProfileIntentsChecker = mock(CrossProfileIntentsChecker.class); when(mCrossProfileIntentsChecker.hasCrossProfileIntents(any(), anyInt(), anyInt())) .thenAnswer(invocation -> hasCrossProfileIntents); diff --git a/tests/activity/src/com/android/intentresolver/v2/ChooserWrapperActivity.java b/tests/activity/src/com/android/intentresolver/v2/ChooserWrapperActivity.java index 07e6e7b4..47d9c8c2 100644 --- a/tests/activity/src/com/android/intentresolver/v2/ChooserWrapperActivity.java +++ b/tests/activity/src/com/android/intentresolver/v2/ChooserWrapperActivity.java @@ -40,7 +40,6 @@ import com.android.intentresolver.chooser.DisplayResolveInfo; import com.android.intentresolver.chooser.TargetInfo; import com.android.intentresolver.emptystate.CrossProfileIntentsChecker; import com.android.intentresolver.shortcuts.ShortcutLoader; -import com.android.internal.logging.nano.MetricsProto.MetricsEvent; import java.util.List; import java.util.function.Consumer; @@ -54,17 +53,7 @@ public class ChooserWrapperActivity extends ChooserActivity implements IChooserW private UsageStatsManager mUsm; @Override - protected final ChooserActivityLogic createActivityLogic() { - return new TestChooserActivityLogic( - "ChooserWrapper", - /* activity = */ this, - this::onWorkProfileStatusUpdated, - sOverrides.annotatedUserHandles, - sOverrides.mWorkProfileAvailability); - } - - @Override - public ChooserListAdapter createChooserListAdapter( + public final ChooserListAdapter createChooserListAdapter( Context context, List<Intent> payloadIntents, Intent[] initialIntents, @@ -151,7 +140,7 @@ public class ChooserWrapperActivity extends ChooserActivity implements IChooserW } @Override - protected ChooserListController createListController(UserHandle userHandle) { + public final ChooserListController createListController(UserHandle userHandle) { if (userHandle == UserHandle.SYSTEM) { return sOverrides.resolverListController; } @@ -187,14 +176,6 @@ public class ChooserWrapperActivity extends ChooserActivity implements IChooserW } @Override - protected boolean isWorkProfile() { - if (sOverrides.alternateProfileSetting != 0) { - return sOverrides.alternateProfileSetting == MetricsEvent.MANAGED_PROFILE; - } - return super.isWorkProfile(); - } - - @Override public DisplayResolveInfo createTestDisplayResolveInfo( Intent originalIntent, ResolveInfo pri, diff --git a/tests/activity/src/com/android/intentresolver/v2/ResolverActivityTest.java b/tests/activity/src/com/android/intentresolver/v2/ResolverActivityTest.java index 993f1760..21fe2904 100644 --- a/tests/activity/src/com/android/intentresolver/v2/ResolverActivityTest.java +++ b/tests/activity/src/com/android/intentresolver/v2/ResolverActivityTest.java @@ -49,7 +49,7 @@ import android.view.View; import android.widget.RelativeLayout; import android.widget.TextView; -import androidx.test.InstrumentationRegistry; +import androidx.test.platform.app.InstrumentationRegistry; import androidx.test.espresso.Espresso; import androidx.test.espresso.NoMatchingViewException; import androidx.test.rule.ActivityTestRule; diff --git a/tests/activity/src/com/android/intentresolver/v2/TestChooserActivityLogic.kt b/tests/activity/src/com/android/intentresolver/v2/TestChooserActivityLogic.kt deleted file mode 100644 index fe649819..00000000 --- a/tests/activity/src/com/android/intentresolver/v2/TestChooserActivityLogic.kt +++ /dev/null @@ -1,25 +0,0 @@ -package com.android.intentresolver.v2 - -import androidx.activity.ComponentActivity -import com.android.intentresolver.AnnotatedUserHandles -import com.android.intentresolver.WorkProfileAvailabilityManager - -/** Activity logic for use when testing [ChooserActivity]. */ -class TestChooserActivityLogic( - tag: String, - activity: ComponentActivity, - onWorkProfileStatusUpdated: () -> Unit, - private val annotatedUserHandlesOverride: AnnotatedUserHandles?, - private val workProfileAvailabilityOverride: WorkProfileAvailabilityManager?, -) : - ChooserActivityLogic( - tag, - activity, - onWorkProfileStatusUpdated, - ) { - override val annotatedUserHandles: AnnotatedUserHandles? - get() = annotatedUserHandlesOverride ?: super.annotatedUserHandles - - override val workProfileAvailabilityManager: WorkProfileAvailabilityManager - get() = workProfileAvailabilityOverride ?: super.workProfileAvailabilityManager -} diff --git a/tests/activity/src/com/android/intentresolver/v2/UnbundledChooserActivityTest.java b/tests/activity/src/com/android/intentresolver/v2/UnbundledChooserActivityTest.java index b8113422..7848983e 100644 --- a/tests/activity/src/com/android/intentresolver/v2/UnbundledChooserActivityTest.java +++ b/tests/activity/src/com/android/intentresolver/v2/UnbundledChooserActivityTest.java @@ -117,27 +117,33 @@ import androidx.test.espresso.matcher.ViewMatchers; import androidx.test.platform.app.InstrumentationRegistry; import androidx.test.rule.ActivityTestRule; -import com.android.intentresolver.AnnotatedUserHandles; import com.android.intentresolver.ChooserListAdapter; +import com.android.intentresolver.FakeImageLoader; import com.android.intentresolver.Flags; import com.android.intentresolver.IChooserWrapper; import com.android.intentresolver.R; import com.android.intentresolver.ResolvedComponentInfo; import com.android.intentresolver.ResolverDataProvider; import com.android.intentresolver.TestContentProvider; -import com.android.intentresolver.TestPreviewImageLoader; import com.android.intentresolver.chooser.DisplayResolveInfo; import com.android.intentresolver.contentpreview.ImageLoader; +import com.android.intentresolver.contentpreview.ImageLoaderModule; +import com.android.intentresolver.ext.RecyclerViewExt; +import com.android.intentresolver.inject.ApplicationUser; import com.android.intentresolver.inject.PackageManagerModule; +import com.android.intentresolver.inject.ProfileParent; import com.android.intentresolver.logging.EventLog; import com.android.intentresolver.logging.FakeEventLog; import com.android.intentresolver.shortcuts.ShortcutLoader; +import com.android.intentresolver.v2.data.repository.FakeUserRepository; +import com.android.intentresolver.v2.data.repository.UserRepository; +import com.android.intentresolver.v2.data.repository.UserRepositoryModule; import com.android.intentresolver.v2.platform.AppPredictionAvailable; import com.android.intentresolver.v2.platform.AppPredictionModule; import com.android.intentresolver.v2.platform.ImageEditor; import com.android.intentresolver.v2.platform.ImageEditorModule; +import com.android.intentresolver.v2.shared.model.User; import com.android.internal.config.sysui.SystemUiDeviceConfigFlags; -import com.android.internal.logging.nano.MetricsProto.MetricsEvent; import dagger.hilt.android.qualifiers.ApplicationContext; import dagger.hilt.android.testing.BindValue; @@ -160,7 +166,6 @@ import org.mockito.Mockito; import java.util.ArrayList; import java.util.Arrays; -import java.util.Collections; import java.util.HashMap; import java.util.List; import java.util.Map; @@ -183,7 +188,9 @@ import javax.inject.Inject; @UninstallModules({ AppPredictionModule.class, ImageEditorModule.class, - PackageManagerModule.class + PackageManagerModule.class, + ImageLoaderModule.class, + UserRepositoryModule.class, }) public class UnbundledChooserActivityTest { @@ -193,9 +200,20 @@ public class UnbundledChooserActivityTest { private static final UserHandle PERSONAL_USER_HANDLE = InstrumentationRegistry .getInstrumentation().getTargetContext().getUser(); + + private static final User PERSONAL_USER = + new User(PERSONAL_USER_HANDLE.getIdentifier(), User.Role.PERSONAL); + private static final UserHandle WORK_PROFILE_USER_HANDLE = UserHandle.of(10); + + private static final User WORK_USER = + new User(WORK_PROFILE_USER_HANDLE.getIdentifier(), User.Role.WORK); + private static final UserHandle CLONE_PROFILE_USER_HANDLE = UserHandle.of(11); + private static final User CLONE_USER = + new User(CLONE_PROFILE_USER_HANDLE.getIdentifier(), User.Role.CLONE); + @Parameters(name = "appPrediction={0}") public static Iterable<?> parameters() { return Arrays.asList( @@ -239,6 +257,25 @@ public class UnbundledChooserActivityTest { @BindValue PackageManager mPackageManager; + /** "launchedAs" */ + @BindValue + @ApplicationUser + UserHandle mApplicationUser = PERSONAL_USER_HANDLE; + + @BindValue + @ProfileParent + UserHandle mProfileParent = PERSONAL_USER_HANDLE; + + private final FakeUserRepository mFakeUserRepo = new FakeUserRepository(List.of(PERSONAL_USER)); + + @BindValue + final UserRepository mUserRepository = mFakeUserRepo; + + private final FakeImageLoader mFakeImageLoader = new FakeImageLoader(); + + @BindValue + final ImageLoader mImageLoader = mFakeImageLoader; + @Before public void setUp() { // TODO: use the other form of `adoptShellPermissionIdentity()` where we explicitly list the @@ -257,6 +294,9 @@ public class UnbundledChooserActivityTest { // values to the dependency graph at activity launch time. This allows replacing // arbitrary bindings per-test case if needed. mPackageManager = mContext.getPackageManager(); + + // TODO: inject image loader in the prod code and remove this override + ChooserActivityOverrideData.getInstance().imageLoader = mFakeImageLoader; } public UnbundledChooserActivityTest(boolean appPredictionAvailable) { @@ -434,14 +474,13 @@ public class UnbundledChooserActivityTest { } @Test - public void visiblePreviewTitleAndThumbnail() throws InterruptedException { + public void visiblePreviewTitleAndThumbnail() { String previewTitle = "My Content Preview Title"; Uri uri = Uri.parse( "android.resource://com.android.frameworks.coretests/" + com.android.intentresolver.tests.R.drawable.test320x240); Intent sendIntent = createSendTextIntentWithPreview(previewTitle, uri); - ChooserActivityOverrideData.getInstance().imageLoader = - createImageLoader(uri, createBitmap()); + mFakeImageLoader.setBitmap(uri, createBitmap()); List<ResolvedComponentInfo> resolvedComponentInfos = createResolvedComponentsForTest(2); setupResolverControllers(resolvedComponentInfos); @@ -707,8 +746,7 @@ public class UnbundledChooserActivityTest { public void testFilePlusTextSharing_ExcludeText() { Uri uri = createTestContentProviderUri(null, "image/png"); Intent sendIntent = createSendImageIntent(uri); - ChooserActivityOverrideData.getInstance().imageLoader = - createImageLoader(uri, createBitmap()); + mFakeImageLoader.setBitmap(uri, createBitmap()); sendIntent.putExtra(Intent.EXTRA_TEXT, "https://google.com/search?q=google"); List<ResolvedComponentInfo> resolvedComponentInfos = Arrays.asList( @@ -749,8 +787,7 @@ public class UnbundledChooserActivityTest { public void testFilePlusTextSharing_RemoveAndAddBackText() { Uri uri = createTestContentProviderUri("application/pdf", "image/png"); Intent sendIntent = createSendImageIntent(uri); - ChooserActivityOverrideData.getInstance().imageLoader = - createImageLoader(uri, createBitmap()); + mFakeImageLoader.setBitmap(uri, createBitmap()); final String text = "https://google.com/search?q=google"; sendIntent.putExtra(Intent.EXTRA_TEXT, text); @@ -797,8 +834,7 @@ public class UnbundledChooserActivityTest { public void testFilePlusTextSharing_TextExclusionDoesNotAffectAlternativeIntent() { Uri uri = createTestContentProviderUri("image/png", null); Intent sendIntent = createSendImageIntent(uri); - ChooserActivityOverrideData.getInstance().imageLoader = - createImageLoader(uri, createBitmap()); + mFakeImageLoader.setBitmap(uri, createBitmap()); sendIntent.putExtra(Intent.EXTRA_TEXT, "https://google.com/search?q=google"); Intent alternativeIntent = createSendTextIntent(); @@ -841,8 +877,6 @@ public class UnbundledChooserActivityTest { public void testImagePlusTextSharing_failedThumbnailAndExcludedText_textChanges() { Uri uri = createTestContentProviderUri("image/png", null); Intent sendIntent = createSendImageIntent(uri); - ChooserActivityOverrideData.getInstance().imageLoader = - new TestPreviewImageLoader(Collections.emptyMap()); sendIntent.putExtra(Intent.EXTRA_TEXT, "https://google.com/search?q=google"); List<ResolvedComponentInfo> resolvedComponentInfos = Arrays.asList( @@ -937,8 +971,7 @@ public class UnbundledChooserActivityTest { Uri uri = createTestContentProviderUri("image/png", null); Intent sendIntent = createSendImageIntent(uri); - ChooserActivityOverrideData.getInstance().imageLoader = - createImageLoader(uri, createBitmap()); + mFakeImageLoader.setBitmap(uri, createBitmap()); List<ResolvedComponentInfo> resolvedComponentInfos = createResolvedComponentsForTest(2); @@ -962,8 +995,7 @@ public class UnbundledChooserActivityTest { uris.add(uri); Intent sendIntent = createSendUriIntentWithPreview(uris); - ChooserActivityOverrideData.getInstance().imageLoader = - createImageLoader(uri, createWideBitmap()); + mFakeImageLoader.setBitmap(uri, createWideBitmap()); List<ResolvedComponentInfo> resolvedComponentInfos = createResolvedComponentsForTest(2); @@ -976,6 +1008,7 @@ public class UnbundledChooserActivityTest { throw exception; } RecyclerView recyclerView = (RecyclerView) view; + RecyclerViewExt.endAnimations(recyclerView); assertThat("recyclerView adapter item count", recyclerView.getAdapter().getItemCount(), is(1)); assertThat("recyclerView child view count", @@ -1000,8 +1033,6 @@ public class UnbundledChooserActivityTest { uris.add(uri); Intent sendIntent = createSendUriIntentWithPreview(uris); - ChooserActivityOverrideData.getInstance().imageLoader = - new TestPreviewImageLoader(Collections.emptyMap()); List<ResolvedComponentInfo> resolvedComponentInfos = createResolvedComponentsForTest(2); @@ -1019,8 +1050,7 @@ public class UnbundledChooserActivityTest { ArrayList<Uri> uris = new ArrayList<>(1); uris.add(uri); Intent sendIntent = createSendUriIntentWithPreview(uris); - ChooserActivityOverrideData.getInstance().imageLoader = - createImageLoader(uri, createBitmap()); + mFakeImageLoader.setBitmap(uri, createBitmap()); List<ResolvedComponentInfo> resolvedComponentInfos = createResolvedComponentsForTest(2); @@ -1046,8 +1076,7 @@ public class UnbundledChooserActivityTest { } uris.add(imageUri); Intent sendIntent = createSendUriIntentWithPreview(uris); - ChooserActivityOverrideData.getInstance().imageLoader = - createImageLoader(imageUri, createBitmap()); + mFakeImageLoader.setBitmap(imageUri, createBitmap()); List<ResolvedComponentInfo> resolvedComponentInfos = createResolvedComponentsForTest(2); setupResolverControllers(resolvedComponentInfos); @@ -1079,8 +1108,7 @@ public class UnbundledChooserActivityTest { uris.add(uri); Intent sendIntent = createSendUriIntentWithPreview(uris); - ChooserActivityOverrideData.getInstance().imageLoader = - createImageLoader(uri, createBitmap()); + mFakeImageLoader.setBitmap(uri, createBitmap()); List<ResolvedComponentInfo> resolvedComponentInfos = createResolvedComponentsForTest(2); @@ -1114,12 +1142,9 @@ public class UnbundledChooserActivityTest { uris.add(docUri); Intent sendIntent = createSendUriIntentWithPreview(uris); - Map<Uri, Bitmap> bitmaps = new HashMap<>(); - bitmaps.put(imgOneUri, createWideBitmap(Color.RED)); - bitmaps.put(imgTwoUri, createWideBitmap(Color.GREEN)); - bitmaps.put(docUri, createWideBitmap(Color.BLUE)); - ChooserActivityOverrideData.getInstance().imageLoader = - new TestPreviewImageLoader(bitmaps); + mFakeImageLoader.setBitmap(imgOneUri, createWideBitmap(Color.RED)); + mFakeImageLoader.setBitmap(imgTwoUri, createWideBitmap(Color.GREEN)); + mFakeImageLoader.setBitmap(docUri, createWideBitmap(Color.BLUE)); List<ResolvedComponentInfo> resolvedComponentInfos = createResolvedComponentsForTest(2); setupResolverControllers(resolvedComponentInfos); @@ -1137,6 +1162,7 @@ public class UnbundledChooserActivityTest { throw exception; } RecyclerView recyclerView = (RecyclerView) view; + RecyclerViewExt.endAnimations(recyclerView); assertThat(recyclerView.getChildCount()).isAtLeast(1); // the first view is a preview View imageView = recyclerView.getChildAt(0).findViewById(R.id.image); @@ -1167,8 +1193,7 @@ public class UnbundledChooserActivityTest { Intent sendIntent = createSendUriIntentWithPreview(uris); sendIntent.putExtra(Intent.EXTRA_TEXT, sharedText); - ChooserActivityOverrideData.getInstance().imageLoader = - createImageLoader(uri, createBitmap()); + mFakeImageLoader.setBitmap(uri, createBitmap()); List<ResolvedComponentInfo> resolvedComponentInfos = createResolvedComponentsForTest(2); @@ -1197,8 +1222,7 @@ public class UnbundledChooserActivityTest { Intent sendIntent = createSendUriIntentWithPreview(uris); sendIntent.putExtra(Intent.EXTRA_TEXT, sharedText); - ChooserActivityOverrideData.getInstance().imageLoader = - createImageLoader(uri, createBitmap()); + mFakeImageLoader.setBitmap(uri, createBitmap()); List<ResolvedComponentInfo> resolvedComponentInfos = createResolvedComponentsForTest(2); @@ -1234,8 +1258,7 @@ public class UnbundledChooserActivityTest { Intent sendIntent = createSendUriIntentWithPreview(uris); sendIntent.putExtra(Intent.EXTRA_TEXT, sharedText); - ChooserActivityOverrideData.getInstance().imageLoader = - createImageLoader(uri, createBitmap()); + mFakeImageLoader.setBitmap(uri, createBitmap()); List<ResolvedComponentInfo> resolvedComponentInfos = createResolvedComponentsForTest(2); @@ -1275,8 +1298,10 @@ public class UnbundledChooserActivityTest { public void testOnCreateLoggingFromWorkProfile() { Intent sendIntent = createSendTextIntent(); sendIntent.setType(TEST_MIME_TYPE); - ChooserActivityOverrideData.getInstance().alternateProfileSetting = - MetricsEvent.MANAGED_PROFILE; + + // Launch as work user. + mFakeUserRepo.addUser(WORK_USER, true); + mApplicationUser = WORK_PROFILE_USER_HANDLE; ChooserWrapperActivity activity = mActivityRule.launchActivity(Intent.createChooser(sendIntent, "logger test")); @@ -1331,8 +1356,7 @@ public class UnbundledChooserActivityTest { uris.add(uri); Intent sendIntent = createSendUriIntentWithPreview(uris); - ChooserActivityOverrideData.getInstance().imageLoader = - createImageLoader(uri, createBitmap()); + mFakeImageLoader.setBitmap(uri, createBitmap()); List<ResolvedComponentInfo> resolvedComponentInfos = createResolvedComponentsForTest(2); @@ -2174,7 +2198,7 @@ public class UnbundledChooserActivityTest { createResolvedComponentsForTestWithOtherProfile(3, /* userId */ 10); List<ResolvedComponentInfo> workResolvedComponentInfos = createResolvedComponentsForTest(workProfileTargets); - ChooserActivityOverrideData.getInstance().isQuietModeEnabled = true; + mFakeUserRepo.updateState(WORK_USER, false); setupResolverControllers(personalResolvedComponentInfos, workResolvedComponentInfos); Intent sendIntent = createSendTextIntent(); sendIntent.setType(TEST_MIME_TYPE); @@ -2228,8 +2252,7 @@ public class UnbundledChooserActivityTest { uris.add(uri); Intent sendIntent = createSendUriIntentWithPreview(uris); - ChooserActivityOverrideData.getInstance().imageLoader = - createImageLoader(uri, createWideBitmap()); + mFakeImageLoader.setBitmap(uri, createWideBitmap()); mActivityRule.launchActivity(Intent.createChooser(sendIntent, "Scrollable preview test")); waitForIdle(); @@ -2257,7 +2280,7 @@ public class UnbundledChooserActivityTest { List<ResolvedComponentInfo> workResolvedComponentInfos = createResolvedComponentsForTest(0); setupResolverControllers(personalResolvedComponentInfos, workResolvedComponentInfos); - ChooserActivityOverrideData.getInstance().isQuietModeEnabled = true; + mFakeUserRepo.updateState(WORK_USER, false); ChooserActivityOverrideData.getInstance().hasCrossProfileIntents = false; Intent sendIntent = createSendTextIntent(); sendIntent.setType(TEST_MIME_TYPE); @@ -2281,7 +2304,7 @@ public class UnbundledChooserActivityTest { List<ResolvedComponentInfo> workResolvedComponentInfos = createResolvedComponentsForTest(0); setupResolverControllers(personalResolvedComponentInfos, workResolvedComponentInfos); - ChooserActivityOverrideData.getInstance().isQuietModeEnabled = true; + mFakeUserRepo.updateState(WORK_USER, false); Intent sendIntent = createSendTextIntent(); sendIntent.setType(TEST_MIME_TYPE); @@ -3017,18 +3040,12 @@ public class UnbundledChooserActivityTest { } private void markOtherProfileAvailability(boolean workAvailable, boolean cloneAvailable) { - AnnotatedUserHandles.Builder handles = AnnotatedUserHandles.newBuilder(); - handles - .setUserIdOfCallingApp(1234) // Must be non-negative. - .setUserHandleSharesheetLaunchedAs(PERSONAL_USER_HANDLE) - .setPersonalProfileUserHandle(PERSONAL_USER_HANDLE); if (workAvailable) { - handles.setWorkProfileUserHandle(WORK_PROFILE_USER_HANDLE); + mFakeUserRepo.addUser(WORK_USER, /* available= */ true); } if (cloneAvailable) { - handles.setCloneProfileUserHandle(CLONE_PROFILE_USER_HANDLE); + mFakeUserRepo.addUser(CLONE_USER, /* available= */ true); } - ChooserWrapperActivity.sOverrides.annotatedUserHandles = handles.build(); } private void setupResolverControllers( @@ -3048,19 +3065,8 @@ public class UnbundledChooserActivityTest { Mockito.anyBoolean(), Mockito.anyBoolean(), Mockito.isA(List.class), - eq(UserHandle.SYSTEM))) - .thenReturn(new ArrayList<>(personalResolvedComponentInfos)); - when( - ChooserActivityOverrideData - .getInstance() - .workResolverListController - .getResolversForIntentAsUser( - Mockito.anyBoolean(), - Mockito.anyBoolean(), - Mockito.anyBoolean(), - Mockito.isA(List.class), - eq(UserHandle.SYSTEM))) - .thenReturn(new ArrayList<>(personalResolvedComponentInfos)); + eq(PERSONAL_USER_HANDLE))) + .thenReturn(new ArrayList<>(personalResolvedComponentInfos)); when( ChooserActivityOverrideData .getInstance() @@ -3070,8 +3076,8 @@ public class UnbundledChooserActivityTest { Mockito.anyBoolean(), Mockito.anyBoolean(), Mockito.isA(List.class), - eq(UserHandle.of(10)))) - .thenReturn(new ArrayList<>(workResolvedComponentInfos)); + eq(WORK_PROFILE_USER_HANDLE))) + .thenReturn(new ArrayList<>(workResolvedComponentInfos)); } private static GridRecyclerSpanCountMatcher withGridColumnCount(int columnCount) { @@ -3134,8 +3140,4 @@ public class UnbundledChooserActivityTest { }; return shortcutLoaders; } - - private static ImageLoader createImageLoader(Uri uri, Bitmap bitmap) { - return new TestPreviewImageLoader(Collections.singletonMap(uri, bitmap)); - } } diff --git a/tests/activity/src/com/android/intentresolver/v2/UnbundledChooserActivityWorkProfileTest.java b/tests/activity/src/com/android/intentresolver/v2/UnbundledChooserActivityWorkProfileTest.java index e4ec1776..8d83773e 100644 --- a/tests/activity/src/com/android/intentresolver/v2/UnbundledChooserActivityWorkProfileTest.java +++ b/tests/activity/src/com/android/intentresolver/v2/UnbundledChooserActivityWorkProfileTest.java @@ -17,6 +17,7 @@ package com.android.intentresolver.v2; import static android.testing.PollingCheck.waitFor; + import static androidx.test.espresso.Espresso.onView; import static androidx.test.espresso.action.ViewActions.click; import static androidx.test.espresso.action.ViewActions.swipeUp; @@ -25,6 +26,7 @@ import static androidx.test.espresso.matcher.ViewMatchers.isDisplayed; import static androidx.test.espresso.matcher.ViewMatchers.isSelected; import static androidx.test.espresso.matcher.ViewMatchers.withId; import static androidx.test.espresso.matcher.ViewMatchers.withText; + import static com.android.intentresolver.v2.ChooserWrapperActivity.sOverrides; import static com.android.intentresolver.v2.UnbundledChooserActivityWorkProfileTest.TestCase.ExpectedBlocker.NO_BLOCKER; import static com.android.intentresolver.v2.UnbundledChooserActivityWorkProfileTest.TestCase.ExpectedBlocker.PERSONAL_PROFILE_ACCESS_BLOCKER; @@ -33,6 +35,7 @@ import static com.android.intentresolver.v2.UnbundledChooserActivityWorkProfileT import static com.android.intentresolver.v2.UnbundledChooserActivityWorkProfileTest.TestCase.ExpectedBlocker.WORK_PROFILE_SHARE_BLOCKER; import static com.android.intentresolver.v2.UnbundledChooserActivityWorkProfileTest.TestCase.Tab.PERSONAL; import static com.android.intentresolver.v2.UnbundledChooserActivityWorkProfileTest.TestCase.Tab.WORK; + import static org.hamcrest.CoreMatchers.not; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.when; @@ -41,15 +44,25 @@ import android.companion.DeviceFilter; import android.content.Intent; import android.os.UserHandle; -import androidx.test.InstrumentationRegistry; +import androidx.test.platform.app.InstrumentationRegistry; import androidx.test.espresso.NoMatchingViewException; import androidx.test.rule.ActivityTestRule; -import com.android.intentresolver.AnnotatedUserHandles; import com.android.intentresolver.R; import com.android.intentresolver.ResolvedComponentInfo; import com.android.intentresolver.ResolverDataProvider; +import com.android.intentresolver.inject.ApplicationUser; +import com.android.intentresolver.inject.ProfileParent; import com.android.intentresolver.v2.UnbundledChooserActivityWorkProfileTest.TestCase.Tab; +import com.android.intentresolver.v2.data.repository.FakeUserRepository; +import com.android.intentresolver.v2.data.repository.UserRepository; +import com.android.intentresolver.v2.data.repository.UserRepositoryModule; +import com.android.intentresolver.v2.shared.model.User; + +import dagger.hilt.android.testing.BindValue; +import dagger.hilt.android.testing.HiltAndroidRule; +import dagger.hilt.android.testing.HiltAndroidTest; +import dagger.hilt.android.testing.UninstallModules; import junit.framework.AssertionFailedError; @@ -65,12 +78,10 @@ import java.util.Arrays; import java.util.Collection; import java.util.List; -import dagger.hilt.android.testing.HiltAndroidRule; -import dagger.hilt.android.testing.HiltAndroidTest; - @DeviceFilter.MediumType @RunWith(Parameterized.class) @HiltAndroidTest +@UninstallModules(UserRepositoryModule.class) public class UnbundledChooserActivityWorkProfileTest { private static final UserHandle PERSONAL_USER_HANDLE = InstrumentationRegistry @@ -84,10 +95,31 @@ public class UnbundledChooserActivityWorkProfileTest { public ActivityTestRule<ChooserWrapperActivity> mActivityRule = new ActivityTestRule<>(ChooserWrapperActivity.class, false, false); + + @BindValue + @ApplicationUser + public final UserHandle mApplicationUser; + + @BindValue + @ProfileParent + public final UserHandle mProfileParent; + + /** For setup of test state, a mutable reference of mUserRepository */ + private final FakeUserRepository mFakeUserRepo = new FakeUserRepository( + List.of(new User(PERSONAL_USER_HANDLE.getIdentifier(), User.Role.PERSONAL))); + + @BindValue + public final UserRepository mUserRepository; + private final TestCase mTestCase; public UnbundledChooserActivityWorkProfileTest(TestCase testCase) { mTestCase = testCase; + mApplicationUser = mTestCase.getMyUserHandle(); + mProfileParent = PERSONAL_USER_HANDLE; + mUserRepository = new FakeUserRepository(List.of( + new User(PERSONAL_USER_HANDLE.getIdentifier(), User.Role.PERSONAL), + new User(WORK_USER_HANDLE.getIdentifier(), User.Role.WORK))); } @Before @@ -268,12 +300,6 @@ public class UnbundledChooserActivityWorkProfileTest { } private void setUpPersonalAndWorkComponentInfos() { - ChooserWrapperActivity.sOverrides.annotatedUserHandles = AnnotatedUserHandles.newBuilder() - .setUserIdOfCallingApp(1234) // Must be non-negative. - .setUserHandleSharesheetLaunchedAs(mTestCase.getMyUserHandle()) - .setPersonalProfileUserHandle(PERSONAL_USER_HANDLE) - .setWorkProfileUserHandle(WORK_USER_HANDLE) - .build(); int workProfileTargets = 4; List<ResolvedComponentInfo> personalResolvedComponentInfos = createResolvedComponentsForTestWithOtherProfile(3, diff --git a/tests/integration/Android.bp b/tests/integration/Android.bp index f17df160..4c8fc37a 100644 --- a/tests/integration/Android.bp +++ b/tests/integration/Android.bp @@ -15,6 +15,7 @@ // package { + default_team: "trendy_team_capture_and_share", default_applicable_licenses: ["Android-Apache-2.0"], } @@ -40,5 +41,5 @@ android_test { "truth", "truth-java8-extension", ], - test_suites: ["general-tests"] + test_suites: ["general-tests"], } diff --git a/tests/shared/Android.bp b/tests/shared/Android.bp index 55188ee3..249bf38f 100644 --- a/tests/shared/Android.bp +++ b/tests/shared/Android.bp @@ -31,7 +31,8 @@ java_library { static_libs: [ "hamcrest", "IntentResolver-core", + "mockito-kotlin2", "mockito-target-minus-junit4", - "truth" + "truth", ], } diff --git a/tests/shared/src/com/android/intentresolver/TestPreviewImageLoader.kt b/tests/shared/src/com/android/intentresolver/FakeImageLoader.kt index f0203bb6..c57ea78b 100644 --- a/tests/shared/src/com/android/intentresolver/TestPreviewImageLoader.kt +++ b/tests/shared/src/com/android/intentresolver/FakeImageLoader.kt @@ -22,7 +22,9 @@ import com.android.intentresolver.contentpreview.ImageLoader import java.util.function.Consumer import kotlinx.coroutines.CoroutineScope -class TestPreviewImageLoader(private val bitmaps: Map<Uri, Bitmap>) : ImageLoader { +class FakeImageLoader(initialBitmaps: Map<Uri, Bitmap> = emptyMap()) : ImageLoader { + private val bitmaps = HashMap<Uri, Bitmap>().apply { putAll(initialBitmaps) } + override fun loadImage(callerScope: CoroutineScope, uri: Uri, callback: Consumer<Bitmap?>) { callback.accept(bitmaps[uri]) } @@ -30,4 +32,8 @@ class TestPreviewImageLoader(private val bitmaps: Map<Uri, Bitmap>) : ImageLoade override suspend fun invoke(uri: Uri, caching: Boolean): Bitmap? = bitmaps[uri] override fun prePopulate(uris: List<Uri>) = Unit + + fun setBitmap(uri: Uri, bitmap: Bitmap) { + bitmaps[uri] = bitmap + } } diff --git a/tests/shared/src/com/android/intentresolver/MockitoKotlinHelpers.kt b/tests/shared/src/com/android/intentresolver/MockitoKotlinHelpers.kt index db9fbd93..b7b97d6f 100644 --- a/tests/shared/src/com/android/intentresolver/MockitoKotlinHelpers.kt +++ b/tests/shared/src/com/android/intentresolver/MockitoKotlinHelpers.kt @@ -14,14 +14,16 @@ * limitations under the License. */ +@file:Suppress("NOTHING_TO_INLINE") + package com.android.intentresolver /** * Kotlin versions of popular mockito methods that can return null in situations when Kotlin expects * a non-null value. Kotlin will throw an IllegalStateException when this takes place ("x must not * be null"). To fix this, we can use methods that modify the return type to be nullable. This - * causes Kotlin to skip the null checks. - * Cloned from frameworks/base/packages/SystemUI/tests/utils/src/com/android/systemui/util/mockito/KotlinMockitoHelpers.kt + * causes Kotlin to skip the null checks. Cloned from + * frameworks/base/packages/SystemUI/tests/utils/src/com/android/systemui/util/mockito/KotlinMockitoHelpers.kt */ import org.mockito.ArgumentCaptor import org.mockito.ArgumentMatcher @@ -33,42 +35,49 @@ import org.mockito.stubbing.OngoingStubbing import org.mockito.stubbing.Stubber /** - * Returns Mockito.eq() as nullable type to avoid java.lang.IllegalStateException when - * null is returned. + * Returns Mockito.eq() as nullable type to avoid java.lang.IllegalStateException when null is + * returned. * * Generic T is nullable because implicitly bounded by Any?. */ -fun <T> eq(obj: T): T = Mockito.eq<T>(obj) +inline fun <T> eq(obj: T): T = Mockito.eq<T>(obj) ?: obj /** - * Returns Mockito.any() as nullable type to avoid java.lang.IllegalStateException when - * null is returned. + * Returns Mockito.same() as nullable type to avoid java.lang.IllegalStateException when null is + * returned. * * Generic T is nullable because implicitly bounded by Any?. */ -fun <T> any(type: Class<T>): T = Mockito.any<T>(type) -inline fun <reified T> any(): T = any(T::class.java) +inline fun <T> same(obj: T): T = Mockito.same<T>(obj) ?: obj /** - * Returns Mockito.argThat() as nullable type to avoid java.lang.IllegalStateException when - * null is returned. + * Returns Mockito.any() as nullable type to avoid java.lang.IllegalStateException when null is + * returned. * * Generic T is nullable because implicitly bounded by Any?. */ -fun <T> argThat(matcher: ArgumentMatcher<T>): T = Mockito.argThat(matcher) +inline fun <T> any(type: Class<T>): T = Mockito.any<T>(type) + +inline fun <reified T> any(): T = any(T::class.java) /** - * Kotlin type-inferred version of Mockito.nullable() + * Returns Mockito.argThat() as nullable type to avoid java.lang.IllegalStateException when null is + * returned. + * + * Generic T is nullable because implicitly bounded by Any?. */ +inline fun <T> argThat(matcher: ArgumentMatcher<T>): T = Mockito.argThat(matcher) + +/** Kotlin type-inferred version of Mockito.nullable() */ inline fun <reified T> nullable(): T? = Mockito.nullable(T::class.java) /** - * Returns ArgumentCaptor.capture() as nullable type to avoid java.lang.IllegalStateException - * when null is returned. + * Returns ArgumentCaptor.capture() as nullable type to avoid java.lang.IllegalStateException when + * null is returned. * * Generic T is nullable because implicitly bounded by Any?. */ -fun <T> capture(argumentCaptor: ArgumentCaptor<T>): T = argumentCaptor.capture() +inline fun <T> capture(argumentCaptor: ArgumentCaptor<T>): T = argumentCaptor.capture() /** * Helper function for creating an argumentCaptor in kotlin. @@ -90,17 +99,18 @@ inline fun <reified T : Any> mock( apply: T.() -> Unit = {} ): T = Mockito.mock(T::class.java, mockSettings).apply(apply) +/** Matches any array of type T. */ +inline fun <reified T : Any?> anyArray(): Array<T> = Mockito.any(Array<T>::class.java) ?: arrayOf() + /** * Helper function for stubbing methods without the need to use backticks. * * @see Mockito.when */ -fun <T> whenever(methodCall: T): OngoingStubbing<T> = Mockito.`when`(methodCall) +inline fun <T> whenever(methodCall: T): OngoingStubbing<T> = Mockito.`when`(methodCall) -/** - * Helper function for stubbing methods without the need to use backticks. - */ -fun <T> Stubber.whenever(mock: T): T = `when`(mock) +/** Helper function for stubbing methods without the need to use backticks. */ +inline fun <T> Stubber.whenever(mock: T): T = `when`(mock) /** * A kotlin implemented wrapper of [ArgumentCaptor] which prevents the following exception when @@ -128,13 +138,12 @@ inline fun <reified T : Any> kotlinArgumentCaptor(): KotlinArgumentCaptor<T> = /** * Helper function for creating and using a single-use ArgumentCaptor in kotlin. * - * val captor = argumentCaptor<Foo>() - * verify(...).someMethod(captor.capture()) - * val captured = captor.value + * val captor = argumentCaptor<Foo>() verify(...).someMethod(captor.capture()) val captured = + * captor.value * * becomes: * - * val captured = withArgCaptor<Foo> { verify(...).someMethod(capture()) } + * val captured = withArgCaptor<Foo> { verify(...).someMethod(capture()) } * * NOTE: this uses the KotlinArgumentCaptor to avoid the NullPointerException. */ @@ -144,13 +153,12 @@ inline fun <reified T : Any> withArgCaptor(block: KotlinArgumentCaptor<T>.() -> /** * Variant of [withArgCaptor] for capturing multiple arguments. * - * val captor = argumentCaptor<Foo>() - * verify(...).someMethod(captor.capture()) - * val captured: List<Foo> = captor.allValues + * val captor = argumentCaptor<Foo>() verify(...).someMethod(captor.capture()) val captured: + * List<Foo> = captor.allValues * * becomes: * - * val capturedList = captureMany<Foo> { verify(...).someMethod(capture()) } + * val capturedList = captureMany<Foo> { verify(...).someMethod(capture()) } */ inline fun <reified T : Any> captureMany(block: KotlinArgumentCaptor<T>.() -> Unit): List<T> = kotlinArgumentCaptor<T>().apply { block() }.allValues diff --git a/tests/shared/src/com/android/intentresolver/TestContentPreviewViewModel.kt b/tests/shared/src/com/android/intentresolver/TestContentPreviewViewModel.kt index b352f360..8f246424 100644 --- a/tests/shared/src/com/android/intentresolver/TestContentPreviewViewModel.kt +++ b/tests/shared/src/com/android/intentresolver/TestContentPreviewViewModel.kt @@ -23,7 +23,6 @@ import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.viewmodel.CreationExtras import com.android.intentresolver.contentpreview.BasePreviewViewModel import com.android.intentresolver.contentpreview.ImageLoader -import com.android.intentresolver.contentpreview.PayloadToggleInteractor /** A test content preview model that supports image loader override. */ class TestContentPreviewViewModel( @@ -34,23 +33,12 @@ class TestContentPreviewViewModel( override val previewDataProvider get() = viewModel.previewDataProvider - override val payloadToggleInteractor: PayloadToggleInteractor? - get() = viewModel.payloadToggleInteractor - override fun init( targetIntent: Intent, - chooserIntent: Intent, additionalContentUri: Uri?, - focusedItemIdx: Int, isPayloadTogglingEnabled: Boolean, ) { - viewModel.init( - targetIntent, - chooserIntent, - additionalContentUri, - focusedItemIdx, - isPayloadTogglingEnabled - ) + viewModel.init(targetIntent, additionalContentUri, isPayloadTogglingEnabled) } companion object { diff --git a/tests/shared/src/com/android/intentresolver/v2/platform/FakeUserManager.kt b/tests/shared/src/com/android/intentresolver/v2/platform/FakeUserManager.kt index 370e5a00..d1b56d5f 100644 --- a/tests/shared/src/com/android/intentresolver/v2/platform/FakeUserManager.kt +++ b/tests/shared/src/com/android/intentresolver/v2/platform/FakeUserManager.kt @@ -1,12 +1,6 @@ package com.android.intentresolver.v2.platform import android.content.Context -import android.content.Intent.ACTION_MANAGED_PROFILE_AVAILABLE -import android.content.Intent.ACTION_MANAGED_PROFILE_UNAVAILABLE -import android.content.Intent.ACTION_PROFILE_ADDED -import android.content.Intent.ACTION_PROFILE_AVAILABLE -import android.content.Intent.ACTION_PROFILE_REMOVED -import android.content.Intent.ACTION_PROFILE_UNAVAILABLE import android.content.pm.UserInfo import android.content.pm.UserInfo.FLAG_FULL import android.content.pm.UserInfo.FLAG_INITIALIZED @@ -18,7 +12,10 @@ import android.os.UserManager import androidx.annotation.NonNull import com.android.intentresolver.THROWS_EXCEPTION import com.android.intentresolver.mock -import com.android.intentresolver.v2.data.repository.UserRepositoryImpl.UserEvent +import com.android.intentresolver.v2.data.repository.AvailabilityChange +import com.android.intentresolver.v2.data.repository.ProfileAdded +import com.android.intentresolver.v2.data.repository.ProfileRemoved +import com.android.intentresolver.v2.data.repository.UserEvent import com.android.intentresolver.v2.platform.FakeUserManager.State import com.android.intentresolver.whenever import kotlin.random.Random @@ -155,21 +152,7 @@ class FakeUserManager(val state: State = State()) : } else { it.flags and UserInfo.FLAG_QUIET_MODE.inv() } - val actions = mutableListOf<String>() - if (quietMode) { - actions += ACTION_PROFILE_UNAVAILABLE - if (it.isManagedProfile) { - actions += ACTION_MANAGED_PROFILE_UNAVAILABLE - } - } else { - actions += ACTION_PROFILE_AVAILABLE - if (it.isManagedProfile) { - actions += ACTION_MANAGED_PROFILE_AVAILABLE - } - } - actions.forEach { action -> - eventChannel.trySend(UserEvent(action, user, quietMode)) - } + eventChannel.trySend(AvailabilityChange(user, quietMode)) } } @@ -187,7 +170,7 @@ class FakeUserManager(val state: State = State()) : profileGroupId = parentUser.profileGroupId } userInfoMap[userInfo.userHandle] = userInfo - eventChannel.trySend(UserEvent(ACTION_PROFILE_ADDED, userInfo.userHandle)) + eventChannel.trySend(ProfileAdded(userInfo.userHandle)) return userInfo.userHandle } @@ -195,7 +178,7 @@ class FakeUserManager(val state: State = State()) : return userInfoMap[handle]?.let { user -> require(user.isProfile) { "Only profiles can be removed" } userInfoMap.remove(user.userHandle) - eventChannel.trySend(UserEvent(ACTION_PROFILE_REMOVED, user.userHandle)) + eventChannel.trySend(ProfileRemoved(user.userHandle)) return true } ?: false diff --git a/tests/unit/Android.bp b/tests/unit/Android.bp index f8b80c72..78d32ae7 100644 --- a/tests/unit/Android.bp +++ b/tests/unit/Android.bp @@ -15,6 +15,7 @@ // package { + default_team: "trendy_team_capture_and_share", default_applicable_licenses: ["Android-Apache-2.0"], } @@ -52,6 +53,7 @@ android_test { "junit", "kotlinx_coroutines_test", "mockito-target-minus-junit4", + "mockito-kotlin2", "platform-compat-test-rules", // PlatformCompatChangeRule "testables", // TestableContext/TestableResources "truth", diff --git a/tests/unit/src/com/android/intentresolver/contentpreview/ChooserContentPreviewUiTest.kt b/tests/unit/src/com/android/intentresolver/contentpreview/ChooserContentPreviewUiTest.kt index c7c3c516..e4489bd1 100644 --- a/tests/unit/src/com/android/intentresolver/contentpreview/ChooserContentPreviewUiTest.kt +++ b/tests/unit/src/com/android/intentresolver/contentpreview/ChooserContentPreviewUiTest.kt @@ -21,7 +21,7 @@ import android.net.Uri import android.platform.test.flag.junit.CheckFlagsRule import android.platform.test.flag.junit.DeviceFlagsValueProvider import com.android.intentresolver.ContentTypeHint -import com.android.intentresolver.TestPreviewImageLoader +import com.android.intentresolver.FakeImageLoader import com.android.intentresolver.contentpreview.ChooserContentPreviewUi.ActionFactory import com.android.intentresolver.mock import com.android.intentresolver.whenever @@ -43,7 +43,7 @@ class ChooserContentPreviewUiTest { private val testScope = TestScope(EmptyCoroutineContext + UnconfinedTestDispatcher()) private val previewData = mock<PreviewDataProvider>() private val headlineGenerator = mock<HeadlineGenerator>() - private val imageLoader = TestPreviewImageLoader(emptyMap()) + private val imageLoader = FakeImageLoader(emptyMap()) private val testMetadataText: CharSequence = "Test metadata text" private val actionFactory = object : ActionFactory { @@ -70,6 +70,7 @@ class ChooserContentPreviewUiTest { targetIntent, imageLoader, actionFactory, + { null }, transitionCallback, headlineGenerator, ContentTypeHint.NONE, diff --git a/tests/unit/src/com/android/intentresolver/contentpreview/CursorUriReaderTest.kt b/tests/unit/src/com/android/intentresolver/contentpreview/CursorUriReaderTest.kt deleted file mode 100644 index cd1c503a..00000000 --- a/tests/unit/src/com/android/intentresolver/contentpreview/CursorUriReaderTest.kt +++ /dev/null @@ -1,125 +0,0 @@ -/* - * Copyright 2024 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.android.intentresolver.contentpreview - -import android.content.ContentInterface -import android.content.Intent -import android.database.MatrixCursor -import android.net.Uri -import android.util.SparseArray -import com.android.intentresolver.any -import com.android.intentresolver.anyOrNull -import com.android.intentresolver.mock -import com.android.intentresolver.whenever -import com.google.common.truth.Truth.assertThat -import com.google.common.truth.Truth.assertWithMessage -import kotlinx.coroutines.test.TestScope -import kotlinx.coroutines.test.runTest -import org.junit.Test - -class CursorUriReaderTest { - private val scope = TestScope() - - @Test - fun readEmptyCursor() { - val testSubject = - CursorUriReader( - cursor = MatrixCursor(arrayOf("uri")), - startPos = 0, - pageSize = 128, - ) { - true - } - - assertThat(testSubject.hasMoreBefore).isFalse() - assertThat(testSubject.hasMoreAfter).isFalse() - assertThat(testSubject.count).isEqualTo(0) - assertThat(testSubject.readPageBefore().size()).isEqualTo(0) - assertThat(testSubject.readPageAfter().size()).isEqualTo(0) - } - - @Test - fun readCursorFromTheMiddle() { - val count = 3 - val testSubject = - CursorUriReader( - cursor = - MatrixCursor(arrayOf("uri")).apply { - for (i in 1..count) { - addRow(arrayOf(createUri(i))) - } - }, - startPos = 1, - pageSize = 2, - ) { - true - } - - assertThat(testSubject.hasMoreBefore).isTrue() - assertThat(testSubject.hasMoreAfter).isTrue() - assertThat(testSubject.count).isEqualTo(3) - - testSubject.readPageBefore().let { page -> - assertThat(testSubject.hasMoreBefore).isFalse() - assertThat(testSubject.hasMoreAfter).isTrue() - assertThat(page.size()).isEqualTo(1) - assertThat(page.keyAt(0)).isEqualTo(0) - assertThat(page.valueAt(0)).isEqualTo(createUri(1)) - } - - testSubject.readPageAfter().let { page -> - assertThat(testSubject.hasMoreBefore).isFalse() - assertThat(testSubject.hasMoreAfter).isFalse() - assertThat(page.size()).isEqualTo(2) - assertThat(page.getKeys()).asList().containsExactly(1, 2).inOrder() - assertThat(page.getValues()) - .asList() - .containsExactly(createUri(2), createUri(3)) - .inOrder() - } - } - - // TODO: add tests with filtered-out items - // TODO: add tests with a failing cursor - - @Test - fun testFailingQueryCall_emptyCursorCreated() = - scope.runTest { - val contentResolver = - mock<ContentInterface> { - whenever(query(any(), any(), anyOrNull(), any())) - .thenThrow(SecurityException("Test exception")) - } - val cursorReader = - CursorUriReader.createCursorReader( - contentResolver, - Uri.parse("content://auth"), - Intent(Intent.ACTION_CHOOSER) - ) - - assertWithMessage("Empty cursor reader is expected") - .that(cursorReader.count) - .isEqualTo(0) - } -} - -private fun createUri(id: Int) = Uri.parse("content://org.pkg/$id") - -private fun <T> SparseArray<T>.getKeys(): IntArray = IntArray(size()) { i -> keyAt(i) } - -private inline fun <reified T> SparseArray<T>.getValues(): Array<T> = - Array(size()) { i -> valueAt(i) } diff --git a/tests/unit/src/com/android/intentresolver/contentpreview/ImagePreviewImageLoaderTest.kt b/tests/unit/src/com/android/intentresolver/contentpreview/ImagePreviewImageLoaderTest.kt index 89978707..41989bda 100644 --- a/tests/unit/src/com/android/intentresolver/contentpreview/ImagePreviewImageLoaderTest.kt +++ b/tests/unit/src/com/android/intentresolver/contentpreview/ImagePreviewImageLoaderTest.kt @@ -20,9 +20,6 @@ import android.content.ContentResolver import android.graphics.Bitmap import android.net.Uri import android.util.Size -import androidx.lifecycle.Lifecycle -import androidx.lifecycle.coroutineScope -import androidx.lifecycle.testing.TestLifecycleOwner import com.android.intentresolver.any import com.android.intentresolver.anyOrNull import com.android.intentresolver.mock @@ -38,25 +35,22 @@ import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineName +import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineStart.UNDISPATCHED -import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.Runnable import kotlinx.coroutines.async +import kotlinx.coroutines.cancel import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.launch -import kotlinx.coroutines.plus import kotlinx.coroutines.sync.Semaphore import kotlinx.coroutines.test.StandardTestDispatcher import kotlinx.coroutines.test.TestCoroutineScheduler +import kotlinx.coroutines.test.TestScope import kotlinx.coroutines.test.UnconfinedTestDispatcher -import kotlinx.coroutines.test.resetMain import kotlinx.coroutines.test.runTest -import kotlinx.coroutines.test.setMain import kotlinx.coroutines.yield -import org.junit.After import org.junit.Assert.assertTrue -import org.junit.Before import org.junit.Test import org.mockito.Mockito.never import org.mockito.Mockito.times @@ -72,281 +66,287 @@ class ImagePreviewImageLoaderTest { mock<ContentResolver> { whenever(loadThumbnail(any(), any(), anyOrNull())).thenReturn(bitmap) } - private val lifecycleOwner = TestLifecycleOwner() - private val dispatcher = UnconfinedTestDispatcher() - private lateinit var testSubject: ImagePreviewImageLoader - - @Before - fun setup() { - Dispatchers.setMain(dispatcher) - lifecycleOwner.handleLifecycleEvent(Lifecycle.Event.ON_CREATE) - // create test subject after we've updated the lifecycle dispatcher - testSubject = - ImagePreviewImageLoader( - lifecycleOwner.lifecycle.coroutineScope + dispatcher, - imageSize.width, - contentResolver, - cacheSize = 1, - ) - } - - @After - fun cleanup() { - lifecycleOwner.handleLifecycleEvent(Lifecycle.Event.ON_DESTROY) - Dispatchers.resetMain() - } + private val scheduler = TestCoroutineScheduler() + private val dispatcher = UnconfinedTestDispatcher(scheduler) + private val scope = TestScope(dispatcher) + private val testSubject = + ImagePreviewImageLoader( + dispatcher, + imageSize.width, + contentResolver, + cacheSize = 1, + ) @Test - fun prePopulate_cachesImagesUpToTheCacheSize() = runTest { - testSubject.prePopulate(listOf(uriOne, uriTwo)) + fun prePopulate_cachesImagesUpToTheCacheSize() = + scope.runTest { + testSubject.prePopulate(listOf(uriOne, uriTwo)) - verify(contentResolver, times(1)).loadThumbnail(uriOne, imageSize, null) - verify(contentResolver, never()).loadThumbnail(uriTwo, imageSize, null) + verify(contentResolver, times(1)).loadThumbnail(uriOne, imageSize, null) + verify(contentResolver, never()).loadThumbnail(uriTwo, imageSize, null) - testSubject(uriOne) - verify(contentResolver, times(1)).loadThumbnail(uriOne, imageSize, null) - } + testSubject(uriOne) + verify(contentResolver, times(1)).loadThumbnail(uriOne, imageSize, null) + } @Test - fun invoke_returnCachedImageWhenCalledTwice() = runTest { - testSubject(uriOne) - testSubject(uriOne) + fun invoke_returnCachedImageWhenCalledTwice() = + scope.runTest { + testSubject(uriOne) + testSubject(uriOne) - verify(contentResolver, times(1)).loadThumbnail(any(), any(), anyOrNull()) - } + verify(contentResolver, times(1)).loadThumbnail(any(), any(), anyOrNull()) + } @Test - fun invoke_whenInstructed_doesNotCache() = runTest { - testSubject(uriOne, false) - testSubject(uriOne, false) + fun invoke_whenInstructed_doesNotCache() = + scope.runTest { + testSubject(uriOne, false) + testSubject(uriOne, false) - verify(contentResolver, times(2)).loadThumbnail(any(), any(), anyOrNull()) - } + verify(contentResolver, times(2)).loadThumbnail(any(), any(), anyOrNull()) + } @Test - fun invoke_overlappedRequests_Deduplicate() = runTest { - val scheduler = TestCoroutineScheduler() - val dispatcher = StandardTestDispatcher(scheduler) - val testSubject = - ImagePreviewImageLoader( - lifecycleOwner.lifecycle.coroutineScope + dispatcher, - imageSize.width, - contentResolver, - cacheSize = 1, - ) - coroutineScope { - launch(start = UNDISPATCHED) { testSubject(uriOne, false) } - launch(start = UNDISPATCHED) { testSubject(uriOne, false) } - scheduler.advanceUntilIdle() - } + fun invoke_overlappedRequests_Deduplicate() = + scope.runTest { + val dispatcher = StandardTestDispatcher(scheduler) + val testSubject = + ImagePreviewImageLoader( + dispatcher, + imageSize.width, + contentResolver, + cacheSize = 1, + ) + coroutineScope { + launch(start = UNDISPATCHED) { testSubject(uriOne, false) } + launch(start = UNDISPATCHED) { testSubject(uriOne, false) } + scheduler.advanceUntilIdle() + } - verify(contentResolver, times(1)).loadThumbnail(any(), any(), anyOrNull()) - } + verify(contentResolver, times(1)).loadThumbnail(any(), any(), anyOrNull()) + } @Test - fun invoke_oldRecordsEvictedFromTheCache() = runTest { - testSubject(uriOne) - testSubject(uriTwo) - testSubject(uriTwo) - testSubject(uriOne) - - verify(contentResolver, times(2)).loadThumbnail(uriOne, imageSize, null) - verify(contentResolver, times(1)).loadThumbnail(uriTwo, imageSize, null) - } + fun invoke_oldRecordsEvictedFromTheCache() = + scope.runTest { + testSubject(uriOne) + testSubject(uriTwo) + testSubject(uriTwo) + testSubject(uriOne) + + verify(contentResolver, times(2)).loadThumbnail(uriOne, imageSize, null) + verify(contentResolver, times(1)).loadThumbnail(uriTwo, imageSize, null) + } @Test - fun invoke_doNotCacheNulls() = runTest { - whenever(contentResolver.loadThumbnail(any(), any(), anyOrNull())).thenReturn(null) - testSubject(uriOne) - testSubject(uriOne) + fun invoke_doNotCacheNulls() = + scope.runTest { + whenever(contentResolver.loadThumbnail(any(), any(), anyOrNull())).thenReturn(null) + testSubject(uriOne) + testSubject(uriOne) - verify(contentResolver, times(2)).loadThumbnail(uriOne, imageSize, null) - } + verify(contentResolver, times(2)).loadThumbnail(uriOne, imageSize, null) + } @Test(expected = CancellationException::class) - fun invoke_onClosedImageLoaderScope_throwsCancellationException() = runTest { - lifecycleOwner.handleLifecycleEvent(Lifecycle.Event.ON_DESTROY) - testSubject(uriOne) - } + fun invoke_onClosedImageLoaderScope_throwsCancellationException() = + scope.runTest { + val imageLoaderScope = CoroutineScope(coroutineContext) + val testSubject = + ImagePreviewImageLoader( + imageLoaderScope, + imageSize.width, + contentResolver, + cacheSize = 1, + ) + imageLoaderScope.cancel() + testSubject(uriOne) + } @Test(expected = CancellationException::class) - fun invoke_imageLoaderScopeClosedMidflight_throwsCancellationException() = runTest { - val scheduler = TestCoroutineScheduler() - val dispatcher = StandardTestDispatcher(scheduler) - val testSubject = - ImagePreviewImageLoader( - lifecycleOwner.lifecycle.coroutineScope + dispatcher, - imageSize.width, - contentResolver, - cacheSize = 1, - ) - coroutineScope { - val deferred = async(start = UNDISPATCHED) { testSubject(uriOne, false) } - lifecycleOwner.handleLifecycleEvent(Lifecycle.Event.ON_DESTROY) - scheduler.advanceUntilIdle() - deferred.await() + fun invoke_imageLoaderScopeClosedMidflight_throwsCancellationException() = + scope.runTest { + val dispatcher = StandardTestDispatcher(scheduler) + val imageLoaderScope = CoroutineScope(coroutineContext + dispatcher) + val testSubject = + ImagePreviewImageLoader( + imageLoaderScope, + imageSize.width, + contentResolver, + cacheSize = 1, + ) + coroutineScope { + val deferred = async(start = UNDISPATCHED) { testSubject(uriOne, false) } + imageLoaderScope.cancel() + scheduler.advanceUntilIdle() + deferred.await() + } } - } @Test - fun invoke_multipleCallsWithDifferentCacheInstructions_cachingPrevails() = runTest { - val scheduler = TestCoroutineScheduler() - val dispatcher = StandardTestDispatcher(scheduler) - val testSubject = - ImagePreviewImageLoader( - lifecycleOwner.lifecycle.coroutineScope + dispatcher, - imageSize.width, - contentResolver, - cacheSize = 1, - ) - coroutineScope { - launch(start = UNDISPATCHED) { testSubject(uriOne, false) } - launch(start = UNDISPATCHED) { testSubject(uriOne, true) } - scheduler.advanceUntilIdle() - } - testSubject(uriOne, true) + fun invoke_multipleCallsWithDifferentCacheInstructions_cachingPrevails() = + scope.runTest { + val dispatcher = StandardTestDispatcher(scheduler) + val imageLoaderScope = CoroutineScope(coroutineContext + dispatcher) + val testSubject = + ImagePreviewImageLoader( + imageLoaderScope, + imageSize.width, + contentResolver, + cacheSize = 1, + ) + coroutineScope { + launch(start = UNDISPATCHED) { testSubject(uriOne, false) } + launch(start = UNDISPATCHED) { testSubject(uriOne, true) } + scheduler.advanceUntilIdle() + } + testSubject(uriOne, true) - verify(contentResolver, times(1)).loadThumbnail(uriOne, imageSize, null) - } + verify(contentResolver, times(1)).loadThumbnail(uriOne, imageSize, null) + } @Test - fun invoke_semaphoreGuardsContentResolverCalls() = runTest { - val contentResolver = - mock<ContentResolver> { - whenever(loadThumbnail(any(), any(), anyOrNull())) - .thenThrow(SecurityException("test")) - } - val acquireCount = AtomicInteger() - val releaseCount = AtomicInteger() - val testSemaphore = - object : Semaphore { - override val availablePermits: Int - get() = error("Unexpected invocation") - - override suspend fun acquire() { - acquireCount.getAndIncrement() + fun invoke_semaphoreGuardsContentResolverCalls() = + scope.runTest { + val contentResolver = + mock<ContentResolver> { + whenever(loadThumbnail(any(), any(), anyOrNull())) + .thenThrow(SecurityException("test")) } - - override fun tryAcquire(): Boolean { - error("Unexpected invocation") + val acquireCount = AtomicInteger() + val releaseCount = AtomicInteger() + val testSemaphore = + object : Semaphore { + override val availablePermits: Int + get() = error("Unexpected invocation") + + override suspend fun acquire() { + acquireCount.getAndIncrement() + } + + override fun tryAcquire(): Boolean { + error("Unexpected invocation") + } + + override fun release() { + releaseCount.getAndIncrement() + } } - override fun release() { - releaseCount.getAndIncrement() - } - } - - val testSubject = - ImagePreviewImageLoader( - lifecycleOwner.lifecycle.coroutineScope + dispatcher, - imageSize.width, - contentResolver, - cacheSize = 1, - testSemaphore, - ) - testSubject(uriOne, false) - - verify(contentResolver, times(1)).loadThumbnail(uriOne, imageSize, null) - assertThat(acquireCount.get()).isEqualTo(1) - assertThat(releaseCount.get()).isEqualTo(1) - } + val testSubject = + ImagePreviewImageLoader( + CoroutineScope(coroutineContext + dispatcher), + imageSize.width, + contentResolver, + cacheSize = 1, + testSemaphore, + ) + testSubject(uriOne, false) + + verify(contentResolver, times(1)).loadThumbnail(uriOne, imageSize, null) + assertThat(acquireCount.get()).isEqualTo(1) + assertThat(releaseCount.get()).isEqualTo(1) + } @Test - fun invoke_semaphoreIsReleasedAfterContentResolverFailure() = runTest { - val semaphoreDeferred = CompletableDeferred<Unit>() - val releaseCount = AtomicInteger() - val testSemaphore = - object : Semaphore { - override val availablePermits: Int - get() = error("Unexpected invocation") - - override suspend fun acquire() { - semaphoreDeferred.await() - } - - override fun tryAcquire(): Boolean { - error("Unexpected invocation") + fun invoke_semaphoreIsReleasedAfterContentResolverFailure() = + scope.runTest { + val semaphoreDeferred = CompletableDeferred<Unit>() + val releaseCount = AtomicInteger() + val testSemaphore = + object : Semaphore { + override val availablePermits: Int + get() = error("Unexpected invocation") + + override suspend fun acquire() { + semaphoreDeferred.await() + } + + override fun tryAcquire(): Boolean { + error("Unexpected invocation") + } + + override fun release() { + releaseCount.getAndIncrement() + } } - override fun release() { - releaseCount.getAndIncrement() - } - } - - val testSubject = - ImagePreviewImageLoader( - lifecycleOwner.lifecycle.coroutineScope + dispatcher, - imageSize.width, - contentResolver, - cacheSize = 1, - testSemaphore, - ) - launch(start = UNDISPATCHED) { testSubject(uriOne, false) } + val testSubject = + ImagePreviewImageLoader( + CoroutineScope(coroutineContext + dispatcher), + imageSize.width, + contentResolver, + cacheSize = 1, + testSemaphore, + ) + launch(start = UNDISPATCHED) { testSubject(uriOne, false) } - verify(contentResolver, never()).loadThumbnail(any(), any(), anyOrNull()) + verify(contentResolver, never()).loadThumbnail(any(), any(), anyOrNull()) - semaphoreDeferred.complete(Unit) + semaphoreDeferred.complete(Unit) - verify(contentResolver, times(1)).loadThumbnail(uriOne, imageSize, null) - assertThat(releaseCount.get()).isEqualTo(1) - } + verify(contentResolver, times(1)).loadThumbnail(uriOne, imageSize, null) + assertThat(releaseCount.get()).isEqualTo(1) + } @Test - fun invoke_multipleSimultaneousCalls_limitOnNumberOfSimultaneousOutgoingCallsIsRespected() { - val requestCount = 4 - val thumbnailCallsCdl = CountDownLatch(requestCount) - val pendingThumbnailCalls = ArrayDeque<CountDownLatch>() - val contentResolver = - mock<ContentResolver> { - whenever(loadThumbnail(any(), any(), anyOrNull())).thenAnswer { - val latch = CountDownLatch(1) - synchronized(pendingThumbnailCalls) { pendingThumbnailCalls.offer(latch) } - thumbnailCallsCdl.countDown() - assertTrue("Timeout waiting thumbnail calls", latch.await(1, SECONDS)) - bitmap + fun invoke_multipleSimultaneousCalls_limitOnNumberOfSimultaneousOutgoingCallsIsRespected() = + scope.runTest { + val requestCount = 4 + val thumbnailCallsCdl = CountDownLatch(requestCount) + val pendingThumbnailCalls = ArrayDeque<CountDownLatch>() + val contentResolver = + mock<ContentResolver> { + whenever(loadThumbnail(any(), any(), anyOrNull())).thenAnswer { + val latch = CountDownLatch(1) + synchronized(pendingThumbnailCalls) { pendingThumbnailCalls.offer(latch) } + thumbnailCallsCdl.countDown() + assertTrue("Timeout waiting thumbnail calls", latch.await(1, SECONDS)) + bitmap + } } - } - val name = "LoadImage" - val maxSimultaneousRequests = 2 - val threadsStartedCdl = CountDownLatch(requestCount) - val dispatcher = NewThreadDispatcher(name) { threadsStartedCdl.countDown() } - val testSubject = - ImagePreviewImageLoader( - lifecycleOwner.lifecycle.coroutineScope + dispatcher + CoroutineName(name), - imageSize.width, - contentResolver, - cacheSize = 1, - maxSimultaneousRequests, - ) - runTest { - repeat(requestCount) { - launch { testSubject(Uri.parse("content://org.pkg.app/image-$it.png")) } - } - yield() - // wait for all requests to be dispatched - assertThat(threadsStartedCdl.await(5, SECONDS)).isTrue() + val name = "LoadImage" + val maxSimultaneousRequests = 2 + val threadsStartedCdl = CountDownLatch(requestCount) + val dispatcher = NewThreadDispatcher(name) { threadsStartedCdl.countDown() } + val testSubject = + ImagePreviewImageLoader( + CoroutineScope(coroutineContext + dispatcher + CoroutineName(name)), + imageSize.width, + contentResolver, + cacheSize = 1, + maxSimultaneousRequests, + ) + coroutineScope { + repeat(requestCount) { + launch { testSubject(Uri.parse("content://org.pkg.app/image-$it.png")) } + } + yield() + // wait for all requests to be dispatched + assertThat(threadsStartedCdl.await(5, SECONDS)).isTrue() - assertThat(thumbnailCallsCdl.await(100, MILLISECONDS)).isFalse() - synchronized(pendingThumbnailCalls) { - assertThat(pendingThumbnailCalls.size).isEqualTo(maxSimultaneousRequests) - } + assertThat(thumbnailCallsCdl.await(100, MILLISECONDS)).isFalse() + synchronized(pendingThumbnailCalls) { + assertThat(pendingThumbnailCalls.size).isEqualTo(maxSimultaneousRequests) + } - pendingThumbnailCalls.poll()?.countDown() - assertThat(thumbnailCallsCdl.await(100, MILLISECONDS)).isFalse() - synchronized(pendingThumbnailCalls) { - assertThat(pendingThumbnailCalls.size).isEqualTo(maxSimultaneousRequests) - } + pendingThumbnailCalls.poll()?.countDown() + assertThat(thumbnailCallsCdl.await(100, MILLISECONDS)).isFalse() + synchronized(pendingThumbnailCalls) { + assertThat(pendingThumbnailCalls.size).isEqualTo(maxSimultaneousRequests) + } - pendingThumbnailCalls.poll()?.countDown() - assertThat(thumbnailCallsCdl.await(100, MILLISECONDS)).isTrue() - synchronized(pendingThumbnailCalls) { - assertThat(pendingThumbnailCalls.size).isEqualTo(maxSimultaneousRequests) - } - for (cdl in pendingThumbnailCalls) { - cdl.countDown() + pendingThumbnailCalls.poll()?.countDown() + assertThat(thumbnailCallsCdl.await(100, MILLISECONDS)).isTrue() + synchronized(pendingThumbnailCalls) { + assertThat(pendingThumbnailCalls.size).isEqualTo(maxSimultaneousRequests) + } + for (cdl in pendingThumbnailCalls) { + cdl.countDown() + } } } - } } private class NewThreadDispatcher( diff --git a/tests/unit/src/com/android/intentresolver/contentpreview/PayloadToggleInteractorTest.kt b/tests/unit/src/com/android/intentresolver/contentpreview/PayloadToggleInteractorTest.kt deleted file mode 100644 index 25c27468..00000000 --- a/tests/unit/src/com/android/intentresolver/contentpreview/PayloadToggleInteractorTest.kt +++ /dev/null @@ -1,162 +0,0 @@ -/* - * Copyright 2024 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.android.intentresolver.contentpreview - -import android.content.Intent -import android.database.Cursor -import android.database.MatrixCursor -import android.net.Uri -import com.google.common.truth.Truth.assertWithMessage -import kotlinx.coroutines.flow.first -import kotlinx.coroutines.test.TestCoroutineScheduler -import kotlinx.coroutines.test.TestScope -import kotlinx.coroutines.test.runTest -import org.junit.Test - -class PayloadToggleInteractorTest { - private val scheduler = TestCoroutineScheduler() - private val testScope = TestScope(scheduler) - - @Test - fun initialState() = - testScope.runTest { - val cursorReader = CursorUriReader(createCursor(10), 2, 2) { true } - val testSubject = - PayloadToggleInteractor( - scope = testScope.backgroundScope, - initiallySharedUris = listOf(makeUri(0), makeUri(2), makeUri(5)), - focusedUriIdx = 1, - mimeTypeClassifier = DefaultMimeTypeClassifier, - cursorReaderProvider = { cursorReader }, - uriMetadataReader = { uri -> - FileInfo.Builder(uri) - .withMimeType("image/png") - .withPreviewUri(uri) - .build() - }, - selectionCallback = { null }, - targetIntentModifier = { Intent(Intent.ACTION_SEND) }, - ) - .apply { start() } - - scheduler.runCurrent() - - testSubject.stateFlow.first().let { initialState -> - assertWithMessage("Two pages (2 items each) are expected to be initially read") - .that(initialState.items) - .hasSize(4) - assertWithMessage("Unexpected cursor values") - .that(initialState.items.map { it.uri }) - .containsExactly(*Array<Uri>(4, ::makeUri)) - .inOrder() - assertWithMessage("No more items are expected to the left") - .that(initialState.hasMoreItemsBefore) - .isFalse() - assertWithMessage("No more items are expected to the right") - .that(initialState.hasMoreItemsAfter) - .isTrue() - assertWithMessage("Selections should no be disabled") - .that(initialState.allowSelectionChange) - .isTrue() - } - - testSubject.loadMoreNextItems() - // this one is expected to be deduplicated - testSubject.loadMoreNextItems() - scheduler.runCurrent() - - testSubject.stateFlow.first().let { state -> - assertWithMessage("Unexpected cursor values") - .that(state.items.map { it.uri }) - .containsExactly(*Array(6, ::makeUri)) - .inOrder() - assertWithMessage("No more items are expected to the left") - .that(state.hasMoreItemsBefore) - .isFalse() - assertWithMessage("No more items are expected to the right") - .that(state.hasMoreItemsAfter) - .isTrue() - assertWithMessage("Selections should no be disabled") - .that(state.allowSelectionChange) - .isTrue() - assertWithMessage("Wrong selected items") - .that(state.items.map { testSubject.selected(it).first() }) - .containsExactly(true, false, true, false, false, true) - .inOrder() - } - } - - @Test - fun testItemsSelection() = - testScope.runTest { - val cursorReader = CursorUriReader(createCursor(10), 2, 2) { true } - val testSubject = - PayloadToggleInteractor( - scope = testScope.backgroundScope, - initiallySharedUris = listOf(makeUri(0)), - focusedUriIdx = 1, - mimeTypeClassifier = DefaultMimeTypeClassifier, - cursorReaderProvider = { cursorReader }, - uriMetadataReader = { uri -> - FileInfo.Builder(uri) - .withMimeType("image/png") - .withPreviewUri(uri) - .build() - }, - selectionCallback = { null }, - targetIntentModifier = { Intent(Intent.ACTION_SEND) }, - ) - .apply { start() } - - scheduler.runCurrent() - val items = testSubject.stateFlow.first().items - assertWithMessage("An initially selected item should be selected") - .that(testSubject.selected(items[0]).first()) - .isTrue() - assertWithMessage("An item that was not initially selected should not be selected") - .that(testSubject.selected(items[1]).first()) - .isFalse() - - testSubject.setSelected(items[0], false) - scheduler.runCurrent() - assertWithMessage("The only selected item can not be unselected") - .that(testSubject.selected(items[0]).first()) - .isTrue() - - testSubject.setSelected(items[1], true) - scheduler.runCurrent() - assertWithMessage("An item selection status should be published") - .that(testSubject.selected(items[1]).first()) - .isTrue() - - testSubject.setSelected(items[0], false) - scheduler.runCurrent() - assertWithMessage("An item can be unselected when there's another selected item") - .that(testSubject.selected(items[0]).first()) - .isFalse() - } -} - -private fun createCursor(count: Int): Cursor { - return MatrixCursor(arrayOf("uri")).apply { - for (i in 0 until count) { - addRow(arrayOf(makeUri(i))) - } - } -} - -private fun makeUri(id: Int) = Uri.parse("content://org.pkg.app/img-$id.png") diff --git a/tests/unit/src/com/android/intentresolver/contentpreview/PreviewViewModelTest.kt b/tests/unit/src/com/android/intentresolver/contentpreview/PreviewViewModelTest.kt deleted file mode 100644 index 1a59a930..00000000 --- a/tests/unit/src/com/android/intentresolver/contentpreview/PreviewViewModelTest.kt +++ /dev/null @@ -1,81 +0,0 @@ -/* - * Copyright 2024 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.android.intentresolver.contentpreview - -import android.content.Intent -import android.net.Uri -import androidx.test.ext.junit.runners.AndroidJUnit4 -import androidx.test.platform.app.InstrumentationRegistry -import com.google.common.truth.Truth.assertThat -import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.test.UnconfinedTestDispatcher -import org.junit.Test -import org.junit.runner.RunWith - -@RunWith(AndroidJUnit4::class) -class PreviewViewModelTest { - @OptIn(ExperimentalCoroutinesApi::class) private val dispatcher = UnconfinedTestDispatcher() - - private val context - get() = InstrumentationRegistry.getInstrumentation().targetContext - - private val targetIntent = Intent(Intent.ACTION_SEND) - private val chooserIntent = Intent.createChooser(targetIntent, null) - private val additionalContentUri = Uri.parse("content://org.pkg.content") - - @Test - fun featureFlagDisabled_noPayloadToggleInteractorCreated() { - val testSubject = - PreviewViewModel(context.contentResolver, 200, dispatcher).apply { - init( - targetIntent, - chooserIntent, - additionalContentUri, - focusedItemIdx = 0, - isPayloadTogglingEnabled = false - ) - } - - assertThat(testSubject.payloadToggleInteractor).isNull() - } - - @Test - fun noAdditionalContentUri_noPayloadToggleInteractorCreated() { - val testSubject = - PreviewViewModel(context.contentResolver, 200, dispatcher).apply { - init( - targetIntent, - chooserIntent, - additionalContentUri = null, - focusedItemIdx = 0, - true - ) - } - - assertThat(testSubject.payloadToggleInteractor).isNull() - } - - @Test - fun flagEnabledAndAdditionalContentUriProvided_createPayloadToggleInteractor() { - val testSubject = - PreviewViewModel(context.contentResolver, 200, dispatcher).apply { - init(targetIntent, chooserIntent, additionalContentUri, focusedItemIdx = 0, true) - } - - assertThat(testSubject.payloadToggleInteractor).isNotNull() - } -} diff --git a/tests/unit/src/com/android/intentresolver/contentpreview/SelectionTrackerTest.kt b/tests/unit/src/com/android/intentresolver/contentpreview/SelectionTrackerTest.kt deleted file mode 100644 index 6ba18466..00000000 --- a/tests/unit/src/com/android/intentresolver/contentpreview/SelectionTrackerTest.kt +++ /dev/null @@ -1,330 +0,0 @@ -/* - * Copyright 2024 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.android.intentresolver.contentpreview - -import android.net.Uri -import android.util.SparseArray -import com.google.common.truth.Truth.assertThat -import org.junit.Test - -class SelectionTrackerTest { - @Test - fun noSelectedItems() { - val testSubject = SelectionTracker<Uri>(emptyList(), 0, 10) { this } - - val items = - (1..5).fold(SparseArray<Uri>(5)) { acc, i -> - acc.apply { append(i * 2, makeUri(i * 2)) } - } - testSubject.onEndItemsAdded(items) - - assertThat(testSubject.getSelection()).isEmpty() - } - - @Test - fun testNoItems() { - val u1 = makeUri(1) - val u2 = makeUri(2) - val u3 = makeUri(3) - val testSubject = SelectionTracker(listOf(u1, u2, u3), 1, 0) { this } - - assertThat(testSubject.getSelection()).containsExactly(u1, u2, u3).inOrder() - } - - @Test - fun focusedItemInPlaceAllItemsOnTheRight_selectionsInTheInitialOrder() { - val u1 = makeUri(1) - val u2 = makeUri(2) - val u3 = makeUri(3) - val count = 7 - val testSubject = SelectionTracker(listOf(u1, u2, u3), 0, count) { this } - - testSubject.onEndItemsAdded( - SparseArray<Uri>(3).apply { - append(1, u1) - append(2, makeUri(4)) - append(3, makeUri(5)) - } - ) - assertThat(testSubject.getSelection()).containsExactly(u1, u2, u3).inOrder() - testSubject.onEndItemsAdded( - SparseArray<Uri>(3).apply { - append(3, makeUri(6)) - append(4, u2) - append(5, u3) - } - ) - assertThat(testSubject.getSelection()).containsExactly(u1, u2, u3).inOrder() - } - - @Test - fun focusedItemInPlaceElementsOnBothSides_selectionsInTheInitialOrder() { - val u1 = makeUri(1) - val u2 = makeUri(2) - val u3 = makeUri(3) - val count = 10 - val testSubject = SelectionTracker(listOf(u1, u2, u3), 1, count) { this } - - testSubject.onEndItemsAdded( - SparseArray<Uri>(3).apply { - append(4, u2) - append(5, makeUri(4)) - append(6, makeUri(5)) - } - ) - assertThat(testSubject.getSelection()).containsExactly(u1, u2, u3).inOrder() - - testSubject.onStartItemsAdded( - SparseArray<Uri>(3).apply { - append(1, makeUri(6)) - append(2, u1) - append(3, makeUri(7)) - } - ) - assertThat(testSubject.getSelection()).containsExactly(u1, u2, u3).inOrder() - - testSubject.onEndItemsAdded(SparseArray<Uri>(3).apply { append(8, u3) }) - assertThat(testSubject.getSelection()).containsExactly(u1, u2, u3).inOrder() - } - - @Test - fun focusedItemInPlaceAllItemsOnTheLeft_selectionsInTheInitialOrder() { - val u1 = makeUri(1) - val u2 = makeUri(2) - val u3 = makeUri(3) - val count = 7 - val testSubject = SelectionTracker(listOf(u1, u2, u3), 2, count) { this } - - testSubject.onEndItemsAdded(SparseArray<Uri>(3).apply { append(6, u3) }) - - assertThat(testSubject.getSelection()).containsExactly(u1, u2, u3).inOrder() - - testSubject.onStartItemsAdded( - SparseArray<Uri>(3).apply { - append(3, makeUri(4)) - append(4, u2) - append(5, makeUri(5)) - } - ) - assertThat(testSubject.getSelection()).containsExactly(u1, u2, u3).inOrder() - - testSubject.onStartItemsAdded( - SparseArray<Uri>(3).apply { - append(1, u1) - append(2, makeUri(6)) - } - ) - assertThat(testSubject.getSelection()).containsExactly(u1, u2, u3).inOrder() - } - - @Test - fun focusedItemInPlaceDuplicatesOnBothSides_selectionsInTheInitialOrder() { - val u1 = makeUri(1) - val u2 = makeUri(2) - val u3 = makeUri(3) - val count = 5 - val testSubject = SelectionTracker(listOf(u1, u2, u1), 1, count) { this } - - testSubject.onEndItemsAdded(SparseArray<Uri>(3).apply { append(2, u2) }) - assertThat(testSubject.getSelection()).containsExactly(u1, u2, u1).inOrder() - - testSubject.onStartItemsAdded( - SparseArray<Uri>(3).apply { - append(0, u1) - append(1, u3) - } - ) - assertThat(testSubject.getSelection()).containsExactly(u1, u2, u1).inOrder() - - testSubject.onStartItemsAdded( - SparseArray<Uri>(3).apply { - append(3, u1) - append(4, u3) - } - ) - assertThat(testSubject.getSelection()).containsExactly(u1, u2, u1).inOrder() - } - - @Test - fun focusedItemInPlaceDuplicatesOnTheRight_selectionsInTheInitialOrder() { - val u1 = makeUri(1) - val u2 = makeUri(2) - val count = 4 - val testSubject = SelectionTracker(listOf(u1, u2), 0, count) { this } - - testSubject.onEndItemsAdded(SparseArray<Uri>(1).apply { append(0, u1) }) - assertThat(testSubject.getSelection()).containsExactly(u1, u2).inOrder() - - testSubject.onEndItemsAdded( - SparseArray<Uri>(3).apply { - append(1, u2) - append(2, u1) - append(3, u2) - } - ) - assertThat(testSubject.getSelection()).containsExactly(u1, u2).inOrder() - } - - @Test - fun focusedItemInPlaceDuplicatesOnTheLeft_selectionsInTheInitialOrder() { - val u1 = makeUri(1) - val u2 = makeUri(2) - val count = 4 - val testSubject = SelectionTracker(listOf(u1, u2), 1, count) { this } - - testSubject.onEndItemsAdded(SparseArray<Uri>(1).apply { append(3, u2) }) - assertThat(testSubject.getSelection()).containsExactly(u1, u2).inOrder() - - testSubject.onStartItemsAdded( - SparseArray<Uri>(3).apply { - append(0, u1) - append(1, u2) - append(2, u1) - } - ) - assertThat(testSubject.getSelection()).containsExactly(u1, u2).inOrder() - } - - @Test - fun differentItemsOrder_selectionsInTheCursorOrder() { - val u1 = makeUri(1) - val u2 = makeUri(2) - val u3 = makeUri(3) - val u4 = makeUri(3) - val count = 10 - val testSubject = SelectionTracker(listOf(u1, u2, u3, u4), 2, count) { this } - - testSubject.onEndItemsAdded( - SparseArray<Uri>(3).apply { - append(4, makeUri(5)) - append(5, u1) - append(6, makeUri(6)) - } - ) - testSubject.onStartItemsAdded( - SparseArray<Uri>(3).apply { - append(2, makeUri(7)) - append(3, u4) - } - ) - testSubject.onEndItemsAdded( - SparseArray<Uri>(3).apply { - append(7, u3) - append(8, makeUri(8)) - } - ) - testSubject.onStartItemsAdded( - SparseArray<Uri>(3).apply { - append(0, makeUri(9)) - append(1, u2) - } - ) - assertThat(testSubject.getSelection()).containsExactly(u2, u4, u1, u3).inOrder() - } - - @Test - fun testPendingItems() { - val u1 = makeUri(1) - val u2 = makeUri(2) - val u3 = makeUri(3) - val u4 = makeUri(4) - val u5 = makeUri(5) - - val testSubject = SelectionTracker(listOf(u1, u2, u3, u4, u5), 2, 5) { this } - - testSubject.onEndItemsAdded( - SparseArray<Uri>(2).apply { - append(2, u3) - append(3, u4) - } - ) - testSubject.onStartItemsAdded(SparseArray<Uri>(2).apply { append(1, u2) }) - - assertThat(testSubject.getPendingItems()).containsExactly(u1, u5).inOrder() - } - - @Test - fun testItemSelection() { - val u1 = makeUri(1) - val u2 = makeUri(2) - val u3 = makeUri(3) - val u4 = makeUri(4) - val u5 = makeUri(5) - - val testSubject = SelectionTracker(listOf(u1, u2, u3, u4, u5), 2, 10) { this } - - testSubject.onEndItemsAdded( - SparseArray<Uri>(2).apply { - append(2, u3) - append(3, u4) - } - ) - assertThat(testSubject.getSelection()).containsExactly(u1, u2, u3, u4, u5).inOrder() - - assertThat(testSubject.setItemSelection(2, u3, false)).isTrue() - assertThat(testSubject.setItemSelection(3, u4, true)).isFalse() - assertThat(testSubject.getSelection()).containsExactly(u1, u2, u4, u5).inOrder() - - testSubject.onEndItemsAdded( - SparseArray<Uri>(1).apply { - append(4, u5) - append(5, u3) - } - ) - testSubject.onStartItemsAdded( - SparseArray<Uri>(2).apply { - append(0, u1) - append(1, u2) - } - ) - assertThat(testSubject.getSelection()).containsExactly(u1, u2, u4, u5).inOrder() - - assertThat(testSubject.setItemSelection(2, u3, true)).isTrue() - assertThat(testSubject.getSelection()).containsExactly(u1, u2, u3, u4, u5).inOrder() - assertThat(testSubject.setItemSelection(5, u3, true)).isTrue() - assertThat(testSubject.getSelection()).containsExactly(u1, u2, u3, u4, u5, u3).inOrder() - } - - @Test - fun testItemSelectionWithDuplicates() { - val u1 = makeUri(1) - val u2 = makeUri(2) - - val testSubject = SelectionTracker(listOf(u1, u2, u1), 1, 3) { this } - testSubject.onEndItemsAdded( - SparseArray<Uri>(2).apply { - append(1, u2) - append(2, u1) - } - ) - - assertThat(testSubject.getPendingItems()).containsExactly(u1) - } - - @Test - fun testUnselectOnlySelectedItem_itemRemainsSelected() { - val u1 = makeUri(1) - - val testSubject = SelectionTracker(listOf(u1), 0, 1) { this } - testSubject.onEndItemsAdded(SparseArray<Uri>(1).apply { append(0, u1) }) - assertThat(testSubject.isItemSelected(0)).isTrue() - assertThat(testSubject.setItemSelection(0, u1, false)).isFalse() - assertThat(testSubject.isItemSelected(0)).isTrue() - } -} - -private fun makeUri(id: Int) = Uri.parse("content://org.pkg.app/img-$id.png") diff --git a/tests/unit/src/com/android/intentresolver/contentpreview/UriMetadataReaderTest.kt b/tests/unit/src/com/android/intentresolver/contentpreview/UriMetadataReaderTest.kt index f7bf33fd..07f3a3f2 100644 --- a/tests/unit/src/com/android/intentresolver/contentpreview/UriMetadataReaderTest.kt +++ b/tests/unit/src/com/android/intentresolver/contentpreview/UriMetadataReaderTest.kt @@ -37,7 +37,7 @@ class UriMetadataReaderTest { fun testImageUri() { val mimeType = "image/png" whenever(contentResolver.getType(uri)).thenReturn(mimeType) - val testSubject = UriMetadataReader(contentResolver, DefaultMimeTypeClassifier) + val testSubject = UriMetadataReaderImpl(contentResolver, DefaultMimeTypeClassifier) testSubject.getMetadata(uri).let { fileInfo -> assertWithMessage("Wrong uri").that(fileInfo.uri).isEqualTo(uri) @@ -52,7 +52,7 @@ class UriMetadataReaderTest { val imageType = "image/png" whenever(contentResolver.getType(uri)).thenReturn(mimeType) whenever(contentResolver.getStreamTypes(eq(uri), any())).thenReturn(arrayOf(imageType)) - val testSubject = UriMetadataReader(contentResolver, DefaultMimeTypeClassifier) + val testSubject = UriMetadataReaderImpl(contentResolver, DefaultMimeTypeClassifier) testSubject.getMetadata(uri).let { fileInfo -> assertWithMessage("Wrong uri").that(fileInfo.uri).isEqualTo(uri) @@ -72,7 +72,7 @@ class UriMetadataReaderTest { addRow(arrayOf(DocumentsContract.Document.FLAG_SUPPORTS_THUMBNAIL)) } ) - val testSubject = UriMetadataReader(contentResolver, DefaultMimeTypeClassifier) + val testSubject = UriMetadataReaderImpl(contentResolver, DefaultMimeTypeClassifier) testSubject.getMetadata(uri).let { fileInfo -> assertWithMessage("Wrong uri").that(fileInfo.uri).isEqualTo(uri) @@ -89,7 +89,7 @@ class UriMetadataReaderTest { val columns = arrayOf(MediaMetadata.METADATA_KEY_DISPLAY_ICON_URI) whenever(contentResolver.query(eq(uri), eq(columns), anyOrNull(), anyOrNull())) .thenReturn(MatrixCursor(columns).apply { addRow(arrayOf(previewUri.toString())) }) - val testSubject = UriMetadataReader(contentResolver, DefaultMimeTypeClassifier) + val testSubject = UriMetadataReaderImpl(contentResolver, DefaultMimeTypeClassifier) testSubject.getMetadata(uri).let { fileInfo -> assertWithMessage("Wrong uri").that(fileInfo.uri).isEqualTo(uri) diff --git a/tests/unit/src/com/android/intentresolver/contentpreview/TargetIntentModifierTest.kt b/tests/unit/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/intent/TargetIntentModifierImplTest.kt index b589f566..7c36ef55 100644 --- a/tests/unit/src/com/android/intentresolver/contentpreview/TargetIntentModifierTest.kt +++ b/tests/unit/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/intent/TargetIntentModifierImplTest.kt @@ -14,7 +14,7 @@ * limitations under the License. */ -package com.android.intentresolver.contentpreview +package com.android.intentresolver.contentpreview.payloadtoggle.domain.intent import android.content.Intent import android.content.Intent.ACTION_SEND @@ -24,21 +24,22 @@ import android.net.Uri import com.google.common.truth.Truth.assertThat import org.junit.Test -class TargetIntentModifierTest { +class TargetIntentModifierImplTest { @Test fun testIntentActionChange() { - val testSubject = TargetIntentModifier<Uri>(Intent(ACTION_SEND), { this }, { "image/png" }) + val testSubject = + TargetIntentModifierImpl<Uri>(Intent(ACTION_SEND), { this }, { "image/png" }) val u1 = createUri(1) val u2 = createUri(2) - testSubject.onSelectionChanged(listOf(u1, u2)).let { intent -> + testSubject.intentFromSelection(listOf(u1, u2)).let { intent -> assertThat(intent.action).isEqualTo(ACTION_SEND_MULTIPLE) assertThat(intent.getParcelableArrayListExtra(EXTRA_STREAM, Uri::class.java)) .containsExactly(u1, u2) .inOrder() } - testSubject.onSelectionChanged(listOf(u1)).let { intent -> + testSubject.intentFromSelection(listOf(u1)).let { intent -> assertThat(intent.action).isEqualTo(ACTION_SEND) assertThat(intent.getParcelableExtra(EXTRA_STREAM, Uri::class.java)).isEqualTo(u1) } @@ -47,24 +48,26 @@ class TargetIntentModifierTest { @Test fun testMimeTypeChange() { val testSubject = - TargetIntentModifier<Pair<Uri, String?>>(Intent(ACTION_SEND), { first }, { second }) + TargetIntentModifierImpl<Pair<Uri, String?>>(Intent(ACTION_SEND), { first }, { second }) val u1 = createUri(1) val u2 = createUri(2) - testSubject.onSelectionChanged(listOf(u1 to "image/png", u2 to "image/png")).let { intent -> + testSubject.intentFromSelection(listOf(u1 to "image/png", u2 to "image/png")).let { intent + -> assertThat(intent.type).isEqualTo("image/png") } - testSubject.onSelectionChanged(listOf(u1 to "image/png", u2 to "image/jpg")).let { intent -> + testSubject.intentFromSelection(listOf(u1 to "image/png", u2 to "image/jpg")).let { intent + -> assertThat(intent.type).isEqualTo("image/*") } - testSubject.onSelectionChanged(listOf(u1 to "image/png", u2 to "video/mpeg")).let { intent + testSubject.intentFromSelection(listOf(u1 to "image/png", u2 to "video/mpeg")).let { intent -> assertThat(intent.type).isEqualTo("*/*") } - testSubject.onSelectionChanged(listOf(u1 to "image/png", u2 to null)).let { intent -> + testSubject.intentFromSelection(listOf(u1 to "image/png", u2 to null)).let { intent -> assertThat(intent.type).isEqualTo("*/*") } } diff --git a/tests/unit/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/interactor/CursorPreviewsInteractorTest.kt b/tests/unit/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/interactor/CursorPreviewsInteractorTest.kt new file mode 100644 index 00000000..b2d9be94 --- /dev/null +++ b/tests/unit/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/interactor/CursorPreviewsInteractorTest.kt @@ -0,0 +1,271 @@ +/* + * Copyright (C) 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +@file:OptIn(ExperimentalCoroutinesApi::class) + +package com.android.intentresolver.contentpreview.payloadtoggle.domain.interactor + +import android.database.MatrixCursor +import android.net.Uri +import androidx.core.os.bundleOf +import com.android.intentresolver.contentpreview.FileInfo +import com.android.intentresolver.contentpreview.payloadtoggle.data.repository.CursorPreviewsRepository +import com.android.intentresolver.contentpreview.payloadtoggle.shared.model.PreviewModel +import com.android.intentresolver.util.cursor.viewBy +import com.google.common.truth.Truth.assertThat +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import org.junit.Test + +class CursorPreviewsInteractorTest { + + private fun runTestWithDeps( + initialSelection: Iterable<Int> = (1..2), + focusedItemIndex: Int = initialSelection.count() / 2, + cursor: Iterable<Int> = (0 until 4), + cursorStartPosition: Int = cursor.count() / 2, + pageSize: Int = 16, + maxLoadedPages: Int = 3, + block: TestScope.(TestDeps) -> Unit, + ): Unit = runTest { + block( + TestDeps( + initialSelection, + focusedItemIndex, + cursor, + cursorStartPosition, + pageSize, + maxLoadedPages, + ) + ) + } + + private class TestDeps( + initialSelectionRange: Iterable<Int>, + focusedItemIndex: Int, + private val cursorRange: Iterable<Int>, + private val cursorStartPosition: Int, + pageSize: Int, + maxLoadedPages: Int, + ) { + val cursor = + MatrixCursor(arrayOf("uri")) + .apply { + extras = bundleOf("position" to cursorStartPosition) + for (i in cursorRange) { + newRow().add("uri", uri(i).toString()) + } + } + .viewBy { getString(0)?.let(Uri::parse) } + val previewsRepo = CursorPreviewsRepository() + val underTest = + CursorPreviewsInteractor( + interactor = SetCursorPreviewsInteractor(previewsRepo = previewsRepo), + focusedItemIdx = focusedItemIndex, + uriMetadataReader = { FileInfo.Builder(it).withMimeType("image/bitmap").build() }, + pageSize = pageSize, + maxLoadedPages = maxLoadedPages, + ) + val initialPreviews: List<PreviewModel> = + initialSelectionRange.map { i -> PreviewModel(uri = uri(i), mimeType = "image/bitmap") } + + private fun uri(index: Int) = Uri.fromParts("scheme$index", "ssp$index", "fragment$index") + } + + @Test + fun initialCursorLoad() = runTestWithDeps { deps -> + backgroundScope.launch { deps.underTest.launch(deps.cursor, deps.initialPreviews) } + runCurrent() + + assertThat(deps.previewsRepo.previewsModel.value).isNotNull() + assertThat(deps.previewsRepo.previewsModel.value!!.startIdx).isEqualTo(0) + assertThat(deps.previewsRepo.previewsModel.value!!.loadMoreLeft).isNull() + assertThat(deps.previewsRepo.previewsModel.value!!.loadMoreRight).isNull() + assertThat(deps.previewsRepo.previewsModel.value!!.previewModels) + .containsExactly( + PreviewModel(Uri.fromParts("scheme0", "ssp0", "fragment0"), "image/bitmap"), + PreviewModel(Uri.fromParts("scheme1", "ssp1", "fragment1"), "image/bitmap"), + PreviewModel(Uri.fromParts("scheme2", "ssp2", "fragment2"), "image/bitmap"), + PreviewModel(Uri.fromParts("scheme3", "ssp3", "fragment3"), "image/bitmap"), + ) + .inOrder() + } + + @Test + fun loadMoreLeft_evictRight() = + runTestWithDeps( + initialSelection = listOf(24), + cursor = (0 until 48), + pageSize = 16, + maxLoadedPages = 1, + ) { deps -> + backgroundScope.launch { deps.underTest.launch(deps.cursor, deps.initialPreviews) } + runCurrent() + + assertThat(deps.previewsRepo.previewsModel.value).isNotNull() + assertThat(deps.previewsRepo.previewsModel.value!!.previewModels).hasSize(16) + assertThat(deps.previewsRepo.previewsModel.value!!.previewModels.first().uri) + .isEqualTo(Uri.fromParts("scheme16", "ssp16", "fragment16")) + assertThat(deps.previewsRepo.previewsModel.value!!.previewModels.last().uri) + .isEqualTo(Uri.fromParts("scheme31", "ssp31", "fragment31")) + assertThat(deps.previewsRepo.previewsModel.value!!.loadMoreLeft).isNotNull() + + deps.previewsRepo.previewsModel.value!!.loadMoreLeft!!.invoke() + runCurrent() + + assertThat(deps.previewsRepo.previewsModel.value).isNotNull() + assertThat(deps.previewsRepo.previewsModel.value!!.previewModels).hasSize(16) + assertThat(deps.previewsRepo.previewsModel.value!!.previewModels.first().uri) + .isEqualTo(Uri.fromParts("scheme0", "ssp0", "fragment0")) + assertThat(deps.previewsRepo.previewsModel.value!!.previewModels.last().uri) + .isEqualTo(Uri.fromParts("scheme15", "ssp15", "fragment15")) + assertThat(deps.previewsRepo.previewsModel.value!!.loadMoreLeft).isNull() + } + + @Test + fun loadMoreLeft_keepRight() = + runTestWithDeps( + initialSelection = listOf(24), + cursor = (0 until 48), + pageSize = 16, + maxLoadedPages = 2, + ) { deps -> + backgroundScope.launch { deps.underTest.launch(deps.cursor, deps.initialPreviews) } + runCurrent() + + assertThat(deps.previewsRepo.previewsModel.value).isNotNull() + assertThat(deps.previewsRepo.previewsModel.value!!.previewModels).hasSize(16) + assertThat(deps.previewsRepo.previewsModel.value!!.previewModels.first().uri) + .isEqualTo(Uri.fromParts("scheme16", "ssp16", "fragment16")) + assertThat(deps.previewsRepo.previewsModel.value!!.previewModels.last().uri) + .isEqualTo(Uri.fromParts("scheme31", "ssp31", "fragment31")) + assertThat(deps.previewsRepo.previewsModel.value!!.loadMoreLeft).isNotNull() + + deps.previewsRepo.previewsModel.value!!.loadMoreLeft!!.invoke() + runCurrent() + + assertThat(deps.previewsRepo.previewsModel.value).isNotNull() + assertThat(deps.previewsRepo.previewsModel.value!!.previewModels).hasSize(32) + assertThat(deps.previewsRepo.previewsModel.value!!.previewModels.first().uri) + .isEqualTo(Uri.fromParts("scheme0", "ssp0", "fragment0")) + assertThat(deps.previewsRepo.previewsModel.value!!.previewModels.last().uri) + .isEqualTo(Uri.fromParts("scheme31", "ssp31", "fragment31")) + assertThat(deps.previewsRepo.previewsModel.value!!.loadMoreLeft).isNull() + } + + @Test + fun loadMoreRight_evictLeft() = + runTestWithDeps( + initialSelection = listOf(24), + cursor = (0 until 48), + pageSize = 16, + maxLoadedPages = 1, + ) { deps -> + backgroundScope.launch { deps.underTest.launch(deps.cursor, deps.initialPreviews) } + runCurrent() + + assertThat(deps.previewsRepo.previewsModel.value).isNotNull() + assertThat(deps.previewsRepo.previewsModel.value!!.previewModels).hasSize(16) + assertThat(deps.previewsRepo.previewsModel.value!!.previewModels.first().uri) + .isEqualTo(Uri.fromParts("scheme16", "ssp16", "fragment16")) + assertThat(deps.previewsRepo.previewsModel.value!!.previewModels.last().uri) + .isEqualTo(Uri.fromParts("scheme31", "ssp31", "fragment31")) + assertThat(deps.previewsRepo.previewsModel.value!!.loadMoreRight).isNotNull() + + deps.previewsRepo.previewsModel.value!!.loadMoreRight!!.invoke() + runCurrent() + + assertThat(deps.previewsRepo.previewsModel.value).isNotNull() + assertThat(deps.previewsRepo.previewsModel.value!!.previewModels).hasSize(16) + assertThat(deps.previewsRepo.previewsModel.value!!.previewModels.first().uri) + .isEqualTo(Uri.fromParts("scheme32", "ssp32", "fragment32")) + assertThat(deps.previewsRepo.previewsModel.value!!.previewModels.last().uri) + .isEqualTo(Uri.fromParts("scheme47", "ssp47", "fragment47")) + } + + @Test + fun loadMoreRight_keepLeft() = + runTestWithDeps( + initialSelection = listOf(24), + cursor = (0 until 48), + pageSize = 16, + maxLoadedPages = 2, + ) { deps -> + backgroundScope.launch { deps.underTest.launch(deps.cursor, deps.initialPreviews) } + runCurrent() + + assertThat(deps.previewsRepo.previewsModel.value).isNotNull() + assertThat(deps.previewsRepo.previewsModel.value!!.previewModels).hasSize(16) + assertThat(deps.previewsRepo.previewsModel.value!!.previewModels.first().uri) + .isEqualTo(Uri.fromParts("scheme16", "ssp16", "fragment16")) + assertThat(deps.previewsRepo.previewsModel.value!!.previewModels.last().uri) + .isEqualTo(Uri.fromParts("scheme31", "ssp31", "fragment31")) + assertThat(deps.previewsRepo.previewsModel.value!!.loadMoreRight).isNotNull() + + deps.previewsRepo.previewsModel.value!!.loadMoreRight!!.invoke() + runCurrent() + + assertThat(deps.previewsRepo.previewsModel.value).isNotNull() + assertThat(deps.previewsRepo.previewsModel.value!!.previewModels).hasSize(32) + assertThat(deps.previewsRepo.previewsModel.value!!.previewModels.first().uri) + .isEqualTo(Uri.fromParts("scheme16", "ssp16", "fragment16")) + assertThat(deps.previewsRepo.previewsModel.value!!.previewModels.last().uri) + .isEqualTo(Uri.fromParts("scheme47", "ssp47", "fragment47")) + } + + @Test + fun noMoreRight_appendUnclaimedFromInitialSelection() = + runTestWithDeps( + initialSelection = listOf(24, 50), + cursor = listOf(24), + pageSize = 16, + maxLoadedPages = 2, + ) { deps -> + backgroundScope.launch { deps.underTest.launch(deps.cursor, deps.initialPreviews) } + runCurrent() + + assertThat(deps.previewsRepo.previewsModel.value).isNotNull() + assertThat(deps.previewsRepo.previewsModel.value!!.previewModels).hasSize(2) + assertThat(deps.previewsRepo.previewsModel.value!!.previewModels.first().uri) + .isEqualTo(Uri.fromParts("scheme24", "ssp24", "fragment24")) + assertThat(deps.previewsRepo.previewsModel.value!!.previewModels.last().uri) + .isEqualTo(Uri.fromParts("scheme50", "ssp50", "fragment50")) + assertThat(deps.previewsRepo.previewsModel.value!!.loadMoreRight).isNull() + } + + @Test + fun noMoreLeft_appendUnclaimedFromInitialSelection() = + runTestWithDeps( + initialSelection = listOf(0, 24), + cursor = listOf(24), + pageSize = 16, + maxLoadedPages = 2, + ) { deps -> + backgroundScope.launch { deps.underTest.launch(deps.cursor, deps.initialPreviews) } + runCurrent() + + assertThat(deps.previewsRepo.previewsModel.value).isNotNull() + assertThat(deps.previewsRepo.previewsModel.value!!.previewModels).hasSize(2) + assertThat(deps.previewsRepo.previewsModel.value!!.previewModels.first().uri) + .isEqualTo(Uri.fromParts("scheme0", "ssp0", "fragment0")) + assertThat(deps.previewsRepo.previewsModel.value!!.previewModels.last().uri) + .isEqualTo(Uri.fromParts("scheme24", "ssp24", "fragment24")) + assertThat(deps.previewsRepo.previewsModel.value!!.loadMoreLeft).isNull() + } +} diff --git a/tests/unit/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/interactor/CustomActionsInteractorTest.kt b/tests/unit/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/interactor/CustomActionsInteractorTest.kt new file mode 100644 index 00000000..ceb20dab --- /dev/null +++ b/tests/unit/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/interactor/CustomActionsInteractorTest.kt @@ -0,0 +1,166 @@ +/* + * Copyright (C) 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +@file:OptIn(ExperimentalCoroutinesApi::class) + +package com.android.intentresolver.contentpreview.payloadtoggle.domain.interactor + +import android.app.Activity +import android.graphics.Bitmap +import android.graphics.drawable.Icon +import com.android.intentresolver.contentpreview.payloadtoggle.data.model.CustomActionModel +import com.android.intentresolver.contentpreview.payloadtoggle.data.repository.ActivityResultRepository +import com.android.intentresolver.contentpreview.payloadtoggle.domain.model.ActionModel +import com.android.intentresolver.icon.BitmapIcon +import com.android.intentresolver.mock +import com.android.intentresolver.util.comparingElementsUsingTransform +import com.android.intentresolver.v2.data.model.fakeChooserRequest +import com.android.intentresolver.v2.data.repository.ChooserRequestRepository +import com.google.common.truth.Truth.assertThat +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import org.junit.Test + +class CustomActionsInteractorTest { + + private val testDispatcher = StandardTestDispatcher() + + @Test + fun customActions_initialRepoValue() = + runTest(testDispatcher) { + val bitmap = Bitmap.createBitmap(100, 100, Bitmap.Config.ALPHA_8) + val icon = Icon.createWithBitmap(bitmap) + val chooserRequestRepository = + ChooserRequestRepository( + initialRequest = fakeChooserRequest(), + initialActions = + listOf( + CustomActionModel(label = "label1", icon = icon, performAction = {}), + ), + ) + val underTest = + CustomActionsInteractor( + activityResultRepo = ActivityResultRepository(), + bgDispatcher = testDispatcher, + contentResolver = mock {}, + eventLog = mock {}, + packageManager = mock {}, + chooserRequestInteractor = + ChooserRequestInteractor(repository = chooserRequestRepository), + ) + val customActions: StateFlow<List<ActionModel>> = + underTest.customActions.stateIn(backgroundScope) + assertThat(customActions.value) + .comparingElementsUsingTransform("has a label of") { model: ActionModel -> + model.label + } + .containsExactly("label1") + .inOrder() + assertThat(customActions.value) + .comparingElementsUsingTransform("has an icon of") { model: ActionModel -> + model.icon + } + .containsExactly(BitmapIcon(icon.bitmap)) + .inOrder() + } + + @Test + fun customActions_tracksRepoUpdates() = + runTest(testDispatcher) { + val chooserRequestRepository = + ChooserRequestRepository( + initialRequest = fakeChooserRequest(), + initialActions = emptyList(), + ) + val underTest = + CustomActionsInteractor( + activityResultRepo = ActivityResultRepository(), + bgDispatcher = testDispatcher, + contentResolver = mock {}, + eventLog = mock {}, + packageManager = mock {}, + chooserRequestInteractor = + ChooserRequestInteractor(repository = chooserRequestRepository), + ) + + val customActions: StateFlow<List<ActionModel>> = + underTest.customActions.stateIn(backgroundScope) + val bitmap = Bitmap.createBitmap(100, 100, Bitmap.Config.ALPHA_8) + val icon = Icon.createWithBitmap(bitmap) + val chooserActions = listOf(CustomActionModel("label1", icon) {}) + chooserRequestRepository.customActions.value = chooserActions + runCurrent() + + assertThat(customActions.value) + .comparingElementsUsingTransform("has a label of") { model: ActionModel -> + model.label + } + .containsExactly("label1") + .inOrder() + assertThat(customActions.value) + .comparingElementsUsingTransform("has an icon of") { model: ActionModel -> + model.icon + } + .containsExactly(BitmapIcon(icon.bitmap)) + .inOrder() + } + + @Test + fun customActions_performAction_sendsPendingIntent() = + runTest(testDispatcher) { + val bitmap = Bitmap.createBitmap(100, 100, Bitmap.Config.ALPHA_8) + val icon = Icon.createWithBitmap(bitmap) + var actionSent = false + val activityResultRepository = ActivityResultRepository() + val chooserRequestRepository = + ChooserRequestRepository( + initialRequest = fakeChooserRequest(), + initialActions = + listOf( + CustomActionModel( + label = "label1", + icon = icon, + performAction = { actionSent = true }, + ) + ), + ) + val underTest = + CustomActionsInteractor( + activityResultRepo = activityResultRepository, + bgDispatcher = testDispatcher, + contentResolver = mock {}, + eventLog = mock {}, + packageManager = mock {}, + chooserRequestInteractor = + ChooserRequestInteractor( + repository = chooserRequestRepository, + ), + ) + val customActions: StateFlow<List<ActionModel>> = + underTest.customActions.stateIn(backgroundScope) + + assertThat(customActions.value).hasSize(1) + + customActions.value[0].performAction(123) + + assertThat(actionSent).isTrue() + assertThat(activityResultRepository.activityResult.value).isEqualTo(Activity.RESULT_OK) + } +} diff --git a/tests/unit/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/interactor/FetchPreviewsInteractorTest.kt b/tests/unit/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/interactor/FetchPreviewsInteractorTest.kt new file mode 100644 index 00000000..08a667b9 --- /dev/null +++ b/tests/unit/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/interactor/FetchPreviewsInteractorTest.kt @@ -0,0 +1,335 @@ +/* + * Copyright (C) 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +@file:OptIn(ExperimentalCoroutinesApi::class) + +package com.android.intentresolver.contentpreview.payloadtoggle.domain.interactor + +import android.database.MatrixCursor +import android.net.Uri +import androidx.core.os.bundleOf +import com.android.intentresolver.contentpreview.FileInfo +import com.android.intentresolver.contentpreview.UriMetadataReader +import com.android.intentresolver.contentpreview.payloadtoggle.data.repository.CursorPreviewsRepository +import com.android.intentresolver.contentpreview.payloadtoggle.data.repository.PreviewSelectionsRepository +import com.android.intentresolver.contentpreview.payloadtoggle.domain.cursor.CursorResolver +import com.android.intentresolver.contentpreview.payloadtoggle.shared.model.PreviewModel +import com.android.intentresolver.contentpreview.payloadtoggle.shared.model.PreviewsModel +import com.android.intentresolver.util.cursor.CursorView +import com.android.intentresolver.util.cursor.viewBy +import com.google.common.truth.Truth.assertThat +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import org.junit.Test + +class FetchPreviewsInteractorTest { + + private fun runTestWithDeps( + initialSelection: Iterable<Int> = (1..2), + focusedItemIndex: Int = initialSelection.count() / 2, + cursor: Iterable<Int> = (0 until 4), + cursorStartPosition: Int = cursor.count() / 2, + pageSize: Int = 16, + maxLoadedPages: Int = 3, + block: TestScope.(TestDeps) -> Unit, + ): Unit = runTest { + block( + TestDeps( + initialSelection, + focusedItemIndex, + cursor, + cursorStartPosition, + pageSize, + maxLoadedPages, + ) + ) + } + + private class TestDeps( + initialSelectionRange: Iterable<Int>, + focusedItemIndex: Int, + private val cursorRange: Iterable<Int>, + private val cursorStartPosition: Int, + pageSize: Int, + maxLoadedPages: Int, + ) { + + private fun uri(index: Int) = Uri.fromParts("scheme$index", "ssp$index", "fragment$index") + + val previewsRepo = CursorPreviewsRepository() + + val cursorResolver = FakeCursorResolver() + + private val uriMetadataReader = UriMetadataReader { + FileInfo.Builder(it).withMimeType("image/bitmap").build() + } + + val underTest = + FetchPreviewsInteractor( + setCursorPreviews = SetCursorPreviewsInteractor(previewsRepo), + selectionRepository = PreviewSelectionsRepository(), + cursorInteractor = + CursorPreviewsInteractor( + interactor = SetCursorPreviewsInteractor(previewsRepo = previewsRepo), + focusedItemIdx = focusedItemIndex, + uriMetadataReader = uriMetadataReader, + pageSize = pageSize, + maxLoadedPages = maxLoadedPages, + ), + focusedItemIdx = focusedItemIndex, + selectedItems = initialSelectionRange.map { idx -> uri(idx) }, + uriMetadataReader = uriMetadataReader, + cursorResolver = cursorResolver, + ) + + inner class FakeCursorResolver : CursorResolver<Uri?> { + private val mutex = Mutex(locked = true) + + fun complete() = mutex.unlock() + + override suspend fun getCursor(): CursorView<Uri?> = + mutex.withLock { + MatrixCursor(arrayOf("uri")) + .apply { + extras = bundleOf("position" to cursorStartPosition) + for (i in cursorRange) { + newRow().add("uri", uri(i).toString()) + } + } + .viewBy { getString(0)?.let(Uri::parse) } + } + } + } + + @Test + fun setsInitialPreviews() = runTestWithDeps { deps -> + backgroundScope.launch { deps.underTest.activate() } + runCurrent() + + assertThat(deps.previewsRepo.previewsModel.value) + .isEqualTo( + PreviewsModel( + previewModels = + setOf( + PreviewModel( + Uri.fromParts("scheme1", "ssp1", "fragment1"), + "image/bitmap", + ), + PreviewModel( + Uri.fromParts("scheme2", "ssp2", "fragment2"), + "image/bitmap", + ), + ), + startIdx = 1, + loadMoreLeft = null, + loadMoreRight = null, + ) + ) + } + + @Test + fun lookupCursorFromContentResolver() = runTestWithDeps { deps -> + backgroundScope.launch { deps.underTest.activate() } + deps.cursorResolver.complete() + runCurrent() + + assertThat(deps.previewsRepo.previewsModel.value).isNotNull() + assertThat(deps.previewsRepo.previewsModel.value!!.startIdx).isEqualTo(0) + assertThat(deps.previewsRepo.previewsModel.value!!.loadMoreLeft).isNull() + assertThat(deps.previewsRepo.previewsModel.value!!.loadMoreRight).isNull() + assertThat(deps.previewsRepo.previewsModel.value!!.previewModels) + .containsExactly( + PreviewModel(Uri.fromParts("scheme0", "ssp0", "fragment0"), "image/bitmap"), + PreviewModel(Uri.fromParts("scheme1", "ssp1", "fragment1"), "image/bitmap"), + PreviewModel(Uri.fromParts("scheme2", "ssp2", "fragment2"), "image/bitmap"), + PreviewModel(Uri.fromParts("scheme3", "ssp3", "fragment3"), "image/bitmap"), + ) + .inOrder() + } + + @Test + fun loadMoreLeft_evictRight() = + runTestWithDeps( + initialSelection = listOf(24), + cursor = (0 until 48), + pageSize = 16, + maxLoadedPages = 1, + ) { deps -> + backgroundScope.launch { deps.underTest.activate() } + deps.cursorResolver.complete() + runCurrent() + + assertThat(deps.previewsRepo.previewsModel.value).isNotNull() + assertThat(deps.previewsRepo.previewsModel.value!!.previewModels).hasSize(16) + assertThat(deps.previewsRepo.previewsModel.value!!.previewModels.first().uri) + .isEqualTo(Uri.fromParts("scheme16", "ssp16", "fragment16")) + assertThat(deps.previewsRepo.previewsModel.value!!.previewModels.last().uri) + .isEqualTo(Uri.fromParts("scheme31", "ssp31", "fragment31")) + assertThat(deps.previewsRepo.previewsModel.value!!.loadMoreLeft).isNotNull() + + deps.previewsRepo.previewsModel.value!!.loadMoreLeft!!.invoke() + runCurrent() + + assertThat(deps.previewsRepo.previewsModel.value).isNotNull() + assertThat(deps.previewsRepo.previewsModel.value!!.previewModels).hasSize(16) + assertThat(deps.previewsRepo.previewsModel.value!!.previewModels.first().uri) + .isEqualTo(Uri.fromParts("scheme0", "ssp0", "fragment0")) + assertThat(deps.previewsRepo.previewsModel.value!!.previewModels.last().uri) + .isEqualTo(Uri.fromParts("scheme15", "ssp15", "fragment15")) + assertThat(deps.previewsRepo.previewsModel.value!!.loadMoreLeft).isNull() + } + + @Test + fun loadMoreLeft_keepRight() = + runTestWithDeps( + initialSelection = listOf(24), + cursor = (0 until 48), + pageSize = 16, + maxLoadedPages = 2, + ) { deps -> + backgroundScope.launch { deps.underTest.activate() } + deps.cursorResolver.complete() + runCurrent() + + assertThat(deps.previewsRepo.previewsModel.value).isNotNull() + assertThat(deps.previewsRepo.previewsModel.value!!.previewModels).hasSize(16) + assertThat(deps.previewsRepo.previewsModel.value!!.previewModels.first().uri) + .isEqualTo(Uri.fromParts("scheme16", "ssp16", "fragment16")) + assertThat(deps.previewsRepo.previewsModel.value!!.previewModels.last().uri) + .isEqualTo(Uri.fromParts("scheme31", "ssp31", "fragment31")) + assertThat(deps.previewsRepo.previewsModel.value!!.loadMoreLeft).isNotNull() + + deps.previewsRepo.previewsModel.value!!.loadMoreLeft!!.invoke() + runCurrent() + + assertThat(deps.previewsRepo.previewsModel.value).isNotNull() + assertThat(deps.previewsRepo.previewsModel.value!!.previewModels).hasSize(32) + assertThat(deps.previewsRepo.previewsModel.value!!.previewModels.first().uri) + .isEqualTo(Uri.fromParts("scheme0", "ssp0", "fragment0")) + assertThat(deps.previewsRepo.previewsModel.value!!.previewModels.last().uri) + .isEqualTo(Uri.fromParts("scheme31", "ssp31", "fragment31")) + assertThat(deps.previewsRepo.previewsModel.value!!.loadMoreLeft).isNull() + } + + @Test + fun loadMoreRight_evictLeft() = + runTestWithDeps( + initialSelection = listOf(24), + cursor = (0 until 48), + pageSize = 16, + maxLoadedPages = 1, + ) { deps -> + backgroundScope.launch { deps.underTest.activate() } + deps.cursorResolver.complete() + runCurrent() + + assertThat(deps.previewsRepo.previewsModel.value).isNotNull() + assertThat(deps.previewsRepo.previewsModel.value!!.previewModels).hasSize(16) + assertThat(deps.previewsRepo.previewsModel.value!!.previewModels.first().uri) + .isEqualTo(Uri.fromParts("scheme16", "ssp16", "fragment16")) + assertThat(deps.previewsRepo.previewsModel.value!!.previewModels.last().uri) + .isEqualTo(Uri.fromParts("scheme31", "ssp31", "fragment31")) + assertThat(deps.previewsRepo.previewsModel.value!!.loadMoreRight).isNotNull() + + deps.previewsRepo.previewsModel.value!!.loadMoreRight!!.invoke() + runCurrent() + + assertThat(deps.previewsRepo.previewsModel.value).isNotNull() + assertThat(deps.previewsRepo.previewsModel.value!!.previewModels).hasSize(16) + assertThat(deps.previewsRepo.previewsModel.value!!.previewModels.first().uri) + .isEqualTo(Uri.fromParts("scheme32", "ssp32", "fragment32")) + assertThat(deps.previewsRepo.previewsModel.value!!.previewModels.last().uri) + .isEqualTo(Uri.fromParts("scheme47", "ssp47", "fragment47")) + } + + @Test + fun loadMoreRight_keepLeft() = + runTestWithDeps( + initialSelection = listOf(24), + cursor = (0 until 48), + pageSize = 16, + maxLoadedPages = 2, + ) { deps -> + backgroundScope.launch { deps.underTest.activate() } + deps.cursorResolver.complete() + runCurrent() + + assertThat(deps.previewsRepo.previewsModel.value).isNotNull() + assertThat(deps.previewsRepo.previewsModel.value!!.previewModels).hasSize(16) + assertThat(deps.previewsRepo.previewsModel.value!!.previewModels.first().uri) + .isEqualTo(Uri.fromParts("scheme16", "ssp16", "fragment16")) + assertThat(deps.previewsRepo.previewsModel.value!!.previewModels.last().uri) + .isEqualTo(Uri.fromParts("scheme31", "ssp31", "fragment31")) + assertThat(deps.previewsRepo.previewsModel.value!!.loadMoreRight).isNotNull() + + deps.previewsRepo.previewsModel.value!!.loadMoreRight!!.invoke() + runCurrent() + + assertThat(deps.previewsRepo.previewsModel.value).isNotNull() + assertThat(deps.previewsRepo.previewsModel.value!!.previewModels).hasSize(32) + assertThat(deps.previewsRepo.previewsModel.value!!.previewModels.first().uri) + .isEqualTo(Uri.fromParts("scheme16", "ssp16", "fragment16")) + assertThat(deps.previewsRepo.previewsModel.value!!.previewModels.last().uri) + .isEqualTo(Uri.fromParts("scheme47", "ssp47", "fragment47")) + } + + @Test + fun noMoreRight_appendUnclaimedFromInitialSelection() = + runTestWithDeps( + initialSelection = listOf(24, 50), + cursor = listOf(24), + pageSize = 16, + maxLoadedPages = 2, + ) { deps -> + backgroundScope.launch { deps.underTest.activate() } + deps.cursorResolver.complete() + runCurrent() + + assertThat(deps.previewsRepo.previewsModel.value).isNotNull() + assertThat(deps.previewsRepo.previewsModel.value!!.previewModels).hasSize(2) + assertThat(deps.previewsRepo.previewsModel.value!!.previewModels.first().uri) + .isEqualTo(Uri.fromParts("scheme24", "ssp24", "fragment24")) + assertThat(deps.previewsRepo.previewsModel.value!!.previewModels.last().uri) + .isEqualTo(Uri.fromParts("scheme50", "ssp50", "fragment50")) + assertThat(deps.previewsRepo.previewsModel.value!!.loadMoreRight).isNull() + } + + @Test + fun noMoreLeft_appendUnclaimedFromInitialSelection() = + runTestWithDeps( + initialSelection = listOf(0, 24), + cursor = listOf(24), + pageSize = 16, + maxLoadedPages = 2, + ) { deps -> + backgroundScope.launch { deps.underTest.activate() } + deps.cursorResolver.complete() + runCurrent() + + assertThat(deps.previewsRepo.previewsModel.value).isNotNull() + assertThat(deps.previewsRepo.previewsModel.value!!.previewModels).hasSize(2) + assertThat(deps.previewsRepo.previewsModel.value!!.previewModels.first().uri) + .isEqualTo(Uri.fromParts("scheme0", "ssp0", "fragment0")) + assertThat(deps.previewsRepo.previewsModel.value!!.previewModels.last().uri) + .isEqualTo(Uri.fromParts("scheme24", "ssp24", "fragment24")) + assertThat(deps.previewsRepo.previewsModel.value!!.loadMoreLeft).isNull() + } +} diff --git a/tests/unit/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/interactor/SelectablePreviewInteractorTest.kt b/tests/unit/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/interactor/SelectablePreviewInteractorTest.kt new file mode 100644 index 00000000..ff22f37b --- /dev/null +++ b/tests/unit/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/interactor/SelectablePreviewInteractorTest.kt @@ -0,0 +1,148 @@ +/* + * Copyright (C) 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +@file:OptIn(ExperimentalCoroutinesApi::class) + +package com.android.intentresolver.contentpreview.payloadtoggle.domain.interactor + +import android.content.Intent +import android.net.Uri +import com.android.intentresolver.contentpreview.payloadtoggle.data.repository.PendingSelectionCallbackRepository +import com.android.intentresolver.contentpreview.payloadtoggle.data.repository.PreviewSelectionsRepository +import com.android.intentresolver.contentpreview.payloadtoggle.shared.model.PreviewModel +import com.android.intentresolver.v2.data.model.fakeChooserRequest +import com.android.intentresolver.v2.data.repository.ChooserRequestRepository +import com.google.common.truth.Truth.assertThat +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import org.junit.Test + +class SelectablePreviewInteractorTest { + + @Test + fun reflectPreviewRepo_initState() = runTest { + val selectionRepo = PreviewSelectionsRepository() + val chooserRequestRepo = + ChooserRequestRepository( + initialRequest = fakeChooserRequest(), + initialActions = emptyList(), + ) + val pendingSelectionCallbackRepo = PendingSelectionCallbackRepository() + val underTest = + SelectablePreviewInteractor( + key = PreviewModel(Uri.fromParts("scheme", "ssp", "fragment"), null), + selectionInteractor = + SelectionInteractor( + selectionsRepo = selectionRepo, + targetIntentModifier = { error("unexpected invocation") }, + updateTargetIntentInteractor = + UpdateTargetIntentInteractor( + repository = pendingSelectionCallbackRepo, + chooserRequestInteractor = + UpdateChooserRequestInteractor( + repository = chooserRequestRepo, + pendingIntentSender = { error("unexpected invocation") }, + ) + ) + ), + ) + runCurrent() + + assertThat(underTest.isSelected.first()).isFalse() + } + + @Test + fun reflectPreviewRepo_updatedState() = runTest { + val selectionRepo = PreviewSelectionsRepository() + val chooserRequestRepo = + ChooserRequestRepository( + initialRequest = fakeChooserRequest(), + initialActions = emptyList(), + ) + val pendingSelectionCallbackRepo = PendingSelectionCallbackRepository() + val underTest = + SelectablePreviewInteractor( + key = PreviewModel(Uri.fromParts("scheme", "ssp", "fragment"), "image/bitmap"), + selectionInteractor = + SelectionInteractor( + selectionsRepo = selectionRepo, + targetIntentModifier = { error("unexpected invocation") }, + updateTargetIntentInteractor = + UpdateTargetIntentInteractor( + repository = pendingSelectionCallbackRepo, + chooserRequestInteractor = + UpdateChooserRequestInteractor( + repository = chooserRequestRepo, + pendingIntentSender = { error("unexpected invocation") }, + ) + ) + ), + ) + + assertThat(underTest.isSelected.first()).isFalse() + + selectionRepo.selections.value = + setOf(PreviewModel(Uri.fromParts("scheme", "ssp", "fragment"), "image/bitmap")) + runCurrent() + + assertThat(underTest.isSelected.first()).isTrue() + } + + @Test + fun setSelected_updatesChooserRequestRepo() = runTest { + val modifiedIntent = Intent() + val selectionRepo = PreviewSelectionsRepository() + val chooserRequestRepo = + ChooserRequestRepository( + initialRequest = fakeChooserRequest(), + initialActions = emptyList(), + ) + val pendingSelectionCallbackRepo = PendingSelectionCallbackRepository() + val underTest = + SelectablePreviewInteractor( + key = PreviewModel(Uri.fromParts("scheme", "ssp", "fragment"), "image/bitmap"), + selectionInteractor = + SelectionInteractor( + selectionsRepo = selectionRepo, + targetIntentModifier = { modifiedIntent }, + updateTargetIntentInteractor = + UpdateTargetIntentInteractor( + repository = pendingSelectionCallbackRepo, + chooserRequestInteractor = + UpdateChooserRequestInteractor( + repository = chooserRequestRepo, + pendingIntentSender = { error("unexpected invocation") }, + ) + ) + ), + ) + + underTest.setSelected(true) + runCurrent() + + assertThat(selectionRepo.selections.value) + .containsExactly( + PreviewModel(Uri.fromParts("scheme", "ssp", "fragment"), "image/bitmap") + ) + + assertThat(chooserRequestRepo.chooserRequest.value.targetIntent) + .isSameInstanceAs(modifiedIntent) + assertThat(pendingSelectionCallbackRepo.pendingTargetIntent.value) + .isSameInstanceAs(modifiedIntent) + } +} diff --git a/tests/unit/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/interactor/SelectablePreviewsInteractorTest.kt b/tests/unit/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/interactor/SelectablePreviewsInteractorTest.kt new file mode 100644 index 00000000..3f02c0cd --- /dev/null +++ b/tests/unit/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/interactor/SelectablePreviewsInteractorTest.kt @@ -0,0 +1,193 @@ +/* + * Copyright (C) 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +@file:OptIn(ExperimentalCoroutinesApi::class) + +package com.android.intentresolver.contentpreview.payloadtoggle.domain.interactor + +import android.net.Uri +import com.android.intentresolver.contentpreview.payloadtoggle.data.repository.CursorPreviewsRepository +import com.android.intentresolver.contentpreview.payloadtoggle.data.repository.PendingSelectionCallbackRepository +import com.android.intentresolver.contentpreview.payloadtoggle.data.repository.PreviewSelectionsRepository +import com.android.intentresolver.contentpreview.payloadtoggle.shared.model.PreviewModel +import com.android.intentresolver.contentpreview.payloadtoggle.shared.model.PreviewsModel +import com.android.intentresolver.v2.data.model.fakeChooserRequest +import com.android.intentresolver.v2.data.repository.ChooserRequestRepository +import com.google.common.truth.Truth.assertThat +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import org.junit.Test + +class SelectablePreviewsInteractorTest { + + @Test + fun keySet_reflectsRepositoryInit() = runTest { + val repo = + CursorPreviewsRepository().apply { + previewsModel.value = + PreviewsModel( + previewModels = + setOf( + PreviewModel( + Uri.fromParts("scheme", "ssp", "fragment"), + "image/bitmap", + ), + PreviewModel( + Uri.fromParts("scheme2", "ssp2", "fragment2"), + "image/bitmap", + ), + ), + startIdx = 0, + loadMoreLeft = null, + loadMoreRight = null, + ) + } + val selectionRepo = + PreviewSelectionsRepository().apply { + selections.value = + setOf( + PreviewModel(Uri.fromParts("scheme", "ssp", "fragment"), null), + ) + } + val chooserRequestRepo = + ChooserRequestRepository( + initialRequest = fakeChooserRequest(), + initialActions = emptyList(), + ) + val underTest = + SelectablePreviewsInteractor( + previewsRepo = repo, + selectionInteractor = + SelectionInteractor( + selectionRepo, + targetIntentModifier = { error("unexpected invocation") }, + updateTargetIntentInteractor = + UpdateTargetIntentInteractor( + repository = PendingSelectionCallbackRepository(), + chooserRequestInteractor = + UpdateChooserRequestInteractor( + repository = chooserRequestRepo, + pendingIntentSender = { error("unexpected invocation") }, + ) + ) + ), + ) + val keySet = underTest.previews.stateIn(backgroundScope) + + assertThat(keySet.value).isNotNull() + assertThat(keySet.value!!.previewModels) + .containsExactly( + PreviewModel(Uri.fromParts("scheme", "ssp", "fragment"), "image/bitmap"), + PreviewModel(Uri.fromParts("scheme2", "ssp2", "fragment2"), "image/bitmap"), + ) + .inOrder() + assertThat(keySet.value!!.startIdx).isEqualTo(0) + assertThat(keySet.value!!.loadMoreLeft).isNull() + assertThat(keySet.value!!.loadMoreRight).isNull() + + val firstModel = + underTest.preview(PreviewModel(Uri.fromParts("scheme", "ssp", "fragment"), null)) + assertThat(firstModel.isSelected.first()).isTrue() + + val secondModel = + underTest.preview(PreviewModel(Uri.fromParts("scheme2", "ssp2", "fragment2"), null)) + assertThat(secondModel.isSelected.first()).isFalse() + } + + @Test + fun keySet_reflectsRepositoryUpdate() = runTest { + val previewsRepo = CursorPreviewsRepository() + val selectionRepo = + PreviewSelectionsRepository().apply { + selections.value = + setOf( + PreviewModel(Uri.fromParts("scheme", "ssp", "fragment"), null), + ) + } + val chooserRequestRepo = + ChooserRequestRepository( + initialRequest = fakeChooserRequest(), + initialActions = emptyList(), + ) + val underTest = + SelectablePreviewsInteractor( + previewsRepo = previewsRepo, + selectionInteractor = + SelectionInteractor( + selectionRepo, + targetIntentModifier = { error("unexpected invocation") }, + updateTargetIntentInteractor = + UpdateTargetIntentInteractor( + repository = PendingSelectionCallbackRepository(), + chooserRequestInteractor = + UpdateChooserRequestInteractor( + repository = chooserRequestRepo, + pendingIntentSender = { error("unexpected invocation") }, + ) + ) + ), + ) + + val previews = underTest.previews.stateIn(backgroundScope) + val firstModel = + underTest.preview(PreviewModel(Uri.fromParts("scheme", "ssp", "fragment"), null)) + + assertThat(previews.value).isNull() + assertThat(firstModel.isSelected.first()).isTrue() + + var loadRequested = false + + previewsRepo.previewsModel.value = + PreviewsModel( + previewModels = + setOf( + PreviewModel( + Uri.fromParts("scheme", "ssp", "fragment"), + "image/bitmap", + ), + PreviewModel( + Uri.fromParts("scheme2", "ssp2", "fragment2"), + "image/bitmap", + ), + ), + startIdx = 5, + loadMoreLeft = null, + loadMoreRight = { loadRequested = true }, + ) + selectionRepo.selections.value = emptySet() + runCurrent() + + assertThat(previews.value).isNotNull() + assertThat(previews.value!!.previewModels) + .containsExactly( + PreviewModel(Uri.fromParts("scheme", "ssp", "fragment"), "image/bitmap"), + PreviewModel(Uri.fromParts("scheme2", "ssp2", "fragment2"), "image/bitmap"), + ) + .inOrder() + assertThat(previews.value!!.startIdx).isEqualTo(5) + assertThat(previews.value!!.loadMoreLeft).isNull() + assertThat(previews.value!!.loadMoreRight).isNotNull() + + assertThat(firstModel.isSelected.first()).isFalse() + + previews.value!!.loadMoreRight!!.invoke() + + assertThat(loadRequested).isTrue() + } +} diff --git a/tests/unit/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/interactor/SetCursorPreviewsInteractorTest.kt b/tests/unit/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/interactor/SetCursorPreviewsInteractorTest.kt new file mode 100644 index 00000000..9683d01f --- /dev/null +++ b/tests/unit/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/interactor/SetCursorPreviewsInteractorTest.kt @@ -0,0 +1,108 @@ +/* + * Copyright (C) 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +@file:OptIn(ExperimentalCoroutinesApi::class) + +package com.android.intentresolver.contentpreview.payloadtoggle.domain.interactor + +import android.net.Uri +import com.android.intentresolver.contentpreview.payloadtoggle.data.repository.CursorPreviewsRepository +import com.android.intentresolver.contentpreview.payloadtoggle.domain.model.LoadDirection +import com.android.intentresolver.contentpreview.payloadtoggle.shared.model.PreviewModel +import com.google.common.truth.Truth.assertThat +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import org.junit.Test + +class SetCursorPreviewsInteractorTest { + @Test + fun setPreviews_noAdditionalData() = runTest { + val repo = CursorPreviewsRepository() + val underTest = SetCursorPreviewsInteractor(repo) + + val loadState = + underTest.setPreviews( + previewsByKey = + setOf( + PreviewModel( + uri = Uri.fromParts("scheme", "ssp", "fragment"), + mimeType = null, + ) + ), + startIndex = 100, + hasMoreLeft = false, + hasMoreRight = false, + ) + + assertThat(loadState.first()).isNull() + repo.previewsModel.value.let { + assertThat(it).isNotNull() + it!! + assertThat(it.loadMoreRight).isNull() + assertThat(it.loadMoreLeft).isNull() + assertThat(it.startIdx).isEqualTo(100) + assertThat(it.previewModels) + .containsExactly( + PreviewModel( + uri = Uri.fromParts("scheme", "ssp", "fragment"), + mimeType = null, + ) + ) + .inOrder() + } + } + + @Test + fun setPreviews_additionalData() = runTest { + val repo = CursorPreviewsRepository() + val underTest = SetCursorPreviewsInteractor(repo) + + val loadState = + underTest + .setPreviews( + previewsByKey = + setOf( + PreviewModel( + uri = Uri.fromParts("scheme", "ssp", "fragment"), + mimeType = null, + ) + ), + startIndex = 100, + hasMoreLeft = true, + hasMoreRight = true, + ) + .stateIn(backgroundScope) + + assertThat(loadState.value).isNull() + repo.previewsModel.value.let { + assertThat(it).isNotNull() + it!! + assertThat(it.loadMoreRight).isNotNull() + assertThat(it.loadMoreLeft).isNotNull() + + it.loadMoreRight!!.invoke() + runCurrent() + assertThat(loadState.value).isEqualTo(LoadDirection.Right) + + it.loadMoreLeft!!.invoke() + runCurrent() + assertThat(loadState.value).isEqualTo(LoadDirection.Left) + } + } +} diff --git a/tests/unit/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/interactor/UpdateChooserRequestInteractorTest.kt b/tests/unit/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/interactor/UpdateChooserRequestInteractorTest.kt new file mode 100644 index 00000000..05c7646a --- /dev/null +++ b/tests/unit/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/interactor/UpdateChooserRequestInteractorTest.kt @@ -0,0 +1,74 @@ +/* + * Copyright (C) 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +@file:OptIn(ExperimentalCoroutinesApi::class) + +package com.android.intentresolver.contentpreview.payloadtoggle.domain.interactor + +import android.content.Intent +import com.android.intentresolver.contentpreview.payloadtoggle.data.repository.PendingSelectionCallbackRepository +import com.android.intentresolver.contentpreview.payloadtoggle.domain.intent.PendingIntentSender +import com.android.intentresolver.contentpreview.payloadtoggle.domain.model.ShareouselUpdate +import com.android.intentresolver.contentpreview.payloadtoggle.domain.model.ValueUpdate +import com.android.intentresolver.v2.data.model.fakeChooserRequest +import com.android.intentresolver.v2.data.repository.ChooserRequestRepository +import com.google.common.truth.Truth.assertThat +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import org.junit.Test + +class UpdateChooserRequestInteractorTest { + @Test + fun updateTargetIntentWithSelection() = runTest { + val pendingIntentSender = PendingIntentSender {} + val chooserRequestRepository = + ChooserRequestRepository( + initialRequest = fakeChooserRequest(), + initialActions = emptyList(), + ) + val selectionCallbackResult = ShareouselUpdate(metadataText = ValueUpdate.Value("update")) + val pendingSelectionCallbackRepository = PendingSelectionCallbackRepository() + val updateTargetIntentInteractor = + UpdateTargetIntentInteractor( + repository = pendingSelectionCallbackRepository, + chooserRequestInteractor = + UpdateChooserRequestInteractor( + repository = chooserRequestRepository, + pendingIntentSender = pendingIntentSender, + ) + ) + val processTargetIntentUpdatesInteractor = + ProcessTargetIntentUpdatesInteractor( + selectionCallback = { selectionCallbackResult }, + repository = pendingSelectionCallbackRepository, + chooserRequestInteractor = + UpdateChooserRequestInteractor( + repository = chooserRequestRepository, + pendingIntentSender = pendingIntentSender, + ) + ) + + backgroundScope.launch { processTargetIntentUpdatesInteractor.activate() } + + updateTargetIntentInteractor.updateTargetIntent(Intent()) + runCurrent() + + assertThat(pendingSelectionCallbackRepository.pendingTargetIntent.value).isNull() + assertThat(chooserRequestRepository.chooserRequest.value.metadataText).isEqualTo("update") + } +} diff --git a/tests/unit/src/com/android/intentresolver/contentpreview/SelectionChangeCallbackTest.kt b/tests/unit/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/update/SelectionChangeCallbackImplTest.kt index 40f2ab26..55b32509 100644 --- a/tests/unit/src/com/android/intentresolver/contentpreview/SelectionChangeCallbackTest.kt +++ b/tests/unit/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/update/SelectionChangeCallbackImplTest.kt @@ -14,7 +14,7 @@ * limitations under the License. */ -package com.android.intentresolver.contentpreview +package com.android.intentresolver.contentpreview.payloadtoggle.domain.update import android.app.PendingIntent import android.content.ComponentName @@ -27,8 +27,10 @@ import android.content.Intent.EXTRA_ALTERNATE_INTENTS import android.content.Intent.EXTRA_CHOOSER_CUSTOM_ACTIONS import android.content.Intent.EXTRA_CHOOSER_MODIFY_SHARE_ACTION import android.content.Intent.EXTRA_CHOOSER_REFINEMENT_INTENT_SENDER +import android.content.Intent.EXTRA_CHOOSER_RESULT_INTENT_SENDER import android.content.Intent.EXTRA_CHOOSER_TARGETS import android.content.Intent.EXTRA_INTENT +import android.content.Intent.EXTRA_METADATA_TEXT import android.content.Intent.EXTRA_STREAM import android.graphics.drawable.Icon import android.net.Uri @@ -36,32 +38,44 @@ import android.os.Bundle import android.service.chooser.AdditionalContentContract.MethodNames.ON_SELECTION_CHANGED import android.service.chooser.ChooserAction import android.service.chooser.ChooserTarget +import android.service.chooser.Flags import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.platform.app.InstrumentationRegistry import com.android.intentresolver.any import com.android.intentresolver.argumentCaptor import com.android.intentresolver.capture +import com.android.intentresolver.contentpreview.payloadtoggle.domain.model.ValueUpdate +import com.android.intentresolver.contentpreview.payloadtoggle.domain.model.ValueUpdate.Absent +import com.android.intentresolver.inject.FakeChooserServiceFlags import com.android.intentresolver.mock import com.android.intentresolver.whenever import com.google.common.truth.Correspondence import com.google.common.truth.Correspondence.BinaryPredicate import com.google.common.truth.Truth.assertThat import com.google.common.truth.Truth.assertWithMessage +import java.lang.IllegalArgumentException +import kotlinx.coroutines.test.runTest import org.junit.Test import org.junit.runner.RunWith import org.mockito.Mockito.times import org.mockito.Mockito.verify @RunWith(AndroidJUnit4::class) -class SelectionChangeCallbackTest { +class SelectionChangeCallbackImplTest { private val uri = Uri.parse("content://org.pkg/content-provider") private val chooserIntent = Intent(ACTION_CHOOSER) private val contentResolver = mock<ContentInterface>() private val context = InstrumentationRegistry.getInstrumentation().context + private val flags = + FakeChooserServiceFlags().apply { + setFlag(Flags.FLAG_CHOOSER_PAYLOAD_TOGGLING, false) + setFlag(Flags.FLAG_CHOOSER_ALBUM_TEXT, false) + setFlag(Flags.FLAG_ENABLE_SHARESHEET_METADATA_EXTRA, false) + } @Test - fun testPayloadChangeCallbackContact() { - val testSubject = SelectionChangeCallback(uri, chooserIntent, contentResolver) + fun testPayloadChangeCallbackContact() = runTest { + val testSubject = SelectionChangeCallbackImpl(uri, chooserIntent, contentResolver, flags) val u1 = createUri(1) val u2 = createUri(2) @@ -127,7 +141,7 @@ class SelectionChangeCallbackTest { } @Test - fun testPayloadChangeCallbackUpdatesCustomActions() { + fun testPayloadChangeCallbackUpdatesCustomActions() = runTest { val a1 = ChooserAction.Builder( Icon.createWithContentUri(createUri(10)), @@ -157,25 +171,27 @@ class SelectionChangeCallbackTest { Bundle().apply { putParcelableArray(EXTRA_CHOOSER_CUSTOM_ACTIONS, arrayOf(a1, a2)) } ) - val testSubject = SelectionChangeCallback(uri, chooserIntent, contentResolver) + val testSubject = SelectionChangeCallbackImpl(uri, chooserIntent, contentResolver, flags) val targetIntent = Intent(ACTION_SEND_MULTIPLE) val result = testSubject.onSelectionChanged(targetIntent) assertWithMessage("Callback result should not be null").that(result).isNotNull() requireNotNull(result) assertWithMessage("Unexpected custom actions") - .that(result.customActions?.map { it.icon to it.label }) + .that(result.customActions.getOrThrow().map { it.icon to it.label }) .containsExactly(a1.icon to a1.label, a2.icon to a2.label) .inOrder() - assertThat(result.modifyShareAction).isNull() - assertThat(result.alternateIntents).isNull() - assertThat(result.callerTargets).isNull() - assertThat(result.refinementIntentSender).isNull() + assertThat(result.modifyShareAction).isEqualTo(Absent) + assertThat(result.alternateIntents).isEqualTo(Absent) + assertThat(result.callerTargets).isEqualTo(Absent) + assertThat(result.refinementIntentSender).isEqualTo(Absent) + assertThat(result.resultIntentSender).isEqualTo(Absent) + assertThat(result.metadataText).isEqualTo(Absent) } @Test - fun testPayloadChangeCallbackUpdatesReselectionAction() { + fun testPayloadChangeCallbackUpdatesReselectionAction() = runTest { val modifyShare = ChooserAction.Builder( Icon.createWithContentUri(createUri(10)), @@ -193,27 +209,29 @@ class SelectionChangeCallbackTest { Bundle().apply { putParcelable(EXTRA_CHOOSER_MODIFY_SHARE_ACTION, modifyShare) } ) - val testSubject = SelectionChangeCallback(uri, chooserIntent, contentResolver) + val testSubject = SelectionChangeCallbackImpl(uri, chooserIntent, contentResolver, flags) val targetIntent = Intent(ACTION_SEND) val result = testSubject.onSelectionChanged(targetIntent) assertWithMessage("Callback result should not be null").that(result).isNotNull() requireNotNull(result) assertWithMessage("Unexpected modify share action: wrong icon") - .that(result.modifyShareAction?.icon) + .that(result.modifyShareAction.getOrThrow()?.icon) .isEqualTo(modifyShare.icon) assertWithMessage("Unexpected modify share action: wrong label") - .that(result.modifyShareAction?.label) + .that(result.modifyShareAction.getOrThrow()?.label) .isEqualTo(modifyShare.label) - assertThat(result.customActions).isNull() - assertThat(result.alternateIntents).isNull() - assertThat(result.callerTargets).isNull() - assertThat(result.refinementIntentSender).isNull() + assertThat(result.customActions).isEqualTo(Absent) + assertThat(result.alternateIntents).isEqualTo(Absent) + assertThat(result.callerTargets).isEqualTo(Absent) + assertThat(result.refinementIntentSender).isEqualTo(Absent) + assertThat(result.resultIntentSender).isEqualTo(Absent) + assertThat(result.metadataText).isEqualTo(Absent) } @Test - fun testPayloadChangeCallbackUpdatesAlternateIntents() { + fun testPayloadChangeCallbackUpdatesAlternateIntents() = runTest { val alternateIntents = arrayOf( Intent(ACTION_SEND_MULTIPLE).apply { @@ -226,33 +244,35 @@ class SelectionChangeCallbackTest { Bundle().apply { putParcelableArray(EXTRA_ALTERNATE_INTENTS, alternateIntents) } ) - val testSubject = SelectionChangeCallback(uri, chooserIntent, contentResolver) + val testSubject = SelectionChangeCallbackImpl(uri, chooserIntent, contentResolver, flags) val targetIntent = Intent(ACTION_SEND) val result = testSubject.onSelectionChanged(targetIntent) assertWithMessage("Callback result should not be null").that(result).isNotNull() requireNotNull(result) assertWithMessage("Wrong number of alternate intents") - .that(result.alternateIntents) + .that(result.alternateIntents.getOrThrow()) .hasSize(1) assertWithMessage("Wrong alternate intent: action") - .that(result.alternateIntents?.get(0)?.action) + .that(result.alternateIntents.getOrThrow()[0].action) .isEqualTo(alternateIntents[0].action) assertWithMessage("Wrong alternate intent: categories") - .that(result.alternateIntents?.get(0)?.categories) + .that(result.alternateIntents.getOrThrow()[0].categories) .containsExactlyElementsIn(alternateIntents[0].categories) assertWithMessage("Wrong alternate intent: mime type") - .that(result.alternateIntents?.get(0)?.type) + .that(result.alternateIntents.getOrThrow()[0].type) .isEqualTo(alternateIntents[0].type) - assertThat(result.customActions).isNull() - assertThat(result.modifyShareAction).isNull() - assertThat(result.callerTargets).isNull() - assertThat(result.refinementIntentSender).isNull() + assertThat(result.customActions).isEqualTo(Absent) + assertThat(result.modifyShareAction).isEqualTo(Absent) + assertThat(result.callerTargets).isEqualTo(Absent) + assertThat(result.refinementIntentSender).isEqualTo(Absent) + assertThat(result.resultIntentSender).isEqualTo(Absent) + assertThat(result.metadataText).isEqualTo(Absent) } @Test - fun testPayloadChangeCallbackUpdatesCallerTargets() { + fun testPayloadChangeCallbackUpdatesCallerTargets() = runTest { val t1 = ChooserTarget( "Target 1", @@ -274,14 +294,14 @@ class SelectionChangeCallbackTest { Bundle().apply { putParcelableArray(EXTRA_CHOOSER_TARGETS, arrayOf(t1, t2)) } ) - val testSubject = SelectionChangeCallback(uri, chooserIntent, contentResolver) + val testSubject = SelectionChangeCallbackImpl(uri, chooserIntent, contentResolver, flags) val targetIntent = Intent(ACTION_SEND) val result = testSubject.onSelectionChanged(targetIntent) assertWithMessage("Callback result should not be null").that(result).isNotNull() requireNotNull(result) assertWithMessage("Wrong caller targets") - .that(result.callerTargets) + .that(result.callerTargets.getOrThrow()) .comparingElementsUsing( Correspondence.from( BinaryPredicate<ChooserTarget?, ChooserTarget> { actual, expected -> @@ -296,14 +316,16 @@ class SelectionChangeCallbackTest { .containsExactly(t1, t2) .inOrder() - assertThat(result.customActions).isNull() - assertThat(result.modifyShareAction).isNull() - assertThat(result.alternateIntents).isNull() - assertThat(result.refinementIntentSender).isNull() + assertThat(result.customActions).isEqualTo(Absent) + assertThat(result.modifyShareAction).isEqualTo(Absent) + assertThat(result.alternateIntents).isEqualTo(Absent) + assertThat(result.refinementIntentSender).isEqualTo(Absent) + assertThat(result.resultIntentSender).isEqualTo(Absent) + assertThat(result.metadataText).isEqualTo(Absent) } @Test - fun testPayloadChangeCallbackUpdatesRefinementIntentSender() { + fun testPayloadChangeCallbackUpdatesRefinementIntentSender() = runTest { val broadcast = PendingIntent.getBroadcast(context, 1, Intent("test"), PendingIntent.FLAG_IMMUTABLE) @@ -314,21 +336,94 @@ class SelectionChangeCallbackTest { } ) - val testSubject = SelectionChangeCallback(uri, chooserIntent, contentResolver) + val testSubject = SelectionChangeCallbackImpl(uri, chooserIntent, contentResolver, flags) val targetIntent = Intent(ACTION_SEND) val result = testSubject.onSelectionChanged(targetIntent) assertWithMessage("Callback result should not be null").that(result).isNotNull() requireNotNull(result) - assertThat(result.customActions).isNull() - assertThat(result.modifyShareAction).isNull() - assertThat(result.alternateIntents).isNull() - assertThat(result.callerTargets).isNull() - assertThat(result.refinementIntentSender).isNotNull() + assertThat(result.customActions).isEqualTo(Absent) + assertThat(result.modifyShareAction).isEqualTo(Absent) + assertThat(result.alternateIntents).isEqualTo(Absent) + assertThat(result.callerTargets).isEqualTo(Absent) + assertThat(result.refinementIntentSender.getOrThrow()).isNotNull() + assertThat(result.resultIntentSender).isEqualTo(Absent) + assertThat(result.metadataText).isEqualTo(Absent) } @Test - fun testPayloadChangeCallbackProvidesInvalidData_invalidDataIgnored() { + fun testPayloadChangeCallbackUpdatesResultIntentSender() = runTest { + val broadcast = + PendingIntent.getBroadcast(context, 1, Intent("test"), PendingIntent.FLAG_IMMUTABLE) + + whenever(contentResolver.call(any<String>(), any(), any(), any())) + .thenReturn( + Bundle().apply { + putParcelable(EXTRA_CHOOSER_RESULT_INTENT_SENDER, broadcast.intentSender) + } + ) + + val testSubject = SelectionChangeCallbackImpl(uri, chooserIntent, contentResolver, flags) + + val targetIntent = Intent(ACTION_SEND) + val result = testSubject.onSelectionChanged(targetIntent) + assertWithMessage("Callback result should not be null").that(result).isNotNull() + requireNotNull(result) + assertThat(result.customActions).isEqualTo(Absent) + assertThat(result.modifyShareAction).isEqualTo(Absent) + assertThat(result.alternateIntents).isEqualTo(Absent) + assertThat(result.callerTargets).isEqualTo(Absent) + assertThat(result.refinementIntentSender).isEqualTo(Absent) + assertThat(result.resultIntentSender.getOrThrow()).isNotNull() + assertThat(result.metadataText).isEqualTo(Absent) + } + + @Test + fun testPayloadChangeCallbackUpdatesMetadataTextWithDisabledFlag_noUpdates() = runTest { + val metadataText = "[Metadata]" + whenever(contentResolver.call(any<String>(), any(), any(), any())) + .thenReturn(Bundle().apply { putCharSequence(EXTRA_METADATA_TEXT, metadataText) }) + + val testSubject = SelectionChangeCallbackImpl(uri, chooserIntent, contentResolver, flags) + + val targetIntent = Intent(ACTION_SEND) + val result = testSubject.onSelectionChanged(targetIntent) + assertWithMessage("Callback result should not be null").that(result).isNotNull() + requireNotNull(result) + assertThat(result.customActions).isEqualTo(Absent) + assertThat(result.modifyShareAction).isEqualTo(Absent) + assertThat(result.alternateIntents).isEqualTo(Absent) + assertThat(result.callerTargets).isEqualTo(Absent) + assertThat(result.refinementIntentSender).isEqualTo(Absent) + assertThat(result.resultIntentSender).isEqualTo(Absent) + assertThat(result.metadataText).isEqualTo(Absent) + } + + @Test + fun testPayloadChangeCallbackUpdatesMetadataTextWithEnabledFlag_valueUpdated() = runTest { + val metadataText = "[Metadata]" + flags.setFlag(Flags.FLAG_ENABLE_SHARESHEET_METADATA_EXTRA, true) + whenever(contentResolver.call(any<String>(), any(), any(), any())) + .thenReturn(Bundle().apply { putCharSequence(EXTRA_METADATA_TEXT, metadataText) }) + + val testSubject = SelectionChangeCallbackImpl(uri, chooserIntent, contentResolver, flags) + + val targetIntent = Intent(ACTION_SEND) + val result = testSubject.onSelectionChanged(targetIntent) + assertWithMessage("Callback result should not be null").that(result).isNotNull() + requireNotNull(result) + assertThat(result.customActions).isEqualTo(Absent) + assertThat(result.modifyShareAction).isEqualTo(Absent) + assertThat(result.alternateIntents).isEqualTo(Absent) + assertThat(result.callerTargets).isEqualTo(Absent) + assertThat(result.refinementIntentSender).isEqualTo(Absent) + assertThat(result.resultIntentSender).isEqualTo(Absent) + assertThat(result.metadataText.getOrThrow()).isEqualTo(metadataText) + } + + @Test + fun testPayloadChangeCallbackProvidesInvalidData_invalidDataIgnored() = runTest { + flags.setFlag(Flags.FLAG_ENABLE_SHARESHEET_METADATA_EXTRA, true) whenever(contentResolver.call(any<String>(), any(), any(), any())) .thenReturn( Bundle().apply { @@ -337,21 +432,31 @@ class SelectionChangeCallbackTest { putParcelableArrayList(EXTRA_ALTERNATE_INTENTS, ArrayList<Intent>()) putParcelableArrayList(EXTRA_CHOOSER_TARGETS, ArrayList<ChooserTarget>()) putParcelable(EXTRA_CHOOSER_REFINEMENT_INTENT_SENDER, createUri(2)) + putParcelable(EXTRA_CHOOSER_RESULT_INTENT_SENDER, createUri(1)) + putInt(EXTRA_METADATA_TEXT, 123) } ) - val testSubject = SelectionChangeCallback(uri, chooserIntent, contentResolver) + val testSubject = SelectionChangeCallbackImpl(uri, chooserIntent, contentResolver, flags) val targetIntent = Intent(ACTION_SEND) val result = testSubject.onSelectionChanged(targetIntent) assertWithMessage("Callback result should not be null").that(result).isNotNull() requireNotNull(result) - assertThat(result.customActions).isNull() - assertThat(result.modifyShareAction).isNull() - assertThat(result.alternateIntents).isNull() - assertThat(result.callerTargets).isNull() - assertThat(result.refinementIntentSender).isNull() + assertThat(result.customActions.getOrThrow()).isEmpty() + assertThat(result.modifyShareAction.getOrThrow()).isNull() + assertThat(result.alternateIntents.getOrThrow()).isEmpty() + assertThat(result.callerTargets.getOrThrow()).isEmpty() + assertThat(result.refinementIntentSender.getOrThrow()).isNull() + assertThat(result.resultIntentSender.getOrThrow()).isNull() + assertThat(result.metadataText.getOrThrow()).isNull() } } +private fun <T> ValueUpdate<T>.getOrThrow(): T = + when (this) { + is ValueUpdate.Value -> value + else -> throw IllegalArgumentException("Value is expected") + } + private fun createUri(id: Int) = Uri.parse("content://org.pkg.images/$id.png") diff --git a/tests/unit/src/com/android/intentresolver/contentpreview/payloadtoggle/ui/viewmodel/ShareouselViewModelTest.kt b/tests/unit/src/com/android/intentresolver/contentpreview/payloadtoggle/ui/viewmodel/ShareouselViewModelTest.kt new file mode 100644 index 00000000..5d95df04 --- /dev/null +++ b/tests/unit/src/com/android/intentresolver/contentpreview/payloadtoggle/ui/viewmodel/ShareouselViewModelTest.kt @@ -0,0 +1,305 @@ +/* + * Copyright (C) 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +@file:OptIn(ExperimentalCoroutinesApi::class) + +package com.android.intentresolver.contentpreview.payloadtoggle.ui.viewmodel + +import android.app.Activity +import android.content.ContentResolver +import android.content.Intent +import android.content.pm.PackageManager +import android.graphics.Bitmap +import android.graphics.drawable.Icon +import android.net.Uri +import com.android.intentresolver.FakeImageLoader +import com.android.intentresolver.contentpreview.HeadlineGenerator +import com.android.intentresolver.contentpreview.payloadtoggle.data.model.CustomActionModel +import com.android.intentresolver.contentpreview.payloadtoggle.data.repository.ActivityResultRepository +import com.android.intentresolver.contentpreview.payloadtoggle.data.repository.CursorPreviewsRepository +import com.android.intentresolver.contentpreview.payloadtoggle.data.repository.PendingSelectionCallbackRepository +import com.android.intentresolver.contentpreview.payloadtoggle.data.repository.PreviewSelectionsRepository +import com.android.intentresolver.contentpreview.payloadtoggle.domain.intent.PendingIntentSender +import com.android.intentresolver.contentpreview.payloadtoggle.domain.intent.TargetIntentModifier +import com.android.intentresolver.contentpreview.payloadtoggle.domain.interactor.ChooserRequestInteractor +import com.android.intentresolver.contentpreview.payloadtoggle.domain.interactor.CustomActionsInteractor +import com.android.intentresolver.contentpreview.payloadtoggle.domain.interactor.SelectablePreviewsInteractor +import com.android.intentresolver.contentpreview.payloadtoggle.domain.interactor.SelectionInteractor +import com.android.intentresolver.contentpreview.payloadtoggle.domain.interactor.UpdateChooserRequestInteractor +import com.android.intentresolver.contentpreview.payloadtoggle.domain.interactor.UpdateTargetIntentInteractor +import com.android.intentresolver.contentpreview.payloadtoggle.shared.model.PreviewModel +import com.android.intentresolver.contentpreview.payloadtoggle.shared.model.PreviewsModel +import com.android.intentresolver.icon.BitmapIcon +import com.android.intentresolver.logging.FakeEventLog +import com.android.intentresolver.mock +import com.android.intentresolver.util.comparingElementsUsingTransform +import com.android.intentresolver.v2.data.model.fakeChooserRequest +import com.android.intentresolver.v2.data.repository.ChooserRequestRepository +import com.android.internal.logging.InstanceId +import com.google.common.truth.Truth.assertThat +import com.google.common.truth.Truth.assertWithMessage +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import org.junit.Test + +class ShareouselViewModelTest { + + class Dependencies( + val pendingIntentSender: PendingIntentSender, + val targetIntentModifier: TargetIntentModifier<PreviewModel>, + ) { + val testDispatcher = StandardTestDispatcher() + val testScope = TestScope(testDispatcher) + val previewsRepository = CursorPreviewsRepository() + val selectionRepository = + PreviewSelectionsRepository().apply { + selections.value = + setOf(PreviewModel(Uri.fromParts("scheme", "ssp", "fragment"), null)) + } + val activityResultRepository = ActivityResultRepository() + val contentResolver = mock<ContentResolver> {} + val packageManager = mock<PackageManager> {} + val eventLog = FakeEventLog(instanceId = InstanceId.fakeInstanceId(1)) + val chooserRequestRepo = + ChooserRequestRepository( + initialRequest = fakeChooserRequest(), + initialActions = emptyList(), + ) + val pendingSelectionCallbackRepo = PendingSelectionCallbackRepository() + + val actionsInteractor + get() = + CustomActionsInteractor( + activityResultRepo = activityResultRepository, + bgDispatcher = testDispatcher, + contentResolver = contentResolver, + eventLog = eventLog, + packageManager = packageManager, + chooserRequestInteractor = chooserRequestInteractor, + ) + + val selectionInteractor + get() = + SelectionInteractor( + selectionsRepo = selectionRepository, + targetIntentModifier = targetIntentModifier, + updateTargetIntentInteractor = updateTargetIntentInteractor, + ) + + val updateTargetIntentInteractor + get() = + UpdateTargetIntentInteractor( + repository = pendingSelectionCallbackRepo, + chooserRequestInteractor = updateChooserRequestInteractor, + ) + + val updateChooserRequestInteractor + get() = + UpdateChooserRequestInteractor( + repository = chooserRequestRepo, + pendingIntentSender = pendingIntentSender, + ) + + val chooserRequestInteractor + get() = ChooserRequestInteractor(repository = chooserRequestRepo) + + val previewsInteractor + get() = + SelectablePreviewsInteractor( + previewsRepo = previewsRepository, + selectionInteractor = selectionInteractor, + ) + + val underTest = + ShareouselViewModelModule.create( + interactor = previewsInteractor, + imageLoader = + FakeImageLoader( + initialBitmaps = + mapOf( + Uri.fromParts("scheme1", "ssp1", "fragment1") to + Bitmap.createBitmap(100, 100, Bitmap.Config.ALPHA_8) + ) + ), + actionsInteractor = actionsInteractor, + headlineGenerator = + object : HeadlineGenerator { + override fun getImagesHeadline(count: Int): String = "IMAGES: $count" + + override fun getTextHeadline(text: CharSequence): String = + error("not supported") + + override fun getAlbumHeadline(): String = error("not supported") + + override fun getImagesWithTextHeadline( + text: CharSequence, + count: Int + ): String = error("not supported") + + override fun getVideosWithTextHeadline( + text: CharSequence, + count: Int + ): String = error("not supported") + + override fun getFilesWithTextHeadline( + text: CharSequence, + count: Int + ): String = error("not supported") + + override fun getVideosHeadline(count: Int): String = error("not supported") + + override fun getFilesHeadline(count: Int): String = error("not supported") + }, + selectionInteractor = selectionInteractor, + scope = testScope.backgroundScope, + ) + } + + private inline fun runTestWithDeps( + pendingIntentSender: PendingIntentSender = PendingIntentSender {}, + targetIntentModifier: TargetIntentModifier<PreviewModel> = TargetIntentModifier { + error("unexpected invocation") + }, + crossinline block: suspend TestScope.(Dependencies) -> Unit, + ): Unit = + Dependencies(pendingIntentSender, targetIntentModifier).run { + testScope.runTest { + runCurrent() + block(this@run) + } + } + + @Test + fun headline() = runTestWithDeps { deps -> + with(deps) { + assertThat(underTest.headline.first()).isEqualTo("IMAGES: 1") + selectionRepository.selections.value = + setOf( + PreviewModel( + Uri.fromParts("scheme", "ssp", "fragment"), + null, + ), + PreviewModel( + Uri.fromParts("scheme1", "ssp1", "fragment1"), + null, + ) + ) + runCurrent() + assertThat(underTest.headline.first()).isEqualTo("IMAGES: 2") + } + } + + @Test + fun previews() = + runTestWithDeps(targetIntentModifier = { Intent() }) { deps -> + with(deps) { + previewsRepository.previewsModel.value = + PreviewsModel( + previewModels = + setOf( + PreviewModel( + Uri.fromParts("scheme", "ssp", "fragment"), + null, + ), + PreviewModel( + Uri.fromParts("scheme1", "ssp1", "fragment1"), + null, + ) + ), + startIdx = 1, + loadMoreLeft = null, + loadMoreRight = null, + ) + runCurrent() + + assertWithMessage("previewsKeys is null") + .that(underTest.previews.first()) + .isNotNull() + assertThat(underTest.previews.first()!!.previewModels) + .comparingElementsUsingTransform("has uri of") { it: PreviewModel -> it.uri } + .containsExactly( + Uri.fromParts("scheme", "ssp", "fragment"), + Uri.fromParts("scheme1", "ssp1", "fragment1"), + ) + .inOrder() + + val previewVm = + underTest.preview( + PreviewModel(Uri.fromParts("scheme1", "ssp1", "fragment1"), null) + ) + + assertWithMessage("preview bitmap is null") + .that(previewVm.bitmap.first()) + .isNotNull() + assertThat(previewVm.isSelected.first()).isFalse() + + previewVm.setSelected(true) + + assertThat(selectionRepository.selections.value) + .comparingElementsUsingTransform("has uri of") { model: PreviewModel -> + model.uri + } + .contains(Uri.fromParts("scheme1", "ssp1", "fragment1")) + } + } + + @Test + fun actions() { + runTestWithDeps { deps -> + with(deps) { + assertThat(underTest.actions.first()).isEmpty() + + val bitmap = Bitmap.createBitmap(100, 100, Bitmap.Config.ALPHA_8) + val icon = Icon.createWithBitmap(bitmap) + var actionSent = false + chooserRequestRepo.customActions.value = + listOf( + CustomActionModel( + label = "label1", + icon = icon, + performAction = { actionSent = true }, + ) + ) + runCurrent() + + assertThat(underTest.actions.first()) + .comparingElementsUsingTransform("has a label of") { vm: ActionChipViewModel -> + vm.label + } + .containsExactly("label1") + .inOrder() + assertThat(underTest.actions.first()) + .comparingElementsUsingTransform("has an icon of") { vm: ActionChipViewModel -> + vm.icon + } + .containsExactly(BitmapIcon(icon.bitmap)) + .inOrder() + + underTest.actions.first()[0].onClicked() + + assertThat(actionSent).isTrue() + assertThat(eventLog.customActionSelected) + .isEqualTo(FakeEventLog.CustomActionSelected(0)) + assertThat(activityResultRepository.activityResult.value) + .isEqualTo(Activity.RESULT_OK) + } + } + } +} diff --git a/tests/unit/src/com/android/intentresolver/model/AbstractResolverComparatorTest.java b/tests/unit/src/com/android/intentresolver/model/AbstractResolverComparatorTest.java index 2140a67d..5cec9734 100644 --- a/tests/unit/src/com/android/intentresolver/model/AbstractResolverComparatorTest.java +++ b/tests/unit/src/com/android/intentresolver/model/AbstractResolverComparatorTest.java @@ -25,7 +25,7 @@ import android.content.pm.ActivityInfo; import android.content.pm.ResolveInfo; import android.os.Message; -import androidx.test.InstrumentationRegistry; +import androidx.test.platform.app.InstrumentationRegistry; import com.android.intentresolver.ResolvedComponentInfo; import com.android.intentresolver.chooser.TargetInfo; @@ -47,7 +47,7 @@ public class AbstractResolverComparatorTest { ResolvedComponentInfo r2 = createResolvedComponentInfo( new ComponentName("zackage", "zlass")); - Context context = InstrumentationRegistry.getTargetContext(); + Context context = InstrumentationRegistry.getInstrumentation().getTargetContext(); AbstractResolverComparator comparator = getTestComparator(context, null); assertEquals("Pinned ranks over unpinned", -1, comparator.compare(r1, r2)); @@ -64,7 +64,7 @@ public class AbstractResolverComparatorTest { new ComponentName("zackage", "zlass")); r2.setPinned(true); - Context context = InstrumentationRegistry.getTargetContext(); + Context context = InstrumentationRegistry.getInstrumentation().getTargetContext(); AbstractResolverComparator comparator = getTestComparator(context, null); assertEquals("Both pinned should rank alphabetically", -1, comparator.compare(r1, r2)); @@ -78,7 +78,7 @@ public class AbstractResolverComparatorTest { ResolvedComponentInfo r2 = createResolvedComponentInfo( new ComponentName("package", "class")); - Context context = InstrumentationRegistry.getTargetContext(); + Context context = InstrumentationRegistry.getInstrumentation().getTargetContext(); AbstractResolverComparator comparator = getTestComparator(context, promoteToFirst); assertEquals("PromoteToFirst ranks over non-cemented", -1, comparator.compare(r1, r2)); @@ -94,7 +94,7 @@ public class AbstractResolverComparatorTest { new ComponentName("package", "class")); r2.setPinned(true); - Context context = InstrumentationRegistry.getTargetContext(); + Context context = InstrumentationRegistry.getInstrumentation().getTargetContext(); AbstractResolverComparator comparator = getTestComparator(context, cementedComponent); assertEquals("PromoteToFirst ranks over pinned", -1, comparator.compare(r1, r2)); diff --git a/tests/unit/src/com/android/intentresolver/util/TruthUtils.kt b/tests/unit/src/com/android/intentresolver/util/TruthUtils.kt new file mode 100644 index 00000000..b96b6f05 --- /dev/null +++ b/tests/unit/src/com/android/intentresolver/util/TruthUtils.kt @@ -0,0 +1,26 @@ +/* + * Copyright (C) 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.intentresolver.util + +import com.google.common.truth.Correspondence +import com.google.common.truth.IterableSubject + +fun <A, B> IterableSubject.comparingElementsUsingTransform( + description: String, + function: (A) -> B, +): IterableSubject.UsingCorrespondence<A, B> = + comparingElementsUsing(Correspondence.transforming(function, description)) diff --git a/tests/unit/src/com/android/intentresolver/v2/ChooserActionFactoryTest.kt b/tests/unit/src/com/android/intentresolver/v2/ChooserActionFactoryTest.kt index 95e4c377..8c55ffa5 100644 --- a/tests/unit/src/com/android/intentresolver/v2/ChooserActionFactoryTest.kt +++ b/tests/unit/src/com/android/intentresolver/v2/ChooserActionFactoryTest.kt @@ -46,7 +46,6 @@ import org.junit.Assert.assertTrue import org.junit.Before import org.junit.Test import org.junit.runner.RunWith -import org.mockito.ArgumentMatchers.eq import org.mockito.Mockito.eq import org.mockito.Mockito.times import org.mockito.Mockito.verify @@ -57,7 +56,6 @@ class ChooserActionFactoryTest { private val logger = mock<EventLog>() private val actionLabel = "Action label" - private val modifyShareLabel = "Modify share" private val testAction = "com.android.intentresolver.testaction" private val countdown = CountDownLatch(1) private val testReceiver: BroadcastReceiver = @@ -105,26 +103,6 @@ class ChooserActionFactoryTest { } @Test - fun testNoModifyShareAction() { - val factory = createFactory(includeModifyShare = false) - - assertThat(factory.modifyShareAction).isNull() - } - - @Test - fun testModifyShareAction() { - val factory = createFactory(includeModifyShare = true) - - val action = factory.modifyShareAction ?: error("Modify share action should not be null") - action.onClicked.run() - - verify(logger).logActionSelected(eq(EventLog.SELECTION_TYPE_MODIFY_SHARE)) - assertEquals(Activity.RESULT_OK, resultConsumer.latestReturn) - // Verify the pending intent has been called - assertTrue("Timed out waiting for broadcast", countdown.await(2500, TimeUnit.MILLISECONDS)) - } - - @Test fun nonSendAction_noCopyRunnable() { val targetIntent = Intent(Intent.ACTION_SEND_MULTIPLE).apply { @@ -142,7 +120,6 @@ class ChooserActionFactoryTest { /* targetIntent = */ chooserRequest.targetIntent, /* referrerPackageName = */ chooserRequest.referrerPackageName, /* chooserActions = */ chooserRequest.chooserActions, - /* modifyShareAction = */ chooserRequest.modifyShareAction, /* imageEditor = */ Optional.empty(), /* log = */ logger, /* onUpdateSharedTextIsExcluded = */ {}, @@ -170,7 +147,6 @@ class ChooserActionFactoryTest { /* targetIntent = */ chooserRequest.targetIntent, /* referrerPackageName = */ chooserRequest.referrerPackageName, /* chooserActions = */ chooserRequest.chooserActions, - /* modifyShareAction = */ chooserRequest.modifyShareAction, /* imageEditor = */ Optional.empty(), /* log = */ logger, /* onUpdateSharedTextIsExcluded = */ {}, @@ -200,7 +176,6 @@ class ChooserActionFactoryTest { /* targetIntent = */ chooserRequest.targetIntent, /* referrerPackageName = */ chooserRequest.referrerPackageName, /* chooserActions = */ chooserRequest.chooserActions, - /* modifyShareAction = */ chooserRequest.modifyShareAction, /* imageEditor = */ Optional.empty(), /* log = */ logger, /* onUpdateSharedTextIsExcluded = */ {}, @@ -217,7 +192,7 @@ class ChooserActionFactoryTest { verify(resultSender, times(1)).onActionSelected(ShareAction.SYSTEM_COPY) } - private fun createFactory(includeModifyShare: Boolean = false): ChooserActionFactory { + private fun createFactory(): ChooserActionFactory { val testPendingIntent = PendingIntent.getBroadcast(context, 0, Intent(testAction), PendingIntent.FLAG_IMMUTABLE) val targetIntent = Intent() @@ -232,23 +207,11 @@ class ChooserActionFactoryTest { whenever(chooserRequest.targetIntent).thenReturn(targetIntent) whenever(chooserRequest.chooserActions).thenReturn(ImmutableList.of(action)) - if (includeModifyShare) { - val modifyShare = - ChooserAction.Builder( - Icon.createWithResource("", Resources.ID_NULL), - modifyShareLabel, - testPendingIntent - ) - .build() - whenever(chooserRequest.modifyShareAction).thenReturn(modifyShare) - } - return ChooserActionFactory( /* context = */ context, /* targetIntent = */ chooserRequest.targetIntent, /* referrerPackageName = */ chooserRequest.referrerPackageName, /* chooserActions = */ chooserRequest.chooserActions, - /* modifyShareAction = */ chooserRequest.modifyShareAction, /* imageEditor = */ Optional.empty(), /* log = */ logger, /* onUpdateSharedTextIsExcluded = */ {}, diff --git a/tests/unit/src/com/android/intentresolver/v2/ChooserMutableActionFactoryTest.kt b/tests/unit/src/com/android/intentresolver/v2/ChooserMutableActionFactoryTest.kt deleted file mode 100644 index ec2b807d..00000000 --- a/tests/unit/src/com/android/intentresolver/v2/ChooserMutableActionFactoryTest.kt +++ /dev/null @@ -1,139 +0,0 @@ -/* - * Copyright 2024 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.android.intentresolver.v2 - -import android.app.PendingIntent -import android.content.Intent -import android.content.res.Resources -import android.graphics.drawable.Icon -import android.service.chooser.ChooserAction -import androidx.test.ext.junit.runners.AndroidJUnit4 -import androidx.test.platform.app.InstrumentationRegistry -import com.android.intentresolver.ChooserRequestParameters -import com.android.intentresolver.logging.EventLog -import com.android.intentresolver.mock -import com.android.intentresolver.whenever -import com.google.common.collect.ImmutableList -import com.google.common.truth.Truth.assertWithMessage -import java.util.Optional -import java.util.function.Consumer -import kotlinx.coroutines.flow.first -import kotlinx.coroutines.test.TestScope -import kotlinx.coroutines.test.runTest -import org.junit.Test -import org.junit.runner.RunWith - -@RunWith(AndroidJUnit4::class) -class ChooserMutableActionFactoryTest { - private val context - get() = InstrumentationRegistry.getInstrumentation().context - - private val logger = mock<EventLog>() - private val testAction = "com.android.intentresolver.testaction" - private val resultConsumer = - object : Consumer<Int> { - var latestReturn = Integer.MIN_VALUE - - override fun accept(resultCode: Int) { - latestReturn = resultCode - } - } - - private val scope = TestScope() - - @Test - fun testInitialValue() = - scope.runTest { - val actions = createChooserActions(2) - val actionFactory = createFactory(actions) - val testSubject = ChooserMutableActionFactory(actionFactory) - - val createdActions = testSubject.createCustomActions() - val observedActions = testSubject.customActionsFlow.first() - - assertWithMessage("Unexpected actions") - .that(createdActions.map { it.label }) - .containsExactlyElementsIn(actions.map { it.label }) - .inOrder() - assertWithMessage("Initially created and initially observed actions should be the same") - .that(createdActions) - .containsExactlyElementsIn(observedActions) - .inOrder() - } - - @Test - fun testUpdateActions_newActionsPublished() = - scope.runTest { - val initialActions = createChooserActions(2) - val updatedActions = createChooserActions(3) - val actionFactory = createFactory(initialActions) - val testSubject = ChooserMutableActionFactory(actionFactory) - - testSubject.updateCustomActions(updatedActions) - val observedActions = testSubject.customActionsFlow.first() - - assertWithMessage("Unexpected updated actions") - .that(observedActions.map { it.label }) - .containsAtLeastElementsIn(updatedActions.map { it.label }) - .inOrder() - } - - private fun createFactory(actions: List<ChooserAction>): ChooserActionFactory { - val targetIntent = Intent() - val chooserRequest = mock<ChooserRequestParameters>() - whenever(chooserRequest.targetIntent).thenReturn(targetIntent) - whenever(chooserRequest.chooserActions).thenReturn(ImmutableList.copyOf(actions)) - - return ChooserActionFactory( - /* context = */ context, - /* targetIntent = */ chooserRequest.targetIntent, - /* referrerPackageName = */ chooserRequest.referrerPackageName, - /* chooserActions = */ chooserRequest.chooserActions, - /* modifyShareAction = */ chooserRequest.modifyShareAction, - /* imageEditor = */ Optional.empty(), - /* log = */ logger, - /* onUpdateSharedTextIsExcluded = */ {}, - /* firstVisibleImageQuery = */ { null }, - /* activityStarter = */ mock(), - /* shareResultSender = */ null, - /* finishCallback = */ resultConsumer, - mock() - ) - } - - private fun createChooserActions(count: Int): List<ChooserAction> { - return buildList(count) { - for (i in 1..count) { - val testPendingIntent = - PendingIntent.getBroadcast( - context, - i, - Intent(testAction), - PendingIntent.FLAG_IMMUTABLE - ) - val action = - ChooserAction.Builder( - Icon.createWithResource("", Resources.ID_NULL), - "Label $i", - testPendingIntent - ) - .build() - add(action) - } - } - } -} diff --git a/tests/unit/src/com/android/intentresolver/v2/ProfileAvailabilityTest.kt b/tests/unit/src/com/android/intentresolver/v2/ProfileAvailabilityTest.kt index b4df058c..9f2b3e0f 100644 --- a/tests/unit/src/com/android/intentresolver/v2/ProfileAvailabilityTest.kt +++ b/tests/unit/src/com/android/intentresolver/v2/ProfileAvailabilityTest.kt @@ -16,7 +16,7 @@ package com.android.intentresolver.v2 -import android.util.Log +import com.android.intentresolver.v2.annotation.JavaInterop import com.android.intentresolver.v2.data.repository.FakeUserRepository import com.android.intentresolver.v2.domain.interactor.UserInteractor import com.android.intentresolver.v2.shared.model.Profile @@ -27,9 +27,7 @@ import kotlinx.coroutines.test.runCurrent import kotlinx.coroutines.test.runTest import org.junit.Test -private const val TAG = "ProfileAvailabilityTest" - -@OptIn(ExperimentalCoroutinesApi::class) +@OptIn(ExperimentalCoroutinesApi::class, JavaInterop::class) class ProfileAvailabilityTest { private val personalUser = User(0, User.Role.PERSONAL) private val workUser = User(10, User.Role.WORK) @@ -42,7 +40,7 @@ class ProfileAvailabilityTest { @Test fun testProfileAvailable() = runTest { - val availability = ProfileAvailability(backgroundScope, interactor) + val availability = ProfileAvailability(backgroundScope, interactor, mapOf()) runCurrent() assertThat(availability.isAvailable(personalProfile)).isTrue() @@ -61,7 +59,7 @@ class ProfileAvailabilityTest { @Test fun waitingToEnableProfile() = runTest { - val availability = ProfileAvailability(backgroundScope, interactor) + val availability = ProfileAvailability(backgroundScope, interactor, mapOf()) runCurrent() availability.requestQuietModeState(workProfile, true) @@ -75,4 +73,4 @@ class ProfileAvailabilityTest { assertThat(availability.waitingToEnableProfile).isFalse() } -}
\ No newline at end of file +} diff --git a/tests/unit/src/com/android/intentresolver/v2/ProfileHelperTest.kt b/tests/unit/src/com/android/intentresolver/v2/ProfileHelperTest.kt index 9cbbfcd8..cb4b1d0a 100644 --- a/tests/unit/src/com/android/intentresolver/v2/ProfileHelperTest.kt +++ b/tests/unit/src/com/android/intentresolver/v2/ProfileHelperTest.kt @@ -20,6 +20,7 @@ import com.android.intentresolver.Flags.FLAG_ENABLE_PRIVATE_PROFILE import com.android.intentresolver.inject.FakeChooserServiceFlags import com.android.intentresolver.inject.FakeIntentResolverFlags import com.android.intentresolver.inject.IntentResolverFlags +import com.android.intentresolver.v2.annotation.JavaInterop import com.android.intentresolver.v2.data.repository.FakeUserRepository import com.android.intentresolver.v2.domain.interactor.UserInteractor import com.android.intentresolver.v2.shared.model.Profile @@ -32,6 +33,7 @@ import org.junit.Assert.* import org.junit.Test +@OptIn(JavaInterop::class) class ProfileHelperTest { private val personalUser = User(0, User.Role.PERSONAL) @@ -93,7 +95,6 @@ class ProfileHelperTest { fun launchedByPersonal() = runTest { val repository = FakeUserRepository(listOf(personalUser)) val interactor = UserInteractor(repository, launchedAs = personalUser.handle) - val availability = interactor.availability.first() val launchedBy = interactor.launchedAsProfile.first() val helper = ProfileHelper( @@ -115,7 +116,6 @@ class ProfileHelperTest { fun launchedByPersonal_withClone() = runTest { val repository = FakeUserRepository(listOf(personalUser, cloneUser)) val interactor = UserInteractor(repository, launchedAs = personalUser.handle) - val availability = interactor.availability.first() val launchedBy = interactor.launchedAsProfile.first() val helper = ProfileHelper( @@ -136,7 +136,6 @@ class ProfileHelperTest { fun launchedByClone() = runTest { val repository = FakeUserRepository(listOf(personalUser, cloneUser)) val interactor = UserInteractor(repository, launchedAs = cloneUser.handle) - val availability = interactor.availability.first() val launchedBy = interactor.launchedAsProfile.first() val helper = ProfileHelper( @@ -159,7 +158,6 @@ class ProfileHelperTest { fun launchedByPersonal_withWork() = runTest { val repository = FakeUserRepository(listOf(personalUser, workUser)) val interactor = UserInteractor(repository, launchedAs = personalUser.handle) - val availability = interactor.availability.first() val launchedBy = interactor.launchedAsProfile.first() val helper = ProfileHelper( @@ -186,7 +184,6 @@ class ProfileHelperTest { fun launchedByWork() = runTest { val repository = FakeUserRepository(listOf(personalUser, workUser)) val interactor = UserInteractor(repository, launchedAs = workUser.handle) - val availability = interactor.availability.first() val launchedBy = interactor.launchedAsProfile.first() val helper = ProfileHelper( @@ -213,7 +210,6 @@ class ProfileHelperTest { fun launchedByPersonal_withPrivate() = runTest { val repository = FakeUserRepository(listOf(personalUser, privateUser)) val interactor = UserInteractor(repository, launchedAs = personalUser.handle) - val availability = interactor.availability.first() val launchedBy = interactor.launchedAsProfile.first() val helper = ProfileHelper( @@ -239,7 +235,6 @@ class ProfileHelperTest { fun launchedByPrivate() = runTest { val repository = FakeUserRepository(listOf(personalUser, privateUser)) val interactor = UserInteractor(repository, launchedAs = privateUser.handle) - val availability = interactor.availability.first() val launchedBy = interactor.launchedAsProfile.first() val helper = ProfileHelper( @@ -268,7 +263,6 @@ class ProfileHelperTest { val repository = FakeUserRepository(listOf(personalUser, privateUser)) val interactor = UserInteractor(repository, launchedAs = personalUser.handle) - val availability = interactor.availability.first() val launchedBy = interactor.launchedAsProfile.first() val helper = ProfileHelper( diff --git a/tests/unit/src/com/android/intentresolver/v2/data/model/FakeChooserRequest.kt b/tests/unit/src/com/android/intentresolver/v2/data/model/FakeChooserRequest.kt new file mode 100644 index 00000000..559e3b77 --- /dev/null +++ b/tests/unit/src/com/android/intentresolver/v2/data/model/FakeChooserRequest.kt @@ -0,0 +1,26 @@ +/* + * Copyright (C) 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.intentresolver.v2.data.model + +import android.content.Intent +import android.net.Uri + +fun fakeChooserRequest( + intent: Intent = Intent(), + packageName: String = "pkg", + referrer: Uri? = null, +) = ChooserRequest(intent, packageName, null) diff --git a/tests/unit/src/com/android/intentresolver/v2/data/repository/UserRepositoryImplTest.kt b/tests/unit/src/com/android/intentresolver/v2/data/repository/UserRepositoryImplTest.kt index 16e8c9bb..3fcc4c84 100644 --- a/tests/unit/src/com/android/intentresolver/v2/data/repository/UserRepositoryImplTest.kt +++ b/tests/unit/src/com/android/intentresolver/v2/data/repository/UserRepositoryImplTest.kt @@ -1,6 +1,5 @@ package com.android.intentresolver.v2.data.repository -import android.content.Intent import android.content.pm.UserInfo import android.os.UserHandle import android.os.UserHandle.SYSTEM @@ -43,9 +42,10 @@ internal class UserRepositoryImplTest { val users by collectLastValue(repo.users) assertWithMessage("collectLastValue(repo.users)").that(users).isNotNull() - assertThat(users!!.filter { it.role.type == User.Type.PROFILE }).isEmpty() + assertThat(users).hasSize(1) val profile = userState.createProfile(ProfileType.WORK) + assertThat(users).hasSize(2) assertThat(users).contains(User(profile.identifier, Role.WORK)) } @@ -89,11 +89,11 @@ internal class UserRepositoryImplTest { repo.requestState(privateUser, false) repo.requestState(privateUser, true) - assertWithMessage("users.size") - .that(users?.size ?: 0).isEqualTo(2) // personal + private + assertWithMessage("users.size").that(users?.size ?: 0).isEqualTo(2) // personal + private assertWithMessage("No duplicate IDs") - .that(users?.count { it.id == private.identifier }).isEqualTo(1) + .that(users?.count { it.id == private.identifier }) + .isEqualTo(1) } @Test @@ -112,13 +112,6 @@ internal class UserRepositoryImplTest { assertThat(available?.get(workUser)).isTrue() } - @Test(expected = IllegalArgumentException::class) - fun requestState_invalidForFullUser() = runTest { - val repo = createUserRepository(userManager) - val primaryUser = User(userState.primaryUserHandle.identifier, Role.PERSONAL) - repo.requestState(primaryUser, available = false) - } - /** * This and all the 'recovers_from_*' tests below all configure a static event flow instead of * using [FakeUserManager]. These tests verify that a invalid broadcast causes the flow to @@ -128,13 +121,7 @@ internal class UserRepositoryImplTest { fun recovers_from_invalid_profile_added_event() = runTest { val userManager = mockUserManager(validUser = USER_SYSTEM, invalidUser = UserHandle.USER_NULL) - val events = - flowOf( - UserRepositoryImpl.UserEvent( - Intent.ACTION_PROFILE_ADDED, - UserHandle.of(UserHandle.USER_NULL) - ) - ) + val events = flowOf(ProfileAdded(UserHandle.of(UserHandle.USER_NULL))) val repo = UserRepositoryImpl( profileParent = SYSTEM, @@ -153,13 +140,7 @@ internal class UserRepositoryImplTest { fun recovers_from_invalid_profile_removed_event() = runTest { val userManager = mockUserManager(validUser = USER_SYSTEM, invalidUser = UserHandle.USER_NULL) - val events = - flowOf( - UserRepositoryImpl.UserEvent( - Intent.ACTION_PROFILE_REMOVED, - UserHandle.of(UserHandle.USER_NULL) - ) - ) + val events = flowOf(ProfileRemoved(UserHandle.of(UserHandle.USER_NULL))) val repo = UserRepositoryImpl( profileParent = SYSTEM, @@ -178,13 +159,7 @@ internal class UserRepositoryImplTest { fun recovers_from_invalid_profile_available_event() = runTest { val userManager = mockUserManager(validUser = USER_SYSTEM, invalidUser = UserHandle.USER_NULL) - val events = - flowOf( - UserRepositoryImpl.UserEvent( - Intent.ACTION_PROFILE_AVAILABLE, - UserHandle.of(UserHandle.USER_NULL) - ) - ) + val events = flowOf(AvailabilityChange(UserHandle.of(UserHandle.USER_NULL))) val repo = UserRepositoryImpl(SYSTEM, userManager, events, backgroundScope, Dispatchers.Unconfined) val users by collectLastValue(repo.users) @@ -197,10 +172,7 @@ internal class UserRepositoryImplTest { fun recovers_from_unknown_event() = runTest { val userManager = mockUserManager(validUser = USER_SYSTEM, invalidUser = UserHandle.USER_NULL) - val events = - flowOf( - UserRepositoryImpl.UserEvent("UNKNOWN_EVENT", UserHandle.of(UserHandle.USER_NULL)) - ) + val events = flowOf(UnknownEvent("UNKNOWN_EVENT")) val repo = UserRepositoryImpl( profileParent = SYSTEM, diff --git a/tests/unit/src/com/android/intentresolver/v2/ui/ShareResultSenderImplTest.kt b/tests/unit/src/com/android/intentresolver/v2/ui/ShareResultSenderImplTest.kt index 371f9c26..d894cad5 100644 --- a/tests/unit/src/com/android/intentresolver/v2/ui/ShareResultSenderImplTest.kt +++ b/tests/unit/src/com/android/intentresolver/v2/ui/ShareResultSenderImplTest.kt @@ -23,7 +23,7 @@ import android.content.Intent import android.os.Process import android.service.chooser.ChooserResult import android.service.chooser.Flags -import androidx.test.InstrumentationRegistry +import androidx.test.platform.app.InstrumentationRegistry import com.android.intentresolver.inject.FakeChooserServiceFlags import com.android.intentresolver.v2.ui.model.ShareAction import com.google.common.truth.Truth.assertThat diff --git a/tests/unit/src/com/android/intentresolver/v2/ui/viewmodel/ChooserRequestTest.kt b/tests/unit/src/com/android/intentresolver/v2/ui/viewmodel/ChooserRequestTest.kt index d3b9f559..987d55fc 100644 --- a/tests/unit/src/com/android/intentresolver/v2/ui/viewmodel/ChooserRequestTest.kt +++ b/tests/unit/src/com/android/intentresolver/v2/ui/viewmodel/ChooserRequestTest.kt @@ -31,8 +31,8 @@ import androidx.core.net.toUri import androidx.core.os.bundleOf import com.android.intentresolver.ContentTypeHint import com.android.intentresolver.inject.FakeChooserServiceFlags +import com.android.intentresolver.v2.data.model.ChooserRequest import com.android.intentresolver.v2.ui.model.ActivityModel -import com.android.intentresolver.v2.ui.model.ChooserRequest import com.android.intentresolver.v2.validation.Importance import com.android.intentresolver.v2.validation.Invalid import com.android.intentresolver.v2.validation.NoValue @@ -126,10 +126,7 @@ class ChooserRequestTest { fun payloadIntents_includesTargetThenAdditional() { val intent1 = Intent(ACTION_SEND) val intent2 = Intent(ACTION_SEND_MULTIPLE) - val model = createActivityModel( - targetIntent = intent1, - additionalIntents = listOf(intent2) - ) + val model = createActivityModel(targetIntent = intent1, additionalIntents = listOf(intent2)) val result = readChooserRequest(model, fakeChooserServiceFlags) @@ -229,7 +226,8 @@ class ChooserRequestTest { fakeChooserServiceFlags.setFlag(Flags.FLAG_CHOOSER_PAYLOAD_TOGGLING, true) val uri = Uri.parse("content://org.pkg/path") val position = 10 - val model = createActivityModel(targetIntent = Intent(ACTION_VIEW)).apply { + val model = + createActivityModel(targetIntent = Intent(ACTION_VIEW)).apply { intent.putExtra(EXTRA_CHOOSER_ADDITIONAL_CONTENT_URI, uri) intent.putExtra(EXTRA_CHOOSER_FOCUSED_ITEM_POSITION, position) } @@ -266,7 +264,8 @@ class ChooserRequestTest { fun metadataText_whenFlagFalse_isNull() { fakeChooserServiceFlags.setFlag(Flags.FLAG_ENABLE_SHARESHEET_METADATA_EXTRA, false) val metadataText: CharSequence = "Test metadata text" - val model = createActivityModel(targetIntent = Intent()).apply { + val model = + createActivityModel(targetIntent = Intent()).apply { intent.putExtra(Intent.EXTRA_METADATA_TEXT, metadataText) } @@ -283,7 +282,8 @@ class ChooserRequestTest { // Arrange fakeChooserServiceFlags.setFlag(Flags.FLAG_ENABLE_SHARESHEET_METADATA_EXTRA, true) val metadataText: CharSequence = "Test metadata text" - val model = createActivityModel(targetIntent = Intent()).apply { + val model = + createActivityModel(targetIntent = Intent()).apply { intent.putExtra(Intent.EXTRA_METADATA_TEXT, metadataText) } diff --git a/tests/unit/src/com/android/intentresolver/v2/ui/viewmodel/ResolverRequestTest.kt b/tests/unit/src/com/android/intentresolver/v2/ui/viewmodel/ResolverRequestTest.kt index 6f1ed853..f6475663 100644 --- a/tests/unit/src/com/android/intentresolver/v2/ui/viewmodel/ResolverRequestTest.kt +++ b/tests/unit/src/com/android/intentresolver/v2/ui/viewmodel/ResolverRequestTest.kt @@ -24,7 +24,6 @@ import androidx.core.os.bundleOf import com.android.intentresolver.v2.ResolverActivity.PROFILE_WORK import com.android.intentresolver.v2.shared.model.Profile.Type.WORK import com.android.intentresolver.v2.ui.model.ActivityModel -import com.android.intentresolver.v2.ui.model.ChooserRequest import com.android.intentresolver.v2.ui.model.ResolverRequest import com.android.intentresolver.v2.validation.Invalid import com.android.intentresolver.v2.validation.UncaughtException |