From bc79511501973fe4371060b9ad78fbb4d009fc9b Mon Sep 17 00:00:00 2001 From: Dave Mankoff Date: Mon, 7 Aug 2023 21:19:55 +0000 Subject: Remove numeric id from SystemUI Flagging System Bug: 292511372 Fixes: 265188950 Test: built and run Change-Id: Id88c6652c3280370784d39e94de9ea8a6f20eb9c --- java/src/com/android/intentresolver/flags/Flags.kt | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) (limited to 'java/src') diff --git a/java/src/com/android/intentresolver/flags/Flags.kt b/java/src/com/android/intentresolver/flags/Flags.kt index b303dd1a..2c20d341 100644 --- a/java/src/com/android/intentresolver/flags/Flags.kt +++ b/java/src/com/android/intentresolver/flags/Flags.kt @@ -23,9 +23,8 @@ import com.android.systemui.flags.UnreleasedFlag // make the flags available in the flag flipper app (see go/sysui-flags). // All flags added should be included in UnbundledChooserActivityTest.ALL_FLAGS. object Flags { - private fun releasedFlag(id: Int, name: String) = - ReleasedFlag(id, name, "systemui") + private fun releasedFlag(name: String) = ReleasedFlag(name, "systemui") - private fun unreleasedFlag(id: Int, name: String, teamfood: Boolean = false) = - UnreleasedFlag(id, name, "systemui", teamfood) + private fun unreleasedFlag(name: String, teamfood: Boolean = false) = + UnreleasedFlag(name, "systemui", teamfood) } -- cgit v1.2.3-59-g8ed1b From c9fbc2e62505d8192a353e9322231d870c7cb923 Mon Sep 17 00:00:00 2001 From: Matt Casey Date: Tue, 22 Aug 2023 19:41:08 +0000 Subject: Append "Pinned" to content description for pinned targets Bug: 174283917 Test: atest ChooserListAdapterTest Test: turn on talkback, verify that pinned apps are specified for both direct share and app targets. Change-Id: Ia5121fbd2e99db111b5a2602fed41ae8a992a600 --- java/res/values/strings.xml | 4 ++ .../android/intentresolver/ChooserListAdapter.java | 27 ++++++-- .../intentresolver/ChooserListAdapterTest.kt | 76 ++++++++++++++++++---- 3 files changed, 89 insertions(+), 18 deletions(-) (limited to 'java/src') diff --git a/java/res/values/strings.xml b/java/res/values/strings.xml index 4b5367c0..0c772573 100644 --- a/java/res/values/strings.xml +++ b/java/res/values/strings.xml @@ -303,4 +303,8 @@ Exclude link Include link + + + Pinned diff --git a/java/src/com/android/intentresolver/ChooserListAdapter.java b/java/src/com/android/intentresolver/ChooserListAdapter.java index e6d6dbf4..d1101f1e 100644 --- a/java/src/com/android/intentresolver/ChooserListAdapter.java +++ b/java/src/com/android/intentresolver/ChooserListAdapter.java @@ -296,11 +296,23 @@ public class ChooserListAdapter extends ResolverListAdapter { CharSequence extendedInfo = info.getExtendedInfo(); String contentDescription = String.join(" ", info.getDisplayLabel(), extendedInfo != null ? extendedInfo : "", appName); + if (info.isPinned()) { + contentDescription = String.join( + ". ", + contentDescription, + mContext.getResources().getString(R.string.pinned)); + } holder.updateContentDescription(contentDescription); if (!info.hasDisplayIcon()) { loadDirectShareIcon((SelectableTargetInfo) info); } } else if (info.isDisplayResolveInfo()) { + if (info.isPinned()) { + holder.updateContentDescription(String.join( + ". ", + info.getDisplayLabel(), + mContext.getResources().getString(R.string.pinned))); + } DisplayResolveInfo dri = (DisplayResolveInfo) info; if (!dri.hasDisplayIcon()) { loadIcon(dri); @@ -384,18 +396,20 @@ public class ChooserListAdapter extends ResolverListAdapter { .stream() .collect(Collectors.groupingBy(target -> target.getResolvedComponentName().getPackageName() - + "#" + target.getDisplayLabel() - + '#' + target.getResolveInfo().userHandle.getIdentifier() + + "#" + target.getDisplayLabel() + + '#' + target.getResolveInfo().userHandle.getIdentifier() )) .values() .stream() .map(appTargets -> (appTargets.size() == 1) - ? appTargets.get(0) - : MultiDisplayResolveInfo.newMultiDisplayResolveInfo(appTargets)) + ? appTargets.get(0) + : MultiDisplayResolveInfo.newMultiDisplayResolveInfo( + appTargets)) .sorted(new ChooserActivity.AzInfoComparator(mContext)) .collect(Collectors.toList()); } + @Override protected void onPostExecute(List newList) { mSortedList = newList; @@ -645,8 +659,8 @@ public class ChooserListAdapter extends ResolverListAdapter { */ @Override AsyncTask, - Void, - List> createSortingTask(boolean doPostProcessing) { + Void, + List> createSortingTask(boolean doPostProcessing) { return new AsyncTask, Void, List>() { @@ -658,6 +672,7 @@ public class ChooserListAdapter extends ResolverListAdapter { Trace.endSection(); return params[0]; } + @Override protected void onPostExecute(List sortedComponents) { processSortedList(sortedComponents, doPostProcessing); diff --git a/java/tests/src/com/android/intentresolver/ChooserListAdapterTest.kt b/java/tests/src/com/android/intentresolver/ChooserListAdapterTest.kt index c8cb4b9b..9b5e2d1c 100644 --- a/java/tests/src/com/android/intentresolver/ChooserListAdapterTest.kt +++ b/java/tests/src/com/android/intentresolver/ChooserListAdapterTest.kt @@ -20,6 +20,7 @@ import android.content.ComponentName import android.content.Intent import android.content.pm.PackageManager import android.content.pm.PackageManager.ResolveInfoFlags +import android.content.pm.ShortcutInfo import android.os.UserHandle import android.view.View import android.widget.FrameLayout @@ -33,6 +34,7 @@ import com.android.intentresolver.chooser.TargetInfo import com.android.intentresolver.icons.TargetDataLoader import com.android.intentresolver.logging.EventLog import com.android.internal.R +import com.google.common.truth.Truth.assertThat import org.junit.Before import org.junit.Test import org.junit.runner.RunWith @@ -50,6 +52,8 @@ class ChooserListAdapterTest { } private val context = InstrumentationRegistry.getInstrumentation().context private val resolverListController = mock() + private val appLabel = "App" + private val targetLabel = "Target" private val mEventLog = mock() private val mTargetDataLoader = mock() @@ -132,29 +136,77 @@ class ChooserListAdapterTest { verify(mTargetDataLoader, times(1)).loadAppTargetIcon(any(), any(), any()) } - private fun createSelectableTargetInfo(): TargetInfo = - SelectableTargetInfo.newSelectableTargetInfo( - /* sourceInfo = */ DisplayResolveInfo.newDisplayResolveInfo( - Intent(), - ResolverDataProvider.createResolveInfo(2, 0, userHandle), - "label", - "extended info", - Intent(), - /* resolveInfoPresentationGetter= */ null - ), + @Test + fun onBindView_contentDescription() { + val view = createView() + val viewHolder = ResolverListAdapter.ViewHolder(view) + view.tag = viewHolder + val targetInfo = createSelectableTargetInfo() + testSubject.onBindView(view, targetInfo, 0) + + assertThat(view.contentDescription).isEqualTo("$targetLabel $appLabel") + } + + @Test + fun onBindView_contentDescriptionPinned() { + val view = createView() + val viewHolder = ResolverListAdapter.ViewHolder(view) + view.tag = viewHolder + val targetInfo = createSelectableTargetInfo(true) + testSubject.onBindView(view, targetInfo, 0) + + assertThat(view.contentDescription).isEqualTo("$targetLabel $appLabel. Pinned") + } + + @Test + fun onBindView_displayInfoContentDescriptionPinned() { + val view = createView() + val viewHolder = ResolverListAdapter.ViewHolder(view) + view.tag = viewHolder + val targetInfo = createDisplayResolveInfo(isPinned = true) + testSubject.onBindView(view, targetInfo, 0) + + assertThat(view.contentDescription).isEqualTo("$appLabel. Pinned") + } + + private fun createSelectableTargetInfo(isPinned: Boolean = false): TargetInfo { + val shortcutInfo = + createShortcutInfo("id-1", ComponentName("pkg", "Class"), 1).apply { + if (isPinned) { + addFlags(ShortcutInfo.FLAG_PINNED) + } + } + return SelectableTargetInfo.newSelectableTargetInfo( + /* sourceInfo = */ createDisplayResolveInfo(isPinned), /* backupResolveInfo = */ mock(), /* resolvedIntent = */ Intent(), /* chooserTarget = */ createChooserTarget( - "Target", + targetLabel, 0.5f, ComponentName("pkg", "Class"), "id-1" ), /* modifiedScore = */ 1f, - /* shortcutInfo = */ createShortcutInfo("id-1", ComponentName("pkg", "Class"), 1), + shortcutInfo, /* appTarget */ null, /* referrerFillInIntent = */ Intent() ) + } + + private fun createDisplayResolveInfo(isPinned: Boolean = false): DisplayResolveInfo = + DisplayResolveInfo.newDisplayResolveInfo( + Intent(), + ResolverDataProvider.createResolveInfo(2, 0, userHandle), + appLabel, + "extended info", + Intent(), + /* resolveInfoPresentationGetter= */ null + ) + .apply { + if (isPinned) { + setPinned(true) + } + } private fun createView(): View { val view = FrameLayout(context) -- cgit v1.2.3-59-g8ed1b From 6d855ba6e08a42acff5a6f916fe73c66879cb1da Mon Sep 17 00:00:00 2001 From: Andrey Epin Date: Tue, 22 Aug 2023 12:29:36 -0700 Subject: Add fade-in animation for preview thumbnails Bug: 297063851 Test: manual visual testing for sharing with slow image loading, many items sharing, schreenshots transition animation. Change-Id: I31e60db5b43bb90af66f20d939acb69bbaa308b3 --- .../widget/ScrollableImagePreviewView.kt | 137 +++++++++++++++++++-- 1 file changed, 125 insertions(+), 12 deletions(-) (limited to 'java/src') diff --git a/java/src/com/android/intentresolver/widget/ScrollableImagePreviewView.kt b/java/src/com/android/intentresolver/widget/ScrollableImagePreviewView.kt index 3bbafc40..7fe16091 100644 --- a/java/src/com/android/intentresolver/widget/ScrollableImagePreviewView.kt +++ b/java/src/com/android/intentresolver/widget/ScrollableImagePreviewView.kt @@ -26,11 +26,16 @@ import android.util.TypedValue import android.view.LayoutInflater import android.view.View import android.view.ViewGroup +import android.view.animation.AlphaAnimation +import android.view.animation.Animation +import android.view.animation.Animation.AnimationListener +import android.view.animation.DecelerateInterpolator import android.widget.ImageView import android.widget.TextView import androidx.annotation.VisibleForTesting import androidx.constraintlayout.widget.ConstraintLayout import androidx.core.view.ViewCompat +import androidx.recyclerview.widget.DefaultItemAnimator import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.RecyclerView import com.android.intentresolver.R @@ -45,6 +50,7 @@ import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.takeWhile import kotlinx.coroutines.joinAll import kotlinx.coroutines.launch +import kotlinx.coroutines.suspendCancellableCoroutine private const val TRANSITION_NAME = "screenshot_preview_image" private const val PLURALS_COUNT = "count" @@ -65,7 +71,6 @@ class ScrollableImagePreviewView : RecyclerView, ImagePreviewView { defStyleAttr: Int ) : super(context, attrs, defStyleAttr) { layoutManager = LinearLayoutManager(context, LinearLayoutManager.HORIZONTAL, false) - adapter = Adapter(context) context .obtainStyledAttributes(attrs, R.styleable.ScrollableImagePreviewView, defStyleAttr, 0) @@ -98,11 +103,14 @@ class ScrollableImagePreviewView : RecyclerView, ImagePreviewView { ) .toInt() } - addItemDecoration(SpacingDecoration(innerSpacing, outerSpacing)) + super.addItemDecoration(SpacingDecoration(innerSpacing, outerSpacing)) maxWidthHint = a.getDimensionPixelSize(R.styleable.ScrollableImagePreviewView_maxWidthHint, -1) } + val itemAnimator = ItemAnimator() + super.setItemAnimator(itemAnimator) + super.setAdapter(Adapter(context, itemAnimator.getAddDuration())) } private var batchLoader: BatchPreviewLoader? = null @@ -167,6 +175,14 @@ class ScrollableImagePreviewView : RecyclerView, ImagePreviewView { return null } + override fun setAdapter(adapter: RecyclerView.Adapter<*>?) { + error("This method is not supported") + } + + override fun setItemAnimator(animator: RecyclerView.ItemAnimator?) { + error("This method is not supported") + } + fun setImageLoader(imageLoader: CachingImageLoader) { previewAdapter.imageLoader = imageLoader } @@ -269,7 +285,10 @@ class ScrollableImagePreviewView : RecyclerView, ImagePreviewView { File } - private class Adapter(private val context: Context) : RecyclerView.Adapter() { + private class Adapter( + private val context: Context, + private val fadeInDurationMs: Long, + ) : RecyclerView.Adapter() { private val previews = ArrayList() private val imagePreviewDescription = context.resources.getString(R.string.image_preview_a11y_description) @@ -311,15 +330,17 @@ class ScrollableImagePreviewView : RecyclerView, ImagePreviewView { if (newPreviews.isEmpty()) return val insertPos = previews.size val hadOtherItem = hasOtherItem - val wasEmpty = previews.isEmpty() + val oldItemCount = getItemCount() previews.addAll(newPreviews) if (firstImagePos < 0) { val pos = newPreviews.indexOfFirst { it.type == PreviewType.Image } if (pos >= 0) firstImagePos = insertPos + pos } - if (wasEmpty) { - // we don't want any item animation in that case - notifyDataSetChanged() + if (insertPos == 0) { + if (oldItemCount > 0) { + notifyItemRangeRemoved(0, oldItemCount) + } + notifyItemRangeInserted(insertPos, getItemCount()) } else { notifyItemRangeInserted(insertPos, newPreviews.size) when { @@ -366,6 +387,7 @@ class ScrollableImagePreviewView : RecyclerView, ImagePreviewView { vh.bind( previews[position], imageLoader ?: error("ImageLoader is missing"), + fadeInDurationMs, isSharedTransitionElement = position == firstImagePos, previewReadyCallback = if ( @@ -416,10 +438,13 @@ class ScrollableImagePreviewView : RecyclerView, ImagePreviewView { fun bind( preview: Preview, imageLoader: CachingImageLoader, + fadeInDurationMs: Long, isSharedTransitionElement: Boolean, previewReadyCallback: ((String) -> Unit)? ) { image.setImageDrawable(null) + image.alpha = 1f + image.clearAnimation() (image.layoutParams as? ConstraintLayout.LayoutParams)?.let { params -> params.dimensionRatio = preview.aspectRatioString } @@ -453,11 +478,11 @@ class ScrollableImagePreviewView : RecyclerView, ImagePreviewView { } resetScope().launch { loadImage(preview, imageLoader) - if (preview.type == PreviewType.Image) { - previewReadyCallback?.let { callback -> - image.waitForPreDraw() - callback(TRANSITION_NAME) - } + if (preview.type == PreviewType.Image && previewReadyCallback != null) { + image.waitForPreDraw() + previewReadyCallback(TRANSITION_NAME) + } else if (image.isAttachedToWindow()) { + fadeInPreview(fadeInDurationMs) } } } @@ -473,6 +498,30 @@ class ScrollableImagePreviewView : RecyclerView, ImagePreviewView { image.setImageBitmap(bitmap) } + private suspend fun fadeInPreview(durationMs: Long) = + suspendCancellableCoroutine { continuation -> + val animation = + AlphaAnimation(0f, 1f).apply { + duration = durationMs + interpolator = DecelerateInterpolator() + setAnimationListener( + object : AnimationListener { + override fun onAnimationStart(animation: Animation?) = Unit + override fun onAnimationRepeat(animation: Animation?) = Unit + + override fun onAnimationEnd(animation: Animation?) { + continuation.resumeWith(Result.success(Unit)) + } + } + ) + } + image.startAnimation(animation) + continuation.invokeOnCancellation { + image.clearAnimation() + image.alpha = 1f + } + } + private fun resetScope(): CoroutineScope = CoroutineScope(Dispatchers.Main.immediate).also { scope?.cancel() @@ -521,6 +570,70 @@ class ScrollableImagePreviewView : RecyclerView, ImagePreviewView { } } + /** + * ItemAnimator to handle a special case of addng first image items into the view. The view is + * used with wrap_content width spec thus after adding the first views it, generally, changes + * its size and position breaking the animation. This class handles that by preserving loading + * idicator position in this special case. + */ + private inner class ItemAnimator() : DefaultItemAnimator() { + private var animatedVH: ViewHolder? = null + private var originalTranslation = 0f + + override fun recordPreLayoutInformation( + state: State, + viewHolder: RecyclerView.ViewHolder, + changeFlags: Int, + payloads: MutableList + ): ItemHolderInfo { + return super.recordPreLayoutInformation(state, viewHolder, changeFlags, payloads).let { + holderInfo -> + if (viewHolder is LoadingItemViewHolder && getChildCount() == 1) { + LoadingItemHolderInfo(holderInfo, parentLeft = left) + } else { + holderInfo + } + } + } + + override fun animateDisappearance( + viewHolder: RecyclerView.ViewHolder, + preLayoutInfo: ItemHolderInfo, + postLayoutInfo: ItemHolderInfo? + ): Boolean { + if (viewHolder is LoadingItemViewHolder && preLayoutInfo is LoadingItemHolderInfo) { + val view = viewHolder.itemView + animatedVH = viewHolder + originalTranslation = view.getTranslationX() + view.setTranslationX( + (preLayoutInfo.parentLeft - left + preLayoutInfo.left).toFloat() - view.left + ) + } + return super.animateDisappearance(viewHolder, preLayoutInfo, postLayoutInfo) + } + + override fun onRemoveFinished(viewHolder: RecyclerView.ViewHolder) { + if (animatedVH === viewHolder) { + viewHolder.itemView.setTranslationX(originalTranslation) + animatedVH = null + } + super.onRemoveFinished(viewHolder) + } + + private inner class LoadingItemHolderInfo( + holderInfo: ItemHolderInfo, + val parentLeft: Int, + ) : ItemHolderInfo() { + init { + left = holderInfo.left + top = holderInfo.top + right = holderInfo.right + bottom = holderInfo.bottom + changeFlags = holderInfo.changeFlags + } + } + } + @VisibleForTesting class BatchPreviewLoader( private val imageLoader: CachingImageLoader, -- cgit v1.2.3-59-g8ed1b From 78928498cb20a929bb95042c8b265037262c61aa Mon Sep 17 00:00:00 2001 From: Andrey Epin Date: Mon, 28 Aug 2023 19:15:06 -0700 Subject: Display rich text in text preview Show rich text and title in Text preview and Image + Text preview UI. Fix: 297493142 Test: intgration tests Test: manual testing using ShareTest app checking that Texp preview UI shows rich text in title (Intent.EXTRA_TITLE) and in text (Intent.EXTRA_TEXT) Test: manual testing checkin that Iamge + Text preview UI displays rich text. Change-Id: I681716c21b8940f3dc367273a104f8cc216b7206 --- .../contentpreview/ChooserContentPreviewUi.java | 2 +- .../contentpreview/TextContentPreviewUi.java | 23 +++-- .../UnbundledChooserActivityTest.java | 105 +++++++++++++++++++++ 3 files changed, 123 insertions(+), 7 deletions(-) (limited to 'java/src') diff --git a/java/src/com/android/intentresolver/contentpreview/ChooserContentPreviewUi.java b/java/src/com/android/intentresolver/contentpreview/ChooserContentPreviewUi.java index d279f11f..b7650b9d 100644 --- a/java/src/com/android/intentresolver/contentpreview/ChooserContentPreviewUi.java +++ b/java/src/com/android/intentresolver/contentpreview/ChooserContentPreviewUi.java @@ -200,7 +200,7 @@ public final class ChooserContentPreviewUi { ImageLoader imageLoader, HeadlineGenerator headlineGenerator) { CharSequence sharingText = targetIntent.getCharSequenceExtra(Intent.EXTRA_TEXT); - String previewTitle = targetIntent.getStringExtra(Intent.EXTRA_TITLE); + CharSequence previewTitle = targetIntent.getCharSequenceExtra(Intent.EXTRA_TITLE); ClipData previewData = targetIntent.getClipData(); Uri previewThumbnail = null; if (previewData != null) { diff --git a/java/src/com/android/intentresolver/contentpreview/TextContentPreviewUi.java b/java/src/com/android/intentresolver/contentpreview/TextContentPreviewUi.java index c38ed03a..b383fbcf 100644 --- a/java/src/com/android/intentresolver/contentpreview/TextContentPreviewUi.java +++ b/java/src/com/android/intentresolver/contentpreview/TextContentPreviewUi.java @@ -20,6 +20,7 @@ import static com.android.intentresolver.util.UriFilters.isOwnedByCurrentUser; import android.content.res.Resources; import android.net.Uri; +import android.text.SpannableStringBuilder; import android.text.TextUtils; import android.view.LayoutInflater; import android.view.View; @@ -93,13 +94,9 @@ class TextContentPreviewUi extends ContentPreviewUi { TextView textView = contentPreviewLayout.findViewById( com.android.internal.R.id.content_preview_text); - String text = mSharingText.toString(); - // If we're only previewing one line, then strip out newlines. - if (textView.getMaxLines() == 1) { - text = text.replace("\n", " "); - } - textView.setText(text); + textView.setText( + textView.getMaxLines() == 1 ? replaceLineBreaks(mSharingText) : mSharingText); TextView previewTitleView = contentPreviewLayout.findViewById( com.android.internal.R.id.content_preview_title); @@ -135,4 +132,18 @@ class TextContentPreviewUi extends ContentPreviewUi { return contentPreviewLayout; } + + @Nullable + private static CharSequence replaceLineBreaks(@Nullable CharSequence text) { + if (text == null) { + return null; + } + SpannableStringBuilder string = new SpannableStringBuilder(text); + for (int i = 0, size = string.length(); i < size; i++) { + if (string.charAt(i) == '\n') { + string.replace(i, i + 1, " "); + } + } + return string; + } } diff --git a/java/tests/src/com/android/intentresolver/UnbundledChooserActivityTest.java b/java/tests/src/com/android/intentresolver/UnbundledChooserActivityTest.java index b8b57403..ebc91701 100644 --- a/java/tests/src/com/android/intentresolver/UnbundledChooserActivityTest.java +++ b/java/tests/src/com/android/intentresolver/UnbundledChooserActivityTest.java @@ -82,6 +82,7 @@ import android.graphics.Canvas; import android.graphics.Color; import android.graphics.Paint; import android.graphics.Rect; +import android.graphics.Typeface; import android.graphics.drawable.Icon; import android.net.Uri; import android.os.Bundle; @@ -89,11 +90,19 @@ import android.os.UserHandle; import android.provider.DeviceConfig; import android.service.chooser.ChooserAction; import android.service.chooser.ChooserTarget; +import android.text.Spannable; +import android.text.SpannableStringBuilder; +import android.text.Spanned; +import android.text.style.BackgroundColorSpan; +import android.text.style.ForegroundColorSpan; +import android.text.style.StyleSpan; +import android.text.style.UnderlineSpan; import android.util.HashedStringCache; import android.util.Pair; import android.util.SparseArray; import android.view.View; import android.view.WindowManager; +import android.widget.TextView; import androidx.annotation.CallSuper; import androidx.annotation.NonNull; @@ -383,6 +392,58 @@ public class UnbundledChooserActivityTest { .check(matches(withText(R.string.whichSendApplication))); } + @Test + public void test_shareRichTextWithRichTitle_richTextAndRichTitleDisplayed() { + CharSequence title = new SpannableStringBuilder() + .append("Rich", new UnderlineSpan(), Spannable.SPAN_INCLUSIVE_EXCLUSIVE) + .append( + "Title", + new ForegroundColorSpan(Color.RED), + Spannable.SPAN_INCLUSIVE_EXCLUSIVE); + CharSequence sharedText = new SpannableStringBuilder() + .append( + "Rich", + new BackgroundColorSpan(Color.YELLOW), + Spanned.SPAN_INCLUSIVE_EXCLUSIVE) + .append( + "Text", + new StyleSpan(Typeface.ITALIC), + Spanned.SPAN_INCLUSIVE_EXCLUSIVE); + Intent sendIntent = createSendTextIntent(); + sendIntent.putExtra(Intent.EXTRA_TEXT, sharedText); + sendIntent.putExtra(Intent.EXTRA_TITLE, title); + List resolvedComponentInfos = createResolvedComponentsForTest(2); + setupResolverControllers(resolvedComponentInfos); + + mActivityRule.launchActivity(Intent.createChooser(sendIntent, null)); + waitForIdle(); + + onView(withId(com.android.internal.R.id.content_preview_title)) + .check((view, e) -> { + assertThat(view).isInstanceOf(TextView.class); + CharSequence text = ((TextView) view).getText(); + assertThat(text).isInstanceOf(Spanned.class); + Spanned spanned = (Spanned) text; + assertThat(spanned.getSpans(0, spanned.length(), Object.class)) + .hasLength(2); + assertThat(spanned.getSpans(0, 4, UnderlineSpan.class)).hasLength(1); + assertThat(spanned.getSpans(4, spanned.length(), ForegroundColorSpan.class)) + .hasLength(1); + }); + + onView(withId(com.android.internal.R.id.content_preview_text)) + .check((view, e) -> { + assertThat(view).isInstanceOf(TextView.class); + CharSequence text = ((TextView) view).getText(); + assertThat(text).isInstanceOf(Spanned.class); + Spanned spanned = (Spanned) text; + assertThat(spanned.getSpans(0, spanned.length(), Object.class)) + .hasLength(2); + assertThat(spanned.getSpans(0, 4, BackgroundColorSpan.class)).hasLength(1); + assertThat(spanned.getSpans(4, spanned.length(), StyleSpan.class)).hasLength(1); + }); + } + @Test public void emptyPreviewTitleAndThumbnail() throws InterruptedException { Intent sendIntent = createSendTextIntentWithPreview(null, null); @@ -1173,6 +1234,50 @@ public class UnbundledChooserActivityTest { .check(matches(isDisplayed())); } + @Test + public void test_shareImageWithRichText_RichTextIsDisplayed() { + final Uri uri = createTestContentProviderUri("image/png", null); + final CharSequence sharedText = new SpannableStringBuilder() + .append( + "text-", + new StyleSpan(Typeface.BOLD_ITALIC), + Spannable.SPAN_INCLUSIVE_EXCLUSIVE) + .append( + Long.toString(System.currentTimeMillis()), + new ForegroundColorSpan(Color.RED), + Spanned.SPAN_INCLUSIVE_EXCLUSIVE); + + ArrayList uris = new ArrayList<>(); + uris.add(uri); + + Intent sendIntent = createSendUriIntentWithPreview(uris); + sendIntent.putExtra(Intent.EXTRA_TEXT, sharedText); + ChooserActivityOverrideData.getInstance().imageLoader = + createImageLoader(uri, createBitmap()); + + List resolvedComponentInfos = createResolvedComponentsForTest(2); + + setupResolverControllers(resolvedComponentInfos); + mActivityRule.launchActivity(Intent.createChooser(sendIntent, null)); + waitForIdle(); + onView(withText(sharedText.toString())) + .check(matches(isDisplayed())) + .check((view, e) -> { + if (e != null) { + throw e; + } + assertThat(view).isInstanceOf(TextView.class); + CharSequence text = ((TextView) view).getText(); + assertThat(text).isInstanceOf(Spanned.class); + Spanned spanned = (Spanned) text; + Object[] spans = spanned.getSpans(0, text.length(), Object.class); + assertThat(spans).hasLength(2); + assertThat(spanned.getSpans(0, 5, StyleSpan.class)).hasLength(1); + assertThat(spanned.getSpans(5, text.length(), ForegroundColorSpan.class)) + .hasLength(1); + }); + } + @Test public void testTextPreviewWhenTextIsSharedWithMultipleImages() { final Uri uri = createTestContentProviderUri("image/png", null); -- cgit v1.2.3-59-g8ed1b From b8f2dd471020d6334d7552ecc6cd428e0f796cdb Mon Sep 17 00:00:00 2001 From: Andrey Epin Date: Wed, 6 Sep 2023 21:15:01 -0700 Subject: ShortcutLoader to use a CoroutineScope instead of a Lifecycle A preparation step to make transfer ShortcutLoader ownership from ChooserActivity to a view model. Test: atest IntentResolverUnitTests Change-Id: Id4e02b3418f49644c11e562a89ce3c00e61f8e5f --- .../android/intentresolver/ChooserActivity.java | 3 +- .../intentresolver/shortcuts/ShortcutLoader.kt | 20 +- .../intentresolver/shortcuts/ShortcutLoaderTest.kt | 606 +++++++++++---------- 3 files changed, 319 insertions(+), 310 deletions(-) (limited to 'java/src') diff --git a/java/src/com/android/intentresolver/ChooserActivity.java b/java/src/com/android/intentresolver/ChooserActivity.java index b27f054e..d244fa2d 100644 --- a/java/src/com/android/intentresolver/ChooserActivity.java +++ b/java/src/com/android/intentresolver/ChooserActivity.java @@ -24,6 +24,7 @@ import static android.app.admin.DevicePolicyResources.Strings.Core.RESOLVER_CROS import static android.stats.devicepolicy.nano.DevicePolicyEnums.RESOLVER_EMPTY_STATE_NO_SHARING_TO_PERSONAL; import static android.stats.devicepolicy.nano.DevicePolicyEnums.RESOLVER_EMPTY_STATE_NO_SHARING_TO_WORK; +import static androidx.lifecycle.LifecycleKt.getCoroutineScope; import static com.android.internal.util.LatencyTracker.ACTION_LOAD_SHARE_SHEET; import android.annotation.IntDef; @@ -406,7 +407,7 @@ public class ChooserActivity extends ResolverActivity implements Consumer callback) { return new ShortcutLoader( context, - getLifecycle(), + getCoroutineScope(getLifecycle()), appPredictor, userHandle, targetIntentFilter, diff --git a/java/src/com/android/intentresolver/shortcuts/ShortcutLoader.kt b/java/src/com/android/intentresolver/shortcuts/ShortcutLoader.kt index f05542e2..e7f71661 100644 --- a/java/src/com/android/intentresolver/shortcuts/ShortcutLoader.kt +++ b/java/src/com/android/intentresolver/shortcuts/ShortcutLoader.kt @@ -35,14 +35,13 @@ import androidx.annotation.MainThread import androidx.annotation.OpenForTesting import androidx.annotation.VisibleForTesting import androidx.annotation.WorkerThread -import androidx.lifecycle.Lifecycle -import androidx.lifecycle.coroutineScope import com.android.intentresolver.chooser.DisplayResolveInfo import com.android.intentresolver.measurements.Tracer import com.android.intentresolver.measurements.runTracing import java.util.concurrent.Executor import java.util.function.Consumer import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.asExecutor import kotlinx.coroutines.channels.BufferOverflow @@ -50,6 +49,7 @@ import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.filter import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.isActive import kotlinx.coroutines.launch /** @@ -58,14 +58,14 @@ import kotlinx.coroutines.launch * A ShortcutLoader instance can be viewed as a per-profile singleton hot stream of shortcut * updates. The shortcut loading is triggered in the constructor or by the [reset] method, the * processing happens on the [dispatcher] and the result is delivered through the [callback] on the - * default [lifecycle]'s dispatcher, the main thread. + * default [scope]'s dispatcher, the main thread. */ @OpenForTesting open class ShortcutLoader @VisibleForTesting constructor( private val context: Context, - private val lifecycle: Lifecycle, + private val scope: CoroutineScope, private val appPredictor: AppPredictorProxy?, private val userHandle: UserHandle, private val isPersonalProfile: Boolean, @@ -84,19 +84,19 @@ constructor( private val shortcutSource = MutableSharedFlow(replay = 1, onBufferOverflow = BufferOverflow.DROP_OLDEST) private val isDestroyed - get() = !lifecycle.currentState.isAtLeast(Lifecycle.State.CREATED) + get() = !scope.isActive @MainThread constructor( context: Context, - lifecycle: Lifecycle, + scope: CoroutineScope, appPredictor: AppPredictor?, userHandle: UserHandle, targetIntentFilter: IntentFilter?, callback: Consumer ) : this( context, - lifecycle, + scope, appPredictor?.let { AppPredictorProxy(it) }, userHandle, userHandle == UserHandle.of(ActivityManager.getCurrentUser()), @@ -107,7 +107,7 @@ constructor( init { appPredictor?.registerPredictionUpdates(dispatcher.asExecutor(), appPredictorCallback) - lifecycle.coroutineScope + scope .launch { appTargetSource .combine(shortcutSource) { appTargets, shortcutData -> @@ -135,13 +135,13 @@ constructor( reset() } - /** Clear application targets (see [updateAppTargets] and initiate shrtcuts loading. */ + /** Clear application targets (see [updateAppTargets] and initiate shortcuts loading. */ @OpenForTesting open fun reset() { Log.d(TAG, "reset shortcut loader for user $userHandle") appTargetSource.tryEmit(null) shortcutSource.tryEmit(null) - lifecycle.coroutineScope.launch(dispatcher) { loadShortcuts() } + scope.launch(dispatcher) { loadShortcuts() } } /** diff --git a/java/tests/src/com/android/intentresolver/shortcuts/ShortcutLoaderTest.kt b/java/tests/src/com/android/intentresolver/shortcuts/ShortcutLoaderTest.kt index 9b4a8057..43d0df79 100644 --- a/java/tests/src/com/android/intentresolver/shortcuts/ShortcutLoaderTest.kt +++ b/java/tests/src/com/android/intentresolver/shortcuts/ShortcutLoaderTest.kt @@ -26,8 +26,6 @@ import android.content.pm.PackageManager.ApplicationInfoFlags import android.content.pm.ShortcutManager import android.os.UserHandle import android.os.UserManager -import androidx.lifecycle.Lifecycle -import androidx.lifecycle.testing.TestLifecycleOwner import androidx.test.filters.SmallTest import com.android.intentresolver.any import com.android.intentresolver.argumentCaptor @@ -39,18 +37,15 @@ import com.android.intentresolver.createShortcutInfo import com.android.intentresolver.mock import com.android.intentresolver.whenever import java.util.function.Consumer -import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.test.TestCoroutineScheduler +import kotlinx.coroutines.test.TestScope import kotlinx.coroutines.test.UnconfinedTestDispatcher -import kotlinx.coroutines.test.resetMain -import kotlinx.coroutines.test.setMain -import org.junit.After +import kotlinx.coroutines.test.runTest import org.junit.Assert.assertArrayEquals import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse import org.junit.Assert.assertTrue -import org.junit.Before import org.junit.Test import org.mockito.Mockito.anyInt import org.mockito.Mockito.atLeastOnce @@ -84,7 +79,7 @@ class ShortcutLoaderTest { } private val scheduler = TestCoroutineScheduler() private val dispatcher = UnconfinedTestDispatcher(scheduler) - private val lifecycleOwner = TestLifecycleOwner() + private val scope = TestScope(dispatcher) private val intentFilter = mock() private val appPredictor = mock() private val callback = mock>() @@ -94,135 +89,239 @@ class ShortcutLoaderTest { private val appTargets = arrayOf(appTarget) private val matchingShortcutInfo = createShortcutInfo("id-0", componentName, 1) - @Before - fun setup() { - Dispatchers.setMain(dispatcher) - lifecycleOwner.handleLifecycleEvent(Lifecycle.Event.ON_CREATE) - } - - @After - fun cleanup() { - lifecycleOwner.handleLifecycleEvent(Lifecycle.Event.ON_DESTROY) - Dispatchers.resetMain() - } - @Test - fun test_loadShortcutsWithAppPredictor_resultIntegrity() { - val testSubject = - ShortcutLoader( - context, - lifecycleOwner.lifecycle, - appPredictor, - UserHandle.of(0), - true, - intentFilter, - dispatcher, - callback - ) - - testSubject.updateAppTargets(appTargets) + fun test_loadShortcutsWithAppPredictor_resultIntegrity() = + scope.runTest { + val testSubject = + ShortcutLoader( + context, + backgroundScope, + appPredictor, + UserHandle.of(0), + true, + intentFilter, + dispatcher, + callback + ) - val matchingAppTarget = createAppTarget(matchingShortcutInfo) - val shortcuts = - listOf( - matchingAppTarget, - // an AppTarget that does not belong to any resolved application; should be ignored - createAppTarget( - createShortcutInfo("id-1", ComponentName("mismatching.pkg", "Class"), 1) + testSubject.updateAppTargets(appTargets) + + val matchingAppTarget = createAppTarget(matchingShortcutInfo) + val shortcuts = + listOf( + matchingAppTarget, + // an AppTarget that does not belong to any resolved application; should be + // ignored + createAppTarget( + createShortcutInfo("id-1", ComponentName("mismatching.pkg", "Class"), 1) + ) ) + val appPredictorCallbackCaptor = argumentCaptor() + verify(appPredictor, atLeastOnce()) + .registerPredictionUpdates(any(), capture(appPredictorCallbackCaptor)) + appPredictorCallbackCaptor.value.onTargetsAvailable(shortcuts) + + val resultCaptor = argumentCaptor() + verify(callback, times(1)).accept(capture(resultCaptor)) + + val result = resultCaptor.value + assertTrue("An app predictor result is expected", result.isFromAppPredictor) + assertArrayEquals( + "Wrong input app targets in the result", + appTargets, + result.appTargets ) - val appPredictorCallbackCaptor = argumentCaptor() - verify(appPredictor, atLeastOnce()) - .registerPredictionUpdates(any(), capture(appPredictorCallbackCaptor)) - appPredictorCallbackCaptor.value.onTargetsAvailable(shortcuts) - - val resultCaptor = argumentCaptor() - verify(callback, times(1)).accept(capture(resultCaptor)) - - val result = resultCaptor.value - assertTrue("An app predictor result is expected", result.isFromAppPredictor) - assertArrayEquals("Wrong input app targets in the result", appTargets, result.appTargets) - assertEquals("Wrong shortcut count", 1, result.shortcutsByApp.size) - assertEquals("Wrong app target", appTarget, result.shortcutsByApp[0].appTarget) - for (shortcut in result.shortcutsByApp[0].shortcuts) { - assertEquals( - "Wrong AppTarget in the cache", - matchingAppTarget, - result.directShareAppTargetCache[shortcut] - ) - assertEquals( - "Wrong ShortcutInfo in the cache", - matchingShortcutInfo, - result.directShareShortcutInfoCache[shortcut] - ) + assertEquals("Wrong shortcut count", 1, result.shortcutsByApp.size) + assertEquals("Wrong app target", appTarget, result.shortcutsByApp[0].appTarget) + for (shortcut in result.shortcutsByApp[0].shortcuts) { + assertEquals( + "Wrong AppTarget in the cache", + matchingAppTarget, + result.directShareAppTargetCache[shortcut] + ) + assertEquals( + "Wrong ShortcutInfo in the cache", + matchingShortcutInfo, + result.directShareShortcutInfoCache[shortcut] + ) + } } - } @Test - fun test_loadShortcutsWithShortcutManager_resultIntegrity() { - val shortcutManagerResult = - listOf( - ShortcutManager.ShareShortcutInfo(matchingShortcutInfo, componentName), - // mismatching shortcut - createShareShortcutInfo("id-1", ComponentName("mismatching.pkg", "Class"), 1) + fun test_loadShortcutsWithShortcutManager_resultIntegrity() = + scope.runTest { + val shortcutManagerResult = + listOf( + ShortcutManager.ShareShortcutInfo(matchingShortcutInfo, componentName), + // mismatching shortcut + createShareShortcutInfo("id-1", ComponentName("mismatching.pkg", "Class"), 1) + ) + val shortcutManager = + mock { + whenever(getShareTargets(intentFilter)).thenReturn(shortcutManagerResult) + } + whenever(context.getSystemService(Context.SHORTCUT_SERVICE)).thenReturn(shortcutManager) + val testSubject = + ShortcutLoader( + context, + backgroundScope, + null, + UserHandle.of(0), + true, + intentFilter, + dispatcher, + callback + ) + + testSubject.updateAppTargets(appTargets) + + val resultCaptor = argumentCaptor() + verify(callback, times(1)).accept(capture(resultCaptor)) + + val result = resultCaptor.value + assertFalse("An ShortcutManager result is expected", result.isFromAppPredictor) + assertArrayEquals( + "Wrong input app targets in the result", + appTargets, + result.appTargets ) - val shortcutManager = - mock { - whenever(getShareTargets(intentFilter)).thenReturn(shortcutManagerResult) + assertEquals("Wrong shortcut count", 1, result.shortcutsByApp.size) + assertEquals("Wrong app target", appTarget, result.shortcutsByApp[0].appTarget) + for (shortcut in result.shortcutsByApp[0].shortcuts) { + assertTrue( + "AppTargets are not expected the cache of a ShortcutManager result", + result.directShareAppTargetCache.isEmpty() + ) + assertEquals( + "Wrong ShortcutInfo in the cache", + matchingShortcutInfo, + result.directShareShortcutInfoCache[shortcut] + ) } - whenever(context.getSystemService(Context.SHORTCUT_SERVICE)).thenReturn(shortcutManager) - val testSubject = - ShortcutLoader( - context, - lifecycleOwner.lifecycle, - null, - UserHandle.of(0), - true, - intentFilter, - dispatcher, - callback - ) + } - testSubject.updateAppTargets(appTargets) + @Test + fun test_appPredictorReturnsEmptyList_fallbackToShortcutManager() = + scope.runTest { + val shortcutManagerResult = + listOf( + ShortcutManager.ShareShortcutInfo(matchingShortcutInfo, componentName), + // mismatching shortcut + createShareShortcutInfo("id-1", ComponentName("mismatching.pkg", "Class"), 1) + ) + val shortcutManager = + mock { + whenever(getShareTargets(intentFilter)).thenReturn(shortcutManagerResult) + } + whenever(context.getSystemService(Context.SHORTCUT_SERVICE)).thenReturn(shortcutManager) + val testSubject = + ShortcutLoader( + context, + backgroundScope, + appPredictor, + UserHandle.of(0), + true, + intentFilter, + dispatcher, + callback + ) - val resultCaptor = argumentCaptor() - verify(callback, times(1)).accept(capture(resultCaptor)) + testSubject.updateAppTargets(appTargets) - val result = resultCaptor.value - assertFalse("An ShortcutManager result is expected", result.isFromAppPredictor) - assertArrayEquals("Wrong input app targets in the result", appTargets, result.appTargets) - assertEquals("Wrong shortcut count", 1, result.shortcutsByApp.size) - assertEquals("Wrong app target", appTarget, result.shortcutsByApp[0].appTarget) - for (shortcut in result.shortcutsByApp[0].shortcuts) { - assertTrue( - "AppTargets are not expected the cache of a ShortcutManager result", - result.directShareAppTargetCache.isEmpty() - ) - assertEquals( - "Wrong ShortcutInfo in the cache", - matchingShortcutInfo, - result.directShareShortcutInfoCache[shortcut] + verify(appPredictor, times(1)).requestPredictionUpdate() + val appPredictorCallbackCaptor = argumentCaptor() + verify(appPredictor, times(1)) + .registerPredictionUpdates(any(), capture(appPredictorCallbackCaptor)) + appPredictorCallbackCaptor.value.onTargetsAvailable(emptyList()) + + val resultCaptor = argumentCaptor() + verify(callback, times(1)).accept(capture(resultCaptor)) + + val result = resultCaptor.value + assertFalse("An ShortcutManager result is expected", result.isFromAppPredictor) + assertArrayEquals( + "Wrong input app targets in the result", + appTargets, + result.appTargets ) + assertEquals("Wrong shortcut count", 1, result.shortcutsByApp.size) + assertEquals("Wrong app target", appTarget, result.shortcutsByApp[0].appTarget) + for (shortcut in result.shortcutsByApp[0].shortcuts) { + assertTrue( + "AppTargets are not expected the cache of a ShortcutManager result", + result.directShareAppTargetCache.isEmpty() + ) + assertEquals( + "Wrong ShortcutInfo in the cache", + matchingShortcutInfo, + result.directShareShortcutInfoCache[shortcut] + ) + } } - } @Test - fun test_appPredictorReturnsEmptyList_fallbackToShortcutManager() { - val shortcutManagerResult = - listOf( - ShortcutManager.ShareShortcutInfo(matchingShortcutInfo, componentName), - // mismatching shortcut - createShareShortcutInfo("id-1", ComponentName("mismatching.pkg", "Class"), 1) + fun test_appPredictor_requestPredictionUpdateFailure_fallbackToShortcutManager() = + scope.runTest { + val shortcutManagerResult = + listOf( + ShortcutManager.ShareShortcutInfo(matchingShortcutInfo, componentName), + // mismatching shortcut + createShareShortcutInfo("id-1", ComponentName("mismatching.pkg", "Class"), 1) + ) + val shortcutManager = + mock { + whenever(getShareTargets(intentFilter)).thenReturn(shortcutManagerResult) + } + whenever(context.getSystemService(Context.SHORTCUT_SERVICE)).thenReturn(shortcutManager) + whenever(appPredictor.requestPredictionUpdate()) + .thenThrow(IllegalStateException("Test exception")) + val testSubject = + ShortcutLoader( + context, + backgroundScope, + appPredictor, + UserHandle.of(0), + true, + intentFilter, + dispatcher, + callback + ) + + testSubject.updateAppTargets(appTargets) + + verify(appPredictor, times(1)).requestPredictionUpdate() + + val resultCaptor = argumentCaptor() + verify(callback, times(1)).accept(capture(resultCaptor)) + + val result = resultCaptor.value + assertFalse("An ShortcutManager result is expected", result.isFromAppPredictor) + assertArrayEquals( + "Wrong input app targets in the result", + appTargets, + result.appTargets ) - val shortcutManager = - mock { - whenever(getShareTargets(intentFilter)).thenReturn(shortcutManagerResult) + assertEquals("Wrong shortcut count", 1, result.shortcutsByApp.size) + assertEquals("Wrong app target", appTarget, result.shortcutsByApp[0].appTarget) + for (shortcut in result.shortcutsByApp[0].shortcuts) { + assertTrue( + "AppTargets are not expected the cache of a ShortcutManager result", + result.directShareAppTargetCache.isEmpty() + ) + assertEquals( + "Wrong ShortcutInfo in the cache", + matchingShortcutInfo, + result.directShareShortcutInfoCache[shortcut] + ) } - whenever(context.getSystemService(Context.SHORTCUT_SERVICE)).thenReturn(shortcutManager) - val testSubject = + } + + @Test + fun test_ShortcutLoader_shortcutsRequestedIndependentlyFromAppTargets() = + scope.runTest { ShortcutLoader( context, - lifecycleOwner.lifecycle, + backgroundScope, appPredictor, UserHandle.of(0), true, @@ -231,122 +330,57 @@ class ShortcutLoaderTest { callback ) - testSubject.updateAppTargets(appTargets) - - verify(appPredictor, times(1)).requestPredictionUpdate() - val appPredictorCallbackCaptor = argumentCaptor() - verify(appPredictor, times(1)) - .registerPredictionUpdates(any(), capture(appPredictorCallbackCaptor)) - appPredictorCallbackCaptor.value.onTargetsAvailable(emptyList()) - - val resultCaptor = argumentCaptor() - verify(callback, times(1)).accept(capture(resultCaptor)) - - val result = resultCaptor.value - assertFalse("An ShortcutManager result is expected", result.isFromAppPredictor) - assertArrayEquals("Wrong input app targets in the result", appTargets, result.appTargets) - assertEquals("Wrong shortcut count", 1, result.shortcutsByApp.size) - assertEquals("Wrong app target", appTarget, result.shortcutsByApp[0].appTarget) - for (shortcut in result.shortcutsByApp[0].shortcuts) { - assertTrue( - "AppTargets are not expected the cache of a ShortcutManager result", - result.directShareAppTargetCache.isEmpty() - ) - assertEquals( - "Wrong ShortcutInfo in the cache", - matchingShortcutInfo, - result.directShareShortcutInfoCache[shortcut] - ) + verify(appPredictor, times(1)).requestPredictionUpdate() + verify(callback, never()).accept(any()) } - } @Test - fun test_appPredictor_requestPredictionUpdateFailure_fallbackToShortcutManager() { - val shortcutManagerResult = - listOf( - ShortcutManager.ShareShortcutInfo(matchingShortcutInfo, componentName), - // mismatching shortcut - createShareShortcutInfo("id-1", ComponentName("mismatching.pkg", "Class"), 1) - ) - val shortcutManager = - mock { - whenever(getShareTargets(intentFilter)).thenReturn(shortcutManagerResult) - } - whenever(context.getSystemService(Context.SHORTCUT_SERVICE)).thenReturn(shortcutManager) - whenever(appPredictor.requestPredictionUpdate()) - .thenThrow(IllegalStateException("Test exception")) - val testSubject = - ShortcutLoader( - context, - lifecycleOwner.lifecycle, - appPredictor, - UserHandle.of(0), - true, - intentFilter, - dispatcher, - callback - ) + fun test_ShortcutLoader_noResultsWithoutAppTargets() = + scope.runTest { + val shortcutManagerResult = + listOf( + ShortcutManager.ShareShortcutInfo(matchingShortcutInfo, componentName), + // mismatching shortcut + createShareShortcutInfo("id-1", ComponentName("mismatching.pkg", "Class"), 1) + ) + val shortcutManager = + mock { + whenever(getShareTargets(intentFilter)).thenReturn(shortcutManagerResult) + } + whenever(context.getSystemService(Context.SHORTCUT_SERVICE)).thenReturn(shortcutManager) + val testSubject = + ShortcutLoader( + context, + backgroundScope, + null, + UserHandle.of(0), + true, + intentFilter, + dispatcher, + callback + ) - testSubject.updateAppTargets(appTargets) + verify(shortcutManager, times(1)).getShareTargets(any()) + verify(callback, never()).accept(any()) - verify(appPredictor, times(1)).requestPredictionUpdate() + testSubject.reset() - val resultCaptor = argumentCaptor() - verify(callback, times(1)).accept(capture(resultCaptor)) + verify(shortcutManager, times(2)).getShareTargets(any()) + verify(callback, never()).accept(any()) - val result = resultCaptor.value - assertFalse("An ShortcutManager result is expected", result.isFromAppPredictor) - assertArrayEquals("Wrong input app targets in the result", appTargets, result.appTargets) - assertEquals("Wrong shortcut count", 1, result.shortcutsByApp.size) - assertEquals("Wrong app target", appTarget, result.shortcutsByApp[0].appTarget) - for (shortcut in result.shortcutsByApp[0].shortcuts) { - assertTrue( - "AppTargets are not expected the cache of a ShortcutManager result", - result.directShareAppTargetCache.isEmpty() - ) - assertEquals( - "Wrong ShortcutInfo in the cache", - matchingShortcutInfo, - result.directShareShortcutInfoCache[shortcut] - ) - } - } + testSubject.updateAppTargets(appTargets) - @Test - fun test_ShortcutLoader_shortcutsRequestedIndependentlyFromAppTargets() { - ShortcutLoader( - context, - lifecycleOwner.lifecycle, - appPredictor, - UserHandle.of(0), - true, - intentFilter, - dispatcher, - callback - ) - - verify(appPredictor, times(1)).requestPredictionUpdate() - verify(callback, never()).accept(any()) - } + verify(shortcutManager, times(2)).getShareTargets(any()) + verify(callback, times(1)).accept(any()) + } @Test - fun test_ShortcutLoader_noResultsWithoutAppTargets() { - val shortcutManagerResult = - listOf( - ShortcutManager.ShareShortcutInfo(matchingShortcutInfo, componentName), - // mismatching shortcut - createShareShortcutInfo("id-1", ComponentName("mismatching.pkg", "Class"), 1) - ) - val shortcutManager = - mock { - whenever(getShareTargets(intentFilter)).thenReturn(shortcutManagerResult) - } - whenever(context.getSystemService(Context.SHORTCUT_SERVICE)).thenReturn(shortcutManager) - val testSubject = + fun test_OnScopeCancellation_unsubscribeFromAppPredictor() { + scope.runTest { ShortcutLoader( context, - lifecycleOwner.lifecycle, - null, + backgroundScope, + appPredictor, UserHandle.of(0), true, intentFilter, @@ -354,36 +388,8 @@ class ShortcutLoaderTest { callback ) - verify(shortcutManager, times(1)).getShareTargets(any()) - verify(callback, never()).accept(any()) - - testSubject.reset() - - verify(shortcutManager, times(2)).getShareTargets(any()) - verify(callback, never()).accept(any()) - - testSubject.updateAppTargets(appTargets) - - verify(shortcutManager, times(2)).getShareTargets(any()) - verify(callback, times(1)).accept(any()) - } - - @Test - fun test_OnLifecycleDestroyed_unsubscribeFromAppPredictor() { - ShortcutLoader( - context, - lifecycleOwner.lifecycle, - appPredictor, - UserHandle.of(0), - true, - intentFilter, - dispatcher, - callback - ) - - verify(appPredictor, never()).unregisterPredictionUpdates(any()) - - lifecycleOwner.handleLifecycleEvent(Lifecycle.Event.ON_DESTROY) + verify(appPredictor, never()).unregisterPredictionUpdates(any()) + } verify(appPredictor, times(1)).unregisterPredictionUpdates(any()) } @@ -422,61 +428,63 @@ class ShortcutLoaderTest { isUserRunning: Boolean = true, isUserUnlocked: Boolean = true, isQuietModeEnabled: Boolean = false - ) { - val userHandle = UserHandle.of(10) - with(userManager) { - whenever(isUserRunning(userHandle)).thenReturn(isUserRunning) - whenever(isUserUnlocked(userHandle)).thenReturn(isUserUnlocked) - whenever(isQuietModeEnabled(userHandle)).thenReturn(isQuietModeEnabled) - } - whenever(context.getSystemService(Context.USER_SERVICE)).thenReturn(userManager) - val appPredictor = mock() - val callback = mock>() - val testSubject = - ShortcutLoader( - context, - lifecycleOwner.lifecycle, - appPredictor, - userHandle, - false, - intentFilter, - dispatcher, - callback - ) + ) = + scope.runTest { + val userHandle = UserHandle.of(10) + with(userManager) { + whenever(isUserRunning(userHandle)).thenReturn(isUserRunning) + whenever(isUserUnlocked(userHandle)).thenReturn(isUserUnlocked) + whenever(isQuietModeEnabled(userHandle)).thenReturn(isQuietModeEnabled) + } + whenever(context.getSystemService(Context.USER_SERVICE)).thenReturn(userManager) + val appPredictor = mock() + val callback = mock>() + val testSubject = + ShortcutLoader( + context, + backgroundScope, + appPredictor, + userHandle, + false, + intentFilter, + dispatcher, + callback + ) - testSubject.updateAppTargets(arrayOf(mock())) + testSubject.updateAppTargets(arrayOf(mock())) - verify(appPredictor, never()).requestPredictionUpdate() - } + verify(appPredictor, never()).requestPredictionUpdate() + } private fun testAlwaysCallSystemForMainProfile( isUserRunning: Boolean = true, isUserUnlocked: Boolean = true, isQuietModeEnabled: Boolean = false - ) { - val userHandle = UserHandle.of(10) - with(userManager) { - whenever(isUserRunning(userHandle)).thenReturn(isUserRunning) - whenever(isUserUnlocked(userHandle)).thenReturn(isUserUnlocked) - whenever(isQuietModeEnabled(userHandle)).thenReturn(isQuietModeEnabled) - } - whenever(context.getSystemService(Context.USER_SERVICE)).thenReturn(userManager) - val appPredictor = mock() - val callback = mock>() - val testSubject = - ShortcutLoader( - context, - lifecycleOwner.lifecycle, - appPredictor, - userHandle, - true, - intentFilter, - dispatcher, - callback - ) + ) = + scope.runTest { + val userHandle = UserHandle.of(10) + with(userManager) { + whenever(isUserRunning(userHandle)).thenReturn(isUserRunning) + whenever(isUserUnlocked(userHandle)).thenReturn(isUserUnlocked) + whenever(isQuietModeEnabled(userHandle)).thenReturn(isQuietModeEnabled) + } + whenever(context.getSystemService(Context.USER_SERVICE)).thenReturn(userManager) + val appPredictor = mock() + val callback = mock>() + val testSubject = + ShortcutLoader( + context, + backgroundScope, + appPredictor, + userHandle, + true, + intentFilter, + dispatcher, + callback + ) - testSubject.updateAppTargets(arrayOf(mock())) + testSubject.updateAppTargets(arrayOf(mock())) - verify(appPredictor, times(1)).requestPredictionUpdate() - } + verify(appPredictor, times(1)).requestPredictionUpdate() + } } -- cgit v1.2.3-59-g8ed1b From 5591d163172fac7e7f5786ea28adde437a841e26 Mon Sep 17 00:00:00 2001 From: Mark Renouf Date: Wed, 30 Aug 2023 14:59:06 -0400 Subject: Enable dependency injection using Hilt This includes modifications to initialization of ChooserActivity to avoid violating scoping and order of initialization. Bug: 299610743 Test: atest IntentResolverUnitTests Test: atest CtsSharesheetDeviceTest Change-Id: I6570bda272eff44b3a64eab9df38049beb9c9fcc --- Android.bp | 7 +- AndroidManifest-app.xml | 1 + .../android/intentresolver/ChooserActivity.java | 80 +++++++++++----------- .../intentresolver/ChooserRefinementManager.java | 10 ++- .../com/android/intentresolver/MainApplication.kt | 22 ++++++ .../contentpreview/PreviewViewModel.kt | 10 ++- .../intentresolver/inject/ActivityModule.kt | 46 +++++++++++++ .../intentresolver/inject/ConcurrencyModule.kt | 43 ++++++++++++ .../intentresolver/inject/FrameworkModule.kt | 75 ++++++++++++++++++++ .../android/intentresolver/inject/Qualifiers.kt | 27 ++++++++ .../intentresolver/ChooserWrapperActivity.java | 3 +- .../com/android/intentresolver/TestApplication.kt | 5 +- 12 files changed, 279 insertions(+), 50 deletions(-) create mode 100644 java/src/com/android/intentresolver/MainApplication.kt create mode 100644 java/src/com/android/intentresolver/inject/ActivityModule.kt create mode 100644 java/src/com/android/intentresolver/inject/ConcurrencyModule.kt create mode 100644 java/src/com/android/intentresolver/inject/FrameworkModule.kt create mode 100644 java/src/com/android/intentresolver/inject/Qualifiers.kt (limited to 'java/src') diff --git a/Android.bp b/Android.bp index aa60038c..93f19179 100644 --- a/Android.bp +++ b/Android.bp @@ -69,7 +69,10 @@ android_library { "androidx.lifecycle_lifecycle-extensions", "androidx.lifecycle_lifecycle-runtime-ktx", "androidx.lifecycle_lifecycle-viewmodel-ktx", + "dagger2", + "hilt_android", "IntentResolverFlagsLib", + "jsr330", "kotlin-stdlib", "kotlinx_coroutines", "kotlinx-coroutines-android", @@ -81,11 +84,11 @@ android_library { java_defaults { name: "App_Defaults", - manifest: "AndroidManifest-app.xml", min_sdk_version: "current", platform_apis: true, certificate: "platform", privileged: true, + manifest: "AndroidManifest-app.xml", required: [ "privapp_whitelist_com.android.intentresolver", ], @@ -110,4 +113,4 @@ android_app { "com.android.intentresolver", "test_com.android.intentresolver", ], -} +} \ No newline at end of file diff --git a/AndroidManifest-app.xml b/AndroidManifest-app.xml index 826d8a5a..9efc7ab1 100644 --- a/AndroidManifest-app.xml +++ b/AndroidManifest-app.xml @@ -24,6 +24,7 @@ coreApp="true"> mRefinementCompletion = new MutableLiveData<>(); + @Inject + public ChooserRefinementManager() {} + public LiveData getRefinementCompletion() { return mRefinementCompletion; } diff --git a/java/src/com/android/intentresolver/MainApplication.kt b/java/src/com/android/intentresolver/MainApplication.kt new file mode 100644 index 00000000..0a826629 --- /dev/null +++ b/java/src/com/android/intentresolver/MainApplication.kt @@ -0,0 +1,22 @@ +/* + * Copyright (C) 2023 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.intentresolver + +import android.app.Application +import dagger.hilt.android.HiltAndroidApp + +@HiltAndroidApp(Application::class) open class MainApplication : Hilt_MainApplication() diff --git a/java/src/com/android/intentresolver/contentpreview/PreviewViewModel.kt b/java/src/com/android/intentresolver/contentpreview/PreviewViewModel.kt index 6013f5a0..b55b8b38 100644 --- a/java/src/com/android/intentresolver/contentpreview/PreviewViewModel.kt +++ b/java/src/com/android/intentresolver/contentpreview/PreviewViewModel.kt @@ -25,14 +25,20 @@ import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewmodel.CreationExtras import com.android.intentresolver.ChooserRequestParameters import com.android.intentresolver.R +import com.android.intentresolver.inject.Background +import dagger.hilt.android.lifecycle.HiltViewModel +import javax.inject.Inject import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.plus /** A trivial view model to keep a [PreviewDataProvider] instance over a configuration change */ -class PreviewViewModel( +@HiltViewModel +class PreviewViewModel +@Inject +constructor( private val application: Application, - private val dispatcher: CoroutineDispatcher = Dispatchers.IO, + @Background private val dispatcher: CoroutineDispatcher = Dispatchers.IO, ) : BasePreviewViewModel() { private var previewDataProvider: PreviewDataProvider? = null private var imageLoader: ImagePreviewImageLoader? = null diff --git a/java/src/com/android/intentresolver/inject/ActivityModule.kt b/java/src/com/android/intentresolver/inject/ActivityModule.kt new file mode 100644 index 00000000..21bfe4c6 --- /dev/null +++ b/java/src/com/android/intentresolver/inject/ActivityModule.kt @@ -0,0 +1,46 @@ +/* + * Copyright (C) 2023 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.intentresolver.inject + +import android.app.Activity +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleOwner +import androidx.lifecycle.lifecycleScope +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.android.components.ActivityComponent +import kotlinx.coroutines.CoroutineScope + +@Module +@InstallIn(ActivityComponent::class) +object ActivityModule { + + @Provides + @ActivityOwned + fun lifecycle(activity: Activity): Lifecycle { + check(activity is LifecycleOwner) { "activity must implement LifecycleOwner" } + return activity.lifecycle + } + + @Provides + @ActivityOwned + fun activityScope(activity: Activity): CoroutineScope { + check(activity is LifecycleOwner) { "activity must implement LifecycleOwner" } + return activity.lifecycleScope + } +} diff --git a/java/src/com/android/intentresolver/inject/ConcurrencyModule.kt b/java/src/com/android/intentresolver/inject/ConcurrencyModule.kt new file mode 100644 index 00000000..e0f8e88b --- /dev/null +++ b/java/src/com/android/intentresolver/inject/ConcurrencyModule.kt @@ -0,0 +1,43 @@ +/* + * Copyright (C) 2023 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.intentresolver.inject + +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import javax.inject.Singleton +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob + +@Module +@InstallIn(SingletonComponent::class) +object ConcurrencyModule { + + @Provides @Main fun mainDispatcher(): CoroutineDispatcher = Dispatchers.Main.immediate + + /** Injectable alternative to [MainScope()][kotlinx.coroutines.MainScope] */ + @Provides + @Singleton + @Main + fun mainCoroutineScope(@Main mainDispatcher: CoroutineDispatcher) = + CoroutineScope(SupervisorJob() + mainDispatcher) + + @Provides @Background fun backgroundDispatcher(): CoroutineDispatcher = Dispatchers.IO +} diff --git a/java/src/com/android/intentresolver/inject/FrameworkModule.kt b/java/src/com/android/intentresolver/inject/FrameworkModule.kt new file mode 100644 index 00000000..39a2faf9 --- /dev/null +++ b/java/src/com/android/intentresolver/inject/FrameworkModule.kt @@ -0,0 +1,75 @@ +/* + * Copyright (C) 2023 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.intentresolver.inject + +import android.app.ActivityManager +import android.app.admin.DevicePolicyManager +import android.content.ClipboardManager +import android.content.Context +import android.content.pm.LauncherApps +import android.content.pm.PackageManager +import android.content.pm.ShortcutManager +import android.os.UserManager +import android.view.WindowManager +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.android.qualifiers.ApplicationContext +import dagger.hilt.components.SingletonComponent + +private fun Context.requireSystemService(serviceClass: Class): T { + return checkNotNull(getSystemService(serviceClass)) +} + +@Module +@InstallIn(SingletonComponent::class) +object FrameworkModule { + + @Provides fun contentResolver(@ApplicationContext ctx: Context) = ctx.contentResolver!! + + @Provides + fun activityManager(@ApplicationContext ctx: Context) = + ctx.requireSystemService(ActivityManager::class.java) + + @Provides + fun clipboardManager(@ApplicationContext ctx: Context) = + ctx.requireSystemService(ClipboardManager::class.java) + + @Provides + fun devicePolicyManager(@ApplicationContext ctx: Context) = + ctx.requireSystemService(DevicePolicyManager::class.java) + + @Provides + fun launcherApps(@ApplicationContext ctx: Context) = + ctx.requireSystemService(LauncherApps::class.java) + + @Provides + fun packageManager(@ApplicationContext ctx: Context) = + ctx.requireSystemService(PackageManager::class.java) + + @Provides + fun shortcutManager(@ApplicationContext ctx: Context) = + ctx.requireSystemService(ShortcutManager::class.java) + + @Provides + fun userManager(@ApplicationContext ctx: Context) = + ctx.requireSystemService(UserManager::class.java) + + @Provides + fun windowManager(@ApplicationContext ctx: Context) = + ctx.requireSystemService(WindowManager::class.java) +} diff --git a/java/src/com/android/intentresolver/inject/Qualifiers.kt b/java/src/com/android/intentresolver/inject/Qualifiers.kt new file mode 100644 index 00000000..2bfb1ff9 --- /dev/null +++ b/java/src/com/android/intentresolver/inject/Qualifiers.kt @@ -0,0 +1,27 @@ +/* + * Copyright (C) 2023 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.intentresolver.inject + +import javax.inject.Qualifier + +@Qualifier @MustBeDocumented @Retention(AnnotationRetention.RUNTIME) annotation class ActivityOwned + +@Qualifier @MustBeDocumented @Retention(AnnotationRetention.RUNTIME) annotation class Background + +@Qualifier @MustBeDocumented @Retention(AnnotationRetention.RUNTIME) annotation class Default + +@Qualifier @MustBeDocumented @Retention(AnnotationRetention.RUNTIME) annotation class Main diff --git a/java/tests/src/com/android/intentresolver/ChooserWrapperActivity.java b/java/tests/src/com/android/intentresolver/ChooserWrapperActivity.java index 8608cf72..8c2a15f1 100644 --- a/java/tests/src/com/android/intentresolver/ChooserWrapperActivity.java +++ b/java/tests/src/com/android/intentresolver/ChooserWrapperActivity.java @@ -51,8 +51,7 @@ import java.util.function.Consumer; * Simple wrapper around chooser activity to be able to initiate it under test. For more * information, see {@code com.android.internal.app.ChooserWrapperActivity}. */ -public class ChooserWrapperActivity - extends com.android.intentresolver.ChooserActivity implements IChooserWrapper { +public class ChooserWrapperActivity extends ChooserActivity implements IChooserWrapper { static final ChooserActivityOverrideData sOverrides = ChooserActivityOverrideData.getInstance(); private UsageStatsManager mUsm; diff --git a/java/tests/src/com/android/intentresolver/TestApplication.kt b/java/tests/src/com/android/intentresolver/TestApplication.kt index 849cfbab..b57fd4d9 100644 --- a/java/tests/src/com/android/intentresolver/TestApplication.kt +++ b/java/tests/src/com/android/intentresolver/TestApplication.kt @@ -16,12 +16,11 @@ package com.android.intentresolver -import android.app.Application import android.content.Context import android.os.UserHandle -class TestApplication : Application() { +class TestApplication : MainApplication() { // return the current context as a work profile doesn't really exist in these tests override fun createContextAsUser(user: UserHandle, flags: Int): Context = this -} \ No newline at end of file +} -- cgit v1.2.3-59-g8ed1b From 42fbb1defed76e2f712195b088c34fdff12a764e Mon Sep 17 00:00:00 2001 From: Mark Renouf Date: Mon, 11 Sep 2023 10:59:41 -0400 Subject: Remove SysUiFlags, provide injectable FeatureFlags Bug: 296633726 Test: atest IntentResolverUnitTests Change-Id: I523c9af094da30307904003c417dc50c6cb4a44b --- Android.bp | 22 --- .../flags/DebugFeatureFlagRepository.kt | 81 ----------- .../flags/FeatureFlagRepositoryFactory.kt | 30 ----- .../flags/FeatureFlagRepositoryFactory.kt | 24 ---- .../flags/ReleaseFeatureFlagRepository.kt | 31 ----- .../android/intentresolver/ChooserActivity.java | 28 ++-- .../intentresolver/ChooserRequestParameters.java | 4 +- .../intentresolver/flags/DeviceConfigProxy.kt | 33 ----- .../intentresolver/flags/FeatureFlagRepository.kt | 25 ---- java/src/com/android/intentresolver/flags/Flags.kt | 30 ----- .../intentresolver/inject/FeatureFlagsModule.kt | 15 +++ .../android/intentresolver/inject/Qualifiers.kt | 5 + .../ChooserActivityOverrideData.java | 3 - .../intentresolver/ChooserRequestParametersTest.kt | 7 +- .../intentresolver/ChooserWrapperActivity.java | 9 -- .../com/android/intentresolver/FeatureFlagRule.kt | 56 -------- .../android/intentresolver/RequireFeatureFlags.kt | 23 ---- .../intentresolver/TestFeatureFlagRepository.kt | 31 ----- .../UnbundledChooserActivityTest.java | 150 ++------------------- 19 files changed, 48 insertions(+), 559 deletions(-) delete mode 100644 java/src-debug/com/android/intentresolver/flags/DebugFeatureFlagRepository.kt delete mode 100644 java/src-debug/com/android/intentresolver/flags/FeatureFlagRepositoryFactory.kt delete mode 100644 java/src-release/com/android/intentresolver/flags/FeatureFlagRepositoryFactory.kt delete mode 100644 java/src-release/com/android/intentresolver/flags/ReleaseFeatureFlagRepository.kt delete mode 100644 java/src/com/android/intentresolver/flags/DeviceConfigProxy.kt delete mode 100644 java/src/com/android/intentresolver/flags/FeatureFlagRepository.kt delete mode 100644 java/src/com/android/intentresolver/flags/Flags.kt create mode 100644 java/src/com/android/intentresolver/inject/FeatureFlagsModule.kt delete mode 100644 java/tests/src/com/android/intentresolver/FeatureFlagRule.kt delete mode 100644 java/tests/src/com/android/intentresolver/RequireFeatureFlags.kt delete mode 100644 java/tests/src/com/android/intentresolver/TestFeatureFlagRepository.kt (limited to 'java/src') diff --git a/Android.bp b/Android.bp index 93f19179..674aae1f 100644 --- a/Android.bp +++ b/Android.bp @@ -19,33 +19,12 @@ package { default_visibility: [":__subpackages__"], } -filegroup { - name: "ReleaseSources", - srcs: [ - "java/src-release/**/*.kt", - ], -} - -filegroup { - name: "DebugSources", - srcs: [ - "java/src-debug/**/*.kt", - ], -} - java_defaults { name: "Java_Defaults", srcs: [ "java/src/**/*.java", "java/src/**/*.kt", - ":ReleaseSources", ], - product_variables: { - debuggable: { - srcs: [":DebugSources"], - exclude_srcs: [":ReleaseSources"], - } - }, resource_dirs: [ "java/res", ], @@ -78,7 +57,6 @@ android_library { "kotlinx-coroutines-android", "//external/kotlinc:kotlin-annotations", "guava", - "SystemUIFlagsLib", ], } diff --git a/java/src-debug/com/android/intentresolver/flags/DebugFeatureFlagRepository.kt b/java/src-debug/com/android/intentresolver/flags/DebugFeatureFlagRepository.kt deleted file mode 100644 index 5067c0ee..00000000 --- a/java/src-debug/com/android/intentresolver/flags/DebugFeatureFlagRepository.kt +++ /dev/null @@ -1,81 +0,0 @@ -/* - * Copyright (C) 2022 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.android.intentresolver.flags - -import android.util.SparseBooleanArray -import androidx.annotation.GuardedBy -import com.android.systemui.flags.BooleanFlag -import com.android.systemui.flags.FlagManager -import com.android.systemui.flags.ReleasedFlag -import com.android.systemui.flags.UnreleasedFlag -import javax.annotation.concurrent.ThreadSafe - -@ThreadSafe -internal class DebugFeatureFlagRepository( - private val flagManager: FlagManager, - private val deviceConfig: DeviceConfigProxy, -) : FeatureFlagRepository { - @GuardedBy("self") - private val cache = hashMapOf() - - override fun isEnabled(flag: UnreleasedFlag): Boolean = isFlagEnabled(flag) - - override fun isEnabled(flag: ReleasedFlag): Boolean = isFlagEnabled(flag) - - private fun isFlagEnabled(flag: BooleanFlag): Boolean { - synchronized(cache) { - cache[flag.name]?.let { return it } - } - val flagValue = readFlagValue(flag) - return synchronized(cache) { - // the first read saved in the cache wins - cache.getOrPut(flag.name) { flagValue } - } - } - - private fun readFlagValue(flag: BooleanFlag): Boolean { - val localOverride = runCatching { - flagManager.isEnabled(flag.name) - }.getOrDefault(null) - val remoteOverride = deviceConfig.isEnabled(flag) - - // Only check for teamfood if the default is false - // and there is no server override. - if (remoteOverride == null - && !flag.default - && localOverride == null - && !flag.isTeamfoodFlag - && flag.teamfood - ) { - return flagManager.isTeamfoodEnabled - } - return localOverride ?: remoteOverride ?: flag.default - } - - companion object { - /** keep in sync with [com.android.systemui.flags.Flags] */ - private const val TEAMFOOD_FLAG_NAME = "teamfood" - - private val BooleanFlag.isTeamfoodFlag: Boolean - get() = name == TEAMFOOD_FLAG_NAME - - private val FlagManager.isTeamfoodEnabled: Boolean - get() = runCatching { - isEnabled(TEAMFOOD_FLAG_NAME) ?: false - }.getOrDefault(false) - } -} diff --git a/java/src-debug/com/android/intentresolver/flags/FeatureFlagRepositoryFactory.kt b/java/src-debug/com/android/intentresolver/flags/FeatureFlagRepositoryFactory.kt deleted file mode 100644 index 4ddb0447..00000000 --- a/java/src-debug/com/android/intentresolver/flags/FeatureFlagRepositoryFactory.kt +++ /dev/null @@ -1,30 +0,0 @@ -/* - * Copyright (C) 2022 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.android.intentresolver.flags - -import android.content.Context -import android.os.Handler -import android.os.Looper -import com.android.systemui.flags.FlagManager - -class FeatureFlagRepositoryFactory { - fun create(context: Context): FeatureFlagRepository = - DebugFeatureFlagRepository( - FlagManager(context, Handler(Looper.getMainLooper())), - DeviceConfigProxy(), - ) -} diff --git a/java/src-release/com/android/intentresolver/flags/FeatureFlagRepositoryFactory.kt b/java/src-release/com/android/intentresolver/flags/FeatureFlagRepositoryFactory.kt deleted file mode 100644 index 6bf7579e..00000000 --- a/java/src-release/com/android/intentresolver/flags/FeatureFlagRepositoryFactory.kt +++ /dev/null @@ -1,24 +0,0 @@ -/* - * Copyright (C) 2022 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.android.intentresolver.flags - -import android.content.Context - -class FeatureFlagRepositoryFactory { - fun create(context: Context): FeatureFlagRepository = - ReleaseFeatureFlagRepository(DeviceConfigProxy()) -} diff --git a/java/src-release/com/android/intentresolver/flags/ReleaseFeatureFlagRepository.kt b/java/src-release/com/android/intentresolver/flags/ReleaseFeatureFlagRepository.kt deleted file mode 100644 index f9fa2c6a..00000000 --- a/java/src-release/com/android/intentresolver/flags/ReleaseFeatureFlagRepository.kt +++ /dev/null @@ -1,31 +0,0 @@ -/* - * Copyright (C) 2022 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.android.intentresolver.flags - -import com.android.systemui.flags.ReleasedFlag -import com.android.systemui.flags.UnreleasedFlag -import javax.annotation.concurrent.ThreadSafe - -@ThreadSafe -internal class ReleaseFeatureFlagRepository( - private val deviceConfig: DeviceConfigProxy, -) : FeatureFlagRepository { - override fun isEnabled(flag: UnreleasedFlag): Boolean = flag.default - - override fun isEnabled(flag: ReleasedFlag): Boolean = - deviceConfig.isEnabled(flag) ?: flag.default -} diff --git a/java/src/com/android/intentresolver/ChooserActivity.java b/java/src/com/android/intentresolver/ChooserActivity.java index 1e670a21..0101c046 100644 --- a/java/src/com/android/intentresolver/ChooserActivity.java +++ b/java/src/com/android/intentresolver/ChooserActivity.java @@ -23,9 +23,7 @@ import static android.app.admin.DevicePolicyResources.Strings.Core.RESOLVER_CANT import static android.app.admin.DevicePolicyResources.Strings.Core.RESOLVER_CROSS_PROFILE_BLOCKED_TITLE; import static android.stats.devicepolicy.nano.DevicePolicyEnums.RESOLVER_EMPTY_STATE_NO_SHARING_TO_PERSONAL; import static android.stats.devicepolicy.nano.DevicePolicyEnums.RESOLVER_EMPTY_STATE_NO_SHARING_TO_WORK; - import static androidx.lifecycle.LifecycleKt.getCoroutineScope; - import static com.android.internal.util.LatencyTracker.ACTION_LOAD_SHARE_SHEET; import android.annotation.IntDef; @@ -85,8 +83,6 @@ import com.android.intentresolver.contentpreview.BasePreviewViewModel; import com.android.intentresolver.contentpreview.ChooserContentPreviewUi; import com.android.intentresolver.contentpreview.HeadlineGeneratorImpl; import com.android.intentresolver.contentpreview.PreviewViewModel; -import com.android.intentresolver.flags.FeatureFlagRepository; -import com.android.intentresolver.flags.FeatureFlagRepositoryFactory; import com.android.intentresolver.grid.ChooserGridAdapter; import com.android.intentresolver.icons.DefaultTargetDataLoader; import com.android.intentresolver.icons.TargetDataLoader; @@ -119,6 +115,8 @@ import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; import java.util.function.Consumer; +import javax.inject.Inject; + /** * The Chooser Activity handles intent resolution specifically for sharing intents - * for example, as generated by {@see android.content.Intent#createChooser(Intent, CharSequence)}. @@ -175,6 +173,8 @@ public class ChooserActivity extends Hilt_ChooserActivity implements @Retention(RetentionPolicy.SOURCE) public @interface ShareTargetType {} + @Inject public FeatureFlags mFeatureFlags; + private ChooserIntegratedDeviceComponents mIntegratedDeviceComponents; /* TODO: this is `nullable` because we have to defer the assignment til onCreate(). We make the @@ -188,7 +188,6 @@ public class ChooserActivity extends Hilt_ChooserActivity implements private ChooserRefinementManager mRefinementManager; - private FeatureFlagRepository mFeatureFlagRepository; private ChooserContentPreviewUi mChooserContentPreviewUi; private boolean mShouldDisplayLandscape; @@ -242,15 +241,11 @@ public class ChooserActivity extends Hilt_ChooserActivity implements getEventLog().logSharesheetTriggered(); - mFeatureFlagRepository = createFeatureFlagRepository(); - mIntegratedDeviceComponents = getIntegratedDeviceComponents(); - try { mChooserRequest = new ChooserRequestParameters( getIntent(), getReferrerPackageName(), - getReferrer(), - mFeatureFlagRepository); + getReferrer()); } catch (IllegalArgumentException e) { Log.e(TAG, "Caller provided invalid Chooser request parameters", e); finish(); @@ -265,7 +260,7 @@ public class ChooserActivity extends Hilt_ChooserActivity implements createProfileRecords( new AppPredictorFactory( - getApplicationContext(), + this, // TODO: Review w/team, possible side effects? mChooserRequest.getSharedText(), mChooserRequest.getTargetIntentFilter()), mChooserRequest.getTargetIntentFilter()); @@ -283,7 +278,10 @@ public class ChooserActivity extends Hilt_ChooserActivity implements new DefaultTargetDataLoader(this, getLifecycle(), false), /* safeForwardingMode= */ true); - mFeatureFlagRepository = createFeatureFlagRepository(); + if (mFeatureFlags.exampleNewSharingMethod()) { + // Sample flag usage + } + mIntegratedDeviceComponents = getIntegratedDeviceComponents(); mRefinementManager = new ViewModelProvider(this).get(ChooserRefinementManager.class); @@ -371,10 +369,6 @@ public class ChooserActivity extends Hilt_ChooserActivity implements return R.style.Theme_DeviceDefault_Chooser; } - protected FeatureFlagRepository createFeatureFlagRepository() { - return new FeatureFlagRepositoryFactory().create(getApplicationContext()); - } - private void createProfileRecords( AppPredictorFactory factory, IntentFilter targetIntentFilter) { UserHandle mainUserHandle = getPersonalProfileUserHandle(); @@ -395,7 +389,7 @@ public class ChooserActivity extends Hilt_ChooserActivity implements ShortcutLoader shortcutLoader = ActivityManager.isLowRamDeviceStatic() ? null : createShortcutLoader( - getApplicationContext(), + this, // TODO: Review w/team, possible side effects? appPredictor, userHandle, targetIntentFilter, diff --git a/java/src/com/android/intentresolver/ChooserRequestParameters.java b/java/src/com/android/intentresolver/ChooserRequestParameters.java index 5157986b..b05d51b2 100644 --- a/java/src/com/android/intentresolver/ChooserRequestParameters.java +++ b/java/src/com/android/intentresolver/ChooserRequestParameters.java @@ -32,7 +32,6 @@ import android.text.TextUtils; import android.util.Log; import android.util.Pair; -import com.android.intentresolver.flags.FeatureFlagRepository; import com.android.intentresolver.util.UriFilters; import com.google.common.collect.ImmutableList; @@ -104,8 +103,7 @@ public class ChooserRequestParameters { public ChooserRequestParameters( final Intent clientIntent, String referrerPackageName, - final Uri referrer, - FeatureFlagRepository featureFlags) { + final Uri referrer) { final Intent requestedTarget = parseTargetIntentExtra( clientIntent.getParcelableExtra(Intent.EXTRA_INTENT)); mTarget = intentWithModifiedLaunchFlags(requestedTarget); diff --git a/java/src/com/android/intentresolver/flags/DeviceConfigProxy.kt b/java/src/com/android/intentresolver/flags/DeviceConfigProxy.kt deleted file mode 100644 index d1494fe7..00000000 --- a/java/src/com/android/intentresolver/flags/DeviceConfigProxy.kt +++ /dev/null @@ -1,33 +0,0 @@ -/* - * Copyright (C) 2022 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.android.intentresolver.flags - -import android.provider.DeviceConfig -import com.android.systemui.flags.ParcelableFlag - -internal class DeviceConfigProxy { - fun isEnabled(flag: ParcelableFlag): Boolean? { - return runCatching { - val hasProperty = DeviceConfig.getProperty(flag.namespace, flag.name) != null - if (hasProperty) { - DeviceConfig.getBoolean(flag.namespace, flag.name, flag.default) - } else { - null - } - }.getOrDefault(null) - } -} diff --git a/java/src/com/android/intentresolver/flags/FeatureFlagRepository.kt b/java/src/com/android/intentresolver/flags/FeatureFlagRepository.kt deleted file mode 100644 index 5b5d769c..00000000 --- a/java/src/com/android/intentresolver/flags/FeatureFlagRepository.kt +++ /dev/null @@ -1,25 +0,0 @@ -/* - * Copyright (C) 2022 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.android.intentresolver.flags - -import com.android.systemui.flags.ReleasedFlag -import com.android.systemui.flags.UnreleasedFlag - -interface FeatureFlagRepository { - fun isEnabled(flag: UnreleasedFlag): Boolean - fun isEnabled(flag: ReleasedFlag): Boolean -} diff --git a/java/src/com/android/intentresolver/flags/Flags.kt b/java/src/com/android/intentresolver/flags/Flags.kt deleted file mode 100644 index 2c20d341..00000000 --- a/java/src/com/android/intentresolver/flags/Flags.kt +++ /dev/null @@ -1,30 +0,0 @@ -/* - * Copyright (C) 2022 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.android.intentresolver.flags - -import com.android.systemui.flags.ReleasedFlag -import com.android.systemui.flags.UnreleasedFlag - -// Flag id, name and namespace should be kept in sync with [com.android.systemui.flags.Flags] to -// make the flags available in the flag flipper app (see go/sysui-flags). -// All flags added should be included in UnbundledChooserActivityTest.ALL_FLAGS. -object Flags { - private fun releasedFlag(name: String) = ReleasedFlag(name, "systemui") - - private fun unreleasedFlag(name: String, teamfood: Boolean = false) = - UnreleasedFlag(name, "systemui", teamfood) -} diff --git a/java/src/com/android/intentresolver/inject/FeatureFlagsModule.kt b/java/src/com/android/intentresolver/inject/FeatureFlagsModule.kt new file mode 100644 index 00000000..05cf2104 --- /dev/null +++ b/java/src/com/android/intentresolver/inject/FeatureFlagsModule.kt @@ -0,0 +1,15 @@ +package com.android.intentresolver.inject + +import com.android.intentresolver.FeatureFlags +import com.android.intentresolver.FeatureFlagsImpl +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent + +@Module +@InstallIn(SingletonComponent::class) +object FeatureFlagsModule { + + @Provides fun featureFlags(): FeatureFlags = FeatureFlagsImpl() +} diff --git a/java/src/com/android/intentresolver/inject/Qualifiers.kt b/java/src/com/android/intentresolver/inject/Qualifiers.kt index 2bfb1ff9..fca1e896 100644 --- a/java/src/com/android/intentresolver/inject/Qualifiers.kt +++ b/java/src/com/android/intentresolver/inject/Qualifiers.kt @@ -20,6 +20,11 @@ import javax.inject.Qualifier @Qualifier @MustBeDocumented @Retention(AnnotationRetention.RUNTIME) annotation class ActivityOwned +@Qualifier +@MustBeDocumented +@Retention(AnnotationRetention.RUNTIME) +annotation class ApplicationOwned + @Qualifier @MustBeDocumented @Retention(AnnotationRetention.RUNTIME) annotation class Background @Qualifier @MustBeDocumented @Retention(AnnotationRetention.RUNTIME) annotation class Default diff --git a/java/tests/src/com/android/intentresolver/ChooserActivityOverrideData.java b/java/tests/src/com/android/intentresolver/ChooserActivityOverrideData.java index 84f5124c..5b938aa1 100644 --- a/java/tests/src/com/android/intentresolver/ChooserActivityOverrideData.java +++ b/java/tests/src/com/android/intentresolver/ChooserActivityOverrideData.java @@ -29,7 +29,6 @@ import android.os.UserHandle; import com.android.intentresolver.AbstractMultiProfilePagerAdapter.CrossProfileIntentsChecker; import com.android.intentresolver.chooser.TargetInfo; import com.android.intentresolver.contentpreview.ImageLoader; -import com.android.intentresolver.flags.FeatureFlagRepository; import com.android.intentresolver.logging.EventLog; import com.android.intentresolver.shortcuts.ShortcutLoader; @@ -77,7 +76,6 @@ public class ChooserActivityOverrideData { public WorkProfileAvailabilityManager mWorkProfileAvailability; public CrossProfileIntentsChecker mCrossProfileIntentsChecker; public PackageManager packageManager; - public FeatureFlagRepository featureFlagRepository; public void reset() { onSafelyStartInternalCallback = null; @@ -127,7 +125,6 @@ public class ChooserActivityOverrideData { mCrossProfileIntentsChecker = mock(CrossProfileIntentsChecker.class); when(mCrossProfileIntentsChecker.hasCrossProfileIntents(any(), anyInt(), anyInt())) .thenAnswer(invocation -> hasCrossProfileIntents); - featureFlagRepository = null; } private ChooserActivityOverrideData() {} diff --git a/java/tests/src/com/android/intentresolver/ChooserRequestParametersTest.kt b/java/tests/src/com/android/intentresolver/ChooserRequestParametersTest.kt index 331d1c21..90f6cf93 100644 --- a/java/tests/src/com/android/intentresolver/ChooserRequestParametersTest.kt +++ b/java/tests/src/com/android/intentresolver/ChooserRequestParametersTest.kt @@ -29,7 +29,6 @@ import org.junit.runner.RunWith @RunWith(AndroidJUnit4::class) class ChooserRequestParametersTest { - val flags = TestFeatureFlagRepository(mapOf()) @Test fun testChooserActions() { @@ -41,7 +40,7 @@ class ChooserRequestParametersTest { putExtra(Intent.EXTRA_INTENT, intent) putExtra(Intent.EXTRA_CHOOSER_CUSTOM_ACTIONS, actions) } - val request = ChooserRequestParameters(chooserIntent, "", Uri.EMPTY, flags) + val request = ChooserRequestParameters(chooserIntent, "", Uri.EMPTY) assertThat(request.chooserActions).containsExactlyElementsIn(actions).inOrder() } @@ -50,7 +49,7 @@ class ChooserRequestParametersTest { val intent = Intent(Intent.ACTION_SEND) val chooserIntent = Intent(Intent.ACTION_CHOOSER).apply { putExtra(Intent.EXTRA_INTENT, intent) } - val request = ChooserRequestParameters(chooserIntent, "", Uri.EMPTY, flags) + val request = ChooserRequestParameters(chooserIntent, "", Uri.EMPTY) assertThat(request.chooserActions).isEmpty() } @@ -64,7 +63,7 @@ class ChooserRequestParametersTest { putExtra(Intent.EXTRA_CHOOSER_CUSTOM_ACTIONS, chooserActions) } - val request = ChooserRequestParameters(chooserIntent, "", Uri.EMPTY, flags) + val request = ChooserRequestParameters(chooserIntent, "", Uri.EMPTY) val expectedActions = chooserActions.sliceArray(0 until 5) assertThat(request.chooserActions).containsExactlyElementsIn(expectedActions).inOrder() diff --git a/java/tests/src/com/android/intentresolver/ChooserWrapperActivity.java b/java/tests/src/com/android/intentresolver/ChooserWrapperActivity.java index 8c2a15f1..578b9557 100644 --- a/java/tests/src/com/android/intentresolver/ChooserWrapperActivity.java +++ b/java/tests/src/com/android/intentresolver/ChooserWrapperActivity.java @@ -37,7 +37,6 @@ import androidx.lifecycle.ViewModelProvider; import com.android.intentresolver.AbstractMultiProfilePagerAdapter.CrossProfileIntentsChecker; import com.android.intentresolver.chooser.DisplayResolveInfo; import com.android.intentresolver.chooser.TargetInfo; -import com.android.intentresolver.flags.FeatureFlagRepository; import com.android.intentresolver.grid.ChooserGridAdapter; import com.android.intentresolver.icons.TargetDataLoader; import com.android.intentresolver.logging.EventLog; @@ -282,12 +281,4 @@ public class ChooserWrapperActivity extends ChooserActivity implements IChooserW return super.createShortcutLoader( context, appPredictor, userHandle, targetIntentFilter, callback); } - - @Override - protected FeatureFlagRepository createFeatureFlagRepository() { - if (sOverrides.featureFlagRepository != null) { - return sOverrides.featureFlagRepository; - } - return super.createFeatureFlagRepository(); - } } diff --git a/java/tests/src/com/android/intentresolver/FeatureFlagRule.kt b/java/tests/src/com/android/intentresolver/FeatureFlagRule.kt deleted file mode 100644 index 3fa01bcc..00000000 --- a/java/tests/src/com/android/intentresolver/FeatureFlagRule.kt +++ /dev/null @@ -1,56 +0,0 @@ -/* - * Copyright (C) 2023 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.android.intentresolver - -import com.android.systemui.flags.BooleanFlag -import org.junit.rules.TestRule -import org.junit.runner.Description -import org.junit.runners.model.Statement - -/** - * Ignores tests annotated with [RequireFeatureFlags] which flag requirements does not - * meet in the active flag set. - * @param flags active flag set - */ -internal class FeatureFlagRule(flags: Map) : TestRule { - private val flags = flags.entries.fold(HashMap()) { map, (key, value) -> - map.apply { - put(key.name, value) - } - } - private val skippingStatement = object : Statement() { - override fun evaluate() = Unit - } - - override fun apply(base: Statement, description: Description): Statement { - val annotation = description.annotations.firstOrNull { - it is RequireFeatureFlags - } as? RequireFeatureFlags - ?: return base - - if (annotation.flags.size != annotation.values.size) { - error("${description.className}#${description.methodName}: inconsistent number of" + - " flags and values in $annotation") - } - for (i in annotation.flags.indices) { - val flag = annotation.flags[i] - val value = annotation.values[i] - if (flags.getOrDefault(flag, !value) != value) return skippingStatement - } - return base - } -} diff --git a/java/tests/src/com/android/intentresolver/RequireFeatureFlags.kt b/java/tests/src/com/android/intentresolver/RequireFeatureFlags.kt deleted file mode 100644 index 1ddf7462..00000000 --- a/java/tests/src/com/android/intentresolver/RequireFeatureFlags.kt +++ /dev/null @@ -1,23 +0,0 @@ -/* - * Copyright (C) 2023 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.android.intentresolver - -/** - * Specifies expected feature flag values for a test. - */ -@Target(AnnotationTarget.FUNCTION) -annotation class RequireFeatureFlags(val flags: Array, val values: BooleanArray) diff --git a/java/tests/src/com/android/intentresolver/TestFeatureFlagRepository.kt b/java/tests/src/com/android/intentresolver/TestFeatureFlagRepository.kt deleted file mode 100644 index b9047712..00000000 --- a/java/tests/src/com/android/intentresolver/TestFeatureFlagRepository.kt +++ /dev/null @@ -1,31 +0,0 @@ -/* - * Copyright (C) 2023 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.android.intentresolver - -import com.android.intentresolver.flags.FeatureFlagRepository -import com.android.systemui.flags.BooleanFlag -import com.android.systemui.flags.ReleasedFlag -import com.android.systemui.flags.UnreleasedFlag - -class TestFeatureFlagRepository( - private val overrides: Map -) : FeatureFlagRepository { - override fun isEnabled(flag: UnreleasedFlag): Boolean = getValue(flag) - override fun isEnabled(flag: ReleasedFlag): Boolean = getValue(flag) - - private fun getValue(flag: BooleanFlag) = overrides.getOrDefault(flag, flag.default) -} diff --git a/java/tests/src/com/android/intentresolver/UnbundledChooserActivityTest.java b/java/tests/src/com/android/intentresolver/UnbundledChooserActivityTest.java index aca78604..59357843 100644 --- a/java/tests/src/com/android/intentresolver/UnbundledChooserActivityTest.java +++ b/java/tests/src/com/android/intentresolver/UnbundledChooserActivityTest.java @@ -17,7 +17,6 @@ package com.android.intentresolver; import static android.app.Activity.RESULT_OK; - import static androidx.test.espresso.Espresso.onView; import static androidx.test.espresso.action.ViewActions.click; import static androidx.test.espresso.action.ViewActions.longClick; @@ -29,7 +28,6 @@ import static androidx.test.espresso.matcher.ViewMatchers.isDisplayed; import static androidx.test.espresso.matcher.ViewMatchers.withEffectiveVisibility; import static androidx.test.espresso.matcher.ViewMatchers.withId; import static androidx.test.espresso.matcher.ViewMatchers.withText; - import static com.android.intentresolver.ChooserActivity.TARGET_TYPE_CHOOSER_TARGET; import static com.android.intentresolver.ChooserActivity.TARGET_TYPE_DEFAULT; import static com.android.intentresolver.ChooserActivity.TARGET_TYPE_SHORTCUTS_FROM_PREDICTION_SERVICE; @@ -37,11 +35,8 @@ import static com.android.intentresolver.ChooserActivity.TARGET_TYPE_SHORTCUTS_F import static com.android.intentresolver.ChooserListAdapter.CALLER_TARGET_SCORE_BOOST; import static com.android.intentresolver.ChooserListAdapter.SHORTCUT_TARGET_SCORE_BOOST; import static com.android.intentresolver.MatcherUtils.first; - import static com.google.common.truth.Truth.assertThat; - import static junit.framework.Assert.assertNull; - import static org.hamcrest.CoreMatchers.allOf; import static org.hamcrest.CoreMatchers.is; import static org.hamcrest.CoreMatchers.not; @@ -105,7 +100,6 @@ import android.view.View; import android.view.WindowManager; import android.widget.TextView; -import androidx.annotation.CallSuper; import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.recyclerview.widget.GridLayoutManager; @@ -122,7 +116,6 @@ import com.android.intentresolver.logging.EventLog; import com.android.intentresolver.shortcuts.ShortcutLoader; import com.android.internal.config.sysui.SystemUiDeviceConfigFlags; import com.android.internal.logging.nano.MetricsProto.MetricsEvent; -import com.android.systemui.flags.BooleanFlag; import org.hamcrest.Description; import org.hamcrest.Matcher; @@ -131,8 +124,6 @@ import org.junit.Before; import org.junit.Ignore; import org.junit.Rule; import org.junit.Test; -import org.junit.rules.RuleChain; -import org.junit.rules.TestRule; import org.junit.runner.RunWith; import org.junit.runners.Parameterized; import org.mockito.ArgumentCaptor; @@ -155,27 +146,14 @@ import java.util.function.Consumer; import java.util.function.Function; /** - * Instrumentation tests for the IntentResolver module's Sharesheet (ChooserActivity). - * TODO: remove methods that supported running these tests against arbitrary ChooserActivity - * subclasses. Those were left over from an earlier version where IntentResolver's ChooserActivity - * inherited from the framework version at com.android.internal.app.ChooserActivity, and this test - * file inherited from the framework's version as well. Once the migration to the IntentResolver - * package is complete, that aspect of the test design can revert to match the style of the - * framework tests prior to ag/16482932. - * TODO: this can simply be renamed to "ChooserActivityTest" if that's ever unambiguous (i.e., if - * there's no risk of confusion with the framework tests that currently share the same name). + * Instrumentation tests for ChooserActivity. + *

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

*/ @RunWith(Parameterized.class) public class UnbundledChooserActivityTest { - /* -------- - * Subclasses should copy the following section verbatim (or alternatively could specify some - * additional @Parameterized.Parameters, as long as the correct parameters are used to - * initialize the ChooserActivityTest). The subclasses should also be @RunWith the - * `Parameterized` runner. - * -------- - */ - private static final UserHandle PERSONAL_USER_HANDLE = InstrumentationRegistry .getInstrumentation().getTargetContext().getUser(); private static final Function DEFAULT_PM = pm -> pm; @@ -186,56 +164,18 @@ public class UnbundledChooserActivityTest { return mock; }; - private static final List ALL_FLAGS = - Arrays.asList(); - - private static final Map ALL_FLAGS_OFF = - createAllFlagsOverride(false); - private static final Map ALL_FLAGS_ON = - createAllFlagsOverride(true); - @Parameterized.Parameters public static Collection packageManagers() { - if (ALL_FLAGS.isEmpty()) { - // No flags to toggle between, so just two configurations. - return Arrays.asList(new Object[][] { - // Default PackageManager and all flags off - { DEFAULT_PM, ALL_FLAGS_OFF}, - // No App Prediction Service and all flags off - { NO_APP_PREDICTION_SERVICE_PM, ALL_FLAGS_OFF }, - }); - } return Arrays.asList(new Object[][] { - // Default PackageManager and all flags off - { DEFAULT_PM, ALL_FLAGS_OFF}, - // Default PackageManager and all flags on - { DEFAULT_PM, ALL_FLAGS_ON}, - // No App Prediction Service and all flags off - { NO_APP_PREDICTION_SERVICE_PM, ALL_FLAGS_OFF }, - // No App Prediction Service and all flags on - { NO_APP_PREDICTION_SERVICE_PM, ALL_FLAGS_ON } + // Default PackageManager + { DEFAULT_PM }, + // No App Prediction Service + { NO_APP_PREDICTION_SERVICE_PM} }); } - private static Map createAllFlagsOverride(boolean value) { - HashMap overrides = new HashMap<>(ALL_FLAGS.size()); - for (BooleanFlag flag : ALL_FLAGS) { - overrides.put(flag, value); - } - return overrides; - } - - /* -------- - * Subclasses can override the following methods to customize test behavior. - * -------- - */ - - /** - * Perform any necessary per-test initialization steps (subclasses may add additional steps - * before and/or after calling up to the superclass implementation). - */ - @CallSuper - protected void setup() { + @Before + public void setUp() { // TODO: use the other form of `adoptShellPermissionIdentity()` where we explicitly list the // permissions we require (which we'll read from the manifest at runtime). InstrumentationRegistry @@ -244,67 +184,11 @@ public class UnbundledChooserActivityTest { .adoptShellPermissionIdentity(); cleanOverrideData(); - ChooserActivityOverrideData.getInstance().featureFlagRepository = - new TestFeatureFlagRepository(mFlags); - } - - /** - * Given an intent that was constructed in a test, perform any additional configuration to - * specify the appropriate concrete ChooserActivity subclass. The activity launched by this - * intent must descend from android.intentresolver.ChooserActivity (for our ActivityTestRule), and - * must also implement the android.intentresolver.IChooserWrapper interface (since test code will - * assume the ability to make unsafe downcasts). - */ - protected Intent getConcreteIntentForLaunch(Intent clientIntent) { - clientIntent.setClass( - InstrumentationRegistry.getInstrumentation().getTargetContext(), - com.android.intentresolver.ChooserWrapperActivity.class); - return clientIntent; - } - - /** - * Whether {@code #testIsAppPredictionServiceAvailable} should verify the behavior after - * changing the availability conditions at runtime. In the unbundled chooser, the availability - * is cached at start and will never be re-evaluated. - * TODO: remove when we no longer want to test the system's on-the-fly evaluation. - */ - protected boolean shouldTestTogglingAppPredictionServiceAvailabilityAtRuntime() { - return false; } - /* -------- - * The code in this section is unorthodox and can be simplified/reverted when we no longer need - * to support the parallel chooser implementations. - * -------- - */ - @Rule - public final TestRule mRule; - - // Shared test code references the activity under test as ChooserActivity, the common ancestor - // of any (inheritance-based) chooser implementation. For testing purposes, that activity will - // usually be cast to IChooserWrapper to expose instrumentation. - private ActivityTestRule mActivityRule = - new ActivityTestRule<>(ChooserActivity.class, false, false) { - @Override - public ChooserActivity launchActivity(Intent clientIntent) { - return super.launchActivity(getConcreteIntentForLaunch(clientIntent)); - } - }; - - @Before - public final void doPolymorphicSetup() { - // The base class needs a @Before-annotated setup for when it runs against the system - // chooser, while subclasses need to be able to specify their own setup behavior. Notably - // the unbundled chooser, running in user-space, needs to take additional steps before it - // can run #cleanOverrideData() (which writes to DeviceConfig). - setup(); - } - - /* -------- - * Subclasses can ignore the remaining code and inherit the full suite of tests. - * -------- - */ + public ActivityTestRule mActivityRule = + new ActivityTestRule<>(ChooserWrapperActivity.class, false, false); private static final String TEST_MIME_TYPE = "application/TestType"; @@ -313,18 +197,10 @@ public class UnbundledChooserActivityTest { private static final int CONTENT_PREVIEW_TEXT = 3; private final Function mPackageManagerOverride; - private final Map mFlags; - public UnbundledChooserActivityTest( - Function packageManagerOverride, - Map flags) { + Function packageManagerOverride) { mPackageManagerOverride = packageManagerOverride; - mFlags = flags; - - mRule = RuleChain - .outerRule(new FeatureFlagRule(flags)) - .around(mActivityRule); } private void setDeviceConfigProperty( -- cgit v1.2.3-59-g8ed1b From 7acb0306549d15d756aca1628fddc91a9bfc354a Mon Sep 17 00:00:00 2001 From: Mark Renouf Date: Tue, 12 Sep 2023 19:57:07 -0400 Subject: Remove obsolete code left over from the system process None of this is necessary now that the activity is running in a normal application process. The 'name-based' version of getSharedPreferences works fine. Bug: 300157408 Flag: EXEMPT Test: manually; share content, long-press to pin/unpin; verify across usages and reboots Change-Id: Id056cd377babf397b323b40c52614cd326d5c79e --- .../src/com/android/intentresolver/ChooserActivity.java | 17 +---------------- 1 file changed, 1 insertion(+), 16 deletions(-) (limited to 'java/src') diff --git a/java/src/com/android/intentresolver/ChooserActivity.java b/java/src/com/android/intentresolver/ChooserActivity.java index 0101c046..ebe6f04b 100644 --- a/java/src/com/android/intentresolver/ChooserActivity.java +++ b/java/src/com/android/intentresolver/ChooserActivity.java @@ -51,11 +51,9 @@ import android.database.Cursor; import android.graphics.Insets; import android.net.Uri; import android.os.Bundle; -import android.os.Environment; import android.os.SystemClock; import android.os.UserHandle; import android.os.UserManager; -import android.os.storage.StorageManager; import android.service.chooser.ChooserTarget; import android.util.Log; import android.util.Slog; @@ -100,7 +98,6 @@ import com.android.internal.logging.nano.MetricsProto.MetricsEvent; import dagger.hilt.android.AndroidEntryPoint; -import java.io.File; import java.lang.annotation.Retention; import java.lang.annotation.RetentionPolicy; import java.text.Collator; @@ -421,19 +418,7 @@ public class ChooserActivity extends Hilt_ChooserActivity implements } static SharedPreferences getPinnedSharedPrefs(Context context) { - // The code below is because in the android:ui process, no one can hear you scream. - // The package info in the context isn't initialized in the way it is for normal apps, - // so the standard, name-based context.getSharedPreferences doesn't work. Instead, we - // build the path manually below using the same policy that appears in ContextImpl. - // This fails silently under the hood if there's a problem, so if we find ourselves in - // the case where we don't have access to credential encrypted storage we just won't - // have our pinned target info. - final File prefsFile = new File(new File( - Environment.getDataUserCePackageDirectory(StorageManager.UUID_PRIVATE_INTERNAL, - context.getUserId(), context.getPackageName()), - "shared_prefs"), - PINNED_SHARED_PREFS_NAME + ".xml"); - return context.getSharedPreferences(prefsFile, MODE_PRIVATE); + return context.getSharedPreferences(PINNED_SHARED_PREFS_NAME, MODE_PRIVATE); } @Override -- cgit v1.2.3-59-g8ed1b From a7765dc1e6172a4b9a296c9788ebfa3be02ee230 Mon Sep 17 00:00:00 2001 From: Andrey Epin Date: Tue, 12 Sep 2023 19:48:47 -0700 Subject: PreviewDataProvider to close content resolver's cursors Fix: 300182416 Test: atest IntentResolverUnitTests:PreviewDataProviderTest Change-Id: If4ee654683ed3e58001c6c07840d86dc2e24a5fc --- .../contentpreview/PreviewDataProvider.kt | 68 +++++++++++----------- .../contentpreview/PreviewDataProviderTest.kt | 22 ++++++- 2 files changed, 55 insertions(+), 35 deletions(-) (limited to 'java/src') diff --git a/java/src/com/android/intentresolver/contentpreview/PreviewDataProvider.kt b/java/src/com/android/intentresolver/contentpreview/PreviewDataProvider.kt index 9f1cc6c1..bb303c7b 100644 --- a/java/src/com/android/intentresolver/contentpreview/PreviewDataProvider.kt +++ b/java/src/com/android/intentresolver/contentpreview/PreviewDataProvider.kt @@ -264,44 +264,46 @@ constructor( private val query by lazy { readQueryResult() } - private fun readQueryResult(): QueryResult { - val cursor = - contentResolver.querySafe(uri)?.takeIf { it.moveToFirst() } ?: return QueryResult() - - var flagColIdx = -1 - var displayIconUriColIdx = -1 - var nameColIndex = -1 - var titleColIndex = -1 - // TODO: double-check why Cursor#getColumnInded didn't work - cursor.columnNames.forEachIndexed { i, columnName -> - when (columnName) { - DocumentsContract.Document.COLUMN_FLAGS -> flagColIdx = i - MediaMetadata.METADATA_KEY_DISPLAY_ICON_URI -> displayIconUriColIdx = i - OpenableColumns.DISPLAY_NAME -> nameColIndex = i - Downloads.Impl.COLUMN_TITLE -> titleColIndex = i + private fun readQueryResult(): QueryResult = + contentResolver.querySafe(uri)?.use { cursor -> + if (!cursor.moveToFirst()) return@use null + + var flagColIdx = -1 + var displayIconUriColIdx = -1 + var nameColIndex = -1 + var titleColIndex = -1 + // TODO: double-check why Cursor#getColumnInded didn't work + cursor.columnNames.forEachIndexed { i, columnName -> + when (columnName) { + DocumentsContract.Document.COLUMN_FLAGS -> flagColIdx = i + MediaMetadata.METADATA_KEY_DISPLAY_ICON_URI -> displayIconUriColIdx = i + OpenableColumns.DISPLAY_NAME -> nameColIndex = i + Downloads.Impl.COLUMN_TITLE -> titleColIndex = i + } } - } - - val supportsThumbnail = - flagColIdx >= 0 && ((cursor.getInt(flagColIdx) and FLAG_SUPPORTS_THUMBNAIL) != 0) - var title = "" - if (nameColIndex >= 0) { - title = cursor.getString(nameColIndex) ?: "" - } - if (TextUtils.isEmpty(title) && titleColIndex >= 0) { - title = cursor.getString(titleColIndex) ?: "" - } + val supportsThumbnail = + flagColIdx >= 0 && + ((cursor.getInt(flagColIdx) and FLAG_SUPPORTS_THUMBNAIL) != 0) - val iconUri = - if (displayIconUriColIdx >= 0) { - cursor.getString(displayIconUriColIdx)?.let(Uri::parse) - } else { - null + var title = "" + if (nameColIndex >= 0) { + title = cursor.getString(nameColIndex) ?: "" + } + if (TextUtils.isEmpty(title) && titleColIndex >= 0) { + title = cursor.getString(titleColIndex) ?: "" } - return QueryResult(supportsThumbnail, title, iconUri) - } + val iconUri = + if (displayIconUriColIdx >= 0) { + cursor.getString(displayIconUriColIdx)?.let(Uri::parse) + } else { + null + } + + QueryResult(supportsThumbnail, title, iconUri) + } + ?: QueryResult() } private class QueryResult( diff --git a/java/tests/src/com/android/intentresolver/contentpreview/PreviewDataProviderTest.kt b/java/tests/src/com/android/intentresolver/contentpreview/PreviewDataProviderTest.kt index 6599baa9..4a8c1392 100644 --- a/java/tests/src/com/android/intentresolver/contentpreview/PreviewDataProviderTest.kt +++ b/java/tests/src/com/android/intentresolver/contentpreview/PreviewDataProviderTest.kt @@ -192,8 +192,9 @@ class PreviewDataProviderTest { val uri = Uri.parse("content://org.pkg.app/test.pdf") val targetIntent = Intent(Intent.ACTION_SEND).apply { putExtra(Intent.EXTRA_STREAM, uri) } whenever(contentResolver.getType(uri)).thenReturn("application/pdf") - whenever(contentResolver.query(uri, METADATA_COLUMNS, null, null)) - .thenReturn(MatrixCursor(columns).apply { addRow(values) }) + val cursor = MatrixCursor(columns).apply { addRow(values) } + whenever(contentResolver.query(uri, METADATA_COLUMNS, null, null)).thenReturn(cursor) + val testSubject = PreviewDataProvider(testScope, targetIntent, contentResolver, mimeTypeClassifier) @@ -202,6 +203,23 @@ class PreviewDataProviderTest { assertThat(testSubject.firstFileInfo?.uri).isEqualTo(uri) assertThat(testSubject.firstFileInfo?.previewUri).isNotNull() verify(contentResolver, times(1)).getType(any()) + assertThat(cursor.isClosed).isTrue() + } + + @Test + fun test_emptyQueryResult_cursorGetsClosed() { + val uri = Uri.parse("content://org.pkg.app/test.pdf") + val targetIntent = Intent(Intent.ACTION_SEND).apply { putExtra(Intent.EXTRA_STREAM, uri) } + whenever(contentResolver.getType(uri)).thenReturn("application/pdf") + val cursor = MatrixCursor(emptyArray()) + whenever(contentResolver.query(uri, METADATA_COLUMNS, null, null)).thenReturn(cursor) + + val testSubject = + PreviewDataProvider(testScope, targetIntent, contentResolver, mimeTypeClassifier) + + assertThat(testSubject.previewType).isEqualTo(ContentPreviewType.CONTENT_PREVIEW_FILE) + verify(contentResolver, times(1)).query(uri, METADATA_COLUMNS, null, null) + assertThat(cursor.isClosed).isTrue() } @Test -- cgit v1.2.3-59-g8ed1b From 4fe01c7ca647194636c592949516677e68fc4a80 Mon Sep 17 00:00:00 2001 From: Mark Renouf Date: Wed, 13 Sep 2023 11:10:10 -0400 Subject: Extract an interface for EventLog, rename implementation This is a purely automated refactor with no functional changes. Test: atest IntentResolverUnitTests Bug: 300157408 Flag: EXEMPT Change-Id: I7440a6bb1035999f202e799402f09bcb1d657ecc --- .../intentresolver/ChooserActionFactory.java | 26 +- .../android/intentresolver/ChooserActivity.java | 3 +- .../android/intentresolver/logging/EventLog.java | 539 --------------------- .../com/android/intentresolver/logging/EventLog.kt | 74 +++ .../intentresolver/logging/EventLogImpl.java | 514 ++++++++++++++++++++ .../intentresolver/logging/FrameworkStatsLogger.kt | 50 ++ .../model/AbstractResolverComparator.java | 4 +- .../AppPredictionServiceResolverComparator.java | 2 +- .../ResolverRankerServiceResolverComparator.java | 13 +- .../intentresolver/ChooserActionFactoryTest.kt | 3 +- .../ChooserActivityOverrideData.java | 6 +- .../intentresolver/ChooserListAdapterTest.kt | 4 +- .../intentresolver/ChooserWrapperActivity.java | 4 +- .../android/intentresolver/IChooserWrapper.java | 4 +- .../UnbundledChooserActivityTest.java | 11 +- .../intentresolver/logging/EventLogImplTest.java | 421 ++++++++++++++++ .../intentresolver/logging/EventLogTest.java | 422 ---------------- 17 files changed, 1101 insertions(+), 999 deletions(-) delete mode 100644 java/src/com/android/intentresolver/logging/EventLog.java create mode 100644 java/src/com/android/intentresolver/logging/EventLog.kt create mode 100644 java/src/com/android/intentresolver/logging/EventLogImpl.java create mode 100644 java/src/com/android/intentresolver/logging/FrameworkStatsLogger.kt create mode 100644 java/tests/src/com/android/intentresolver/logging/EventLogImplTest.java delete mode 100644 java/tests/src/com/android/intentresolver/logging/EventLogTest.java (limited to 'java/src') diff --git a/java/src/com/android/intentresolver/ChooserActionFactory.java b/java/src/com/android/intentresolver/ChooserActionFactory.java index a54e8c62..2c97c0b1 100644 --- a/java/src/com/android/intentresolver/ChooserActionFactory.java +++ b/java/src/com/android/intentresolver/ChooserActionFactory.java @@ -98,7 +98,7 @@ public final class ChooserActionFactory implements ChooserContentPreviewUi.Actio private final @Nullable ChooserAction mModifyShareAction; private final Consumer mExcludeSharedTextAction; private final Consumer mFinishCallback; - private final EventLog mLogger; + private final EventLog mLog; /** * @param context @@ -117,7 +117,7 @@ public final class ChooserActionFactory implements ChooserContentPreviewUi.Actio Context context, ChooserRequestParameters chooserRequest, ChooserIntegratedDeviceComponents integratedDeviceComponents, - EventLog logger, + EventLog log, Consumer onUpdateSharedTextIsExcluded, Callable firstVisibleImageQuery, ActionActivityStarter activityStarter, @@ -129,7 +129,7 @@ public final class ChooserActionFactory implements ChooserContentPreviewUi.Actio chooserRequest.getTargetIntent(), chooserRequest.getReferrerPackageName(), finishCallback, - logger), + log), makeEditButtonRunnable( getEditSharingTarget( context, @@ -137,11 +137,11 @@ public final class ChooserActionFactory implements ChooserContentPreviewUi.Actio integratedDeviceComponents), firstVisibleImageQuery, activityStarter, - logger), + log), chooserRequest.getChooserActions(), chooserRequest.getModifyShareAction(), onUpdateSharedTextIsExcluded, - logger, + log, finishCallback); } @@ -153,7 +153,7 @@ public final class ChooserActionFactory implements ChooserContentPreviewUi.Actio List customActions, @Nullable ChooserAction modifyShareAction, Consumer onUpdateSharedTextIsExcluded, - EventLog logger, + EventLog log, Consumer finishCallback) { mContext = context; mCopyButtonRunnable = copyButtonRunnable; @@ -161,7 +161,7 @@ public final class ChooserActionFactory implements ChooserContentPreviewUi.Actio mCustomActions = ImmutableList.copyOf(customActions); mModifyShareAction = modifyShareAction; mExcludeSharedTextAction = onUpdateSharedTextIsExcluded; - mLogger = logger; + mLog = log; mFinishCallback = finishCallback; } @@ -188,7 +188,7 @@ public final class ChooserActionFactory implements ChooserContentPreviewUi.Actio mCustomActions.get(i), mFinishCallback, () -> { - mLogger.logCustomActionSelected(position); + mLog.logCustomActionSelected(position); } ); if (actionRow != null) { @@ -209,7 +209,7 @@ public final class ChooserActionFactory implements ChooserContentPreviewUi.Actio mModifyShareAction, mFinishCallback, () -> { - mLogger.logActionSelected(EventLog.SELECTION_TYPE_MODIFY_SHARE); + mLog.logActionSelected(EventLog.SELECTION_TYPE_MODIFY_SHARE); }); } @@ -233,7 +233,7 @@ public final class ChooserActionFactory implements ChooserContentPreviewUi.Actio Intent targetIntent, String referrerPackageName, Consumer finishCallback, - EventLog logger) { + EventLog log) { final ClipData clipData; try { clipData = extractTextToCopy(targetIntent); @@ -249,7 +249,7 @@ public final class ChooserActionFactory implements ChooserContentPreviewUi.Actio Context.CLIPBOARD_SERVICE); clipboardManager.setPrimaryClipAsPackage(clipData, referrerPackageName); - logger.logActionSelected(EventLog.SELECTION_TYPE_COPY); + log.logActionSelected(EventLog.SELECTION_TYPE_COPY); finishCallback.accept(Activity.RESULT_OK); }; } @@ -328,10 +328,10 @@ public final class ChooserActionFactory implements ChooserContentPreviewUi.Actio TargetInfo editSharingTarget, Callable firstVisibleImageQuery, ActionActivityStarter activityStarter, - EventLog logger) { + EventLog log) { return () -> { // Log share completion via edit. - logger.logActionSelected(EventLog.SELECTION_TYPE_EDIT); + log.logActionSelected(EventLog.SELECTION_TYPE_EDIT); View firstImageView = null; try { diff --git a/java/src/com/android/intentresolver/ChooserActivity.java b/java/src/com/android/intentresolver/ChooserActivity.java index ebe6f04b..0a4f9f46 100644 --- a/java/src/com/android/intentresolver/ChooserActivity.java +++ b/java/src/com/android/intentresolver/ChooserActivity.java @@ -85,6 +85,7 @@ import com.android.intentresolver.grid.ChooserGridAdapter; import com.android.intentresolver.icons.DefaultTargetDataLoader; import com.android.intentresolver.icons.TargetDataLoader; import com.android.intentresolver.logging.EventLog; +import com.android.intentresolver.logging.EventLogImpl; import com.android.intentresolver.measurements.Tracer; import com.android.intentresolver.model.AbstractResolverComparator; import com.android.intentresolver.model.AppPredictionServiceResolverComparator; @@ -1111,7 +1112,7 @@ public class ChooserActivity extends Hilt_ChooserActivity implements protected EventLog getEventLog() { if (mEventLog == null) { - mEventLog = new EventLog(); + mEventLog = new EventLogImpl(); } return mEventLog; } diff --git a/java/src/com/android/intentresolver/logging/EventLog.java b/java/src/com/android/intentresolver/logging/EventLog.java deleted file mode 100644 index b30e825b..00000000 --- a/java/src/com/android/intentresolver/logging/EventLog.java +++ /dev/null @@ -1,539 +0,0 @@ -/* - * Copyright (C) 2023 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.android.intentresolver.logging; - -import android.annotation.Nullable; -import android.content.Intent; -import android.metrics.LogMaker; -import android.net.Uri; -import android.provider.MediaStore; -import android.util.HashedStringCache; -import android.util.Log; - -import com.android.intentresolver.ChooserActivity; -import com.android.intentresolver.contentpreview.ContentPreviewType; -import com.android.internal.annotations.VisibleForTesting; -import com.android.internal.logging.InstanceId; -import com.android.internal.logging.InstanceIdSequence; -import com.android.internal.logging.MetricsLogger; -import com.android.internal.logging.UiEvent; -import com.android.internal.logging.UiEventLogger; -import com.android.internal.logging.UiEventLoggerImpl; -import com.android.internal.logging.nano.MetricsProto.MetricsEvent; -import com.android.internal.util.FrameworkStatsLog; - -/** - * Helper for writing Sharesheet atoms to statsd log. - * @hide - */ -public class EventLog { - private static final String TAG = "ChooserActivity"; - private static final boolean DEBUG = true; - - public static final int SELECTION_TYPE_SERVICE = 1; - public static final int SELECTION_TYPE_APP = 2; - public static final int SELECTION_TYPE_STANDARD = 3; - public static final int SELECTION_TYPE_COPY = 4; - public static final int SELECTION_TYPE_NEARBY = 5; - public static final int SELECTION_TYPE_EDIT = 6; - public static final int SELECTION_TYPE_MODIFY_SHARE = 7; - public static final int SELECTION_TYPE_CUSTOM_ACTION = 8; - - /** - * This shim is provided only for testing. In production, clients will only ever use a - * {@link DefaultFrameworkStatsLogger}. - */ - @VisibleForTesting - interface FrameworkStatsLogger { - /** Overload to use for logging {@code FrameworkStatsLog.SHARESHEET_STARTED}. */ - void write( - int frameworkEventId, - int appEventId, - String packageName, - int instanceId, - String mimeType, - int numAppProvidedDirectTargets, - int numAppProvidedAppTargets, - boolean isWorkProfile, - int previewType, - int intentType, - int numCustomActions, - boolean modifyShareActionProvided); - - /** Overload to use for logging {@code FrameworkStatsLog.RANKING_SELECTED}. */ - void write( - int frameworkEventId, - int appEventId, - String packageName, - int instanceId, - int positionPicked, - boolean isPinned); - } - - private static final int SHARESHEET_INSTANCE_ID_MAX = (1 << 13); - - // A small per-notification ID, used for statsd logging. - // TODO: consider precomputing and storing as final. - private static InstanceIdSequence sInstanceIdSequence; - private InstanceId mInstanceId; - - private final UiEventLogger mUiEventLogger; - private final FrameworkStatsLogger mFrameworkStatsLogger; - private final MetricsLogger mMetricsLogger; - - public EventLog() { - this(new UiEventLoggerImpl(), new DefaultFrameworkStatsLogger(), new MetricsLogger()); - } - - @VisibleForTesting - EventLog( - UiEventLogger uiEventLogger, - FrameworkStatsLogger frameworkLogger, - MetricsLogger metricsLogger) { - mUiEventLogger = uiEventLogger; - mFrameworkStatsLogger = frameworkLogger; - mMetricsLogger = metricsLogger; - } - - /** Records metrics for the start time of the {@link ChooserActivity}. */ - public void logChooserActivityShown( - boolean isWorkProfile, String targetMimeType, long systemCost) { - mMetricsLogger.write(new LogMaker(MetricsEvent.ACTION_ACTIVITY_CHOOSER_SHOWN) - .setSubtype( - isWorkProfile ? MetricsEvent.MANAGED_PROFILE : MetricsEvent.PARENT_PROFILE) - .addTaggedData(MetricsEvent.FIELD_SHARESHEET_MIMETYPE, targetMimeType) - .addTaggedData(MetricsEvent.FIELD_TIME_TO_APP_TARGETS, systemCost)); - } - - /** Logs a UiEventReported event for the system sharesheet completing initial start-up. */ - public void logShareStarted( - String packageName, - String mimeType, - int appProvidedDirect, - int appProvidedApp, - boolean isWorkprofile, - int previewType, - String intent, - int customActionCount, - boolean modifyShareActionProvided) { - mFrameworkStatsLogger.write(FrameworkStatsLog.SHARESHEET_STARTED, - /* event_id = 1 */ SharesheetStartedEvent.SHARE_STARTED.getId(), - /* package_name = 2 */ packageName, - /* instance_id = 3 */ getInstanceId().getId(), - /* mime_type = 4 */ mimeType, - /* num_app_provided_direct_targets = 5 */ appProvidedDirect, - /* num_app_provided_app_targets = 6 */ appProvidedApp, - /* is_workprofile = 7 */ isWorkprofile, - /* previewType = 8 */ typeFromPreviewInt(previewType), - /* intentType = 9 */ typeFromIntentString(intent), - /* num_provided_custom_actions = 10 */ customActionCount, - /* modify_share_action_provided = 11 */ modifyShareActionProvided); - } - - /** - * Log that a custom action has been tapped by the user. - * - * @param positionPicked index of the custom action within the list of custom actions. - */ - public void logCustomActionSelected(int positionPicked) { - mFrameworkStatsLogger.write(FrameworkStatsLog.RANKING_SELECTED, - /* event_id = 1 */ - SharesheetTargetSelectedEvent.SHARESHEET_CUSTOM_ACTION_SELECTED.getId(), - /* package_name = 2 */ null, - /* instance_id = 3 */ getInstanceId().getId(), - /* position_picked = 4 */ positionPicked, - /* is_pinned = 5 */ false); - } - - /** - * Logs a UiEventReported event for the system sharesheet when the user selects a target. - * TODO: document parameters and/or consider breaking up by targetType so we don't have to - * support an overly-generic signature. - */ - public void logShareTargetSelected( - int targetType, - String packageName, - int positionPicked, - int directTargetAlsoRanked, - int numCallerProvided, - @Nullable HashedStringCache.HashResult directTargetHashed, - boolean isPinned, - boolean successfullySelected, - long selectionCost) { - mFrameworkStatsLogger.write(FrameworkStatsLog.RANKING_SELECTED, - /* event_id = 1 */ SharesheetTargetSelectedEvent.fromTargetType(targetType).getId(), - /* package_name = 2 */ packageName, - /* instance_id = 3 */ getInstanceId().getId(), - /* position_picked = 4 */ positionPicked, - /* is_pinned = 5 */ isPinned); - - int category = getTargetSelectionCategory(targetType); - if (category != 0) { - LogMaker targetLogMaker = new LogMaker(category).setSubtype(positionPicked); - if (directTargetHashed != null) { - targetLogMaker.addTaggedData( - MetricsEvent.FIELD_HASHED_TARGET_NAME, directTargetHashed.hashedString); - targetLogMaker.addTaggedData( - MetricsEvent.FIELD_HASHED_TARGET_SALT_GEN, - directTargetHashed.saltGeneration); - targetLogMaker.addTaggedData(MetricsEvent.FIELD_RANKED_POSITION, - directTargetAlsoRanked); - } - targetLogMaker.addTaggedData(MetricsEvent.FIELD_IS_CATEGORY_USED, numCallerProvided); - mMetricsLogger.write(targetLogMaker); - } - - if (successfullySelected) { - if (DEBUG) { - Log.d(TAG, "User Selection Time Cost is " + selectionCost); - Log.d(TAG, "position of selected app/service/caller is " + positionPicked); - } - MetricsLogger.histogram( - null, "user_selection_cost_for_smart_sharing", (int) selectionCost); - MetricsLogger.histogram(null, "app_position_for_smart_sharing", positionPicked); - } - } - - /** Log when direct share targets were received. */ - public void logDirectShareTargetReceived(int category, int latency) { - mMetricsLogger.write(new LogMaker(category).setSubtype(latency)); - } - - /** - * Log when we display a preview UI of the specified {@code previewType} as part of our - * Sharesheet session. - */ - public void logActionShareWithPreview(int previewType) { - mMetricsLogger.write( - new LogMaker(MetricsEvent.ACTION_SHARE_WITH_PREVIEW).setSubtype(previewType)); - } - - /** Log when the user selects an action button with the specified {@code targetType}. */ - public void logActionSelected(int targetType) { - if (targetType == SELECTION_TYPE_COPY) { - LogMaker targetLogMaker = new LogMaker( - MetricsEvent.ACTION_ACTIVITY_CHOOSER_PICKED_SYSTEM_TARGET).setSubtype(1); - mMetricsLogger.write(targetLogMaker); - } - mFrameworkStatsLogger.write(FrameworkStatsLog.RANKING_SELECTED, - /* event_id = 1 */ SharesheetTargetSelectedEvent.fromTargetType(targetType).getId(), - /* package_name = 2 */ "", - /* instance_id = 3 */ getInstanceId().getId(), - /* position_picked = 4 */ -1, - /* is_pinned = 5 */ false); - } - - /** Log a warning that we couldn't display the content preview from the supplied {@code uri}. */ - public void logContentPreviewWarning(Uri uri) { - // The ContentResolver already logs the exception. Log something more informative. - Log.w(TAG, "Could not load (" + uri.toString() + ") thumbnail/name for preview. If " - + "desired, consider using Intent#createChooser to launch the ChooserActivity, " - + "and set your Intent's clipData and flags in accordance with that method's " - + "documentation"); - - } - - /** Logs a UiEventReported event for the system sharesheet being triggered by the user. */ - public void logSharesheetTriggered() { - log(SharesheetStandardEvent.SHARESHEET_TRIGGERED, getInstanceId()); - } - - /** Logs a UiEventReported event for the system sharesheet completing loading app targets. */ - public void logSharesheetAppLoadComplete() { - log(SharesheetStandardEvent.SHARESHEET_APP_LOAD_COMPLETE, getInstanceId()); - } - - /** - * Logs a UiEventReported event for the system sharesheet completing loading service targets. - */ - public void logSharesheetDirectLoadComplete() { - log(SharesheetStandardEvent.SHARESHEET_DIRECT_LOAD_COMPLETE, getInstanceId()); - } - - /** - * Logs a UiEventReported event for the system sharesheet timing out loading service targets. - */ - public void logSharesheetDirectLoadTimeout() { - log(SharesheetStandardEvent.SHARESHEET_DIRECT_LOAD_TIMEOUT, getInstanceId()); - } - - /** - * Logs a UiEventReported event for the system sharesheet switching - * between work and main profile. - */ - public void logSharesheetProfileChanged() { - log(SharesheetStandardEvent.SHARESHEET_PROFILE_CHANGED, getInstanceId()); - } - - /** Logs a UiEventReported event for the system sharesheet getting expanded or collapsed. */ - public void logSharesheetExpansionChanged(boolean isCollapsed) { - log(isCollapsed ? SharesheetStandardEvent.SHARESHEET_COLLAPSED : - SharesheetStandardEvent.SHARESHEET_EXPANDED, getInstanceId()); - } - - /** - * Logs a UiEventReported event for the system sharesheet app share ranking timing out. - */ - public void logSharesheetAppShareRankingTimeout() { - log(SharesheetStandardEvent.SHARESHEET_APP_SHARE_RANKING_TIMEOUT, getInstanceId()); - } - - /** - * Logs a UiEventReported event for the system sharesheet when direct share row is empty. - */ - public void logSharesheetEmptyDirectShareRow() { - log(SharesheetStandardEvent.SHARESHEET_EMPTY_DIRECT_SHARE_ROW, getInstanceId()); - } - - /** - * Logs a UiEventReported event for a given share activity - * @param event - * @param instanceId - */ - private void log(UiEventLogger.UiEventEnum event, InstanceId instanceId) { - mUiEventLogger.logWithInstanceId( - event, - 0, - null, - instanceId); - } - - /** - * @return A unique {@link InstanceId} to join across events recorded by this logger instance. - */ - private InstanceId getInstanceId() { - if (mInstanceId == null) { - if (sInstanceIdSequence == null) { - sInstanceIdSequence = new InstanceIdSequence(SHARESHEET_INSTANCE_ID_MAX); - } - mInstanceId = sInstanceIdSequence.newInstanceId(); - } - return mInstanceId; - } - - /** - * The UiEvent enums that this class can log. - */ - enum SharesheetStartedEvent implements UiEventLogger.UiEventEnum { - @UiEvent(doc = "Basic system Sharesheet has started and is visible.") - SHARE_STARTED(228); - - private final int mId; - SharesheetStartedEvent(int id) { - mId = id; - } - @Override - public int getId() { - return mId; - } - } - - /** - * The UiEvent enums that this class can log. - */ - enum SharesheetTargetSelectedEvent implements UiEventLogger.UiEventEnum { - INVALID(0), - @UiEvent(doc = "User selected a service target.") - SHARESHEET_SERVICE_TARGET_SELECTED(232), - @UiEvent(doc = "User selected an app target.") - SHARESHEET_APP_TARGET_SELECTED(233), - @UiEvent(doc = "User selected a standard target.") - SHARESHEET_STANDARD_TARGET_SELECTED(234), - @UiEvent(doc = "User selected the copy target.") - SHARESHEET_COPY_TARGET_SELECTED(235), - @UiEvent(doc = "User selected the nearby target.") - SHARESHEET_NEARBY_TARGET_SELECTED(626), - @UiEvent(doc = "User selected the edit target.") - SHARESHEET_EDIT_TARGET_SELECTED(669), - @UiEvent(doc = "User selected the modify share target.") - SHARESHEET_MODIFY_SHARE_SELECTED(1316), - @UiEvent(doc = "User selected a custom action.") - SHARESHEET_CUSTOM_ACTION_SELECTED(1317); - - private final int mId; - SharesheetTargetSelectedEvent(int id) { - mId = id; - } - @Override public int getId() { - return mId; - } - - public static SharesheetTargetSelectedEvent fromTargetType(int targetType) { - switch(targetType) { - case SELECTION_TYPE_SERVICE: - return SHARESHEET_SERVICE_TARGET_SELECTED; - case SELECTION_TYPE_APP: - return SHARESHEET_APP_TARGET_SELECTED; - case SELECTION_TYPE_STANDARD: - return SHARESHEET_STANDARD_TARGET_SELECTED; - case SELECTION_TYPE_COPY: - return SHARESHEET_COPY_TARGET_SELECTED; - case SELECTION_TYPE_NEARBY: - return SHARESHEET_NEARBY_TARGET_SELECTED; - case SELECTION_TYPE_EDIT: - return SHARESHEET_EDIT_TARGET_SELECTED; - case SELECTION_TYPE_MODIFY_SHARE: - return SHARESHEET_MODIFY_SHARE_SELECTED; - case SELECTION_TYPE_CUSTOM_ACTION: - return SHARESHEET_CUSTOM_ACTION_SELECTED; - default: - return INVALID; - } - } - } - - /** - * The UiEvent enums that this class can log. - */ - enum SharesheetStandardEvent implements UiEventLogger.UiEventEnum { - INVALID(0), - @UiEvent(doc = "User clicked share.") - SHARESHEET_TRIGGERED(227), - @UiEvent(doc = "User changed from work to personal profile or vice versa.") - SHARESHEET_PROFILE_CHANGED(229), - @UiEvent(doc = "User expanded target list.") - SHARESHEET_EXPANDED(230), - @UiEvent(doc = "User collapsed target list.") - SHARESHEET_COLLAPSED(231), - @UiEvent(doc = "Sharesheet app targets is fully populated.") - SHARESHEET_APP_LOAD_COMPLETE(322), - @UiEvent(doc = "Sharesheet direct targets is fully populated.") - SHARESHEET_DIRECT_LOAD_COMPLETE(323), - @UiEvent(doc = "Sharesheet direct targets timed out.") - SHARESHEET_DIRECT_LOAD_TIMEOUT(324), - @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); - - private final int mId; - SharesheetStandardEvent(int id) { - mId = id; - } - @Override public int getId() { - return mId; - } - } - - /** - * Returns the enum used in sharesheet started atom to indicate what preview type was used. - */ - private static int typeFromPreviewInt(int previewType) { - switch(previewType) { - case ContentPreviewType.CONTENT_PREVIEW_IMAGE: - return FrameworkStatsLog.SHARESHEET_STARTED__PREVIEW_TYPE__CONTENT_PREVIEW_IMAGE; - case ContentPreviewType.CONTENT_PREVIEW_FILE: - return FrameworkStatsLog.SHARESHEET_STARTED__PREVIEW_TYPE__CONTENT_PREVIEW_FILE; - case ContentPreviewType.CONTENT_PREVIEW_TEXT: - default: - return FrameworkStatsLog - .SHARESHEET_STARTED__PREVIEW_TYPE__CONTENT_PREVIEW_TYPE_UNKNOWN; - } - } - - /** - * Returns the enum used in sharesheet started atom to indicate what intent triggers the - * ChooserActivity. - */ - private static int typeFromIntentString(String intent) { - if (intent == null) { - return FrameworkStatsLog.SHARESHEET_STARTED__INTENT_TYPE__INTENT_DEFAULT; - } - switch (intent) { - case Intent.ACTION_VIEW: - return FrameworkStatsLog.SHARESHEET_STARTED__INTENT_TYPE__INTENT_ACTION_VIEW; - case Intent.ACTION_EDIT: - return FrameworkStatsLog.SHARESHEET_STARTED__INTENT_TYPE__INTENT_ACTION_EDIT; - case Intent.ACTION_SEND: - return FrameworkStatsLog.SHARESHEET_STARTED__INTENT_TYPE__INTENT_ACTION_SEND; - case Intent.ACTION_SENDTO: - return FrameworkStatsLog.SHARESHEET_STARTED__INTENT_TYPE__INTENT_ACTION_SENDTO; - case Intent.ACTION_SEND_MULTIPLE: - return FrameworkStatsLog - .SHARESHEET_STARTED__INTENT_TYPE__INTENT_ACTION_SEND_MULTIPLE; - case MediaStore.ACTION_IMAGE_CAPTURE: - return FrameworkStatsLog - .SHARESHEET_STARTED__INTENT_TYPE__INTENT_ACTION_IMAGE_CAPTURE; - case Intent.ACTION_MAIN: - return FrameworkStatsLog.SHARESHEET_STARTED__INTENT_TYPE__INTENT_ACTION_MAIN; - default: - return FrameworkStatsLog.SHARESHEET_STARTED__INTENT_TYPE__INTENT_DEFAULT; - } - } - - @VisibleForTesting - static int getTargetSelectionCategory(int targetType) { - switch (targetType) { - case SELECTION_TYPE_SERVICE: - return MetricsEvent.ACTION_ACTIVITY_CHOOSER_PICKED_SERVICE_TARGET; - case SELECTION_TYPE_APP: - return MetricsEvent.ACTION_ACTIVITY_CHOOSER_PICKED_APP_TARGET; - case SELECTION_TYPE_STANDARD: - return MetricsEvent.ACTION_ACTIVITY_CHOOSER_PICKED_STANDARD_TARGET; - default: - return 0; - } - } - - private static class DefaultFrameworkStatsLogger implements FrameworkStatsLogger { - @Override - public void write( - int frameworkEventId, - int appEventId, - String packageName, - int instanceId, - String mimeType, - int numAppProvidedDirectTargets, - int numAppProvidedAppTargets, - boolean isWorkProfile, - int previewType, - int intentType, - int numCustomActions, - boolean modifyShareActionProvided) { - FrameworkStatsLog.write( - frameworkEventId, - /* event_id = 1 */ appEventId, - /* package_name = 2 */ packageName, - /* instance_id = 3 */ instanceId, - /* mime_type = 4 */ mimeType, - /* num_app_provided_direct_targets */ numAppProvidedDirectTargets, - /* num_app_provided_app_targets */ numAppProvidedAppTargets, - /* is_workprofile */ isWorkProfile, - /* previewType = 8 */ previewType, - /* intentType = 9 */ intentType, - /* num_provided_custom_actions = 10 */ numCustomActions, - /* modify_share_action_provided = 11 */ modifyShareActionProvided); - } - - @Override - public void write( - int frameworkEventId, - int appEventId, - String packageName, - int instanceId, - int positionPicked, - boolean isPinned) { - FrameworkStatsLog.write( - frameworkEventId, - /* event_id = 1 */ appEventId, - /* package_name = 2 */ packageName, - /* instance_id = 3 */ instanceId, - /* position_picked = 4 */ positionPicked, - /* is_pinned = 5 */ isPinned); - } - } -} diff --git a/java/src/com/android/intentresolver/logging/EventLog.kt b/java/src/com/android/intentresolver/logging/EventLog.kt new file mode 100644 index 00000000..476bd4bf --- /dev/null +++ b/java/src/com/android/intentresolver/logging/EventLog.kt @@ -0,0 +1,74 @@ +/* + * Copyright (C) 2023 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.android.intentresolver.logging + +import android.net.Uri +import android.util.HashedStringCache + +/** Logs notable events during ShareSheet usage. */ +interface EventLog { + + companion object { + const val SELECTION_TYPE_SERVICE = 1 + const val SELECTION_TYPE_APP = 2 + const val SELECTION_TYPE_STANDARD = 3 + const val SELECTION_TYPE_COPY = 4 + const val SELECTION_TYPE_NEARBY = 5 + const val SELECTION_TYPE_EDIT = 6 + const val SELECTION_TYPE_MODIFY_SHARE = 7 + const val SELECTION_TYPE_CUSTOM_ACTION = 8 + } + + fun logChooserActivityShown(isWorkProfile: Boolean, targetMimeType: String?, systemCost: Long) + + fun logShareStarted( + packageName: String?, + mimeType: String?, + appProvidedDirect: Int, + appProvidedApp: Int, + isWorkprofile: Boolean, + previewType: Int, + intent: String?, + customActionCount: Int, + modifyShareActionProvided: Boolean + ) + + fun logCustomActionSelected(positionPicked: Int) + fun logShareTargetSelected( + targetType: Int, + packageName: String?, + positionPicked: Int, + directTargetAlsoRanked: Int, + numCallerProvided: Int, + directTargetHashed: HashedStringCache.HashResult?, + isPinned: Boolean, + successfullySelected: Boolean, + selectionCost: Long + ) + + fun logDirectShareTargetReceived(category: Int, latency: Int) + fun logActionShareWithPreview(previewType: Int) + fun logActionSelected(targetType: Int) + fun logContentPreviewWarning(uri: Uri?) + fun logSharesheetTriggered() + fun logSharesheetAppLoadComplete() + fun logSharesheetDirectLoadComplete() + fun logSharesheetDirectLoadTimeout() + fun logSharesheetProfileChanged() + fun logSharesheetExpansionChanged(isCollapsed: Boolean) + fun logSharesheetAppShareRankingTimeout() + fun logSharesheetEmptyDirectShareRow() +} diff --git a/java/src/com/android/intentresolver/logging/EventLogImpl.java b/java/src/com/android/intentresolver/logging/EventLogImpl.java new file mode 100644 index 00000000..33e617b1 --- /dev/null +++ b/java/src/com/android/intentresolver/logging/EventLogImpl.java @@ -0,0 +1,514 @@ +/* + * Copyright (C) 2023 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.intentresolver.logging; + +import android.annotation.Nullable; +import android.content.Intent; +import android.metrics.LogMaker; +import android.net.Uri; +import android.provider.MediaStore; +import android.util.HashedStringCache; +import android.util.Log; + +import com.android.intentresolver.ChooserActivity; +import com.android.intentresolver.contentpreview.ContentPreviewType; +import com.android.internal.annotations.VisibleForTesting; +import com.android.internal.logging.InstanceId; +import com.android.internal.logging.InstanceIdSequence; +import com.android.internal.logging.MetricsLogger; +import com.android.internal.logging.UiEvent; +import com.android.internal.logging.UiEventLogger; +import com.android.internal.logging.UiEventLoggerImpl; +import com.android.internal.logging.nano.MetricsProto.MetricsEvent; +import com.android.internal.util.FrameworkStatsLog; + +/** + * Helper for writing Sharesheet atoms to statsd log. + */ +public class EventLogImpl implements EventLog { + private static final String TAG = "ChooserActivity"; + private static final boolean DEBUG = true; + + private static final int SHARESHEET_INSTANCE_ID_MAX = (1 << 13); + + // A small per-notification ID, used for statsd logging. + // TODO: consider precomputing and storing as final. + private static InstanceIdSequence sInstanceIdSequence; + private InstanceId mInstanceId; + + private final UiEventLogger mUiEventLogger; + private final FrameworkStatsLogger mFrameworkStatsLogger; + private final MetricsLogger mMetricsLogger; + + public EventLogImpl() { + this(new UiEventLoggerImpl(), new DefaultFrameworkStatsLogger(), new MetricsLogger()); + } + + @VisibleForTesting + EventLogImpl( + UiEventLogger uiEventLogger, + FrameworkStatsLogger frameworkLogger, + MetricsLogger metricsLogger) { + mUiEventLogger = uiEventLogger; + mFrameworkStatsLogger = frameworkLogger; + mMetricsLogger = metricsLogger; + } + + /** Records metrics for the start time of the {@link ChooserActivity}. */ + @Override + public void logChooserActivityShown( + boolean isWorkProfile, String targetMimeType, long systemCost) { + mMetricsLogger.write(new LogMaker(MetricsEvent.ACTION_ACTIVITY_CHOOSER_SHOWN) + .setSubtype( + isWorkProfile ? MetricsEvent.MANAGED_PROFILE : MetricsEvent.PARENT_PROFILE) + .addTaggedData(MetricsEvent.FIELD_SHARESHEET_MIMETYPE, targetMimeType) + .addTaggedData(MetricsEvent.FIELD_TIME_TO_APP_TARGETS, systemCost)); + } + + /** Logs a UiEventReported event for the system sharesheet completing initial start-up. */ + @Override + public void logShareStarted( + String packageName, + String mimeType, + int appProvidedDirect, + int appProvidedApp, + boolean isWorkprofile, + int previewType, + String intent, + int customActionCount, + boolean modifyShareActionProvided) { + mFrameworkStatsLogger.write(FrameworkStatsLog.SHARESHEET_STARTED, + /* event_id = 1 */ SharesheetStartedEvent.SHARE_STARTED.getId(), + /* package_name = 2 */ packageName, + /* instance_id = 3 */ getInstanceId().getId(), + /* mime_type = 4 */ mimeType, + /* num_app_provided_direct_targets = 5 */ appProvidedDirect, + /* num_app_provided_app_targets = 6 */ appProvidedApp, + /* is_workprofile = 7 */ isWorkprofile, + /* previewType = 8 */ typeFromPreviewInt(previewType), + /* intentType = 9 */ typeFromIntentString(intent), + /* num_provided_custom_actions = 10 */ customActionCount, + /* modify_share_action_provided = 11 */ modifyShareActionProvided); + } + + /** + * Log that a custom action has been tapped by the user. + * + * @param positionPicked index of the custom action within the list of custom actions. + */ + @Override + public void logCustomActionSelected(int positionPicked) { + mFrameworkStatsLogger.write(FrameworkStatsLog.RANKING_SELECTED, + /* event_id = 1 */ + SharesheetTargetSelectedEvent.SHARESHEET_CUSTOM_ACTION_SELECTED.getId(), + /* package_name = 2 */ null, + /* instance_id = 3 */ getInstanceId().getId(), + /* position_picked = 4 */ positionPicked, + /* is_pinned = 5 */ false); + } + + /** + * Logs a UiEventReported event for the system sharesheet when the user selects a target. + * TODO: document parameters and/or consider breaking up by targetType so we don't have to + * support an overly-generic signature. + */ + @Override + public void logShareTargetSelected( + int targetType, + String packageName, + int positionPicked, + int directTargetAlsoRanked, + int numCallerProvided, + @Nullable HashedStringCache.HashResult directTargetHashed, + boolean isPinned, + boolean successfullySelected, + long selectionCost) { + mFrameworkStatsLogger.write(FrameworkStatsLog.RANKING_SELECTED, + /* event_id = 1 */ SharesheetTargetSelectedEvent.fromTargetType(targetType).getId(), + /* package_name = 2 */ packageName, + /* instance_id = 3 */ getInstanceId().getId(), + /* position_picked = 4 */ positionPicked, + /* is_pinned = 5 */ isPinned); + + int category = getTargetSelectionCategory(targetType); + if (category != 0) { + LogMaker targetLogMaker = new LogMaker(category).setSubtype(positionPicked); + if (directTargetHashed != null) { + targetLogMaker.addTaggedData( + MetricsEvent.FIELD_HASHED_TARGET_NAME, directTargetHashed.hashedString); + targetLogMaker.addTaggedData( + MetricsEvent.FIELD_HASHED_TARGET_SALT_GEN, + directTargetHashed.saltGeneration); + targetLogMaker.addTaggedData(MetricsEvent.FIELD_RANKED_POSITION, + directTargetAlsoRanked); + } + targetLogMaker.addTaggedData(MetricsEvent.FIELD_IS_CATEGORY_USED, numCallerProvided); + mMetricsLogger.write(targetLogMaker); + } + + if (successfullySelected) { + if (DEBUG) { + Log.d(TAG, "User Selection Time Cost is " + selectionCost); + Log.d(TAG, "position of selected app/service/caller is " + positionPicked); + } + MetricsLogger.histogram( + null, "user_selection_cost_for_smart_sharing", (int) selectionCost); + MetricsLogger.histogram(null, "app_position_for_smart_sharing", positionPicked); + } + } + + /** Log when direct share targets were received. */ + @Override + public void logDirectShareTargetReceived(int category, int latency) { + mMetricsLogger.write(new LogMaker(category).setSubtype(latency)); + } + + /** + * Log when we display a preview UI of the specified {@code previewType} as part of our + * Sharesheet session. + */ + @Override + public void logActionShareWithPreview(int previewType) { + mMetricsLogger.write( + new LogMaker(MetricsEvent.ACTION_SHARE_WITH_PREVIEW).setSubtype(previewType)); + } + + /** Log when the user selects an action button with the specified {@code targetType}. */ + @Override + public void logActionSelected(int targetType) { + if (targetType == SELECTION_TYPE_COPY) { + LogMaker targetLogMaker = new LogMaker( + MetricsEvent.ACTION_ACTIVITY_CHOOSER_PICKED_SYSTEM_TARGET).setSubtype(1); + mMetricsLogger.write(targetLogMaker); + } + mFrameworkStatsLogger.write(FrameworkStatsLog.RANKING_SELECTED, + /* event_id = 1 */ SharesheetTargetSelectedEvent.fromTargetType(targetType).getId(), + /* package_name = 2 */ "", + /* instance_id = 3 */ getInstanceId().getId(), + /* position_picked = 4 */ -1, + /* is_pinned = 5 */ false); + } + + /** Log a warning that we couldn't display the content preview from the supplied {@code uri}. */ + @Override + public void logContentPreviewWarning(Uri uri) { + // The ContentResolver already logs the exception. Log something more informative. + Log.w(TAG, "Could not load (" + uri.toString() + ") thumbnail/name for preview. If " + + "desired, consider using Intent#createChooser to launch the ChooserActivity, " + + "and set your Intent's clipData and flags in accordance with that method's " + + "documentation"); + + } + + /** Logs a UiEventReported event for the system sharesheet being triggered by the user. */ + @Override + public void logSharesheetTriggered() { + log(SharesheetStandardEvent.SHARESHEET_TRIGGERED, getInstanceId()); + } + + /** Logs a UiEventReported event for the system sharesheet completing loading app targets. */ + @Override + public void logSharesheetAppLoadComplete() { + log(SharesheetStandardEvent.SHARESHEET_APP_LOAD_COMPLETE, getInstanceId()); + } + + /** + * Logs a UiEventReported event for the system sharesheet completing loading service targets. + */ + @Override + public void logSharesheetDirectLoadComplete() { + log(SharesheetStandardEvent.SHARESHEET_DIRECT_LOAD_COMPLETE, getInstanceId()); + } + + /** + * Logs a UiEventReported event for the system sharesheet timing out loading service targets. + */ + @Override + public void logSharesheetDirectLoadTimeout() { + log(SharesheetStandardEvent.SHARESHEET_DIRECT_LOAD_TIMEOUT, getInstanceId()); + } + + /** + * Logs a UiEventReported event for the system sharesheet switching + * between work and main profile. + */ + @Override + public void logSharesheetProfileChanged() { + log(SharesheetStandardEvent.SHARESHEET_PROFILE_CHANGED, getInstanceId()); + } + + /** Logs a UiEventReported event for the system sharesheet getting expanded or collapsed. */ + @Override + public void logSharesheetExpansionChanged(boolean isCollapsed) { + log(isCollapsed ? SharesheetStandardEvent.SHARESHEET_COLLAPSED : + SharesheetStandardEvent.SHARESHEET_EXPANDED, getInstanceId()); + } + + /** + * Logs a UiEventReported event for the system sharesheet app share ranking timing out. + */ + @Override + public void logSharesheetAppShareRankingTimeout() { + log(SharesheetStandardEvent.SHARESHEET_APP_SHARE_RANKING_TIMEOUT, getInstanceId()); + } + + /** + * Logs a UiEventReported event for the system sharesheet when direct share row is empty. + */ + @Override + public void logSharesheetEmptyDirectShareRow() { + log(SharesheetStandardEvent.SHARESHEET_EMPTY_DIRECT_SHARE_ROW, getInstanceId()); + } + + /** + * Logs a UiEventReported event for a given share activity + * @param event + * @param instanceId + */ + private void log(UiEventLogger.UiEventEnum event, InstanceId instanceId) { + mUiEventLogger.logWithInstanceId( + event, + 0, + null, + instanceId); + } + + /** + * @return A unique {@link InstanceId} to join across events recorded by this logger instance. + */ + private InstanceId getInstanceId() { + if (mInstanceId == null) { + if (sInstanceIdSequence == null) { + sInstanceIdSequence = new InstanceIdSequence(SHARESHEET_INSTANCE_ID_MAX); + } + mInstanceId = sInstanceIdSequence.newInstanceId(); + } + return mInstanceId; + } + + /** + * The UiEvent enums that this class can log. + */ + enum SharesheetStartedEvent implements UiEventLogger.UiEventEnum { + @UiEvent(doc = "Basic system Sharesheet has started and is visible.") + SHARE_STARTED(228); + + private final int mId; + SharesheetStartedEvent(int id) { + mId = id; + } + @Override + public int getId() { + return mId; + } + } + + /** + * The UiEvent enums that this class can log. + */ + enum SharesheetTargetSelectedEvent implements UiEventLogger.UiEventEnum { + INVALID(0), + @UiEvent(doc = "User selected a service target.") + SHARESHEET_SERVICE_TARGET_SELECTED(232), + @UiEvent(doc = "User selected an app target.") + SHARESHEET_APP_TARGET_SELECTED(233), + @UiEvent(doc = "User selected a standard target.") + SHARESHEET_STANDARD_TARGET_SELECTED(234), + @UiEvent(doc = "User selected the copy target.") + SHARESHEET_COPY_TARGET_SELECTED(235), + @UiEvent(doc = "User selected the nearby target.") + SHARESHEET_NEARBY_TARGET_SELECTED(626), + @UiEvent(doc = "User selected the edit target.") + SHARESHEET_EDIT_TARGET_SELECTED(669), + @UiEvent(doc = "User selected the modify share target.") + SHARESHEET_MODIFY_SHARE_SELECTED(1316), + @UiEvent(doc = "User selected a custom action.") + SHARESHEET_CUSTOM_ACTION_SELECTED(1317); + + private final int mId; + SharesheetTargetSelectedEvent(int id) { + mId = id; + } + @Override public int getId() { + return mId; + } + + public static SharesheetTargetSelectedEvent fromTargetType(int targetType) { + switch(targetType) { + case SELECTION_TYPE_SERVICE: + return SHARESHEET_SERVICE_TARGET_SELECTED; + case SELECTION_TYPE_APP: + return SHARESHEET_APP_TARGET_SELECTED; + case SELECTION_TYPE_STANDARD: + return SHARESHEET_STANDARD_TARGET_SELECTED; + case SELECTION_TYPE_COPY: + return SHARESHEET_COPY_TARGET_SELECTED; + case SELECTION_TYPE_NEARBY: + return SHARESHEET_NEARBY_TARGET_SELECTED; + case SELECTION_TYPE_EDIT: + return SHARESHEET_EDIT_TARGET_SELECTED; + case SELECTION_TYPE_MODIFY_SHARE: + return SHARESHEET_MODIFY_SHARE_SELECTED; + case SELECTION_TYPE_CUSTOM_ACTION: + return SHARESHEET_CUSTOM_ACTION_SELECTED; + default: + return INVALID; + } + } + } + + /** + * The UiEvent enums that this class can log. + */ + enum SharesheetStandardEvent implements UiEventLogger.UiEventEnum { + INVALID(0), + @UiEvent(doc = "User clicked share.") + SHARESHEET_TRIGGERED(227), + @UiEvent(doc = "User changed from work to personal profile or vice versa.") + SHARESHEET_PROFILE_CHANGED(229), + @UiEvent(doc = "User expanded target list.") + SHARESHEET_EXPANDED(230), + @UiEvent(doc = "User collapsed target list.") + SHARESHEET_COLLAPSED(231), + @UiEvent(doc = "Sharesheet app targets is fully populated.") + SHARESHEET_APP_LOAD_COMPLETE(322), + @UiEvent(doc = "Sharesheet direct targets is fully populated.") + SHARESHEET_DIRECT_LOAD_COMPLETE(323), + @UiEvent(doc = "Sharesheet direct targets timed out.") + SHARESHEET_DIRECT_LOAD_TIMEOUT(324), + @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); + + private final int mId; + SharesheetStandardEvent(int id) { + mId = id; + } + @Override public int getId() { + return mId; + } + } + + /** + * Returns the enum used in sharesheet started atom to indicate what preview type was used. + */ + private static int typeFromPreviewInt(int previewType) { + switch(previewType) { + case ContentPreviewType.CONTENT_PREVIEW_IMAGE: + return FrameworkStatsLog.SHARESHEET_STARTED__PREVIEW_TYPE__CONTENT_PREVIEW_IMAGE; + case ContentPreviewType.CONTENT_PREVIEW_FILE: + return FrameworkStatsLog.SHARESHEET_STARTED__PREVIEW_TYPE__CONTENT_PREVIEW_FILE; + case ContentPreviewType.CONTENT_PREVIEW_TEXT: + default: + return FrameworkStatsLog + .SHARESHEET_STARTED__PREVIEW_TYPE__CONTENT_PREVIEW_TYPE_UNKNOWN; + } + } + + /** + * Returns the enum used in sharesheet started atom to indicate what intent triggers the + * ChooserActivity. + */ + private static int typeFromIntentString(String intent) { + if (intent == null) { + return FrameworkStatsLog.SHARESHEET_STARTED__INTENT_TYPE__INTENT_DEFAULT; + } + switch (intent) { + case Intent.ACTION_VIEW: + return FrameworkStatsLog.SHARESHEET_STARTED__INTENT_TYPE__INTENT_ACTION_VIEW; + case Intent.ACTION_EDIT: + return FrameworkStatsLog.SHARESHEET_STARTED__INTENT_TYPE__INTENT_ACTION_EDIT; + case Intent.ACTION_SEND: + return FrameworkStatsLog.SHARESHEET_STARTED__INTENT_TYPE__INTENT_ACTION_SEND; + case Intent.ACTION_SENDTO: + return FrameworkStatsLog.SHARESHEET_STARTED__INTENT_TYPE__INTENT_ACTION_SENDTO; + case Intent.ACTION_SEND_MULTIPLE: + return FrameworkStatsLog + .SHARESHEET_STARTED__INTENT_TYPE__INTENT_ACTION_SEND_MULTIPLE; + case MediaStore.ACTION_IMAGE_CAPTURE: + return FrameworkStatsLog + .SHARESHEET_STARTED__INTENT_TYPE__INTENT_ACTION_IMAGE_CAPTURE; + case Intent.ACTION_MAIN: + return FrameworkStatsLog.SHARESHEET_STARTED__INTENT_TYPE__INTENT_ACTION_MAIN; + default: + return FrameworkStatsLog.SHARESHEET_STARTED__INTENT_TYPE__INTENT_DEFAULT; + } + } + + @VisibleForTesting + static int getTargetSelectionCategory(int targetType) { + switch (targetType) { + case SELECTION_TYPE_SERVICE: + return MetricsEvent.ACTION_ACTIVITY_CHOOSER_PICKED_SERVICE_TARGET; + case SELECTION_TYPE_APP: + return MetricsEvent.ACTION_ACTIVITY_CHOOSER_PICKED_APP_TARGET; + case SELECTION_TYPE_STANDARD: + return MetricsEvent.ACTION_ACTIVITY_CHOOSER_PICKED_STANDARD_TARGET; + default: + return 0; + } + } + + private static class DefaultFrameworkStatsLogger implements FrameworkStatsLogger { + @Override + public void write( + int frameworkEventId, + int appEventId, + String packageName, + int instanceId, + String mimeType, + int numAppProvidedDirectTargets, + int numAppProvidedAppTargets, + boolean isWorkProfile, + int previewType, + int intentType, + int numCustomActions, + boolean modifyShareActionProvided) { + FrameworkStatsLog.write( + frameworkEventId, + /* event_id = 1 */ appEventId, + /* package_name = 2 */ packageName, + /* instance_id = 3 */ instanceId, + /* mime_type = 4 */ mimeType, + /* num_app_provided_direct_targets */ numAppProvidedDirectTargets, + /* num_app_provided_app_targets */ numAppProvidedAppTargets, + /* is_workprofile */ isWorkProfile, + /* previewType = 8 */ previewType, + /* intentType = 9 */ intentType, + /* num_provided_custom_actions = 10 */ numCustomActions, + /* modify_share_action_provided = 11 */ modifyShareActionProvided); + } + + @Override + public void write( + int frameworkEventId, + int appEventId, + String packageName, + int instanceId, + int positionPicked, + boolean isPinned) { + FrameworkStatsLog.write( + frameworkEventId, + /* event_id = 1 */ appEventId, + /* package_name = 2 */ packageName, + /* instance_id = 3 */ instanceId, + /* position_picked = 4 */ positionPicked, + /* is_pinned = 5 */ isPinned); + } + } +} diff --git a/java/src/com/android/intentresolver/logging/FrameworkStatsLogger.kt b/java/src/com/android/intentresolver/logging/FrameworkStatsLogger.kt new file mode 100644 index 00000000..e0682b9e --- /dev/null +++ b/java/src/com/android/intentresolver/logging/FrameworkStatsLogger.kt @@ -0,0 +1,50 @@ +/* + * Copyright (C) 2023 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.android.intentresolver.logging + +import com.android.internal.util.FrameworkStatsLog + +/** A documenting annotation for FrameworkStatsLog methods and their associated UiEvents. */ +internal annotation class ForUiEvent(vararg val uiEventId: Int) + +/** Isolates the specific method signatures to use for each of the logged UiEvents. */ +internal interface FrameworkStatsLogger { + @ForUiEvent(FrameworkStatsLog.SHARESHEET_STARTED) + fun write( + frameworkEventId: Int, + appEventId: Int, + packageName: String?, + instanceId: Int, + mimeType: String?, + numAppProvidedDirectTargets: Int, + numAppProvidedAppTargets: Int, + isWorkProfile: Boolean, + previewType: Int, + intentType: Int, + numCustomActions: Int, + modifyShareActionProvided: Boolean + ) + + @ForUiEvent(FrameworkStatsLog.RANKING_SELECTED) + fun write( + frameworkEventId: Int, + appEventId: Int, + packageName: String?, + instanceId: Int, + positionPicked: Int, + isPinned: Boolean + ) +} diff --git a/java/src/com/android/intentresolver/model/AbstractResolverComparator.java b/java/src/com/android/intentresolver/model/AbstractResolverComparator.java index ff2d6a0f..932d8590 100644 --- a/java/src/com/android/intentresolver/model/AbstractResolverComparator.java +++ b/java/src/com/android/intentresolver/model/AbstractResolverComparator.java @@ -30,10 +30,11 @@ import android.os.Message; import android.os.UserHandle; import android.util.Log; -import com.android.intentresolver.logging.EventLog; import com.android.intentresolver.ResolvedComponentInfo; import com.android.intentresolver.ResolverActivity; +import com.android.intentresolver.ResolverListController; import com.android.intentresolver.chooser.TargetInfo; +import com.android.intentresolver.logging.EventLog; import java.text.Collator; import java.util.ArrayList; @@ -75,6 +76,7 @@ public abstract class AbstractResolverComparator implements Comparator targetUserSpaceList, - @Nullable ComponentName promoteToFirst) { + String referrerPackage, Runnable afterCompute, EventLog eventLog, + List targetUserSpaceList, @Nullable ComponentName promoteToFirst) { super(launchedFromContext, intent, targetUserSpaceList, promoteToFirst); mCollator = Collator.getInstance( launchedFromContext.getResources().getConfiguration().locale); diff --git a/java/tests/src/com/android/intentresolver/ChooserActionFactoryTest.kt b/java/tests/src/com/android/intentresolver/ChooserActionFactoryTest.kt index 2d1ac4e4..e2b987c2 100644 --- a/java/tests/src/com/android/intentresolver/ChooserActionFactoryTest.kt +++ b/java/tests/src/com/android/intentresolver/ChooserActionFactoryTest.kt @@ -28,12 +28,13 @@ 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.logging.EventLog +import com.android.intentresolver.logging.EventLogImpl import com.google.common.collect.ImmutableList import com.google.common.truth.Truth.assertThat import java.util.concurrent.CountDownLatch import java.util.concurrent.TimeUnit import java.util.function.Consumer +import com.android.intentresolver.logging.EventLog import org.junit.After import org.junit.Assert.assertEquals import org.junit.Assert.assertTrue diff --git a/java/tests/src/com/android/intentresolver/ChooserActivityOverrideData.java b/java/tests/src/com/android/intentresolver/ChooserActivityOverrideData.java index 5b938aa1..b5c14ff1 100644 --- a/java/tests/src/com/android/intentresolver/ChooserActivityOverrideData.java +++ b/java/tests/src/com/android/intentresolver/ChooserActivityOverrideData.java @@ -29,7 +29,7 @@ import android.os.UserHandle; import com.android.intentresolver.AbstractMultiProfilePagerAdapter.CrossProfileIntentsChecker; import com.android.intentresolver.chooser.TargetInfo; import com.android.intentresolver.contentpreview.ImageLoader; -import com.android.intentresolver.logging.EventLog; +import com.android.intentresolver.logging.EventLogImpl; import com.android.intentresolver.shortcuts.ShortcutLoader; import java.util.function.Consumer; @@ -64,7 +64,7 @@ public class ChooserActivityOverrideData { public Cursor resolverCursor; public boolean resolverForceException; public ImageLoader imageLoader; - public EventLog mEventLog; + public EventLogImpl mEventLog; public int alternateProfileSetting; public Resources resources; public UserHandle workProfileUserHandle; @@ -86,7 +86,7 @@ public class ChooserActivityOverrideData { resolverForceException = false; resolverListController = mock(ChooserActivity.ChooserListController.class); workResolverListController = mock(ChooserActivity.ChooserListController.class); - mEventLog = mock(EventLog.class); + mEventLog = mock(EventLogImpl.class); alternateProfileSetting = 0; resources = null; workProfileUserHandle = null; diff --git a/java/tests/src/com/android/intentresolver/ChooserListAdapterTest.kt b/java/tests/src/com/android/intentresolver/ChooserListAdapterTest.kt index 9b5e2d1c..87e58954 100644 --- a/java/tests/src/com/android/intentresolver/ChooserListAdapterTest.kt +++ b/java/tests/src/com/android/intentresolver/ChooserListAdapterTest.kt @@ -32,7 +32,7 @@ import com.android.intentresolver.chooser.DisplayResolveInfo import com.android.intentresolver.chooser.SelectableTargetInfo import com.android.intentresolver.chooser.TargetInfo import com.android.intentresolver.icons.TargetDataLoader -import com.android.intentresolver.logging.EventLog +import com.android.intentresolver.logging.EventLogImpl import com.android.internal.R import com.google.common.truth.Truth.assertThat import org.junit.Before @@ -54,7 +54,7 @@ class ChooserListAdapterTest { private val resolverListController = mock() private val appLabel = "App" private val targetLabel = "Target" - private val mEventLog = mock() + private val mEventLog = mock() private val mTargetDataLoader = mock() private val testSubject by lazy { diff --git a/java/tests/src/com/android/intentresolver/ChooserWrapperActivity.java b/java/tests/src/com/android/intentresolver/ChooserWrapperActivity.java index 578b9557..6dbf9b3f 100644 --- a/java/tests/src/com/android/intentresolver/ChooserWrapperActivity.java +++ b/java/tests/src/com/android/intentresolver/ChooserWrapperActivity.java @@ -39,7 +39,7 @@ import com.android.intentresolver.chooser.DisplayResolveInfo; import com.android.intentresolver.chooser.TargetInfo; import com.android.intentresolver.grid.ChooserGridAdapter; import com.android.intentresolver.icons.TargetDataLoader; -import com.android.intentresolver.logging.EventLog; +import com.android.intentresolver.logging.EventLogImpl; import com.android.intentresolver.shortcuts.ShortcutLoader; import com.android.internal.logging.nano.MetricsProto.MetricsEvent; @@ -204,7 +204,7 @@ public class ChooserWrapperActivity extends ChooserActivity implements IChooserW } @Override - public EventLog getEventLog() { + public EventLogImpl getEventLog() { return sOverrides.mEventLog; } diff --git a/java/tests/src/com/android/intentresolver/IChooserWrapper.java b/java/tests/src/com/android/intentresolver/IChooserWrapper.java index 3326d7f2..54e4da9e 100644 --- a/java/tests/src/com/android/intentresolver/IChooserWrapper.java +++ b/java/tests/src/com/android/intentresolver/IChooserWrapper.java @@ -23,7 +23,7 @@ import android.content.pm.ResolveInfo; import android.os.UserHandle; import com.android.intentresolver.chooser.DisplayResolveInfo; -import com.android.intentresolver.logging.EventLog; +import com.android.intentresolver.logging.EventLogImpl; import java.util.concurrent.Executor; @@ -42,6 +42,6 @@ public interface IChooserWrapper { CharSequence pLabel, CharSequence pInfo, Intent replacementIntent, @Nullable TargetPresentationGetter resolveInfoPresentationGetter); UserHandle getCurrentUserHandle(); - EventLog getEventLog(); + EventLogImpl getEventLog(); Executor getMainExecutor(); } diff --git a/java/tests/src/com/android/intentresolver/UnbundledChooserActivityTest.java b/java/tests/src/com/android/intentresolver/UnbundledChooserActivityTest.java index 59357843..2838f00b 100644 --- a/java/tests/src/com/android/intentresolver/UnbundledChooserActivityTest.java +++ b/java/tests/src/com/android/intentresolver/UnbundledChooserActivityTest.java @@ -113,6 +113,7 @@ import androidx.test.rule.ActivityTestRule; import com.android.intentresolver.chooser.DisplayResolveInfo; import com.android.intentresolver.contentpreview.ImageLoader; import com.android.intentresolver.logging.EventLog; +import com.android.intentresolver.logging.EventLogImpl; import com.android.intentresolver.shortcuts.ShortcutLoader; import com.android.internal.config.sysui.SystemUiDeviceConfigFlags; import com.android.internal.logging.nano.MetricsProto.MetricsEvent; @@ -845,7 +846,7 @@ public class UnbundledChooserActivityTest { onView(withId(R.id.copy)).perform(click()); EventLog logger = activity.getEventLog(); - verify(logger, times(1)).logActionSelected(eq(EventLog.SELECTION_TYPE_COPY)); + verify(logger, times(1)).logActionSelected(eq(EventLogImpl.SELECTION_TYPE_COPY)); } @Test @@ -1466,7 +1467,7 @@ public class UnbundledChooserActivityTest { ArgumentCaptor hashCaptor = ArgumentCaptor.forClass(HashedStringCache.HashResult.class); verify(activity.getEventLog(), times(1)).logShareTargetSelected( - eq(EventLog.SELECTION_TYPE_SERVICE), + eq(EventLogImpl.SELECTION_TYPE_SERVICE), /* packageName= */ any(), /* positionPicked= */ anyInt(), /* directTargetAlsoRanked= */ eq(-1), @@ -1547,7 +1548,7 @@ public class UnbundledChooserActivityTest { waitForIdle(); verify(activity.getEventLog(), times(1)).logShareTargetSelected( - eq(EventLog.SELECTION_TYPE_SERVICE), + eq(EventLogImpl.SELECTION_TYPE_SERVICE), /* packageName= */ any(), /* positionPicked= */ anyInt(), /* directTargetAlsoRanked= */ eq(0), @@ -1963,7 +1964,7 @@ public class UnbundledChooserActivityTest { EventLog logger = wrapper.getEventLog(); verify(logger, times(1)).logShareTargetSelected( - eq(EventLog.SELECTION_TYPE_SERVICE), + eq(EventLogImpl.SELECTION_TYPE_SERVICE), /* packageName= */ any(), /* positionPicked= */ anyInt(), // The packages sholdn't match for app target and direct target: @@ -2296,7 +2297,7 @@ public class UnbundledChooserActivityTest { EventLog logger = activity.getEventLog(); ArgumentCaptor typeCaptor = ArgumentCaptor.forClass(Integer.class); verify(logger, times(1)).logShareTargetSelected( - eq(EventLog.SELECTION_TYPE_SERVICE), + eq(EventLogImpl.SELECTION_TYPE_SERVICE), /* packageName= */ any(), /* positionPicked= */ anyInt(), /* directTargetAlsoRanked= */ anyInt(), diff --git a/java/tests/src/com/android/intentresolver/logging/EventLogImplTest.java b/java/tests/src/com/android/intentresolver/logging/EventLogImplTest.java new file mode 100644 index 00000000..19177798 --- /dev/null +++ b/java/tests/src/com/android/intentresolver/logging/EventLogImplTest.java @@ -0,0 +1,421 @@ +/* + * Copyright (C) 2023 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.intentresolver.logging; + +import static com.google.common.truth.Truth.assertThat; + +import static org.mockito.AdditionalMatchers.gt; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyBoolean; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.ArgumentMatchers.isNull; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoMoreInteractions; + +import android.content.Intent; +import android.metrics.LogMaker; + +import com.android.intentresolver.logging.EventLogImpl.SharesheetStandardEvent; +import com.android.intentresolver.logging.EventLogImpl.SharesheetStartedEvent; +import com.android.intentresolver.logging.EventLogImpl.SharesheetTargetSelectedEvent; +import com.android.intentresolver.contentpreview.ContentPreviewType; +import com.android.internal.logging.InstanceId; +import com.android.internal.logging.MetricsLogger; +import com.android.internal.logging.UiEventLogger; +import com.android.internal.logging.UiEventLogger.UiEventEnum; +import com.android.internal.logging.nano.MetricsProto.MetricsEvent; +import com.android.internal.util.FrameworkStatsLog; + +import org.junit.After; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.junit.MockitoJUnitRunner; + +@RunWith(MockitoJUnitRunner.class) +public final class EventLogImplTest { + @Mock private UiEventLogger mUiEventLog; + @Mock private FrameworkStatsLogger mFrameworkLog; + @Mock private MetricsLogger mMetricsLogger; + + private EventLogImpl mChooserLogger; + + @Before + public void setUp() { + //Mockito.reset(mUiEventLog, mFrameworkLog, mMetricsLogger); + mChooserLogger = new EventLogImpl(mUiEventLog, mFrameworkLog, mMetricsLogger); + } + + @After + public void tearDown() { + verifyNoMoreInteractions(mUiEventLog); + verifyNoMoreInteractions(mFrameworkLog); + verifyNoMoreInteractions(mMetricsLogger); + } + + @Test + public void testLogChooserActivityShown_personalProfile() { + final boolean isWorkProfile = false; + final String mimeType = "application/TestType"; + final long systemCost = 456; + + mChooserLogger.logChooserActivityShown(isWorkProfile, mimeType, systemCost); + + ArgumentCaptor eventCaptor = ArgumentCaptor.forClass(LogMaker.class); + verify(mMetricsLogger).write(eventCaptor.capture()); + LogMaker event = eventCaptor.getValue(); + + assertThat(event.getCategory()).isEqualTo(MetricsEvent.ACTION_ACTIVITY_CHOOSER_SHOWN); + assertThat(event.getSubtype()).isEqualTo(MetricsEvent.PARENT_PROFILE); + assertThat(event.getTaggedData(MetricsEvent.FIELD_SHARESHEET_MIMETYPE)).isEqualTo(mimeType); + assertThat(event.getTaggedData(MetricsEvent.FIELD_TIME_TO_APP_TARGETS)) + .isEqualTo(systemCost); + } + + @Test + public void testLogChooserActivityShown_workProfile() { + final boolean isWorkProfile = true; + final String mimeType = "application/TestType"; + final long systemCost = 456; + + mChooserLogger.logChooserActivityShown(isWorkProfile, mimeType, systemCost); + + ArgumentCaptor eventCaptor = ArgumentCaptor.forClass(LogMaker.class); + verify(mMetricsLogger).write(eventCaptor.capture()); + LogMaker event = eventCaptor.getValue(); + + assertThat(event.getCategory()).isEqualTo(MetricsEvent.ACTION_ACTIVITY_CHOOSER_SHOWN); + assertThat(event.getSubtype()).isEqualTo(MetricsEvent.MANAGED_PROFILE); + assertThat(event.getTaggedData(MetricsEvent.FIELD_SHARESHEET_MIMETYPE)).isEqualTo(mimeType); + assertThat(event.getTaggedData(MetricsEvent.FIELD_TIME_TO_APP_TARGETS)) + .isEqualTo(systemCost); + } + + @Test + public void testLogShareStarted() { + final String packageName = "com.test.foo"; + final String mimeType = "text/plain"; + final int appProvidedDirectTargets = 123; + final int appProvidedAppTargets = 456; + final boolean workProfile = true; + final int previewType = ContentPreviewType.CONTENT_PREVIEW_FILE; + final String intentAction = Intent.ACTION_SENDTO; + final int numCustomActions = 3; + final boolean modifyShareProvided = true; + + mChooserLogger.logShareStarted( + packageName, + mimeType, + appProvidedDirectTargets, + appProvidedAppTargets, + workProfile, + previewType, + intentAction, + numCustomActions, + modifyShareProvided); + + verify(mFrameworkLog).write( + eq(FrameworkStatsLog.SHARESHEET_STARTED), + eq(SharesheetStartedEvent.SHARE_STARTED.getId()), + eq(packageName), + /* instanceId=*/ gt(0), + eq(mimeType), + eq(appProvidedDirectTargets), + eq(appProvidedAppTargets), + eq(workProfile), + eq(FrameworkStatsLog.SHARESHEET_STARTED__PREVIEW_TYPE__CONTENT_PREVIEW_FILE), + eq(FrameworkStatsLog.SHARESHEET_STARTED__INTENT_TYPE__INTENT_ACTION_SENDTO), + /* custom actions provided */ eq(numCustomActions), + /* reselection action provided */ eq(modifyShareProvided)); + } + + @Test + public void testLogShareTargetSelected() { + final int targetType = EventLogImpl.SELECTION_TYPE_SERVICE; + final String packageName = "com.test.foo"; + final int positionPicked = 123; + final int directTargetAlsoRanked = -1; + final int callerTargetCount = 0; + final boolean isPinned = true; + final boolean isSuccessfullySelected = true; + final long selectionCost = 456; + + mChooserLogger.logShareTargetSelected( + targetType, + packageName, + positionPicked, + directTargetAlsoRanked, + callerTargetCount, + /* directTargetHashed= */ null, + isPinned, + isSuccessfullySelected, + selectionCost); + + verify(mFrameworkLog).write( + eq(FrameworkStatsLog.RANKING_SELECTED), + eq(SharesheetTargetSelectedEvent.SHARESHEET_SERVICE_TARGET_SELECTED.getId()), + eq(packageName), + /* instanceId=*/ gt(0), + eq(positionPicked), + eq(isPinned)); + + ArgumentCaptor eventCaptor = ArgumentCaptor.forClass(LogMaker.class); + verify(mMetricsLogger).write(eventCaptor.capture()); + LogMaker event = eventCaptor.getValue(); + assertThat(event.getCategory()).isEqualTo( + MetricsEvent.ACTION_ACTIVITY_CHOOSER_PICKED_SERVICE_TARGET); + assertThat(event.getSubtype()).isEqualTo(positionPicked); + } + + @Test + public void testLogActionSelected() { + mChooserLogger.logActionSelected(EventLogImpl.SELECTION_TYPE_COPY); + + verify(mFrameworkLog).write( + eq(FrameworkStatsLog.RANKING_SELECTED), + eq(SharesheetTargetSelectedEvent.SHARESHEET_COPY_TARGET_SELECTED.getId()), + eq(""), + /* instanceId=*/ gt(0), + eq(-1), + eq(false)); + + ArgumentCaptor eventCaptor = ArgumentCaptor.forClass(LogMaker.class); + verify(mMetricsLogger).write(eventCaptor.capture()); + LogMaker event = eventCaptor.getValue(); + assertThat(event.getCategory()).isEqualTo( + MetricsEvent.ACTION_ACTIVITY_CHOOSER_PICKED_SYSTEM_TARGET); + assertThat(event.getSubtype()).isEqualTo(1); + } + + @Test + public void testLogCustomActionSelected() { + final int position = 4; + mChooserLogger.logCustomActionSelected(position); + + verify(mFrameworkLog).write( + eq(FrameworkStatsLog.RANKING_SELECTED), + eq(SharesheetTargetSelectedEvent.SHARESHEET_CUSTOM_ACTION_SELECTED.getId()), + any(), anyInt(), eq(position), eq(false)); + } + + @Test + public void testLogDirectShareTargetReceived() { + final int category = MetricsEvent.ACTION_DIRECT_SHARE_TARGETS_LOADED_SHORTCUT_MANAGER; + final int latency = 123; + + mChooserLogger.logDirectShareTargetReceived(category, latency); + + ArgumentCaptor eventCaptor = ArgumentCaptor.forClass(LogMaker.class); + verify(mMetricsLogger).write(eventCaptor.capture()); + LogMaker event = eventCaptor.getValue(); + assertThat(event.getCategory()).isEqualTo(category); + assertThat(event.getSubtype()).isEqualTo(latency); + } + + @Test + public void testLogActionShareWithPreview() { + final int previewType = ContentPreviewType.CONTENT_PREVIEW_TEXT; + + mChooserLogger.logActionShareWithPreview(previewType); + + ArgumentCaptor eventCaptor = ArgumentCaptor.forClass(LogMaker.class); + verify(mMetricsLogger).write(eventCaptor.capture()); + LogMaker event = eventCaptor.getValue(); + assertThat(event.getCategory()).isEqualTo(MetricsEvent.ACTION_SHARE_WITH_PREVIEW); + assertThat(event.getSubtype()).isEqualTo(previewType); + } + + @Test + public void testLogSharesheetTriggered() { + mChooserLogger.logSharesheetTriggered(); + verify(mUiEventLog).logWithInstanceId( + eq(SharesheetStandardEvent.SHARESHEET_TRIGGERED), eq(0), isNull(), any()); + } + + @Test + public void testLogSharesheetAppLoadComplete() { + mChooserLogger.logSharesheetAppLoadComplete(); + verify(mUiEventLog).logWithInstanceId( + eq(SharesheetStandardEvent.SHARESHEET_APP_LOAD_COMPLETE), eq(0), isNull(), any()); + } + + @Test + public void testLogSharesheetDirectLoadComplete() { + mChooserLogger.logSharesheetDirectLoadComplete(); + verify(mUiEventLog).logWithInstanceId( + eq(SharesheetStandardEvent.SHARESHEET_DIRECT_LOAD_COMPLETE), + eq(0), + isNull(), + any()); + } + + @Test + public void testLogSharesheetDirectLoadTimeout() { + mChooserLogger.logSharesheetDirectLoadTimeout(); + verify(mUiEventLog).logWithInstanceId( + eq(SharesheetStandardEvent.SHARESHEET_DIRECT_LOAD_TIMEOUT), eq(0), isNull(), any()); + } + + @Test + public void testLogSharesheetProfileChanged() { + mChooserLogger.logSharesheetProfileChanged(); + verify(mUiEventLog).logWithInstanceId( + eq(SharesheetStandardEvent.SHARESHEET_PROFILE_CHANGED), eq(0), isNull(), any()); + } + + @Test + public void testLogSharesheetExpansionChanged_collapsed() { + mChooserLogger.logSharesheetExpansionChanged(/* isCollapsed=*/ true); + verify(mUiEventLog).logWithInstanceId( + eq(SharesheetStandardEvent.SHARESHEET_COLLAPSED), eq(0), isNull(), any()); + } + + @Test + public void testLogSharesheetExpansionChanged_expanded() { + mChooserLogger.logSharesheetExpansionChanged(/* isCollapsed=*/ false); + verify(mUiEventLog).logWithInstanceId( + eq(SharesheetStandardEvent.SHARESHEET_EXPANDED), eq(0), isNull(), any()); + } + + @Test + public void testLogSharesheetAppShareRankingTimeout() { + mChooserLogger.logSharesheetAppShareRankingTimeout(); + verify(mUiEventLog).logWithInstanceId( + eq(SharesheetStandardEvent.SHARESHEET_APP_SHARE_RANKING_TIMEOUT), + eq(0), + isNull(), + any()); + } + + @Test + public void testLogSharesheetEmptyDirectShareRow() { + mChooserLogger.logSharesheetEmptyDirectShareRow(); + verify(mUiEventLog).logWithInstanceId( + eq(SharesheetStandardEvent.SHARESHEET_EMPTY_DIRECT_SHARE_ROW), + eq(0), + isNull(), + any()); + } + + @Test + public void testDifferentLoggerInstancesUseDifferentInstanceIds() { + ArgumentCaptor idIntCaptor = ArgumentCaptor.forClass(Integer.class); + EventLogImpl chooserLogger2 = + new EventLogImpl(mUiEventLog, mFrameworkLog, mMetricsLogger); + + final int targetType = EventLogImpl.SELECTION_TYPE_COPY; + final String packageName = "com.test.foo"; + final int positionPicked = 123; + final int directTargetAlsoRanked = -1; + final int callerTargetCount = 0; + final boolean isPinned = true; + final boolean isSuccessfullySelected = true; + final long selectionCost = 456; + + mChooserLogger.logShareTargetSelected( + targetType, + packageName, + positionPicked, + directTargetAlsoRanked, + callerTargetCount, + /* directTargetHashed= */ null, + isPinned, + isSuccessfullySelected, + selectionCost); + + chooserLogger2.logShareTargetSelected( + targetType, + packageName, + positionPicked, + directTargetAlsoRanked, + callerTargetCount, + /* directTargetHashed= */ null, + isPinned, + isSuccessfullySelected, + selectionCost); + + verify(mFrameworkLog, times(2)).write( + anyInt(), anyInt(), anyString(), idIntCaptor.capture(), anyInt(), anyBoolean()); + + int id1 = idIntCaptor.getAllValues().get(0); + int id2 = idIntCaptor.getAllValues().get(1); + + assertThat(id1).isGreaterThan(0); + assertThat(id2).isGreaterThan(0); + assertThat(id1).isNotEqualTo(id2); + } + + @Test + public void testUiAndFrameworkEventsUseSameInstanceIdForSameLoggerInstance() { + ArgumentCaptor idIntCaptor = ArgumentCaptor.forClass(Integer.class); + ArgumentCaptor idObjectCaptor = ArgumentCaptor.forClass(InstanceId.class); + + final int targetType = EventLogImpl.SELECTION_TYPE_COPY; + final String packageName = "com.test.foo"; + final int positionPicked = 123; + final int directTargetAlsoRanked = -1; + final int callerTargetCount = 0; + final boolean isPinned = true; + final boolean isSuccessfullySelected = true; + final long selectionCost = 456; + + mChooserLogger.logShareTargetSelected( + targetType, + packageName, + positionPicked, + directTargetAlsoRanked, + callerTargetCount, + /* directTargetHashed= */ null, + isPinned, + isSuccessfullySelected, + selectionCost); + + verify(mFrameworkLog).write( + anyInt(), anyInt(), anyString(), idIntCaptor.capture(), anyInt(), anyBoolean()); + + mChooserLogger.logSharesheetTriggered(); + verify(mUiEventLog).logWithInstanceId( + any(UiEventEnum.class), anyInt(), any(), idObjectCaptor.capture()); + + assertThat(idIntCaptor.getValue()).isGreaterThan(0); + assertThat(idObjectCaptor.getValue().getId()).isEqualTo(idIntCaptor.getValue()); + } + + @Test + public void testTargetSelectionCategories() { + assertThat(EventLogImpl.getTargetSelectionCategory( + EventLogImpl.SELECTION_TYPE_SERVICE)) + .isEqualTo(MetricsEvent.ACTION_ACTIVITY_CHOOSER_PICKED_SERVICE_TARGET); + assertThat(EventLogImpl.getTargetSelectionCategory( + EventLogImpl.SELECTION_TYPE_APP)) + .isEqualTo(MetricsEvent.ACTION_ACTIVITY_CHOOSER_PICKED_APP_TARGET); + assertThat(EventLogImpl.getTargetSelectionCategory( + EventLogImpl.SELECTION_TYPE_STANDARD)) + .isEqualTo(MetricsEvent.ACTION_ACTIVITY_CHOOSER_PICKED_STANDARD_TARGET); + assertThat(EventLogImpl.getTargetSelectionCategory( + EventLogImpl.SELECTION_TYPE_COPY)).isEqualTo(0); + assertThat(EventLogImpl.getTargetSelectionCategory( + EventLogImpl.SELECTION_TYPE_NEARBY)).isEqualTo(0); + assertThat(EventLogImpl.getTargetSelectionCategory( + EventLogImpl.SELECTION_TYPE_EDIT)).isEqualTo(0); + } +} diff --git a/java/tests/src/com/android/intentresolver/logging/EventLogTest.java b/java/tests/src/com/android/intentresolver/logging/EventLogTest.java deleted file mode 100644 index 17452774..00000000 --- a/java/tests/src/com/android/intentresolver/logging/EventLogTest.java +++ /dev/null @@ -1,422 +0,0 @@ -/* - * Copyright (C) 2023 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.android.intentresolver.logging; - -import static com.google.common.truth.Truth.assertThat; - -import static org.mockito.AdditionalMatchers.gt; -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.ArgumentMatchers.anyBoolean; -import static org.mockito.ArgumentMatchers.anyInt; -import static org.mockito.ArgumentMatchers.anyString; -import static org.mockito.ArgumentMatchers.eq; -import static org.mockito.ArgumentMatchers.isNull; -import static org.mockito.Mockito.times; -import static org.mockito.Mockito.verify; -import static org.mockito.Mockito.verifyNoMoreInteractions; - -import android.content.Intent; -import android.metrics.LogMaker; - -import com.android.intentresolver.logging.EventLog.FrameworkStatsLogger; -import com.android.intentresolver.logging.EventLog.SharesheetStandardEvent; -import com.android.intentresolver.logging.EventLog.SharesheetStartedEvent; -import com.android.intentresolver.logging.EventLog.SharesheetTargetSelectedEvent; -import com.android.intentresolver.contentpreview.ContentPreviewType; -import com.android.internal.logging.InstanceId; -import com.android.internal.logging.MetricsLogger; -import com.android.internal.logging.UiEventLogger; -import com.android.internal.logging.UiEventLogger.UiEventEnum; -import com.android.internal.logging.nano.MetricsProto.MetricsEvent; -import com.android.internal.util.FrameworkStatsLog; - -import org.junit.After; -import org.junit.Before; -import org.junit.Test; -import org.junit.runner.RunWith; -import org.mockito.ArgumentCaptor; -import org.mockito.Mock; -import org.mockito.junit.MockitoJUnitRunner; - -@RunWith(MockitoJUnitRunner.class) -public final class EventLogTest { - @Mock private UiEventLogger mUiEventLog; - @Mock private FrameworkStatsLogger mFrameworkLog; - @Mock private MetricsLogger mMetricsLogger; - - private EventLog mChooserLogger; - - @Before - public void setUp() { - //Mockito.reset(mUiEventLog, mFrameworkLog, mMetricsLogger); - mChooserLogger = new EventLog(mUiEventLog, mFrameworkLog, mMetricsLogger); - } - - @After - public void tearDown() { - verifyNoMoreInteractions(mUiEventLog); - verifyNoMoreInteractions(mFrameworkLog); - verifyNoMoreInteractions(mMetricsLogger); - } - - @Test - public void testLogChooserActivityShown_personalProfile() { - final boolean isWorkProfile = false; - final String mimeType = "application/TestType"; - final long systemCost = 456; - - mChooserLogger.logChooserActivityShown(isWorkProfile, mimeType, systemCost); - - ArgumentCaptor eventCaptor = ArgumentCaptor.forClass(LogMaker.class); - verify(mMetricsLogger).write(eventCaptor.capture()); - LogMaker event = eventCaptor.getValue(); - - assertThat(event.getCategory()).isEqualTo(MetricsEvent.ACTION_ACTIVITY_CHOOSER_SHOWN); - assertThat(event.getSubtype()).isEqualTo(MetricsEvent.PARENT_PROFILE); - assertThat(event.getTaggedData(MetricsEvent.FIELD_SHARESHEET_MIMETYPE)).isEqualTo(mimeType); - assertThat(event.getTaggedData(MetricsEvent.FIELD_TIME_TO_APP_TARGETS)) - .isEqualTo(systemCost); - } - - @Test - public void testLogChooserActivityShown_workProfile() { - final boolean isWorkProfile = true; - final String mimeType = "application/TestType"; - final long systemCost = 456; - - mChooserLogger.logChooserActivityShown(isWorkProfile, mimeType, systemCost); - - ArgumentCaptor eventCaptor = ArgumentCaptor.forClass(LogMaker.class); - verify(mMetricsLogger).write(eventCaptor.capture()); - LogMaker event = eventCaptor.getValue(); - - assertThat(event.getCategory()).isEqualTo(MetricsEvent.ACTION_ACTIVITY_CHOOSER_SHOWN); - assertThat(event.getSubtype()).isEqualTo(MetricsEvent.MANAGED_PROFILE); - assertThat(event.getTaggedData(MetricsEvent.FIELD_SHARESHEET_MIMETYPE)).isEqualTo(mimeType); - assertThat(event.getTaggedData(MetricsEvent.FIELD_TIME_TO_APP_TARGETS)) - .isEqualTo(systemCost); - } - - @Test - public void testLogShareStarted() { - final String packageName = "com.test.foo"; - final String mimeType = "text/plain"; - final int appProvidedDirectTargets = 123; - final int appProvidedAppTargets = 456; - final boolean workProfile = true; - final int previewType = ContentPreviewType.CONTENT_PREVIEW_FILE; - final String intentAction = Intent.ACTION_SENDTO; - final int numCustomActions = 3; - final boolean modifyShareProvided = true; - - mChooserLogger.logShareStarted( - packageName, - mimeType, - appProvidedDirectTargets, - appProvidedAppTargets, - workProfile, - previewType, - intentAction, - numCustomActions, - modifyShareProvided); - - verify(mFrameworkLog).write( - eq(FrameworkStatsLog.SHARESHEET_STARTED), - eq(SharesheetStartedEvent.SHARE_STARTED.getId()), - eq(packageName), - /* instanceId=*/ gt(0), - eq(mimeType), - eq(appProvidedDirectTargets), - eq(appProvidedAppTargets), - eq(workProfile), - eq(FrameworkStatsLog.SHARESHEET_STARTED__PREVIEW_TYPE__CONTENT_PREVIEW_FILE), - eq(FrameworkStatsLog.SHARESHEET_STARTED__INTENT_TYPE__INTENT_ACTION_SENDTO), - /* custom actions provided */ eq(numCustomActions), - /* reselection action provided */ eq(modifyShareProvided)); - } - - @Test - public void testLogShareTargetSelected() { - final int targetType = EventLog.SELECTION_TYPE_SERVICE; - final String packageName = "com.test.foo"; - final int positionPicked = 123; - final int directTargetAlsoRanked = -1; - final int callerTargetCount = 0; - final boolean isPinned = true; - final boolean isSuccessfullySelected = true; - final long selectionCost = 456; - - mChooserLogger.logShareTargetSelected( - targetType, - packageName, - positionPicked, - directTargetAlsoRanked, - callerTargetCount, - /* directTargetHashed= */ null, - isPinned, - isSuccessfullySelected, - selectionCost); - - verify(mFrameworkLog).write( - eq(FrameworkStatsLog.RANKING_SELECTED), - eq(SharesheetTargetSelectedEvent.SHARESHEET_SERVICE_TARGET_SELECTED.getId()), - eq(packageName), - /* instanceId=*/ gt(0), - eq(positionPicked), - eq(isPinned)); - - ArgumentCaptor eventCaptor = ArgumentCaptor.forClass(LogMaker.class); - verify(mMetricsLogger).write(eventCaptor.capture()); - LogMaker event = eventCaptor.getValue(); - assertThat(event.getCategory()).isEqualTo( - MetricsEvent.ACTION_ACTIVITY_CHOOSER_PICKED_SERVICE_TARGET); - assertThat(event.getSubtype()).isEqualTo(positionPicked); - } - - @Test - public void testLogActionSelected() { - mChooserLogger.logActionSelected(EventLog.SELECTION_TYPE_COPY); - - verify(mFrameworkLog).write( - eq(FrameworkStatsLog.RANKING_SELECTED), - eq(SharesheetTargetSelectedEvent.SHARESHEET_COPY_TARGET_SELECTED.getId()), - eq(""), - /* instanceId=*/ gt(0), - eq(-1), - eq(false)); - - ArgumentCaptor eventCaptor = ArgumentCaptor.forClass(LogMaker.class); - verify(mMetricsLogger).write(eventCaptor.capture()); - LogMaker event = eventCaptor.getValue(); - assertThat(event.getCategory()).isEqualTo( - MetricsEvent.ACTION_ACTIVITY_CHOOSER_PICKED_SYSTEM_TARGET); - assertThat(event.getSubtype()).isEqualTo(1); - } - - @Test - public void testLogCustomActionSelected() { - final int position = 4; - mChooserLogger.logCustomActionSelected(position); - - verify(mFrameworkLog).write( - eq(FrameworkStatsLog.RANKING_SELECTED), - eq(SharesheetTargetSelectedEvent.SHARESHEET_CUSTOM_ACTION_SELECTED.getId()), - any(), anyInt(), eq(position), eq(false)); - } - - @Test - public void testLogDirectShareTargetReceived() { - final int category = MetricsEvent.ACTION_DIRECT_SHARE_TARGETS_LOADED_SHORTCUT_MANAGER; - final int latency = 123; - - mChooserLogger.logDirectShareTargetReceived(category, latency); - - ArgumentCaptor eventCaptor = ArgumentCaptor.forClass(LogMaker.class); - verify(mMetricsLogger).write(eventCaptor.capture()); - LogMaker event = eventCaptor.getValue(); - assertThat(event.getCategory()).isEqualTo(category); - assertThat(event.getSubtype()).isEqualTo(latency); - } - - @Test - public void testLogActionShareWithPreview() { - final int previewType = ContentPreviewType.CONTENT_PREVIEW_TEXT; - - mChooserLogger.logActionShareWithPreview(previewType); - - ArgumentCaptor eventCaptor = ArgumentCaptor.forClass(LogMaker.class); - verify(mMetricsLogger).write(eventCaptor.capture()); - LogMaker event = eventCaptor.getValue(); - assertThat(event.getCategory()).isEqualTo(MetricsEvent.ACTION_SHARE_WITH_PREVIEW); - assertThat(event.getSubtype()).isEqualTo(previewType); - } - - @Test - public void testLogSharesheetTriggered() { - mChooserLogger.logSharesheetTriggered(); - verify(mUiEventLog).logWithInstanceId( - eq(SharesheetStandardEvent.SHARESHEET_TRIGGERED), eq(0), isNull(), any()); - } - - @Test - public void testLogSharesheetAppLoadComplete() { - mChooserLogger.logSharesheetAppLoadComplete(); - verify(mUiEventLog).logWithInstanceId( - eq(SharesheetStandardEvent.SHARESHEET_APP_LOAD_COMPLETE), eq(0), isNull(), any()); - } - - @Test - public void testLogSharesheetDirectLoadComplete() { - mChooserLogger.logSharesheetDirectLoadComplete(); - verify(mUiEventLog).logWithInstanceId( - eq(SharesheetStandardEvent.SHARESHEET_DIRECT_LOAD_COMPLETE), - eq(0), - isNull(), - any()); - } - - @Test - public void testLogSharesheetDirectLoadTimeout() { - mChooserLogger.logSharesheetDirectLoadTimeout(); - verify(mUiEventLog).logWithInstanceId( - eq(SharesheetStandardEvent.SHARESHEET_DIRECT_LOAD_TIMEOUT), eq(0), isNull(), any()); - } - - @Test - public void testLogSharesheetProfileChanged() { - mChooserLogger.logSharesheetProfileChanged(); - verify(mUiEventLog).logWithInstanceId( - eq(SharesheetStandardEvent.SHARESHEET_PROFILE_CHANGED), eq(0), isNull(), any()); - } - - @Test - public void testLogSharesheetExpansionChanged_collapsed() { - mChooserLogger.logSharesheetExpansionChanged(/* isCollapsed=*/ true); - verify(mUiEventLog).logWithInstanceId( - eq(SharesheetStandardEvent.SHARESHEET_COLLAPSED), eq(0), isNull(), any()); - } - - @Test - public void testLogSharesheetExpansionChanged_expanded() { - mChooserLogger.logSharesheetExpansionChanged(/* isCollapsed=*/ false); - verify(mUiEventLog).logWithInstanceId( - eq(SharesheetStandardEvent.SHARESHEET_EXPANDED), eq(0), isNull(), any()); - } - - @Test - public void testLogSharesheetAppShareRankingTimeout() { - mChooserLogger.logSharesheetAppShareRankingTimeout(); - verify(mUiEventLog).logWithInstanceId( - eq(SharesheetStandardEvent.SHARESHEET_APP_SHARE_RANKING_TIMEOUT), - eq(0), - isNull(), - any()); - } - - @Test - public void testLogSharesheetEmptyDirectShareRow() { - mChooserLogger.logSharesheetEmptyDirectShareRow(); - verify(mUiEventLog).logWithInstanceId( - eq(SharesheetStandardEvent.SHARESHEET_EMPTY_DIRECT_SHARE_ROW), - eq(0), - isNull(), - any()); - } - - @Test - public void testDifferentLoggerInstancesUseDifferentInstanceIds() { - ArgumentCaptor idIntCaptor = ArgumentCaptor.forClass(Integer.class); - EventLog chooserLogger2 = - new EventLog(mUiEventLog, mFrameworkLog, mMetricsLogger); - - final int targetType = EventLog.SELECTION_TYPE_COPY; - final String packageName = "com.test.foo"; - final int positionPicked = 123; - final int directTargetAlsoRanked = -1; - final int callerTargetCount = 0; - final boolean isPinned = true; - final boolean isSuccessfullySelected = true; - final long selectionCost = 456; - - mChooserLogger.logShareTargetSelected( - targetType, - packageName, - positionPicked, - directTargetAlsoRanked, - callerTargetCount, - /* directTargetHashed= */ null, - isPinned, - isSuccessfullySelected, - selectionCost); - - chooserLogger2.logShareTargetSelected( - targetType, - packageName, - positionPicked, - directTargetAlsoRanked, - callerTargetCount, - /* directTargetHashed= */ null, - isPinned, - isSuccessfullySelected, - selectionCost); - - verify(mFrameworkLog, times(2)).write( - anyInt(), anyInt(), anyString(), idIntCaptor.capture(), anyInt(), anyBoolean()); - - int id1 = idIntCaptor.getAllValues().get(0); - int id2 = idIntCaptor.getAllValues().get(1); - - assertThat(id1).isGreaterThan(0); - assertThat(id2).isGreaterThan(0); - assertThat(id1).isNotEqualTo(id2); - } - - @Test - public void testUiAndFrameworkEventsUseSameInstanceIdForSameLoggerInstance() { - ArgumentCaptor idIntCaptor = ArgumentCaptor.forClass(Integer.class); - ArgumentCaptor idObjectCaptor = ArgumentCaptor.forClass(InstanceId.class); - - final int targetType = EventLog.SELECTION_TYPE_COPY; - final String packageName = "com.test.foo"; - final int positionPicked = 123; - final int directTargetAlsoRanked = -1; - final int callerTargetCount = 0; - final boolean isPinned = true; - final boolean isSuccessfullySelected = true; - final long selectionCost = 456; - - mChooserLogger.logShareTargetSelected( - targetType, - packageName, - positionPicked, - directTargetAlsoRanked, - callerTargetCount, - /* directTargetHashed= */ null, - isPinned, - isSuccessfullySelected, - selectionCost); - - verify(mFrameworkLog).write( - anyInt(), anyInt(), anyString(), idIntCaptor.capture(), anyInt(), anyBoolean()); - - mChooserLogger.logSharesheetTriggered(); - verify(mUiEventLog).logWithInstanceId( - any(UiEventEnum.class), anyInt(), any(), idObjectCaptor.capture()); - - assertThat(idIntCaptor.getValue()).isGreaterThan(0); - assertThat(idObjectCaptor.getValue().getId()).isEqualTo(idIntCaptor.getValue()); - } - - @Test - public void testTargetSelectionCategories() { - assertThat(EventLog.getTargetSelectionCategory( - EventLog.SELECTION_TYPE_SERVICE)) - .isEqualTo(MetricsEvent.ACTION_ACTIVITY_CHOOSER_PICKED_SERVICE_TARGET); - assertThat(EventLog.getTargetSelectionCategory( - EventLog.SELECTION_TYPE_APP)) - .isEqualTo(MetricsEvent.ACTION_ACTIVITY_CHOOSER_PICKED_APP_TARGET); - assertThat(EventLog.getTargetSelectionCategory( - EventLog.SELECTION_TYPE_STANDARD)) - .isEqualTo(MetricsEvent.ACTION_ACTIVITY_CHOOSER_PICKED_STANDARD_TARGET); - assertThat(EventLog.getTargetSelectionCategory( - EventLog.SELECTION_TYPE_COPY)).isEqualTo(0); - assertThat(EventLog.getTargetSelectionCategory( - EventLog.SELECTION_TYPE_NEARBY)).isEqualTo(0); - assertThat(EventLog.getTargetSelectionCategory( - EventLog.SELECTION_TYPE_EDIT)).isEqualTo(0); - } -} -- cgit v1.2.3-59-g8ed1b From 500df2ce5d4c36934a347aeaed5435e4d136453e Mon Sep 17 00:00:00 2001 From: Joshua Trask Date: Wed, 23 Aug 2023 22:19:54 +0000 Subject: Switch activities to AnnotatedUserHandles fully As we proceed with our refactoring of other components that may benefit from AnnotatedUserHandles, it's important that we keep consistent values; in one local experiment, I had to debug tests that failed after injecting AnnotatedUserHandles into one such component, because the test overrides weren't reflected in the annotated records. This CL switches to AnnotatedUserHandles as the "single source of truth" (at least w/r/t the getters exposed in the activities/test wrappers; downstream clients can switch over incrementally). This is essentially the same as (abandoned) ag/24083383, but this version is more thorough at switching over in the tests; the previous version used the legacy (per-handle) data overrides and then adapted to AnnotatedUserHandles on-the-fly in the one overridden accessor method. The current approach better mirrors our real app usage in tests. Note: as in that earlier draft, this also switches some test cases to specifying the "handle Sharesheet launched as" instead of the "tab owner user handle for launch." The "launch handle" is an independent environment variable that we use to *derive* the "tab owner" according to our application logic, and we shouldn't skip that in tests. Test: IntentResolverUnitTests, CtsSharesheetDeviceTest Bug: 286249609 Change-Id: I3ad911c691b43db52a2774c74dab58eeddb8e443 --- .../android/intentresolver/ChooserActivity.java | 49 ++++--- .../android/intentresolver/ResolverActivity.java | 138 +++++++++---------- .../ChooserActivityOverrideData.java | 12 +- .../intentresolver/ChooserWrapperActivity.java | 12 +- .../intentresolver/ResolverActivityTest.java | 148 +++++++++++---------- .../intentresolver/ResolverWrapperActivity.java | 28 ++-- .../UnbundledChooserActivityTest.java | 63 +++++---- .../UnbundledChooserActivityWorkProfileTest.java | 12 +- 8 files changed, 228 insertions(+), 234 deletions(-) (limited to 'java/src') diff --git a/java/src/com/android/intentresolver/ChooserActivity.java b/java/src/com/android/intentresolver/ChooserActivity.java index ebe6f04b..9ae616a9 100644 --- a/java/src/com/android/intentresolver/ChooserActivity.java +++ b/java/src/com/android/intentresolver/ChooserActivity.java @@ -368,13 +368,13 @@ public class ChooserActivity extends Hilt_ChooserActivity implements private void createProfileRecords( AppPredictorFactory factory, IntentFilter targetIntentFilter) { - UserHandle mainUserHandle = getPersonalProfileUserHandle(); + UserHandle mainUserHandle = getAnnotatedUserHandles().personalProfileUserHandle; ProfileRecord record = createProfileRecord(mainUserHandle, targetIntentFilter, factory); if (record.shortcutLoader == null) { Tracer.INSTANCE.endLaunchToShortcutTrace(); } - UserHandle workUserHandle = getWorkProfileUserHandle(); + UserHandle workUserHandle = getAnnotatedUserHandles().workProfileUserHandle; if (workUserHandle != null) { createProfileRecord(workUserHandle, targetIntentFilter, factory); } @@ -467,9 +467,12 @@ public class ChooserActivity extends Hilt_ChooserActivity implements /* devicePolicyEventId= */ RESOLVER_EMPTY_STATE_NO_SHARING_TO_WORK, /* devicePolicyEventCategory= */ ResolverActivity.METRICS_CATEGORY_CHOOSER); - return new NoCrossProfileEmptyStateProvider(getPersonalProfileUserHandle(), - noWorkToPersonalEmptyState, noPersonalToWorkEmptyState, - createCrossProfileIntentsChecker(), getTabOwnerUserHandleForLaunch()); + return new NoCrossProfileEmptyStateProvider( + getAnnotatedUserHandles().personalProfileUserHandle, + noWorkToPersonalEmptyState, + noPersonalToWorkEmptyState, + createCrossProfileIntentsChecker(), + getAnnotatedUserHandles().tabOwnerUserHandleForLaunch); } private ChooserMultiProfilePagerAdapter createChooserMultiProfilePagerAdapterForOneProfile( @@ -483,7 +486,7 @@ public class ChooserActivity extends Hilt_ChooserActivity implements initialIntents, rList, filterLastUsed, - /* userHandle */ getPersonalProfileUserHandle(), + /* userHandle */ getAnnotatedUserHandles().personalProfileUserHandle, targetDataLoader); return new ChooserMultiProfilePagerAdapter( /* context */ this, @@ -491,7 +494,7 @@ public class ChooserActivity extends Hilt_ChooserActivity implements createEmptyStateProvider(/* workProfileUserHandle= */ null), /* workProfileQuietModeChecker= */ () -> false, /* workProfileUserHandle= */ null, - getCloneProfileUserHandle(), + getAnnotatedUserHandles().cloneProfileUserHandle, mMaxTargetsPerRow); } @@ -507,7 +510,7 @@ public class ChooserActivity extends Hilt_ChooserActivity implements selectedProfile == PROFILE_PERSONAL ? initialIntents : null, rList, filterLastUsed, - /* userHandle */ getPersonalProfileUserHandle(), + /* userHandle */ getAnnotatedUserHandles().personalProfileUserHandle, targetDataLoader); ChooserGridAdapter workAdapter = createChooserGridAdapter( /* context */ this, @@ -515,24 +518,25 @@ public class ChooserActivity extends Hilt_ChooserActivity implements selectedProfile == PROFILE_WORK ? initialIntents : null, rList, filterLastUsed, - /* userHandle */ getWorkProfileUserHandle(), + /* userHandle */ getAnnotatedUserHandles().workProfileUserHandle, targetDataLoader); return new ChooserMultiProfilePagerAdapter( /* context */ this, personalAdapter, workAdapter, - createEmptyStateProvider(/* workProfileUserHandle= */ getWorkProfileUserHandle()), + createEmptyStateProvider(getAnnotatedUserHandles().workProfileUserHandle), () -> mWorkProfileAvailability.isQuietModeEnabled(), selectedProfile, - getWorkProfileUserHandle(), - getCloneProfileUserHandle(), + getAnnotatedUserHandles().workProfileUserHandle, + getAnnotatedUserHandles().cloneProfileUserHandle, mMaxTargetsPerRow); } private int findSelectedProfile() { int selectedProfile = getSelectedProfileExtra(); if (selectedProfile == -1) { - selectedProfile = getProfileForUser(getTabOwnerUserHandleForLaunch()); + selectedProfile = getProfileForUser( + getAnnotatedUserHandles().tabOwnerUserHandleForLaunch); } return selectedProfile; } @@ -1085,7 +1089,8 @@ 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 || getCloneProfileUserHandle() != null) ? null : record.appPredictor; + return ((record == null) || (getAnnotatedUserHandles().cloneProfileUserHandle != null)) + ? null : record.appPredictor; } /** @@ -1226,8 +1231,8 @@ public class ChooserActivity extends Hilt_ChooserActivity implements int maxTargetsPerRow, TargetDataLoader targetDataLoader) { UserHandle initialIntentsUserSpace = isLaunchedAsCloneProfile() - && userHandle.equals(getPersonalProfileUserHandle()) - ? getCloneProfileUserHandle() : userHandle; + && userHandle.equals(getAnnotatedUserHandles().personalProfileUserHandle) + ? getAnnotatedUserHandles().cloneProfileUserHandle : userHandle; return new ChooserListAdapter( context, payloadIntents, @@ -1248,7 +1253,7 @@ public class ChooserActivity extends Hilt_ChooserActivity implements @Override protected void onWorkProfileStatusUpdated() { - UserHandle workUser = getWorkProfileUserHandle(); + UserHandle workUser = getAnnotatedUserHandles().workProfileUserHandle; ProfileRecord record = workUser == null ? null : getProfileRecord(workUser); if (record != null && record.shortcutLoader != null) { record.shortcutLoader.reset(); @@ -1303,7 +1308,8 @@ public class ChooserActivity extends Hilt_ChooserActivity implements new ChooserActionFactory.ActionActivityStarter() { @Override public void safelyStartActivityAsPersonalProfileUser(TargetInfo targetInfo) { - safelyStartActivityAsUser(targetInfo, getPersonalProfileUserHandle()); + safelyStartActivityAsUser( + targetInfo, getAnnotatedUserHandles().personalProfileUserHandle); finish(); } @@ -1313,11 +1319,12 @@ public class ChooserActivity extends Hilt_ChooserActivity implements ActivityOptions options = ActivityOptions.makeSceneTransitionAnimation( ChooserActivity.this, sharedElement, sharedElementName); safelyStartActivityAsUser( - targetInfo, getPersonalProfileUserHandle(), options.toBundle()); + targetInfo, + getAnnotatedUserHandles().personalProfileUserHandle, + options.toBundle()); // Can't finish right away because the shared element transition may not // be ready to start. mFinishWhenStopped = true; - } }, (status) -> { @@ -1470,7 +1477,7 @@ public class ChooserActivity extends Hilt_ChooserActivity implements * Returns {@link #PROFILE_PERSONAL}, otherwise. **/ private int getProfileForUser(UserHandle currentUserHandle) { - if (currentUserHandle.equals(getWorkProfileUserHandle())) { + if (currentUserHandle.equals(getAnnotatedUserHandles().workProfileUserHandle)) { return PROFILE_WORK; } // We return personal profile, as it is the default when there is no work profile, personal diff --git a/java/src/com/android/intentresolver/ResolverActivity.java b/java/src/com/android/intentresolver/ResolverActivity.java index 35c7e897..0d3becc2 100644 --- a/java/src/com/android/intentresolver/ResolverActivity.java +++ b/java/src/com/android/intentresolver/ResolverActivity.java @@ -239,11 +239,20 @@ public class ResolverActivity extends FragmentActivity implements // new component whose lifecycle is limited to the "created" Activity (so that we can just hold // the annotations as a `final` ivar, which is a better way to show immutability). private Supplier mLazyAnnotatedUserHandles = () -> { - final AnnotatedUserHandles result = AnnotatedUserHandles.forShareActivity(this); + final AnnotatedUserHandles result = computeAnnotatedUserHandles(); mLazyAnnotatedUserHandles = () -> result; return result; }; + // This method is called exactly once during creation to compute the immutable annotations + // accessible through the lazy supplier {@link mLazyAnnotatedUserHandles}. + // TODO: this is only defined so that tests can provide an override that injects fake + // annotations. Dagger could provide a cleaner model for our testing/injection requirements. + @VisibleForTesting(visibility = VisibleForTesting.Visibility.PRIVATE) + protected AnnotatedUserHandles computeAnnotatedUserHandles() { + return AnnotatedUserHandles.forShareActivity(this); + } + @Nullable private OnSwitchOnWorkSelectedListener mOnSwitchOnWorkSelectedListener; @@ -438,11 +447,12 @@ public class ResolverActivity extends FragmentActivity implements mPersonalPackageMonitor = createPackageMonitor( mMultiProfilePagerAdapter.getPersonalListAdapter()); mPersonalPackageMonitor.register( - this, getMainLooper(), getPersonalProfileUserHandle(), false); + this, getMainLooper(), getAnnotatedUserHandles().personalProfileUserHandle, false); if (shouldShowTabs()) { mWorkPackageMonitor = createPackageMonitor( mMultiProfilePagerAdapter.getWorkListAdapter()); - mWorkPackageMonitor.register(this, getMainLooper(), getWorkProfileUserHandle(), false); + mWorkPackageMonitor.register( + this, getMainLooper(), getAnnotatedUserHandles().workProfileUserHandle, false); } mRegistered = true; @@ -532,9 +542,12 @@ public class ResolverActivity extends FragmentActivity implements /* devicePolicyEventCategory= */ ResolverActivity.METRICS_CATEGORY_RESOLVER); - return new NoCrossProfileEmptyStateProvider(getPersonalProfileUserHandle(), - noWorkToPersonalEmptyState, noPersonalToWorkEmptyState, - createCrossProfileIntentsChecker(), getTabOwnerUserHandleForLaunch()); + return new NoCrossProfileEmptyStateProvider( + getAnnotatedUserHandles().personalProfileUserHandle, + noWorkToPersonalEmptyState, + noPersonalToWorkEmptyState, + createCrossProfileIntentsChecker(), + getAnnotatedUserHandles().tabOwnerUserHandleForLaunch); } protected int appliedThemeResId() { @@ -1014,7 +1027,7 @@ public class ResolverActivity extends FragmentActivity implements @Override // ResolverListCommunicator public void onHandlePackagesChanged(ResolverListAdapter listAdapter) { if (listAdapter == mMultiProfilePagerAdapter.getActiveListAdapter()) { - if (listAdapter.getUserHandle().equals(getWorkProfileUserHandle()) + if (listAdapter.getUserHandle().equals(getAnnotatedUserHandles().workProfileUserHandle) && mWorkProfileAvailability.isWaitingToEnableWorkProfile()) { // We have just turned on the work profile and entered the pass code to start it, // now we are waiting to receive the ACTION_USER_UNLOCKED broadcast. There is no @@ -1052,16 +1065,15 @@ public class ResolverActivity extends FragmentActivity implements } protected WorkProfileAvailabilityManager createWorkProfileAvailabilityManager() { - final UserHandle workUser = getWorkProfileUserHandle(); - return new WorkProfileAvailabilityManager( getSystemService(UserManager.class), - workUser, + getAnnotatedUserHandles().workProfileUserHandle, this::onWorkProfileStatusUpdated); } protected void onWorkProfileStatusUpdated() { - if (mMultiProfilePagerAdapter.getCurrentUserHandle().equals(getWorkProfileUserHandle())) { + if (mMultiProfilePagerAdapter.getCurrentUserHandle().equals( + getAnnotatedUserHandles().workProfileUserHandle)) { mMultiProfilePagerAdapter.rebuildActiveTab(true); } else { mMultiProfilePagerAdapter.clearInactiveProfileCache(); @@ -1079,8 +1091,8 @@ public class ResolverActivity extends FragmentActivity implements UserHandle userHandle, TargetDataLoader targetDataLoader) { UserHandle initialIntentsUserSpace = isLaunchedAsCloneProfile() - && userHandle.equals(getPersonalProfileUserHandle()) - ? getCloneProfileUserHandle() : userHandle; + && userHandle.equals(getAnnotatedUserHandles().personalProfileUserHandle) + ? getAnnotatedUserHandles().cloneProfileUserHandle : userHandle; return new ResolverListAdapter( context, payloadIntents, @@ -1136,9 +1148,9 @@ public class ResolverActivity extends FragmentActivity implements final EmptyStateProvider noAppsEmptyStateProvider = new NoAppsAvailableEmptyStateProvider( this, workProfileUserHandle, - getPersonalProfileUserHandle(), + getAnnotatedUserHandles().personalProfileUserHandle, getMetricsCategory(), - getTabOwnerUserHandleForLaunch() + getAnnotatedUserHandles().tabOwnerUserHandleForLaunch ); // Return composite provider, the order matters (the higher, the more priority) @@ -1188,7 +1200,7 @@ public class ResolverActivity extends FragmentActivity implements initialIntents, resolutionList, filterLastUsed, - /* userHandle */ getPersonalProfileUserHandle(), + /* userHandle */ getAnnotatedUserHandles().personalProfileUserHandle, targetDataLoader); return new ResolverMultiProfilePagerAdapter( /* context */ this, @@ -1196,13 +1208,13 @@ public class ResolverActivity extends FragmentActivity implements createEmptyStateProvider(/* workProfileUserHandle= */ null), /* workProfileQuietModeChecker= */ () -> false, /* workProfileUserHandle= */ null, - getCloneProfileUserHandle()); + getAnnotatedUserHandles().cloneProfileUserHandle); } private UserHandle getIntentUser() { return getIntent().hasExtra(EXTRA_CALLING_USER) ? getIntent().getParcelableExtra(EXTRA_CALLING_USER) - : getTabOwnerUserHandleForLaunch(); + : getAnnotatedUserHandles().tabOwnerUserHandleForLaunch; } private ResolverMultiProfilePagerAdapter createResolverMultiProfilePagerAdapterForTwoProfiles( @@ -1215,10 +1227,10 @@ public class ResolverActivity extends FragmentActivity implements // this happens, we check for it here and set the current profile's tab. int selectedProfile = getCurrentProfile(); UserHandle intentUser = getIntentUser(); - if (!getTabOwnerUserHandleForLaunch().equals(intentUser)) { - if (getPersonalProfileUserHandle().equals(intentUser)) { + if (!getAnnotatedUserHandles().tabOwnerUserHandleForLaunch.equals(intentUser)) { + if (getAnnotatedUserHandles().personalProfileUserHandle.equals(intentUser)) { selectedProfile = PROFILE_PERSONAL; - } else if (getWorkProfileUserHandle().equals(intentUser)) { + } else if (getAnnotatedUserHandles().workProfileUserHandle.equals(intentUser)) { selectedProfile = PROFILE_WORK; } } else { @@ -1236,10 +1248,10 @@ public class ResolverActivity extends FragmentActivity implements selectedProfile == PROFILE_PERSONAL ? initialIntents : null, resolutionList, (filterLastUsed && UserHandle.myUserId() - == getPersonalProfileUserHandle().getIdentifier()), - /* userHandle */ getPersonalProfileUserHandle(), + == getAnnotatedUserHandles().personalProfileUserHandle.getIdentifier()), + /* userHandle */ getAnnotatedUserHandles().personalProfileUserHandle, targetDataLoader); - UserHandle workProfileUserHandle = getWorkProfileUserHandle(); + UserHandle workProfileUserHandle = getAnnotatedUserHandles().workProfileUserHandle; ResolverListAdapter workAdapter = createResolverListAdapter( /* context */ this, /* payloadIntents */ mIntents, @@ -1253,11 +1265,11 @@ public class ResolverActivity extends FragmentActivity implements /* context */ this, personalAdapter, workAdapter, - createEmptyStateProvider(getWorkProfileUserHandle()), + createEmptyStateProvider(workProfileUserHandle), () -> mWorkProfileAvailability.isQuietModeEnabled(), selectedProfile, - getWorkProfileUserHandle(), - getCloneProfileUserHandle()); + workProfileUserHandle, + getAnnotatedUserHandles().cloneProfileUserHandle); } /** @@ -1280,55 +1292,29 @@ public class ResolverActivity extends FragmentActivity implements } protected final @Profile int getCurrentProfile() { - return (getTabOwnerUserHandleForLaunch().equals(getPersonalProfileUserHandle()) - ? PROFILE_PERSONAL : PROFILE_WORK); + UserHandle launchUser = getAnnotatedUserHandles().tabOwnerUserHandleForLaunch; + UserHandle personalUser = getAnnotatedUserHandles().personalProfileUserHandle; + return launchUser.equals(personalUser) ? PROFILE_PERSONAL : PROFILE_WORK; } protected final AnnotatedUserHandles getAnnotatedUserHandles() { return mLazyAnnotatedUserHandles.get(); } - protected final UserHandle getPersonalProfileUserHandle() { - return getAnnotatedUserHandles().personalProfileUserHandle; - } - - // TODO: have tests override `getAnnotatedUserHandles()`, and make this method `final`. - // @NonFinalForTesting - @Nullable - protected UserHandle getWorkProfileUserHandle() { - return getAnnotatedUserHandles().workProfileUserHandle; - } - - // TODO: have tests override `getAnnotatedUserHandles()`, and make this method `final`. - @Nullable - protected UserHandle getCloneProfileUserHandle() { - return getAnnotatedUserHandles().cloneProfileUserHandle; - } - - // TODO: have tests override `getAnnotatedUserHandles()`, and make this method `final`. - protected UserHandle getTabOwnerUserHandleForLaunch() { - return getAnnotatedUserHandles().tabOwnerUserHandleForLaunch; - } - - protected UserHandle getUserHandleSharesheetLaunchedAs() { - return getAnnotatedUserHandles().userHandleSharesheetLaunchedAs; - } - - private boolean hasWorkProfile() { - return getWorkProfileUserHandle() != null; + return getAnnotatedUserHandles().workProfileUserHandle != null; } private boolean hasCloneProfile() { - return getCloneProfileUserHandle() != null; + return getAnnotatedUserHandles().cloneProfileUserHandle != null; } protected final boolean isLaunchedAsCloneProfile() { - return hasCloneProfile() - && getUserHandleSharesheetLaunchedAs().equals(getCloneProfileUserHandle()); + UserHandle launchUser = getAnnotatedUserHandles().userHandleSharesheetLaunchedAs; + UserHandle cloneUser = getAnnotatedUserHandles().cloneProfileUserHandle; + return hasCloneProfile() && launchUser.equals(cloneUser); } - protected final boolean shouldShowTabs() { return hasWorkProfile(); } @@ -1368,7 +1354,9 @@ public class ResolverActivity extends FragmentActivity implements } DevicePolicyEventLogger .createEvent(DevicePolicyEnums.RESOLVER_CROSS_PROFILE_TARGET_OPENED) - .setBoolean(currentUserHandle.equals(getPersonalProfileUserHandle())) + .setBoolean( + currentUserHandle.equals( + getAnnotatedUserHandles().personalProfileUserHandle)) .setStrings(getMetricsCategory(), cti.isInDirectShareMetricsCategory() ? "direct_share" : "other_target") .write(); @@ -1491,15 +1479,21 @@ public class ResolverActivity extends FragmentActivity implements protected final void onRestart() { super.onRestart(); if (!mRegistered) { - mPersonalPackageMonitor.register(this, getMainLooper(), - getPersonalProfileUserHandle(), false); + mPersonalPackageMonitor.register( + this, + getMainLooper(), + getAnnotatedUserHandles().personalProfileUserHandle, + false); if (shouldShowTabs()) { if (mWorkPackageMonitor == null) { mWorkPackageMonitor = createPackageMonitor( mMultiProfilePagerAdapter.getWorkListAdapter()); } - mWorkPackageMonitor.register(this, getMainLooper(), - getWorkProfileUserHandle(), false); + mWorkPackageMonitor.register( + this, + getMainLooper(), + getAnnotatedUserHandles().workProfileUserHandle, + false); } mRegistered = true; } @@ -1973,7 +1967,7 @@ public class ResolverActivity extends FragmentActivity implements DevicePolicyEventLogger .createEvent(DevicePolicyEnums.RESOLVER_AUTOLAUNCH_CROSS_PROFILE_TARGET) .setBoolean(activeListAdapter.getUserHandle() - .equals(getPersonalProfileUserHandle())) + .equals(getAnnotatedUserHandles().personalProfileUserHandle)) .setStrings(getMetricsCategory()) .write(); safelyStartActivity(activeProfileTarget); @@ -2256,7 +2250,7 @@ public class ResolverActivity extends FragmentActivity implements // filtered item. We always show the same default app even in the inactive user profile. boolean adapterForCurrentUserHasFilteredItem = mMultiProfilePagerAdapter.getListAdapterForUserHandle( - getTabOwnerUserHandleForLaunch()).hasFilteredItem(); + getAnnotatedUserHandles().tabOwnerUserHandleForLaunch).hasFilteredItem(); return mSupportsAlwaysUseOption && adapterForCurrentUserHasFilteredItem; } @@ -2391,7 +2385,7 @@ public class ResolverActivity extends FragmentActivity implements * {@link ResolverListController} configured for the provided {@code userHandle}. */ protected final UserHandle getQueryIntentsUser(UserHandle userHandle) { - return mLazyAnnotatedUserHandles.get().getQueryIntentsUser(userHandle); + return getAnnotatedUserHandles().getQueryIntentsUser(userHandle); } /** @@ -2411,9 +2405,9 @@ public class ResolverActivity extends FragmentActivity implements // Add clonedProfileUserHandle to the list only if we are: // a. Building the Personal Tab. // b. CloneProfile exists on the device. - if (userHandle.equals(getPersonalProfileUserHandle()) - && getCloneProfileUserHandle() != null) { - userList.add(getCloneProfileUserHandle()); + if (userHandle.equals(getAnnotatedUserHandles().personalProfileUserHandle) + && hasCloneProfile()) { + userList.add(getAnnotatedUserHandles().cloneProfileUserHandle); } return userList; } diff --git a/java/tests/src/com/android/intentresolver/ChooserActivityOverrideData.java b/java/tests/src/com/android/intentresolver/ChooserActivityOverrideData.java index 5b938aa1..e4a286ca 100644 --- a/java/tests/src/com/android/intentresolver/ChooserActivityOverrideData.java +++ b/java/tests/src/com/android/intentresolver/ChooserActivityOverrideData.java @@ -67,9 +67,7 @@ public class ChooserActivityOverrideData { public EventLog mEventLog; public int alternateProfileSetting; public Resources resources; - public UserHandle workProfileUserHandle; - public UserHandle cloneProfileUserHandle; - public UserHandle tabOwnerUserHandleForLaunch; + public AnnotatedUserHandles annotatedUserHandles; public boolean hasCrossProfileIntents; public boolean isQuietModeEnabled; public Integer myUserId; @@ -89,9 +87,11 @@ public class ChooserActivityOverrideData { mEventLog = mock(EventLog.class); alternateProfileSetting = 0; resources = null; - workProfileUserHandle = null; - cloneProfileUserHandle = null; - tabOwnerUserHandleForLaunch = null; + annotatedUserHandles = AnnotatedUserHandles.newBuilder() + .setUserIdOfCallingApp(1234) // Must be non-negative. + .setUserHandleSharesheetLaunchedAs(UserHandle.SYSTEM) + .setPersonalProfileUserHandle(UserHandle.SYSTEM) + .build(); hasCrossProfileIntents = true; isQuietModeEnabled = false; myUserId = null; diff --git a/java/tests/src/com/android/intentresolver/ChooserWrapperActivity.java b/java/tests/src/com/android/intentresolver/ChooserWrapperActivity.java index 578b9557..710c4c88 100644 --- a/java/tests/src/com/android/intentresolver/ChooserWrapperActivity.java +++ b/java/tests/src/com/android/intentresolver/ChooserWrapperActivity.java @@ -243,8 +243,8 @@ public class ChooserWrapperActivity extends ChooserActivity implements IChooserW } @Override - protected UserHandle getWorkProfileUserHandle() { - return sOverrides.workProfileUserHandle; + protected AnnotatedUserHandles computeAnnotatedUserHandles() { + return sOverrides.annotatedUserHandles; } @Override @@ -252,14 +252,6 @@ public class ChooserWrapperActivity extends ChooserActivity implements IChooserW return mMultiProfilePagerAdapter.getCurrentUserHandle(); } - @Override - protected UserHandle getTabOwnerUserHandleForLaunch() { - if (sOverrides.tabOwnerUserHandleForLaunch == null) { - return super.getTabOwnerUserHandleForLaunch(); - } - return sOverrides.tabOwnerUserHandleForLaunch; - } - @Override public Context createContextAsUser(UserHandle user, int flags) { // return the current context as a work profile doesn't really exist in these tests diff --git a/java/tests/src/com/android/intentresolver/ResolverActivityTest.java b/java/tests/src/com/android/intentresolver/ResolverActivityTest.java index 7233fd3d..1ce1b3b0 100644 --- a/java/tests/src/com/android/intentresolver/ResolverActivityTest.java +++ b/java/tests/src/com/android/intentresolver/ResolverActivityTest.java @@ -77,6 +77,9 @@ public class ResolverActivityTest { private static final UserHandle PERSONAL_USER_HANDLE = androidx.test.platform.app .InstrumentationRegistry.getInstrumentation().getTargetContext().getUser(); + private static final UserHandle WORK_PROFILE_USER_HANDLE = UserHandle.of(10); + private static final UserHandle CLONE_PROFILE_USER_HANDLE = UserHandle.of(11); + protected Intent getConcreteIntentForLaunch(Intent clientIntent) { clientIntent.setClass( androidx.test.platform.app.InstrumentationRegistry.getInstrumentation().getTargetContext(), @@ -238,9 +241,9 @@ public class ResolverActivityTest { List personalResolvedComponentInfos = createResolvedComponentsForTestWithOtherProfile(2, /* userId */ 10, PERSONAL_USER_HANDLE); - markWorkProfileUserAvailable(); + markOtherProfileAvailability(/* workAvailable= */ true, /* cloneAvailable= */ false); List workResolvedComponentInfos = createResolvedComponentsForTest(4, - sOverrides.workProfileUserHandle); + WORK_PROFILE_USER_HANDLE); setupResolverControllers(personalResolvedComponentInfos, workResolvedComponentInfos); ResolveInfo toChoose = personalResolvedComponentInfos.get(1).getResolveInfoAt(0); @@ -350,7 +353,7 @@ public class ResolverActivityTest { @Test public void testWorkTab_displayedWhenWorkProfileUserAvailable() { Intent sendIntent = createSendImageIntent(); - markWorkProfileUserAvailable(); + markOtherProfileAvailability(/* workAvailable= */ true, /* cloneAvailable= */ false); mActivityRule.launchActivity(sendIntent); waitForIdle(); @@ -373,9 +376,9 @@ public class ResolverActivityTest { List personalResolvedComponentInfos = createResolvedComponentsForTestWithOtherProfile(3, /* userId = */ 10, PERSONAL_USER_HANDLE); - markWorkProfileUserAvailable(); + markOtherProfileAvailability(/* workAvailable= */ true, /* cloneAvailable= */ false); List workResolvedComponentInfos = createResolvedComponentsForTest(4, - sOverrides.workProfileUserHandle); + WORK_PROFILE_USER_HANDLE); setupResolverControllers(personalResolvedComponentInfos, new ArrayList<>(workResolvedComponentInfos)); Intent sendIntent = createSendImageIntent(); @@ -393,12 +396,12 @@ public class ResolverActivityTest { List personalResolvedComponentInfos = createResolvedComponentsForTestWithOtherProfile(3, /* userId */ 10, PERSONAL_USER_HANDLE); - markWorkProfileUserAvailable(); + markOtherProfileAvailability(/* workAvailable= */ true, /* cloneAvailable= */ false); List workResolvedComponentInfos = createResolvedComponentsForTest(4, - sOverrides.workProfileUserHandle); + WORK_PROFILE_USER_HANDLE); setupResolverControllers(personalResolvedComponentInfos, workResolvedComponentInfos); Intent sendIntent = createSendImageIntent(); - markWorkProfileUserAvailable(); + markOtherProfileAvailability(/* workAvailable= */ true, /* cloneAvailable= */ false); final ResolverWrapperActivity activity = mActivityRule.launchActivity(sendIntent); waitForIdle(); @@ -412,9 +415,9 @@ public class ResolverActivityTest { public void testWorkTab_personalTabUsesExpectedAdapter() { List personalResolvedComponentInfos = createResolvedComponentsForTestWithOtherProfile(3, PERSONAL_USER_HANDLE); - markWorkProfileUserAvailable(); + markOtherProfileAvailability(/* workAvailable= */ true, /* cloneAvailable= */ false); List workResolvedComponentInfos = createResolvedComponentsForTest(4, - sOverrides.workProfileUserHandle); + WORK_PROFILE_USER_HANDLE); setupResolverControllers(personalResolvedComponentInfos, workResolvedComponentInfos); Intent sendIntent = createSendImageIntent(); @@ -428,12 +431,12 @@ public class ResolverActivityTest { @Test public void testWorkTab_workProfileHasExpectedNumberOfTargets() throws InterruptedException { - markWorkProfileUserAvailable(); + markOtherProfileAvailability(/* workAvailable= */ true, /* cloneAvailable= */ false); List personalResolvedComponentInfos = createResolvedComponentsForTestWithOtherProfile(3, /* userId */ 10, PERSONAL_USER_HANDLE); List workResolvedComponentInfos = createResolvedComponentsForTest(4, - sOverrides.workProfileUserHandle); + WORK_PROFILE_USER_HANDLE); setupResolverControllers(personalResolvedComponentInfos, workResolvedComponentInfos); Intent sendIntent = createSendImageIntent(); @@ -448,12 +451,12 @@ public class ResolverActivityTest { @Test public void testWorkTab_selectingWorkTabAppOpensAppInWorkProfile() throws InterruptedException { - markWorkProfileUserAvailable(); + markOtherProfileAvailability(/* workAvailable= */ true, /* cloneAvailable= */ false); List personalResolvedComponentInfos = createResolvedComponentsForTestWithOtherProfile(3, /* userId */ 10, PERSONAL_USER_HANDLE); List workResolvedComponentInfos = createResolvedComponentsForTest(4, - sOverrides.workProfileUserHandle); + WORK_PROFILE_USER_HANDLE); setupResolverControllers(personalResolvedComponentInfos, workResolvedComponentInfos); Intent sendIntent = createSendImageIntent(); ResolveInfo[] chosen = new ResolveInfo[1]; @@ -480,11 +483,11 @@ public class ResolverActivityTest { @Test public void testWorkTab_noPersonalApps_workTabHasExpectedNumberOfTargets() throws InterruptedException { - markWorkProfileUserAvailable(); + markOtherProfileAvailability(/* workAvailable= */ true, /* cloneAvailable= */ false); List personalResolvedComponentInfos = createResolvedComponentsForTestWithOtherProfile(1, PERSONAL_USER_HANDLE); List workResolvedComponentInfos = createResolvedComponentsForTest(4, - sOverrides.workProfileUserHandle); + WORK_PROFILE_USER_HANDLE); setupResolverControllers(personalResolvedComponentInfos, workResolvedComponentInfos); Intent sendIntent = createSendImageIntent(); @@ -499,11 +502,11 @@ public class ResolverActivityTest { @Test public void testWorkTab_headerIsVisibleInPersonalTab() { - markWorkProfileUserAvailable(); + markOtherProfileAvailability(/* workAvailable= */ true, /* cloneAvailable= */ false); List personalResolvedComponentInfos = createResolvedComponentsForTestWithOtherProfile(1, PERSONAL_USER_HANDLE); List workResolvedComponentInfos = createResolvedComponentsForTest(4, - sOverrides.workProfileUserHandle); + WORK_PROFILE_USER_HANDLE); setupResolverControllers(personalResolvedComponentInfos, workResolvedComponentInfos); Intent sendIntent = createOpenWebsiteIntent(); @@ -517,11 +520,11 @@ public class ResolverActivityTest { @Test public void testWorkTab_switchTabs_headerStaysSame() { - markWorkProfileUserAvailable(); + markOtherProfileAvailability(/* workAvailable= */ true, /* cloneAvailable= */ false); List personalResolvedComponentInfos = createResolvedComponentsForTestWithOtherProfile(1, PERSONAL_USER_HANDLE); List workResolvedComponentInfos = createResolvedComponentsForTest(4, - sOverrides.workProfileUserHandle); + WORK_PROFILE_USER_HANDLE); setupResolverControllers(personalResolvedComponentInfos, workResolvedComponentInfos); Intent sendIntent = createOpenWebsiteIntent(); @@ -543,12 +546,12 @@ public class ResolverActivityTest { @Test public void testWorkTab_noPersonalApps_canStartWorkApps() throws InterruptedException { - markWorkProfileUserAvailable(); + markOtherProfileAvailability(/* workAvailable= */ true, /* cloneAvailable= */ false); List personalResolvedComponentInfos = createResolvedComponentsForTestWithOtherProfile(3, /* userId= */ 10, PERSONAL_USER_HANDLE); List workResolvedComponentInfos = createResolvedComponentsForTest(4, - sOverrides.workProfileUserHandle); + WORK_PROFILE_USER_HANDLE); setupResolverControllers(personalResolvedComponentInfos, workResolvedComponentInfos); Intent sendIntent = createSendImageIntent(); ResolveInfo[] chosen = new ResolveInfo[1]; @@ -576,14 +579,13 @@ public class ResolverActivityTest { @Test public void testWorkTab_crossProfileIntentsDisabled_personalToWork_emptyStateShown() { - markWorkProfileUserAvailable(); + markOtherProfileAvailability(/* workAvailable= */ true, /* cloneAvailable= */ false); int workProfileTargets = 4; List personalResolvedComponentInfos = createResolvedComponentsForTestWithOtherProfile(3, /* userId */ 10, PERSONAL_USER_HANDLE); List workResolvedComponentInfos = - createResolvedComponentsForTest(workProfileTargets, - sOverrides.workProfileUserHandle); + createResolvedComponentsForTest(workProfileTargets, WORK_PROFILE_USER_HANDLE); sOverrides.hasCrossProfileIntents = false; setupResolverControllers(personalResolvedComponentInfos, workResolvedComponentInfos); Intent sendIntent = createSendImageIntent(); @@ -602,14 +604,13 @@ public class ResolverActivityTest { @Test public void testWorkTab_workProfileDisabled_emptyStateShown() { - markWorkProfileUserAvailable(); + markOtherProfileAvailability(/* workAvailable= */ true, /* cloneAvailable= */ false); int workProfileTargets = 4; List personalResolvedComponentInfos = createResolvedComponentsForTestWithOtherProfile(3, /* userId */ 10, PERSONAL_USER_HANDLE); List workResolvedComponentInfos = - createResolvedComponentsForTest(workProfileTargets, - sOverrides.workProfileUserHandle); + createResolvedComponentsForTest(workProfileTargets, WORK_PROFILE_USER_HANDLE); sOverrides.isQuietModeEnabled = true; setupResolverControllers(personalResolvedComponentInfos, workResolvedComponentInfos); Intent sendIntent = createSendImageIntent(); @@ -628,11 +629,11 @@ public class ResolverActivityTest { @Test public void testWorkTab_noWorkAppsAvailable_emptyStateShown() { - markWorkProfileUserAvailable(); + markOtherProfileAvailability(/* workAvailable= */ true, /* cloneAvailable= */ false); List personalResolvedComponentInfos = createResolvedComponentsForTest(3, PERSONAL_USER_HANDLE); List workResolvedComponentInfos = - createResolvedComponentsForTest(0, sOverrides.workProfileUserHandle); + createResolvedComponentsForTest(0, WORK_PROFILE_USER_HANDLE); setupResolverControllers(personalResolvedComponentInfos, workResolvedComponentInfos); Intent sendIntent = createSendImageIntent(); sendIntent.setType("TestType"); @@ -650,11 +651,11 @@ public class ResolverActivityTest { @Test public void testWorkTab_xProfileOff_noAppsAvailable_workOff_xProfileOffEmptyStateShown() { - markWorkProfileUserAvailable(); + markOtherProfileAvailability(/* workAvailable= */ true, /* cloneAvailable= */ false); List personalResolvedComponentInfos = createResolvedComponentsForTest(3, PERSONAL_USER_HANDLE); List workResolvedComponentInfos = - createResolvedComponentsForTest(0, sOverrides.workProfileUserHandle); + createResolvedComponentsForTest(0, WORK_PROFILE_USER_HANDLE); setupResolverControllers(personalResolvedComponentInfos, workResolvedComponentInfos); Intent sendIntent = createSendImageIntent(); sendIntent.setType("TestType"); @@ -674,11 +675,11 @@ public class ResolverActivityTest { @Test public void testMiniResolver() { - markWorkProfileUserAvailable(); + markOtherProfileAvailability(/* workAvailable= */ true, /* cloneAvailable= */ false); List personalResolvedComponentInfos = createResolvedComponentsForTest(1, PERSONAL_USER_HANDLE); List workResolvedComponentInfos = - createResolvedComponentsForTest(1, sOverrides.workProfileUserHandle); + createResolvedComponentsForTest(1, WORK_PROFILE_USER_HANDLE); // Personal profile only has a browser personalResolvedComponentInfos.get(0).getResolveInfoAt(0).handleAllWebDataURI = true; setupResolverControllers(personalResolvedComponentInfos, workResolvedComponentInfos); @@ -692,11 +693,11 @@ public class ResolverActivityTest { @Test public void testMiniResolver_noCurrentProfileTarget() { - markWorkProfileUserAvailable(); + markOtherProfileAvailability(/* workAvailable= */ true, /* cloneAvailable= */ false); List personalResolvedComponentInfos = createResolvedComponentsForTest(0, PERSONAL_USER_HANDLE); List workResolvedComponentInfos = - createResolvedComponentsForTest(1, sOverrides.workProfileUserHandle); + createResolvedComponentsForTest(1, WORK_PROFILE_USER_HANDLE); setupResolverControllers(personalResolvedComponentInfos, workResolvedComponentInfos); Intent sendIntent = createSendImageIntent(); sendIntent.setType("TestType"); @@ -720,11 +721,11 @@ public class ResolverActivityTest { @Test public void testWorkTab_noAppsAvailable_workOff_noAppsAvailableEmptyStateShown() { - markWorkProfileUserAvailable(); + markOtherProfileAvailability(/* workAvailable= */ true, /* cloneAvailable= */ false); List personalResolvedComponentInfos = createResolvedComponentsForTest(3, PERSONAL_USER_HANDLE); List workResolvedComponentInfos = - createResolvedComponentsForTest(0, sOverrides.workProfileUserHandle); + createResolvedComponentsForTest(0, WORK_PROFILE_USER_HANDLE); setupResolverControllers(personalResolvedComponentInfos, workResolvedComponentInfos); Intent sendIntent = createSendImageIntent(); sendIntent.setType("TestType"); @@ -743,14 +744,13 @@ public class ResolverActivityTest { @Test public void testWorkTab_onePersonalTarget_emptyStateOnWorkTarget_doesNotAutoLaunch() { - markWorkProfileUserAvailable(); + markOtherProfileAvailability(/* workAvailable= */ true, /* cloneAvailable= */ false); int workProfileTargets = 4; List personalResolvedComponentInfos = createResolvedComponentsForTestWithOtherProfile(2, /* userId */ 10, PERSONAL_USER_HANDLE); List workResolvedComponentInfos = - createResolvedComponentsForTest(workProfileTargets, - sOverrides.workProfileUserHandle); + createResolvedComponentsForTest(workProfileTargets, WORK_PROFILE_USER_HANDLE); sOverrides.hasCrossProfileIntents = false; setupResolverControllers(personalResolvedComponentInfos, workResolvedComponentInfos); Intent sendIntent = createSendImageIntent(); @@ -769,7 +769,7 @@ public class ResolverActivityTest { @Test public void testLayoutWithDefault_withWorkTab_neverShown() throws RemoteException { - markWorkProfileUserAvailable(); + markOtherProfileAvailability(/* workAvailable= */ true, /* cloneAvailable= */ false); // In this case we prefer the other profile and don't display anything about the last // chosen activity. @@ -794,54 +794,53 @@ public class ResolverActivityTest { @Test public void testClonedProfilePresent_personalAdapterIsSetWithPersonalProfile() { // enable cloneProfile - markCloneProfileUserAvailable(); + markOtherProfileAvailability(/* workAvailable= */ false, /* cloneAvailable= */ true); List resolvedComponentInfos = createResolvedComponentsWithCloneProfileForTest( 3, PERSONAL_USER_HANDLE, - sOverrides.cloneProfileUserHandle); + CLONE_PROFILE_USER_HANDLE); setupResolverControllers(resolvedComponentInfos); Intent sendIntent = createSendImageIntent(); final ResolverWrapperActivity activity = mActivityRule.launchActivity(sendIntent); waitForIdle(); - assertThat(activity.getCurrentUserHandle(), is(activity.getPersonalProfileUserHandle())); + assertThat(activity.getCurrentUserHandle(), is(PERSONAL_USER_HANDLE)); assertThat(activity.getAdapter().getCount(), is(3)); } @Test public void testClonedProfilePresent_personalTabUsesExpectedAdapter() { - markWorkProfileUserAvailable(); // enable cloneProfile - markCloneProfileUserAvailable(); + markOtherProfileAvailability(/* workAvailable= */ true, /* cloneAvailable= */ true); List personalResolvedComponentInfos = createResolvedComponentsWithCloneProfileForTest( 3, PERSONAL_USER_HANDLE, - sOverrides.cloneProfileUserHandle); + CLONE_PROFILE_USER_HANDLE); List workResolvedComponentInfos = createResolvedComponentsForTest(4, - sOverrides.workProfileUserHandle); + WORK_PROFILE_USER_HANDLE); setupResolverControllers(personalResolvedComponentInfos, workResolvedComponentInfos); Intent sendIntent = createSendImageIntent(); final ResolverWrapperActivity activity = mActivityRule.launchActivity(sendIntent); waitForIdle(); - assertThat(activity.getCurrentUserHandle(), is(activity.getPersonalProfileUserHandle())); + assertThat(activity.getCurrentUserHandle(), is(PERSONAL_USER_HANDLE)); assertThat(activity.getAdapter().getCount(), is(3)); } @Test public void testClonedProfilePresent_layoutWithDefault_neverShown() throws Exception { // enable cloneProfile - markCloneProfileUserAvailable(); + markOtherProfileAvailability(/* workAvailable= */ false, /* cloneAvailable= */ true); Intent sendIntent = createSendImageIntent(); List resolvedComponentInfos = createResolvedComponentsWithCloneProfileForTest( 2, PERSONAL_USER_HANDLE, - sOverrides.cloneProfileUserHandle); + CLONE_PROFILE_USER_HANDLE); setupResolverControllers(resolvedComponentInfos); when(sOverrides.resolverListController.getLastChosen()) @@ -859,13 +858,13 @@ public class ResolverActivityTest { @Test public void testClonedProfilePresent_alwaysButtonDisabled() throws Exception { // enable cloneProfile - markCloneProfileUserAvailable(); + markOtherProfileAvailability(/* workAvailable= */ false, /* cloneAvailable= */ true); Intent sendIntent = createSendImageIntent(); List resolvedComponentInfos = createResolvedComponentsWithCloneProfileForTest( 3, PERSONAL_USER_HANDLE, - sOverrides.cloneProfileUserHandle); + CLONE_PROFILE_USER_HANDLE); setupResolverControllers(resolvedComponentInfos); when(sOverrides.resolverListController.getLastChosen()) @@ -892,17 +891,16 @@ public class ResolverActivityTest { @Test public void testClonedProfilePresent_personalProfileActivityIsStartedInCorrectUser() throws Exception { - markWorkProfileUserAvailable(); // enable cloneProfile - markCloneProfileUserAvailable(); + markOtherProfileAvailability(/* workAvailable= */ true, /* cloneAvailable= */ true); List personalResolvedComponentInfos = createResolvedComponentsWithCloneProfileForTest( 3, PERSONAL_USER_HANDLE, - sOverrides.cloneProfileUserHandle); + CLONE_PROFILE_USER_HANDLE); List workResolvedComponentInfos = - createResolvedComponentsForTest(3, sOverrides.workProfileUserHandle); + createResolvedComponentsForTest(3, WORK_PROFILE_USER_HANDLE); sOverrides.hasCrossProfileIntents = false; setupResolverControllers(personalResolvedComponentInfos, workResolvedComponentInfos); Intent sendIntent = createSendImageIntent(); @@ -928,17 +926,16 @@ public class ResolverActivityTest { @Test public void testClonedProfilePresent_workProfileActivityIsStartedInCorrectUser() throws Exception { - markWorkProfileUserAvailable(); // enable cloneProfile - markCloneProfileUserAvailable(); + markOtherProfileAvailability(/* workAvailable= */ true, /* cloneAvailable= */ true); List personalResolvedComponentInfos = createResolvedComponentsWithCloneProfileForTest( 3, PERSONAL_USER_HANDLE, - sOverrides.cloneProfileUserHandle); + CLONE_PROFILE_USER_HANDLE); List workResolvedComponentInfos = - createResolvedComponentsForTest(3, sOverrides.workProfileUserHandle); + createResolvedComponentsForTest(3, WORK_PROFILE_USER_HANDLE); setupResolverControllers(personalResolvedComponentInfos, workResolvedComponentInfos); Intent sendIntent = createSendImageIntent(); sendIntent.setType("TestType"); @@ -967,12 +964,12 @@ public class ResolverActivityTest { public void testClonedProfilePresent_personalProfileResolverComparatorHasCorrectUsers() throws Exception { // enable cloneProfile - markCloneProfileUserAvailable(); + markOtherProfileAvailability(/* workAvailable= */ false, /* cloneAvailable= */ true); List resolvedComponentInfos = createResolvedComponentsWithCloneProfileForTest( 3, PERSONAL_USER_HANDLE, - sOverrides.cloneProfileUserHandle); + CLONE_PROFILE_USER_HANDLE); setupResolverControllers(resolvedComponentInfos); Intent sendIntent = createSendImageIntent(); @@ -981,8 +978,8 @@ public class ResolverActivityTest { List result = activity .getResolverRankerServiceUserHandleList(PERSONAL_USER_HANDLE); - assertThat(result.containsAll(Lists.newArrayList(PERSONAL_USER_HANDLE, - sOverrides.cloneProfileUserHandle)), is(true)); + assertThat(result.containsAll( + Lists.newArrayList(PERSONAL_USER_HANDLE, CLONE_PROFILE_USER_HANDLE)), is(true)); } private Intent createSendImageIntent() { @@ -1059,8 +1056,19 @@ public class ResolverActivityTest { InstrumentationRegistry.getInstrumentation().waitForIdleSync(); } - private void markWorkProfileUserAvailable() { - ResolverWrapperActivity.sOverrides.workProfileUserHandle = UserHandle.of(10); + private void markOtherProfileAvailability(boolean workAvailable, boolean cloneAvailable) { + AnnotatedUserHandles.Builder handles = AnnotatedUserHandles.newBuilder(); + handles + .setUserIdOfCallingApp(1234) // Must be non-negative. + .setUserHandleSharesheetLaunchedAs(PERSONAL_USER_HANDLE) + .setPersonalProfileUserHandle(PERSONAL_USER_HANDLE); + if (workAvailable) { + handles.setWorkProfileUserHandle(WORK_PROFILE_USER_HANDLE); + } + if (cloneAvailable) { + handles.setCloneProfileUserHandle(CLONE_PROFILE_USER_HANDLE); + } + sOverrides.annotatedUserHandles = handles.build(); } private void setupResolverControllers( @@ -1068,10 +1076,6 @@ public class ResolverActivityTest { setupResolverControllers(personalResolvedComponentInfos, new ArrayList<>()); } - private void markCloneProfileUserAvailable() { - ResolverWrapperActivity.sOverrides.cloneProfileUserHandle = UserHandle.of(11); - } - private void setupResolverControllers( List personalResolvedComponentInfos, List workResolvedComponentInfos) { diff --git a/java/tests/src/com/android/intentresolver/ResolverWrapperActivity.java b/java/tests/src/com/android/intentresolver/ResolverWrapperActivity.java index 401ede26..60180c0b 100644 --- a/java/tests/src/com/android/intentresolver/ResolverWrapperActivity.java +++ b/java/tests/src/com/android/intentresolver/ResolverWrapperActivity.java @@ -57,13 +57,6 @@ public class ResolverWrapperActivity extends ResolverActivity { super(/* isIntentPicker= */ true); } - // ResolverActivity inspects the launched-from UID at onCreate and needs to see some - // non-negative value in the test. - @Override - public int getLaunchedFromUid() { - return 1234; - } - public CountingIdlingResource getLabelIdlingResource() { return mLabelIdlingResource; } @@ -161,13 +154,8 @@ public class ResolverWrapperActivity extends ResolverActivity { } @Override - protected UserHandle getWorkProfileUserHandle() { - return sOverrides.workProfileUserHandle; - } - - @Override - protected UserHandle getCloneProfileUserHandle() { - return sOverrides.cloneProfileUserHandle; + protected AnnotatedUserHandles computeAnnotatedUserHandles() { + return sOverrides.annotatedUserHandles; } @Override @@ -193,9 +181,7 @@ public class ResolverWrapperActivity extends ResolverActivity { public ResolverListController resolverListController; public ResolverListController workResolverListController; public Boolean isVoiceInteraction; - public UserHandle workProfileUserHandle; - public UserHandle cloneProfileUserHandle; - public UserHandle tabOwnerUserHandleForLaunch; + public AnnotatedUserHandles annotatedUserHandles; public Integer myUserId; public boolean hasCrossProfileIntents; public boolean isQuietModeEnabled; @@ -208,9 +194,11 @@ public class ResolverWrapperActivity extends ResolverActivity { createPackageManager = null; resolverListController = mock(ResolverListController.class); workResolverListController = mock(ResolverListController.class); - workProfileUserHandle = null; - cloneProfileUserHandle = null; - tabOwnerUserHandleForLaunch = null; + annotatedUserHandles = AnnotatedUserHandles.newBuilder() + .setUserIdOfCallingApp(1234) // Must be non-negative. + .setUserHandleSharesheetLaunchedAs(UserHandle.SYSTEM) + .setPersonalProfileUserHandle(UserHandle.SYSTEM) + .build(); myUserId = null; hasCrossProfileIntents = true; isQuietModeEnabled = false; diff --git a/java/tests/src/com/android/intentresolver/UnbundledChooserActivityTest.java b/java/tests/src/com/android/intentresolver/UnbundledChooserActivityTest.java index 59357843..addad3ab 100644 --- a/java/tests/src/com/android/intentresolver/UnbundledChooserActivityTest.java +++ b/java/tests/src/com/android/intentresolver/UnbundledChooserActivityTest.java @@ -156,6 +156,9 @@ public class UnbundledChooserActivityTest { private static final UserHandle PERSONAL_USER_HANDLE = InstrumentationRegistry .getInstrumentation().getTargetContext().getUser(); + private static final UserHandle WORK_PROFILE_USER_HANDLE = UserHandle.of(10); + private static final UserHandle CLONE_PROFILE_USER_HANDLE = UserHandle.of(11); + private static final Function DEFAULT_PM = pm -> pm; private static final Function NO_APP_PREDICTION_SERVICE_PM = pm -> { @@ -545,7 +548,7 @@ public class UnbundledChooserActivityTest { createResolvedComponentsForTestWithOtherProfile(2, /* userId */ 10); List workResolvedComponentInfos = createResolvedComponentsForTest(4); setupResolverControllers(personalResolvedComponentInfos, workResolvedComponentInfos); - markWorkProfileUserAvailable(); + markOtherProfileAvailability(/* workAvailable= */ true, /* cloneAvailable= */ false); ResolveInfo toChoose = personalResolvedComponentInfos.get(1).getResolveInfoAt(0); Intent sendIntent = createSendTextIntent(); @@ -1715,7 +1718,7 @@ public class UnbundledChooserActivityTest { // We need app targets for direct targets to get displayed List resolvedComponentInfos = createResolvedComponentsForTest(2); setupResolverControllers(resolvedComponentInfos, resolvedComponentInfos); - markWorkProfileUserAvailable(); + markOtherProfileAvailability(/* workAvailable= */ true, /* cloneAvailable= */ false); // set caller-provided target Intent chooserIntent = Intent.createChooser(createSendTextIntent(), null); @@ -1979,7 +1982,7 @@ public class UnbundledChooserActivityTest { public void testWorkTab_displayedWhenWorkProfileUserAvailable() { Intent sendIntent = createSendTextIntent(); sendIntent.setType(TEST_MIME_TYPE); - markWorkProfileUserAvailable(); + markOtherProfileAvailability(/* workAvailable= */ true, /* cloneAvailable= */ false); mActivityRule.launchActivity(Intent.createChooser(sendIntent, "work tab test")); waitForIdle(); @@ -2011,7 +2014,7 @@ public class UnbundledChooserActivityTest { setupResolverControllers(personalResolvedComponentInfos, workResolvedComponentInfos); Intent sendIntent = createSendTextIntent(); sendIntent.setType(TEST_MIME_TYPE); - markWorkProfileUserAvailable(); + markOtherProfileAvailability(/* workAvailable= */ true, /* cloneAvailable= */ false); final IChooserWrapper activity = (IChooserWrapper) mActivityRule.launchActivity(Intent.createChooser(sendIntent, "work tab test")); @@ -2026,7 +2029,7 @@ public class UnbundledChooserActivityTest { @Test public void testWorkTab_workProfileHasExpectedNumberOfTargets() throws InterruptedException { - markWorkProfileUserAvailable(); + markOtherProfileAvailability(/* workAvailable= */ true, /* cloneAvailable= */ false); int workProfileTargets = 4; List personalResolvedComponentInfos = createResolvedComponentsForTestWithOtherProfile(3, /* userId */ 10); @@ -2047,7 +2050,7 @@ public class UnbundledChooserActivityTest { @Test @Ignore public void testWorkTab_selectingWorkTabAppOpensAppInWorkProfile() { - markWorkProfileUserAvailable(); + markOtherProfileAvailability(/* workAvailable= */ true, /* cloneAvailable= */ false); List personalResolvedComponentInfos = createResolvedComponentsForTestWithOtherProfile(3, /* userId */ 10); int workProfileTargets = 4; @@ -2078,7 +2081,7 @@ public class UnbundledChooserActivityTest { @Test public void testWorkTab_crossProfileIntentsDisabled_personalToWork_emptyStateShown() { - markWorkProfileUserAvailable(); + markOtherProfileAvailability(/* workAvailable= */ true, /* cloneAvailable= */ false); int workProfileTargets = 4; List personalResolvedComponentInfos = createResolvedComponentsForTestWithOtherProfile(3, /* userId */ 10); @@ -2102,7 +2105,7 @@ public class UnbundledChooserActivityTest { @Test public void testWorkTab_workProfileDisabled_emptyStateShown() { - markWorkProfileUserAvailable(); + markOtherProfileAvailability(/* workAvailable= */ true, /* cloneAvailable= */ false); int workProfileTargets = 4; List personalResolvedComponentInfos = createResolvedComponentsForTestWithOtherProfile(3, /* userId */ 10); @@ -2126,7 +2129,7 @@ public class UnbundledChooserActivityTest { @Test public void testWorkTab_noWorkAppsAvailable_emptyStateShown() { - markWorkProfileUserAvailable(); + markOtherProfileAvailability(/* workAvailable= */ true, /* cloneAvailable= */ false); List personalResolvedComponentInfos = createResolvedComponentsForTest(3); List workResolvedComponentInfos = @@ -2149,7 +2152,7 @@ public class UnbundledChooserActivityTest { @Ignore // b/220067877 @Test public void testWorkTab_xProfileOff_noAppsAvailable_workOff_xProfileOffEmptyStateShown() { - markWorkProfileUserAvailable(); + markOtherProfileAvailability(/* workAvailable= */ true, /* cloneAvailable= */ false); List personalResolvedComponentInfos = createResolvedComponentsForTest(3); List workResolvedComponentInfos = @@ -2173,7 +2176,7 @@ public class UnbundledChooserActivityTest { @Test public void testWorkTab_noAppsAvailable_workOff_noAppsAvailableEmptyStateShown() { - markWorkProfileUserAvailable(); + markOtherProfileAvailability(/* workAvailable= */ true, /* cloneAvailable= */ false); List personalResolvedComponentInfos = createResolvedComponentsForTest(3); List workResolvedComponentInfos = @@ -2407,7 +2410,7 @@ public class UnbundledChooserActivityTest { @Test @Ignore("b/222124533") public void testSwitchProfileLogging() throws InterruptedException { - markWorkProfileUserAvailable(); + markOtherProfileAvailability(/* workAvailable= */ true, /* cloneAvailable= */ false); int workProfileTargets = 4; List personalResolvedComponentInfos = createResolvedComponentsForTestWithOtherProfile(3, /* userId */ 10); @@ -2430,7 +2433,7 @@ public class UnbundledChooserActivityTest { @Test public void testWorkTab_onePersonalTarget_emptyStateOnWorkTarget_doesNotAutoLaunch() { - markWorkProfileUserAvailable(); + markOtherProfileAvailability(/* workAvailable= */ true, /* cloneAvailable= */ false); int workProfileTargets = 4; List personalResolvedComponentInfos = createResolvedComponentsForTestWithOtherProfile(2, /* userId */ 10); @@ -2482,7 +2485,7 @@ public class UnbundledChooserActivityTest { @Test public void testWorkTab_withInitialIntents_workTabDoesNotIncludePersonalInitialIntents() { - markWorkProfileUserAvailable(); + markOtherProfileAvailability(/* workAvailable= */ true, /* cloneAvailable= */ false); int workProfileTargets = 1; List personalResolvedComponentInfos = createResolvedComponentsForTestWithOtherProfile(2, /* userId */ 10); @@ -2512,7 +2515,7 @@ public class UnbundledChooserActivityTest { @Test public void testWorkTab_xProfileIntentsDisabled_personalToWork_nonSendIntent_emptyStateShown() { - markWorkProfileUserAvailable(); + markOtherProfileAvailability(/* workAvailable= */ true, /* cloneAvailable= */ false); int workProfileTargets = 4; List personalResolvedComponentInfos = createResolvedComponentsForTestWithOtherProfile(3, /* userId */ 10); @@ -2546,7 +2549,7 @@ public class UnbundledChooserActivityTest { @Test public void testWorkTab_noWorkAppsAvailable_nonSendIntent_emptyStateShown() { - markWorkProfileUserAvailable(); + markOtherProfileAvailability(/* workAvailable= */ true, /* cloneAvailable= */ false); List personalResolvedComponentInfos = createResolvedComponentsForTest(3); List workResolvedComponentInfos = @@ -2607,7 +2610,7 @@ public class UnbundledChooserActivityTest { @Test public void test_query_shortcut_loader_for_the_selected_tab() { - markWorkProfileUserAvailable(); + markOtherProfileAvailability(/* workAvailable= */ true, /* cloneAvailable= */ false); List personalResolvedComponentInfos = createResolvedComponentsForTestWithOtherProfile(3, /* userId */ 10); List workResolvedComponentInfos = @@ -2640,12 +2643,12 @@ public class UnbundledChooserActivityTest { @Test public void testClonedProfilePresent_personalAdapterIsSetWithPersonalProfile() { // enable cloneProfile - markCloneProfileUserAvailable(); + markOtherProfileAvailability(/* workAvailable= */ false, /* cloneAvailable= */ true); List resolvedComponentInfos = createResolvedComponentsWithCloneProfileForTest( 3, PERSONAL_USER_HANDLE, - ChooserActivityOverrideData.getInstance().cloneProfileUserHandle); + CLONE_PROFILE_USER_HANDLE); setupResolverControllers(resolvedComponentInfos); Intent sendIntent = createSendTextIntent(); @@ -2659,8 +2662,7 @@ public class UnbundledChooserActivityTest { @Test public void testClonedProfilePresent_personalTabUsesExpectedAdapter() { - markWorkProfileUserAvailable(); - markCloneProfileUserAvailable(); + markOtherProfileAvailability(/* workAvailable= */ true, /* cloneAvailable= */ true); List personalResolvedComponentInfos = createResolvedComponentsForTest(3); List workResolvedComponentInfos = createResolvedComponentsForTest( @@ -2981,12 +2983,19 @@ public class UnbundledChooserActivityTest { return shortcuts; } - private void markWorkProfileUserAvailable() { - ChooserActivityOverrideData.getInstance().workProfileUserHandle = UserHandle.of(10); - } - - private void markCloneProfileUserAvailable() { - ChooserActivityOverrideData.getInstance().cloneProfileUserHandle = UserHandle.of(11); + private void markOtherProfileAvailability(boolean workAvailable, boolean cloneAvailable) { + AnnotatedUserHandles.Builder handles = AnnotatedUserHandles.newBuilder(); + handles + .setUserIdOfCallingApp(1234) // Must be non-negative. + .setUserHandleSharesheetLaunchedAs(PERSONAL_USER_HANDLE) + .setPersonalProfileUserHandle(PERSONAL_USER_HANDLE); + if (workAvailable) { + handles.setWorkProfileUserHandle(WORK_PROFILE_USER_HANDLE); + } + if (cloneAvailable) { + handles.setCloneProfileUserHandle(CLONE_PROFILE_USER_HANDLE); + } + ChooserWrapperActivity.sOverrides.annotatedUserHandles = handles.build(); } private void setupResolverControllers( diff --git a/java/tests/src/com/android/intentresolver/UnbundledChooserActivityWorkProfileTest.java b/java/tests/src/com/android/intentresolver/UnbundledChooserActivityWorkProfileTest.java index 92bccb7d..b56fdbdb 100644 --- a/java/tests/src/com/android/intentresolver/UnbundledChooserActivityWorkProfileTest.java +++ b/java/tests/src/com/android/intentresolver/UnbundledChooserActivityWorkProfileTest.java @@ -98,7 +98,6 @@ public class UnbundledChooserActivityWorkProfileTest { public void testBlocker() { setUpPersonalAndWorkComponentInfos(); sOverrides.hasCrossProfileIntents = mTestCase.hasCrossProfileIntents(); - sOverrides.tabOwnerUserHandleForLaunch = mTestCase.getMyUserHandle(); launchActivity(mTestCase.getIsSendAction()); switchToTab(mTestCase.getTab()); @@ -261,7 +260,12 @@ public class UnbundledChooserActivityWorkProfileTest { } private void setUpPersonalAndWorkComponentInfos() { - markWorkProfileUserAvailable(); + ChooserWrapperActivity.sOverrides.annotatedUserHandles = AnnotatedUserHandles.newBuilder() + .setUserIdOfCallingApp(1234) // Must be non-negative. + .setUserHandleSharesheetLaunchedAs(mTestCase.getMyUserHandle()) + .setPersonalProfileUserHandle(PERSONAL_USER_HANDLE) + .setWorkProfileUserHandle(WORK_USER_HANDLE) + .build(); int workProfileTargets = 4; List personalResolvedComponentInfos = createResolvedComponentsForTestWithOtherProfile(3, @@ -301,10 +305,6 @@ public class UnbundledChooserActivityWorkProfileTest { InstrumentationRegistry.getInstrumentation().waitForIdleSync(); } - private void markWorkProfileUserAvailable() { - ChooserWrapperActivity.sOverrides.workProfileUserHandle = WORK_USER_HANDLE; - } - private void assertCantAccessWorkAppsBlockerDisplayed() { onView(withText(R.string.resolver_cross_profile_blocked)) .check(matches(isDisplayed())); -- cgit v1.2.3-59-g8ed1b From a7382f3265d0bb76532b561059e56bafa855542e Mon Sep 17 00:00:00 2001 From: Mark Renouf Date: Wed, 30 Aug 2023 14:59:06 -0400 Subject: Switches to HiltTestApplication in tests. This allows testing with @HiltAndroidTest, @TestInstallIn, etc. Usage to follow in a CL series. Bug: 296633726 Bug: 300157408 Flag: EXEMPT Change-Id: I13fbf29005e11ea7375d77d11da4bc1bfa68b666 --- .../android/intentresolver/ChooserActivity.java | 8 ++----- java/tests/Android.bp | 1 + java/tests/AndroidManifest.xml | 2 +- .../intentresolver/ChooserWrapperActivity.java | 2 +- .../com/android/intentresolver/TestApplication.kt | 26 ---------------------- .../UnbundledChooserActivityTest.java | 9 +++++++- .../UnbundledChooserActivityWorkProfileTest.java | 9 +++++++- 7 files changed, 21 insertions(+), 36 deletions(-) delete mode 100644 java/tests/src/com/android/intentresolver/TestApplication.kt (limited to 'java/src') diff --git a/java/src/com/android/intentresolver/ChooserActivity.java b/java/src/com/android/intentresolver/ChooserActivity.java index 0a4f9f46..c27d3bac 100644 --- a/java/src/com/android/intentresolver/ChooserActivity.java +++ b/java/src/com/android/intentresolver/ChooserActivity.java @@ -258,7 +258,7 @@ public class ChooserActivity extends Hilt_ChooserActivity implements createProfileRecords( new AppPredictorFactory( - this, // TODO: Review w/team, possible side effects? + this, mChooserRequest.getSharedText(), mChooserRequest.getTargetIntentFilter()), mChooserRequest.getTargetIntentFilter()); @@ -276,10 +276,6 @@ public class ChooserActivity extends Hilt_ChooserActivity implements new DefaultTargetDataLoader(this, getLifecycle(), false), /* safeForwardingMode= */ true); - if (mFeatureFlags.exampleNewSharingMethod()) { - // Sample flag usage - } - mIntegratedDeviceComponents = getIntegratedDeviceComponents(); mRefinementManager = new ViewModelProvider(this).get(ChooserRefinementManager.class); @@ -387,7 +383,7 @@ public class ChooserActivity extends Hilt_ChooserActivity implements ShortcutLoader shortcutLoader = ActivityManager.isLowRamDeviceStatic() ? null : createShortcutLoader( - this, // TODO: Review w/team, possible side effects? + this, appPredictor, userHandle, targetIntentFilter, diff --git a/java/tests/Android.bp b/java/tests/Android.bp index e4d24eb5..974b8a47 100644 --- a/java/tests/Android.bp +++ b/java/tests/Android.bp @@ -44,6 +44,7 @@ android_test { "androidx.lifecycle_lifecycle-common-java8", "androidx.lifecycle_lifecycle-extensions", "androidx.lifecycle_lifecycle-runtime-testing", + "hilt_android_testing", "IntentResolver-core", "junit", "kotlinx_coroutines_test", diff --git a/java/tests/AndroidManifest.xml b/java/tests/AndroidManifest.xml index ae6a2205..35dc2ee6 100644 --- a/java/tests/AndroidManifest.xml +++ b/java/tests/AndroidManifest.xml @@ -23,7 +23,7 @@ - + diff --git a/java/tests/src/com/android/intentresolver/ChooserWrapperActivity.java b/java/tests/src/com/android/intentresolver/ChooserWrapperActivity.java index 6dbf9b3f..d488e02b 100644 --- a/java/tests/src/com/android/intentresolver/ChooserWrapperActivity.java +++ b/java/tests/src/com/android/intentresolver/ChooserWrapperActivity.java @@ -263,7 +263,7 @@ public class ChooserWrapperActivity extends ChooserActivity implements IChooserW @Override public Context createContextAsUser(UserHandle user, int flags) { // return the current context as a work profile doesn't really exist in these tests - return getApplicationContext(); + return this; } @Override diff --git a/java/tests/src/com/android/intentresolver/TestApplication.kt b/java/tests/src/com/android/intentresolver/TestApplication.kt deleted file mode 100644 index b57fd4d9..00000000 --- a/java/tests/src/com/android/intentresolver/TestApplication.kt +++ /dev/null @@ -1,26 +0,0 @@ -/* - * Copyright (C) 2022 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.android.intentresolver - -import android.content.Context -import android.os.UserHandle - -class TestApplication : MainApplication() { - - // return the current context as a work profile doesn't really exist in these tests - override fun createContextAsUser(user: UserHandle, flags: Int): Context = this -} diff --git a/java/tests/src/com/android/intentresolver/UnbundledChooserActivityTest.java b/java/tests/src/com/android/intentresolver/UnbundledChooserActivityTest.java index 2838f00b..0b3cb20a 100644 --- a/java/tests/src/com/android/intentresolver/UnbundledChooserActivityTest.java +++ b/java/tests/src/com/android/intentresolver/UnbundledChooserActivityTest.java @@ -146,6 +146,9 @@ import java.util.concurrent.atomic.AtomicReference; import java.util.function.Consumer; import java.util.function.Function; +import dagger.hilt.android.testing.HiltAndroidRule; +import dagger.hilt.android.testing.HiltAndroidTest; + /** * Instrumentation tests for ChooserActivity. *

@@ -153,6 +156,7 @@ import java.util.function.Function; *

*/ @RunWith(Parameterized.class) +@HiltAndroidTest public class UnbundledChooserActivityTest { private static final UserHandle PERSONAL_USER_HANDLE = InstrumentationRegistry @@ -187,7 +191,10 @@ public class UnbundledChooserActivityTest { cleanOverrideData(); } - @Rule + @Rule(order = 0) + public HiltAndroidRule mHiltAndroidRule = new HiltAndroidRule(this); + + @Rule(order = 1) public ActivityTestRule mActivityRule = new ActivityTestRule<>(ChooserWrapperActivity.class, false, false); diff --git a/java/tests/src/com/android/intentresolver/UnbundledChooserActivityWorkProfileTest.java b/java/tests/src/com/android/intentresolver/UnbundledChooserActivityWorkProfileTest.java index 92bccb7d..f0e88564 100644 --- a/java/tests/src/com/android/intentresolver/UnbundledChooserActivityWorkProfileTest.java +++ b/java/tests/src/com/android/intentresolver/UnbundledChooserActivityWorkProfileTest.java @@ -64,15 +64,22 @@ import java.util.Arrays; import java.util.Collection; import java.util.List; +import dagger.hilt.android.testing.HiltAndroidRule; +import dagger.hilt.android.testing.HiltAndroidTest; + @DeviceFilter.MediumType @RunWith(Parameterized.class) +@HiltAndroidTest public class UnbundledChooserActivityWorkProfileTest { private static final UserHandle PERSONAL_USER_HANDLE = InstrumentationRegistry .getInstrumentation().getTargetContext().getUser(); private static final UserHandle WORK_USER_HANDLE = UserHandle.of(10); - @Rule + @Rule(order = 0) + public HiltAndroidRule mHiltAndroidRule = new HiltAndroidRule(this); + + @Rule(order = 1) public ActivityTestRule mActivityRule = new ActivityTestRule<>(ChooserWrapperActivity.class, false, false); -- cgit v1.2.3-59-g8ed1b From ae322c4ddbcb552979fba17a22b931081020014e Mon Sep 17 00:00:00 2001 From: Mark Renouf Date: Wed, 13 Sep 2023 16:06:05 -0400 Subject: Injects EventLog, provides FakeEventLog within tests Using Hilt we are able to declaratively replace modules within integration tests. This is the first such instance within IntentResolver. Scope: An InstanceIdSequence in @Singleton scope provides a new InstanceId value to each EventLog created. EventLog is @ActivityScoped or one-per-activity instance. This matches the existing behavior. By adding [TestEventLogModule], all integration tests are now using a FakeEventLog by default (migrated away from 'override data' scheme). Bug: 299610743 Bug: 300157408 Test: atest IntentResolverUnitTests Change-Id: I33d6f4d1241a890ab88b631859652117ce20f7be --- .../android/intentresolver/ChooserActivity.java | 14 +- .../intentresolver/inject/SingletonModule.kt | 15 ++ .../intentresolver/logging/EventLogImpl.java | 109 +++-------- .../intentresolver/logging/EventLogModule.kt | 46 +++++ .../intentresolver/logging/FrameworkStatsLogger.kt | 35 +++- .../intentresolver/ChooserActionFactoryTest.kt | 3 +- .../ChooserActivityOverrideData.java | 3 - .../intentresolver/ChooserWrapperActivity.java | 6 - .../android/intentresolver/IChooserWrapper.java | 2 - .../UnbundledChooserActivityTest.java | 205 ++++++++++----------- .../intentresolver/logging/EventLogImplTest.java | 10 +- .../android/intentresolver/logging/FakeEventLog.kt | 197 ++++++++++++++++++++ .../logging/FakeFrameworkStatsLogger.kt | 95 ++++++++++ .../intentresolver/logging/TestEventLogModule.kt | 39 ++++ 14 files changed, 559 insertions(+), 220 deletions(-) create mode 100644 java/src/com/android/intentresolver/inject/SingletonModule.kt create mode 100644 java/src/com/android/intentresolver/logging/EventLogModule.kt create mode 100644 java/tests/src/com/android/intentresolver/logging/FakeEventLog.kt create mode 100644 java/tests/src/com/android/intentresolver/logging/FakeFrameworkStatsLogger.kt create mode 100644 java/tests/src/com/android/intentresolver/logging/TestEventLogModule.kt (limited to 'java/src') diff --git a/java/src/com/android/intentresolver/ChooserActivity.java b/java/src/com/android/intentresolver/ChooserActivity.java index c27d3bac..acfd3572 100644 --- a/java/src/com/android/intentresolver/ChooserActivity.java +++ b/java/src/com/android/intentresolver/ChooserActivity.java @@ -85,7 +85,6 @@ import com.android.intentresolver.grid.ChooserGridAdapter; import com.android.intentresolver.icons.DefaultTargetDataLoader; import com.android.intentresolver.icons.TargetDataLoader; import com.android.intentresolver.logging.EventLog; -import com.android.intentresolver.logging.EventLogImpl; import com.android.intentresolver.measurements.Tracer; import com.android.intentresolver.model.AbstractResolverComparator; import com.android.intentresolver.model.AppPredictionServiceResolverComparator; @@ -172,6 +171,7 @@ public class ChooserActivity extends Hilt_ChooserActivity implements public @interface ShareTargetType {} @Inject public FeatureFlags mFeatureFlags; + @Inject public EventLog mEventLog; private ChooserIntegratedDeviceComponents mIntegratedDeviceComponents; @@ -189,9 +189,6 @@ public class ChooserActivity extends Hilt_ChooserActivity implements private ChooserContentPreviewUi mChooserContentPreviewUi; private boolean mShouldDisplayLandscape; - // statsd logger wrapper - protected EventLog mEventLog; - private long mChooserShownTime; protected boolean mIsSuccessfullySelected; @@ -237,8 +234,6 @@ public class ChooserActivity extends Hilt_ChooserActivity implements final long intentReceivedTime = System.currentTimeMillis(); mLatencyTracker.onActionStart(ACTION_LOAD_SHARE_SHEET); - getEventLog().logSharesheetTriggered(); - try { mChooserRequest = new ChooserRequestParameters( getIntent(), @@ -276,6 +271,8 @@ public class ChooserActivity extends Hilt_ChooserActivity implements new DefaultTargetDataLoader(this, getLifecycle(), false), /* safeForwardingMode= */ true); + getEventLog().logSharesheetTriggered(); + mIntegratedDeviceComponents = getIntegratedDeviceComponents(); mRefinementManager = new ViewModelProvider(this).get(ChooserRefinementManager.class); @@ -1107,9 +1104,6 @@ public class ChooserActivity extends Hilt_ChooserActivity implements } protected EventLog getEventLog() { - if (mEventLog == null) { - mEventLog = new EventLogImpl(); - } return mEventLog; } @@ -1573,6 +1567,7 @@ public class ChooserActivity extends Hilt_ChooserActivity implements getResources().getDimensionPixelSize(R.dimen.chooser_header_scroll_elevation); mChooserMultiProfilePagerAdapter.getActiveAdapterView().addOnScrollListener( new RecyclerView.OnScrollListener() { + @Override public void onScrollStateChanged(RecyclerView view, int scrollState) { if (scrollState == RecyclerView.SCROLL_STATE_IDLE) { if (mScrollStatus == SCROLL_STATUS_SCROLLING_VERTICAL) { @@ -1587,6 +1582,7 @@ public class ChooserActivity extends Hilt_ChooserActivity implements } } + @Override public void onScrolled(RecyclerView view, int dx, int dy) { if (view.getChildCount() > 0) { View child = view.getLayoutManager().findViewByPosition(0); diff --git a/java/src/com/android/intentresolver/inject/SingletonModule.kt b/java/src/com/android/intentresolver/inject/SingletonModule.kt new file mode 100644 index 00000000..fbda8be6 --- /dev/null +++ b/java/src/com/android/intentresolver/inject/SingletonModule.kt @@ -0,0 +1,15 @@ +package com.android.intentresolver.inject + +import com.android.intentresolver.logging.EventLogImpl +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import javax.inject.Singleton + +@InstallIn(SingletonComponent::class) +@Module +class SingletonModule { + + @Provides @Singleton fun instanceIdSequence() = EventLogImpl.newIdSequence() +} diff --git a/java/src/com/android/intentresolver/logging/EventLogImpl.java b/java/src/com/android/intentresolver/logging/EventLogImpl.java index 33e617b1..26c79d00 100644 --- a/java/src/com/android/intentresolver/logging/EventLogImpl.java +++ b/java/src/com/android/intentresolver/logging/EventLogImpl.java @@ -32,10 +32,13 @@ import com.android.internal.logging.InstanceIdSequence; import com.android.internal.logging.MetricsLogger; import com.android.internal.logging.UiEvent; import com.android.internal.logging.UiEventLogger; -import com.android.internal.logging.UiEventLoggerImpl; import com.android.internal.logging.nano.MetricsProto.MetricsEvent; import com.android.internal.util.FrameworkStatsLog; +import javax.inject.Inject; + +import dagger.hilt.android.scopes.ActivityScoped; + /** * Helper for writing Sharesheet atoms to statsd log. */ @@ -45,29 +48,26 @@ public class EventLogImpl implements EventLog { private static final int SHARESHEET_INSTANCE_ID_MAX = (1 << 13); - // A small per-notification ID, used for statsd logging. - // TODO: consider precomputing and storing as final. - private static InstanceIdSequence sInstanceIdSequence; - private InstanceId mInstanceId; + private final InstanceId mInstanceId; private final UiEventLogger mUiEventLogger; private final FrameworkStatsLogger mFrameworkStatsLogger; private final MetricsLogger mMetricsLogger; - public EventLogImpl() { - this(new UiEventLoggerImpl(), new DefaultFrameworkStatsLogger(), new MetricsLogger()); + public static InstanceIdSequence newIdSequence() { + return new InstanceIdSequence(SHARESHEET_INSTANCE_ID_MAX); } - @VisibleForTesting - EventLogImpl( - UiEventLogger uiEventLogger, - FrameworkStatsLogger frameworkLogger, - MetricsLogger metricsLogger) { + @Inject + public EventLogImpl(UiEventLogger uiEventLogger, FrameworkStatsLogger frameworkLogger, + MetricsLogger metricsLogger, InstanceId instanceId) { mUiEventLogger = uiEventLogger; mFrameworkStatsLogger = frameworkLogger; mMetricsLogger = metricsLogger; + mInstanceId = instanceId; } + /** Records metrics for the start time of the {@link ChooserActivity}. */ @Override public void logChooserActivityShown( @@ -94,7 +94,7 @@ public class EventLogImpl implements EventLog { mFrameworkStatsLogger.write(FrameworkStatsLog.SHARESHEET_STARTED, /* event_id = 1 */ SharesheetStartedEvent.SHARE_STARTED.getId(), /* package_name = 2 */ packageName, - /* instance_id = 3 */ getInstanceId().getId(), + /* instance_id = 3 */ mInstanceId.getId(), /* mime_type = 4 */ mimeType, /* num_app_provided_direct_targets = 5 */ appProvidedDirect, /* num_app_provided_app_targets = 6 */ appProvidedApp, @@ -116,7 +116,7 @@ public class EventLogImpl implements EventLog { /* event_id = 1 */ SharesheetTargetSelectedEvent.SHARESHEET_CUSTOM_ACTION_SELECTED.getId(), /* package_name = 2 */ null, - /* instance_id = 3 */ getInstanceId().getId(), + /* instance_id = 3 */ mInstanceId.getId(), /* position_picked = 4 */ positionPicked, /* is_pinned = 5 */ false); } @@ -140,7 +140,7 @@ public class EventLogImpl implements EventLog { mFrameworkStatsLogger.write(FrameworkStatsLog.RANKING_SELECTED, /* event_id = 1 */ SharesheetTargetSelectedEvent.fromTargetType(targetType).getId(), /* package_name = 2 */ packageName, - /* instance_id = 3 */ getInstanceId().getId(), + /* instance_id = 3 */ mInstanceId.getId(), /* position_picked = 4 */ positionPicked, /* is_pinned = 5 */ isPinned); @@ -198,7 +198,7 @@ public class EventLogImpl implements EventLog { mFrameworkStatsLogger.write(FrameworkStatsLog.RANKING_SELECTED, /* event_id = 1 */ SharesheetTargetSelectedEvent.fromTargetType(targetType).getId(), /* package_name = 2 */ "", - /* instance_id = 3 */ getInstanceId().getId(), + /* instance_id = 3 */ mInstanceId.getId(), /* position_picked = 4 */ -1, /* is_pinned = 5 */ false); } @@ -217,13 +217,13 @@ public class EventLogImpl implements EventLog { /** Logs a UiEventReported event for the system sharesheet being triggered by the user. */ @Override public void logSharesheetTriggered() { - log(SharesheetStandardEvent.SHARESHEET_TRIGGERED, getInstanceId()); + log(SharesheetStandardEvent.SHARESHEET_TRIGGERED, mInstanceId); } /** Logs a UiEventReported event for the system sharesheet completing loading app targets. */ @Override public void logSharesheetAppLoadComplete() { - log(SharesheetStandardEvent.SHARESHEET_APP_LOAD_COMPLETE, getInstanceId()); + log(SharesheetStandardEvent.SHARESHEET_APP_LOAD_COMPLETE, mInstanceId); } /** @@ -231,7 +231,7 @@ public class EventLogImpl implements EventLog { */ @Override public void logSharesheetDirectLoadComplete() { - log(SharesheetStandardEvent.SHARESHEET_DIRECT_LOAD_COMPLETE, getInstanceId()); + log(SharesheetStandardEvent.SHARESHEET_DIRECT_LOAD_COMPLETE, mInstanceId); } /** @@ -239,7 +239,7 @@ public class EventLogImpl implements EventLog { */ @Override public void logSharesheetDirectLoadTimeout() { - log(SharesheetStandardEvent.SHARESHEET_DIRECT_LOAD_TIMEOUT, getInstanceId()); + log(SharesheetStandardEvent.SHARESHEET_DIRECT_LOAD_TIMEOUT, mInstanceId); } /** @@ -248,14 +248,14 @@ public class EventLogImpl implements EventLog { */ @Override public void logSharesheetProfileChanged() { - log(SharesheetStandardEvent.SHARESHEET_PROFILE_CHANGED, getInstanceId()); + log(SharesheetStandardEvent.SHARESHEET_PROFILE_CHANGED, mInstanceId); } /** Logs a UiEventReported event for the system sharesheet getting expanded or collapsed. */ @Override public void logSharesheetExpansionChanged(boolean isCollapsed) { log(isCollapsed ? SharesheetStandardEvent.SHARESHEET_COLLAPSED : - SharesheetStandardEvent.SHARESHEET_EXPANDED, getInstanceId()); + SharesheetStandardEvent.SHARESHEET_EXPANDED, mInstanceId); } /** @@ -263,7 +263,7 @@ public class EventLogImpl implements EventLog { */ @Override public void logSharesheetAppShareRankingTimeout() { - log(SharesheetStandardEvent.SHARESHEET_APP_SHARE_RANKING_TIMEOUT, getInstanceId()); + log(SharesheetStandardEvent.SHARESHEET_APP_SHARE_RANKING_TIMEOUT, mInstanceId); } /** @@ -271,7 +271,7 @@ public class EventLogImpl implements EventLog { */ @Override public void logSharesheetEmptyDirectShareRow() { - log(SharesheetStandardEvent.SHARESHEET_EMPTY_DIRECT_SHARE_ROW, getInstanceId()); + log(SharesheetStandardEvent.SHARESHEET_EMPTY_DIRECT_SHARE_ROW, mInstanceId); } /** @@ -287,19 +287,6 @@ public class EventLogImpl implements EventLog { instanceId); } - /** - * @return A unique {@link InstanceId} to join across events recorded by this logger instance. - */ - private InstanceId getInstanceId() { - if (mInstanceId == null) { - if (sInstanceIdSequence == null) { - sInstanceIdSequence = new InstanceIdSequence(SHARESHEET_INSTANCE_ID_MAX); - } - mInstanceId = sInstanceIdSequence.newInstanceId(); - } - return mInstanceId; - } - /** * The UiEvent enums that this class can log. */ @@ -463,52 +450,4 @@ public class EventLogImpl implements EventLog { return 0; } } - - private static class DefaultFrameworkStatsLogger implements FrameworkStatsLogger { - @Override - public void write( - int frameworkEventId, - int appEventId, - String packageName, - int instanceId, - String mimeType, - int numAppProvidedDirectTargets, - int numAppProvidedAppTargets, - boolean isWorkProfile, - int previewType, - int intentType, - int numCustomActions, - boolean modifyShareActionProvided) { - FrameworkStatsLog.write( - frameworkEventId, - /* event_id = 1 */ appEventId, - /* package_name = 2 */ packageName, - /* instance_id = 3 */ instanceId, - /* mime_type = 4 */ mimeType, - /* num_app_provided_direct_targets */ numAppProvidedDirectTargets, - /* num_app_provided_app_targets */ numAppProvidedAppTargets, - /* is_workprofile */ isWorkProfile, - /* previewType = 8 */ previewType, - /* intentType = 9 */ intentType, - /* num_provided_custom_actions = 10 */ numCustomActions, - /* modify_share_action_provided = 11 */ modifyShareActionProvided); - } - - @Override - public void write( - int frameworkEventId, - int appEventId, - String packageName, - int instanceId, - int positionPicked, - boolean isPinned) { - FrameworkStatsLog.write( - frameworkEventId, - /* event_id = 1 */ appEventId, - /* package_name = 2 */ packageName, - /* instance_id = 3 */ instanceId, - /* position_picked = 4 */ positionPicked, - /* is_pinned = 5 */ isPinned); - } - } } diff --git a/java/src/com/android/intentresolver/logging/EventLogModule.kt b/java/src/com/android/intentresolver/logging/EventLogModule.kt new file mode 100644 index 00000000..eba8ecc8 --- /dev/null +++ b/java/src/com/android/intentresolver/logging/EventLogModule.kt @@ -0,0 +1,46 @@ +/* + * Copyright (C) 2023 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.android.intentresolver.logging + +import com.android.internal.logging.InstanceId +import com.android.internal.logging.InstanceIdSequence +import com.android.internal.logging.MetricsLogger +import com.android.internal.logging.UiEventLogger +import com.android.internal.logging.UiEventLoggerImpl +import dagger.Binds +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.android.components.ActivityComponent +import dagger.hilt.android.scopes.ActivityScoped + +@Module +@InstallIn(ActivityComponent::class) +interface EventLogModule { + + @Binds @ActivityScoped fun eventLog(value: EventLogImpl): EventLog + + companion object { + @Provides + fun instanceId(sequence: InstanceIdSequence): InstanceId = sequence.newInstanceId() + + @Provides fun uiEventLogger(): UiEventLogger = UiEventLoggerImpl() + + @Provides fun frameworkLogger(): FrameworkStatsLogger = object : FrameworkStatsLogger {} + + @Provides fun metricsLogger(): MetricsLogger = MetricsLogger() + } +} diff --git a/java/src/com/android/intentresolver/logging/FrameworkStatsLogger.kt b/java/src/com/android/intentresolver/logging/FrameworkStatsLogger.kt index e0682b9e..6508d305 100644 --- a/java/src/com/android/intentresolver/logging/FrameworkStatsLogger.kt +++ b/java/src/com/android/intentresolver/logging/FrameworkStatsLogger.kt @@ -21,7 +21,8 @@ import com.android.internal.util.FrameworkStatsLog internal annotation class ForUiEvent(vararg val uiEventId: Int) /** Isolates the specific method signatures to use for each of the logged UiEvents. */ -internal interface FrameworkStatsLogger { +interface FrameworkStatsLogger { + @ForUiEvent(FrameworkStatsLog.SHARESHEET_STARTED) fun write( frameworkEventId: Int, @@ -35,8 +36,23 @@ internal interface FrameworkStatsLogger { previewType: Int, intentType: Int, numCustomActions: Int, - modifyShareActionProvided: Boolean - ) + modifyShareActionProvided: Boolean, + ) { + FrameworkStatsLog.write( + frameworkEventId, /* event_id = 1 */ + appEventId, /* package_name = 2 */ + packageName, /* instance_id = 3 */ + instanceId, /* mime_type = 4 */ + mimeType, /* num_app_provided_direct_targets */ + numAppProvidedDirectTargets, /* num_app_provided_app_targets */ + numAppProvidedAppTargets, /* is_workprofile */ + isWorkProfile, /* previewType = 8 */ + previewType, /* intentType = 9 */ + intentType, /* num_provided_custom_actions = 10 */ + numCustomActions, /* modify_share_action_provided = 11 */ + modifyShareActionProvided + ) + } @ForUiEvent(FrameworkStatsLog.RANKING_SELECTED) fun write( @@ -45,6 +61,15 @@ internal interface FrameworkStatsLogger { packageName: String?, instanceId: Int, positionPicked: Int, - isPinned: Boolean - ) + isPinned: Boolean, + ) { + FrameworkStatsLog.write( + frameworkEventId, /* event_id = 1 */ + appEventId, /* package_name = 2 */ + packageName, /* instance_id = 3 */ + instanceId, /* position_picked = 4 */ + positionPicked, /* is_pinned = 5 */ + isPinned + ) + } } diff --git a/java/tests/src/com/android/intentresolver/ChooserActionFactoryTest.kt b/java/tests/src/com/android/intentresolver/ChooserActionFactoryTest.kt index e2b987c2..2d1ac4e4 100644 --- a/java/tests/src/com/android/intentresolver/ChooserActionFactoryTest.kt +++ b/java/tests/src/com/android/intentresolver/ChooserActionFactoryTest.kt @@ -28,13 +28,12 @@ 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.logging.EventLogImpl +import com.android.intentresolver.logging.EventLog import com.google.common.collect.ImmutableList import com.google.common.truth.Truth.assertThat import java.util.concurrent.CountDownLatch import java.util.concurrent.TimeUnit import java.util.function.Consumer -import com.android.intentresolver.logging.EventLog import org.junit.After import org.junit.Assert.assertEquals import org.junit.Assert.assertTrue diff --git a/java/tests/src/com/android/intentresolver/ChooserActivityOverrideData.java b/java/tests/src/com/android/intentresolver/ChooserActivityOverrideData.java index b5c14ff1..d77aa099 100644 --- a/java/tests/src/com/android/intentresolver/ChooserActivityOverrideData.java +++ b/java/tests/src/com/android/intentresolver/ChooserActivityOverrideData.java @@ -29,7 +29,6 @@ import android.os.UserHandle; import com.android.intentresolver.AbstractMultiProfilePagerAdapter.CrossProfileIntentsChecker; import com.android.intentresolver.chooser.TargetInfo; import com.android.intentresolver.contentpreview.ImageLoader; -import com.android.intentresolver.logging.EventLogImpl; import com.android.intentresolver.shortcuts.ShortcutLoader; import java.util.function.Consumer; @@ -64,7 +63,6 @@ public class ChooserActivityOverrideData { public Cursor resolverCursor; public boolean resolverForceException; public ImageLoader imageLoader; - public EventLogImpl mEventLog; public int alternateProfileSetting; public Resources resources; public UserHandle workProfileUserHandle; @@ -86,7 +84,6 @@ public class ChooserActivityOverrideData { resolverForceException = false; resolverListController = mock(ChooserActivity.ChooserListController.class); workResolverListController = mock(ChooserActivity.ChooserListController.class); - mEventLog = mock(EventLogImpl.class); alternateProfileSetting = 0; resources = null; workProfileUserHandle = null; diff --git a/java/tests/src/com/android/intentresolver/ChooserWrapperActivity.java b/java/tests/src/com/android/intentresolver/ChooserWrapperActivity.java index d488e02b..27b7037f 100644 --- a/java/tests/src/com/android/intentresolver/ChooserWrapperActivity.java +++ b/java/tests/src/com/android/intentresolver/ChooserWrapperActivity.java @@ -39,7 +39,6 @@ import com.android.intentresolver.chooser.DisplayResolveInfo; import com.android.intentresolver.chooser.TargetInfo; import com.android.intentresolver.grid.ChooserGridAdapter; import com.android.intentresolver.icons.TargetDataLoader; -import com.android.intentresolver.logging.EventLogImpl; import com.android.intentresolver.shortcuts.ShortcutLoader; import com.android.internal.logging.nano.MetricsProto.MetricsEvent; @@ -203,11 +202,6 @@ public class ChooserWrapperActivity extends ChooserActivity implements IChooserW sOverrides.imageLoader); } - @Override - public EventLogImpl getEventLog() { - return sOverrides.mEventLog; - } - @Override public Cursor queryResolver(ContentResolver resolver, Uri uri) { if (sOverrides.resolverCursor != null) { diff --git a/java/tests/src/com/android/intentresolver/IChooserWrapper.java b/java/tests/src/com/android/intentresolver/IChooserWrapper.java index 54e4da9e..d439b037 100644 --- a/java/tests/src/com/android/intentresolver/IChooserWrapper.java +++ b/java/tests/src/com/android/intentresolver/IChooserWrapper.java @@ -23,7 +23,6 @@ import android.content.pm.ResolveInfo; import android.os.UserHandle; import com.android.intentresolver.chooser.DisplayResolveInfo; -import com.android.intentresolver.logging.EventLogImpl; import java.util.concurrent.Executor; @@ -42,6 +41,5 @@ public interface IChooserWrapper { CharSequence pLabel, CharSequence pInfo, Intent replacementIntent, @Nullable TargetPresentationGetter resolveInfoPresentationGetter); UserHandle getCurrentUserHandle(); - EventLogImpl getEventLog(); Executor getMainExecutor(); } diff --git a/java/tests/src/com/android/intentresolver/UnbundledChooserActivityTest.java b/java/tests/src/com/android/intentresolver/UnbundledChooserActivityTest.java index 0b3cb20a..9f16ced5 100644 --- a/java/tests/src/com/android/intentresolver/UnbundledChooserActivityTest.java +++ b/java/tests/src/com/android/intentresolver/UnbundledChooserActivityTest.java @@ -36,6 +36,7 @@ import static com.android.intentresolver.ChooserListAdapter.CALLER_TARGET_SCORE_ import static com.android.intentresolver.ChooserListAdapter.SHORTCUT_TARGET_SCORE_BOOST; import static com.android.intentresolver.MatcherUtils.first; import static com.google.common.truth.Truth.assertThat; +import static com.google.common.truth.Truth.assertWithMessage; import static junit.framework.Assert.assertNull; import static org.hamcrest.CoreMatchers.allOf; import static org.hamcrest.CoreMatchers.is; @@ -44,9 +45,7 @@ import static org.hamcrest.MatcherAssert.assertThat; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertTrue; import static org.mockito.ArgumentMatchers.any; -import static org.mockito.ArgumentMatchers.anyBoolean; import static org.mockito.ArgumentMatchers.anyInt; -import static org.mockito.ArgumentMatchers.anyLong; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.doReturn; import static org.mockito.Mockito.mock; @@ -93,7 +92,6 @@ import android.text.style.BackgroundColorSpan; import android.text.style.ForegroundColorSpan; import android.text.style.StyleSpan; import android.text.style.UnderlineSpan; -import android.util.HashedStringCache; import android.util.Pair; import android.util.SparseArray; import android.view.View; @@ -113,11 +111,14 @@ import androidx.test.rule.ActivityTestRule; import com.android.intentresolver.chooser.DisplayResolveInfo; import com.android.intentresolver.contentpreview.ImageLoader; import com.android.intentresolver.logging.EventLog; -import com.android.intentresolver.logging.EventLogImpl; +import com.android.intentresolver.logging.FakeEventLog; import com.android.intentresolver.shortcuts.ShortcutLoader; import com.android.internal.config.sysui.SystemUiDeviceConfigFlags; import com.android.internal.logging.nano.MetricsProto.MetricsEvent; +import dagger.hilt.android.testing.HiltAndroidRule; +import dagger.hilt.android.testing.HiltAndroidTest; + import org.hamcrest.Description; import org.hamcrest.Matcher; import org.hamcrest.Matchers; @@ -146,9 +147,6 @@ import java.util.concurrent.atomic.AtomicReference; import java.util.function.Consumer; import java.util.function.Function; -import dagger.hilt.android.testing.HiltAndroidRule; -import dagger.hilt.android.testing.HiltAndroidTest; - /** * Instrumentation tests for ChooserActivity. *

@@ -159,6 +157,10 @@ import dagger.hilt.android.testing.HiltAndroidTest; @HiltAndroidTest public class UnbundledChooserActivityTest { + private static FakeEventLog getEventLog(ChooserWrapperActivity activity) { + return (FakeEventLog) activity.mEventLog; + } + private static final UserHandle PERSONAL_USER_HANDLE = InstrumentationRegistry .getInstrumentation().getTargetContext().getUser(); private static final Function DEFAULT_PM = pm -> pm; @@ -179,6 +181,20 @@ public class UnbundledChooserActivityTest { }); } + private static final String TEST_MIME_TYPE = "application/TestType"; + + private static final int CONTENT_PREVIEW_IMAGE = 1; + private static final int CONTENT_PREVIEW_FILE = 2; + private static final int CONTENT_PREVIEW_TEXT = 3; + + + @Rule(order = 0) + public HiltAndroidRule mHiltAndroidRule = new HiltAndroidRule(this); + + @Rule(order = 1) + public ActivityTestRule mActivityRule = + new ActivityTestRule<>(ChooserWrapperActivity.class, false, false); + @Before public void setUp() { // TODO: use the other form of `adoptShellPermissionIdentity()` where we explicitly list the @@ -189,21 +205,9 @@ public class UnbundledChooserActivityTest { .adoptShellPermissionIdentity(); cleanOverrideData(); + mHiltAndroidRule.inject(); } - @Rule(order = 0) - public HiltAndroidRule mHiltAndroidRule = new HiltAndroidRule(this); - - @Rule(order = 1) - public ActivityTestRule mActivityRule = - new ActivityTestRule<>(ChooserWrapperActivity.class, false, false); - - private static final String TEST_MIME_TYPE = "application/TestType"; - - private static final int CONTENT_PREVIEW_IMAGE = 1; - private static final int CONTENT_PREVIEW_FILE = 2; - private static final int CONTENT_PREVIEW_TEXT = 3; - private final Function mPackageManagerOverride; public UnbundledChooserActivityTest( @@ -845,15 +849,16 @@ public class UnbundledChooserActivityTest { setupResolverControllers(resolvedComponentInfos); - final IChooserWrapper activity = (IChooserWrapper) + ChooserWrapperActivity activity = mActivityRule.launchActivity(Intent.createChooser(sendIntent, null)); waitForIdle(); onView(withId(R.id.copy)).check(matches(isDisplayed())); onView(withId(R.id.copy)).perform(click()); - - EventLog logger = activity.getEventLog(); - verify(logger, times(1)).logActionSelected(eq(EventLogImpl.SELECTION_TYPE_COPY)); + FakeEventLog eventLog = getEventLog(activity); + assertThat(eventLog.getActionSelected()) + .isEqualTo(new FakeEventLog.ActionSelected( + /* targetType = */ EventLog.SELECTION_TYPE_COPY)); } @Test @@ -864,8 +869,7 @@ public class UnbundledChooserActivityTest { setupResolverControllers(resolvedComponentInfos); - final IChooserWrapper activity = (IChooserWrapper) - mActivityRule.launchActivity(Intent.createChooser(sendIntent, null)); + mActivityRule.launchActivity(Intent.createChooser(sendIntent, null)); waitForIdle(); onView(withId(com.android.internal.R.id.chooser_nearby_button)) @@ -888,8 +892,7 @@ public class UnbundledChooserActivityTest { setupResolverControllers(resolvedComponentInfos); - final IChooserWrapper activity = (IChooserWrapper) - mActivityRule.launchActivity(Intent.createChooser(sendIntent, null)); + mActivityRule.launchActivity(Intent.createChooser(sendIntent, null)); waitForIdle(); onView(withId(com.android.internal.R.id.chooser_edit_button)).check(matches(isDisplayed())); @@ -1200,12 +1203,15 @@ public class UnbundledChooserActivityTest { Intent sendIntent = createSendTextIntent(); sendIntent.setType(TEST_MIME_TYPE); - final IChooserWrapper activity = (IChooserWrapper) + ChooserWrapperActivity activity = mActivityRule.launchActivity(Intent.createChooser(sendIntent, "logger test")); - EventLog logger = activity.getEventLog(); waitForIdle(); - verify(logger).logChooserActivityShown(eq(false), eq(TEST_MIME_TYPE), anyLong()); + FakeEventLog eventLog = getEventLog(activity); + FakeEventLog.ChooserActivityShown event = eventLog.getChooserActivityShown(); + assertThat(event).isNotNull(); + assertThat(event.isWorkProfile()).isFalse(); + assertThat(event.getTargetMimeType()).isEqualTo(TEST_MIME_TYPE); } @Test @@ -1215,25 +1221,31 @@ public class UnbundledChooserActivityTest { ChooserActivityOverrideData.getInstance().alternateProfileSetting = MetricsEvent.MANAGED_PROFILE; - final IChooserWrapper activity = (IChooserWrapper) + ChooserWrapperActivity activity = mActivityRule.launchActivity(Intent.createChooser(sendIntent, "logger test")); - EventLog logger = activity.getEventLog(); waitForIdle(); - verify(logger).logChooserActivityShown(eq(true), eq(TEST_MIME_TYPE), anyLong()); + FakeEventLog eventLog = getEventLog(activity); + FakeEventLog.ChooserActivityShown event = eventLog.getChooserActivityShown(); + assertThat(event).isNotNull(); + assertThat(event.isWorkProfile()).isTrue(); + assertThat(event.getTargetMimeType()).isEqualTo(TEST_MIME_TYPE); } @Test public void testEmptyPreviewLogging() { Intent sendIntent = createSendTextIntentWithPreview(null, null); - final IChooserWrapper activity = (IChooserWrapper) - mActivityRule.launchActivity( - Intent.createChooser(sendIntent, "empty preview logger test")); - EventLog logger = activity.getEventLog(); + ChooserWrapperActivity activity = + mActivityRule.launchActivity(Intent.createChooser(sendIntent, + "empty preview logger test")); waitForIdle(); - verify(logger).logChooserActivityShown(eq(false), eq(null), anyLong()); + FakeEventLog eventLog = getEventLog(activity); + FakeEventLog.ChooserActivityShown event = eventLog.getChooserActivityShown(); + assertThat(event).isNotNull(); + assertThat(event.isWorkProfile()).isFalse(); + assertThat(event.getTargetMimeType()).isNull(); } @Test @@ -1244,13 +1256,14 @@ public class UnbundledChooserActivityTest { setupResolverControllers(resolvedComponentInfos); - final IChooserWrapper activity = (IChooserWrapper) + ChooserWrapperActivity activity = mActivityRule.launchActivity(Intent.createChooser(sendIntent, null)); waitForIdle(); - // Second invocation is from onCreate - EventLog logger = activity.getEventLog(); - Mockito.verify(logger, times(1)).logActionShareWithPreview(eq(CONTENT_PREVIEW_TEXT)); + FakeEventLog eventLog = getEventLog(activity); + assertThat(eventLog.getActionShareWithPreview()) + .isEqualTo(new FakeEventLog.ActionShareWithPreview( + /* previewType = */ CONTENT_PREVIEW_TEXT)); } @Test @@ -1268,11 +1281,14 @@ public class UnbundledChooserActivityTest { setupResolverControllers(resolvedComponentInfos); - final IChooserWrapper activity = (IChooserWrapper) + ChooserWrapperActivity activity = mActivityRule.launchActivity(Intent.createChooser(sendIntent, null)); waitForIdle(); - EventLog logger = activity.getEventLog(); - Mockito.verify(logger, times(1)).logActionShareWithPreview(eq(CONTENT_PREVIEW_IMAGE)); + + FakeEventLog eventLog = getEventLog(activity); + assertThat(eventLog.getActionShareWithPreview()) + .isEqualTo(new FakeEventLog.ActionShareWithPreview( + /* previewType = */ CONTENT_PREVIEW_IMAGE)); } @Test @@ -1421,7 +1437,7 @@ public class UnbundledChooserActivityTest { createShortcutLoaderFactory(); // Start activity - final IChooserWrapper activity = (IChooserWrapper) + ChooserWrapperActivity activity = mActivityRule.launchActivity(Intent.createChooser(sendIntent, null)); waitForIdle(); @@ -1471,22 +1487,15 @@ public class UnbundledChooserActivityTest { .perform(click()); waitForIdle(); - ArgumentCaptor hashCaptor = - ArgumentCaptor.forClass(HashedStringCache.HashResult.class); - verify(activity.getEventLog(), times(1)).logShareTargetSelected( - eq(EventLogImpl.SELECTION_TYPE_SERVICE), - /* packageName= */ any(), - /* positionPicked= */ anyInt(), - /* directTargetAlsoRanked= */ eq(-1), - /* numCallerProvided= */ anyInt(), - /* directTargetHashed= */ hashCaptor.capture(), - /* isPinned= */ anyBoolean(), - /* successfullySelected= */ anyBoolean(), - /* selectionCost= */ anyLong()); - String hashedName = hashCaptor.getValue().hashedString; - assertThat( - "Hash is not predictable but must be obfuscated", - hashedName, is(not(name))); + FakeEventLog eventLog = getEventLog(activity); + assertThat(eventLog.getShareTargetSelected()).hasSize(1); + FakeEventLog.ShareTargetSelected call = eventLog.getShareTargetSelected().get(0); + assertThat(call.getTargetType()).isEqualTo(EventLog.SELECTION_TYPE_SERVICE); + assertThat(call.getDirectTargetAlsoRanked()).isEqualTo(-1); + var hashResult = call.getDirectTargetHashed(); + var hash = hashResult == null ? "" : hashResult.hashedString; + assertWithMessage("Hash is not predictable but must be obfuscated") + .that(hash).isNotEqualTo(name); } // This test is too long and too slow and should not be taken as an example for future tests. @@ -1502,7 +1511,7 @@ public class UnbundledChooserActivityTest { createShortcutLoaderFactory(); // Start activity - final IChooserWrapper activity = (IChooserWrapper) + ChooserWrapperActivity activity = mActivityRule.launchActivity(Intent.createChooser(sendIntent, null)); waitForIdle(); @@ -1554,16 +1563,12 @@ public class UnbundledChooserActivityTest { .perform(click()); waitForIdle(); - verify(activity.getEventLog(), times(1)).logShareTargetSelected( - eq(EventLogImpl.SELECTION_TYPE_SERVICE), - /* packageName= */ any(), - /* positionPicked= */ anyInt(), - /* directTargetAlsoRanked= */ eq(0), - /* numCallerProvided= */ anyInt(), - /* directTargetHashed= */ any(), - /* isPinned= */ anyBoolean(), - /* successfullySelected= */ anyBoolean(), - /* selectionCost= */ anyLong()); + FakeEventLog eventLog = getEventLog(activity); + assertThat(eventLog.getShareTargetSelected()).hasSize(1); + FakeEventLog.ShareTargetSelected call = eventLog.getShareTargetSelected().get(0); + + assertThat(call.getTargetType()).isEqualTo(EventLog.SELECTION_TYPE_SERVICE); + assertThat(call.getDirectTargetAlsoRanked()).isEqualTo(0); } @Test @@ -1935,14 +1940,14 @@ public class UnbundledChooserActivityTest { ResolveInfo ri = ResolverDataProvider.createResolveInfo(16, 0, PERSONAL_USER_HANDLE); // Start activity - final IChooserWrapper wrapper = (IChooserWrapper) + ChooserWrapperActivity activity = mActivityRule.launchActivity(Intent.createChooser(sendIntent, null)); // Insert the direct share target Map directShareToShortcutInfos = new HashMap<>(); directShareToShortcutInfos.put(serviceTargets.get(0), null); InstrumentationRegistry.getInstrumentation().runOnMainSync( - () -> wrapper.getAdapter().addServiceResults( - wrapper.createTestDisplayResolveInfo(sendIntent, + () -> activity.getAdapter().addServiceResults( + activity.createTestDisplayResolveInfo(sendIntent, ri, "testLabel", "testInfo", @@ -1957,11 +1962,11 @@ public class UnbundledChooserActivityTest { assertThat( String.format("Chooser should have %d targets (%d apps, 1 direct, 15 A-Z)", appTargetsExpected + 16, appTargetsExpected), - wrapper.getAdapter().getCount(), is(appTargetsExpected + 16)); + activity.getAdapter().getCount(), is(appTargetsExpected + 16)); assertThat("Chooser should have exactly one selectable direct target", - wrapper.getAdapter().getSelectableServiceTargetCount(), is(1)); + activity.getAdapter().getSelectableServiceTargetCount(), is(1)); assertThat("The resolver info must match the resolver info used to create the target", - wrapper.getAdapter().getItem(0).getResolveInfo(), is(ri)); + activity.getAdapter().getItem(0).getResolveInfo(), is(ri)); // Click on the direct target String name = serviceTargets.get(0).getTitle().toString(); @@ -1969,18 +1974,16 @@ public class UnbundledChooserActivityTest { .perform(click()); waitForIdle(); - EventLog logger = wrapper.getEventLog(); - verify(logger, times(1)).logShareTargetSelected( - eq(EventLogImpl.SELECTION_TYPE_SERVICE), - /* packageName= */ any(), - /* positionPicked= */ anyInt(), - // The packages sholdn't match for app target and direct target: - /* directTargetAlsoRanked= */ eq(-1), - /* numCallerProvided= */ anyInt(), - /* directTargetHashed= */ any(), - /* isPinned= */ anyBoolean(), - /* successfullySelected= */ anyBoolean(), - /* selectionCost= */ anyLong()); + FakeEventLog eventLog = getEventLog(activity); + var invocations = eventLog.getShareTargetSelected(); + assertWithMessage("Only one ShareTargetSelected event logged") + .that(invocations).hasSize(1); + FakeEventLog.ShareTargetSelected call = invocations.get(0); + assertWithMessage("targetType should be SELECTION_TYPE_SERVICE") + .that(call.getTargetType()).isEqualTo(EventLog.SELECTION_TYPE_SERVICE); + assertWithMessage( + "The packages shouldn't match for app target and direct target") + .that(call.getDirectTargetAlsoRanked()).isEqualTo(-1); } @Test @@ -2253,7 +2256,7 @@ public class UnbundledChooserActivityTest { }; // Start activity - final IChooserWrapper activity = (IChooserWrapper) + ChooserWrapperActivity activity = mActivityRule.launchActivity(Intent.createChooser(sendIntent, null)); waitForIdle(); @@ -2301,18 +2304,10 @@ public class UnbundledChooserActivityTest { .perform(click()); waitForIdle(); - EventLog logger = activity.getEventLog(); - ArgumentCaptor typeCaptor = ArgumentCaptor.forClass(Integer.class); - verify(logger, times(1)).logShareTargetSelected( - eq(EventLogImpl.SELECTION_TYPE_SERVICE), - /* packageName= */ any(), - /* positionPicked= */ anyInt(), - /* directTargetAlsoRanked= */ anyInt(), - /* numCallerProvided= */ anyInt(), - /* directTargetHashed= */ any(), - /* isPinned= */ anyBoolean(), - /* successfullySelected= */ anyBoolean(), - /* selectionCost= */ anyLong()); + FakeEventLog eventLog = getEventLog(activity); + assertThat(eventLog.getShareTargetSelected()).hasSize(1); + FakeEventLog.ShareTargetSelected call = eventLog.getShareTargetSelected().get(0); + assertThat(call.getTargetType()).isEqualTo(EventLog.SELECTION_TYPE_SERVICE); } @Test diff --git a/java/tests/src/com/android/intentresolver/logging/EventLogImplTest.java b/java/tests/src/com/android/intentresolver/logging/EventLogImplTest.java index 19177798..d75ea99b 100644 --- a/java/tests/src/com/android/intentresolver/logging/EventLogImplTest.java +++ b/java/tests/src/com/android/intentresolver/logging/EventLogImplTest.java @@ -37,6 +37,7 @@ import com.android.intentresolver.logging.EventLogImpl.SharesheetStartedEvent; import com.android.intentresolver.logging.EventLogImpl.SharesheetTargetSelectedEvent; import com.android.intentresolver.contentpreview.ContentPreviewType; import com.android.internal.logging.InstanceId; +import com.android.internal.logging.InstanceIdSequence; import com.android.internal.logging.MetricsLogger; import com.android.internal.logging.UiEventLogger; import com.android.internal.logging.UiEventLogger.UiEventEnum; @@ -59,10 +60,12 @@ public final class EventLogImplTest { private EventLogImpl mChooserLogger; + private final InstanceIdSequence mSequence = EventLogImpl.newIdSequence(); + @Before public void setUp() { - //Mockito.reset(mUiEventLog, mFrameworkLog, mMetricsLogger); - mChooserLogger = new EventLogImpl(mUiEventLog, mFrameworkLog, mMetricsLogger); + mChooserLogger = new EventLogImpl(mUiEventLog, mFrameworkLog, mMetricsLogger, + mSequence.newInstanceId()); } @After @@ -320,7 +323,8 @@ public final class EventLogImplTest { public void testDifferentLoggerInstancesUseDifferentInstanceIds() { ArgumentCaptor idIntCaptor = ArgumentCaptor.forClass(Integer.class); EventLogImpl chooserLogger2 = - new EventLogImpl(mUiEventLog, mFrameworkLog, mMetricsLogger); + new EventLogImpl(mUiEventLog, mFrameworkLog, mMetricsLogger, + mSequence.newInstanceId()); final int targetType = EventLogImpl.SELECTION_TYPE_COPY; final String packageName = "com.test.foo"; diff --git a/java/tests/src/com/android/intentresolver/logging/FakeEventLog.kt b/java/tests/src/com/android/intentresolver/logging/FakeEventLog.kt new file mode 100644 index 00000000..9ed47db6 --- /dev/null +++ b/java/tests/src/com/android/intentresolver/logging/FakeEventLog.kt @@ -0,0 +1,197 @@ +/* + * Copyright (C) 2023 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.intentresolver.logging + +import android.net.Uri +import android.util.HashedStringCache +import android.util.Log +import com.android.internal.logging.InstanceId +import javax.inject.Inject + +private const val TAG = "EventLog" +private const val LOG = true + +/** A fake EventLog. */ +class FakeEventLog @Inject constructor(private val instanceId: InstanceId) : EventLog { + + var chooserActivityShown: ChooserActivityShown? = null + var actionSelected: ActionSelected? = null + var customActionSelected: CustomActionSelected? = null + var actionShareWithPreview: ActionShareWithPreview? = null + val shareTargetSelected: MutableList = mutableListOf() + + private fun log(message: () -> Any?) { + if (LOG) { + Log.d(TAG, "[%04x] ".format(instanceId.id) + message()) + } + } + + override fun logChooserActivityShown( + isWorkProfile: Boolean, + targetMimeType: String?, + systemCost: Long + ) { + chooserActivityShown = ChooserActivityShown(isWorkProfile, targetMimeType, systemCost) + log { chooserActivityShown } + } + + override fun logShareStarted( + packageName: String?, + mimeType: String?, + appProvidedDirect: Int, + appProvidedApp: Int, + isWorkprofile: Boolean, + previewType: Int, + intent: String?, + customActionCount: Int, + modifyShareActionProvided: Boolean + ) { + log { + ShareStarted( + packageName, + mimeType, + appProvidedDirect, + appProvidedApp, + isWorkprofile, + previewType, + intent, + customActionCount, + modifyShareActionProvided + ) + } + } + + override fun logCustomActionSelected(positionPicked: Int) { + customActionSelected = CustomActionSelected(positionPicked) + log { "logCustomActionSelected(positionPicked=$positionPicked)" } + } + + override fun logShareTargetSelected( + targetType: Int, + packageName: String?, + positionPicked: Int, + directTargetAlsoRanked: Int, + numCallerProvided: Int, + directTargetHashed: HashedStringCache.HashResult?, + isPinned: Boolean, + successfullySelected: Boolean, + selectionCost: Long + ) { + shareTargetSelected.add( + ShareTargetSelected( + targetType, + packageName, + positionPicked, + directTargetAlsoRanked, + numCallerProvided, + directTargetHashed, + isPinned, + successfullySelected, + selectionCost + ) + ) + log { shareTargetSelected.last() } + shareTargetSelected.limitSize(10) + } + + private fun MutableList<*>.limitSize(n: Int) { + while (size > n) { + removeFirst() + } + } + + override fun logDirectShareTargetReceived(category: Int, latency: Int) { + log { "logDirectShareTargetReceived(category=$category, latency=$latency)" } + } + + override fun logActionShareWithPreview(previewType: Int) { + actionShareWithPreview = ActionShareWithPreview(previewType) + log { actionShareWithPreview } + } + + override fun logActionSelected(targetType: Int) { + actionSelected = ActionSelected(targetType) + log { actionSelected } + } + + override fun logContentPreviewWarning(uri: Uri?) { + log { "logContentPreviewWarning(uri=$uri)" } + } + + override fun logSharesheetTriggered() { + log { "logSharesheetTriggered()" } + } + + override fun logSharesheetAppLoadComplete() { + log { "logSharesheetAppLoadComplete()" } + } + + override fun logSharesheetDirectLoadComplete() { + log { "logSharesheetAppLoadComplete()" } + } + + override fun logSharesheetDirectLoadTimeout() { + log { "logSharesheetDirectLoadTimeout()" } + } + + override fun logSharesheetProfileChanged() { + log { "logSharesheetProfileChanged()" } + } + + override fun logSharesheetExpansionChanged(isCollapsed: Boolean) { + log { "logSharesheetExpansionChanged(isCollapsed=$isCollapsed)" } + } + + override fun logSharesheetAppShareRankingTimeout() { + log { "logSharesheetAppShareRankingTimeout()" } + } + + override fun logSharesheetEmptyDirectShareRow() { + log { "logSharesheetEmptyDirectShareRow()" } + } + + data class ActionSelected(val targetType: Int) + data class CustomActionSelected(val positionPicked: Int) + data class ActionShareWithPreview(val previewType: Int) + data class ChooserActivityShown( + val isWorkProfile: Boolean, + val targetMimeType: String?, + val systemCost: Long + ) + data class ShareStarted( + val packageName: String?, + val mimeType: String?, + val appProvidedDirect: Int, + val appProvidedApp: Int, + val isWorkprofile: Boolean, + val previewType: Int, + val intent: String?, + val customActionCount: Int, + val modifyShareActionProvided: Boolean + ) + data class ShareTargetSelected( + val targetType: Int, + val packageName: String?, + val positionPicked: Int, + val directTargetAlsoRanked: Int, + val numCallerProvided: Int, + val directTargetHashed: HashedStringCache.HashResult?, + val pinned: Boolean, + val successfullySelected: Boolean, + val selectionCost: Long + ) +} diff --git a/java/tests/src/com/android/intentresolver/logging/FakeFrameworkStatsLogger.kt b/java/tests/src/com/android/intentresolver/logging/FakeFrameworkStatsLogger.kt new file mode 100644 index 00000000..dcf8d23f --- /dev/null +++ b/java/tests/src/com/android/intentresolver/logging/FakeFrameworkStatsLogger.kt @@ -0,0 +1,95 @@ +package com.android.intentresolver.logging +/* + * Copyright (C) 2023 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import com.android.internal.util.FrameworkStatsLog + +internal data class ShareSheetStarted( + val frameworkEventId: Int = FrameworkStatsLog.SHARESHEET_STARTED, + val appEventId: Int, + val packageName: String?, + val instanceId: Int, + val mimeType: String?, + val numAppProvidedDirectTargets: Int, + val numAppProvidedAppTargets: Int, + val isWorkProfile: Boolean, + val previewType: Int, + val intentType: Int, + val numCustomActions: Int, + val modifyShareActionProvided: Boolean +) + +internal data class RankingSelected( + val frameworkEventId: Int = FrameworkStatsLog.RANKING_SELECTED, + val appEventId: Int, + val packageName: String?, + val instanceId: Int, + val positionPicked: Int, + val isPinned: Boolean +) + +internal class FakeFrameworkStatsLogger : FrameworkStatsLogger { + var shareSheetStarted: ShareSheetStarted? = null + var rankingSelected: RankingSelected? = null + override fun write( + frameworkEventId: Int, + appEventId: Int, + packageName: String?, + instanceId: Int, + mimeType: String?, + numAppProvidedDirectTargets: Int, + numAppProvidedAppTargets: Int, + isWorkProfile: Boolean, + previewType: Int, + intentType: Int, + numCustomActions: Int, + modifyShareActionProvided: Boolean + ) { + shareSheetStarted = + ShareSheetStarted( + frameworkEventId, + appEventId, + packageName, + instanceId, + mimeType, + numAppProvidedDirectTargets, + numAppProvidedAppTargets, + isWorkProfile, + previewType, + intentType, + numCustomActions, + modifyShareActionProvided + ) + } + override fun write( + frameworkEventId: Int, + appEventId: Int, + packageName: String?, + instanceId: Int, + positionPicked: Int, + isPinned: Boolean + ) { + rankingSelected = + RankingSelected( + frameworkEventId, + appEventId, + packageName, + instanceId, + positionPicked, + isPinned + ) + } +} diff --git a/java/tests/src/com/android/intentresolver/logging/TestEventLogModule.kt b/java/tests/src/com/android/intentresolver/logging/TestEventLogModule.kt new file mode 100644 index 00000000..cd808af4 --- /dev/null +++ b/java/tests/src/com/android/intentresolver/logging/TestEventLogModule.kt @@ -0,0 +1,39 @@ +/* + * Copyright (C) 2023 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.intentresolver.logging + +import com.android.internal.logging.InstanceId +import com.android.internal.logging.InstanceIdSequence +import dagger.Binds +import dagger.Module +import dagger.Provides +import dagger.hilt.android.components.ActivityComponent +import dagger.hilt.android.scopes.ActivityScoped +import dagger.hilt.testing.TestInstallIn + +/** Binds a [FakeEventLog] as [EventLog] in tests. */ +@Module +@TestInstallIn(components = [ActivityComponent::class], replaces = [EventLogModule::class]) +interface TestEventLogModule { + + @Binds @ActivityScoped fun fakeEventLog(impl: FakeEventLog): EventLog + + companion object { + @Provides + fun instanceId(sequence: InstanceIdSequence): InstanceId = sequence.newInstanceId() + } +} -- cgit v1.2.3-59-g8ed1b From 369287f575566fda8d2173f3dc76c3388fea18a5 Mon Sep 17 00:00:00 2001 From: Andrey Epin Date: Thu, 14 Sep 2023 16:25:18 -0700 Subject: Make chooser full-width in phone-portrait orientation Max Chooser width for phones increased based on Pixel 6/7 pro dimensions and max configurable display density (System Settings -> Display -> Dispaly size and text). Fix: 297980420 Test: visual testing on a phone and tablet with various display settings Change-Id: I6111fd214075646832e667a84d2ea9ada7626536 --- java/res/values-port/dimens.xml | 18 ++++++++++++++++++ java/res/values/dimens.xml | 2 +- .../intentresolver/grid/ChooserGridAdapter.java | 6 ++++-- 3 files changed, 23 insertions(+), 3 deletions(-) create mode 100644 java/res/values-port/dimens.xml (limited to 'java/src') diff --git a/java/res/values-port/dimens.xml b/java/res/values-port/dimens.xml new file mode 100644 index 00000000..100a7e17 --- /dev/null +++ b/java/res/values-port/dimens.xml @@ -0,0 +1,18 @@ + + + -1px + diff --git a/java/res/values/dimens.xml b/java/res/values/dimens.xml index 6590d70e..ae80815b 100644 --- a/java/res/values/dimens.xml +++ b/java/res/values/dimens.xml @@ -20,7 +20,7 @@ 28dp 2dp 200dp - 412dp + 450dp 28dp 14dp 25dp diff --git a/java/src/com/android/intentresolver/grid/ChooserGridAdapter.java b/java/src/com/android/intentresolver/grid/ChooserGridAdapter.java index 77ae20f5..fadea934 100644 --- a/java/src/com/android/intentresolver/grid/ChooserGridAdapter.java +++ b/java/src/com/android/intentresolver/grid/ChooserGridAdapter.java @@ -164,8 +164,10 @@ public final class ChooserGridAdapter extends RecyclerView.Adapter= 0) { + width = Math.min(mChooserWidthPixels, width); + } int newWidth = width / mMaxTargetsPerRow; if (newWidth != mChooserTargetWidth) { -- cgit v1.2.3-59-g8ed1b From 928bc9858768b3697c64a2fd30b48e206768e7d3 Mon Sep 17 00:00:00 2001 From: Andrey Epin Date: Wed, 26 Jul 2023 15:07:53 -0700 Subject: Add headline view argument to content preview UI "Sticky" headline is one of the requirements of the scrollable preview feature. Currently headling row is included in every preview type we have and to be "sticky" it should be moved out of all of them. This change is a preparation work that makes all of the preview UI controllers aware of a possible external headline row (but no actual external headline view is used). Bug: 287102904 Test: manual testing of all preivew types checking that there are no regressions Test: atest com.android.intentresolver.contentpreview Change-Id: Ib6110aaa82246e3354fdce18f431ab1c116e7b73 --- java/res/layout/chooser_grid_preview_file.xml | 8 ++- .../res/layout/chooser_grid_preview_files_text.xml | 8 ++- java/res/layout/chooser_grid_preview_image.xml | 8 ++- java/res/layout/chooser_grid_preview_text.xml | 8 ++- .../android/intentresolver/ChooserActivity.java | 5 +- .../contentpreview/ChooserContentPreviewUi.java | 8 ++- .../contentpreview/ContentPreviewUi.java | 39 ++++++---- .../contentpreview/FileContentPreviewUi.java | 22 ++++-- .../FilesPlusTextContentPreviewUi.java | 43 ++++++++---- .../contentpreview/NoContextPreviewUi.kt | 6 +- .../contentpreview/TextContentPreviewUi.java | 20 ++++-- .../contentpreview/UnifiedContentPreviewUi.java | 31 +++++--- .../contentpreview/FileContentPreviewUiTest.kt | 76 ++++++++++++++++++++ .../FilesPlusTextContentPreviewUiTest.kt | 9 ++- .../contentpreview/TextContentPreviewUiTest.kt | 82 ++++++++++++++++++++++ .../contentpreview/UnifiedContentPreviewUiTest.kt | 7 +- 16 files changed, 320 insertions(+), 60 deletions(-) create mode 100644 java/tests/src/com/android/intentresolver/contentpreview/FileContentPreviewUiTest.kt create mode 100644 java/tests/src/com/android/intentresolver/contentpreview/TextContentPreviewUiTest.kt (limited to 'java/src') diff --git a/java/res/layout/chooser_grid_preview_file.xml b/java/res/layout/chooser_grid_preview_file.xml index 3c836b4c..90832d23 100644 --- a/java/res/layout/chooser_grid_preview_file.xml +++ b/java/res/layout/chooser_grid_preview_file.xml @@ -26,7 +26,13 @@ android:orientation="vertical" android:background="?androidprv:attr/materialColorSurfaceContainer"> - + - + - + - + mFiles; @Nullable private ViewGroup mContentPreviewView; + @Nullable + private View mHeadlineView; UnifiedContentPreviewUi( CoroutineScope scope, @@ -83,9 +85,14 @@ class UnifiedContentPreviewUi extends ContentPreviewUi { } @Override - public ViewGroup display(Resources resources, LayoutInflater layoutInflater, ViewGroup parent) { - ViewGroup layout = displayInternal(layoutInflater, parent); - displayModifyShareAction(layout, mActionFactory); + public ViewGroup display( + Resources resources, + LayoutInflater layoutInflater, + ViewGroup parent, + @Nullable View headlineViewParent) { + ViewGroup layout = displayInternal(layoutInflater, parent, headlineViewParent); + displayModifyShareAction( + headlineViewParent == null ? layout : headlineViewParent, mActionFactory); return layout; } @@ -96,13 +103,16 @@ class UnifiedContentPreviewUi extends ContentPreviewUi { .toList()); mFiles = files; if (mContentPreviewView != null) { - updatePreviewWithFiles(mContentPreviewView, files); + updatePreviewWithFiles(mContentPreviewView, mHeadlineView, files); } } - private ViewGroup displayInternal(LayoutInflater layoutInflater, ViewGroup parent) { + private ViewGroup displayInternal( + LayoutInflater layoutInflater, ViewGroup parent, @Nullable View headlineViewParent) { mContentPreviewView = (ViewGroup) layoutInflater.inflate( R.layout.chooser_grid_preview_image, parent, false); + mHeadlineView = headlineViewParent == null ? mContentPreviewView : headlineViewParent; + inflateHeadline(mHeadlineView); final ActionRow actionRow = mContentPreviewView.findViewById(com.android.internal.R.id.chooser_action_row); @@ -122,10 +132,10 @@ class UnifiedContentPreviewUi extends ContentPreviewUi { mItemCount); if (mFiles != null) { - updatePreviewWithFiles(mContentPreviewView, mFiles); + updatePreviewWithFiles(mContentPreviewView, mHeadlineView, mFiles); } else { displayHeadline( - mContentPreviewView, + mHeadlineView, mItemCount, mTypeClassifier.isImageType(mIntentMimeType), mTypeClassifier.isVideoType(mIntentMimeType)); @@ -135,7 +145,8 @@ class UnifiedContentPreviewUi extends ContentPreviewUi { return mContentPreviewView; } - private void updatePreviewWithFiles(ViewGroup contentPreviewView, List files) { + private void updatePreviewWithFiles( + ViewGroup contentPreviewView, View headlineView, List files) { final int count = files.size(); ScrollableImagePreviewView imagePreview = contentPreviewView.requireViewById(R.id.scrollable_image_preview); @@ -158,11 +169,11 @@ class UnifiedContentPreviewUi extends ContentPreviewUi { allVideos = allVideos && previewType == ScrollableImagePreviewView.PreviewType.Video; } - displayHeadline(contentPreviewView, count, allImages, allVideos); + displayHeadline(headlineView, count, allImages, allVideos); } private void displayHeadline( - ViewGroup layout, int count, boolean allImages, boolean allVideos) { + View layout, int count, boolean allImages, boolean allVideos) { if (allImages) { displayHeadline(layout, mHeadlineGenerator.getImagesHeadline(count)); } else if (allVideos) { diff --git a/java/tests/src/com/android/intentresolver/contentpreview/FileContentPreviewUiTest.kt b/java/tests/src/com/android/intentresolver/contentpreview/FileContentPreviewUiTest.kt new file mode 100644 index 00000000..6409da8a --- /dev/null +++ b/java/tests/src/com/android/intentresolver/contentpreview/FileContentPreviewUiTest.kt @@ -0,0 +1,76 @@ +/* + * Copyright (C) 2023 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.intentresolver.contentpreview + +import android.view.LayoutInflater +import android.view.ViewGroup +import android.widget.TextView +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry +import com.android.intentresolver.R +import com.android.intentresolver.mock +import com.android.intentresolver.whenever +import com.android.intentresolver.widget.ActionRow +import com.google.common.truth.Truth +import java.util.function.Consumer +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class FileContentPreviewUiTest { + private val fileCount = 2 + private val text = "Sharing 2 files" + private val actionFactory = + object : ChooserContentPreviewUi.ActionFactory { + override fun getEditButtonRunnable(): Runnable? = null + override fun getCopyButtonRunnable(): Runnable? = null + override fun createCustomActions(): List = emptyList() + override fun getModifyShareAction(): ActionRow.Action? = null + override fun getExcludeSharedTextAction(): Consumer = Consumer {} + } + private val headlineGenerator = + mock { whenever(getFilesHeadline(fileCount)).thenReturn(text) } + + private val context + get() = InstrumentationRegistry.getInstrumentation().context + + @Test + fun test_display_titleIsDisplayed() { + val testSubject = + FileContentPreviewUi( + fileCount, + actionFactory, + headlineGenerator, + ) + + val layoutInflater = LayoutInflater.from(context) + val gridLayout = layoutInflater.inflate(R.layout.chooser_grid, null, false) as ViewGroup + + val previewView = + testSubject.display( + context.resources, + layoutInflater, + gridLayout, + /*headlineViewParent=*/ null + ) + + Truth.assertThat(previewView).isNotNull() + val headlineView = previewView?.findViewById(R.id.headline) + Truth.assertThat(headlineView).isNotNull() + Truth.assertThat(headlineView?.text).isEqualTo(text) + } +} diff --git a/java/tests/src/com/android/intentresolver/contentpreview/FilesPlusTextContentPreviewUiTest.kt b/java/tests/src/com/android/intentresolver/contentpreview/FilesPlusTextContentPreviewUiTest.kt index fe13a215..1144e3c9 100644 --- a/java/tests/src/com/android/intentresolver/contentpreview/FilesPlusTextContentPreviewUiTest.kt +++ b/java/tests/src/com/android/intentresolver/contentpreview/FilesPlusTextContentPreviewUiTest.kt @@ -167,7 +167,7 @@ class FilesPlusTextContentPreviewUiTest { val gridLayout = layoutInflater.inflate(R.layout.chooser_grid, null, false) as ViewGroup val previewView = - testSubject.display(context.resources, LayoutInflater.from(context), gridLayout) + testSubject.display(context.resources, LayoutInflater.from(context), gridLayout, null) verify(headlineGenerator, times(1)).getFilesHeadline(sharedFileCount) verify(headlineGenerator, never()).getImagesHeadline(sharedFileCount) @@ -201,7 +201,12 @@ class FilesPlusTextContentPreviewUiTest { val gridLayout = layoutInflater.inflate(R.layout.chooser_grid, null, false) as ViewGroup loadedFileMetadata?.let(testSubject::updatePreviewMetadata) - return testSubject.display(context.resources, LayoutInflater.from(context), gridLayout) + return testSubject.display( + context.resources, + LayoutInflater.from(context), + gridLayout, + /*headlineViewParent=*/ null + ) } private fun createFileInfosWithMimeTypes(vararg mimeTypes: String): List { diff --git a/java/tests/src/com/android/intentresolver/contentpreview/TextContentPreviewUiTest.kt b/java/tests/src/com/android/intentresolver/contentpreview/TextContentPreviewUiTest.kt new file mode 100644 index 00000000..69053f73 --- /dev/null +++ b/java/tests/src/com/android/intentresolver/contentpreview/TextContentPreviewUiTest.kt @@ -0,0 +1,82 @@ +/* + * Copyright (C) 2023 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.intentresolver.contentpreview + +import android.view.LayoutInflater +import android.view.ViewGroup +import android.widget.TextView +import androidx.lifecycle.testing.TestLifecycleOwner +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry +import com.android.intentresolver.R +import com.android.intentresolver.mock +import com.android.intentresolver.whenever +import com.android.intentresolver.widget.ActionRow +import com.google.common.truth.Truth.assertThat +import java.util.function.Consumer +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class TextContentPreviewUiTest { + private val text = "Shared Text" + private val title = "Preview Title" + private val lifecycleOwner = TestLifecycleOwner() + private val actionFactory = + object : ChooserContentPreviewUi.ActionFactory { + override fun getEditButtonRunnable(): Runnable? = null + override fun getCopyButtonRunnable(): Runnable? = null + override fun createCustomActions(): List = emptyList() + override fun getModifyShareAction(): ActionRow.Action? = null + override fun getExcludeSharedTextAction(): Consumer = Consumer {} + } + private val imageLoader = mock() + private val headlineGenerator = + mock { whenever(getTextHeadline(text)).thenReturn(text) } + + private val context + get() = InstrumentationRegistry.getInstrumentation().context + + @Test + fun test_display_headlineIsDisplayed() { + val testSubject = + TextContentPreviewUi( + lifecycleOwner.lifecycle, + text, + title, + /*previewThumbnail=*/ null, + actionFactory, + imageLoader, + headlineGenerator, + ) + val layoutInflater = LayoutInflater.from(context) + val gridLayout = layoutInflater.inflate(R.layout.chooser_grid, null, false) as ViewGroup + + val previewView = + testSubject.display( + context.resources, + layoutInflater, + gridLayout, + /*headlineViewParent=*/ null + ) + + assertThat(previewView).isNotNull() + val headlineView = previewView?.findViewById(R.id.headline) + assertThat(headlineView).isNotNull() + assertThat(headlineView?.text).isEqualTo(text) + } +} diff --git a/java/tests/src/com/android/intentresolver/contentpreview/UnifiedContentPreviewUiTest.kt b/java/tests/src/com/android/intentresolver/contentpreview/UnifiedContentPreviewUiTest.kt index e7de0b7b..6b22b850 100644 --- a/java/tests/src/com/android/intentresolver/contentpreview/UnifiedContentPreviewUiTest.kt +++ b/java/tests/src/com/android/intentresolver/contentpreview/UnifiedContentPreviewUiTest.kt @@ -159,7 +159,12 @@ class UnifiedContentPreviewUiTest { val layoutInflater = LayoutInflater.from(context) val gridLayout = layoutInflater.inflate(chooser_grid, null, false) as ViewGroup - testSubject.display(context.resources, LayoutInflater.from(context), gridLayout) + testSubject.display( + context.resources, + LayoutInflater.from(context), + gridLayout, + /*headlineViewParent=*/ null + ) emptySourceFlow.tryEmit(endMarker) } } -- cgit v1.2.3-59-g8ed1b From 1c0f4edb1114be0b7b994d6d7a7e3c6f0c5215d0 Mon Sep 17 00:00:00 2001 From: Andrey Epin Date: Tue, 12 Sep 2023 14:20:36 -0700 Subject: Add ResolverListAdapter unit tests Replace AsyncTask usage with an background executor and posting on a main thread hanler to facilitate testing. Tests are mostly around targets resolution logic in the adapter. Test: atest IntentResolverUnitTests:ResolverListAdapterTest Change-Id: I7af047226aa718ca3052aa4284d1e9d2a4c43ded --- .../android/intentresolver/ChooserListAdapter.java | 46 +- .../android/intentresolver/ResolverActivity.java | 14 - .../android/intentresolver/ResolverInfoHelpers.kt | 34 + .../intentresolver/ResolverListAdapter.java | 103 ++- .../intentresolver/ResolverListController.java | 2 - .../intentresolver/ResolverListAdapterTest.kt | 727 +++++++++++++++++++++ .../android/intentresolver/util/TestExecutor.kt | 40 ++ .../intentresolver/util/TestImmediateHandler.kt | 42 ++ 8 files changed, 938 insertions(+), 70 deletions(-) create mode 100644 java/src/com/android/intentresolver/ResolverInfoHelpers.kt create mode 100644 java/tests/src/com/android/intentresolver/ResolverListAdapterTest.kt create mode 100644 java/tests/src/com/android/intentresolver/util/TestExecutor.kt create mode 100644 java/tests/src/com/android/intentresolver/util/TestImmediateHandler.kt (limited to 'java/src') diff --git a/java/src/com/android/intentresolver/ChooserListAdapter.java b/java/src/com/android/intentresolver/ChooserListAdapter.java index d1101f1e..a9ed983d 100644 --- a/java/src/com/android/intentresolver/ChooserListAdapter.java +++ b/java/src/com/android/intentresolver/ChooserListAdapter.java @@ -43,6 +43,9 @@ import android.view.View; import android.view.ViewGroup; import android.widget.TextView; +import androidx.annotation.MainThread; +import androidx.annotation.WorkerThread; + import com.android.intentresolver.chooser.DisplayResolveInfo; import com.android.intentresolver.chooser.MultiDisplayResolveInfo; import com.android.intentresolver.chooser.NotSelectableTargetInfo; @@ -567,7 +570,7 @@ public class ChooserListAdapter extends ResolverListAdapter { protected boolean shouldAddResolveInfo(DisplayResolveInfo dri) { // Checks if this info is already listed in callerTargets. for (TargetInfo existingInfo : mCallerTargets) { - if (mResolverListCommunicator.resolveInfoMatch( + if (ResolveInfoHelpers.resolveInfoMatch( dri.getResolveInfo(), existingInfo.getResolveInfo())) { return false; } @@ -658,30 +661,23 @@ public class ChooserListAdapter extends ResolverListAdapter { * in the head of input list and fill the tail with other elements in undetermined order. */ @Override - AsyncTask, - Void, - List> createSortingTask(boolean doPostProcessing) { - return new AsyncTask, - Void, - List>() { - @Override - protected List doInBackground( - List... params) { - Trace.beginSection("ChooserListAdapter#SortingTask"); - mResolverListController.topK(params[0], mMaxRankedTargets); - Trace.endSection(); - return params[0]; - } - - @Override - protected void onPostExecute(List sortedComponents) { - processSortedList(sortedComponents, doPostProcessing); - if (doPostProcessing) { - mResolverListCommunicator.updateProfileViewButton(); - notifyDataSetChanged(); - } - } - }; + @WorkerThread + protected void sortComponents(List components) { + Trace.beginSection("ChooserListAdapter#SortingTask"); + mResolverListController.topK(components, mMaxRankedTargets); + Trace.endSection(); } + @Override + @MainThread + protected void onComponentsSorted( + @Nullable List sortedComponents, boolean doPostProcessing) { + processSortedList(sortedComponents, doPostProcessing); + if (doPostProcessing) { + mResolverListCommunicator.updateProfileViewButton(); + //TODO: this method is different from super's only in that `notifyDataSetChanged` is + // called conditionally here; is it really important? + notifyDataSetChanged(); + } + } } diff --git a/java/src/com/android/intentresolver/ResolverActivity.java b/java/src/com/android/intentresolver/ResolverActivity.java index 0d3becc2..47a8bf2a 100644 --- a/java/src/com/android/intentresolver/ResolverActivity.java +++ b/java/src/com/android/intentresolver/ResolverActivity.java @@ -2262,20 +2262,6 @@ public class ResolverActivity extends FragmentActivity implements mRetainInOnStop = retainInOnStop; } - /** - * Check a simple match for the component of two ResolveInfos. - */ - @Override // ResolverListCommunicator - public final boolean resolveInfoMatch(ResolveInfo lhs, ResolveInfo rhs) { - return lhs == null ? rhs == null - : lhs.activityInfo == null ? rhs.activityInfo == null - : Objects.equals(lhs.activityInfo.name, rhs.activityInfo.name) - && Objects.equals(lhs.activityInfo.packageName, rhs.activityInfo.packageName) - // Comparing against resolveInfo.userHandle in case cloned apps are present, - // as they will have the same activityInfo. - && Objects.equals(lhs.userHandle, rhs.userHandle); - } - private boolean inactiveListAdapterHasItems() { if (!shouldShowTabs()) { return false; diff --git a/java/src/com/android/intentresolver/ResolverInfoHelpers.kt b/java/src/com/android/intentresolver/ResolverInfoHelpers.kt new file mode 100644 index 00000000..8d1d8658 --- /dev/null +++ b/java/src/com/android/intentresolver/ResolverInfoHelpers.kt @@ -0,0 +1,34 @@ +/* + * Copyright (C) 2023 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +@file:JvmName("ResolveInfoHelpers") + +package com.android.intentresolver + +import android.content.pm.ActivityInfo +import android.content.pm.ResolveInfo + +fun resolveInfoMatch(lhs: ResolveInfo?, rhs: ResolveInfo?): Boolean = + (lhs === rhs) || + ((lhs != null && rhs != null) && + activityInfoMatch(lhs.activityInfo, rhs.activityInfo) && + // Comparing against resolveInfo.userHandle in case cloned apps are present, + // as they will have the same activityInfo. + lhs.userHandle == rhs.userHandle) + +private fun activityInfoMatch(lhs: ActivityInfo?, rhs: ActivityInfo?): Boolean = + (lhs === rhs) || + (lhs != null && rhs != null && lhs.name == rhs.name && lhs.packageName == rhs.packageName) diff --git a/java/src/com/android/intentresolver/ResolverListAdapter.java b/java/src/com/android/intentresolver/ResolverListAdapter.java index 282a672f..14ce0e0e 100644 --- a/java/src/com/android/intentresolver/ResolverListAdapter.java +++ b/java/src/com/android/intentresolver/ResolverListAdapter.java @@ -28,6 +28,7 @@ import android.graphics.ColorMatrix; import android.graphics.ColorMatrixColorFilter; import android.graphics.drawable.Drawable; import android.os.AsyncTask; +import android.os.Handler; import android.os.RemoteException; import android.os.Trace; import android.os.UserHandle; @@ -42,6 +43,9 @@ import android.widget.BaseAdapter; import android.widget.ImageView; import android.widget.TextView; +import androidx.annotation.MainThread; +import androidx.annotation.WorkerThread; + import com.android.intentresolver.chooser.DisplayResolveInfo; import com.android.intentresolver.chooser.TargetInfo; import com.android.intentresolver.icons.TargetDataLoader; @@ -53,6 +57,7 @@ import java.util.ArrayList; import java.util.HashSet; import java.util.List; import java.util.Set; +import java.util.concurrent.Executor; public class ResolverListAdapter extends BaseAdapter { private static final String TAG = "ResolverListAdapter"; @@ -75,6 +80,8 @@ public class ResolverListAdapter extends BaseAdapter { private final Set mRequestedIcons = new HashSet<>(); private final Set mRequestedLabels = new HashSet<>(); + private final Executor mBgExecutor; + private final Handler mMainHandler; private ResolveInfo mLastChosen; private DisplayResolveInfo mOtherProfile; @@ -103,6 +110,37 @@ public class ResolverListAdapter extends BaseAdapter { ResolverListCommunicator resolverListCommunicator, UserHandle initialIntentsUserSpace, TargetDataLoader targetDataLoader) { + this( + context, + payloadIntents, + initialIntents, + rList, + filterLastUsed, + resolverListController, + userHandle, + targetIntent, + resolverListCommunicator, + initialIntentsUserSpace, + targetDataLoader, + AsyncTask.SERIAL_EXECUTOR, + context.getMainThreadHandler()); + } + + @VisibleForTesting + public ResolverListAdapter( + Context context, + List payloadIntents, + Intent[] initialIntents, + List rList, + boolean filterLastUsed, + ResolverListController resolverListController, + UserHandle userHandle, + Intent targetIntent, + ResolverListCommunicator resolverListCommunicator, + UserHandle initialIntentsUserSpace, + TargetDataLoader targetDataLoader, + Executor bgExecutor, + Handler mainHandler) { mContext = context; mIntents = payloadIntents; mInitialIntents = initialIntents; @@ -117,6 +155,8 @@ public class ResolverListAdapter extends BaseAdapter { mTargetIntent = targetIntent; mResolverListCommunicator = resolverListCommunicator; mInitialIntentsUserSpace = initialIntentsUserSpace; + mBgExecutor = bgExecutor; + mMainHandler = mainHandler; } public final DisplayResolveInfo getFirstDisplayResolveInfo() { @@ -402,35 +442,42 @@ public class ResolverListAdapter extends BaseAdapter { // Send an "incomplete" list-ready while the async task is running. postListReadyRunnable(doPostProcessing, /* rebuildCompleted */ false); - createSortingTask(doPostProcessing).execute(filteredResolveList); + mBgExecutor.execute(() -> { + List sortedComponents = null; + //TODO: the try-catch logic here is to formally match the AsyncTask's behavior. + // Empirically, we don't need it as in the case on an exception, the app will crash and + // `onComponentsSorted` won't be invoked. + try { + sortComponents(filteredResolveList); + sortedComponents = filteredResolveList; + } catch (Throwable t) { + Log.e(TAG, "Failed to sort components", t); + throw t; + } finally { + final List result = sortedComponents; + mMainHandler.post(() -> onComponentsSorted(result, doPostProcessing)); + } + }); return false; } - AsyncTask, - Void, - List> createSortingTask(boolean doPostProcessing) { - return new AsyncTask, - Void, - List>() { - @Override - protected List doInBackground( - List... params) { - mResolverListController.sort(params[0]); - return params[0]; - } - @Override - protected void onPostExecute(List sortedComponents) { - processSortedList(sortedComponents, doPostProcessing); - notifyDataSetChanged(); - if (doPostProcessing) { - mResolverListCommunicator.updateProfileViewButton(); - } - } - }; + @WorkerThread + protected void sortComponents(List components) { + mResolverListController.sort(components); } - protected void processSortedList(List sortedComponents, - boolean doPostProcessing) { + @MainThread + protected void onComponentsSorted( + @Nullable List sortedComponents, boolean doPostProcessing) { + processSortedList(sortedComponents, doPostProcessing); + notifyDataSetChanged(); + if (doPostProcessing) { + mResolverListCommunicator.updateProfileViewButton(); + } + } + + protected void processSortedList( + @Nullable List sortedComponents, boolean doPostProcessing) { final int n = sortedComponents != null ? sortedComponents.size() : 0; Trace.beginSection("ResolverListAdapter#processSortedList:" + n); if (n != 0) { @@ -509,7 +556,7 @@ public class ResolverListAdapter extends BaseAdapter { mPostListReadyRunnable = null; } }; - mContext.getMainThreadHandler().post(mPostListReadyRunnable); + mMainHandler.post(mPostListReadyRunnable); } } @@ -572,7 +619,7 @@ public class ResolverListAdapter extends BaseAdapter { protected boolean shouldAddResolveInfo(DisplayResolveInfo dri) { // Checks if this info is already listed in display. for (DisplayResolveInfo existingInfo : mDisplayList) { - if (mResolverListCommunicator + if (ResolveInfoHelpers .resolveInfoMatch(dri.getResolveInfo(), existingInfo.getResolveInfo())) { return false; } @@ -728,7 +775,7 @@ public class ResolverListAdapter extends BaseAdapter { public void onDestroy() { if (mPostListReadyRunnable != null) { - mContext.getMainThreadHandler().removeCallbacks(mPostListReadyRunnable); + mMainHandler.removeCallbacks(mPostListReadyRunnable); mPostListReadyRunnable = null; } if (mResolverListController != null) { @@ -856,8 +903,6 @@ public class ResolverListAdapter extends BaseAdapter { */ interface ResolverListCommunicator { - boolean resolveInfoMatch(ResolveInfo lhs, ResolveInfo rhs); - Intent getReplacementIntent(ActivityInfo activityInfo, Intent defIntent); void onPostListReady(ResolverListAdapter listAdapter, boolean updateUi, diff --git a/java/src/com/android/intentresolver/ResolverListController.java b/java/src/com/android/intentresolver/ResolverListController.java index d5a5fedf..cb56ab30 100644 --- a/java/src/com/android/intentresolver/ResolverListController.java +++ b/java/src/com/android/intentresolver/ResolverListController.java @@ -254,7 +254,6 @@ public class ResolverListController { isComputed = true; } - @VisibleForTesting @WorkerThread public void sort(List inputList) { try { @@ -273,7 +272,6 @@ public class ResolverListController { } } - @VisibleForTesting @WorkerThread public void topK(List inputList, int k) { if (inputList == null || inputList.isEmpty() || k <= 0) { diff --git a/java/tests/src/com/android/intentresolver/ResolverListAdapterTest.kt b/java/tests/src/com/android/intentresolver/ResolverListAdapterTest.kt new file mode 100644 index 00000000..a5fe6c47 --- /dev/null +++ b/java/tests/src/com/android/intentresolver/ResolverListAdapterTest.kt @@ -0,0 +1,727 @@ +/* + * Copyright (C) 2023 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.intentresolver + +import android.content.ComponentName +import android.content.Context +import android.content.Intent +import android.content.pm.ActivityInfo +import android.content.pm.ApplicationInfo +import android.content.pm.ResolveInfo +import android.os.UserHandle +import android.view.LayoutInflater +import com.android.intentresolver.icons.TargetDataLoader +import com.android.intentresolver.util.TestExecutor +import com.android.intentresolver.util.TestImmediateHandler +import com.google.common.truth.Truth.assertThat +import java.util.concurrent.atomic.AtomicInteger +import org.junit.Test +import org.mockito.Mockito.anyBoolean + +private const val PKG_NAME = "org.pkg.app" +private const val PKG_NAME_TWO = "org.pkgtwo.app" +private const val CLASS_NAME = "org.pkg.app.TheClass" + +class ResolverListAdapterTest { + private val testHandler = TestImmediateHandler() + private val layoutInflater = mock() + private val context = + mock { + whenever(getSystemService(Context.LAYOUT_INFLATER_SERVICE)).thenReturn(layoutInflater) + whenever(mainThreadHandler).thenReturn(testHandler) + } + private val targetIntent = Intent(Intent.ACTION_SEND) + private val payloadIntents = listOf(targetIntent) + private val resolverListController = + mock { + whenever(filterIneligibleActivities(any(), anyBoolean())).thenReturn(null) + whenever(filterLowPriority(any(), anyBoolean())).thenReturn(null) + } + private val resolverListCommunicator = FakeResolverListCommunicator() + private val userHandle = UserHandle.of(0) + private val targetDataLoader = mock() + private val executor = TestExecutor() + + @Test + fun test_oneTargetNoLastChosen_oneTargetInAdapter() { + val resolvedTargets = createResolvedComponents(ComponentName(PKG_NAME, CLASS_NAME)) + whenever( + resolverListController.getResolversForIntentAsUser( + true, + resolverListCommunicator.shouldGetActivityMetadata(), + resolverListCommunicator.shouldGetOnlyDefaultActivities(), + payloadIntents, + userHandle + ) + ) + .thenReturn(resolvedTargets) + val testSubject = + ResolverListAdapter( + context, + payloadIntents, + /*initialIntents=*/ null, + /*rList=*/ null, + /*filterLastUsed=*/ true, + resolverListController, + userHandle, + targetIntent, + resolverListCommunicator, + /*initialIntentsUserSpace=*/ userHandle, + targetDataLoader, + executor, + testHandler, + ) + val doPostProcessing = true + + val isLoaded = testSubject.rebuildList(doPostProcessing) + + assertThat(isLoaded).isTrue() + assertThat(testSubject.count).isEqualTo(resolvedTargets.size) + assertThat(testSubject.placeholderCount).isEqualTo(0) + assertThat(testSubject.hasFilteredItem()).isFalse() + assertThat(testSubject.filteredItem).isNull() + assertThat(testSubject.filteredPosition).isLessThan(0) + assertThat(testSubject.unfilteredResolveList).containsExactlyElementsIn(resolvedTargets) + assertThat(testSubject.isTabLoaded).isTrue() + assertThat(executor.pendingCommandCount).isEqualTo(0) + assertThat(resolverListCommunicator.updateProfileViewButtonCount).isEqualTo(0) + assertThat(resolverListCommunicator.sendVoiceCommandCount).isEqualTo(1) + } + + @Test + fun test_oneTargetThatWasLastChosen_NoTargetsInAdapter() { + val resolvedTargets = createResolvedComponents(ComponentName(PKG_NAME, CLASS_NAME)) + whenever( + resolverListController.getResolversForIntentAsUser( + true, + resolverListCommunicator.shouldGetActivityMetadata(), + resolverListCommunicator.shouldGetOnlyDefaultActivities(), + payloadIntents, + userHandle + ) + ) + .thenReturn(resolvedTargets) + whenever(resolverListController.lastChosen) + .thenReturn(resolvedTargets[0].getResolveInfoAt(0)) + val testSubject = + ResolverListAdapter( + context, + payloadIntents, + /*initialIntents=*/ null, + /*rList=*/ null, + /*filterLastUsed=*/ true, + resolverListController, + userHandle, + targetIntent, + resolverListCommunicator, + /*initialIntentsUserSpace=*/ userHandle, + targetDataLoader, + executor, + testHandler, + ) + val doPostProcessing = true + + val isLoaded = testSubject.rebuildList(doPostProcessing) + + assertThat(isLoaded).isTrue() + assertThat(testSubject.count).isEqualTo(0) + assertThat(testSubject.placeholderCount).isEqualTo(0) + assertThat(testSubject.hasFilteredItem()).isTrue() + assertThat(testSubject.filteredItem).isNotNull() + assertThat(testSubject.filteredPosition).isEqualTo(0) + assertThat(testSubject.unfilteredResolveList).containsExactlyElementsIn(resolvedTargets) + assertThat(testSubject.isTabLoaded).isTrue() + assertThat(executor.pendingCommandCount).isEqualTo(0) + } + + @Test + fun test_oneTargetLastChosenNotInTheList_oneTargetInAdapter() { + val resolvedTargets = createResolvedComponents(ComponentName(PKG_NAME, CLASS_NAME)) + whenever( + resolverListController.getResolversForIntentAsUser( + true, + resolverListCommunicator.shouldGetActivityMetadata(), + resolverListCommunicator.shouldGetOnlyDefaultActivities(), + payloadIntents, + userHandle + ) + ) + .thenReturn(resolvedTargets) + whenever(resolverListController.lastChosen) + .thenReturn(createResolveInfo(PKG_NAME_TWO, CLASS_NAME)) + val testSubject = + ResolverListAdapter( + context, + payloadIntents, + /*initialIntents=*/ null, + /*rList=*/ null, + /*filterLastUsed=*/ true, + resolverListController, + userHandle, + targetIntent, + resolverListCommunicator, + /*initialIntentsUserSpace=*/ userHandle, + targetDataLoader, + executor, + testHandler, + ) + val doPostProcessing = true + + val isLoaded = testSubject.rebuildList(doPostProcessing) + + assertThat(isLoaded).isTrue() + assertThat(testSubject.count).isEqualTo(resolvedTargets.size) + assertThat(testSubject.placeholderCount).isEqualTo(0) + assertThat(testSubject.hasFilteredItem()).isTrue() + assertThat(testSubject.filteredItem).isNull() + assertThat(testSubject.filteredPosition).isLessThan(0) + assertThat(testSubject.unfilteredResolveList).containsExactlyElementsIn(resolvedTargets) + assertThat(testSubject.isTabLoaded).isTrue() + assertThat(executor.pendingCommandCount).isEqualTo(0) + } + + @Test + fun test_oneTargetThatWasLastChosenFilteringDisabled_oneTargetInAdapter() { + val resolvedTargets = createResolvedComponents(ComponentName(PKG_NAME, CLASS_NAME)) + whenever( + resolverListController.getResolversForIntentAsUser( + true, + resolverListCommunicator.shouldGetActivityMetadata(), + resolverListCommunicator.shouldGetOnlyDefaultActivities(), + payloadIntents, + userHandle + ) + ) + .thenReturn(resolvedTargets) + whenever(resolverListController.lastChosen) + .thenReturn(resolvedTargets[0].getResolveInfoAt(0)) + val testSubject = + ResolverListAdapter( + context, + payloadIntents, + /*initialIntents=*/ null, + /*rList=*/ null, + /*filterLastUsed=*/ false, + resolverListController, + userHandle, + targetIntent, + resolverListCommunicator, + /*initialIntentsUserSpace=*/ userHandle, + targetDataLoader, + executor, + testHandler, + ) + val doPostProcessing = true + + val isLoaded = testSubject.rebuildList(doPostProcessing) + + assertThat(isLoaded).isTrue() + assertThat(testSubject.count).isEqualTo(resolvedTargets.size) + // we don't reset placeholder count + assertThat(testSubject.placeholderCount).isEqualTo(0) + assertThat(testSubject.hasFilteredItem()).isFalse() + assertThat(testSubject.filteredItem).isNull() + assertThat(testSubject.filteredPosition).isLessThan(0) + assertThat(testSubject.unfilteredResolveList).containsExactlyElementsIn(resolvedTargets) + assertThat(testSubject.isTabLoaded).isTrue() + } + + @Test + fun test_twoTargetsNoLastChosenUseLayoutWithDefaults_twoTargetsInAdapter() { + testTwoTargets(hasLastChosen = false, useLayoutWithDefaults = true) + } + + @Test + fun test_twoTargetsNoLastChosenDontUseLayoutWithDefaults_twoTargetsInAdapter() { + testTwoTargets(hasLastChosen = false, useLayoutWithDefaults = false) + } + + @Test + fun test_twoTargetsLastChosenUseLayoutWithDefaults_oneTargetInAdapter() { + testTwoTargets(hasLastChosen = true, useLayoutWithDefaults = true) + } + + @Test + fun test_twoTargetsLastChosenDontUseLayoutWithDefaults_oneTargetInAdapter() { + testTwoTargets(hasLastChosen = true, useLayoutWithDefaults = false) + } + + private fun testTwoTargets(hasLastChosen: Boolean, useLayoutWithDefaults: Boolean) { + val resolvedTargets = + createResolvedComponents( + ComponentName(PKG_NAME, CLASS_NAME), + ComponentName(PKG_NAME_TWO, CLASS_NAME), + ) + if (hasLastChosen) { + whenever(resolverListController.lastChosen) + .thenReturn(resolvedTargets[0].getResolveInfoAt(0)) + } + whenever( + resolverListController.getResolversForIntentAsUser( + true, + resolverListCommunicator.shouldGetActivityMetadata(), + resolverListCommunicator.shouldGetOnlyDefaultActivities(), + payloadIntents, + userHandle + ) + ) + .thenReturn(resolvedTargets) + val resolverListCommunicator = FakeResolverListCommunicator(useLayoutWithDefaults) + val testSubject = + ResolverListAdapter( + context, + payloadIntents, + /*initialIntents=*/ null, + /*rList=*/ null, + /*filterLastUsed=*/ true, + resolverListController, + userHandle, + targetIntent, + resolverListCommunicator, + /*initialIntentsUserSpace=*/ userHandle, + targetDataLoader, + executor, + testHandler, + ) + val doPostProcessing = true + + val isLoaded = testSubject.rebuildList(doPostProcessing) + + assertThat(isLoaded).isFalse() + val placeholderCount = resolvedTargets.size - (if (useLayoutWithDefaults) 1 else 0) + assertThat(testSubject.count).isEqualTo(placeholderCount) + assertThat(testSubject.placeholderCount).isEqualTo(placeholderCount) + assertThat(testSubject.hasFilteredItem()).isEqualTo(hasLastChosen) + assertThat(testSubject.filteredItem).isNull() + assertThat(testSubject.filteredPosition).isLessThan(0) + assertThat(testSubject.unfilteredResolveList).containsExactlyElementsIn(resolvedTargets) + assertThat(testSubject.isTabLoaded).isFalse() + assertThat(executor.pendingCommandCount).isEqualTo(1) + assertThat(resolverListCommunicator.updateProfileViewButtonCount).isEqualTo(0) + assertThat(resolverListCommunicator.sendVoiceCommandCount).isEqualTo(0) + + executor.runUntilIdle() + + // we don't reset placeholder count (legacy logic, likely an oversight?) + assertThat(testSubject.placeholderCount).isEqualTo(placeholderCount) + assertThat(testSubject.hasFilteredItem()).isEqualTo(hasLastChosen) + if (hasLastChosen) { + assertThat(testSubject.count).isEqualTo(resolvedTargets.size - 1) + assertThat(testSubject.filteredItem).isNotNull() + assertThat(testSubject.filteredPosition).isEqualTo(0) + } else { + assertThat(testSubject.count).isEqualTo(resolvedTargets.size) + assertThat(testSubject.filteredItem).isNull() + assertThat(testSubject.filteredPosition).isLessThan(0) + } + assertThat(testSubject.unfilteredResolveList).containsExactlyElementsIn(resolvedTargets) + assertThat(testSubject.isTabLoaded).isTrue() + assertThat(resolverListCommunicator.updateProfileViewButtonCount).isEqualTo(1) + assertThat(resolverListCommunicator.sendVoiceCommandCount).isEqualTo(1) + assertThat(executor.pendingCommandCount).isEqualTo(0) + } + + @Test + fun test_twoTargetsLastChosenNotInTheList_twoTargetsInAdapter() { + val resolvedTargets = + createResolvedComponents( + ComponentName(PKG_NAME, CLASS_NAME), + ComponentName(PKG_NAME_TWO, CLASS_NAME), + ) + whenever(resolverListController.lastChosen) + .thenReturn(createResolveInfo(PKG_NAME, CLASS_NAME + "2")) + whenever( + resolverListController.getResolversForIntentAsUser( + true, + resolverListCommunicator.shouldGetActivityMetadata(), + resolverListCommunicator.shouldGetOnlyDefaultActivities(), + payloadIntents, + userHandle + ) + ) + .thenReturn(resolvedTargets) + val testSubject = + ResolverListAdapter( + context, + payloadIntents, + /*initialIntents=*/ null, + /*rList=*/ null, + /*filterLastUsed=*/ true, + resolverListController, + userHandle, + targetIntent, + resolverListCommunicator, + /*initialIntentsUserSpace=*/ userHandle, + targetDataLoader, + executor, + testHandler, + ) + val doPostProcessing = false + + val isLoaded = testSubject.rebuildList(doPostProcessing) + + assertThat(isLoaded).isFalse() + val placeholderCount = resolvedTargets.size - 1 + assertThat(testSubject.count).isEqualTo(placeholderCount) + assertThat(testSubject.placeholderCount).isEqualTo(placeholderCount) + assertThat(testSubject.hasFilteredItem()).isTrue() + assertThat(testSubject.filteredItem).isNull() + assertThat(testSubject.filteredPosition).isLessThan(0) + assertThat(testSubject.unfilteredResolveList).containsExactlyElementsIn(resolvedTargets) + assertThat(testSubject.isTabLoaded).isFalse() + assertThat(executor.pendingCommandCount).isEqualTo(1) + assertThat(resolverListCommunicator.updateProfileViewButtonCount).isEqualTo(0) + + executor.runUntilIdle() + + // we don't reset placeholder count (legacy logic, likely an oversight?) + assertThat(testSubject.placeholderCount).isEqualTo(placeholderCount) + assertThat(testSubject.hasFilteredItem()).isTrue() + assertThat(testSubject.count).isEqualTo(resolvedTargets.size) + assertThat(testSubject.filteredItem).isNull() + assertThat(testSubject.filteredPosition).isLessThan(0) + assertThat(testSubject.unfilteredResolveList).containsExactlyElementsIn(resolvedTargets) + assertThat(testSubject.isTabLoaded).isTrue() + assertThat(resolverListCommunicator.updateProfileViewButtonCount).isEqualTo(0) + assertThat(executor.pendingCommandCount).isEqualTo(0) + } + + @Test + fun test_twoTargetsWithOtherProfileAndLastChosen_oneTargetInAdapter() { + val resolvedTargets = + createResolvedComponents( + ComponentName(PKG_NAME, CLASS_NAME), + ComponentName(PKG_NAME_TWO, CLASS_NAME), + ) + resolvedTargets[1].getResolveInfoAt(0).targetUserId = 10 + whenever(resolvedTargets[1].getResolveInfoAt(0).loadLabel(any())).thenReturn("Label") + whenever(resolverListController.lastChosen) + .thenReturn(resolvedTargets[0].getResolveInfoAt(0)) + whenever( + resolverListController.getResolversForIntentAsUser( + true, + resolverListCommunicator.shouldGetActivityMetadata(), + resolverListCommunicator.shouldGetOnlyDefaultActivities(), + payloadIntents, + userHandle + ) + ) + .thenReturn(resolvedTargets) + val testSubject = + ResolverListAdapter( + context, + payloadIntents, + /*initialIntents=*/ null, + /*rList=*/ null, + /*filterLastUsed=*/ true, + resolverListController, + userHandle, + targetIntent, + resolverListCommunicator, + /*initialIntentsUserSpace=*/ userHandle, + targetDataLoader, + executor, + testHandler, + ) + val doPostProcessing = true + + val isLoaded = testSubject.rebuildList(doPostProcessing) + + assertThat(isLoaded).isTrue() + assertThat(testSubject.count).isEqualTo(1) + assertThat(testSubject.placeholderCount).isEqualTo(0) + assertThat(testSubject.otherProfile).isNotNull() + assertThat(testSubject.hasFilteredItem()).isFalse() + assertThat(testSubject.filteredItem).isNull() + assertThat(testSubject.filteredPosition).isLessThan(0) + assertThat(testSubject.unfilteredResolveList).containsExactlyElementsIn(resolvedTargets) + assertThat(testSubject.isTabLoaded).isTrue() + assertThat(executor.pendingCommandCount).isEqualTo(0) + } + + @Suppress("UNCHECKED_CAST") + @Test + fun test_resultsSorted_appearInSortedOrderInAdapter() { + val resolvedTargets = + createResolvedComponents( + ComponentName(PKG_NAME, CLASS_NAME), + ComponentName(PKG_NAME_TWO, CLASS_NAME), + ) + whenever( + resolverListController.getResolversForIntentAsUser( + true, + resolverListCommunicator.shouldGetActivityMetadata(), + resolverListCommunicator.shouldGetOnlyDefaultActivities(), + payloadIntents, + userHandle + ) + ) + .thenReturn(resolvedTargets) + whenever(resolverListController.sort(any())).thenAnswer { invocation -> + val components = invocation.arguments[0] as MutableList + components[0] = components[1].also { components[1] = components[0] } + null + } + val testSubject = + ResolverListAdapter( + context, + payloadIntents, + /*initialIntents=*/ null, + /*rList=*/ null, + /*filterLastUsed=*/ true, + resolverListController, + userHandle, + targetIntent, + resolverListCommunicator, + /*initialIntentsUserSpace=*/ userHandle, + targetDataLoader, + executor, + testHandler, + ) + val doPostProcessing = true + + testSubject.rebuildList(doPostProcessing) + + executor.runUntilIdle() + + // we don't reset placeholder count (legacy logic, likely an oversight?) + assertThat(testSubject.count).isEqualTo(resolvedTargets.size) + assertThat(resolvedTargets[0].getResolveInfoAt(0).activityInfo.packageName) + .isEqualTo(PKG_NAME_TWO) + assertThat(resolvedTargets[1].getResolveInfoAt(0).activityInfo.packageName) + .isEqualTo(PKG_NAME) + } + + @Suppress("UNCHECKED_CAST") + @Test + fun test_ineligibleActivityFilteredOut_filteredComponentNotPresentInAdapter() { + val resolvedTargets = + createResolvedComponents( + ComponentName(PKG_NAME, CLASS_NAME), + ComponentName(PKG_NAME_TWO, CLASS_NAME), + ) + whenever( + resolverListController.getResolversForIntentAsUser( + true, + resolverListCommunicator.shouldGetActivityMetadata(), + resolverListCommunicator.shouldGetOnlyDefaultActivities(), + payloadIntents, + userHandle + ) + ) + .thenReturn(resolvedTargets) + whenever(resolverListController.filterIneligibleActivities(any(), anyBoolean())) + .thenAnswer { invocation -> + val components = invocation.arguments[0] as MutableList + val original = ArrayList(components) + components.removeAt(1) + original + } + val testSubject = + ResolverListAdapter( + context, + payloadIntents, + /*initialIntents=*/ null, + /*rList=*/ null, + /*filterLastUsed=*/ true, + resolverListController, + userHandle, + targetIntent, + resolverListCommunicator, + /*initialIntentsUserSpace=*/ userHandle, + targetDataLoader, + executor, + testHandler, + ) + val doPostProcessing = true + + testSubject.rebuildList(doPostProcessing) + + executor.runUntilIdle() + + // we don't reset placeholder count (legacy logic, likely an oversight?) + assertThat(testSubject.count).isEqualTo(1) + assertThat(testSubject.getItem(0)?.resolveInfo) + .isEqualTo(resolvedTargets[0].getResolveInfoAt(0)) + assertThat(testSubject.unfilteredResolveList).hasSize(2) + } + + @Suppress("UNCHECKED_CAST") + @Test + fun test_baseResolveList_excludedFromIneligibleActivityFiltering() { + val rList = listOf(createResolveInfo(PKG_NAME, CLASS_NAME)) + whenever(resolverListController.addResolveListDedupe(any(), eq(targetIntent), eq(rList))) + .thenAnswer { invocation -> + val result = invocation.arguments[0] as MutableList + result.addAll( + createResolvedComponents( + ComponentName(PKG_NAME, CLASS_NAME), + ComponentName(PKG_NAME_TWO, CLASS_NAME), + ) + ) + null + } + whenever(resolverListController.filterIneligibleActivities(any(), anyBoolean())) + .thenAnswer { invocation -> + val components = invocation.arguments[0] as MutableList + val original = ArrayList(components) + components.clear() + original + } + val testSubject = + ResolverListAdapter( + context, + payloadIntents, + /*initialIntents=*/ null, + rList, + /*filterLastUsed=*/ true, + resolverListController, + userHandle, + targetIntent, + resolverListCommunicator, + /*initialIntentsUserSpace=*/ userHandle, + targetDataLoader, + executor, + testHandler, + ) + val doPostProcessing = true + + testSubject.rebuildList(doPostProcessing) + + executor.runUntilIdle() + + // we don't reset placeholder count (legacy logic, likely an oversight?) + assertThat(testSubject.count).isEqualTo(2) + assertThat(testSubject.unfilteredResolveList).hasSize(2) + } + + @Suppress("UNCHECKED_CAST") + @Test + fun test_lowPriorityComponentFilteredOut_filteredComponentNotPresentInAdapter() { + val resolvedTargets = + createResolvedComponents( + ComponentName(PKG_NAME, CLASS_NAME), + ComponentName(PKG_NAME_TWO, CLASS_NAME), + ) + whenever( + resolverListController.getResolversForIntentAsUser( + true, + resolverListCommunicator.shouldGetActivityMetadata(), + resolverListCommunicator.shouldGetOnlyDefaultActivities(), + payloadIntents, + userHandle + ) + ) + .thenReturn(resolvedTargets) + whenever(resolverListController.filterLowPriority(any(), anyBoolean())).thenAnswer { + invocation -> + val components = invocation.arguments[0] as MutableList + val original = ArrayList(components) + components.removeAt(1) + original + } + val testSubject = + ResolverListAdapter( + context, + payloadIntents, + /*initialIntents=*/ null, + /*rList=*/ null, + /*filterLastUsed=*/ true, + resolverListController, + userHandle, + targetIntent, + resolverListCommunicator, + /*initialIntentsUserSpace=*/ userHandle, + targetDataLoader, + executor, + testHandler, + ) + val doPostProcessing = true + + testSubject.rebuildList(doPostProcessing) + + executor.runUntilIdle() + + // we don't reset placeholder count (legacy logic, likely an oversight?) + assertThat(testSubject.count).isEqualTo(1) + assertThat(testSubject.getItem(0)?.resolveInfo) + .isEqualTo(resolvedTargets[0].getResolveInfoAt(0)) + assertThat(testSubject.unfilteredResolveList).hasSize(2) + } + + private fun createResolvedComponents( + vararg components: ComponentName + ): List { + val result = ArrayList(components.size) + for (component in components) { + val resolvedComponentInfo = + ResolvedComponentInfo( + ComponentName(PKG_NAME, CLASS_NAME), + targetIntent, + createResolveInfo(component.packageName, component.className) + ) + result.add(resolvedComponentInfo) + } + return result + } + + private fun createResolveInfo(packageName: String, className: String): ResolveInfo = + mock { + activityInfo = + ActivityInfo().apply { + name = className + this.packageName = packageName + applicationInfo = ApplicationInfo().apply { this.packageName = packageName } + } + targetUserId = UserHandle.USER_CURRENT + } +} + +private class FakeResolverListCommunicator(private val layoutWithDefaults: Boolean = true) : + ResolverListAdapter.ResolverListCommunicator { + private val sendVoiceCounter = AtomicInteger() + private val updateProfileViewButtonCounter = AtomicInteger() + + val sendVoiceCommandCount + get() = sendVoiceCounter.get() + val updateProfileViewButtonCount + get() = updateProfileViewButtonCounter.get() + + override fun getReplacementIntent(activityInfo: ActivityInfo?, defIntent: Intent): Intent { + return defIntent + } + + override fun onPostListReady( + listAdapter: ResolverListAdapter?, + updateUi: Boolean, + rebuildCompleted: Boolean, + ) = Unit + + override fun sendVoiceChoicesIfNeeded() { + sendVoiceCounter.incrementAndGet() + } + + override fun updateProfileViewButton() { + updateProfileViewButtonCounter.incrementAndGet() + } + + override fun useLayoutWithDefault(): Boolean = layoutWithDefaults + + override fun shouldGetActivityMetadata(): Boolean = true + + override fun onHandlePackagesChanged(listAdapter: ResolverListAdapter?) {} +} diff --git a/java/tests/src/com/android/intentresolver/util/TestExecutor.kt b/java/tests/src/com/android/intentresolver/util/TestExecutor.kt new file mode 100644 index 00000000..214b9707 --- /dev/null +++ b/java/tests/src/com/android/intentresolver/util/TestExecutor.kt @@ -0,0 +1,40 @@ +/* + * Copyright (C) 2023 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.intentresolver.util + +import java.util.concurrent.Executor + +class TestExecutor(private val immediate: Boolean = false) : Executor { + private var pendingCommands = ArrayDeque() + + val pendingCommandCount: Int + get() = pendingCommands.size + + override fun execute(command: Runnable) { + if (immediate) { + command.run() + } else { + pendingCommands.add(command) + } + } + + fun runUntilIdle() { + while (pendingCommands.isNotEmpty()) { + pendingCommands.removeFirst().run() + } + } +} diff --git a/java/tests/src/com/android/intentresolver/util/TestImmediateHandler.kt b/java/tests/src/com/android/intentresolver/util/TestImmediateHandler.kt new file mode 100644 index 00000000..9e6fc989 --- /dev/null +++ b/java/tests/src/com/android/intentresolver/util/TestImmediateHandler.kt @@ -0,0 +1,42 @@ +/* + * Copyright (C) 2023 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.intentresolver.util + +import android.os.Handler +import android.os.Looper +import android.os.Message + +/** + * A test Handler that executes posted [Runnable] immediately regardless of the target time (delay). + * Does not support messages. + */ +class TestImmediateHandler : Handler(createTestLooper()) { + override fun sendMessageAtTime(msg: Message, uptimeMillis: Long): Boolean { + msg.callback.run() + return true + } + + companion object { + private val looperConstructor by lazy { + Looper::class.java.getDeclaredConstructor(java.lang.Boolean.TYPE).apply { + isAccessible = true + } + } + + private fun createTestLooper(): Looper = looperConstructor.newInstance(true) + } +} -- cgit v1.2.3-59-g8ed1b From 08b3b6f27e054307bfa32c7b52c24ba8c58a2156 Mon Sep 17 00:00:00 2001 From: Andrey Epin Date: Wed, 28 Jun 2023 13:17:43 -0700 Subject: Load app target labels explicitly DisplayResolveInfo#getDisplayLabel contains a logic to load the label if it is missing and the majority of app targets in Chooser are loaded this way. This CL: * removes label loading logic from DisplayResolveInfo; * adds explicit label loading with TargetDataLoader, where it's needed; * wrap some of the view related code blocks from ChooserListAdapter#onBindView into ViewHoder methods to reduce branching in the method and make it easier to read. The legacy DisplayResolveInfo lable-loading logic is effectively replaced with LoadLabelTaks's logic, the one that is used by resolver. Bug: 289264582 Test: manual testing: labes loading, targets groupping, targets pinning. Change-Id: I86814b5a4c67bf117fb1ea28c1d9980b5cf28ef5 --- .../intentresolver/ChooserActionFactory.java | 3 +- .../android/intentresolver/ChooserListAdapter.java | 94 +++++++++++----------- .../android/intentresolver/ResolverActivity.java | 26 ++++-- .../intentresolver/ResolverListAdapter.java | 56 +++++++++---- .../intentresolver/chooser/DisplayResolveInfo.java | 38 +++------ .../icons/DefaultTargetDataLoader.kt | 11 ++- .../intentresolver/icons/LoadLabelTask.java | 21 +++-- .../intentresolver/icons/TargetDataLoader.kt | 8 +- .../intentresolver/ChooserListAdapterTest.kt | 4 +- .../intentresolver/ChooserWrapperActivity.java | 12 +-- .../android/intentresolver/IChooserWrapper.java | 10 ++- .../intentresolver/ResolverWrapperActivity.java | 5 +- .../intentresolver/ShortcutSelectionLogicTest.kt | 9 +-- .../UnbundledChooserActivityTest.java | 10 +-- .../chooser/ImmutableTargetInfoTest.kt | 13 ++- .../intentresolver/chooser/TargetInfoTest.kt | 42 +++++----- 16 files changed, 195 insertions(+), 167 deletions(-) (limited to 'java/src') diff --git a/java/src/com/android/intentresolver/ChooserActionFactory.java b/java/src/com/android/intentresolver/ChooserActionFactory.java index 2c97c0b1..6d56146d 100644 --- a/java/src/com/android/intentresolver/ChooserActionFactory.java +++ b/java/src/com/android/intentresolver/ChooserActionFactory.java @@ -317,8 +317,7 @@ public final class ChooserActionFactory implements ChooserContentPreviewUi.Actio ri, context.getString(R.string.screenshot_edit), "", - resolveIntent, - null); + resolveIntent); dri.getDisplayIconHolder().setDisplayIcon( context.getDrawable(com.android.internal.R.drawable.ic_screenshot_edit)); return dri; diff --git a/java/src/com/android/intentresolver/ChooserListAdapter.java b/java/src/com/android/intentresolver/ChooserListAdapter.java index a9ed983d..2e1476d8 100644 --- a/java/src/com/android/intentresolver/ChooserListAdapter.java +++ b/java/src/com/android/intentresolver/ChooserListAdapter.java @@ -38,6 +38,7 @@ import android.os.UserManager; import android.provider.DeviceConfig; import android.service.chooser.ChooserTarget; import android.text.Layout; +import android.text.TextUtils; import android.util.Log; import android.view.View; import android.view.ViewGroup; @@ -60,6 +61,7 @@ import java.util.ArrayList; import java.util.HashSet; import java.util.List; import java.util.Map; +import java.util.Objects; import java.util.Set; import java.util.stream.Collectors; @@ -230,9 +232,8 @@ public class ChooserListAdapter extends ResolverListAdapter { ri.icon = 0; } ri.userHandle = initialIntentsUserSpace; - // TODO: remove DisplayResolveInfo dependency on presentation getter - DisplayResolveInfo displayResolveInfo = DisplayResolveInfo.newDisplayResolveInfo( - ii, ri, ii, mTargetDataLoader.createPresentationGetter(ri)); + DisplayResolveInfo displayResolveInfo = + DisplayResolveInfo.newDisplayResolveInfo(ii, ri, ii); mCallerTargets.add(displayResolveInfo); if (mCallerTargets.size() == MAX_SUGGESTED_APP_TARGETS) break; } @@ -275,35 +276,42 @@ public class ChooserListAdapter extends ResolverListAdapter { public void onBindView(View view, TargetInfo info, int position) { final ViewHolder holder = (ViewHolder) view.getTag(); + holder.reset(); + // Always remove the spacing listener, attach as needed to direct share targets below. + holder.text.removeOnLayoutChangeListener(mPinTextSpacingListener); + if (info == null) { holder.icon.setImageDrawable(loadIconPlaceholder()); return; } - holder.bindLabel(info.getDisplayLabel(), info.getExtendedInfo()); - mAnimationTracker.animateLabel(holder.text, info); - if (holder.text2.getVisibility() == View.VISIBLE) { + final CharSequence displayLabel = Objects.requireNonNullElse(info.getDisplayLabel(), ""); + final CharSequence extendedInfo = Objects.requireNonNullElse(info.getExtendedInfo(), ""); + holder.bindLabel(displayLabel, extendedInfo); + if (!TextUtils.isEmpty(displayLabel)) { + mAnimationTracker.animateLabel(holder.text, info); + } + if (!TextUtils.isEmpty(extendedInfo) && holder.text2.getVisibility() == View.VISIBLE) { mAnimationTracker.animateLabel(holder.text2, info); } + holder.bindIcon(info); - if (info.getDisplayIconHolder().getDisplayIcon() != null) { + if (info.hasDisplayIcon()) { mAnimationTracker.animateIcon(holder.icon, info); - } else { - holder.icon.clearAnimation(); } if (info.isSelectableTargetInfo()) { // direct share targets should append the application name for a better readout DisplayResolveInfo rInfo = info.getDisplayResolveInfo(); - CharSequence appName = rInfo != null ? rInfo.getDisplayLabel() : ""; - CharSequence extendedInfo = info.getExtendedInfo(); - String contentDescription = String.join(" ", info.getDisplayLabel(), - extendedInfo != null ? extendedInfo : "", appName); + CharSequence appName = + Objects.requireNonNullElse(rInfo == null ? null : rInfo.getDisplayLabel(), ""); + String contentDescription = + String.join(" ", info.getDisplayLabel(), extendedInfo, appName); if (info.isPinned()) { contentDescription = String.join( - ". ", - contentDescription, - mContext.getResources().getString(R.string.pinned)); + ". ", + contentDescription, + mContext.getResources().getString(R.string.pinned)); } holder.updateContentDescription(contentDescription); if (!info.hasDisplayIcon()) { @@ -320,42 +328,30 @@ public class ChooserListAdapter extends ResolverListAdapter { if (!dri.hasDisplayIcon()) { loadIcon(dri); } + if (!dri.hasDisplayLabel()) { + loadLabel(dri); + } } // If target is loading, show a special placeholder shape in the label, make unclickable if (info.isPlaceHolderTargetInfo()) { - final int maxWidth = mContext.getResources().getDimensionPixelSize( + int maxTextWidth = mContext.getResources().getDimensionPixelSize( R.dimen.chooser_direct_share_label_placeholder_max_width); - holder.text.setMaxWidth(maxWidth); - holder.text.setBackground(mContext.getResources().getDrawable( - R.drawable.chooser_direct_share_label_placeholder, mContext.getTheme())); - // Prevent rippling by removing background containing ripple - holder.itemView.setBackground(null); - } else { - holder.text.setMaxWidth(Integer.MAX_VALUE); - holder.text.setBackground(null); - holder.itemView.setBackground(holder.defaultItemViewBackground); + Drawable placeholderDrawable = mContext.getResources().getDrawable( + R.drawable.chooser_direct_share_label_placeholder, mContext.getTheme()); + holder.bindPlaceholderDrawable(maxTextWidth, placeholderDrawable); } - // Always remove the spacing listener, attach as needed to direct share targets below. - holder.text.removeOnLayoutChangeListener(mPinTextSpacingListener); - if (info.isMultiDisplayResolveInfo()) { // If the target is grouped show an indicator - Drawable bkg = mContext.getDrawable(R.drawable.chooser_group_background); - holder.text.setPaddingRelative(0, 0, bkg.getIntrinsicWidth() /* end */, 0); - holder.text.setBackground(bkg); + holder.bindGroupIndicator( + mContext.getDrawable(R.drawable.chooser_group_background)); } else if (info.isPinned() && (getPositionTargetType(position) == TARGET_STANDARD || getPositionTargetType(position) == TARGET_SERVICE)) { // If the appShare or directShare target is pinned and in the suggested row show a // pinned indicator - Drawable bkg = mContext.getDrawable(R.drawable.chooser_pinned_background); - holder.text.setPaddingRelative(bkg.getIntrinsicWidth() /* start */, 0, 0, 0); - holder.text.setBackground(bkg); + holder.bindPinnedIndicator(mContext.getDrawable(R.drawable.chooser_pinned_background)); holder.text.addOnLayoutChangeListener(mPinTextSpacingListener); - } else { - holder.text.setBackground(null); - holder.text.setPaddingRelative(0, 0, 0, 0); } } @@ -376,8 +372,12 @@ public class ChooserListAdapter extends ResolverListAdapter { } void updateAlphabeticalList() { - // TODO: this procedure seems like it should be relatively lightweight. Why does it need to - // run in an `AsyncTask`? + final ChooserActivity.AzInfoComparator comparator = + new ChooserActivity.AzInfoComparator(mContext); + final List allTargets = new ArrayList<>(); + allTargets.addAll(getTargetsInCurrentDisplayList()); + allTargets.addAll(mCallerTargets); + new AsyncTask>() { @Override protected List doInBackground(Void... voids) { @@ -390,9 +390,7 @@ public class ChooserListAdapter extends ResolverListAdapter { } private List updateList() { - List allTargets = new ArrayList<>(); - allTargets.addAll(getTargetsInCurrentDisplayList()); - allTargets.addAll(mCallerTargets); + loadMissingLabels(allTargets); // Consolidate multiple targets from same app. return allTargets @@ -408,8 +406,8 @@ public class ChooserListAdapter extends ResolverListAdapter { (appTargets.size() == 1) ? appTargets.get(0) : MultiDisplayResolveInfo.newMultiDisplayResolveInfo( - appTargets)) - .sorted(new ChooserActivity.AzInfoComparator(mContext)) + appTargets)) + .sorted(comparator) .collect(Collectors.toList()); } @@ -418,6 +416,12 @@ public class ChooserListAdapter extends ResolverListAdapter { mSortedList = newList; notifyDataSetChanged(); } + + private void loadMissingLabels(List targets) { + for (DisplayResolveInfo target: targets) { + mTargetDataLoader.getOrLoadLabel(target); + } + } }.execute(); } diff --git a/java/src/com/android/intentresolver/ResolverActivity.java b/java/src/com/android/intentresolver/ResolverActivity.java index 47a8bf2a..1161ca81 100644 --- a/java/src/com/android/intentresolver/ResolverActivity.java +++ b/java/src/com/android/intentresolver/ResolverActivity.java @@ -33,7 +33,6 @@ import static android.content.PermissionChecker.PID_UNKNOWN; import static android.stats.devicepolicy.nano.DevicePolicyEnums.RESOLVER_EMPTY_STATE_NO_SHARING_TO_PERSONAL; import static android.stats.devicepolicy.nano.DevicePolicyEnums.RESOLVER_EMPTY_STATE_NO_SHARING_TO_WORK; import static android.view.WindowManager.LayoutParams.SYSTEM_FLAG_HIDE_NON_SYSTEM_OVERLAY_WINDOWS; - import static com.android.internal.annotations.VisibleForTesting.Visibility.PROTECTED; import android.annotation.Nullable; @@ -199,6 +198,8 @@ public class ResolverActivity extends FragmentActivity implements private PackageMonitor mPersonalPackageMonitor; private PackageMonitor mWorkPackageMonitor; + private TargetDataLoader mTargetDataLoader; + @VisibleForTesting protected AbstractMultiProfilePagerAdapter mMultiProfilePagerAdapter; @@ -427,6 +428,7 @@ public class ResolverActivity extends FragmentActivity implements mSupportsAlwaysUseOption = supportsAlwaysUseOption; mSafeForwardingMode = safeForwardingMode; + mTargetDataLoader = targetDataLoader; // The last argument of createResolverListAdapter is whether to do special handling // of the last used choice to highlight it in the list. We need to always @@ -1387,7 +1389,7 @@ public class ResolverActivity extends FragmentActivity implements } final Option optionForChooserTarget(TargetInfo target, int index) { - return new Option(target.getDisplayLabel(), index); + return new Option(getOrLoadDisplayLabel(target), index); } public final Intent getTargetIntent() { @@ -1463,8 +1465,11 @@ public class ResolverActivity extends FragmentActivity implements return getString(defaultTitleRes); } else { return named - ? getString(title.namedTitleRes, mMultiProfilePagerAdapter - .getActiveListAdapter().getFilteredItem().getDisplayLabel()) + ? getString( + title.namedTitleRes, + getOrLoadDisplayLabel( + mMultiProfilePagerAdapter + .getActiveListAdapter().getFilteredItem())) : getString(title.titleRes); } } @@ -1801,9 +1806,10 @@ public class ResolverActivity extends FragmentActivity implements ((TextView) findViewById(com.android.internal.R.id.open_cross_profile)).setText( getResources().getString( - inWorkProfile ? R.string.miniresolver_open_in_personal + inWorkProfile + ? R.string.miniresolver_open_in_personal : R.string.miniresolver_open_in_work, - otherProfileResolveInfo.getDisplayLabel())); + getOrLoadDisplayLabel(otherProfileResolveInfo))); ((Button) findViewById(com.android.internal.R.id.use_same_profile_browser)).setText( inWorkProfile ? R.string.miniresolver_use_work_browser : R.string.miniresolver_use_personal_browser); @@ -2397,4 +2403,12 @@ public class ResolverActivity extends FragmentActivity implements } return userList; } + + private CharSequence getOrLoadDisplayLabel(TargetInfo info) { + if (info.isDisplayResolveInfo()) { + mTargetDataLoader.getOrLoadLabel((DisplayResolveInfo) info); + } + CharSequence displayLabel = info.getDisplayLabel(); + return displayLabel == null ? "" : displayLabel; + } } diff --git a/java/src/com/android/intentresolver/ResolverListAdapter.java b/java/src/com/android/intentresolver/ResolverListAdapter.java index 14ce0e0e..a243ac2a 100644 --- a/java/src/com/android/intentresolver/ResolverListAdapter.java +++ b/java/src/com/android/intentresolver/ResolverListAdapter.java @@ -397,8 +397,8 @@ public class ResolverListAdapter extends BaseAdapter { otherProfileInfo, mPm, mTargetIntent, - mResolverListCommunicator, - mTargetDataLoader); + mResolverListCommunicator + ); } else { mOtherProfile = null; try { @@ -518,8 +518,7 @@ public class ResolverListAdapter extends BaseAdapter { ri, ri.loadLabel(mPm), null, - ii, - mTargetDataLoader.createPresentationGetter(ri))); + ii)); } } @@ -571,8 +570,7 @@ public class ResolverListAdapter extends BaseAdapter { final DisplayResolveInfo dri = DisplayResolveInfo.newDisplayResolveInfo( intent, add, - (replaceIntent != null) ? replaceIntent : defaultIntent, - mTargetDataLoader.createPresentationGetter(add)); + (replaceIntent != null) ? replaceIntent : defaultIntent); dri.setPinned(rci.isPinned()); if (rci.isPinned()) { Log.i(TAG, "Pinned item: " + rci.name); @@ -757,7 +755,7 @@ public class ResolverListAdapter extends BaseAdapter { } } - private void loadLabel(DisplayResolveInfo info) { + protected final void loadLabel(DisplayResolveInfo info) { if (mRequestedLabels.add(info)) { mTargetDataLoader.loadLabel(info, (result) -> onLabelLoaded(info, result)); } @@ -875,8 +873,7 @@ public class ResolverListAdapter extends BaseAdapter { ResolvedComponentInfo resolvedComponentInfo, PackageManager pm, Intent targetIntent, - ResolverListCommunicator resolverListCommunicator, - TargetDataLoader targetDataLoader) { + ResolverListCommunicator resolverListCommunicator) { ResolveInfo resolveInfo = resolvedComponentInfo.getResolveInfoAt(0); Intent pOrigIntent = resolverListCommunicator.getReplacementIntent( @@ -885,16 +882,12 @@ public class ResolverListAdapter extends BaseAdapter { Intent replacementIntent = resolverListCommunicator.getReplacementIntent( resolveInfo.activityInfo, targetIntent); - TargetPresentationGetter presentationGetter = - targetDataLoader.createPresentationGetter(resolveInfo); - return DisplayResolveInfo.newDisplayResolveInfo( resolvedComponentInfo.getIntentAt(0), resolveInfo, resolveInfo.loadLabel(pm), resolveInfo.loadLabel(pm), - pOrigIntent != null ? pOrigIntent : replacementIntent, - presentationGetter); + pOrigIntent != null ? pOrigIntent : replacementIntent); } /** @@ -938,6 +931,24 @@ public class ResolverListAdapter extends BaseAdapter { public TextView text2; public ImageView icon; + public final void reset() { + text.setText(""); + text.setMaxLines(2); + text.setMaxWidth(Integer.MAX_VALUE); + text.setBackground(null); + text.setPaddingRelative(0, 0, 0, 0); + + text2.setVisibility(View.GONE); + text2.setText(""); + + itemView.setContentDescription(null); + itemView.setBackground(defaultItemViewBackground); + + icon.setImageDrawable(null); + icon.setColorFilter(null); + icon.clearAnimation(); + } + @VisibleForTesting public ViewHolder(View view) { itemView = view; @@ -982,5 +993,22 @@ public class ResolverListAdapter extends BaseAdapter { icon.setColorFilter(null); } } + + public void bindPlaceholderDrawable(int maxTextWidth, Drawable drawable) { + text.setMaxWidth(maxTextWidth); + text.setBackground(drawable); + // Prevent rippling by removing background containing ripple + itemView.setBackground(null); + } + + public void bindGroupIndicator(Drawable indicator) { + text.setPaddingRelative(0, 0, /*end = */indicator.getIntrinsicWidth(), 0); + text.setBackground(indicator); + } + + public void bindPinnedIndicator(Drawable indicator) { + text.setPaddingRelative(/*start = */indicator.getIntrinsicWidth(), 0, 0, 0); + text.setBackground(indicator); + } } } diff --git a/java/src/com/android/intentresolver/chooser/DisplayResolveInfo.java b/java/src/com/android/intentresolver/chooser/DisplayResolveInfo.java index 09cf319f..866da5f6 100644 --- a/java/src/com/android/intentresolver/chooser/DisplayResolveInfo.java +++ b/java/src/com/android/intentresolver/chooser/DisplayResolveInfo.java @@ -27,10 +27,7 @@ import android.content.pm.ResolveInfo; import android.os.Bundle; import android.os.UserHandle; -import com.android.intentresolver.TargetPresentationGetter; - import java.util.ArrayList; -import java.util.Arrays; import java.util.List; /** @@ -39,12 +36,11 @@ import java.util.List; */ public class DisplayResolveInfo implements TargetInfo { private final ResolveInfo mResolveInfo; - private CharSequence mDisplayLabel; - private CharSequence mExtendedInfo; + private volatile CharSequence mDisplayLabel; + private volatile CharSequence mExtendedInfo; private final Intent mResolvedIntent; private final List mSourceIntents = new ArrayList<>(); private final boolean mIsSuspended; - private TargetPresentationGetter mPresentationGetter; private boolean mPinned = false; private final IconHolder mDisplayIconHolder = new SettableIconHolder(); @@ -52,15 +48,13 @@ public class DisplayResolveInfo implements TargetInfo { public static DisplayResolveInfo newDisplayResolveInfo( Intent originalIntent, ResolveInfo resolveInfo, - @NonNull Intent resolvedIntent, - @Nullable TargetPresentationGetter presentationGetter) { + @NonNull Intent resolvedIntent) { return newDisplayResolveInfo( originalIntent, resolveInfo, /* displayLabel=*/ null, /* extendedInfo=*/ null, - resolvedIntent, - presentationGetter); + resolvedIntent); } /** Create a new {@code DisplayResolveInfo} instance. */ @@ -69,15 +63,13 @@ public class DisplayResolveInfo implements TargetInfo { ResolveInfo resolveInfo, CharSequence displayLabel, CharSequence extendedInfo, - @NonNull Intent resolvedIntent, - @Nullable TargetPresentationGetter presentationGetter) { + @NonNull Intent resolvedIntent) { return new DisplayResolveInfo( originalIntent, resolveInfo, displayLabel, extendedInfo, - resolvedIntent, - presentationGetter); + resolvedIntent); } private DisplayResolveInfo( @@ -85,13 +77,11 @@ public class DisplayResolveInfo implements TargetInfo { ResolveInfo resolveInfo, CharSequence displayLabel, CharSequence extendedInfo, - @NonNull Intent resolvedIntent, - @Nullable TargetPresentationGetter presentationGetter) { + @NonNull Intent resolvedIntent) { mSourceIntents.add(originalIntent); mResolveInfo = resolveInfo; mDisplayLabel = displayLabel; mExtendedInfo = extendedInfo; - mPresentationGetter = presentationGetter; final ActivityInfo ai = mResolveInfo.activityInfo; mIsSuspended = (ai.applicationInfo.flags & ApplicationInfo.FLAG_SUSPENDED) != 0; @@ -101,8 +91,7 @@ public class DisplayResolveInfo implements TargetInfo { private DisplayResolveInfo( DisplayResolveInfo other, - @Nullable Intent baseIntentToSend, - TargetPresentationGetter presentationGetter) { + @Nullable Intent baseIntentToSend) { mSourceIntents.addAll(other.getAllSourceIntents()); mResolveInfo = other.mResolveInfo; mIsSuspended = other.mIsSuspended; @@ -112,7 +101,6 @@ public class DisplayResolveInfo implements TargetInfo { mResolvedIntent = createResolvedIntent( baseIntentToSend == null ? other.mResolvedIntent : baseIntentToSend, mResolveInfo.activityInfo); - mPresentationGetter = presentationGetter; mDisplayIconHolder.setDisplayIcon(other.mDisplayIconHolder.getDisplayIcon()); } @@ -124,7 +112,6 @@ public class DisplayResolveInfo implements TargetInfo { mDisplayLabel = other.mDisplayLabel; mExtendedInfo = other.mExtendedInfo; mResolvedIntent = other.mResolvedIntent; - mPresentationGetter = other.mPresentationGetter; mDisplayIconHolder.setDisplayIcon(other.mDisplayIconHolder.getDisplayIcon()); } @@ -147,10 +134,6 @@ public class DisplayResolveInfo implements TargetInfo { } public CharSequence getDisplayLabel() { - if (mDisplayLabel == null && mPresentationGetter != null) { - mDisplayLabel = mPresentationGetter.getLabel(); - mExtendedInfo = mPresentationGetter.getSubLabel(); - } return mDisplayLabel; } @@ -186,8 +169,7 @@ public class DisplayResolveInfo implements TargetInfo { return new DisplayResolveInfo( this, - TargetInfo.mergeRefinementIntoMatchingBaseIntent(matchingBase, proposedRefinement), - mPresentationGetter); + TargetInfo.mergeRefinementIntoMatchingBaseIntent(matchingBase, proposedRefinement)); } @Override @@ -197,7 +179,7 @@ public class DisplayResolveInfo implements TargetInfo { @Override public ArrayList getAllDisplayTargets() { - return new ArrayList<>(Arrays.asList(this)); + return new ArrayList<>(List.of(this)); } public void addAlternateSourceIntent(Intent alt) { diff --git a/java/src/com/android/intentresolver/icons/DefaultTargetDataLoader.kt b/java/src/com/android/intentresolver/icons/DefaultTargetDataLoader.kt index 0e4d0209..646ca8e1 100644 --- a/java/src/com/android/intentresolver/icons/DefaultTargetDataLoader.kt +++ b/java/src/com/android/intentresolver/icons/DefaultTargetDataLoader.kt @@ -18,7 +18,6 @@ package com.android.intentresolver.icons import android.app.ActivityManager import android.content.Context -import android.content.pm.ResolveInfo import android.graphics.drawable.Drawable import android.os.AsyncTask import android.os.UserHandle @@ -105,8 +104,14 @@ class DefaultTargetDataLoader( .executeOnExecutor(executor) } - override fun createPresentationGetter(info: ResolveInfo): TargetPresentationGetter = - presentationFactory.makePresentationGetter(info) + override fun getOrLoadLabel(info: DisplayResolveInfo) { + if (!info.hasDisplayLabel()) { + val result = + LoadLabelTask.loadLabel(context, info, isAudioCaptureDevice, presentationFactory) + info.displayLabel = result[0] + info.extendedInfo = result[1] + } + } private fun addTask(id: Int, task: AsyncTask<*, *, *>) { synchronized(activeTasks) { activeTasks.put(id, task) } diff --git a/java/src/com/android/intentresolver/icons/LoadLabelTask.java b/java/src/com/android/intentresolver/icons/LoadLabelTask.java index a0867b8e..b9a9d89d 100644 --- a/java/src/com/android/intentresolver/icons/LoadLabelTask.java +++ b/java/src/com/android/intentresolver/icons/LoadLabelTask.java @@ -49,25 +49,30 @@ class LoadLabelTask extends AsyncTask { protected CharSequence[] doInBackground(Void... voids) { try { Trace.beginSection("app-label"); - return loadLabel(); + return loadLabel( + mContext, mDisplayResolveInfo, mIsAudioCaptureDevice, mPresentationFactory); } finally { Trace.endSection(); } } - private CharSequence[] loadLabel() { - TargetPresentationGetter pg = mPresentationFactory.makePresentationGetter( - mDisplayResolveInfo.getResolveInfo()); + static CharSequence[] loadLabel( + Context context, + DisplayResolveInfo displayResolveInfo, + boolean isAudioCaptureDevice, + TargetPresentationGetter.Factory presentationFactory) { + TargetPresentationGetter pg = presentationFactory.makePresentationGetter( + displayResolveInfo.getResolveInfo()); - if (mIsAudioCaptureDevice) { + if (isAudioCaptureDevice) { // This is an audio capture device, so check record permissions - ActivityInfo activityInfo = mDisplayResolveInfo.getResolveInfo().activityInfo; + ActivityInfo activityInfo = displayResolveInfo.getResolveInfo().activityInfo; String packageName = activityInfo.packageName; int uid = activityInfo.applicationInfo.uid; boolean hasRecordPermission = PermissionChecker.checkPermissionForPreflight( - mContext, + context, android.Manifest.permission.RECORD_AUDIO, -1, uid, packageName) == android.content.pm.PackageManager.PERMISSION_GRANTED; @@ -76,7 +81,7 @@ class LoadLabelTask extends AsyncTask { // Doesn't have record permission, so warn the user return new CharSequence[]{ pg.getLabel(), - mContext.getString(R.string.usb_device_resolve_prompt_warn) + context.getString(R.string.usb_device_resolve_prompt_warn) }; } } diff --git a/java/src/com/android/intentresolver/icons/TargetDataLoader.kt b/java/src/com/android/intentresolver/icons/TargetDataLoader.kt index 50f731f8..6186a5ab 100644 --- a/java/src/com/android/intentresolver/icons/TargetDataLoader.kt +++ b/java/src/com/android/intentresolver/icons/TargetDataLoader.kt @@ -16,10 +16,8 @@ package com.android.intentresolver.icons -import android.content.pm.ResolveInfo import android.graphics.drawable.Drawable import android.os.UserHandle -import com.android.intentresolver.TargetPresentationGetter import com.android.intentresolver.chooser.DisplayResolveInfo import com.android.intentresolver.chooser.SelectableTargetInfo import java.util.function.Consumer @@ -43,8 +41,6 @@ abstract class TargetDataLoader { /** Load target label */ abstract fun loadLabel(info: DisplayResolveInfo, callback: Consumer>) - /** Create a presentation getter to be used with a [DisplayResolveInfo] */ - // TODO: get rid of DisplayResolveInfo's dependency on the presentation getter and remove this - // method. - abstract fun createPresentationGetter(info: ResolveInfo): TargetPresentationGetter + /** Loads DisplayResolveInfo's display label synchronously, if needed */ + abstract fun getOrLoadLabel(info: DisplayResolveInfo) } diff --git a/java/tests/src/com/android/intentresolver/ChooserListAdapterTest.kt b/java/tests/src/com/android/intentresolver/ChooserListAdapterTest.kt index 87e58954..a4078365 100644 --- a/java/tests/src/com/android/intentresolver/ChooserListAdapterTest.kt +++ b/java/tests/src/com/android/intentresolver/ChooserListAdapterTest.kt @@ -123,8 +123,7 @@ class ChooserListAdapterTest { ResolverDataProvider.createResolveInfo(2, 0, userHandle), null, "extended info", - Intent(), - /* resolveInfoPresentationGetter= */ null + Intent() ) testSubject.onBindView(view, targetInfo, 0) @@ -200,7 +199,6 @@ class ChooserListAdapterTest { appLabel, "extended info", Intent(), - /* resolveInfoPresentationGetter= */ null ) .apply { if (isPinned) { diff --git a/java/tests/src/com/android/intentresolver/ChooserWrapperActivity.java b/java/tests/src/com/android/intentresolver/ChooserWrapperActivity.java index fbc99a3a..48f8be5d 100644 --- a/java/tests/src/com/android/intentresolver/ChooserWrapperActivity.java +++ b/java/tests/src/com/android/intentresolver/ChooserWrapperActivity.java @@ -224,16 +224,18 @@ public class ChooserWrapperActivity extends ChooserActivity implements IChooserW } @Override - public DisplayResolveInfo createTestDisplayResolveInfo(Intent originalIntent, ResolveInfo pri, - CharSequence pLabel, CharSequence pInfo, Intent replacementIntent, - @Nullable TargetPresentationGetter resolveInfoPresentationGetter) { + public DisplayResolveInfo createTestDisplayResolveInfo( + Intent originalIntent, + ResolveInfo pri, + CharSequence pLabel, + CharSequence pInfo, + Intent replacementIntent) { return DisplayResolveInfo.newDisplayResolveInfo( originalIntent, pri, pLabel, pInfo, - replacementIntent, - resolveInfoPresentationGetter); + replacementIntent); } @Override diff --git a/java/tests/src/com/android/intentresolver/IChooserWrapper.java b/java/tests/src/com/android/intentresolver/IChooserWrapper.java index d439b037..e34217a8 100644 --- a/java/tests/src/com/android/intentresolver/IChooserWrapper.java +++ b/java/tests/src/com/android/intentresolver/IChooserWrapper.java @@ -16,7 +16,6 @@ package com.android.intentresolver; -import android.annotation.Nullable; import android.app.usage.UsageStatsManager; import android.content.Intent; import android.content.pm.ResolveInfo; @@ -37,9 +36,12 @@ public interface IChooserWrapper { ChooserListAdapter getWorkListAdapter(); boolean getIsSelected(); UsageStatsManager getUsageStatsManager(); - DisplayResolveInfo createTestDisplayResolveInfo(Intent originalIntent, ResolveInfo pri, - CharSequence pLabel, CharSequence pInfo, Intent replacementIntent, - @Nullable TargetPresentationGetter resolveInfoPresentationGetter); + DisplayResolveInfo createTestDisplayResolveInfo( + Intent originalIntent, + ResolveInfo pri, + CharSequence pLabel, + CharSequence pInfo, + Intent replacementIntent); UserHandle getCurrentUserHandle(); Executor getMainExecutor(); } diff --git a/java/tests/src/com/android/intentresolver/ResolverWrapperActivity.java b/java/tests/src/com/android/intentresolver/ResolverWrapperActivity.java index 60180c0b..1bb05437 100644 --- a/java/tests/src/com/android/intentresolver/ResolverWrapperActivity.java +++ b/java/tests/src/com/android/intentresolver/ResolverWrapperActivity.java @@ -273,10 +273,9 @@ public class ResolverWrapperActivity extends ResolverActivity { }); } - @NonNull @Override - public TargetPresentationGetter createPresentationGetter(@NonNull ResolveInfo info) { - return mTargetDataLoader.createPresentationGetter(info); + public void getOrLoadLabel(@NonNull DisplayResolveInfo info) { + mTargetDataLoader.getOrLoadLabel(info); } } } diff --git a/java/tests/src/com/android/intentresolver/ShortcutSelectionLogicTest.kt b/java/tests/src/com/android/intentresolver/ShortcutSelectionLogicTest.kt index 9ddeed84..2346d98b 100644 --- a/java/tests/src/com/android/intentresolver/ShortcutSelectionLogicTest.kt +++ b/java/tests/src/com/android/intentresolver/ShortcutSelectionLogicTest.kt @@ -19,7 +19,6 @@ package com.android.intentresolver import android.content.ComponentName import android.content.Context import android.content.Intent -import android.content.pm.ResolveInfo import android.content.pm.ShortcutInfo import android.os.UserHandle import android.service.chooser.ChooserTarget @@ -60,16 +59,16 @@ class ShortcutSelectionLogicTest { ResolverDataProvider.createResolveInfo(3, 0, PERSONAL_USER_HANDLE), "label", "extended info", - Intent(), - /* resolveInfoPresentationGetter= */ null) + Intent() + ) private val otherBaseDisplayInfo = DisplayResolveInfo.newDisplayResolveInfo( Intent(), ResolverDataProvider.createResolveInfo(4, 0, PERSONAL_USER_HANDLE), "label 2", "extended info 2", - Intent(), - /* resolveInfoPresentationGetter= */ null) + Intent() + ) private operator fun Map>.get(pkg: String, idx: Int) = this[pkg]?.get(idx) ?: error("missing package $pkg") diff --git a/java/tests/src/com/android/intentresolver/UnbundledChooserActivityTest.java b/java/tests/src/com/android/intentresolver/UnbundledChooserActivityTest.java index c0b15b6d..e303c070 100644 --- a/java/tests/src/com/android/intentresolver/UnbundledChooserActivityTest.java +++ b/java/tests/src/com/android/intentresolver/UnbundledChooserActivityTest.java @@ -500,8 +500,8 @@ public class UnbundledChooserActivityTest { }; ResolveInfo toChoose = resolvedComponentInfos.get(0).getResolveInfoAt(0); DisplayResolveInfo testDri = - activity.createTestDisplayResolveInfo(sendIntent, toChoose, "testLabel", "testInfo", - sendIntent, /* resolveInfoPresentationGetter */ null); + activity.createTestDisplayResolveInfo( + sendIntent, toChoose, "testLabel", "testInfo", sendIntent); onView(withText(toChoose.activityInfo.name)) .perform(click()); waitForIdle(); @@ -1414,8 +1414,7 @@ public class UnbundledChooserActivityTest { ResolverDataProvider.createResolveInfo(3, 0, PERSONAL_USER_HANDLE), "testLabel", "testInfo", - sendIntent, - /* resolveInfoPresentationGetter */ null); + sendIntent); final ChooserListAdapter adapter = activity.getAdapter(); assertThat(adapter.getBaseScore(null, 0), is(CALLER_TARGET_SCORE_BOOST)); @@ -1954,8 +1953,7 @@ public class UnbundledChooserActivityTest { ri, "testLabel", "testInfo", - sendIntent, - /* resolveInfoPresentationGetter */ null), + sendIntent), serviceTargets, TARGET_TYPE_CHOOSER_TARGET, directShareToShortcutInfos, diff --git a/java/tests/src/com/android/intentresolver/chooser/ImmutableTargetInfoTest.kt b/java/tests/src/com/android/intentresolver/chooser/ImmutableTargetInfoTest.kt index f3ca76a9..6712bf31 100644 --- a/java/tests/src/com/android/intentresolver/chooser/ImmutableTargetInfoTest.kt +++ b/java/tests/src/com/android/intentresolver/chooser/ImmutableTargetInfoTest.kt @@ -21,7 +21,6 @@ import android.app.prediction.AppTarget import android.app.prediction.AppTargetId import android.content.ComponentName import android.content.Intent -import android.content.pm.ResolveInfo import android.os.Bundle import android.os.UserHandle import com.android.intentresolver.createShortcutInfo @@ -52,15 +51,15 @@ class ImmutableTargetInfoTest { ResolverDataProvider.createResolveInfo(2, 0, PERSONAL_USER_HANDLE), "display1 label", "display1 extended info", - Intent("display1_resolved"), - /* resolveInfoPresentationGetter= */ null) + Intent("display1_resolved") + ) private val displayTarget2 = DisplayResolveInfo.newDisplayResolveInfo( Intent("display2"), ResolverDataProvider.createResolveInfo(3, 0, PERSONAL_USER_HANDLE), "display2 label", "display2 extended info", - Intent("display2_resolved"), - /* resolveInfoPresentationGetter= */ null) + Intent("display2_resolved") + ) private val directShareShortcutInfo = createShortcutInfo( "shortcutid", ResolverDataProvider.createComponentName(4), 4) private val directShareAppTarget = AppTarget( @@ -73,8 +72,8 @@ class ImmutableTargetInfoTest { ResolverDataProvider.createResolveInfo(5, 0, PERSONAL_USER_HANDLE), "displayresolve label", "displayresolve extended info", - Intent("display_resolved"), - /* resolveInfoPresentationGetter= */ null) + Intent("display_resolved") + ) private val hashProvider: ImmutableTargetInfo.TargetHashProvider = mock() @Test diff --git a/java/tests/src/com/android/intentresolver/chooser/TargetInfoTest.kt b/java/tests/src/com/android/intentresolver/chooser/TargetInfoTest.kt index 78e0c3ee..a7574c12 100644 --- a/java/tests/src/com/android/intentresolver/chooser/TargetInfoTest.kt +++ b/java/tests/src/com/android/intentresolver/chooser/TargetInfoTest.kt @@ -87,8 +87,8 @@ class TargetInfoTest { ResolverDataProvider.createResolveInfo(1, 0, PERSONAL_USER_HANDLE), "label", "extended info", - resolvedIntent, - /* resolveInfoPresentationGetter= */ null) + resolvedIntent + ) val chooserTarget = createChooserTarget( "title", 0.3f, ResolverDataProvider.createComponentName(2), "test_shortcut_id") val shortcutInfo = createShortcutInfo("id", ResolverDataProvider.createComponentName(3), 3) @@ -161,8 +161,8 @@ class TargetInfoTest { ResolverDataProvider.createResolveInfo(1, 0), "label", "extended info", - resolvedIntent, - /* resolveInfoPresentationGetter= */ null) + resolvedIntent + ) val chooserTarget = createChooserTarget( "title", 0.3f, ResolverDataProvider.createComponentName(2), "test_shortcut_id") val shortcutInfo = createShortcutInfo("id", ResolverDataProvider.createComponentName(3), 3) @@ -200,8 +200,8 @@ class TargetInfoTest { resolveInfo, "label", "extended info", - intent, - /* resolveInfoPresentationGetter= */ null) + intent + ) assertThat(targetInfo.isDisplayResolveInfo()).isTrue() assertThat(targetInfo.isMultiDisplayResolveInfo()).isFalse() assertThat(targetInfo.isChooserTargetInfo()).isFalse() @@ -223,8 +223,8 @@ class TargetInfoTest { ResolverDataProvider.createResolveInfo(3, 0), "label", "extended info", - originalIntent, - /* resolveInfoPresentationGetter= */ null) + originalIntent + ) originalInfo.addAlternateSourceIntent(mismatchedAlternate) originalInfo.addAlternateSourceIntent(targetAlternate) originalInfo.addAlternateSourceIntent(extraMatch) @@ -257,8 +257,8 @@ class TargetInfoTest { ResolverDataProvider.createResolveInfo(3, 0), "label", "extended info", - originalIntent, - /* resolveInfoPresentationGetter= */ null) + originalIntent + ) originalInfo.addAlternateSourceIntent(mismatchedAlternate) val refinement = Intent("PROPOSED_REFINEMENT") @@ -277,15 +277,15 @@ class TargetInfoTest { resolveInfo, "label 1", "extended info 1", - intent, - /* resolveInfoPresentationGetter= */ null) + intent + ) val secondTargetInfo = DisplayResolveInfo.newDisplayResolveInfo( intent, resolveInfo, "label 2", "extended info 2", - intent, - /* resolveInfoPresentationGetter= */ null) + intent + ) val multiTargetInfo = MultiDisplayResolveInfo.newMultiDisplayResolveInfo( listOf(firstTargetInfo, secondTargetInfo)) @@ -328,24 +328,23 @@ class TargetInfoTest { resolveInfo, "Send Image", "Sends only images", - sendImage, - /* resolveInfoPresentationGetter= */ null) + sendImage + ) val textOnlyTarget = DisplayResolveInfo.newDisplayResolveInfo( sendUri, resolveInfo, "Send Text", "Sends only text", - sendUri, - /* resolveInfoPresentationGetter= */ null) + sendUri + ) val imageOrTextTarget = DisplayResolveInfo.newDisplayResolveInfo( sendImage, resolveInfo, "Send Image or Text", "Sends images or text", - sendImage, - /* resolveInfoPresentationGetter= */ null + sendImage ).apply { addAlternateSourceIntent(sendUri) } @@ -377,8 +376,7 @@ class TargetInfoTest { ResolverDataProvider.createResolveInfo(1, 0), "Target One", "Target One", - sendImage, - /* resolveInfoPresentationGetter= */ null + sendImage ) ) val targetTwo = mock { -- cgit v1.2.3-59-g8ed1b From 5e4370b5de1347909d514e279e1aada582458820 Mon Sep 17 00:00:00 2001 From: Andrey Epin Date: Tue, 1 Aug 2023 14:44:48 -0700 Subject: Make preview scrollable under a feature flag. Base feature implementation controlled by a flag. A few issues are known and will be addressed separately. Known issues: * No (dis)appearance animation for the A-Z targets divider bar. Bug: 287102904 Test: Enabled the flag programmatically and test the new functionality. Manually test for possible regressions with the unset flag. Test: atest com.android.intentresolver.contentpreview Test: atest IntentResolverUnitTests:UnbundledChooserActivityTest Change-Id: I8273cf365a1e00b1acff4030086f1a044ad7531f --- aconfig/FeatureFlags.aconfig | 7 + .../res/layout/chooser_grid_scrollable_preview.xml | 128 +++++++++++ java/res/layout/chooser_list_per_profile_wrap.xml | 42 ++++ java/res/values/attrs.xml | 5 + .../android/intentresolver/ChooserActivity.java | 29 ++- .../ChooserMultiProfilePagerAdapter.java | 25 ++- .../intentresolver/grid/ChooserGridAdapter.java | 34 ++- .../widget/ChooserNestedScrollView.kt | 90 ++++++++ .../widget/ResolverDrawerLayout.java | 96 ++++++++- java/tests/Android.bp | 2 + .../UnbundledChooserActivityTest.java | 51 ++++- .../contentpreview/FileContentPreviewUiTest.kt | 45 +++- .../FilesPlusTextContentPreviewUiTest.kt | 199 ++++++++++++++++- .../contentpreview/TextContentPreviewUiTest.kt | 44 +++- .../contentpreview/UnifiedContentPreviewUiTest.kt | 235 ++++++++++++++++++--- 15 files changed, 954 insertions(+), 78 deletions(-) create mode 100644 java/res/layout/chooser_grid_scrollable_preview.xml create mode 100644 java/res/layout/chooser_list_per_profile_wrap.xml create mode 100644 java/src/com/android/intentresolver/widget/ChooserNestedScrollView.kt (limited to 'java/src') diff --git a/aconfig/FeatureFlags.aconfig b/aconfig/FeatureFlags.aconfig index 037a3b9a..5c611b01 100644 --- a/aconfig/FeatureFlags.aconfig +++ b/aconfig/FeatureFlags.aconfig @@ -10,3 +10,10 @@ flag { description: "Enables the example new sharing mechanism." bug: "" } + +flag { + name: "scrollable_preview" + namespace: "intentresolver" + description: "Makes preview scrollable with multiple profiles" + bug: "287102904" +} diff --git a/java/res/layout/chooser_grid_scrollable_preview.xml b/java/res/layout/chooser_grid_scrollable_preview.xml new file mode 100644 index 00000000..a5ac75a2 --- /dev/null +++ b/java/res/layout/chooser_grid_scrollable_preview.xml @@ -0,0 +1,128 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/java/res/layout/chooser_list_per_profile_wrap.xml b/java/res/layout/chooser_list_per_profile_wrap.xml new file mode 100644 index 00000000..157fa75d --- /dev/null +++ b/java/res/layout/chooser_list_per_profile_wrap.xml @@ -0,0 +1,42 @@ + + + + + + + + diff --git a/java/res/values/attrs.xml b/java/res/values/attrs.xml index 67acb3ae..c9f2c300 100644 --- a/java/res/values/attrs.xml +++ b/java/res/values/attrs.xml @@ -32,6 +32,11 @@ will push all ignoreOffset siblings below it when the drawer is moved i.e. setting the top limit the ignoreOffset elements. --> + + diff --git a/java/src/com/android/intentresolver/ChooserActivity.java b/java/src/com/android/intentresolver/ChooserActivity.java index 26dbd224..f455be4c 100644 --- a/java/src/com/android/intentresolver/ChooserActivity.java +++ b/java/src/com/android/intentresolver/ChooserActivity.java @@ -491,7 +491,8 @@ public class ChooserActivity extends Hilt_ChooserActivity implements /* workProfileQuietModeChecker= */ () -> false, /* workProfileUserHandle= */ null, getAnnotatedUserHandles().cloneProfileUserHandle, - mMaxTargetsPerRow); + mMaxTargetsPerRow, + mFeatureFlags); } private ChooserMultiProfilePagerAdapter createChooserMultiProfilePagerAdapterForTwoProfiles( @@ -525,7 +526,8 @@ public class ChooserActivity extends Hilt_ChooserActivity implements selectedProfile, getAnnotatedUserHandles().workProfileUserHandle, getAnnotatedUserHandles().cloneProfileUserHandle, - mMaxTargetsPerRow); + mMaxTargetsPerRow, + mFeatureFlags); } private int findSelectedProfile() { @@ -667,7 +669,9 @@ public class ChooserActivity extends Hilt_ChooserActivity implements getResources(), getLayoutInflater(), parent, - /*headlineViewParent=*/null); + mFeatureFlags.scrollablePreview() + ? findViewById(R.id.chooser_headline_row_container) + : null); if (layout != null) { adjustPreviewWidth(getResources().getConfiguration().orientation, layout); @@ -788,7 +792,9 @@ public class ChooserActivity extends Hilt_ChooserActivity implements @Override public int getLayoutResource() { - return R.layout.chooser_grid; + return mFeatureFlags.scrollablePreview() + ? R.layout.chooser_grid_scrollable_preview + : R.layout.chooser_grid; } @Override // ResolverListCommunicator @@ -1208,7 +1214,8 @@ public class ChooserActivity extends Hilt_ChooserActivity implements }, chooserListAdapter, shouldShowContentPreview(), - mMaxTargetsPerRow); + mMaxTargetsPerRow, + mFeatureFlags); } @VisibleForTesting @@ -1639,11 +1646,13 @@ public class ChooserActivity extends Hilt_ChooserActivity implements } private boolean shouldShowStickyContentPreviewNoOrientationCheck() { - return shouldShowTabs() - && (mMultiProfilePagerAdapter.getListAdapterForUserHandle( - UserHandle.of(UserHandle.myUserId())).getCount() > 0 - || shouldShowContentPreviewWhenEmpty()) - && shouldShowContentPreview(); + if (!shouldShowContentPreview()) { + return false; + } + boolean isEmpty = mMultiProfilePagerAdapter.getListAdapterForUserHandle( + UserHandle.of(UserHandle.myUserId())).getCount() == 0; + return (mFeatureFlags.scrollablePreview() || shouldShowTabs()) + && (!isEmpty || shouldShowContentPreviewWhenEmpty()); } /** diff --git a/java/src/com/android/intentresolver/ChooserMultiProfilePagerAdapter.java b/java/src/com/android/intentresolver/ChooserMultiProfilePagerAdapter.java index c159243e..ba35ae5d 100644 --- a/java/src/com/android/intentresolver/ChooserMultiProfilePagerAdapter.java +++ b/java/src/com/android/intentresolver/ChooserMultiProfilePagerAdapter.java @@ -52,7 +52,8 @@ public class ChooserMultiProfilePagerAdapter extends GenericMultiProfilePagerAda Supplier workProfileQuietModeChecker, UserHandle workProfileUserHandle, UserHandle cloneProfileUserHandle, - int maxTargetsPerRow) { + int maxTargetsPerRow, + FeatureFlags featureFlags) { this( context, new ChooserProfileAdapterBinder(maxTargetsPerRow), @@ -62,7 +63,8 @@ public class ChooserMultiProfilePagerAdapter extends GenericMultiProfilePagerAda /* defaultProfile= */ 0, workProfileUserHandle, cloneProfileUserHandle, - new BottomPaddingOverrideSupplier(context)); + new BottomPaddingOverrideSupplier(context), + featureFlags); } ChooserMultiProfilePagerAdapter( @@ -74,7 +76,8 @@ public class ChooserMultiProfilePagerAdapter extends GenericMultiProfilePagerAda @Profile int defaultProfile, UserHandle workProfileUserHandle, UserHandle cloneProfileUserHandle, - int maxTargetsPerRow) { + int maxTargetsPerRow, + FeatureFlags featureFlags) { this( context, new ChooserProfileAdapterBinder(maxTargetsPerRow), @@ -84,7 +87,8 @@ public class ChooserMultiProfilePagerAdapter extends GenericMultiProfilePagerAda defaultProfile, workProfileUserHandle, cloneProfileUserHandle, - new BottomPaddingOverrideSupplier(context)); + new BottomPaddingOverrideSupplier(context), + featureFlags); } private ChooserMultiProfilePagerAdapter( @@ -96,7 +100,8 @@ public class ChooserMultiProfilePagerAdapter extends GenericMultiProfilePagerAda @Profile int defaultProfile, UserHandle workProfileUserHandle, UserHandle cloneProfileUserHandle, - BottomPaddingOverrideSupplier bottomPaddingOverrideSupplier) { + BottomPaddingOverrideSupplier bottomPaddingOverrideSupplier, + FeatureFlags featureFlags) { super( context, gridAdapter -> gridAdapter.getListAdapter(), @@ -107,7 +112,7 @@ public class ChooserMultiProfilePagerAdapter extends GenericMultiProfilePagerAda defaultProfile, workProfileUserHandle, cloneProfileUserHandle, - () -> makeProfileView(context), + () -> makeProfileView(context, featureFlags), bottomPaddingOverrideSupplier); mAdapterBinder = adapterBinder; mBottomPaddingOverrideSupplier = bottomPaddingOverrideSupplier; @@ -131,10 +136,12 @@ public class ChooserMultiProfilePagerAdapter extends GenericMultiProfilePagerAda } } - private static ViewGroup makeProfileView(Context context) { + private static ViewGroup makeProfileView( + Context context, FeatureFlags featureFlags) { LayoutInflater inflater = LayoutInflater.from(context); - ViewGroup rootView = (ViewGroup) inflater.inflate( - R.layout.chooser_list_per_profile, null, false); + ViewGroup rootView = featureFlags.scrollablePreview() + ? (ViewGroup) inflater.inflate(R.layout.chooser_list_per_profile_wrap, null, false) + : (ViewGroup) inflater.inflate(R.layout.chooser_list_per_profile, null, false); RecyclerView recyclerView = rootView.findViewById(com.android.internal.R.id.resolver_list); recyclerView.setAccessibilityDelegateCompat( new ChooserRecyclerViewAccessibilityDelegate(recyclerView)); diff --git a/java/src/com/android/intentresolver/grid/ChooserGridAdapter.java b/java/src/com/android/intentresolver/grid/ChooserGridAdapter.java index 77ae20f5..091ad158 100644 --- a/java/src/com/android/intentresolver/grid/ChooserGridAdapter.java +++ b/java/src/com/android/intentresolver/grid/ChooserGridAdapter.java @@ -32,9 +32,12 @@ import android.view.animation.DecelerateInterpolator; import android.widget.Space; import android.widget.TextView; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; import androidx.recyclerview.widget.RecyclerView; import com.android.intentresolver.ChooserListAdapter; +import com.android.intentresolver.FeatureFlags; import com.android.intentresolver.R; import com.android.intentresolver.ResolverListAdapter.ViewHolder; import com.android.internal.annotations.VisibleForTesting; @@ -107,6 +110,9 @@ public final class ChooserGridAdapter extends RecyclerView.Adapter= 0) { + if (mRecyclerView != null) { + for (int i = 0, size = mRecyclerView.getChildCount(); i < size; i++) { + View child = mRecyclerView.getChildAt(i); + if (mRecyclerView.getChildAdapterPosition(child) == azRowPos) { + child.setVisibility(isVisible ? View.VISIBLE : View.GONE); + } + } + return; + } notifyItemChanged(azRowPos); } } diff --git a/java/src/com/android/intentresolver/widget/ChooserNestedScrollView.kt b/java/src/com/android/intentresolver/widget/ChooserNestedScrollView.kt new file mode 100644 index 00000000..26464ca1 --- /dev/null +++ b/java/src/com/android/intentresolver/widget/ChooserNestedScrollView.kt @@ -0,0 +1,90 @@ +package com.android.intentresolver.widget + +import android.content.Context +import android.util.AttributeSet +import android.view.View +import android.widget.LinearLayout +import androidx.core.view.ScrollingView +import androidx.core.view.marginBottom +import androidx.core.view.marginLeft +import androidx.core.view.marginRight +import androidx.core.view.marginTop +import androidx.core.widget.NestedScrollView + +/** + * A narrowly tailored [NestedScrollView] to be used inside [ResolverDrawerLayout] and help to + * orchestrate content preview scrolling. It expects one [LinearLayout] child with + * [LinearLayout.VERTICAL] orientation. If the child has more than one child, the first its child + * will be made scrollable (it is expected to be a content preview view). + */ +class ChooserNestedScrollView : NestedScrollView { + constructor(context: Context) : super(context) + constructor(context: Context, attrs: AttributeSet?) : super(context, attrs) + constructor( + context: Context, + attrs: AttributeSet?, + defStyleAttr: Int + ) : super(context, attrs, defStyleAttr) + + override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) { + val content = + getChildAt(0) as? LinearLayout ?: error("Exactly one child, LinerLayout, is expected") + require(content.orientation == LinearLayout.VERTICAL) { "VERTICAL orientation is expected" } + require(MeasureSpec.getMode(widthMeasureSpec) == MeasureSpec.EXACTLY) { + "Expected to have an exact width" + } + + val lp = content.layoutParams ?: error("LayoutParams is missing") + val contentWidthSpec = + getChildMeasureSpec( + widthMeasureSpec, + paddingLeft + content.marginLeft + content.marginRight + paddingRight, + lp.width + ) + val contentHeightSpec = + getChildMeasureSpec( + heightMeasureSpec, + paddingTop + content.marginTop + content.marginBottom + paddingBottom, + lp.height + ) + content.measure(contentWidthSpec, contentHeightSpec) + + if (content.childCount > 1) { + // We expect that the first child should be scrollable up + val child = content.getChildAt(0) + val height = + MeasureSpec.getSize(heightMeasureSpec) + + child.measuredHeight + + child.marginTop + + child.marginBottom + + content.measure( + contentWidthSpec, + MeasureSpec.makeMeasureSpec(height, MeasureSpec.getMode(heightMeasureSpec)) + ) + } + setMeasuredDimension( + MeasureSpec.getSize(widthMeasureSpec), + minOf( + MeasureSpec.getSize(heightMeasureSpec), + paddingTop + + content.marginTop + + content.measuredHeight + + content.marginBottom + + paddingBottom + ) + ) + } + + override fun onNestedPreScroll(target: View, dx: Int, dy: Int, consumed: IntArray, type: Int) { + // let the parent scroll + super.onNestedPreScroll(target, dx, dy, consumed, type) + // scroll ourselves, if recycler has not scrolled + val delta = dy - consumed[1] + if (delta > 0 && target is ScrollingView && !target.canScrollVertically(-1)) { + val preScrollY = scrollY + scrollBy(0, delta) + consumed[1] += scrollY - preScrollY + } + } +} diff --git a/java/src/com/android/intentresolver/widget/ResolverDrawerLayout.java b/java/src/com/android/intentresolver/widget/ResolverDrawerLayout.java index de76a1d2..b8fbedbf 100644 --- a/java/src/com/android/intentresolver/widget/ResolverDrawerLayout.java +++ b/java/src/com/android/intentresolver/widget/ResolverDrawerLayout.java @@ -45,6 +45,8 @@ import android.view.animation.AnimationUtils; import android.widget.AbsListView; import android.widget.OverScroller; +import androidx.annotation.Nullable; +import androidx.core.view.ScrollingView; import androidx.recyclerview.widget.RecyclerView; import com.android.intentresolver.R; @@ -131,6 +133,9 @@ public class ResolverDrawerLayout extends ViewGroup { private AbsListView mNestedListChild; private RecyclerView mNestedRecyclerChild; + @Nullable + private final ScrollablePreviewFlingLogicDelegate mFlingLogicDelegate; + private final ViewTreeObserver.OnTouchModeChangeListener mTouchModeChangeListener = new ViewTreeObserver.OnTouchModeChangeListener() { @Override @@ -167,6 +172,12 @@ public class ResolverDrawerLayout extends ViewGroup { mIgnoreOffsetTopLimitViewId = a.getResourceId( R.styleable.ResolverDrawerLayout_ignoreOffsetTopLimit, ID_NULL); } + mFlingLogicDelegate = + a.getBoolean( + R.styleable.ResolverDrawerLayout_useScrollablePreviewNestedFlingLogic, + false) + ? new ScrollablePreviewFlingLogicDelegate() {} + : null; a.recycle(); mScrollIndicatorDrawable = mContext.getDrawable( @@ -832,6 +843,9 @@ public class ResolverDrawerLayout extends ViewGroup { @Override public boolean onNestedPreFling(View target, float velocityX, float velocityY) { + if (mFlingLogicDelegate != null) { + return mFlingLogicDelegate.onNestedPreFling(this, target, velocityX, velocityY); + } if (!getShowAtTop() && velocityY > mMinFlingVelocity && mCollapseOffset != 0) { smoothScrollTo(0, velocityY); return true; @@ -841,9 +855,12 @@ public class ResolverDrawerLayout extends ViewGroup { @Override public boolean onNestedFling(View target, float velocityX, float velocityY, boolean consumed) { + if (mFlingLogicDelegate != null) { + return mFlingLogicDelegate.onNestedFling(this, target, velocityX, velocityY, consumed); + } // TODO: find a more suitable way to fix it. // RecyclerView started reporting `consumed` as true whenever a scrolling is enabled, - // previously the value was based whether the fling can be performed in given direction + // previously the value was based on whether the fling can be performed in given direction // i.e. whether it is at the top or at the bottom. isRecyclerViewAtTheTop method is a // workaround that restores the legacy functionality. boolean shouldConsume = (Math.abs(velocityY) > mMinFlingVelocity) @@ -885,6 +902,13 @@ public class ResolverDrawerLayout extends ViewGroup { && firstChild.getTop() >= recyclerView.getPaddingTop(); } + private static boolean isFlingTargetAtTop(View target) { + if (target instanceof ScrollingView) { + return !target.canScrollVertically(-1); + } + return false; + } + private boolean performAccessibilityActionCommon(int action) { switch (action) { case AccessibilityNodeInfo.ACTION_SCROLL_FORWARD: @@ -1299,4 +1323,74 @@ public class ResolverDrawerLayout extends ViewGroup { } return mMetricsLogger; } + + /** + * Controlled by + * {@link com.android.intentresolver.Flags#FLAG_SCROLLABLE_PREVIEW} + */ + private interface ScrollablePreviewFlingLogicDelegate { + default boolean onNestedPreFling( + ResolverDrawerLayout drawer, View target, float velocityX, float velocityY) { + boolean shouldScroll = !drawer.getShowAtTop() && velocityY > drawer.mMinFlingVelocity + && drawer.mCollapseOffset != 0; + if (shouldScroll) { + drawer.smoothScrollTo(0, velocityY); + return true; + } + boolean shouldDismiss = (Math.abs(velocityY) > drawer.mMinFlingVelocity) + && velocityY < 0 + && isFlingTargetAtTop(target); + if (shouldDismiss) { + if (drawer.getShowAtTop()) { + drawer.smoothScrollTo(drawer.mCollapsibleHeight, velocityY); + } else { + if (drawer.isDismissable() + && drawer.mCollapseOffset > drawer.mCollapsibleHeight) { + drawer.smoothScrollTo(drawer.mHeightUsed, velocityY); + drawer.mDismissOnScrollerFinished = true; + } else { + drawer.smoothScrollTo(drawer.mCollapsibleHeight, velocityY); + } + } + return true; + } + return false; + } + + default boolean onNestedFling( + ResolverDrawerLayout drawer, + View target, + float velocityX, + float velocityY, + boolean consumed) { + // TODO: find a more suitable way to fix it. + // RecyclerView started reporting `consumed` as true whenever a scrolling is enabled, + // previously the value was based on whether the fling can be performed in given + // direction i.e. whether it is at the top or at the bottom. isRecyclerViewAtTheTop + // method is a workaround that restores the legacy functionality. + boolean shouldConsume = (Math.abs(velocityY) > drawer.mMinFlingVelocity) && !consumed; + if (shouldConsume) { + if (drawer.getShowAtTop()) { + if (drawer.isDismissable() && velocityY > 0) { + drawer.abortAnimation(); + drawer.dismiss(); + } else { + drawer.smoothScrollTo( + velocityY < 0 ? drawer.mCollapsibleHeight : 0, velocityY); + } + } else { + if (drawer.isDismissable() + && velocityY < 0 + && drawer.mCollapseOffset > drawer.mCollapsibleHeight) { + drawer.smoothScrollTo(drawer.mHeightUsed, velocityY); + drawer.mDismissOnScrollerFinished = true; + } else { + drawer.smoothScrollTo( + velocityY > 0 ? 0 : drawer.mCollapsibleHeight, velocityY); + } + } + } + return shouldConsume; + } + } } diff --git a/java/tests/Android.bp b/java/tests/Android.bp index 974b8a47..5244bf7b 100644 --- a/java/tests/Android.bp +++ b/java/tests/Android.bp @@ -51,6 +51,8 @@ android_test { "mockito-target-minus-junit4", "testables", "truth-prebuilt", + "flag-junit", + "platform-test-annotations", ], plugins: ["dagger2-compiler"], test_suites: ["general-tests"], diff --git a/java/tests/src/com/android/intentresolver/UnbundledChooserActivityTest.java b/java/tests/src/com/android/intentresolver/UnbundledChooserActivityTest.java index c0b15b6d..bc9e521c 100644 --- a/java/tests/src/com/android/intentresolver/UnbundledChooserActivityTest.java +++ b/java/tests/src/com/android/intentresolver/UnbundledChooserActivityTest.java @@ -17,6 +17,7 @@ package com.android.intentresolver; import static android.app.Activity.RESULT_OK; + import static androidx.test.espresso.Espresso.onView; import static androidx.test.espresso.action.ViewActions.click; import static androidx.test.espresso.action.ViewActions.longClick; @@ -24,10 +25,12 @@ import static androidx.test.espresso.action.ViewActions.swipeUp; import static androidx.test.espresso.assertion.ViewAssertions.doesNotExist; import static androidx.test.espresso.assertion.ViewAssertions.matches; import static androidx.test.espresso.matcher.ViewMatchers.hasSibling; +import static androidx.test.espresso.matcher.ViewMatchers.isCompletelyDisplayed; import static androidx.test.espresso.matcher.ViewMatchers.isDisplayed; import static androidx.test.espresso.matcher.ViewMatchers.withEffectiveVisibility; import static androidx.test.espresso.matcher.ViewMatchers.withId; import static androidx.test.espresso.matcher.ViewMatchers.withText; + import static com.android.intentresolver.ChooserActivity.TARGET_TYPE_CHOOSER_TARGET; import static com.android.intentresolver.ChooserActivity.TARGET_TYPE_DEFAULT; import static com.android.intentresolver.ChooserActivity.TARGET_TYPE_SHORTCUTS_FROM_PREDICTION_SERVICE; @@ -35,9 +38,12 @@ import static com.android.intentresolver.ChooserActivity.TARGET_TYPE_SHORTCUTS_F import static com.android.intentresolver.ChooserListAdapter.CALLER_TARGET_SCORE_BOOST; import static com.android.intentresolver.ChooserListAdapter.SHORTCUT_TARGET_SCORE_BOOST; import static com.android.intentresolver.MatcherUtils.first; + import static com.google.common.truth.Truth.assertThat; import static com.google.common.truth.Truth.assertWithMessage; + import static junit.framework.Assert.assertNull; + import static org.hamcrest.CoreMatchers.allOf; import static org.hamcrest.CoreMatchers.is; import static org.hamcrest.CoreMatchers.not; @@ -82,6 +88,9 @@ import android.graphics.drawable.Icon; import android.net.Uri; import android.os.Bundle; import android.os.UserHandle; +import android.platform.test.annotations.RequiresFlagsEnabled; +import android.platform.test.flag.junit.CheckFlagsRule; +import android.platform.test.flag.junit.DeviceFlagsValueProvider; import android.provider.DeviceConfig; import android.service.chooser.ChooserAction; import android.service.chooser.ChooserTarget; @@ -190,11 +199,13 @@ public class UnbundledChooserActivityTest { private static final int CONTENT_PREVIEW_FILE = 2; private static final int CONTENT_PREVIEW_TEXT = 3; - @Rule(order = 0) - public HiltAndroidRule mHiltAndroidRule = new HiltAndroidRule(this); + public CheckFlagsRule mCheckFlagsRule = DeviceFlagsValueProvider.createCheckFlagsRule(); @Rule(order = 1) + public HiltAndroidRule mHiltAndroidRule = new HiltAndroidRule(this); + + @Rule(order = 2) public ActivityTestRule mActivityRule = new ActivityTestRule<>(ChooserWrapperActivity.class, false, false); @@ -2160,6 +2171,42 @@ public class UnbundledChooserActivityTest { .check(matches(isDisplayed())); } + @Test + @RequiresFlagsEnabled(Flags.FLAG_SCROLLABLE_PREVIEW) + public void testWorkTab_previewIsScrollable() { + markOtherProfileAvailability(/* workAvailable= */ true, /* cloneAvailable= */ false); + List personalResolvedComponentInfos = + createResolvedComponentsForTest(300); + List workResolvedComponentInfos = + createResolvedComponentsForTest(3); + setupResolverControllers(personalResolvedComponentInfos, workResolvedComponentInfos); + + Uri uri = createTestContentProviderUri("image/png", null); + + ArrayList uris = new ArrayList<>(); + uris.add(uri); + + Intent sendIntent = createSendUriIntentWithPreview(uris); + ChooserActivityOverrideData.getInstance().imageLoader = + createImageLoader(uri, createWideBitmap()); + + mActivityRule.launchActivity(Intent.createChooser(sendIntent, "Scrollable preview test")); + waitForIdle(); + + onView(withId(com.android.intentresolver.R.id.scrollable_image_preview)) + .check(matches(isDisplayed())); + + onView(withId(com.android.internal.R.id.contentPanel)).perform(swipeUp()); + waitForIdle(); + + onView(withId(com.android.intentresolver.R.id.chooser_headline_row_container)) + .check(matches(isCompletelyDisplayed())); + onView(withId(com.android.intentresolver.R.id.headline)) + .check(matches(isDisplayed())); + onView(withId(com.android.intentresolver.R.id.scrollable_image_preview)) + .check(matches(not(isDisplayed()))); + } + @Ignore // b/220067877 @Test public void testWorkTab_xProfileOff_noAppsAvailable_workOff_xProfileOffEmptyStateShown() { diff --git a/java/tests/src/com/android/intentresolver/contentpreview/FileContentPreviewUiTest.kt b/java/tests/src/com/android/intentresolver/contentpreview/FileContentPreviewUiTest.kt index 6409da8a..d2d952ae 100644 --- a/java/tests/src/com/android/intentresolver/contentpreview/FileContentPreviewUiTest.kt +++ b/java/tests/src/com/android/intentresolver/contentpreview/FileContentPreviewUiTest.kt @@ -17,6 +17,7 @@ package com.android.intentresolver.contentpreview import android.view.LayoutInflater +import android.view.View import android.view.ViewGroup import android.widget.TextView import androidx.test.ext.junit.runners.AndroidJUnit4 @@ -25,7 +26,7 @@ import com.android.intentresolver.R import com.android.intentresolver.mock import com.android.intentresolver.whenever import com.android.intentresolver.widget.ActionRow -import com.google.common.truth.Truth +import com.google.common.truth.Truth.assertThat import java.util.function.Consumer import org.junit.Test import org.junit.runner.RunWith @@ -48,15 +49,15 @@ class FileContentPreviewUiTest { private val context get() = InstrumentationRegistry.getInstrumentation().context + private val testSubject = + FileContentPreviewUi( + fileCount, + actionFactory, + headlineGenerator, + ) + @Test fun test_display_titleIsDisplayed() { - val testSubject = - FileContentPreviewUi( - fileCount, - actionFactory, - headlineGenerator, - ) - val layoutInflater = LayoutInflater.from(context) val gridLayout = layoutInflater.inflate(R.layout.chooser_grid, null, false) as ViewGroup @@ -68,9 +69,31 @@ class FileContentPreviewUiTest { /*headlineViewParent=*/ null ) - Truth.assertThat(previewView).isNotNull() + assertThat(previewView).isNotNull() val headlineView = previewView?.findViewById(R.id.headline) - Truth.assertThat(headlineView).isNotNull() - Truth.assertThat(headlineView?.text).isEqualTo(text) + assertThat(headlineView).isNotNull() + assertThat(headlineView?.text).isEqualTo(text) + } + + @Test + fun test_displayWithExternalHeaderView() { + val layoutInflater = LayoutInflater.from(context) + val gridLayout = + layoutInflater.inflate(R.layout.chooser_grid_scrollable_preview, null, false) + as ViewGroup + val externalHeaderView = + gridLayout.requireViewById(R.id.chooser_headline_row_container) + + assertThat(externalHeaderView.findViewById(R.id.headline)).isNull() + + val previewView = + testSubject.display(context.resources, layoutInflater, gridLayout, externalHeaderView) + + assertThat(previewView).isNotNull() + assertThat(previewView.findViewById(R.id.headline)).isNull() + + val headlineView = externalHeaderView.findViewById(R.id.headline) + assertThat(headlineView).isNotNull() + assertThat(headlineView?.text).isEqualTo(text) } } diff --git a/java/tests/src/com/android/intentresolver/contentpreview/FilesPlusTextContentPreviewUiTest.kt b/java/tests/src/com/android/intentresolver/contentpreview/FilesPlusTextContentPreviewUiTest.kt index 1144e3c9..0976dbf1 100644 --- a/java/tests/src/com/android/intentresolver/contentpreview/FilesPlusTextContentPreviewUiTest.kt +++ b/java/tests/src/com/android/intentresolver/contentpreview/FilesPlusTextContentPreviewUiTest.kt @@ -18,6 +18,7 @@ package com.android.intentresolver.contentpreview import android.net.Uri import android.view.LayoutInflater +import android.view.View import android.view.ViewGroup import android.widget.TextView import androidx.lifecycle.testing.TestLifecycleOwner @@ -28,6 +29,7 @@ import com.android.intentresolver.mock import com.android.intentresolver.whenever import com.android.intentresolver.widget.ActionRow import com.google.common.truth.Truth.assertThat +import com.google.common.truth.Truth.assertWithMessage import java.util.function.Consumer import org.junit.Test import org.junit.runner.RunWith @@ -73,6 +75,17 @@ class FilesPlusTextContentPreviewUiTest { verifySharedText(previewView) } + @Test + fun test_displayImagesPlusTextWithoutUriMetadataExternalHeader_showImagesHeadline() { + val sharedFileCount = 2 + val (previewView, headerParent) = testLoadingExternalHeadline("image/*", sharedFileCount) + + verify(headlineGenerator, times(1)).getImagesHeadline(sharedFileCount) + verifyInternalHeadlineAbsence(previewView) + verifyPreviewHeadline(headerParent, HEADLINE_IMAGES) + verifySharedText(previewView) + } + @Test fun test_displayVideosPlusTextWithoutUriMetadata_showVideosHeadline() { val sharedFileCount = 2 @@ -83,6 +96,17 @@ class FilesPlusTextContentPreviewUiTest { verifySharedText(previewView) } + @Test + fun test_displayVideosPlusTextWithoutUriMetadataExternalHeader_showVideosHeadline() { + val sharedFileCount = 2 + val (previewView, headerParent) = testLoadingExternalHeadline("video/*", sharedFileCount) + + verify(headlineGenerator, times(1)).getVideosHeadline(sharedFileCount) + verifyInternalHeadlineAbsence(previewView) + verifyPreviewHeadline(headerParent, HEADLINE_VIDEOS) + verifySharedText(previewView) + } + @Test fun test_displayDocsPlusTextWithoutUriMetadata_showFilesHeadline() { val sharedFileCount = 2 @@ -93,6 +117,18 @@ class FilesPlusTextContentPreviewUiTest { verifySharedText(previewView) } + @Test + fun test_displayDocsPlusTextWithoutUriMetadataExternalHeader_showFilesHeadline() { + val sharedFileCount = 2 + val (previewView, headerParent) = + testLoadingExternalHeadline("application/pdf", sharedFileCount) + + verify(headlineGenerator, times(1)).getFilesHeadline(sharedFileCount) + verifyInternalHeadlineAbsence(previewView) + verifyPreviewHeadline(headerParent, HEADLINE_FILES) + verifySharedText(previewView) + } + @Test fun test_displayMixedContentPlusTextWithoutUriMetadata_showFilesHeadline() { val sharedFileCount = 2 @@ -103,6 +139,17 @@ class FilesPlusTextContentPreviewUiTest { verifySharedText(previewView) } + @Test + fun test_displayMixedContentPlusTextWithoutUriMetadataExternalHeader_showFilesHeadline() { + val sharedFileCount = 2 + val (previewView, headerParent) = testLoadingExternalHeadline("*/*", sharedFileCount) + + verify(headlineGenerator, times(1)).getFilesHeadline(sharedFileCount) + verifyInternalHeadlineAbsence(previewView) + verifyPreviewHeadline(headerParent, HEADLINE_FILES) + verifySharedText(previewView) + } + @Test fun test_displayImagesPlusTextWithUriMetadataSet_showImagesHeadline() { val loadedFileMetadata = createFileInfosWithMimeTypes("image/png", "image/jpeg") @@ -114,6 +161,19 @@ class FilesPlusTextContentPreviewUiTest { verifySharedText(previewView) } + @Test + fun test_displayImagesPlusTextWithUriMetadataSetExternalHeader_showImagesHeadline() { + val loadedFileMetadata = createFileInfosWithMimeTypes("image/png", "image/jpeg") + val sharedFileCount = loadedFileMetadata.size + val (previewView, headerParent) = + testLoadingExternalHeadline("image/*", sharedFileCount, loadedFileMetadata) + + verify(headlineGenerator, times(1)).getImagesHeadline(sharedFileCount) + verifyInternalHeadlineAbsence(previewView) + verifyPreviewHeadline(headerParent, HEADLINE_IMAGES) + verifySharedText(previewView) + } + @Test fun test_displayVideosPlusTextWithUriMetadataSet_showVideosHeadline() { val loadedFileMetadata = createFileInfosWithMimeTypes("video/mp4", "video/mp4") @@ -125,6 +185,19 @@ class FilesPlusTextContentPreviewUiTest { verifySharedText(previewView) } + @Test + fun test_displayVideosPlusTextWithUriMetadataSetExternalHeader_showVideosHeadline() { + val loadedFileMetadata = createFileInfosWithMimeTypes("video/mp4", "video/mp4") + val sharedFileCount = loadedFileMetadata.size + val (previewView, headerParent) = + testLoadingExternalHeadline("video/*", sharedFileCount, loadedFileMetadata) + + verify(headlineGenerator, times(1)).getVideosHeadline(sharedFileCount) + verifyInternalHeadlineAbsence(previewView) + verifyPreviewHeadline(headerParent, HEADLINE_VIDEOS) + verifySharedText(previewView) + } + @Test fun test_displayImagesAndVideosPlusTextWithUriMetadataSet_showFilesHeadline() { val loadedFileMetadata = createFileInfosWithMimeTypes("image/png", "video/mp4") @@ -136,6 +209,19 @@ class FilesPlusTextContentPreviewUiTest { verifySharedText(previewView) } + @Test + fun test_displayImagesAndVideosPlusTextWithUriMetadataSetExternalHeader_showFilesHeadline() { + val loadedFileMetadata = createFileInfosWithMimeTypes("image/png", "video/mp4") + val sharedFileCount = loadedFileMetadata.size + val (previewView, headerParent) = + testLoadingExternalHeadline("*/*", sharedFileCount, loadedFileMetadata) + + verify(headlineGenerator, times(1)).getFilesHeadline(sharedFileCount) + verifyInternalHeadlineAbsence(previewView) + verifyPreviewHeadline(headerParent, HEADLINE_FILES) + verifySharedText(previewView) + } + @Test fun test_displayDocsPlusTextWithUriMetadataSet_showFilesHeadline() { val loadedFileMetadata = createFileInfosWithMimeTypes("application/pdf", "application/pdf") @@ -148,6 +234,19 @@ class FilesPlusTextContentPreviewUiTest { verifySharedText(previewView) } + @Test + fun test_displayDocsPlusTextWithUriMetadataSetExternalHeader_showFilesHeadline() { + val loadedFileMetadata = createFileInfosWithMimeTypes("application/pdf", "application/pdf") + val sharedFileCount = loadedFileMetadata.size + val (previewView, headerParent) = + testLoadingExternalHeadline("application/pdf", sharedFileCount, loadedFileMetadata) + + verify(headlineGenerator, times(1)).getFilesHeadline(sharedFileCount) + verifyInternalHeadlineAbsence(previewView) + verifyPreviewHeadline(headerParent, HEADLINE_FILES) + verifySharedText(previewView) + } + @Test fun test_uriMetadataIsMoreSpecificThanIntentMimeType_headlineGetsUpdated() { val sharedFileCount = 2 @@ -180,10 +279,56 @@ class FilesPlusTextContentPreviewUiTest { verifyPreviewHeadline(previewView, HEADLINE_IMAGES) } + @Test + fun test_uriMetadataIsMoreSpecificThanIntentMimeTypeExternalHeader_headlineGetsUpdated() { + val sharedFileCount = 2 + val testSubject = + FilesPlusTextContentPreviewUi( + lifecycleOwner.lifecycle, + /*isSingleImage=*/ false, + sharedFileCount, + SHARED_TEXT, + /*intentMimeType=*/ "*/*", + actionFactory, + imageLoader, + DefaultMimeTypeClassifier, + headlineGenerator + ) + val layoutInflater = LayoutInflater.from(context) + val gridLayout = + layoutInflater.inflate(R.layout.chooser_grid_scrollable_preview, null, false) + as ViewGroup + val externalHeaderView = + gridLayout.requireViewById(R.id.chooser_headline_row_container) + + assertWithMessage("External headline should not be inflated by default") + .that(externalHeaderView.findViewById(R.id.headline)) + .isNull() + + val previewView = + testSubject.display( + context.resources, + LayoutInflater.from(context), + gridLayout, + externalHeaderView + ) + + verify(headlineGenerator, times(1)).getFilesHeadline(sharedFileCount) + verify(headlineGenerator, never()).getImagesHeadline(sharedFileCount) + verifyInternalHeadlineAbsence(previewView) + verifyPreviewHeadline(externalHeaderView, HEADLINE_FILES) + + testSubject.updatePreviewMetadata(createFileInfosWithMimeTypes("image/png", "image/jpg")) + + verify(headlineGenerator, times(1)).getFilesHeadline(sharedFileCount) + verify(headlineGenerator, times(1)).getImagesHeadline(sharedFileCount) + verifyPreviewHeadline(externalHeaderView, HEADLINE_IMAGES) + } + private fun testLoadingHeadline( intentMimeType: String, sharedFileCount: Int, - loadedFileMetadata: List? = null + loadedFileMetadata: List? = null, ): ViewGroup? { val testSubject = FilesPlusTextContentPreviewUi( @@ -209,14 +354,51 @@ class FilesPlusTextContentPreviewUiTest { ) } + private fun testLoadingExternalHeadline( + intentMimeType: String, + sharedFileCount: Int, + loadedFileMetadata: List? = null, + ): Pair { + val testSubject = + FilesPlusTextContentPreviewUi( + lifecycleOwner.lifecycle, + /*isSingleImage=*/ false, + sharedFileCount, + SHARED_TEXT, + intentMimeType, + actionFactory, + imageLoader, + DefaultMimeTypeClassifier, + headlineGenerator + ) + val layoutInflater = LayoutInflater.from(context) + val gridLayout = + layoutInflater.inflate(R.layout.chooser_grid_scrollable_preview, null, false) + as ViewGroup + val externalHeaderView = + gridLayout.requireViewById(R.id.chooser_headline_row_container) + + assertWithMessage("External headline should not be inflated by default") + .that(externalHeaderView.findViewById(R.id.headline)) + .isNull() + + loadedFileMetadata?.let(testSubject::updatePreviewMetadata) + return testSubject.display( + context.resources, + LayoutInflater.from(context), + gridLayout, + externalHeaderView + ) to externalHeaderView + } + private fun createFileInfosWithMimeTypes(vararg mimeTypes: String): List { val uri = Uri.parse("content://pkg.app/file") return mimeTypes.map { mimeType -> FileInfo.Builder(uri).withMimeType(mimeType).build() } } - private fun verifyPreviewHeadline(previewView: ViewGroup?, expectedText: String) { - assertThat(previewView).isNotNull() - val headlineView = previewView?.findViewById(R.id.headline) + private fun verifyPreviewHeadline(headerViewParent: View?, expectedText: String) { + assertThat(headerViewParent).isNotNull() + val headlineView = headerViewParent?.findViewById(R.id.headline) assertThat(headlineView).isNotNull() assertThat(headlineView?.text).isEqualTo(expectedText) } @@ -227,4 +409,13 @@ class FilesPlusTextContentPreviewUiTest { assertThat(textContentView).isNotNull() assertThat(textContentView?.text).isEqualTo(SHARED_TEXT) } + + private fun verifyInternalHeadlineAbsence(previewView: ViewGroup?) { + assertWithMessage("Preview parent should not be null").that(previewView).isNotNull() + assertWithMessage( + "Preview headline should not be inflated when an external headline is used" + ) + .that(previewView?.findViewById(R.id.headline)) + .isNull() + } } diff --git a/java/tests/src/com/android/intentresolver/contentpreview/TextContentPreviewUiTest.kt b/java/tests/src/com/android/intentresolver/contentpreview/TextContentPreviewUiTest.kt index 69053f73..b91ed436 100644 --- a/java/tests/src/com/android/intentresolver/contentpreview/TextContentPreviewUiTest.kt +++ b/java/tests/src/com/android/intentresolver/contentpreview/TextContentPreviewUiTest.kt @@ -17,6 +17,7 @@ package com.android.intentresolver.contentpreview import android.view.LayoutInflater +import android.view.View import android.view.ViewGroup import android.widget.TextView import androidx.lifecycle.testing.TestLifecycleOwner @@ -51,18 +52,19 @@ class TextContentPreviewUiTest { private val context get() = InstrumentationRegistry.getInstrumentation().context + private val testSubject = + TextContentPreviewUi( + lifecycleOwner.lifecycle, + text, + title, + /*previewThumbnail=*/ null, + actionFactory, + imageLoader, + headlineGenerator, + ) + @Test fun test_display_headlineIsDisplayed() { - val testSubject = - TextContentPreviewUi( - lifecycleOwner.lifecycle, - text, - title, - /*previewThumbnail=*/ null, - actionFactory, - imageLoader, - headlineGenerator, - ) val layoutInflater = LayoutInflater.from(context) val gridLayout = layoutInflater.inflate(R.layout.chooser_grid, null, false) as ViewGroup @@ -79,4 +81,26 @@ class TextContentPreviewUiTest { assertThat(headlineView).isNotNull() assertThat(headlineView?.text).isEqualTo(text) } + + @Test + fun test_displayWithExternalHeaderView_externalHeaderIsDisplayed() { + val layoutInflater = LayoutInflater.from(context) + val gridLayout = + layoutInflater.inflate(R.layout.chooser_grid_scrollable_preview, null, false) + as ViewGroup + val externalHeaderView = + gridLayout.requireViewById(R.id.chooser_headline_row_container) + + assertThat(externalHeaderView.findViewById(R.id.headline)).isNull() + + val previewView = + testSubject.display(context.resources, layoutInflater, gridLayout, externalHeaderView) + + assertThat(previewView).isNotNull() + assertThat(previewView.findViewById(R.id.headline)).isNull() + + val headlineView = externalHeaderView.findViewById(R.id.headline) + assertThat(headlineView).isNotNull() + assertThat(headlineView?.text).isEqualTo(text) + } } diff --git a/java/tests/src/com/android/intentresolver/contentpreview/UnifiedContentPreviewUiTest.kt b/java/tests/src/com/android/intentresolver/contentpreview/UnifiedContentPreviewUiTest.kt index 6b22b850..7e07e0ca 100644 --- a/java/tests/src/com/android/intentresolver/contentpreview/UnifiedContentPreviewUiTest.kt +++ b/java/tests/src/com/android/intentresolver/contentpreview/UnifiedContentPreviewUiTest.kt @@ -18,13 +18,17 @@ package com.android.intentresolver.contentpreview import android.net.Uri import android.view.LayoutInflater +import android.view.View import android.view.ViewGroup +import android.widget.TextView import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.platform.app.InstrumentationRegistry.getInstrumentation -import com.android.intentresolver.R.layout.chooser_grid +import com.android.intentresolver.R import com.android.intentresolver.mock import com.android.intentresolver.whenever import com.android.intentresolver.widget.ImagePreviewView.TransitionElementStatusCallback +import com.google.common.truth.Truth +import com.google.common.truth.Truth.assertWithMessage import kotlin.coroutines.EmptyCoroutineContext import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.asFlow @@ -38,6 +42,10 @@ import org.mockito.Mockito.anyInt import org.mockito.Mockito.times import org.mockito.Mockito.verify +private const val IMAGE_HEADLINE = "Image Headline" +private const val VIDEO_HEADLINE = "Video Headline" +private const val FILES_HEADLINE = "Files Headline" + @RunWith(AndroidJUnit4::class) class UnifiedContentPreviewUiTest { private val testScope = TestScope(EmptyCoroutineContext + UnconfinedTestDispatcher()) @@ -48,40 +56,76 @@ class UnifiedContentPreviewUiTest { private val imageLoader = mock() private val headlineGenerator = mock { - whenever(getImagesHeadline(anyInt())).thenReturn("Image Headline") - whenever(getVideosHeadline(anyInt())).thenReturn("Video Headline") - whenever(getFilesHeadline(anyInt())).thenReturn("Files Headline") + whenever(getImagesHeadline(anyInt())).thenReturn(IMAGE_HEADLINE) + whenever(getVideosHeadline(anyInt())).thenReturn(VIDEO_HEADLINE) + whenever(getFilesHeadline(anyInt())).thenReturn(FILES_HEADLINE) } private val context - get() = getInstrumentation().getContext() + get() = getInstrumentation().context @Test fun test_displayImagesWithoutUriMetadata_showImagesHeadline() { - testLoadingHeadline("image/*", files = null) + testLoadingHeadline("image/*", files = null) { previewView -> + verify(headlineGenerator, times(1)).getImagesHeadline(2) + verifyPreviewHeadline(previewView, IMAGE_HEADLINE) + } + } - verify(headlineGenerator, times(1)).getImagesHeadline(2) + @Test + fun test_displayImagesWithoutUriMetadataExternalHeader_showImagesHeadline() { + testLoadingExternalHeadline("image/*", files = null) { externalHeaderView -> + verify(headlineGenerator, times(1)).getImagesHeadline(2) + verifyPreviewHeadline(externalHeaderView, IMAGE_HEADLINE) + } } @Test fun test_displayVideosWithoutUriMetadata_showImagesHeadline() { - testLoadingHeadline("video/*", files = null) + testLoadingHeadline("video/*", files = null) { previewView -> + verify(headlineGenerator, times(1)).getVideosHeadline(2) + verifyPreviewHeadline(previewView, VIDEO_HEADLINE) + } + } - verify(headlineGenerator, times(1)).getVideosHeadline(2) + @Test + fun test_displayVideosWithoutUriMetadataExternalHeader_showImagesHeadline() { + testLoadingExternalHeadline("video/*", files = null) { externalHeaderView -> + verify(headlineGenerator, times(1)).getVideosHeadline(2) + verifyPreviewHeadline(externalHeaderView, VIDEO_HEADLINE) + } } @Test fun test_displayDocumentsWithoutUriMetadata_showImagesHeadline() { - testLoadingHeadline("application/pdf", files = null) + testLoadingHeadline("application/pdf", files = null) { previewView -> + verify(headlineGenerator, times(1)).getFilesHeadline(2) + verifyPreviewHeadline(previewView, FILES_HEADLINE) + } + } - verify(headlineGenerator, times(1)).getFilesHeadline(2) + @Test + fun test_displayDocumentsWithoutUriMetadataExternalHeader_showImagesHeadline() { + testLoadingExternalHeadline("application/pdf", files = null) { externalHeaderView -> + verify(headlineGenerator, times(1)).getFilesHeadline(2) + verifyPreviewHeadline(externalHeaderView, FILES_HEADLINE) + } } @Test fun test_displayMixedContentWithoutUriMetadata_showImagesHeadline() { - testLoadingHeadline("*/*", files = null) + testLoadingHeadline("*/*", files = null) { previewView -> + verify(headlineGenerator, times(1)).getFilesHeadline(2) + verifyPreviewHeadline(previewView, FILES_HEADLINE) + } + } - verify(headlineGenerator, times(1)).getFilesHeadline(2) + @Test + fun test_displayMixedContentWithoutUriMetadataExternalHeader_showImagesHeadline() { + testLoadingExternalHeadline("*/*", files = null) { externalHeader -> + verify(headlineGenerator, times(1)).getFilesHeadline(2) + verifyPreviewHeadline(externalHeader, FILES_HEADLINE) + } } @Test @@ -92,9 +136,24 @@ class UnifiedContentPreviewUiTest { FileInfo.Builder(uri).withMimeType("image/png").build(), FileInfo.Builder(uri).withMimeType("image/jpeg").build(), ) - testLoadingHeadline("image/*", files) + testLoadingHeadline("image/*", files) { preivewView -> + verify(headlineGenerator, times(1)).getImagesHeadline(2) + verifyPreviewHeadline(preivewView, IMAGE_HEADLINE) + } + } - verify(headlineGenerator, times(1)).getImagesHeadline(2) + @Test + fun test_displayImagesWithUriMetadataSetExternalHeader_showImagesHeadline() { + val uri = Uri.parse("content://pkg.app/image.png") + val files = + listOf( + FileInfo.Builder(uri).withMimeType("image/png").build(), + FileInfo.Builder(uri).withMimeType("image/jpeg").build(), + ) + testLoadingExternalHeadline("image/*", files) { externalHeader -> + verify(headlineGenerator, times(1)).getImagesHeadline(2) + verifyPreviewHeadline(externalHeader, IMAGE_HEADLINE) + } } @Test @@ -105,9 +164,24 @@ class UnifiedContentPreviewUiTest { FileInfo.Builder(uri).withMimeType("video/mp4").build(), FileInfo.Builder(uri).withMimeType("video/mp4").build(), ) - testLoadingHeadline("video/*", files) + testLoadingHeadline("video/*", files) { previewView -> + verify(headlineGenerator, times(1)).getVideosHeadline(2) + verifyPreviewHeadline(previewView, VIDEO_HEADLINE) + } + } - verify(headlineGenerator, times(1)).getVideosHeadline(2) + @Test + fun test_displayVideosWithUriMetadataSetExternalHeader_showImagesHeadline() { + val uri = Uri.parse("content://pkg.app/image.png") + val files = + listOf( + FileInfo.Builder(uri).withMimeType("video/mp4").build(), + FileInfo.Builder(uri).withMimeType("video/mp4").build(), + ) + testLoadingExternalHeadline("video/*", files) { externalHeader -> + verify(headlineGenerator, times(1)).getVideosHeadline(2) + verifyPreviewHeadline(externalHeader, VIDEO_HEADLINE) + } } @Test @@ -118,9 +192,24 @@ class UnifiedContentPreviewUiTest { FileInfo.Builder(uri).withMimeType("image/png").build(), FileInfo.Builder(uri).withMimeType("video/mp4").build(), ) - testLoadingHeadline("*/*", files) + testLoadingHeadline("*/*", files) { previewView -> + verify(headlineGenerator, times(1)).getFilesHeadline(2) + verifyPreviewHeadline(previewView, FILES_HEADLINE) + } + } - verify(headlineGenerator, times(1)).getFilesHeadline(2) + @Test + fun test_displayImagesAndVideosWithUriMetadataSetExternalHeader_showImagesHeadline() { + val uri = Uri.parse("content://pkg.app/image.png") + val files = + listOf( + FileInfo.Builder(uri).withMimeType("image/png").build(), + FileInfo.Builder(uri).withMimeType("video/mp4").build(), + ) + testLoadingExternalHeadline("*/*", files) { externalHeader -> + verify(headlineGenerator, times(1)).getFilesHeadline(2) + verifyPreviewHeadline(externalHeader, FILES_HEADLINE) + } } @Test @@ -131,12 +220,31 @@ class UnifiedContentPreviewUiTest { FileInfo.Builder(uri).withMimeType("application/pdf").build(), FileInfo.Builder(uri).withMimeType("application/pdf").build(), ) - testLoadingHeadline("application/pdf", files) + testLoadingHeadline("application/pdf", files) { previewView -> + verify(headlineGenerator, times(1)).getFilesHeadline(2) + verifyPreviewHeadline(previewView, FILES_HEADLINE) + } + } - verify(headlineGenerator, times(1)).getFilesHeadline(2) + @Test + fun test_displayDocumentsWithUriMetadataSetExternalHeader_showImagesHeadline() { + val uri = Uri.parse("content://pkg.app/image.png") + val files = + listOf( + FileInfo.Builder(uri).withMimeType("application/pdf").build(), + FileInfo.Builder(uri).withMimeType("application/pdf").build(), + ) + testLoadingExternalHeadline("application/pdf", files) { externalHeader -> + verify(headlineGenerator, times(1)).getFilesHeadline(2) + verifyPreviewHeadline(externalHeader, FILES_HEADLINE) + } } - private fun testLoadingHeadline(intentMimeType: String, files: List?) { + private fun testLoadingHeadline( + intentMimeType: String, + files: List?, + verificationBlock: (ViewGroup?) -> Unit, + ) { testScope.runTest { val endMarker = FileInfo.Builder(Uri.EMPTY).build() val emptySourceFlow = MutableSharedFlow(replay = 1) @@ -157,15 +265,84 @@ class UnifiedContentPreviewUiTest { headlineGenerator ) val layoutInflater = LayoutInflater.from(context) - val gridLayout = layoutInflater.inflate(chooser_grid, null, false) as ViewGroup + val gridLayout = layoutInflater.inflate(R.layout.chooser_grid, null, false) as ViewGroup - testSubject.display( - context.resources, - LayoutInflater.from(context), - gridLayout, - /*headlineViewParent=*/ null - ) + val previewView = + testSubject.display( + context.resources, + LayoutInflater.from(context), + gridLayout, + /*headlineViewParent=*/ null + ) emptySourceFlow.tryEmit(endMarker) + + verificationBlock(previewView) } } + + private fun testLoadingExternalHeadline( + intentMimeType: String, + files: List?, + verificationBlock: (View?) -> Unit, + ) { + testScope.runTest { + val endMarker = FileInfo.Builder(Uri.EMPTY).build() + val emptySourceFlow = MutableSharedFlow(replay = 1) + val testSubject = + UnifiedContentPreviewUi( + testScope, + /*isSingleImage=*/ false, + intentMimeType, + actionFactory, + imageLoader, + DefaultMimeTypeClassifier, + object : TransitionElementStatusCallback { + override fun onTransitionElementReady(name: String) = Unit + override fun onAllTransitionElementsReady() = Unit + }, + files?.let { it.asFlow() } ?: emptySourceFlow.takeWhile { it !== endMarker }, + /*itemCount=*/ 2, + headlineGenerator + ) + val layoutInflater = LayoutInflater.from(context) + val gridLayout = + layoutInflater.inflate(R.layout.chooser_grid_scrollable_preview, null, false) + as ViewGroup + val externalHeaderView = + gridLayout.requireViewById(R.id.chooser_headline_row_container) + + assertWithMessage("External headline should not be inflated by default") + .that(externalHeaderView.findViewById(R.id.headline)) + .isNull() + + val previewView = + testSubject.display( + context.resources, + LayoutInflater.from(context), + gridLayout, + externalHeaderView, + ) + + emptySourceFlow.tryEmit(endMarker) + + verifyInternalHeadlineAbsence(previewView) + verificationBlock(externalHeaderView) + } + } + + private fun verifyPreviewHeadline(headerViewParent: View?, expectedText: String) { + Truth.assertThat(headerViewParent).isNotNull() + val headlineView = headerViewParent?.findViewById(R.id.headline) + Truth.assertThat(headlineView).isNotNull() + Truth.assertThat(headlineView?.text).isEqualTo(expectedText) + } + + private fun verifyInternalHeadlineAbsence(previewView: ViewGroup?) { + assertWithMessage("Preview parent should not be null").that(previewView).isNotNull() + assertWithMessage( + "Preview headline should not be inflated when an external headline is used" + ) + .that(previewView?.findViewById(R.id.headline)) + .isNull() + } } -- cgit v1.2.3-59-g8ed1b From fb8126b8fc336d681ba2ded6214d90a6f113b92e Mon Sep 17 00:00:00 2001 From: Joshua Trask Date: Mon, 25 Sep 2023 17:29:32 +0000 Subject: For consistency, stop throttling "list ready" CBs. This change is best understood through the newly-introduced `testPostListReadyAtEndOfRebuild_` tests: 1. The `synchronous` and `stages` variants pass both before and after this change, to show the expected "normal" behavior. 2. The `queued` variant exercises the hypothetical bug that motivated this change; it only passes once the legacy "throttling" condition is removed in this CL. 3. The `skippedIfStillQueuedOnDestroy` variant covers a side-effect of the fix. The original implementation had logic for this "skipping" condition (and presumably would've passed this new test as-written), but because the fix relaxes the invariant where we can have only a single "active" callback instance at a time, I also had to introduce the new `mDestroyed` condition to effect the same high-level "cancellation" behavior. (This also addresses a minor theoretical bug where the "destroy" could race against our internal two-stage asynchronous flow, such that we'd end up posting the follow-up callback to the handler after we'd supposedly already been destroyed. I didn't think it was important to test for this bug separately from the other coverage of the `mDestroyed` condition.) -- This CL removes a "throttling" condition that was unneeded, but which could cause a hypothetical bug (reproducible in tests). The original condition prevented list-ready messages if we'd already had one posted in the queue (but not yet processed); however, there was no check that the messages had the same parameters to indicate "partial" vs. "complete" progress. Since the legacy mechanism favored the earlier messages, we could end up dropping the one-off "completion" message where our listener actually does work -- i.e., if we had to drop messages, this is exactly the one we would want *not* to drop. I believe this "throttling" mechanism was likely an optimization to support the legacy `ChooserTargetService` design; the simplified newer design requests fewer callbacks and so the throttling should rarely come into play (but presents a bigger risk whenever it might). Even in the older design, I suspect there would've been a risk of the same "dropped completion" bug. And, of course, it's nice to "simplify" by removing this condition, even if it *weren't* strictly harmful. Update: the second snapshot removes the old "callback removal" on destroy, since the new `mDestroyed` condition gives effectively the same behavior. Technically that's the only reason we depended on `Handler` and we could now switch to using an `Executor` or etc -- but I definitely want to keep that update to a separate CL. Bug: as described above Test: IntentResolverUnitTests, CtsSharesheetDeviceTest Change-Id: Ifda9dc9a8ac8512d241e15fe52f24c3dea5bd9e7 --- .../intentresolver/ResolverListAdapter.java | 29 ++- .../intentresolver/ResolverListAdapterTest.kt | 256 +++++++++++++++++++++ 2 files changed, 270 insertions(+), 15 deletions(-) (limited to 'java/src') diff --git a/java/src/com/android/intentresolver/ResolverListAdapter.java b/java/src/com/android/intentresolver/ResolverListAdapter.java index a243ac2a..4ad8d926 100644 --- a/java/src/com/android/intentresolver/ResolverListAdapter.java +++ b/java/src/com/android/intentresolver/ResolverListAdapter.java @@ -58,6 +58,7 @@ import java.util.HashSet; import java.util.List; import java.util.Set; import java.util.concurrent.Executor; +import java.util.concurrent.atomic.AtomicBoolean; public class ResolverListAdapter extends BaseAdapter { private static final String TAG = "ResolverListAdapter"; @@ -82,6 +83,7 @@ public class ResolverListAdapter extends BaseAdapter { private final Set mRequestedLabels = new HashSet<>(); private final Executor mBgExecutor; private final Handler mMainHandler; + private final AtomicBoolean mDestroyed = new AtomicBoolean(); private ResolveInfo mLastChosen; private DisplayResolveInfo mOtherProfile; @@ -93,7 +95,6 @@ public class ResolverListAdapter extends BaseAdapter { private int mLastChosenPosition = -1; private final boolean mFilterLastUsed; - private Runnable mPostListReadyRunnable; private boolean mIsTabLoaded; // Represents the UserSpace in which the Initial Intents should be resolved. private final UserHandle mInitialIntentsUserSpace; @@ -546,17 +547,17 @@ public class ResolverListAdapter extends BaseAdapter { * @param rebuildCompleted Whether the list has been completely rebuilt */ void postListReadyRunnable(boolean doPostProcessing, boolean rebuildCompleted) { - if (mPostListReadyRunnable == null) { - mPostListReadyRunnable = new Runnable() { - @Override - public void run() { - mResolverListCommunicator.onPostListReady(ResolverListAdapter.this, - doPostProcessing, rebuildCompleted); - mPostListReadyRunnable = null; + Runnable listReadyRunnable = new Runnable() { + @Override + public void run() { + if (mDestroyed.get()) { + return; } - }; - mMainHandler.post(mPostListReadyRunnable); - } + mResolverListCommunicator.onPostListReady(ResolverListAdapter.this, + doPostProcessing, rebuildCompleted); + } + }; + mMainHandler.post(listReadyRunnable); } private void addResolveInfoWithAlternates(ResolvedComponentInfo rci) { @@ -772,10 +773,8 @@ public class ResolverListAdapter extends BaseAdapter { } public void onDestroy() { - if (mPostListReadyRunnable != null) { - mMainHandler.removeCallbacks(mPostListReadyRunnable); - mPostListReadyRunnable = null; - } + mDestroyed.set(true); + if (mResolverListController != null) { mResolverListController.destroy(); } diff --git a/java/tests/src/com/android/intentresolver/ResolverListAdapterTest.kt b/java/tests/src/com/android/intentresolver/ResolverListAdapterTest.kt index a5fe6c47..0f381aac 100644 --- a/java/tests/src/com/android/intentresolver/ResolverListAdapterTest.kt +++ b/java/tests/src/com/android/intentresolver/ResolverListAdapterTest.kt @@ -22,15 +22,22 @@ import android.content.Intent import android.content.pm.ActivityInfo import android.content.pm.ApplicationInfo import android.content.pm.ResolveInfo +import android.os.Handler +import android.os.Looper import android.os.UserHandle import android.view.LayoutInflater +import com.android.intentresolver.ResolverListAdapter.ResolverListCommunicator import com.android.intentresolver.icons.TargetDataLoader import com.android.intentresolver.util.TestExecutor import com.android.intentresolver.util.TestImmediateHandler import com.google.common.truth.Truth.assertThat +import java.util.concurrent.CountDownLatch import java.util.concurrent.atomic.AtomicInteger import org.junit.Test import org.mockito.Mockito.anyBoolean +import org.mockito.Mockito.inOrder +import org.mockito.Mockito.never +import org.mockito.Mockito.verify private const val PKG_NAME = "org.pkg.app" private const val PKG_NAME_TWO = "org.pkgtwo.app" @@ -663,6 +670,255 @@ class ResolverListAdapterTest { assertThat(testSubject.unfilteredResolveList).hasSize(2) } + @Test + fun testPostListReadyAtEndOfRebuild_synchronous() { + val communicator = mock {} + val testSubject = + ResolverListAdapter( + context, + payloadIntents, + /*initialIntents=*/ null, + /*rList=*/ null, + /*filterLastUsed=*/ true, + resolverListController, + userHandle, + targetIntent, + communicator, + /*initialIntentsUserSpace=*/ userHandle, + targetDataLoader, + executor, + testHandler, + ) + val doPostProcessing = false + + executor.runUntilIdle() + + testSubject.rebuildList(doPostProcessing) + verify(communicator).onPostListReady(testSubject, doPostProcessing, true) + } + + @Test + fun testPostListReadyAtEndOfRebuild_stages() { + // We need at least two targets to trigger asynchronous sorting/"staged" progress callbacks. + val resolvedTargets = + createResolvedComponents( + ComponentName(PKG_NAME, CLASS_NAME), + ComponentName(PKG_NAME_TWO, CLASS_NAME), + ) + // TODO: there's a lot of boilerplate required for this test even to trigger the expected + // conditions; if the configuration is incorrect, the test may accidentally pass for the + // wrong reasons. Separating responsibilities to other components will help minimize the + // *amount* of boilerplate, but we should also consider setting up test defaults that work + // according to our usual expectations so that we don't overlook false-negative results. + whenever( + resolverListController.getResolversForIntentAsUser( + any(), + any(), + any(), + any(), + any(), + ) + ) + .thenReturn(resolvedTargets) + val communicator = + mock { + whenever(getReplacementIntent(any(), any())).thenAnswer { invocation -> + invocation.arguments[1] + } + } + val testSubject = + ResolverListAdapter( + context, + payloadIntents, + /*initialIntents=*/ null, + /*rList=*/ null, + /*filterLastUsed=*/ true, + resolverListController, + userHandle, + targetIntent, + communicator, + /*initialIntentsUserSpace=*/ userHandle, + targetDataLoader, + executor, + testHandler, + ) + val doPostProcessing = false + + testSubject.rebuildList(doPostProcessing) + + executor.runUntilIdle() + + val inOrder = inOrder(communicator) + inOrder.verify(communicator).onPostListReady(testSubject, doPostProcessing, false) + inOrder.verify(communicator).onPostListReady(testSubject, doPostProcessing, true) + } + + @Test + fun testPostListReadyAtEndOfRebuild_queued() { + // Set up a runnable that blocks the callback handler until we're ready to start + // processing queued messages. This is to test against a legacy bug where subsequent + // list-ready notifications could be dropped if the older one wasn't yet dequeued, even if + // the newer ones had different parameters. + // TODO: after removing the logic responsible for this "dropping" we could migrate off the + // `Handler` API (to `Executor` or similar) and use a simpler mechanism to test. + val callbackHandler = Handler(Looper.getMainLooper()) + val unblockCallbacksSignal = CountDownLatch(1) + val countdownBlockingRunnable = Runnable { unblockCallbacksSignal.await() } + callbackHandler.post(countdownBlockingRunnable) + + // We need at least two targets to trigger asynchronous sorting/"staged" progress callbacks. + val resolvedTargets = + createResolvedComponents( + ComponentName(PKG_NAME, CLASS_NAME), + ComponentName(PKG_NAME_TWO, CLASS_NAME), + ) + // TODO: there's a lot of boilerplate required for this test even to trigger the expected + // conditions; if the configuration is incorrect, the test may accidentally pass for the + // wrong reasons. Separating responsibilities to other components will help minimize the + // *amount* of boilerplate, but we should also consider setting up test defaults that work + // according to our usual expectations so that we don't overlook false-negative results. + whenever( + resolverListController.getResolversForIntentAsUser( + any(), + any(), + any(), + any(), + any(), + ) + ) + .thenReturn(resolvedTargets) + val communicator = + mock { + whenever(getReplacementIntent(any(), any())).thenAnswer { invocation -> + invocation.arguments[1] + } + } + val testSubject = + ResolverListAdapter( + context, + payloadIntents, + /*initialIntents=*/ null, + /*rList=*/ null, + /*filterLastUsed=*/ true, + resolverListController, + userHandle, + targetIntent, + communicator, + /*initialIntentsUserSpace=*/ userHandle, + targetDataLoader, + executor, + callbackHandler + ) + val doPostProcessing = false + testSubject.rebuildList(doPostProcessing) + + // Finish all the background work (enqueueing both the "partial" and "complete" progress + // callbacks) before dequeueing either callback. + executor.runUntilIdle() + + // Allow the handler to flush out and process both callbacks. + unblockCallbacksSignal.countDown() + + // Finally, force a synchronization in the other direction to ensure that we've finished + // processing any callbacks before we start making assertions about them. + // TODO: there are less "ad-hoc" ways to write this (e.g. with a special `Handler` subclass + // for tests), but we should switch off using `Handler` altogether (in favor of `Executor` + // or otherwise), and then it'll be much simpler to clean up this boilerplate. + val unblockAssertionsSignal = CountDownLatch(1) + val countdownAssertionsRunnable = Runnable { unblockAssertionsSignal.countDown() } + callbackHandler.post(countdownAssertionsRunnable) + unblockAssertionsSignal.await() + + // TODO: we may not necessarily care to assert that there's a "partial progress" callback in + // this case, since there won't be a chance to reflect the "partial" state in the UI before + // the "completion" is queued (and if we depend on seeing an intermediate state, that could + // be a bad sign for our handling in the "synchronous" case?). But we should probably at + // least assert that the "partial" callback never arrives *after* the completion? + val inOrder = inOrder(communicator) + inOrder.verify(communicator).onPostListReady(testSubject, doPostProcessing, false) + inOrder.verify(communicator).onPostListReady(testSubject, doPostProcessing, true) + } + + @Test + fun testPostListReadyAtEndOfRebuild_skippedIfStillQueuedOnDestroy() { + // Set up a runnable that blocks the callback handler until we're ready to start + // processing queued messages. + // TODO: after removing the logic responsible for this "dropping" we could migrate off the + // `Handler` API (to `Executor` or similar) and use a simpler mechanism to test. + val callbackHandler = Handler(Looper.getMainLooper()) + val unblockCallbacksSignal = CountDownLatch(1) + val countdownBlockingRunnable = Runnable { unblockCallbacksSignal.await() } + callbackHandler.post(countdownBlockingRunnable) + + // We need at least two targets to trigger asynchronous sorting/"staged" progress callbacks. + val resolvedTargets = + createResolvedComponents( + ComponentName(PKG_NAME, CLASS_NAME), + ComponentName(PKG_NAME_TWO, CLASS_NAME), + ) + // TODO: there's a lot of boilerplate required for this test even to trigger the expected + // conditions; if the configuration is incorrect, the test may accidentally pass for the + // wrong reasons. Separating responsibilities to other components will help minimize the + // *amount* of boilerplate, but we should also consider setting up test defaults that work + // according to our usual expectations so that we don't overlook false-negative results. + whenever( + resolverListController.getResolversForIntentAsUser( + any(), + any(), + any(), + any(), + any(), + ) + ) + .thenReturn(resolvedTargets) + val communicator = + mock { + whenever(getReplacementIntent(any(), any())).thenAnswer { invocation -> + invocation.arguments[1] + } + } + val testSubject = + ResolverListAdapter( + context, + payloadIntents, + /*initialIntents=*/ null, + /*rList=*/ null, + /*filterLastUsed=*/ true, + resolverListController, + userHandle, + targetIntent, + communicator, + /*initialIntentsUserSpace=*/ userHandle, + targetDataLoader, + executor, + callbackHandler + ) + val doPostProcessing = false + testSubject.rebuildList(doPostProcessing) + + // Finish all the background work (enqueueing both the "partial" and "complete" progress + // callbacks) before dequeueing either callback. + executor.runUntilIdle() + + // Notify that our activity is being destroyed while the callbacks are still queued. + testSubject.onDestroy() + + // Allow the handler to flush out, but now the callbacks are gone. + unblockCallbacksSignal.countDown() + + // Finally, force a synchronization in the other direction to ensure that we've finished + // processing any callbacks before we start making assertions about them. + // TODO: there are less "ad-hoc" ways to write this (e.g. with a special `Handler` subclass + // for tests), but we should switch off using `Handler` altogether (in favor of `Executor` + // or otherwise), and then it'll be much simpler to clean up this boilerplate. + val unblockAssertionsSignal = CountDownLatch(1) + val countdownAssertionsRunnable = Runnable { unblockAssertionsSignal.countDown() } + callbackHandler.post(countdownAssertionsRunnable) + unblockAssertionsSignal.await() + + verify(communicator, never()).onPostListReady(eq(testSubject), eq(doPostProcessing), any()) + } + private fun createResolvedComponents( vararg components: ComponentName ): List { -- cgit v1.2.3-59-g8ed1b From c2fcb4099bd4fad19d9bcff4ca197f0ab0c904a2 Mon Sep 17 00:00:00 2001 From: Joshua Trask Date: Tue, 26 Sep 2023 13:53:36 +0000 Subject: ResolverListAdapter: Switch `Handler`->`Executor` With the new `mDestroyed` bookkeeping in ag/24854314, we no longer need the richer (but less-testable/etc) API of a `Handler`. In the first snapshot, this retains the ad-hoc flow control mechanisms that direct the `testPostListReadyAtEndOfRebuild_` tests to show the direct before-and-after equivalence against the `Handler` model. All the existing tests still pass on the new code. The second snapshot removes those ad-hoc mechanisms because the tests now have complete control of the execution via `TestExecutor`. Bug: (general code cleanup) Test: `IntentResolverUnitTests` / `CtsSharesheetDeviceTest` Change-Id: I3063d45aedec47a81bf9bc7f76c26873de1383af --- .../intentresolver/ResolverListAdapter.java | 23 ++-- .../intentresolver/ResolverListAdapterTest.kt | 153 ++++++++------------- 2 files changed, 65 insertions(+), 111 deletions(-) (limited to 'java/src') diff --git a/java/src/com/android/intentresolver/ResolverListAdapter.java b/java/src/com/android/intentresolver/ResolverListAdapter.java index 4ad8d926..0d199fa3 100644 --- a/java/src/com/android/intentresolver/ResolverListAdapter.java +++ b/java/src/com/android/intentresolver/ResolverListAdapter.java @@ -28,7 +28,6 @@ import android.graphics.ColorMatrix; import android.graphics.ColorMatrixColorFilter; import android.graphics.drawable.Drawable; import android.os.AsyncTask; -import android.os.Handler; import android.os.RemoteException; import android.os.Trace; import android.os.UserHandle; @@ -82,7 +81,7 @@ public class ResolverListAdapter extends BaseAdapter { private final Set mRequestedIcons = new HashSet<>(); private final Set mRequestedLabels = new HashSet<>(); private final Executor mBgExecutor; - private final Handler mMainHandler; + private final Executor mCallbackExecutor; private final AtomicBoolean mDestroyed = new AtomicBoolean(); private ResolveInfo mLastChosen; @@ -124,7 +123,7 @@ public class ResolverListAdapter extends BaseAdapter { initialIntentsUserSpace, targetDataLoader, AsyncTask.SERIAL_EXECUTOR, - context.getMainThreadHandler()); + runnable -> context.getMainThreadHandler().post(runnable)); } @VisibleForTesting @@ -141,7 +140,7 @@ public class ResolverListAdapter extends BaseAdapter { UserHandle initialIntentsUserSpace, TargetDataLoader targetDataLoader, Executor bgExecutor, - Handler mainHandler) { + Executor callbackExecutor) { mContext = context; mIntents = payloadIntents; mInitialIntents = initialIntents; @@ -157,7 +156,7 @@ public class ResolverListAdapter extends BaseAdapter { mResolverListCommunicator = resolverListCommunicator; mInitialIntentsUserSpace = initialIntentsUserSpace; mBgExecutor = bgExecutor; - mMainHandler = mainHandler; + mCallbackExecutor = callbackExecutor; } public final DisplayResolveInfo getFirstDisplayResolveInfo() { @@ -236,12 +235,12 @@ public class ResolverListAdapter extends BaseAdapter { /** * Rebuild the list of resolvers. When rebuilding is complete, queue the {@code onPostListReady} - * callback on the main handler with {@code rebuildCompleted} true. + * callback on the callback executor with {@code rebuildCompleted} true. * * In some cases some parts will need some asynchronous work to complete. Then this will first - * immediately queue {@code onPostListReady} (on the main handler) with {@code rebuildCompleted} - * false; only when the asynchronous work completes will this then go on to queue another - * {@code onPostListReady} callback with {@code rebuildCompleted} true. + * immediately queue {@code onPostListReady} (on the callback executor) with + * {@code rebuildCompleted} false; only when the asynchronous work completes will this then go + * on to queue another {@code onPostListReady} callback with {@code rebuildCompleted} true. * * The {@code doPostProcessing} parameter is used to specify whether to update the UI and * load additional targets (e.g. direct share) after the list has been rebuilt. We may choose @@ -456,7 +455,7 @@ public class ResolverListAdapter extends BaseAdapter { throw t; } finally { final List result = sortedComponents; - mMainHandler.post(() -> onComponentsSorted(result, doPostProcessing)); + mCallbackExecutor.execute(() -> onComponentsSorted(result, doPostProcessing)); } }); return false; @@ -541,7 +540,7 @@ public class ResolverListAdapter extends BaseAdapter { /** * Some necessary methods for creating the list are initiated in onCreate and will also * determine the layout known. We therefore can't update the UI inline and post to the - * handler thread to update after the current task is finished. + * callback executor to update after the current task is finished. * @param doPostProcessing Whether to update the UI and load additional direct share targets * after the list has been rebuilt * @param rebuildCompleted Whether the list has been completely rebuilt @@ -557,7 +556,7 @@ public class ResolverListAdapter extends BaseAdapter { doPostProcessing, rebuildCompleted); } }; - mMainHandler.post(listReadyRunnable); + mCallbackExecutor.execute(listReadyRunnable); } private void addResolveInfoWithAlternates(ResolvedComponentInfo rci) { diff --git a/java/tests/src/com/android/intentresolver/ResolverListAdapterTest.kt b/java/tests/src/com/android/intentresolver/ResolverListAdapterTest.kt index 0f381aac..53c90199 100644 --- a/java/tests/src/com/android/intentresolver/ResolverListAdapterTest.kt +++ b/java/tests/src/com/android/intentresolver/ResolverListAdapterTest.kt @@ -22,16 +22,12 @@ import android.content.Intent import android.content.pm.ActivityInfo import android.content.pm.ApplicationInfo import android.content.pm.ResolveInfo -import android.os.Handler -import android.os.Looper import android.os.UserHandle import android.view.LayoutInflater import com.android.intentresolver.ResolverListAdapter.ResolverListCommunicator import com.android.intentresolver.icons.TargetDataLoader import com.android.intentresolver.util.TestExecutor -import com.android.intentresolver.util.TestImmediateHandler import com.google.common.truth.Truth.assertThat -import java.util.concurrent.CountDownLatch import java.util.concurrent.atomic.AtomicInteger import org.junit.Test import org.mockito.Mockito.anyBoolean @@ -44,12 +40,10 @@ private const val PKG_NAME_TWO = "org.pkgtwo.app" private const val CLASS_NAME = "org.pkg.app.TheClass" class ResolverListAdapterTest { - private val testHandler = TestImmediateHandler() private val layoutInflater = mock() private val context = mock { whenever(getSystemService(Context.LAYOUT_INFLATER_SERVICE)).thenReturn(layoutInflater) - whenever(mainThreadHandler).thenReturn(testHandler) } private val targetIntent = Intent(Intent.ACTION_SEND) private val payloadIntents = listOf(targetIntent) @@ -61,7 +55,8 @@ class ResolverListAdapterTest { private val resolverListCommunicator = FakeResolverListCommunicator() private val userHandle = UserHandle.of(0) private val targetDataLoader = mock() - private val executor = TestExecutor() + private val backgroundExecutor = TestExecutor() + private val immediateExecutor = TestExecutor(immediate = true) @Test fun test_oneTargetNoLastChosen_oneTargetInAdapter() { @@ -89,8 +84,8 @@ class ResolverListAdapterTest { resolverListCommunicator, /*initialIntentsUserSpace=*/ userHandle, targetDataLoader, - executor, - testHandler, + backgroundExecutor, + immediateExecutor, ) val doPostProcessing = true @@ -104,7 +99,7 @@ class ResolverListAdapterTest { assertThat(testSubject.filteredPosition).isLessThan(0) assertThat(testSubject.unfilteredResolveList).containsExactlyElementsIn(resolvedTargets) assertThat(testSubject.isTabLoaded).isTrue() - assertThat(executor.pendingCommandCount).isEqualTo(0) + assertThat(backgroundExecutor.pendingCommandCount).isEqualTo(0) assertThat(resolverListCommunicator.updateProfileViewButtonCount).isEqualTo(0) assertThat(resolverListCommunicator.sendVoiceCommandCount).isEqualTo(1) } @@ -137,8 +132,8 @@ class ResolverListAdapterTest { resolverListCommunicator, /*initialIntentsUserSpace=*/ userHandle, targetDataLoader, - executor, - testHandler, + backgroundExecutor, + immediateExecutor, ) val doPostProcessing = true @@ -152,7 +147,7 @@ class ResolverListAdapterTest { assertThat(testSubject.filteredPosition).isEqualTo(0) assertThat(testSubject.unfilteredResolveList).containsExactlyElementsIn(resolvedTargets) assertThat(testSubject.isTabLoaded).isTrue() - assertThat(executor.pendingCommandCount).isEqualTo(0) + assertThat(backgroundExecutor.pendingCommandCount).isEqualTo(0) } @Test @@ -183,8 +178,8 @@ class ResolverListAdapterTest { resolverListCommunicator, /*initialIntentsUserSpace=*/ userHandle, targetDataLoader, - executor, - testHandler, + backgroundExecutor, + immediateExecutor, ) val doPostProcessing = true @@ -198,7 +193,7 @@ class ResolverListAdapterTest { assertThat(testSubject.filteredPosition).isLessThan(0) assertThat(testSubject.unfilteredResolveList).containsExactlyElementsIn(resolvedTargets) assertThat(testSubject.isTabLoaded).isTrue() - assertThat(executor.pendingCommandCount).isEqualTo(0) + assertThat(backgroundExecutor.pendingCommandCount).isEqualTo(0) } @Test @@ -229,8 +224,8 @@ class ResolverListAdapterTest { resolverListCommunicator, /*initialIntentsUserSpace=*/ userHandle, targetDataLoader, - executor, - testHandler, + backgroundExecutor, + immediateExecutor, ) val doPostProcessing = true @@ -301,8 +296,8 @@ class ResolverListAdapterTest { resolverListCommunicator, /*initialIntentsUserSpace=*/ userHandle, targetDataLoader, - executor, - testHandler, + backgroundExecutor, + immediateExecutor, ) val doPostProcessing = true @@ -317,11 +312,11 @@ class ResolverListAdapterTest { assertThat(testSubject.filteredPosition).isLessThan(0) assertThat(testSubject.unfilteredResolveList).containsExactlyElementsIn(resolvedTargets) assertThat(testSubject.isTabLoaded).isFalse() - assertThat(executor.pendingCommandCount).isEqualTo(1) + assertThat(backgroundExecutor.pendingCommandCount).isEqualTo(1) assertThat(resolverListCommunicator.updateProfileViewButtonCount).isEqualTo(0) assertThat(resolverListCommunicator.sendVoiceCommandCount).isEqualTo(0) - executor.runUntilIdle() + backgroundExecutor.runUntilIdle() // we don't reset placeholder count (legacy logic, likely an oversight?) assertThat(testSubject.placeholderCount).isEqualTo(placeholderCount) @@ -339,7 +334,7 @@ class ResolverListAdapterTest { assertThat(testSubject.isTabLoaded).isTrue() assertThat(resolverListCommunicator.updateProfileViewButtonCount).isEqualTo(1) assertThat(resolverListCommunicator.sendVoiceCommandCount).isEqualTo(1) - assertThat(executor.pendingCommandCount).isEqualTo(0) + assertThat(backgroundExecutor.pendingCommandCount).isEqualTo(0) } @Test @@ -374,8 +369,8 @@ class ResolverListAdapterTest { resolverListCommunicator, /*initialIntentsUserSpace=*/ userHandle, targetDataLoader, - executor, - testHandler, + backgroundExecutor, + immediateExecutor, ) val doPostProcessing = false @@ -390,10 +385,10 @@ class ResolverListAdapterTest { assertThat(testSubject.filteredPosition).isLessThan(0) assertThat(testSubject.unfilteredResolveList).containsExactlyElementsIn(resolvedTargets) assertThat(testSubject.isTabLoaded).isFalse() - assertThat(executor.pendingCommandCount).isEqualTo(1) + assertThat(backgroundExecutor.pendingCommandCount).isEqualTo(1) assertThat(resolverListCommunicator.updateProfileViewButtonCount).isEqualTo(0) - executor.runUntilIdle() + backgroundExecutor.runUntilIdle() // we don't reset placeholder count (legacy logic, likely an oversight?) assertThat(testSubject.placeholderCount).isEqualTo(placeholderCount) @@ -404,7 +399,7 @@ class ResolverListAdapterTest { assertThat(testSubject.unfilteredResolveList).containsExactlyElementsIn(resolvedTargets) assertThat(testSubject.isTabLoaded).isTrue() assertThat(resolverListCommunicator.updateProfileViewButtonCount).isEqualTo(0) - assertThat(executor.pendingCommandCount).isEqualTo(0) + assertThat(backgroundExecutor.pendingCommandCount).isEqualTo(0) } @Test @@ -441,8 +436,8 @@ class ResolverListAdapterTest { resolverListCommunicator, /*initialIntentsUserSpace=*/ userHandle, targetDataLoader, - executor, - testHandler, + backgroundExecutor, + immediateExecutor, ) val doPostProcessing = true @@ -457,7 +452,7 @@ class ResolverListAdapterTest { assertThat(testSubject.filteredPosition).isLessThan(0) assertThat(testSubject.unfilteredResolveList).containsExactlyElementsIn(resolvedTargets) assertThat(testSubject.isTabLoaded).isTrue() - assertThat(executor.pendingCommandCount).isEqualTo(0) + assertThat(backgroundExecutor.pendingCommandCount).isEqualTo(0) } @Suppress("UNCHECKED_CAST") @@ -496,14 +491,14 @@ class ResolverListAdapterTest { resolverListCommunicator, /*initialIntentsUserSpace=*/ userHandle, targetDataLoader, - executor, - testHandler, + backgroundExecutor, + immediateExecutor, ) val doPostProcessing = true testSubject.rebuildList(doPostProcessing) - executor.runUntilIdle() + backgroundExecutor.runUntilIdle() // we don't reset placeholder count (legacy logic, likely an oversight?) assertThat(testSubject.count).isEqualTo(resolvedTargets.size) @@ -551,14 +546,14 @@ class ResolverListAdapterTest { resolverListCommunicator, /*initialIntentsUserSpace=*/ userHandle, targetDataLoader, - executor, - testHandler, + backgroundExecutor, + immediateExecutor, ) val doPostProcessing = true testSubject.rebuildList(doPostProcessing) - executor.runUntilIdle() + backgroundExecutor.runUntilIdle() // we don't reset placeholder count (legacy logic, likely an oversight?) assertThat(testSubject.count).isEqualTo(1) @@ -602,14 +597,14 @@ class ResolverListAdapterTest { resolverListCommunicator, /*initialIntentsUserSpace=*/ userHandle, targetDataLoader, - executor, - testHandler, + backgroundExecutor, + immediateExecutor, ) val doPostProcessing = true testSubject.rebuildList(doPostProcessing) - executor.runUntilIdle() + backgroundExecutor.runUntilIdle() // we don't reset placeholder count (legacy logic, likely an oversight?) assertThat(testSubject.count).isEqualTo(2) @@ -654,14 +649,14 @@ class ResolverListAdapterTest { resolverListCommunicator, /*initialIntentsUserSpace=*/ userHandle, targetDataLoader, - executor, - testHandler, + backgroundExecutor, + immediateExecutor, ) val doPostProcessing = true testSubject.rebuildList(doPostProcessing) - executor.runUntilIdle() + backgroundExecutor.runUntilIdle() // we don't reset placeholder count (legacy logic, likely an oversight?) assertThat(testSubject.count).isEqualTo(1) @@ -686,14 +681,13 @@ class ResolverListAdapterTest { communicator, /*initialIntentsUserSpace=*/ userHandle, targetDataLoader, - executor, - testHandler, + backgroundExecutor, + immediateExecutor, ) val doPostProcessing = false - executor.runUntilIdle() - testSubject.rebuildList(doPostProcessing) + verify(communicator).onPostListReady(testSubject, doPostProcessing, true) } @@ -739,14 +733,14 @@ class ResolverListAdapterTest { communicator, /*initialIntentsUserSpace=*/ userHandle, targetDataLoader, - executor, - testHandler, + backgroundExecutor, + immediateExecutor, ) val doPostProcessing = false testSubject.rebuildList(doPostProcessing) - executor.runUntilIdle() + backgroundExecutor.runUntilIdle() val inOrder = inOrder(communicator) inOrder.verify(communicator).onPostListReady(testSubject, doPostProcessing, false) @@ -755,16 +749,7 @@ class ResolverListAdapterTest { @Test fun testPostListReadyAtEndOfRebuild_queued() { - // Set up a runnable that blocks the callback handler until we're ready to start - // processing queued messages. This is to test against a legacy bug where subsequent - // list-ready notifications could be dropped if the older one wasn't yet dequeued, even if - // the newer ones had different parameters. - // TODO: after removing the logic responsible for this "dropping" we could migrate off the - // `Handler` API (to `Executor` or similar) and use a simpler mechanism to test. - val callbackHandler = Handler(Looper.getMainLooper()) - val unblockCallbacksSignal = CountDownLatch(1) - val countdownBlockingRunnable = Runnable { unblockCallbacksSignal.await() } - callbackHandler.post(countdownBlockingRunnable) + val queuedCallbacksExecutor = TestExecutor() // We need at least two targets to trigger asynchronous sorting/"staged" progress callbacks. val resolvedTargets = @@ -806,28 +791,16 @@ class ResolverListAdapterTest { communicator, /*initialIntentsUserSpace=*/ userHandle, targetDataLoader, - executor, - callbackHandler + backgroundExecutor, + queuedCallbacksExecutor ) val doPostProcessing = false testSubject.rebuildList(doPostProcessing) // Finish all the background work (enqueueing both the "partial" and "complete" progress // callbacks) before dequeueing either callback. - executor.runUntilIdle() - - // Allow the handler to flush out and process both callbacks. - unblockCallbacksSignal.countDown() - - // Finally, force a synchronization in the other direction to ensure that we've finished - // processing any callbacks before we start making assertions about them. - // TODO: there are less "ad-hoc" ways to write this (e.g. with a special `Handler` subclass - // for tests), but we should switch off using `Handler` altogether (in favor of `Executor` - // or otherwise), and then it'll be much simpler to clean up this boilerplate. - val unblockAssertionsSignal = CountDownLatch(1) - val countdownAssertionsRunnable = Runnable { unblockAssertionsSignal.countDown() } - callbackHandler.post(countdownAssertionsRunnable) - unblockAssertionsSignal.await() + backgroundExecutor.runUntilIdle() + queuedCallbacksExecutor.runUntilIdle() // TODO: we may not necessarily care to assert that there's a "partial progress" callback in // this case, since there won't be a chance to reflect the "partial" state in the UI before @@ -841,14 +814,7 @@ class ResolverListAdapterTest { @Test fun testPostListReadyAtEndOfRebuild_skippedIfStillQueuedOnDestroy() { - // Set up a runnable that blocks the callback handler until we're ready to start - // processing queued messages. - // TODO: after removing the logic responsible for this "dropping" we could migrate off the - // `Handler` API (to `Executor` or similar) and use a simpler mechanism to test. - val callbackHandler = Handler(Looper.getMainLooper()) - val unblockCallbacksSignal = CountDownLatch(1) - val countdownBlockingRunnable = Runnable { unblockCallbacksSignal.await() } - callbackHandler.post(countdownBlockingRunnable) + val queuedCallbacksExecutor = TestExecutor() // We need at least two targets to trigger asynchronous sorting/"staged" progress callbacks. val resolvedTargets = @@ -890,31 +856,20 @@ class ResolverListAdapterTest { communicator, /*initialIntentsUserSpace=*/ userHandle, targetDataLoader, - executor, - callbackHandler + backgroundExecutor, + queuedCallbacksExecutor ) val doPostProcessing = false testSubject.rebuildList(doPostProcessing) // Finish all the background work (enqueueing both the "partial" and "complete" progress // callbacks) before dequeueing either callback. - executor.runUntilIdle() + backgroundExecutor.runUntilIdle() // Notify that our activity is being destroyed while the callbacks are still queued. testSubject.onDestroy() - // Allow the handler to flush out, but now the callbacks are gone. - unblockCallbacksSignal.countDown() - - // Finally, force a synchronization in the other direction to ensure that we've finished - // processing any callbacks before we start making assertions about them. - // TODO: there are less "ad-hoc" ways to write this (e.g. with a special `Handler` subclass - // for tests), but we should switch off using `Handler` altogether (in favor of `Executor` - // or otherwise), and then it'll be much simpler to clean up this boilerplate. - val unblockAssertionsSignal = CountDownLatch(1) - val countdownAssertionsRunnable = Runnable { unblockAssertionsSignal.countDown() } - callbackHandler.post(countdownAssertionsRunnable) - unblockAssertionsSignal.await() + queuedCallbacksExecutor.runUntilIdle() verify(communicator, never()).onPostListReady(eq(testSubject), eq(doPostProcessing), any()) } -- cgit v1.2.3-59-g8ed1b From 258e41f0b7b29dd85063a01ce98f75a332ef86d4 Mon Sep 17 00:00:00 2001 From: Joshua Trask Date: Tue, 22 Aug 2023 19:17:20 +0000 Subject: Merge the `MultiProfilePagerAdapter` base classes That is, this CL consolidates `GenericMultiProfilePagerAdapter` up into the base `AbstractMultiProfilePagerAdapter` (and renames to drop the prefixes). This advances an in-progress refactoring to simplify these classes (as in an earlier `GenericMultiProfilePagerAdapter` TODO comment when that class was first introduced). This prepares for other upcoming work in the base class (especially in service of "private space" support) since the responsibilities were unclear and arbitrarily split between these two bases, which had no other direct clients/subclasses (i.e., this CL is exactly equivalent to the earlier code, but removes a class from the hierarchy). I've also started a little bit of cleanup around method visibility but left much of that work out-of-scope for now. Test: IntentResolverUnitTests / CtsSharesheetDeviceTest Change-Id: I073e0bec5764ea16736af585af0bc9f744089d03 --- .../AbstractMultiProfilePagerAdapter.java | 582 ---------------- .../android/intentresolver/ChooserActivity.java | 6 +- .../ChooserMultiProfilePagerAdapter.java | 7 +- .../GenericMultiProfilePagerAdapter.java | 235 ------- .../intentresolver/MultiProfilePagerAdapter.java | 734 +++++++++++++++++++++ .../NoAppsAvailableEmptyStateProvider.java | 4 +- .../NoCrossProfileEmptyStateProvider.java | 6 +- .../android/intentresolver/ResolverActivity.java | 28 +- .../ResolverMultiProfilePagerAdapter.java | 3 +- .../WorkProfilePausedEmptyStateProvider.java | 6 +- .../ChooserActivityOverrideData.java | 2 +- .../intentresolver/ChooserWrapperActivity.java | 2 +- .../GenericMultiProfilePagerAdapterTest.kt | 276 -------- .../intentresolver/MultiProfilePagerAdapterTest.kt | 269 ++++++++ .../intentresolver/ResolverWrapperActivity.java | 2 +- 15 files changed, 1035 insertions(+), 1127 deletions(-) delete mode 100644 java/src/com/android/intentresolver/AbstractMultiProfilePagerAdapter.java delete mode 100644 java/src/com/android/intentresolver/GenericMultiProfilePagerAdapter.java create mode 100644 java/src/com/android/intentresolver/MultiProfilePagerAdapter.java delete mode 100644 java/tests/src/com/android/intentresolver/GenericMultiProfilePagerAdapterTest.kt create mode 100644 java/tests/src/com/android/intentresolver/MultiProfilePagerAdapterTest.kt (limited to 'java/src') diff --git a/java/src/com/android/intentresolver/AbstractMultiProfilePagerAdapter.java b/java/src/com/android/intentresolver/AbstractMultiProfilePagerAdapter.java deleted file mode 100644 index 4b06db3b..00000000 --- a/java/src/com/android/intentresolver/AbstractMultiProfilePagerAdapter.java +++ /dev/null @@ -1,582 +0,0 @@ -/* - * Copyright (C) 2019 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package com.android.intentresolver; - -import android.annotation.IntDef; -import android.annotation.NonNull; -import android.annotation.Nullable; -import android.annotation.UserIdInt; -import android.app.AppGlobals; -import android.content.ContentResolver; -import android.content.Context; -import android.content.Intent; -import android.content.pm.IPackageManager; -import android.os.Trace; -import android.os.UserHandle; -import android.view.View; -import android.view.ViewGroup; -import android.widget.Button; -import android.widget.TextView; - -import androidx.viewpager.widget.PagerAdapter; -import androidx.viewpager.widget.ViewPager; - -import com.android.internal.annotations.VisibleForTesting; - -import java.util.HashSet; -import java.util.List; -import java.util.Objects; -import java.util.Set; -import java.util.function.Supplier; - -/** - * Skeletal {@link PagerAdapter} implementation of a work or personal profile page for - * intent resolution (including share sheet). - */ -public abstract class AbstractMultiProfilePagerAdapter extends PagerAdapter { - - private static final String TAG = "AbstractMultiProfilePagerAdapter"; - static final int PROFILE_PERSONAL = 0; - static final int PROFILE_WORK = 1; - - @IntDef({PROFILE_PERSONAL, PROFILE_WORK}) - @interface Profile {} - - private final Context mContext; - private int mCurrentPage; - private OnProfileSelectedListener mOnProfileSelectedListener; - - private Set mLoadedPages; - private final EmptyStateProvider mEmptyStateProvider; - private final UserHandle mWorkProfileUserHandle; - private final UserHandle mCloneProfileUserHandle; - private final Supplier mWorkProfileQuietModeChecker; // True when work is quiet. - - AbstractMultiProfilePagerAdapter( - Context context, - int currentPage, - EmptyStateProvider emptyStateProvider, - Supplier workProfileQuietModeChecker, - UserHandle workProfileUserHandle, - UserHandle cloneProfileUserHandle) { - mContext = Objects.requireNonNull(context); - mCurrentPage = currentPage; - mLoadedPages = new HashSet<>(); - mWorkProfileUserHandle = workProfileUserHandle; - mCloneProfileUserHandle = cloneProfileUserHandle; - mEmptyStateProvider = emptyStateProvider; - mWorkProfileQuietModeChecker = workProfileQuietModeChecker; - } - - void setOnProfileSelectedListener(OnProfileSelectedListener listener) { - mOnProfileSelectedListener = listener; - } - - Context getContext() { - return mContext; - } - - /** - * Sets this instance of this class as {@link ViewPager}'s {@link PagerAdapter} and sets - * an {@link ViewPager.OnPageChangeListener} where it keeps track of the currently displayed - * page and rebuilds the list. - */ - void setupViewPager(ViewPager viewPager) { - viewPager.setOnPageChangeListener(new ViewPager.SimpleOnPageChangeListener() { - @Override - public void onPageSelected(int position) { - mCurrentPage = position; - if (!mLoadedPages.contains(position)) { - rebuildActiveTab(true); - mLoadedPages.add(position); - } - if (mOnProfileSelectedListener != null) { - mOnProfileSelectedListener.onProfileSelected(position); - } - } - - @Override - public void onPageScrollStateChanged(int state) { - if (mOnProfileSelectedListener != null) { - mOnProfileSelectedListener.onProfilePageStateChanged(state); - } - } - }); - viewPager.setAdapter(this); - viewPager.setCurrentItem(mCurrentPage); - mLoadedPages.add(mCurrentPage); - } - - void clearInactiveProfileCache() { - if (mLoadedPages.size() == 1) { - return; - } - mLoadedPages.remove(1 - mCurrentPage); - } - - @Override - public ViewGroup instantiateItem(ViewGroup container, int position) { - final ProfileDescriptor profileDescriptor = getItem(position); - container.addView(profileDescriptor.rootView); - return profileDescriptor.rootView; - } - - @Override - public void destroyItem(ViewGroup container, int position, Object view) { - container.removeView((View) view); - } - - @Override - public int getCount() { - return getItemCount(); - } - - protected int getCurrentPage() { - return mCurrentPage; - } - - @VisibleForTesting - public UserHandle getCurrentUserHandle() { - return getActiveListAdapter().getUserHandle(); - } - - @Override - public boolean isViewFromObject(View view, Object object) { - return view == object; - } - - @Override - public CharSequence getPageTitle(int position) { - return null; - } - - public UserHandle getCloneUserHandle() { - return mCloneProfileUserHandle; - } - - /** - * Returns the {@link ProfileDescriptor} relevant to the given pageIndex. - *

    - *
  • For a device with only one user, pageIndex value of - * 0 would return the personal profile {@link ProfileDescriptor}.
  • - *
  • For a device with a work profile, pageIndex value of 0 would - * return the personal profile {@link ProfileDescriptor}, and pageIndex value of - * 1 would return the work profile {@link ProfileDescriptor}.
  • - *
- */ - abstract ProfileDescriptor getItem(int pageIndex); - - protected ViewGroup getEmptyStateView(int pageIndex) { - return getItem(pageIndex).getEmptyStateView(); - } - - /** - * Returns the number of {@link ProfileDescriptor} objects. - *

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

For a device with a work profile returns 2. - */ - abstract int getItemCount(); - - /** - * Performs view-related initialization procedures for the adapter specified - * by pageIndex. - */ - abstract void setupListAdapter(int pageIndex); - - /** - * Returns the adapter of the list view for the relevant page specified by - * pageIndex. - *

This method is meant to be implemented with an implementation-specific return type - * depending on the adapter type. - */ - @VisibleForTesting - public abstract Object getAdapterForIndex(int pageIndex); - - /** - * Returns the {@link ResolverListAdapter} instance of the profile that represents - * userHandle. If there is no such adapter for the specified - * userHandle, returns {@code null}. - *

For example, if there is a work profile on the device with user id 10, calling this method - * with UserHandle.of(10) returns the work profile {@link ResolverListAdapter}. - */ - @Nullable - abstract ResolverListAdapter getListAdapterForUserHandle(UserHandle userHandle); - - /** - * Returns the {@link ResolverListAdapter} instance of the profile that is currently visible - * to the user. - *

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

For example, if the user is viewing the work tab in the share sheet, this method returns - * the personal profile {@link ResolverListAdapter}. - * @see #getActiveListAdapter() - */ - @VisibleForTesting - public abstract @Nullable ResolverListAdapter getInactiveListAdapter(); - - public abstract ResolverListAdapter getPersonalListAdapter(); - - public abstract @Nullable ResolverListAdapter getWorkListAdapter(); - - abstract Object getCurrentRootAdapter(); - - abstract ViewGroup getActiveAdapterView(); - - abstract @Nullable ViewGroup getInactiveAdapterView(); - - /** - * Rebuilds the tab that is currently visible to the user. - *

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

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

    - *
  1. (highest priority) cross-profile disabled by policy (handled in - * {@link #rebuildTab(ResolverListAdapter, boolean)})
  2. - *
  3. no apps available
  4. - *
  5. (least priority) work is off
  6. - *
- * - * The intention is to prevent the user from having to turn - * the work profile on if there will not be any apps resolved - * anyway. - */ - void showEmptyResolverListEmptyState(ResolverListAdapter listAdapter) { - final EmptyState emptyState = mEmptyStateProvider.getEmptyState(listAdapter); - - if (emptyState == null) { - return; - } - - emptyState.onEmptyStateShown(); - - View.OnClickListener clickListener = null; - - if (emptyState.getButtonClickListener() != null) { - clickListener = v -> emptyState.getButtonClickListener().onClick(() -> { - ProfileDescriptor descriptor = getItem( - userHandleToPageIndex(listAdapter.getUserHandle())); - AbstractMultiProfilePagerAdapter.this.showSpinner(descriptor.getEmptyStateView()); - }); - } - - showEmptyState(listAdapter, emptyState, clickListener); - } - - /** - * Class to get user id of the current process - */ - public static class MyUserIdProvider { - /** - * @return user id of the current process - */ - public int getMyUserId() { - return UserHandle.myUserId(); - } - } - - /** - * Utility class to check if there are cross profile intents, it is in a separate class so - * it could be mocked in tests - */ - public static class CrossProfileIntentsChecker { - - private final ContentResolver mContentResolver; - - public CrossProfileIntentsChecker(@NonNull ContentResolver contentResolver) { - mContentResolver = contentResolver; - } - - /** - * Returns {@code true} if at least one of the provided {@code intents} can be forwarded - * from {@code source} (user id) to {@code target} (user id). - */ - public boolean hasCrossProfileIntents(List intents, @UserIdInt int source, - @UserIdInt int target) { - IPackageManager packageManager = AppGlobals.getPackageManager(); - - return intents.stream().anyMatch(intent -> - null != IntentForwarderActivity.canForward(intent, source, target, - packageManager, mContentResolver)); - } - } - - protected void showEmptyState(ResolverListAdapter activeListAdapter, EmptyState emptyState, - View.OnClickListener buttonOnClick) { - ProfileDescriptor descriptor = getItem( - userHandleToPageIndex(activeListAdapter.getUserHandle())); - descriptor.rootView.findViewById(com.android.internal.R.id.resolver_list).setVisibility(View.GONE); - ViewGroup emptyStateView = descriptor.getEmptyStateView(); - resetViewVisibilitiesForEmptyState(emptyStateView); - emptyStateView.setVisibility(View.VISIBLE); - - View container = emptyStateView.findViewById(com.android.internal.R.id.resolver_empty_state_container); - setupContainerPadding(container); - - TextView titleView = emptyStateView.findViewById(com.android.internal.R.id.resolver_empty_state_title); - String title = emptyState.getTitle(); - if (title != null) { - titleView.setVisibility(View.VISIBLE); - titleView.setText(title); - } else { - titleView.setVisibility(View.GONE); - } - - TextView subtitleView = emptyStateView.findViewById(com.android.internal.R.id.resolver_empty_state_subtitle); - String subtitle = emptyState.getSubtitle(); - if (subtitle != null) { - subtitleView.setVisibility(View.VISIBLE); - subtitleView.setText(subtitle); - } else { - subtitleView.setVisibility(View.GONE); - } - - View defaultEmptyText = emptyStateView.findViewById(com.android.internal.R.id.empty); - defaultEmptyText.setVisibility(emptyState.useDefaultEmptyView() ? View.VISIBLE : View.GONE); - - Button button = emptyStateView.findViewById(com.android.internal.R.id.resolver_empty_state_button); - button.setVisibility(buttonOnClick != null ? View.VISIBLE : View.GONE); - button.setOnClickListener(buttonOnClick); - - activeListAdapter.markTabLoaded(); - } - - /** - * Sets up the padding of the view containing the empty state screens. - *

This method is meant to be overridden so that subclasses can customize the padding. - */ - protected void setupContainerPadding(View container) {} - - private void showSpinner(View emptyStateView) { - emptyStateView.findViewById(com.android.internal.R.id.resolver_empty_state_title).setVisibility(View.INVISIBLE); - emptyStateView.findViewById(com.android.internal.R.id.resolver_empty_state_button).setVisibility(View.INVISIBLE); - emptyStateView.findViewById(com.android.internal.R.id.resolver_empty_state_progress).setVisibility(View.VISIBLE); - emptyStateView.findViewById(com.android.internal.R.id.empty).setVisibility(View.GONE); - } - - private void resetViewVisibilitiesForEmptyState(View emptyStateView) { - emptyStateView.findViewById(com.android.internal.R.id.resolver_empty_state_title).setVisibility(View.VISIBLE); - emptyStateView.findViewById(com.android.internal.R.id.resolver_empty_state_subtitle).setVisibility(View.VISIBLE); - emptyStateView.findViewById(com.android.internal.R.id.resolver_empty_state_button).setVisibility(View.INVISIBLE); - emptyStateView.findViewById(com.android.internal.R.id.resolver_empty_state_progress).setVisibility(View.GONE); - emptyStateView.findViewById(com.android.internal.R.id.empty).setVisibility(View.GONE); - } - - protected void showListView(ResolverListAdapter activeListAdapter) { - ProfileDescriptor descriptor = getItem( - userHandleToPageIndex(activeListAdapter.getUserHandle())); - descriptor.rootView.findViewById(com.android.internal.R.id.resolver_list).setVisibility(View.VISIBLE); - View emptyStateView = descriptor.rootView.findViewById(com.android.internal.R.id.resolver_empty_state); - emptyStateView.setVisibility(View.GONE); - } - - boolean shouldShowEmptyStateScreen(ResolverListAdapter listAdapter) { - int count = listAdapter.getUnfilteredCount(); - return (count == 0 && listAdapter.getPlaceholderCount() == 0) - || (listAdapter.getUserHandle().equals(mWorkProfileUserHandle) - && mWorkProfileQuietModeChecker.get()); - } - - protected static class ProfileDescriptor { - final ViewGroup rootView; - private final ViewGroup mEmptyStateView; - ProfileDescriptor(ViewGroup rootView) { - this.rootView = rootView; - mEmptyStateView = rootView.findViewById(com.android.internal.R.id.resolver_empty_state); - } - - protected ViewGroup getEmptyStateView() { - return mEmptyStateView; - } - } - - public interface OnProfileSelectedListener { - /** - * Callback for when the user changes the active tab from personal to work or vice versa. - *

This callback is only called when the intent resolver or share sheet shows - * the work and personal profiles. - * @param profileIndex {@link #PROFILE_PERSONAL} if the personal profile was selected or - * {@link #PROFILE_WORK} if the work profile was selected. - */ - void onProfileSelected(int profileIndex); - - - /** - * Callback for when the scroll state changes. Useful for discovering when the user begins - * dragging, when the pager is automatically settling to the current page, or when it is - * fully stopped/idle. - * @param state {@link ViewPager#SCROLL_STATE_IDLE}, {@link ViewPager#SCROLL_STATE_DRAGGING} - * or {@link ViewPager#SCROLL_STATE_SETTLING} - * @see ViewPager.OnPageChangeListener#onPageScrollStateChanged - */ - void onProfilePageStateChanged(int state); - } - - /** - * Returns an empty state to show for the current profile page (tab) if necessary. - * This could be used e.g. to show a blocker on a tab if device management policy doesn't - * allow to use it or there are no apps available. - */ - public interface EmptyStateProvider { - /** - * When a non-null empty state is returned the corresponding profile page will show - * this empty state - * @param resolverListAdapter the current adapter - */ - @Nullable - default EmptyState getEmptyState(ResolverListAdapter resolverListAdapter) { - return null; - } - } - - /** - * Empty state provider that combines multiple providers. Providers earlier in the list have - * priority, that is if there is a provider that returns non-null empty state then all further - * providers will be ignored. - */ - public static class CompositeEmptyStateProvider implements EmptyStateProvider { - - private final EmptyStateProvider[] mProviders; - - public CompositeEmptyStateProvider(EmptyStateProvider... providers) { - mProviders = providers; - } - - @Nullable - @Override - public EmptyState getEmptyState(ResolverListAdapter resolverListAdapter) { - for (EmptyStateProvider provider : mProviders) { - EmptyState emptyState = provider.getEmptyState(resolverListAdapter); - if (emptyState != null) { - return emptyState; - } - } - return null; - } - } - - /** - * Describes how the blocked empty state should look like for a profile tab - */ - public interface EmptyState { - /** - * Title that will be shown on the empty state - */ - @Nullable - default String getTitle() { return null; } - - /** - * Subtitle that will be shown underneath the title on the empty state - */ - @Nullable - default String getSubtitle() { return null; } - - /** - * If non-null then a button will be shown and this listener will be called - * when the button is clicked - */ - @Nullable - default ClickListener getButtonClickListener() { return null; } - - /** - * If true then default text ('No apps can perform this action') and style for the empty - * state will be applied, title and subtitle will be ignored. - */ - default boolean useDefaultEmptyView() { return false; } - - /** - * Returns true if for this empty state we should skip rebuilding of the apps list - * for this tab. - */ - default boolean shouldSkipDataRebuild() { return false; } - - /** - * Called when empty state is shown, could be used e.g. to track analytics events - */ - default void onEmptyStateShown() {} - - interface ClickListener { - void onClick(TabControl currentTab); - } - - interface TabControl { - void showSpinner(); - } - } - - - /** - * Listener for when the user switches on the work profile from the work tab. - */ - interface OnSwitchOnWorkSelectedListener { - /** - * Callback for when the user switches on the work profile from the work tab. - */ - void onSwitchOnWorkSelected(); - } -} diff --git a/java/src/com/android/intentresolver/ChooserActivity.java b/java/src/com/android/intentresolver/ChooserActivity.java index f455be4c..7b4f4827 100644 --- a/java/src/com/android/intentresolver/ChooserActivity.java +++ b/java/src/com/android/intentresolver/ChooserActivity.java @@ -73,8 +73,8 @@ import androidx.recyclerview.widget.GridLayoutManager; import androidx.recyclerview.widget.RecyclerView; import androidx.viewpager.widget.ViewPager; -import com.android.intentresolver.AbstractMultiProfilePagerAdapter.EmptyState; -import com.android.intentresolver.AbstractMultiProfilePagerAdapter.EmptyStateProvider; +import com.android.intentresolver.MultiProfilePagerAdapter.EmptyState; +import com.android.intentresolver.MultiProfilePagerAdapter.EmptyStateProvider; import com.android.intentresolver.NoCrossProfileEmptyStateProvider.DevicePolicyBlockerEmptyState; import com.android.intentresolver.chooser.DisplayResolveInfo; import com.android.intentresolver.chooser.MultiDisplayResolveInfo; @@ -418,7 +418,7 @@ public class ChooserActivity extends Hilt_ChooserActivity implements } @Override - protected AbstractMultiProfilePagerAdapter createMultiProfilePagerAdapter( + protected ChooserMultiProfilePagerAdapter createMultiProfilePagerAdapter( Intent[] initialIntents, List rList, boolean filterLastUsed, diff --git a/java/src/com/android/intentresolver/ChooserMultiProfilePagerAdapter.java b/java/src/com/android/intentresolver/ChooserMultiProfilePagerAdapter.java index ba35ae5d..75ff3a7f 100644 --- a/java/src/com/android/intentresolver/ChooserMultiProfilePagerAdapter.java +++ b/java/src/com/android/intentresolver/ChooserMultiProfilePagerAdapter.java @@ -38,7 +38,7 @@ import java.util.function.Supplier; * A {@link PagerAdapter} which describes the work and personal profile share sheet screens. */ @VisibleForTesting -public class ChooserMultiProfilePagerAdapter extends GenericMultiProfilePagerAdapter< +public class ChooserMultiProfilePagerAdapter extends MultiProfilePagerAdapter< RecyclerView, ChooserGridAdapter, ChooserListAdapter> { private static final int SINGLE_CELL_SPAN_SIZE = 1; @@ -103,7 +103,6 @@ public class ChooserMultiProfilePagerAdapter extends GenericMultiProfilePagerAda BottomPaddingOverrideSupplier bottomPaddingOverrideSupplier, FeatureFlags featureFlags) { super( - context, gridAdapter -> gridAdapter.getListAdapter(), adapterBinder, gridAdapters, @@ -149,7 +148,7 @@ public class ChooserMultiProfilePagerAdapter extends GenericMultiProfilePagerAda } @Override - boolean rebuildActiveTab(boolean doPostProcessing) { + public boolean rebuildActiveTab(boolean doPostProcessing) { if (doPostProcessing) { Tracer.INSTANCE.beginAppTargetLoadingSection(getActiveListAdapter().getUserHandle()); } @@ -157,7 +156,7 @@ public class ChooserMultiProfilePagerAdapter extends GenericMultiProfilePagerAda } @Override - boolean rebuildInactiveTab(boolean doPostProcessing) { + public boolean rebuildInactiveTab(boolean doPostProcessing) { if (getItemCount() != 1 && doPostProcessing) { Tracer.INSTANCE.beginAppTargetLoadingSection(getInactiveListAdapter().getUserHandle()); } diff --git a/java/src/com/android/intentresolver/GenericMultiProfilePagerAdapter.java b/java/src/com/android/intentresolver/GenericMultiProfilePagerAdapter.java deleted file mode 100644 index a1c53402..00000000 --- a/java/src/com/android/intentresolver/GenericMultiProfilePagerAdapter.java +++ /dev/null @@ -1,235 +0,0 @@ -/* - * Copyright (C) 2022 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.android.intentresolver; - -import android.annotation.Nullable; -import android.content.Context; -import android.os.UserHandle; -import android.util.Log; -import android.view.View; -import android.view.ViewGroup; - -import com.android.internal.annotations.VisibleForTesting; - -import com.google.common.collect.ImmutableList; - -import java.util.Optional; -import java.util.function.Function; -import java.util.function.Supplier; - -/** - * Implementation of {@link AbstractMultiProfilePagerAdapter} that consolidates the variation in - * existing implementations; most overrides were only to vary type signatures (which are better - * represented via generic types), and a few minor behavioral customizations are now implemented - * through small injectable delegate classes. - * TODO: now that the existing implementations are shown to be expressible in terms of this new - * generic type, merge up into the base class and simplify the public APIs. - * TODO: attempt to further restrict visibility in the methods we expose. - * TODO: deprecate and audit/fix usages of any methods that refer to the "active" or "inactive" - * adapters; these were marked {@link VisibleForTesting} and their usage seems like an accident - * waiting to happen since clients seem to make assumptions about which adapter will be "active" in - * a particular context, and more explicit APIs would make sure those were valid. - * TODO: consider renaming legacy methods (e.g. why do we know it's a "list", not just a "page"?) - * - * @param the type of the widget that represents the contents of a page in this adapter - * @param the type of a "root" adapter class to be instantiated and included in - * the per-profile records. - * @param the concrete type of a {@link ResolverListAdapter} implementation to - * control the contents of a given per-profile list. This is provided for convenience, since it must - * be possible to get the list adapter from the page adapter via our {@link mListAdapterExtractor}. - * - * TODO: this class doesn't make any explicit usage of the {@link ResolverListAdapter} API, so the - * type constraint can probably be dropped once the API is merged upwards and cleaned. - */ -class GenericMultiProfilePagerAdapter< - PageViewT extends ViewGroup, - SinglePageAdapterT, - ListAdapterT extends ResolverListAdapter> extends AbstractMultiProfilePagerAdapter { - - /** Delegate to set up a given adapter and page view to be used together. */ - public interface AdapterBinder { - /** - * The given {@code view} will be associated with the given {@code adapter}. Do any work - * necessary to configure them compatibly, introduce them to each other, etc. - */ - void bind(PageViewT view, SinglePageAdapterT adapter); - } - - private final Function mListAdapterExtractor; - private final AdapterBinder mAdapterBinder; - private final Supplier mPageViewInflater; - private final Supplier> mContainerBottomPaddingOverrideSupplier; - - private final ImmutableList> mItems; - - GenericMultiProfilePagerAdapter( - Context context, - Function listAdapterExtractor, - AdapterBinder adapterBinder, - ImmutableList adapters, - EmptyStateProvider emptyStateProvider, - Supplier workProfileQuietModeChecker, - @Profile int defaultProfile, - UserHandle workProfileUserHandle, - UserHandle cloneProfileUserHandle, - Supplier pageViewInflater, - Supplier> containerBottomPaddingOverrideSupplier) { - super( - context, - /* currentPage= */ defaultProfile, - emptyStateProvider, - workProfileQuietModeChecker, - workProfileUserHandle, - cloneProfileUserHandle); - - mListAdapterExtractor = listAdapterExtractor; - mAdapterBinder = adapterBinder; - mPageViewInflater = pageViewInflater; - mContainerBottomPaddingOverrideSupplier = containerBottomPaddingOverrideSupplier; - - ImmutableList.Builder> items = - new ImmutableList.Builder<>(); - for (SinglePageAdapterT adapter : adapters) { - items.add(createProfileDescriptor(adapter)); - } - mItems = items.build(); - } - - private GenericProfileDescriptor - createProfileDescriptor(SinglePageAdapterT adapter) { - return new GenericProfileDescriptor<>(mPageViewInflater.get(), adapter); - } - - @Override - protected GenericProfileDescriptor getItem(int pageIndex) { - return mItems.get(pageIndex); - } - - @Override - public int getItemCount() { - return mItems.size(); - } - - public PageViewT getListViewForIndex(int index) { - return getItem(index).mView; - } - - @Override - @VisibleForTesting - public SinglePageAdapterT getAdapterForIndex(int index) { - return getItem(index).mAdapter; - } - - @Override - protected void setupListAdapter(int pageIndex) { - mAdapterBinder.bind(getListViewForIndex(pageIndex), getAdapterForIndex(pageIndex)); - } - - @Override - public ViewGroup instantiateItem(ViewGroup container, int position) { - setupListAdapter(position); - return super.instantiateItem(container, position); - } - - @Override - @Nullable - protected ListAdapterT getListAdapterForUserHandle(UserHandle userHandle) { - if (getPersonalListAdapter().getUserHandle().equals(userHandle) - || userHandle.equals(getCloneUserHandle())) { - return getPersonalListAdapter(); - } else if (getWorkListAdapter() != null - && getWorkListAdapter().getUserHandle().equals(userHandle)) { - return getWorkListAdapter(); - } - return null; - } - - @Override - @VisibleForTesting - public ListAdapterT getActiveListAdapter() { - return mListAdapterExtractor.apply(getAdapterForIndex(getCurrentPage())); - } - - @Override - @VisibleForTesting - public ListAdapterT getInactiveListAdapter() { - if (getCount() < 2) { - return null; - } - return mListAdapterExtractor.apply(getAdapterForIndex(1 - getCurrentPage())); - } - - @Override - public ListAdapterT getPersonalListAdapter() { - return mListAdapterExtractor.apply(getAdapterForIndex(PROFILE_PERSONAL)); - } - - @Override - public ListAdapterT getWorkListAdapter() { - if (!hasAdapterForIndex(PROFILE_WORK)) { - return null; - } - return mListAdapterExtractor.apply(getAdapterForIndex(PROFILE_WORK)); - } - - @Override - protected SinglePageAdapterT getCurrentRootAdapter() { - return getAdapterForIndex(getCurrentPage()); - } - - @Override - protected PageViewT getActiveAdapterView() { - return getListViewForIndex(getCurrentPage()); - } - - @Override - protected PageViewT getInactiveAdapterView() { - if (getCount() < 2) { - return null; - } - return getListViewForIndex(1 - getCurrentPage()); - } - - @Override - protected void setupContainerPadding(View container) { - Optional bottomPaddingOverride = mContainerBottomPaddingOverrideSupplier.get(); - bottomPaddingOverride.ifPresent(paddingBottom -> - container.setPadding( - container.getPaddingLeft(), - container.getPaddingTop(), - container.getPaddingRight(), - paddingBottom)); - } - - private boolean hasAdapterForIndex(int pageIndex) { - return (pageIndex < getCount()); - } - - // TODO: `ChooserActivity` also has a per-profile record type. Maybe the "multi-profile pager" - // should be the owner of all per-profile data (especially now that the API is generic)? - private static class GenericProfileDescriptor extends - ProfileDescriptor { - private final SinglePageAdapterT mAdapter; - private final PageViewT mView; - - GenericProfileDescriptor(ViewGroup rootView, SinglePageAdapterT adapter) { - super(rootView); - mAdapter = adapter; - mView = (PageViewT) rootView.findViewById(com.android.internal.R.id.resolver_list); - } - } -} diff --git a/java/src/com/android/intentresolver/MultiProfilePagerAdapter.java b/java/src/com/android/intentresolver/MultiProfilePagerAdapter.java new file mode 100644 index 00000000..cc079a87 --- /dev/null +++ b/java/src/com/android/intentresolver/MultiProfilePagerAdapter.java @@ -0,0 +1,734 @@ +/* + * Copyright (C) 2019 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.android.intentresolver; + +import android.annotation.IntDef; +import android.annotation.NonNull; +import android.annotation.Nullable; +import android.annotation.UserIdInt; +import android.app.AppGlobals; +import android.content.ContentResolver; +import android.content.Context; +import android.content.Intent; +import android.content.pm.IPackageManager; +import android.os.Trace; +import android.os.UserHandle; +import android.view.View; +import android.view.ViewGroup; +import android.widget.Button; +import android.widget.TextView; + +import androidx.viewpager.widget.PagerAdapter; +import androidx.viewpager.widget.ViewPager; + +import com.android.internal.annotations.VisibleForTesting; + +import com.google.common.collect.ImmutableList; + +import java.util.HashSet; +import java.util.List; +import java.util.Objects; +import java.util.Optional; +import java.util.Set; +import java.util.function.Function; +import java.util.function.Supplier; + +/** + * Skeletal {@link PagerAdapter} implementation for a UI with per-profile tabs (as in Sharesheet). + * + * TODO: attempt to further restrict visibility/improve encapsulation in the methods we expose. + * TODO: deprecate and audit/fix usages of any methods that refer to the "active" or "inactive" + * adapters; these were marked {@link VisibleForTesting} and their usage seems like an accident + * waiting to happen since clients seem to make assumptions about which adapter will be "active" in + * a particular context, and more explicit APIs would make sure those were valid. + * TODO: consider renaming legacy methods (e.g. why do we know it's a "list", not just a "page"?) + * + * @param the type of the widget that represents the contents of a page in this adapter + * @param the type of a "root" adapter class to be instantiated and included in + * the per-profile records. + * @param the concrete type of a {@link ResolverListAdapter} implementation to + * control the contents of a given per-profile list. This is provided for convenience, since it must + * be possible to get the list adapter from the page adapter via our {@link mListAdapterExtractor}. + * + * TODO: this is part of an in-progress refactor to merge with `GenericMultiProfilePagerAdapter`. + * As originally noted there, we've reduced explicit references to the `ResolverListAdapter` base + * type and may be able to drop the type constraint. + */ +public class MultiProfilePagerAdapter< + PageViewT extends ViewGroup, + SinglePageAdapterT, + ListAdapterT extends ResolverListAdapter> extends PagerAdapter { + + /** + * Delegate to set up a given adapter and page view to be used together. + * @param (as in {@link MultiProfilePagerAdapter}). + * @param (as in {@link MultiProfilePagerAdapter}). + */ + public interface AdapterBinder { + /** + * The given {@code view} will be associated with the given {@code adapter}. Do any work + * necessary to configure them compatibly, introduce them to each other, etc. + */ + void bind(PageViewT view, SinglePageAdapterT adapter); + } + + static final int PROFILE_PERSONAL = 0; + static final int PROFILE_WORK = 1; + + @IntDef({PROFILE_PERSONAL, PROFILE_WORK}) + @interface Profile {} + + private final Function mListAdapterExtractor; + private final AdapterBinder mAdapterBinder; + private final Supplier mPageViewInflater; + private final Supplier> mContainerBottomPaddingOverrideSupplier; + + private final ImmutableList> mItems; + + private final EmptyStateProvider mEmptyStateProvider; + private final UserHandle mWorkProfileUserHandle; + private final UserHandle mCloneProfileUserHandle; + private final Supplier mWorkProfileQuietModeChecker; // True when work is quiet. + + private Set mLoadedPages; + private int mCurrentPage; + private OnProfileSelectedListener mOnProfileSelectedListener; + + protected MultiProfilePagerAdapter( + Function listAdapterExtractor, + AdapterBinder adapterBinder, + ImmutableList adapters, + EmptyStateProvider emptyStateProvider, + Supplier workProfileQuietModeChecker, + @Profile int defaultProfile, + UserHandle workProfileUserHandle, + UserHandle cloneProfileUserHandle, + Supplier pageViewInflater, + Supplier> containerBottomPaddingOverrideSupplier) { + mCurrentPage = defaultProfile; + mLoadedPages = new HashSet<>(); + mWorkProfileUserHandle = workProfileUserHandle; + mCloneProfileUserHandle = cloneProfileUserHandle; + mEmptyStateProvider = emptyStateProvider; + mWorkProfileQuietModeChecker = workProfileQuietModeChecker; + + mListAdapterExtractor = listAdapterExtractor; + mAdapterBinder = adapterBinder; + mPageViewInflater = pageViewInflater; + mContainerBottomPaddingOverrideSupplier = containerBottomPaddingOverrideSupplier; + + ImmutableList.Builder> items = + new ImmutableList.Builder<>(); + for (SinglePageAdapterT adapter : adapters) { + items.add(createProfileDescriptor(adapter)); + } + mItems = items.build(); + } + + private ProfileDescriptor createProfileDescriptor( + SinglePageAdapterT adapter) { + return new ProfileDescriptor<>(mPageViewInflater.get(), adapter); + } + + public void setOnProfileSelectedListener(OnProfileSelectedListener listener) { + mOnProfileSelectedListener = listener; + } + + /** + * Sets this instance of this class as {@link ViewPager}'s {@link PagerAdapter} and sets + * an {@link ViewPager.OnPageChangeListener} where it keeps track of the currently displayed + * page and rebuilds the list. + */ + public void setupViewPager(ViewPager viewPager) { + viewPager.setOnPageChangeListener(new ViewPager.SimpleOnPageChangeListener() { + @Override + public void onPageSelected(int position) { + mCurrentPage = position; + if (!mLoadedPages.contains(position)) { + rebuildActiveTab(true); + mLoadedPages.add(position); + } + if (mOnProfileSelectedListener != null) { + mOnProfileSelectedListener.onProfileSelected(position); + } + } + + @Override + public void onPageScrollStateChanged(int state) { + if (mOnProfileSelectedListener != null) { + mOnProfileSelectedListener.onProfilePageStateChanged(state); + } + } + }); + viewPager.setAdapter(this); + viewPager.setCurrentItem(mCurrentPage); + mLoadedPages.add(mCurrentPage); + } + + public void clearInactiveProfileCache() { + if (mLoadedPages.size() == 1) { + return; + } + mLoadedPages.remove(1 - mCurrentPage); + } + + @Override + public final ViewGroup instantiateItem(ViewGroup container, int position) { + setupListAdapter(position); + final ProfileDescriptor descriptor = getItem(position); + container.addView(descriptor.mRootView); + return descriptor.mRootView; + } + + @Override + public void destroyItem(ViewGroup container, int position, Object view) { + container.removeView((View) view); + } + + @Override + public int getCount() { + return getItemCount(); + } + + protected int getCurrentPage() { + return mCurrentPage; + } + + @VisibleForTesting + public UserHandle getCurrentUserHandle() { + return getActiveListAdapter().getUserHandle(); + } + + @Override + public boolean isViewFromObject(View view, Object object) { + return view == object; + } + + @Override + public CharSequence getPageTitle(int position) { + return null; + } + + public UserHandle getCloneUserHandle() { + return mCloneProfileUserHandle; + } + + /** + * Returns the {@link ProfileDescriptor} relevant to the given pageIndex. + *

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

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

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

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

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

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

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

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

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

    + *
  1. (highest priority) cross-profile disabled by policy (handled in + * {@link #rebuildTab(ListAdapterT, boolean)})
  2. + *
  3. no apps available
  4. + *
  5. (least priority) work is off
  6. + *
+ * + * The intention is to prevent the user from having to turn + * the work profile on if there will not be any apps resolved + * anyway. + */ + public void showEmptyResolverListEmptyState(ListAdapterT listAdapter) { + final EmptyState emptyState = mEmptyStateProvider.getEmptyState(listAdapter); + + if (emptyState == null) { + return; + } + + emptyState.onEmptyStateShown(); + + View.OnClickListener clickListener = null; + + if (emptyState.getButtonClickListener() != null) { + clickListener = v -> emptyState.getButtonClickListener().onClick(() -> { + ProfileDescriptor descriptor = getItem( + userHandleToPageIndex(listAdapter.getUserHandle())); + MultiProfilePagerAdapter.this.showSpinner(descriptor.getEmptyStateView()); + }); + } + + showEmptyState(listAdapter, emptyState, clickListener); + } + + /** + * Class to get user id of the current process + */ + public static class MyUserIdProvider { + /** + * @return user id of the current process + */ + public int getMyUserId() { + return UserHandle.myUserId(); + } + } + + /** + * Utility class to check if there are cross profile intents, it is in a separate class so + * it could be mocked in tests + */ + public static class CrossProfileIntentsChecker { + + private final ContentResolver mContentResolver; + + public CrossProfileIntentsChecker(@NonNull ContentResolver contentResolver) { + mContentResolver = contentResolver; + } + + /** + * Returns {@code true} if at least one of the provided {@code intents} can be forwarded + * from {@code source} (user id) to {@code target} (user id). + */ + public boolean hasCrossProfileIntents(List intents, @UserIdInt int source, + @UserIdInt int target) { + IPackageManager packageManager = AppGlobals.getPackageManager(); + + return intents.stream().anyMatch(intent -> + null != IntentForwarderActivity.canForward(intent, source, target, + packageManager, mContentResolver)); + } + } + + protected void showEmptyState( + ListAdapterT activeListAdapter, + EmptyState emptyState, + View.OnClickListener buttonOnClick) { + ProfileDescriptor descriptor = getItem( + userHandleToPageIndex(activeListAdapter.getUserHandle())); + descriptor.mRootView.findViewById( + com.android.internal.R.id.resolver_list).setVisibility(View.GONE); + ViewGroup emptyStateView = descriptor.getEmptyStateView(); + resetViewVisibilitiesForEmptyState(emptyStateView); + emptyStateView.setVisibility(View.VISIBLE); + + View container = emptyStateView.findViewById( + com.android.internal.R.id.resolver_empty_state_container); + setupContainerPadding(container); + + TextView titleView = emptyStateView.findViewById( + com.android.internal.R.id.resolver_empty_state_title); + String title = emptyState.getTitle(); + if (title != null) { + titleView.setVisibility(View.VISIBLE); + titleView.setText(title); + } else { + titleView.setVisibility(View.GONE); + } + + TextView subtitleView = emptyStateView.findViewById( + com.android.internal.R.id.resolver_empty_state_subtitle); + String subtitle = emptyState.getSubtitle(); + if (subtitle != null) { + subtitleView.setVisibility(View.VISIBLE); + subtitleView.setText(subtitle); + } else { + subtitleView.setVisibility(View.GONE); + } + + View defaultEmptyText = emptyStateView.findViewById(com.android.internal.R.id.empty); + defaultEmptyText.setVisibility(emptyState.useDefaultEmptyView() ? View.VISIBLE : View.GONE); + + Button button = emptyStateView.findViewById( + com.android.internal.R.id.resolver_empty_state_button); + button.setVisibility(buttonOnClick != null ? View.VISIBLE : View.GONE); + button.setOnClickListener(buttonOnClick); + + activeListAdapter.markTabLoaded(); + } + + /** + * Sets up the padding of the view containing the empty state screens. + *

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

This callback is only called when the intent resolver or share sheet shows + * the work and personal profiles. + * @param profileIndex {@link #PROFILE_PERSONAL} if the personal profile was selected or + * {@link #PROFILE_WORK} if the work profile was selected. + */ + void onProfileSelected(int profileIndex); + + + /** + * Callback for when the scroll state changes. Useful for discovering when the user begins + * dragging, when the pager is automatically settling to the current page, or when it is + * fully stopped/idle. + * @param state {@link ViewPager#SCROLL_STATE_IDLE}, {@link ViewPager#SCROLL_STATE_DRAGGING} + * or {@link ViewPager#SCROLL_STATE_SETTLING} + * @see ViewPager.OnPageChangeListener#onPageScrollStateChanged + */ + void onProfilePageStateChanged(int state); + } + + /** + * Returns an empty state to show for the current profile page (tab) if necessary. + * This could be used e.g. to show a blocker on a tab if device management policy doesn't + * allow to use it or there are no apps available. + */ + public interface EmptyStateProvider { + /** + * When a non-null empty state is returned the corresponding profile page will show + * this empty state + * @param resolverListAdapter the current adapter + */ + @Nullable + default EmptyState getEmptyState(ResolverListAdapter resolverListAdapter) { + return null; + } + } + + /** + * Empty state provider that combines multiple providers. Providers earlier in the list have + * priority, that is if there is a provider that returns non-null empty state then all further + * providers will be ignored. + */ + public static class CompositeEmptyStateProvider implements EmptyStateProvider { + + private final EmptyStateProvider[] mProviders; + + public CompositeEmptyStateProvider(EmptyStateProvider... providers) { + mProviders = providers; + } + + @Nullable + @Override + public EmptyState getEmptyState(ResolverListAdapter resolverListAdapter) { + for (EmptyStateProvider provider : mProviders) { + EmptyState emptyState = provider.getEmptyState(resolverListAdapter); + if (emptyState != null) { + return emptyState; + } + } + return null; + } + } + + /** + * Describes how the blocked empty state should look like for a profile tab + */ + public interface EmptyState { + /** + * Title that will be shown on the empty state + */ + @Nullable + default String getTitle() { + return null; + } + + /** + * Subtitle that will be shown underneath the title on the empty state + */ + @Nullable + default String getSubtitle() { + return null; + } + + /** + * If non-null then a button will be shown and this listener will be called + * when the button is clicked + */ + @Nullable + default ClickListener getButtonClickListener() { + return null; + } + + /** + * If true then default text ('No apps can perform this action') and style for the empty + * state will be applied, title and subtitle will be ignored. + */ + default boolean useDefaultEmptyView() { + return false; + } + + /** + * Returns true if for this empty state we should skip rebuilding of the apps list + * for this tab. + */ + default boolean shouldSkipDataRebuild() { + return false; + } + + /** + * Called when empty state is shown, could be used e.g. to track analytics events + */ + default void onEmptyStateShown() {} + + interface ClickListener { + void onClick(TabControl currentTab); + } + + interface TabControl { + void showSpinner(); + } + } + + /** + * Listener for when the user switches on the work profile from the work tab. + */ + public interface OnSwitchOnWorkSelectedListener { + /** + * Callback for when the user switches on the work profile from the work tab. + */ + void onSwitchOnWorkSelected(); + } +} diff --git a/java/src/com/android/intentresolver/NoAppsAvailableEmptyStateProvider.java b/java/src/com/android/intentresolver/NoAppsAvailableEmptyStateProvider.java index a7b50f38..1900abee 100644 --- a/java/src/com/android/intentresolver/NoAppsAvailableEmptyStateProvider.java +++ b/java/src/com/android/intentresolver/NoAppsAvailableEmptyStateProvider.java @@ -28,8 +28,8 @@ import android.content.pm.ResolveInfo; import android.os.UserHandle; import android.stats.devicepolicy.nano.DevicePolicyEnums; -import com.android.intentresolver.AbstractMultiProfilePagerAdapter.EmptyState; -import com.android.intentresolver.AbstractMultiProfilePagerAdapter.EmptyStateProvider; +import com.android.intentresolver.MultiProfilePagerAdapter.EmptyState; +import com.android.intentresolver.MultiProfilePagerAdapter.EmptyStateProvider; import com.android.internal.R; import java.util.List; diff --git a/java/src/com/android/intentresolver/NoCrossProfileEmptyStateProvider.java b/java/src/com/android/intentresolver/NoCrossProfileEmptyStateProvider.java index 6f72bb00..ad262f0e 100644 --- a/java/src/com/android/intentresolver/NoCrossProfileEmptyStateProvider.java +++ b/java/src/com/android/intentresolver/NoCrossProfileEmptyStateProvider.java @@ -24,9 +24,9 @@ import android.app.admin.DevicePolicyManager; import android.content.Context; import android.os.UserHandle; -import com.android.intentresolver.AbstractMultiProfilePagerAdapter.CrossProfileIntentsChecker; -import com.android.intentresolver.AbstractMultiProfilePagerAdapter.EmptyState; -import com.android.intentresolver.AbstractMultiProfilePagerAdapter.EmptyStateProvider; +import com.android.intentresolver.MultiProfilePagerAdapter.CrossProfileIntentsChecker; +import com.android.intentresolver.MultiProfilePagerAdapter.EmptyState; +import com.android.intentresolver.MultiProfilePagerAdapter.EmptyStateProvider; /** * Empty state provider that does not allow cross profile sharing, it will return a blocker diff --git a/java/src/com/android/intentresolver/ResolverActivity.java b/java/src/com/android/intentresolver/ResolverActivity.java index 1161ca81..d1d86aff 100644 --- a/java/src/com/android/intentresolver/ResolverActivity.java +++ b/java/src/com/android/intentresolver/ResolverActivity.java @@ -98,12 +98,12 @@ import android.widget.Toast; import androidx.fragment.app.FragmentActivity; import androidx.viewpager.widget.ViewPager; -import com.android.intentresolver.AbstractMultiProfilePagerAdapter.CompositeEmptyStateProvider; -import com.android.intentresolver.AbstractMultiProfilePagerAdapter.CrossProfileIntentsChecker; -import com.android.intentresolver.AbstractMultiProfilePagerAdapter.EmptyStateProvider; -import com.android.intentresolver.AbstractMultiProfilePagerAdapter.MyUserIdProvider; -import com.android.intentresolver.AbstractMultiProfilePagerAdapter.OnSwitchOnWorkSelectedListener; -import com.android.intentresolver.AbstractMultiProfilePagerAdapter.Profile; +import com.android.intentresolver.MultiProfilePagerAdapter.CompositeEmptyStateProvider; +import com.android.intentresolver.MultiProfilePagerAdapter.CrossProfileIntentsChecker; +import com.android.intentresolver.MultiProfilePagerAdapter.EmptyStateProvider; +import com.android.intentresolver.MultiProfilePagerAdapter.MyUserIdProvider; +import com.android.intentresolver.MultiProfilePagerAdapter.OnSwitchOnWorkSelectedListener; +import com.android.intentresolver.MultiProfilePagerAdapter.Profile; import com.android.intentresolver.NoCrossProfileEmptyStateProvider.DevicePolicyBlockerEmptyState; import com.android.intentresolver.chooser.DisplayResolveInfo; import com.android.intentresolver.chooser.TargetInfo; @@ -201,7 +201,7 @@ public class ResolverActivity extends FragmentActivity implements private TargetDataLoader mTargetDataLoader; @VisibleForTesting - protected AbstractMultiProfilePagerAdapter mMultiProfilePagerAdapter; + protected MultiProfilePagerAdapter mMultiProfilePagerAdapter; protected WorkProfileAvailabilityManager mWorkProfileAvailability; @@ -228,8 +228,8 @@ public class ResolverActivity extends FragmentActivity implements static final String EXTRA_CALLING_USER = "com.android.internal.app.ResolverActivity.EXTRA_CALLING_USER"; - protected static final int PROFILE_PERSONAL = AbstractMultiProfilePagerAdapter.PROFILE_PERSONAL; - protected static final int PROFILE_WORK = AbstractMultiProfilePagerAdapter.PROFILE_WORK; + protected static final int PROFILE_PERSONAL = MultiProfilePagerAdapter.PROFILE_PERSONAL; + protected static final int PROFILE_WORK = MultiProfilePagerAdapter.PROFILE_WORK; private UserHandle mHeaderCreatorUser; @@ -496,12 +496,12 @@ public class ResolverActivity extends FragmentActivity implements + (categories != null ? Arrays.toString(categories.toArray()) : "")); } - protected AbstractMultiProfilePagerAdapter createMultiProfilePagerAdapter( + protected MultiProfilePagerAdapter createMultiProfilePagerAdapter( Intent[] initialIntents, List resolutionList, boolean filterLastUsed, TargetDataLoader targetDataLoader) { - AbstractMultiProfilePagerAdapter resolverMultiProfilePagerAdapter = null; + MultiProfilePagerAdapter resolverMultiProfilePagerAdapter = null; if (shouldShowTabs()) { resolverMultiProfilePagerAdapter = createResolverMultiProfilePagerAdapterForTwoProfiles( @@ -521,7 +521,7 @@ public class ResolverActivity extends FragmentActivity implements return new EmptyStateProvider() {}; } - final AbstractMultiProfilePagerAdapter.EmptyState + final MultiProfilePagerAdapter.EmptyState noWorkToPersonalEmptyState = new DevicePolicyBlockerEmptyState(/* context= */ this, /* devicePolicyStringTitleId= */ RESOLVER_CROSS_PROFILE_BLOCKED_TITLE, @@ -533,7 +533,7 @@ public class ResolverActivity extends FragmentActivity implements /* devicePolicyEventCategory= */ ResolverActivity.METRICS_CATEGORY_RESOLVER); - final AbstractMultiProfilePagerAdapter.EmptyState noPersonalToWorkEmptyState = + final MultiProfilePagerAdapter.EmptyState noPersonalToWorkEmptyState = new DevicePolicyBlockerEmptyState(/* context= */ this, /* devicePolicyStringTitleId= */ RESOLVER_CROSS_PROFILE_BLOCKED_TITLE, /* defaultTitleResource= */ R.string.resolver_cross_profile_blocked, @@ -2080,7 +2080,7 @@ public class ResolverActivity extends FragmentActivity implements viewPager.setVisibility(View.VISIBLE); tabHost.setCurrentTab(mMultiProfilePagerAdapter.getCurrentPage()); mMultiProfilePagerAdapter.setOnProfileSelectedListener( - new AbstractMultiProfilePagerAdapter.OnProfileSelectedListener() { + new MultiProfilePagerAdapter.OnProfileSelectedListener() { @Override public void onProfileSelected(int index) { tabHost.setCurrentTab(index); diff --git a/java/src/com/android/intentresolver/ResolverMultiProfilePagerAdapter.java b/java/src/com/android/intentresolver/ResolverMultiProfilePagerAdapter.java index 85d97ad5..9fb35948 100644 --- a/java/src/com/android/intentresolver/ResolverMultiProfilePagerAdapter.java +++ b/java/src/com/android/intentresolver/ResolverMultiProfilePagerAdapter.java @@ -36,7 +36,7 @@ import java.util.function.Supplier; */ @VisibleForTesting public class ResolverMultiProfilePagerAdapter extends - GenericMultiProfilePagerAdapter { + MultiProfilePagerAdapter { private final BottomPaddingOverrideSupplier mBottomPaddingOverrideSupplier; ResolverMultiProfilePagerAdapter( @@ -86,7 +86,6 @@ public class ResolverMultiProfilePagerAdapter extends UserHandle cloneProfileUserHandle, BottomPaddingOverrideSupplier bottomPaddingOverrideSupplier) { super( - context, listAdapter -> listAdapter, (listView, bindAdapter) -> listView.setAdapter(bindAdapter), listAdapters, diff --git a/java/src/com/android/intentresolver/WorkProfilePausedEmptyStateProvider.java b/java/src/com/android/intentresolver/WorkProfilePausedEmptyStateProvider.java index 2f3dfbd5..9ea7ceee 100644 --- a/java/src/com/android/intentresolver/WorkProfilePausedEmptyStateProvider.java +++ b/java/src/com/android/intentresolver/WorkProfilePausedEmptyStateProvider.java @@ -26,9 +26,9 @@ import android.content.Context; import android.os.UserHandle; import android.stats.devicepolicy.nano.DevicePolicyEnums; -import com.android.intentresolver.AbstractMultiProfilePagerAdapter.EmptyState; -import com.android.intentresolver.AbstractMultiProfilePagerAdapter.EmptyStateProvider; -import com.android.intentresolver.AbstractMultiProfilePagerAdapter.OnSwitchOnWorkSelectedListener; +import com.android.intentresolver.MultiProfilePagerAdapter.EmptyState; +import com.android.intentresolver.MultiProfilePagerAdapter.EmptyStateProvider; +import com.android.intentresolver.MultiProfilePagerAdapter.OnSwitchOnWorkSelectedListener; /** * Chooser/ResolverActivity empty state provider that returns empty state which is shown when diff --git a/java/tests/src/com/android/intentresolver/ChooserActivityOverrideData.java b/java/tests/src/com/android/intentresolver/ChooserActivityOverrideData.java index b1424849..3bf144dd 100644 --- a/java/tests/src/com/android/intentresolver/ChooserActivityOverrideData.java +++ b/java/tests/src/com/android/intentresolver/ChooserActivityOverrideData.java @@ -26,7 +26,7 @@ import android.content.res.Resources; import android.database.Cursor; import android.os.UserHandle; -import com.android.intentresolver.AbstractMultiProfilePagerAdapter.CrossProfileIntentsChecker; +import com.android.intentresolver.MultiProfilePagerAdapter.CrossProfileIntentsChecker; import com.android.intentresolver.chooser.TargetInfo; import com.android.intentresolver.contentpreview.ImageLoader; import com.android.intentresolver.shortcuts.ShortcutLoader; diff --git a/java/tests/src/com/android/intentresolver/ChooserWrapperActivity.java b/java/tests/src/com/android/intentresolver/ChooserWrapperActivity.java index 48f8be5d..64c4a50a 100644 --- a/java/tests/src/com/android/intentresolver/ChooserWrapperActivity.java +++ b/java/tests/src/com/android/intentresolver/ChooserWrapperActivity.java @@ -34,7 +34,7 @@ import android.os.UserHandle; import androidx.lifecycle.ViewModelProvider; -import com.android.intentresolver.AbstractMultiProfilePagerAdapter.CrossProfileIntentsChecker; +import com.android.intentresolver.MultiProfilePagerAdapter.CrossProfileIntentsChecker; import com.android.intentresolver.chooser.DisplayResolveInfo; import com.android.intentresolver.chooser.TargetInfo; import com.android.intentresolver.grid.ChooserGridAdapter; diff --git a/java/tests/src/com/android/intentresolver/GenericMultiProfilePagerAdapterTest.kt b/java/tests/src/com/android/intentresolver/GenericMultiProfilePagerAdapterTest.kt deleted file mode 100644 index d8e1e4b9..00000000 --- a/java/tests/src/com/android/intentresolver/GenericMultiProfilePagerAdapterTest.kt +++ /dev/null @@ -1,276 +0,0 @@ -/* - * Copyright (C) 2023 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.android.intentresolver - -import android.os.UserHandle -import android.view.View -import android.widget.ListView -import androidx.test.platform.app.InstrumentationRegistry -import com.android.intentresolver.AbstractMultiProfilePagerAdapter.PROFILE_PERSONAL -import com.android.intentresolver.AbstractMultiProfilePagerAdapter.PROFILE_WORK -import com.google.common.collect.ImmutableList -import com.google.common.truth.Truth.assertThat -import java.util.Optional -import org.junit.Test -import org.mockito.Mockito.never -import org.mockito.Mockito.verify - -class GenericMultiProfilePagerAdapterTest { - private val PERSONAL_USER_HANDLE = UserHandle.of(10) - private val WORK_USER_HANDLE = UserHandle.of(20) - - private val context = InstrumentationRegistry.getInstrumentation().getContext() - - @Test - fun testSinglePageProfileAdapter() { - val personalListAdapter = - mock { whenever(getUserHandle()).thenReturn(PERSONAL_USER_HANDLE) } - val pagerAdapter = - GenericMultiProfilePagerAdapter( - context, - { listAdapter: ResolverListAdapter -> listAdapter }, - { listView: ListView, bindAdapter: ResolverListAdapter -> - listView.setAdapter(bindAdapter) - }, - ImmutableList.of(personalListAdapter), - object : AbstractMultiProfilePagerAdapter.EmptyStateProvider {}, - { false }, - PROFILE_PERSONAL, - null, - null, - { ListView(context) }, - { Optional.empty() } - ) - assertThat(pagerAdapter.count).isEqualTo(1) - assertThat(pagerAdapter.currentPage).isEqualTo(PROFILE_PERSONAL) - assertThat(pagerAdapter.currentUserHandle).isEqualTo(PERSONAL_USER_HANDLE) - assertThat(pagerAdapter.getAdapterForIndex(0)).isSameInstanceAs(personalListAdapter) - assertThat(pagerAdapter.activeListAdapter).isSameInstanceAs(personalListAdapter) - assertThat(pagerAdapter.inactiveListAdapter).isNull() - assertThat(pagerAdapter.personalListAdapter).isSameInstanceAs(personalListAdapter) - assertThat(pagerAdapter.workListAdapter).isNull() - assertThat(pagerAdapter.itemCount).isEqualTo(1) - // TODO: consider covering some of the package-private methods (and making them public?). - // TODO: consider exercising responsibilities as an implementation of a ViewPager adapter. - } - - @Test - fun testTwoProfilePagerAdapter() { - val personalListAdapter = - mock { whenever(getUserHandle()).thenReturn(PERSONAL_USER_HANDLE) } - val workListAdapter = - mock { whenever(getUserHandle()).thenReturn(WORK_USER_HANDLE) } - val pagerAdapter = - GenericMultiProfilePagerAdapter( - context, - { listAdapter: ResolverListAdapter -> listAdapter }, - { listView: ListView, bindAdapter: ResolverListAdapter -> - listView.setAdapter(bindAdapter) - }, - ImmutableList.of(personalListAdapter, workListAdapter), - object : AbstractMultiProfilePagerAdapter.EmptyStateProvider {}, - { false }, - PROFILE_PERSONAL, - WORK_USER_HANDLE, // TODO: why does this test pass even if this is null? - null, - { ListView(context) }, - { Optional.empty() } - ) - assertThat(pagerAdapter.count).isEqualTo(2) - assertThat(pagerAdapter.currentPage).isEqualTo(PROFILE_PERSONAL) - assertThat(pagerAdapter.currentUserHandle).isEqualTo(PERSONAL_USER_HANDLE) - assertThat(pagerAdapter.getAdapterForIndex(0)).isSameInstanceAs(personalListAdapter) - assertThat(pagerAdapter.getAdapterForIndex(1)).isSameInstanceAs(workListAdapter) - assertThat(pagerAdapter.activeListAdapter).isSameInstanceAs(personalListAdapter) - assertThat(pagerAdapter.inactiveListAdapter).isSameInstanceAs(workListAdapter) - assertThat(pagerAdapter.personalListAdapter).isSameInstanceAs(personalListAdapter) - assertThat(pagerAdapter.workListAdapter).isSameInstanceAs(workListAdapter) - assertThat(pagerAdapter.itemCount).isEqualTo(2) - // TODO: consider covering some of the package-private methods (and making them public?). - // TODO: consider exercising responsibilities as an implementation of a ViewPager adapter; - // especially matching profiles to ListViews? - // TODO: test ProfileSelectedListener (and getters for "current" state) as the selected - // page changes. Currently there's no API to change the selected page directly; that's - // only possible through manipulation of the bound ViewPager. - } - - @Test - fun testTwoProfilePagerAdapter_workIsDefault() { - val personalListAdapter = - mock { whenever(getUserHandle()).thenReturn(PERSONAL_USER_HANDLE) } - val workListAdapter = - mock { whenever(getUserHandle()).thenReturn(WORK_USER_HANDLE) } - val pagerAdapter = - GenericMultiProfilePagerAdapter( - context, - { listAdapter: ResolverListAdapter -> listAdapter }, - { listView: ListView, bindAdapter: ResolverListAdapter -> - listView.setAdapter(bindAdapter) - }, - ImmutableList.of(personalListAdapter, workListAdapter), - object : AbstractMultiProfilePagerAdapter.EmptyStateProvider {}, - { false }, - PROFILE_WORK, // <-- This test specifically requests we start on work profile. - WORK_USER_HANDLE, // TODO: why does this test pass even if this is null? - null, - { ListView(context) }, - { Optional.empty() } - ) - assertThat(pagerAdapter.count).isEqualTo(2) - assertThat(pagerAdapter.currentPage).isEqualTo(PROFILE_WORK) - assertThat(pagerAdapter.currentUserHandle).isEqualTo(WORK_USER_HANDLE) - assertThat(pagerAdapter.getAdapterForIndex(0)).isSameInstanceAs(personalListAdapter) - assertThat(pagerAdapter.getAdapterForIndex(1)).isSameInstanceAs(workListAdapter) - assertThat(pagerAdapter.activeListAdapter).isSameInstanceAs(workListAdapter) - assertThat(pagerAdapter.inactiveListAdapter).isSameInstanceAs(personalListAdapter) - assertThat(pagerAdapter.personalListAdapter).isSameInstanceAs(personalListAdapter) - assertThat(pagerAdapter.workListAdapter).isSameInstanceAs(workListAdapter) - assertThat(pagerAdapter.itemCount).isEqualTo(2) - // TODO: consider covering some of the package-private methods (and making them public?). - // TODO: test ProfileSelectedListener (and getters for "current" state) as the selected - // page changes. Currently there's no API to change the selected page directly; that's - // only possible through manipulation of the bound ViewPager. - } - - @Test - fun testBottomPaddingDelegate_default() { - val container = - mock { - whenever(getPaddingLeft()).thenReturn(1) - whenever(getPaddingTop()).thenReturn(2) - whenever(getPaddingRight()).thenReturn(3) - whenever(getPaddingBottom()).thenReturn(4) - } - val pagerAdapter = - GenericMultiProfilePagerAdapter( - context, - { listAdapter: ResolverListAdapter -> listAdapter }, - { listView: ListView, bindAdapter: ResolverListAdapter -> - listView.setAdapter(bindAdapter) - }, - ImmutableList.of(), - object : AbstractMultiProfilePagerAdapter.EmptyStateProvider {}, - { false }, - PROFILE_PERSONAL, - null, - null, - { ListView(context) }, - { Optional.empty() } - ) - pagerAdapter.setupContainerPadding(container) - verify(container, never()).setPadding(any(), any(), any(), any()) - } - - @Test - fun testBottomPaddingDelegate_override() { - val container = - mock { - whenever(getPaddingLeft()).thenReturn(1) - whenever(getPaddingTop()).thenReturn(2) - whenever(getPaddingRight()).thenReturn(3) - whenever(getPaddingBottom()).thenReturn(4) - } - val pagerAdapter = - GenericMultiProfilePagerAdapter( - context, - { listAdapter: ResolverListAdapter -> listAdapter }, - { listView: ListView, bindAdapter: ResolverListAdapter -> - listView.setAdapter(bindAdapter) - }, - ImmutableList.of(), - object : AbstractMultiProfilePagerAdapter.EmptyStateProvider {}, - { false }, - PROFILE_PERSONAL, - null, - null, - { ListView(context) }, - { Optional.of(42) } - ) - pagerAdapter.setupContainerPadding(container) - verify(container).setPadding(1, 2, 3, 42) - } - - @Test - fun testPresumedQuietModeEmptyStateForWorkProfile_whenQuiet() { - // TODO: this is "presumed" because the conditions to determine whether we "should" show an - // empty state aren't enforced to align with the conditions when we actually *would* -- I - // believe `shouldShowEmptyStateScreen` should be implemented in terms of the provider? - val personalListAdapter = - mock { - whenever(getUserHandle()).thenReturn(PERSONAL_USER_HANDLE) - whenever(getUnfilteredCount()).thenReturn(1) - } - val workListAdapter = - mock { - whenever(getUserHandle()).thenReturn(WORK_USER_HANDLE) - whenever(getUnfilteredCount()).thenReturn(1) - } - val pagerAdapter = - GenericMultiProfilePagerAdapter( - context, - { listAdapter: ResolverListAdapter -> listAdapter }, - { listView: ListView, bindAdapter: ResolverListAdapter -> - listView.setAdapter(bindAdapter) - }, - ImmutableList.of(personalListAdapter, workListAdapter), - object : AbstractMultiProfilePagerAdapter.EmptyStateProvider {}, - { true }, // <-- Work mode is quiet. - PROFILE_WORK, - WORK_USER_HANDLE, - null, - { ListView(context) }, - { Optional.empty() } - ) - assertThat(pagerAdapter.shouldShowEmptyStateScreen(workListAdapter)).isTrue() - assertThat(pagerAdapter.shouldShowEmptyStateScreen(personalListAdapter)).isFalse() - } - - @Test - fun testPresumedQuietModeEmptyStateForWorkProfile_notWhenNotQuiet() { - // TODO: this is "presumed" because the conditions to determine whether we "should" show an - // empty state aren't enforced to align with the conditions when we actually *would* -- I - // believe `shouldShowEmptyStateScreen` should be implemented in terms of the provider? - val personalListAdapter = - mock { - whenever(getUserHandle()).thenReturn(PERSONAL_USER_HANDLE) - whenever(getUnfilteredCount()).thenReturn(1) - } - val workListAdapter = - mock { - whenever(getUserHandle()).thenReturn(WORK_USER_HANDLE) - whenever(getUnfilteredCount()).thenReturn(1) - } - val pagerAdapter = - GenericMultiProfilePagerAdapter( - context, - { listAdapter: ResolverListAdapter -> listAdapter }, - { listView: ListView, bindAdapter: ResolverListAdapter -> - listView.setAdapter(bindAdapter) - }, - ImmutableList.of(personalListAdapter, workListAdapter), - object : AbstractMultiProfilePagerAdapter.EmptyStateProvider {}, - { false }, // <-- Work mode is not quiet. - PROFILE_WORK, - WORK_USER_HANDLE, - null, - { ListView(context) }, - { Optional.empty() } - ) - assertThat(pagerAdapter.shouldShowEmptyStateScreen(workListAdapter)).isFalse() - assertThat(pagerAdapter.shouldShowEmptyStateScreen(personalListAdapter)).isFalse() - } -} diff --git a/java/tests/src/com/android/intentresolver/MultiProfilePagerAdapterTest.kt b/java/tests/src/com/android/intentresolver/MultiProfilePagerAdapterTest.kt new file mode 100644 index 00000000..dcf53cea --- /dev/null +++ b/java/tests/src/com/android/intentresolver/MultiProfilePagerAdapterTest.kt @@ -0,0 +1,269 @@ +/* + * Copyright (C) 2023 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.intentresolver + +import android.os.UserHandle +import android.view.View +import android.widget.ListView +import androidx.test.platform.app.InstrumentationRegistry +import com.android.intentresolver.MultiProfilePagerAdapter.PROFILE_PERSONAL +import com.android.intentresolver.MultiProfilePagerAdapter.PROFILE_WORK +import com.google.common.collect.ImmutableList +import com.google.common.truth.Truth.assertThat +import java.util.Optional +import org.junit.Test +import org.mockito.Mockito.never +import org.mockito.Mockito.verify + +class MultiProfilePagerAdapterTest { + private val PERSONAL_USER_HANDLE = UserHandle.of(10) + private val WORK_USER_HANDLE = UserHandle.of(20) + + private val context = InstrumentationRegistry.getInstrumentation().getContext() + + @Test + fun testSinglePageProfileAdapter() { + val personalListAdapter = + mock { whenever(getUserHandle()).thenReturn(PERSONAL_USER_HANDLE) } + val pagerAdapter = + MultiProfilePagerAdapter( + { listAdapter: ResolverListAdapter -> listAdapter }, + { listView: ListView, bindAdapter: ResolverListAdapter -> + listView.setAdapter(bindAdapter) + }, + ImmutableList.of(personalListAdapter), + object : MultiProfilePagerAdapter.EmptyStateProvider {}, + { false }, + PROFILE_PERSONAL, + null, + null, + { ListView(context) }, + { Optional.empty() } + ) + assertThat(pagerAdapter.count).isEqualTo(1) + assertThat(pagerAdapter.currentPage).isEqualTo(PROFILE_PERSONAL) + assertThat(pagerAdapter.currentUserHandle).isEqualTo(PERSONAL_USER_HANDLE) + assertThat(pagerAdapter.getAdapterForIndex(0)).isSameInstanceAs(personalListAdapter) + assertThat(pagerAdapter.activeListAdapter).isSameInstanceAs(personalListAdapter) + assertThat(pagerAdapter.inactiveListAdapter).isNull() + assertThat(pagerAdapter.personalListAdapter).isSameInstanceAs(personalListAdapter) + assertThat(pagerAdapter.workListAdapter).isNull() + assertThat(pagerAdapter.itemCount).isEqualTo(1) + // TODO: consider covering some of the package-private methods (and making them public?). + // TODO: consider exercising responsibilities as an implementation of a ViewPager adapter. + } + + @Test + fun testTwoProfilePagerAdapter() { + val personalListAdapter = + mock { whenever(getUserHandle()).thenReturn(PERSONAL_USER_HANDLE) } + val workListAdapter = + mock { whenever(getUserHandle()).thenReturn(WORK_USER_HANDLE) } + val pagerAdapter = + MultiProfilePagerAdapter( + { listAdapter: ResolverListAdapter -> listAdapter }, + { listView: ListView, bindAdapter: ResolverListAdapter -> + listView.setAdapter(bindAdapter) + }, + ImmutableList.of(personalListAdapter, workListAdapter), + object : MultiProfilePagerAdapter.EmptyStateProvider {}, + { false }, + PROFILE_PERSONAL, + WORK_USER_HANDLE, // TODO: why does this test pass even if this is null? + null, + { ListView(context) }, + { Optional.empty() } + ) + assertThat(pagerAdapter.count).isEqualTo(2) + assertThat(pagerAdapter.currentPage).isEqualTo(PROFILE_PERSONAL) + assertThat(pagerAdapter.currentUserHandle).isEqualTo(PERSONAL_USER_HANDLE) + assertThat(pagerAdapter.getAdapterForIndex(0)).isSameInstanceAs(personalListAdapter) + assertThat(pagerAdapter.getAdapterForIndex(1)).isSameInstanceAs(workListAdapter) + assertThat(pagerAdapter.activeListAdapter).isSameInstanceAs(personalListAdapter) + assertThat(pagerAdapter.inactiveListAdapter).isSameInstanceAs(workListAdapter) + assertThat(pagerAdapter.personalListAdapter).isSameInstanceAs(personalListAdapter) + assertThat(pagerAdapter.workListAdapter).isSameInstanceAs(workListAdapter) + assertThat(pagerAdapter.itemCount).isEqualTo(2) + // TODO: consider covering some of the package-private methods (and making them public?). + // TODO: consider exercising responsibilities as an implementation of a ViewPager adapter; + // especially matching profiles to ListViews? + // TODO: test ProfileSelectedListener (and getters for "current" state) as the selected + // page changes. Currently there's no API to change the selected page directly; that's + // only possible through manipulation of the bound ViewPager. + } + + @Test + fun testTwoProfilePagerAdapter_workIsDefault() { + val personalListAdapter = + mock { whenever(getUserHandle()).thenReturn(PERSONAL_USER_HANDLE) } + val workListAdapter = + mock { whenever(getUserHandle()).thenReturn(WORK_USER_HANDLE) } + val pagerAdapter = + MultiProfilePagerAdapter( + { listAdapter: ResolverListAdapter -> listAdapter }, + { listView: ListView, bindAdapter: ResolverListAdapter -> + listView.setAdapter(bindAdapter) + }, + ImmutableList.of(personalListAdapter, workListAdapter), + object : MultiProfilePagerAdapter.EmptyStateProvider {}, + { false }, + PROFILE_WORK, // <-- This test specifically requests we start on work profile. + WORK_USER_HANDLE, // TODO: why does this test pass even if this is null? + null, + { ListView(context) }, + { Optional.empty() } + ) + assertThat(pagerAdapter.count).isEqualTo(2) + assertThat(pagerAdapter.currentPage).isEqualTo(PROFILE_WORK) + assertThat(pagerAdapter.currentUserHandle).isEqualTo(WORK_USER_HANDLE) + assertThat(pagerAdapter.getAdapterForIndex(0)).isSameInstanceAs(personalListAdapter) + assertThat(pagerAdapter.getAdapterForIndex(1)).isSameInstanceAs(workListAdapter) + assertThat(pagerAdapter.activeListAdapter).isSameInstanceAs(workListAdapter) + assertThat(pagerAdapter.inactiveListAdapter).isSameInstanceAs(personalListAdapter) + assertThat(pagerAdapter.personalListAdapter).isSameInstanceAs(personalListAdapter) + assertThat(pagerAdapter.workListAdapter).isSameInstanceAs(workListAdapter) + assertThat(pagerAdapter.itemCount).isEqualTo(2) + // TODO: consider covering some of the package-private methods (and making them public?). + // TODO: test ProfileSelectedListener (and getters for "current" state) as the selected + // page changes. Currently there's no API to change the selected page directly; that's + // only possible through manipulation of the bound ViewPager. + } + + @Test + fun testBottomPaddingDelegate_default() { + val container = + mock { + whenever(getPaddingLeft()).thenReturn(1) + whenever(getPaddingTop()).thenReturn(2) + whenever(getPaddingRight()).thenReturn(3) + whenever(getPaddingBottom()).thenReturn(4) + } + val pagerAdapter = + MultiProfilePagerAdapter( + { listAdapter: ResolverListAdapter -> listAdapter }, + { listView: ListView, bindAdapter: ResolverListAdapter -> + listView.setAdapter(bindAdapter) + }, + ImmutableList.of(), + object : MultiProfilePagerAdapter.EmptyStateProvider {}, + { false }, + PROFILE_PERSONAL, + null, + null, + { ListView(context) }, + { Optional.empty() } + ) + pagerAdapter.setupContainerPadding(container) + verify(container, never()).setPadding(any(), any(), any(), any()) + } + + @Test + fun testBottomPaddingDelegate_override() { + val container = + mock { + whenever(getPaddingLeft()).thenReturn(1) + whenever(getPaddingTop()).thenReturn(2) + whenever(getPaddingRight()).thenReturn(3) + whenever(getPaddingBottom()).thenReturn(4) + } + val pagerAdapter = + MultiProfilePagerAdapter( + { listAdapter: ResolverListAdapter -> listAdapter }, + { listView: ListView, bindAdapter: ResolverListAdapter -> + listView.setAdapter(bindAdapter) + }, + ImmutableList.of(), + object : MultiProfilePagerAdapter.EmptyStateProvider {}, + { false }, + PROFILE_PERSONAL, + null, + null, + { ListView(context) }, + { Optional.of(42) } + ) + pagerAdapter.setupContainerPadding(container) + verify(container).setPadding(1, 2, 3, 42) + } + + @Test + fun testPresumedQuietModeEmptyStateForWorkProfile_whenQuiet() { + // TODO: this is "presumed" because the conditions to determine whether we "should" show an + // empty state aren't enforced to align with the conditions when we actually *would* -- I + // believe `shouldShowEmptyStateScreen` should be implemented in terms of the provider? + val personalListAdapter = + mock { + whenever(getUserHandle()).thenReturn(PERSONAL_USER_HANDLE) + whenever(getUnfilteredCount()).thenReturn(1) + } + val workListAdapter = + mock { + whenever(getUserHandle()).thenReturn(WORK_USER_HANDLE) + whenever(getUnfilteredCount()).thenReturn(1) + } + val pagerAdapter = + MultiProfilePagerAdapter( + { listAdapter: ResolverListAdapter -> listAdapter }, + { listView: ListView, bindAdapter: ResolverListAdapter -> + listView.setAdapter(bindAdapter) + }, + ImmutableList.of(personalListAdapter, workListAdapter), + object : MultiProfilePagerAdapter.EmptyStateProvider {}, + { true }, // <-- Work mode is quiet. + PROFILE_WORK, + WORK_USER_HANDLE, + null, + { ListView(context) }, + { Optional.empty() } + ) + assertThat(pagerAdapter.shouldShowEmptyStateScreen(workListAdapter)).isTrue() + assertThat(pagerAdapter.shouldShowEmptyStateScreen(personalListAdapter)).isFalse() + } + + @Test + fun testPresumedQuietModeEmptyStateForWorkProfile_notWhenNotQuiet() { + // TODO: this is "presumed" because the conditions to determine whether we "should" show an + // empty state aren't enforced to align with the conditions when we actually *would* -- I + // believe `shouldShowEmptyStateScreen` should be implemented in terms of the provider? + val personalListAdapter = + mock { + whenever(getUserHandle()).thenReturn(PERSONAL_USER_HANDLE) + whenever(getUnfilteredCount()).thenReturn(1) + } + val workListAdapter = + mock { + whenever(getUserHandle()).thenReturn(WORK_USER_HANDLE) + whenever(getUnfilteredCount()).thenReturn(1) + } + val pagerAdapter = + MultiProfilePagerAdapter( + { listAdapter: ResolverListAdapter -> listAdapter }, + { listView: ListView, bindAdapter: ResolverListAdapter -> + listView.setAdapter(bindAdapter) + }, + ImmutableList.of(personalListAdapter, workListAdapter), + object : MultiProfilePagerAdapter.EmptyStateProvider {}, + { false }, // <-- Work mode is not quiet. + PROFILE_WORK, + WORK_USER_HANDLE, + null, + { ListView(context) }, + { Optional.empty() } + ) + assertThat(pagerAdapter.shouldShowEmptyStateScreen(workListAdapter)).isFalse() + assertThat(pagerAdapter.shouldShowEmptyStateScreen(personalListAdapter)).isFalse() + } +} diff --git a/java/tests/src/com/android/intentresolver/ResolverWrapperActivity.java b/java/tests/src/com/android/intentresolver/ResolverWrapperActivity.java index 1bb05437..d4bd123a 100644 --- a/java/tests/src/com/android/intentresolver/ResolverWrapperActivity.java +++ b/java/tests/src/com/android/intentresolver/ResolverWrapperActivity.java @@ -34,7 +34,7 @@ import android.util.Pair; import androidx.annotation.NonNull; import androidx.test.espresso.idling.CountingIdlingResource; -import com.android.intentresolver.AbstractMultiProfilePagerAdapter.CrossProfileIntentsChecker; +import com.android.intentresolver.MultiProfilePagerAdapter.CrossProfileIntentsChecker; import com.android.intentresolver.chooser.DisplayResolveInfo; import com.android.intentresolver.chooser.SelectableTargetInfo; import com.android.intentresolver.chooser.TargetInfo; -- cgit v1.2.3-59-g8ed1b From d2f4d2171b3f8c42dc69d6636e09542dce9ae7bf Mon Sep 17 00:00:00 2001 From: Joshua Trask Date: Tue, 26 Sep 2023 17:27:53 +0000 Subject: Begin extracting `emptystate` module. This CL just contains the initial code-moves to group relevant classes (including some inner classes/interfaces that are being pulled out of `MultiProfilePagerAdapter`), without any other anticipated fixes or reorganization. This corresponds to snapshots 1-3 of the prototype ag/24516421 (to be followed with the other work that was started in that CL). Bug: 302311217 Test: IntentResolverUnitTests, CtsSharesheetDeviceTest Change-Id: If82a761193f6ff0605c5a46c106f7c95699350c0 --- .../android/intentresolver/ChooserActivity.java | 7 +- .../ChooserMultiProfilePagerAdapter.java | 1 + .../intentresolver/IntentForwarderActivity.java | 2 +- .../intentresolver/MultiProfilePagerAdapter.java | 139 +------------------ .../NoAppsAvailableEmptyStateProvider.java | 153 --------------------- .../NoCrossProfileEmptyStateProvider.java | 136 ------------------ .../android/intentresolver/ResolverActivity.java | 24 ++-- .../intentresolver/ResolverListAdapter.java | 5 +- .../ResolverMultiProfilePagerAdapter.java | 1 + .../WorkProfilePausedEmptyStateProvider.java | 112 --------------- .../emptystate/CompositeEmptyStateProvider.java | 46 +++++++ .../emptystate/CrossProfileIntentsChecker.java | 59 ++++++++ .../intentresolver/emptystate/EmptyState.java | 78 +++++++++++ .../emptystate/EmptyStateProvider.java | 37 +++++ .../NoAppsAvailableEmptyStateProvider.java | 153 +++++++++++++++++++++ .../NoCrossProfileEmptyStateProvider.java | 134 ++++++++++++++++++ .../WorkProfilePausedEmptyStateProvider.java | 113 +++++++++++++++ .../ChooserActivityOverrideData.java | 2 +- .../intentresolver/ChooserWrapperActivity.java | 2 +- .../intentresolver/MultiProfilePagerAdapterTest.kt | 15 +- .../intentresolver/ResolverWrapperActivity.java | 2 +- .../emptystate/CompositeEmptyStateProviderTest.kt | 65 +++++++++ .../emptystate/CrossProfileIntentsCheckerTest.kt | 84 +++++++++++ 23 files changed, 807 insertions(+), 563 deletions(-) delete mode 100644 java/src/com/android/intentresolver/NoAppsAvailableEmptyStateProvider.java delete mode 100644 java/src/com/android/intentresolver/NoCrossProfileEmptyStateProvider.java delete mode 100644 java/src/com/android/intentresolver/WorkProfilePausedEmptyStateProvider.java create mode 100644 java/src/com/android/intentresolver/emptystate/CompositeEmptyStateProvider.java create mode 100644 java/src/com/android/intentresolver/emptystate/CrossProfileIntentsChecker.java create mode 100644 java/src/com/android/intentresolver/emptystate/EmptyState.java create mode 100644 java/src/com/android/intentresolver/emptystate/EmptyStateProvider.java create mode 100644 java/src/com/android/intentresolver/emptystate/NoAppsAvailableEmptyStateProvider.java create mode 100644 java/src/com/android/intentresolver/emptystate/NoCrossProfileEmptyStateProvider.java create mode 100644 java/src/com/android/intentresolver/emptystate/WorkProfilePausedEmptyStateProvider.java create mode 100644 java/tests/src/com/android/intentresolver/emptystate/CompositeEmptyStateProviderTest.kt create mode 100644 java/tests/src/com/android/intentresolver/emptystate/CrossProfileIntentsCheckerTest.kt (limited to 'java/src') diff --git a/java/src/com/android/intentresolver/ChooserActivity.java b/java/src/com/android/intentresolver/ChooserActivity.java index 7b4f4827..182cfafe 100644 --- a/java/src/com/android/intentresolver/ChooserActivity.java +++ b/java/src/com/android/intentresolver/ChooserActivity.java @@ -73,9 +73,6 @@ import androidx.recyclerview.widget.GridLayoutManager; import androidx.recyclerview.widget.RecyclerView; import androidx.viewpager.widget.ViewPager; -import com.android.intentresolver.MultiProfilePagerAdapter.EmptyState; -import com.android.intentresolver.MultiProfilePagerAdapter.EmptyStateProvider; -import com.android.intentresolver.NoCrossProfileEmptyStateProvider.DevicePolicyBlockerEmptyState; import com.android.intentresolver.chooser.DisplayResolveInfo; import com.android.intentresolver.chooser.MultiDisplayResolveInfo; import com.android.intentresolver.chooser.TargetInfo; @@ -83,6 +80,10 @@ import com.android.intentresolver.contentpreview.BasePreviewViewModel; import com.android.intentresolver.contentpreview.ChooserContentPreviewUi; import com.android.intentresolver.contentpreview.HeadlineGeneratorImpl; import com.android.intentresolver.contentpreview.PreviewViewModel; +import com.android.intentresolver.emptystate.EmptyState; +import com.android.intentresolver.emptystate.EmptyStateProvider; +import com.android.intentresolver.emptystate.NoCrossProfileEmptyStateProvider; +import com.android.intentresolver.emptystate.NoCrossProfileEmptyStateProvider.DevicePolicyBlockerEmptyState; import com.android.intentresolver.grid.ChooserGridAdapter; import com.android.intentresolver.icons.DefaultTargetDataLoader; import com.android.intentresolver.icons.TargetDataLoader; diff --git a/java/src/com/android/intentresolver/ChooserMultiProfilePagerAdapter.java b/java/src/com/android/intentresolver/ChooserMultiProfilePagerAdapter.java index 75ff3a7f..23a081d2 100644 --- a/java/src/com/android/intentresolver/ChooserMultiProfilePagerAdapter.java +++ b/java/src/com/android/intentresolver/ChooserMultiProfilePagerAdapter.java @@ -25,6 +25,7 @@ import androidx.recyclerview.widget.GridLayoutManager; import androidx.recyclerview.widget.RecyclerView; import androidx.viewpager.widget.PagerAdapter; +import com.android.intentresolver.emptystate.EmptyStateProvider; import com.android.intentresolver.grid.ChooserGridAdapter; import com.android.intentresolver.measurements.Tracer; import com.android.internal.annotations.VisibleForTesting; diff --git a/java/src/com/android/intentresolver/IntentForwarderActivity.java b/java/src/com/android/intentresolver/IntentForwarderActivity.java index 5e8945f1..acee1316 100644 --- a/java/src/com/android/intentresolver/IntentForwarderActivity.java +++ b/java/src/com/android/intentresolver/IntentForwarderActivity.java @@ -309,7 +309,7 @@ public class IntentForwarderActivity extends Activity { * Check whether the intent can be forwarded to target user. Return the intent used for * forwarding if it can be forwarded, {@code null} otherwise. */ - static Intent canForward(Intent incomingIntent, int sourceUserId, int targetUserId, + public static Intent canForward(Intent incomingIntent, int sourceUserId, int targetUserId, IPackageManager packageManager, ContentResolver contentResolver) { Intent forwardIntent = new Intent(incomingIntent); forwardIntent.addFlags( diff --git a/java/src/com/android/intentresolver/MultiProfilePagerAdapter.java b/java/src/com/android/intentresolver/MultiProfilePagerAdapter.java index cc079a87..2c98d89f 100644 --- a/java/src/com/android/intentresolver/MultiProfilePagerAdapter.java +++ b/java/src/com/android/intentresolver/MultiProfilePagerAdapter.java @@ -16,14 +16,7 @@ package com.android.intentresolver; import android.annotation.IntDef; -import android.annotation.NonNull; import android.annotation.Nullable; -import android.annotation.UserIdInt; -import android.app.AppGlobals; -import android.content.ContentResolver; -import android.content.Context; -import android.content.Intent; -import android.content.pm.IPackageManager; import android.os.Trace; import android.os.UserHandle; import android.view.View; @@ -34,13 +27,13 @@ import android.widget.TextView; import androidx.viewpager.widget.PagerAdapter; import androidx.viewpager.widget.ViewPager; +import com.android.intentresolver.emptystate.EmptyState; +import com.android.intentresolver.emptystate.EmptyStateProvider; import com.android.internal.annotations.VisibleForTesting; import com.google.common.collect.ImmutableList; import java.util.HashSet; -import java.util.List; -import java.util.Objects; import java.util.Optional; import java.util.Set; import java.util.function.Function; @@ -450,32 +443,6 @@ public class MultiProfilePagerAdapter< } } - /** - * Utility class to check if there are cross profile intents, it is in a separate class so - * it could be mocked in tests - */ - public static class CrossProfileIntentsChecker { - - private final ContentResolver mContentResolver; - - public CrossProfileIntentsChecker(@NonNull ContentResolver contentResolver) { - mContentResolver = contentResolver; - } - - /** - * Returns {@code true} if at least one of the provided {@code intents} can be forwarded - * from {@code source} (user id) to {@code target} (user id). - */ - public boolean hasCrossProfileIntents(List intents, @UserIdInt int source, - @UserIdInt int target) { - IPackageManager packageManager = AppGlobals.getPackageManager(); - - return intents.stream().anyMatch(intent -> - null != IntentForwarderActivity.canForward(intent, source, target, - packageManager, mContentResolver)); - } - } - protected void showEmptyState( ListAdapterT activeListAdapter, EmptyState emptyState, @@ -620,108 +587,6 @@ public class MultiProfilePagerAdapter< void onProfilePageStateChanged(int state); } - /** - * Returns an empty state to show for the current profile page (tab) if necessary. - * This could be used e.g. to show a blocker on a tab if device management policy doesn't - * allow to use it or there are no apps available. - */ - public interface EmptyStateProvider { - /** - * When a non-null empty state is returned the corresponding profile page will show - * this empty state - * @param resolverListAdapter the current adapter - */ - @Nullable - default EmptyState getEmptyState(ResolverListAdapter resolverListAdapter) { - return null; - } - } - - /** - * Empty state provider that combines multiple providers. Providers earlier in the list have - * priority, that is if there is a provider that returns non-null empty state then all further - * providers will be ignored. - */ - public static class CompositeEmptyStateProvider implements EmptyStateProvider { - - private final EmptyStateProvider[] mProviders; - - public CompositeEmptyStateProvider(EmptyStateProvider... providers) { - mProviders = providers; - } - - @Nullable - @Override - public EmptyState getEmptyState(ResolverListAdapter resolverListAdapter) { - for (EmptyStateProvider provider : mProviders) { - EmptyState emptyState = provider.getEmptyState(resolverListAdapter); - if (emptyState != null) { - return emptyState; - } - } - return null; - } - } - - /** - * Describes how the blocked empty state should look like for a profile tab - */ - public interface EmptyState { - /** - * Title that will be shown on the empty state - */ - @Nullable - default String getTitle() { - return null; - } - - /** - * Subtitle that will be shown underneath the title on the empty state - */ - @Nullable - default String getSubtitle() { - return null; - } - - /** - * If non-null then a button will be shown and this listener will be called - * when the button is clicked - */ - @Nullable - default ClickListener getButtonClickListener() { - return null; - } - - /** - * If true then default text ('No apps can perform this action') and style for the empty - * state will be applied, title and subtitle will be ignored. - */ - default boolean useDefaultEmptyView() { - return false; - } - - /** - * Returns true if for this empty state we should skip rebuilding of the apps list - * for this tab. - */ - default boolean shouldSkipDataRebuild() { - return false; - } - - /** - * Called when empty state is shown, could be used e.g. to track analytics events - */ - default void onEmptyStateShown() {} - - interface ClickListener { - void onClick(TabControl currentTab); - } - - interface TabControl { - void showSpinner(); - } - } - /** * Listener for when the user switches on the work profile from the work tab. */ diff --git a/java/src/com/android/intentresolver/NoAppsAvailableEmptyStateProvider.java b/java/src/com/android/intentresolver/NoAppsAvailableEmptyStateProvider.java deleted file mode 100644 index 1900abee..00000000 --- a/java/src/com/android/intentresolver/NoAppsAvailableEmptyStateProvider.java +++ /dev/null @@ -1,153 +0,0 @@ -/* - * Copyright (C) 2022 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.android.intentresolver; - -import static android.app.admin.DevicePolicyResources.Strings.Core.RESOLVER_NO_PERSONAL_APPS; -import static android.app.admin.DevicePolicyResources.Strings.Core.RESOLVER_NO_WORK_APPS; - -import android.annotation.NonNull; -import android.annotation.Nullable; -import android.app.admin.DevicePolicyEventLogger; -import android.app.admin.DevicePolicyManager; -import android.content.Context; -import android.content.pm.ResolveInfo; -import android.os.UserHandle; -import android.stats.devicepolicy.nano.DevicePolicyEnums; - -import com.android.intentresolver.MultiProfilePagerAdapter.EmptyState; -import com.android.intentresolver.MultiProfilePagerAdapter.EmptyStateProvider; -import com.android.internal.R; - -import java.util.List; - -/** - * Chooser/ResolverActivity empty state provider that returns empty state which is shown when - * there are no apps available. - */ -public class NoAppsAvailableEmptyStateProvider implements EmptyStateProvider { - - @NonNull - private final Context mContext; - @Nullable - private final UserHandle mWorkProfileUserHandle; - @Nullable - private final UserHandle mPersonalProfileUserHandle; - @NonNull - private final String mMetricsCategory; - @NonNull - private final UserHandle mTabOwnerUserHandleForLaunch; - - public NoAppsAvailableEmptyStateProvider(Context context, UserHandle workProfileUserHandle, - UserHandle personalProfileUserHandle, String metricsCategory, - UserHandle tabOwnerUserHandleForLaunch) { - mContext = context; - mWorkProfileUserHandle = workProfileUserHandle; - mPersonalProfileUserHandle = personalProfileUserHandle; - mMetricsCategory = metricsCategory; - mTabOwnerUserHandleForLaunch = tabOwnerUserHandleForLaunch; - } - - @Nullable - @Override - @SuppressWarnings("ReferenceEquality") - public EmptyState getEmptyState(ResolverListAdapter resolverListAdapter) { - UserHandle listUserHandle = resolverListAdapter.getUserHandle(); - - if (mWorkProfileUserHandle != null - && (mTabOwnerUserHandleForLaunch.equals(listUserHandle) - || !hasAppsInOtherProfile(resolverListAdapter))) { - - String title; - if (listUserHandle == mPersonalProfileUserHandle) { - title = mContext.getSystemService( - DevicePolicyManager.class).getResources().getString( - RESOLVER_NO_PERSONAL_APPS, - () -> mContext.getString(R.string.resolver_no_personal_apps_available)); - } else { - title = mContext.getSystemService( - DevicePolicyManager.class).getResources().getString( - RESOLVER_NO_WORK_APPS, - () -> mContext.getString(R.string.resolver_no_work_apps_available)); - } - - return new NoAppsAvailableEmptyState( - title, mMetricsCategory, - /* isPersonalProfile= */ listUserHandle == mPersonalProfileUserHandle - ); - } else if (mWorkProfileUserHandle == null) { - // Return default empty state without tracking - return new DefaultEmptyState(); - } - - return null; - } - - private boolean hasAppsInOtherProfile(ResolverListAdapter adapter) { - if (mWorkProfileUserHandle == null) { - return false; - } - List resolversForIntent = - adapter.getResolversForUser(mTabOwnerUserHandleForLaunch); - for (ResolvedComponentInfo info : resolversForIntent) { - ResolveInfo resolveInfo = info.getResolveInfoAt(0); - if (resolveInfo.targetUserId != UserHandle.USER_CURRENT) { - return true; - } - } - return false; - } - - public static class DefaultEmptyState implements EmptyState { - @Override - public boolean useDefaultEmptyView() { - return true; - } - } - - public static class NoAppsAvailableEmptyState implements EmptyState { - - @NonNull - private String mTitle; - - @NonNull - private String mMetricsCategory; - - private boolean mIsPersonalProfile; - - public NoAppsAvailableEmptyState(String title, String metricsCategory, - boolean isPersonalProfile) { - mTitle = title; - mMetricsCategory = metricsCategory; - mIsPersonalProfile = isPersonalProfile; - } - - @Nullable - @Override - public String getTitle() { - return mTitle; - } - - @Override - public void onEmptyStateShown() { - DevicePolicyEventLogger.createEvent( - DevicePolicyEnums.RESOLVER_EMPTY_STATE_NO_APPS_RESOLVED) - .setStrings(mMetricsCategory) - .setBoolean(/*isPersonalProfile*/ mIsPersonalProfile) - .write(); - } - } -} diff --git a/java/src/com/android/intentresolver/NoCrossProfileEmptyStateProvider.java b/java/src/com/android/intentresolver/NoCrossProfileEmptyStateProvider.java deleted file mode 100644 index ad262f0e..00000000 --- a/java/src/com/android/intentresolver/NoCrossProfileEmptyStateProvider.java +++ /dev/null @@ -1,136 +0,0 @@ -/* - * Copyright (C) 2022 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.android.intentresolver; - -import android.annotation.NonNull; -import android.annotation.Nullable; -import android.annotation.StringRes; -import android.app.admin.DevicePolicyEventLogger; -import android.app.admin.DevicePolicyManager; -import android.content.Context; -import android.os.UserHandle; - -import com.android.intentresolver.MultiProfilePagerAdapter.CrossProfileIntentsChecker; -import com.android.intentresolver.MultiProfilePagerAdapter.EmptyState; -import com.android.intentresolver.MultiProfilePagerAdapter.EmptyStateProvider; - -/** - * Empty state provider that does not allow cross profile sharing, it will return a blocker - * in case if the profile of the current tab is not the same as the profile of the calling app. - */ -public class NoCrossProfileEmptyStateProvider implements EmptyStateProvider { - - private final UserHandle mPersonalProfileUserHandle; - private final EmptyState mNoWorkToPersonalEmptyState; - private final EmptyState mNoPersonalToWorkEmptyState; - private final CrossProfileIntentsChecker mCrossProfileIntentsChecker; - private final UserHandle mTabOwnerUserHandleForLaunch; - - public NoCrossProfileEmptyStateProvider(UserHandle personalUserHandle, - EmptyState noWorkToPersonalEmptyState, - EmptyState noPersonalToWorkEmptyState, - CrossProfileIntentsChecker crossProfileIntentsChecker, - UserHandle tabOwnerUserHandleForLaunch) { - mPersonalProfileUserHandle = personalUserHandle; - mNoWorkToPersonalEmptyState = noWorkToPersonalEmptyState; - mNoPersonalToWorkEmptyState = noPersonalToWorkEmptyState; - mCrossProfileIntentsChecker = crossProfileIntentsChecker; - mTabOwnerUserHandleForLaunch = tabOwnerUserHandleForLaunch; - } - - @Nullable - @Override - public EmptyState getEmptyState(ResolverListAdapter resolverListAdapter) { - boolean shouldShowBlocker = - !mTabOwnerUserHandleForLaunch.equals(resolverListAdapter.getUserHandle()) - && !mCrossProfileIntentsChecker - .hasCrossProfileIntents(resolverListAdapter.getIntents(), - mTabOwnerUserHandleForLaunch.getIdentifier(), - resolverListAdapter.getUserHandle().getIdentifier()); - - if (!shouldShowBlocker) { - return null; - } - - if (resolverListAdapter.getUserHandle().equals(mPersonalProfileUserHandle)) { - return mNoWorkToPersonalEmptyState; - } else { - return mNoPersonalToWorkEmptyState; - } - } - - - /** - * Empty state that gets strings from the device policy manager and tracks events into - * event logger of the device policy events. - */ - public static class DevicePolicyBlockerEmptyState implements EmptyState { - - @NonNull - private final Context mContext; - private final String mDevicePolicyStringTitleId; - @StringRes - private final int mDefaultTitleResource; - private final String mDevicePolicyStringSubtitleId; - @StringRes - private final int mDefaultSubtitleResource; - private final int mEventId; - @NonNull - private final String mEventCategory; - - public DevicePolicyBlockerEmptyState(Context context, String devicePolicyStringTitleId, - @StringRes int defaultTitleResource, String devicePolicyStringSubtitleId, - @StringRes int defaultSubtitleResource, - int devicePolicyEventId, String devicePolicyEventCategory) { - mContext = context; - mDevicePolicyStringTitleId = devicePolicyStringTitleId; - mDefaultTitleResource = defaultTitleResource; - mDevicePolicyStringSubtitleId = devicePolicyStringSubtitleId; - mDefaultSubtitleResource = defaultSubtitleResource; - mEventId = devicePolicyEventId; - mEventCategory = devicePolicyEventCategory; - } - - @Nullable - @Override - public String getTitle() { - return mContext.getSystemService(DevicePolicyManager.class).getResources().getString( - mDevicePolicyStringTitleId, - () -> mContext.getString(mDefaultTitleResource)); - } - - @Nullable - @Override - public String getSubtitle() { - return mContext.getSystemService(DevicePolicyManager.class).getResources().getString( - mDevicePolicyStringSubtitleId, - () -> mContext.getString(mDefaultSubtitleResource)); - } - - @Override - public void onEmptyStateShown() { - DevicePolicyEventLogger.createEvent(mEventId) - .setStrings(mEventCategory) - .write(); - } - - @Override - public boolean shouldSkipDataRebuild() { - return true; - } - } -} diff --git a/java/src/com/android/intentresolver/ResolverActivity.java b/java/src/com/android/intentresolver/ResolverActivity.java index d1d86aff..aa9d051c 100644 --- a/java/src/com/android/intentresolver/ResolverActivity.java +++ b/java/src/com/android/intentresolver/ResolverActivity.java @@ -33,6 +33,7 @@ import static android.content.PermissionChecker.PID_UNKNOWN; import static android.stats.devicepolicy.nano.DevicePolicyEnums.RESOLVER_EMPTY_STATE_NO_SHARING_TO_PERSONAL; import static android.stats.devicepolicy.nano.DevicePolicyEnums.RESOLVER_EMPTY_STATE_NO_SHARING_TO_WORK; import static android.view.WindowManager.LayoutParams.SYSTEM_FLAG_HIDE_NON_SYSTEM_OVERLAY_WINDOWS; + import static com.android.internal.annotations.VisibleForTesting.Visibility.PROTECTED; import android.annotation.Nullable; @@ -98,15 +99,19 @@ import android.widget.Toast; import androidx.fragment.app.FragmentActivity; import androidx.viewpager.widget.ViewPager; -import com.android.intentresolver.MultiProfilePagerAdapter.CompositeEmptyStateProvider; -import com.android.intentresolver.MultiProfilePagerAdapter.CrossProfileIntentsChecker; -import com.android.intentresolver.MultiProfilePagerAdapter.EmptyStateProvider; import com.android.intentresolver.MultiProfilePagerAdapter.MyUserIdProvider; import com.android.intentresolver.MultiProfilePagerAdapter.OnSwitchOnWorkSelectedListener; import com.android.intentresolver.MultiProfilePagerAdapter.Profile; -import com.android.intentresolver.NoCrossProfileEmptyStateProvider.DevicePolicyBlockerEmptyState; import com.android.intentresolver.chooser.DisplayResolveInfo; import com.android.intentresolver.chooser.TargetInfo; +import com.android.intentresolver.emptystate.CompositeEmptyStateProvider; +import com.android.intentresolver.emptystate.CrossProfileIntentsChecker; +import com.android.intentresolver.emptystate.EmptyState; +import com.android.intentresolver.emptystate.EmptyStateProvider; +import com.android.intentresolver.emptystate.NoAppsAvailableEmptyStateProvider; +import com.android.intentresolver.emptystate.NoCrossProfileEmptyStateProvider; +import com.android.intentresolver.emptystate.NoCrossProfileEmptyStateProvider.DevicePolicyBlockerEmptyState; +import com.android.intentresolver.emptystate.WorkProfilePausedEmptyStateProvider; import com.android.intentresolver.icons.DefaultTargetDataLoader; import com.android.intentresolver.icons.TargetDataLoader; import com.android.intentresolver.model.ResolverRankerServiceResolverComparator; @@ -521,9 +526,9 @@ public class ResolverActivity extends FragmentActivity implements return new EmptyStateProvider() {}; } - final MultiProfilePagerAdapter.EmptyState - noWorkToPersonalEmptyState = - new DevicePolicyBlockerEmptyState(/* context= */ this, + final EmptyState noWorkToPersonalEmptyState = + new DevicePolicyBlockerEmptyState( + /* context= */ this, /* devicePolicyStringTitleId= */ RESOLVER_CROSS_PROFILE_BLOCKED_TITLE, /* defaultTitleResource= */ R.string.resolver_cross_profile_blocked, /* devicePolicyStringSubtitleId= */ RESOLVER_CANT_ACCESS_PERSONAL, @@ -533,8 +538,9 @@ public class ResolverActivity extends FragmentActivity implements /* devicePolicyEventCategory= */ ResolverActivity.METRICS_CATEGORY_RESOLVER); - final MultiProfilePagerAdapter.EmptyState noPersonalToWorkEmptyState = - new DevicePolicyBlockerEmptyState(/* context= */ this, + final EmptyState noPersonalToWorkEmptyState = + new DevicePolicyBlockerEmptyState( + /* context= */ this, /* devicePolicyStringTitleId= */ RESOLVER_CROSS_PROFILE_BLOCKED_TITLE, /* defaultTitleResource= */ R.string.resolver_cross_profile_blocked, /* devicePolicyStringSubtitleId= */ RESOLVER_CANT_ACCESS_WORK, diff --git a/java/src/com/android/intentresolver/ResolverListAdapter.java b/java/src/com/android/intentresolver/ResolverListAdapter.java index 4ad8d926..413bcb7e 100644 --- a/java/src/com/android/intentresolver/ResolverListAdapter.java +++ b/java/src/com/android/intentresolver/ResolverListAdapter.java @@ -821,7 +821,7 @@ public class ResolverListAdapter extends BaseAdapter { return mUserHandle; } - protected List getResolversForUser(UserHandle userHandle) { + public final List getResolversForUser(UserHandle userHandle) { return mResolverListController.getResolversForIntentAsUser( /* shouldGetResolvedFilter= */ true, mResolverListCommunicator.shouldGetActivityMetadata(), @@ -830,7 +830,8 @@ public class ResolverListAdapter extends BaseAdapter { userHandle); } - protected List getIntents() { + public final List getIntents() { + // TODO: immutable copy? return mIntents; } diff --git a/java/src/com/android/intentresolver/ResolverMultiProfilePagerAdapter.java b/java/src/com/android/intentresolver/ResolverMultiProfilePagerAdapter.java index 9fb35948..e0c5380f 100644 --- a/java/src/com/android/intentresolver/ResolverMultiProfilePagerAdapter.java +++ b/java/src/com/android/intentresolver/ResolverMultiProfilePagerAdapter.java @@ -24,6 +24,7 @@ import android.widget.ListView; import androidx.viewpager.widget.PagerAdapter; +import com.android.intentresolver.emptystate.EmptyStateProvider; import com.android.internal.annotations.VisibleForTesting; import com.google.common.collect.ImmutableList; diff --git a/java/src/com/android/intentresolver/WorkProfilePausedEmptyStateProvider.java b/java/src/com/android/intentresolver/WorkProfilePausedEmptyStateProvider.java deleted file mode 100644 index 9ea7ceee..00000000 --- a/java/src/com/android/intentresolver/WorkProfilePausedEmptyStateProvider.java +++ /dev/null @@ -1,112 +0,0 @@ -/* - * Copyright (C) 2022 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.android.intentresolver; - -import static android.app.admin.DevicePolicyResources.Strings.Core.RESOLVER_WORK_PAUSED_TITLE; - -import android.annotation.NonNull; -import android.annotation.Nullable; -import android.app.admin.DevicePolicyEventLogger; -import android.app.admin.DevicePolicyManager; -import android.content.Context; -import android.os.UserHandle; -import android.stats.devicepolicy.nano.DevicePolicyEnums; - -import com.android.intentresolver.MultiProfilePagerAdapter.EmptyState; -import com.android.intentresolver.MultiProfilePagerAdapter.EmptyStateProvider; -import com.android.intentresolver.MultiProfilePagerAdapter.OnSwitchOnWorkSelectedListener; - -/** - * Chooser/ResolverActivity empty state provider that returns empty state which is shown when - * work profile is paused and we need to show a button to enable it. - */ -public class WorkProfilePausedEmptyStateProvider implements EmptyStateProvider { - - private final UserHandle mWorkProfileUserHandle; - private final WorkProfileAvailabilityManager mWorkProfileAvailability; - private final String mMetricsCategory; - private final OnSwitchOnWorkSelectedListener mOnSwitchOnWorkSelectedListener; - private final Context mContext; - - public WorkProfilePausedEmptyStateProvider(@NonNull Context context, - @Nullable UserHandle workProfileUserHandle, - @NonNull WorkProfileAvailabilityManager workProfileAvailability, - @Nullable OnSwitchOnWorkSelectedListener onSwitchOnWorkSelectedListener, - @NonNull String metricsCategory) { - mContext = context; - mWorkProfileUserHandle = workProfileUserHandle; - mWorkProfileAvailability = workProfileAvailability; - mMetricsCategory = metricsCategory; - mOnSwitchOnWorkSelectedListener = onSwitchOnWorkSelectedListener; - } - - @Nullable - @Override - public EmptyState getEmptyState(ResolverListAdapter resolverListAdapter) { - if (!resolverListAdapter.getUserHandle().equals(mWorkProfileUserHandle) - || !mWorkProfileAvailability.isQuietModeEnabled() - || resolverListAdapter.getCount() == 0) { - return null; - } - - final String title = mContext.getSystemService(DevicePolicyManager.class) - .getResources().getString(RESOLVER_WORK_PAUSED_TITLE, - () -> mContext.getString(R.string.resolver_turn_on_work_apps)); - - return new WorkProfileOffEmptyState(title, (tab) -> { - tab.showSpinner(); - if (mOnSwitchOnWorkSelectedListener != null) { - mOnSwitchOnWorkSelectedListener.onSwitchOnWorkSelected(); - } - mWorkProfileAvailability.requestQuietModeEnabled(false); - }, mMetricsCategory); - } - - public static class WorkProfileOffEmptyState implements EmptyState { - - private final String mTitle; - private final ClickListener mOnClick; - private final String mMetricsCategory; - - public WorkProfileOffEmptyState(String title, @NonNull ClickListener onClick, - @NonNull String metricsCategory) { - mTitle = title; - mOnClick = onClick; - mMetricsCategory = metricsCategory; - } - - @Nullable - @Override - public String getTitle() { - return mTitle; - } - - @Nullable - @Override - public ClickListener getButtonClickListener() { - return mOnClick; - } - - @Override - public void onEmptyStateShown() { - DevicePolicyEventLogger - .createEvent(DevicePolicyEnums.RESOLVER_EMPTY_STATE_WORK_APPS_DISABLED) - .setStrings(mMetricsCategory) - .write(); - } - } -} diff --git a/java/src/com/android/intentresolver/emptystate/CompositeEmptyStateProvider.java b/java/src/com/android/intentresolver/emptystate/CompositeEmptyStateProvider.java new file mode 100644 index 00000000..41422b66 --- /dev/null +++ b/java/src/com/android/intentresolver/emptystate/CompositeEmptyStateProvider.java @@ -0,0 +1,46 @@ +/* + * Copyright (C) 2023 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.android.intentresolver.emptystate; + +import android.annotation.Nullable; + +import com.android.intentresolver.ResolverListAdapter; + +/** + * Empty state provider that combines multiple providers. Providers earlier in the list have + * priority, that is if there is a provider that returns non-null empty state then all further + * providers will be ignored. + */ +public class CompositeEmptyStateProvider implements EmptyStateProvider { + + private final EmptyStateProvider[] mProviders; + + public CompositeEmptyStateProvider(EmptyStateProvider... providers) { + mProviders = providers; + } + + @Nullable + @Override + public EmptyState getEmptyState(ResolverListAdapter resolverListAdapter) { + for (EmptyStateProvider provider : mProviders) { + EmptyState emptyState = provider.getEmptyState(resolverListAdapter); + if (emptyState != null) { + return emptyState; + } + } + return null; + } +} diff --git a/java/src/com/android/intentresolver/emptystate/CrossProfileIntentsChecker.java b/java/src/com/android/intentresolver/emptystate/CrossProfileIntentsChecker.java new file mode 100644 index 00000000..2164e533 --- /dev/null +++ b/java/src/com/android/intentresolver/emptystate/CrossProfileIntentsChecker.java @@ -0,0 +1,59 @@ +/* + * Copyright (C) 2023 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.android.intentresolver.emptystate; + +import android.annotation.NonNull; +import android.annotation.UserIdInt; +import android.app.AppGlobals; +import android.content.ContentResolver; +import android.content.Intent; +import android.content.pm.IPackageManager; + +import com.android.intentresolver.IntentForwarderActivity; + +import java.util.List; + +/** + * Utility class to check if there are cross profile intents, it is in a separate class so + * it could be mocked in tests + */ +public class CrossProfileIntentsChecker { + + private final ContentResolver mContentResolver; + private final IPackageManager mPackageManager; + + public CrossProfileIntentsChecker(@NonNull ContentResolver contentResolver) { + this(contentResolver, AppGlobals.getPackageManager()); + } + + CrossProfileIntentsChecker( + @NonNull ContentResolver contentResolver, IPackageManager packageManager) { + mContentResolver = contentResolver; + mPackageManager = packageManager; + } + + /** + * Returns {@code true} if at least one of the provided {@code intents} can be forwarded + * from {@code source} (user id) to {@code target} (user id). + */ + public boolean hasCrossProfileIntents( + List intents, @UserIdInt int source, @UserIdInt int target) { + return intents.stream().anyMatch(intent -> + null != IntentForwarderActivity.canForward(intent, source, target, + mPackageManager, mContentResolver)); + } +} + diff --git a/java/src/com/android/intentresolver/emptystate/EmptyState.java b/java/src/com/android/intentresolver/emptystate/EmptyState.java new file mode 100644 index 00000000..cde99fe1 --- /dev/null +++ b/java/src/com/android/intentresolver/emptystate/EmptyState.java @@ -0,0 +1,78 @@ +/* + * Copyright (C) 2023 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.intentresolver.emptystate; + +import android.annotation.Nullable; + +/** + * Model for the "empty state"/"blocker" UI to display instead of a profile tab's normal contents. + */ +public interface EmptyState { + /** + * Get the title to show on the empty state. + */ + @Nullable + default String getTitle() { + return null; + } + + /** + * Get the subtitle string to show underneath the title on the empty state. + */ + @Nullable + default String getSubtitle() { + return null; + } + + /** + * Get the handler for an optional button associated with this empty state. If the result is + * non-null, the empty-state UI will be built with a button that dispatches this handler. + */ + @Nullable + default ClickListener getButtonClickListener() { + return null; + } + + /** + * Get whether to show the default UI for the empty state. If true, the UI will show the default + * blocker text ('No apps can perform this action') and style; title and subtitle are ignored. + */ + default boolean useDefaultEmptyView() { + return false; + } + + /** + * Returns true if for this empty state we should skip rebuilding of the apps list + * for this tab. + */ + default boolean shouldSkipDataRebuild() { + return false; + } + + /** + * Called when empty state is shown, could be used e.g. to track analytics events. + */ + default void onEmptyStateShown() {} + + interface ClickListener { + void onClick(TabControl currentTab); + } + + interface TabControl { + void showSpinner(); + } +} diff --git a/java/src/com/android/intentresolver/emptystate/EmptyStateProvider.java b/java/src/com/android/intentresolver/emptystate/EmptyStateProvider.java new file mode 100644 index 00000000..c3261287 --- /dev/null +++ b/java/src/com/android/intentresolver/emptystate/EmptyStateProvider.java @@ -0,0 +1,37 @@ +/* + * Copyright (C) 2023 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.android.intentresolver.emptystate; + +import android.annotation.Nullable; + +import com.android.intentresolver.ResolverListAdapter; + +/** + * Returns an empty state to show for the current profile page (tab) if necessary. + * This could be used e.g. to show a blocker on a tab if device management policy doesn't + * allow to use it or there are no apps available. + */ +public interface EmptyStateProvider { + /** + * When a non-null empty state is returned the corresponding profile page will show + * this empty state + * @param resolverListAdapter the current adapter + */ + @Nullable + default EmptyState getEmptyState(ResolverListAdapter resolverListAdapter) { + return null; + } +} diff --git a/java/src/com/android/intentresolver/emptystate/NoAppsAvailableEmptyStateProvider.java b/java/src/com/android/intentresolver/emptystate/NoAppsAvailableEmptyStateProvider.java new file mode 100644 index 00000000..b7084466 --- /dev/null +++ b/java/src/com/android/intentresolver/emptystate/NoAppsAvailableEmptyStateProvider.java @@ -0,0 +1,153 @@ +/* + * Copyright (C) 2022 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.intentresolver.emptystate; + +import static android.app.admin.DevicePolicyResources.Strings.Core.RESOLVER_NO_PERSONAL_APPS; +import static android.app.admin.DevicePolicyResources.Strings.Core.RESOLVER_NO_WORK_APPS; + +import android.annotation.NonNull; +import android.annotation.Nullable; +import android.app.admin.DevicePolicyEventLogger; +import android.app.admin.DevicePolicyManager; +import android.content.Context; +import android.content.pm.ResolveInfo; +import android.os.UserHandle; +import android.stats.devicepolicy.nano.DevicePolicyEnums; + +import com.android.intentresolver.ResolvedComponentInfo; +import com.android.intentresolver.ResolverListAdapter; +import com.android.internal.R; + +import java.util.List; + +/** + * Chooser/ResolverActivity empty state provider that returns empty state which is shown when + * there are no apps available. + */ +public class NoAppsAvailableEmptyStateProvider implements EmptyStateProvider { + + @NonNull + private final Context mContext; + @Nullable + private final UserHandle mWorkProfileUserHandle; + @Nullable + private final UserHandle mPersonalProfileUserHandle; + @NonNull + private final String mMetricsCategory; + @NonNull + private final UserHandle mTabOwnerUserHandleForLaunch; + + public NoAppsAvailableEmptyStateProvider(Context context, UserHandle workProfileUserHandle, + UserHandle personalProfileUserHandle, String metricsCategory, + UserHandle tabOwnerUserHandleForLaunch) { + mContext = context; + mWorkProfileUserHandle = workProfileUserHandle; + mPersonalProfileUserHandle = personalProfileUserHandle; + mMetricsCategory = metricsCategory; + mTabOwnerUserHandleForLaunch = tabOwnerUserHandleForLaunch; + } + + @Nullable + @Override + @SuppressWarnings("ReferenceEquality") + public EmptyState getEmptyState(ResolverListAdapter resolverListAdapter) { + UserHandle listUserHandle = resolverListAdapter.getUserHandle(); + + if (mWorkProfileUserHandle != null + && (mTabOwnerUserHandleForLaunch.equals(listUserHandle) + || !hasAppsInOtherProfile(resolverListAdapter))) { + + String title; + if (listUserHandle == mPersonalProfileUserHandle) { + title = mContext.getSystemService( + DevicePolicyManager.class).getResources().getString( + RESOLVER_NO_PERSONAL_APPS, + () -> mContext.getString(R.string.resolver_no_personal_apps_available)); + } else { + title = mContext.getSystemService( + DevicePolicyManager.class).getResources().getString( + RESOLVER_NO_WORK_APPS, + () -> mContext.getString(R.string.resolver_no_work_apps_available)); + } + + return new NoAppsAvailableEmptyState( + title, mMetricsCategory, + /* isPersonalProfile= */ listUserHandle == mPersonalProfileUserHandle + ); + } else if (mWorkProfileUserHandle == null) { + // Return default empty state without tracking + return new DefaultEmptyState(); + } + + return null; + } + + private boolean hasAppsInOtherProfile(ResolverListAdapter adapter) { + if (mWorkProfileUserHandle == null) { + return false; + } + List resolversForIntent = + adapter.getResolversForUser(mTabOwnerUserHandleForLaunch); + for (ResolvedComponentInfo info : resolversForIntent) { + ResolveInfo resolveInfo = info.getResolveInfoAt(0); + if (resolveInfo.targetUserId != UserHandle.USER_CURRENT) { + return true; + } + } + return false; + } + + public static class DefaultEmptyState implements EmptyState { + @Override + public boolean useDefaultEmptyView() { + return true; + } + } + + public static class NoAppsAvailableEmptyState implements EmptyState { + + @NonNull + private String mTitle; + + @NonNull + private String mMetricsCategory; + + private boolean mIsPersonalProfile; + + public NoAppsAvailableEmptyState(String title, String metricsCategory, + boolean isPersonalProfile) { + mTitle = title; + mMetricsCategory = metricsCategory; + mIsPersonalProfile = isPersonalProfile; + } + + @Nullable + @Override + public String getTitle() { + return mTitle; + } + + @Override + public void onEmptyStateShown() { + DevicePolicyEventLogger.createEvent( + DevicePolicyEnums.RESOLVER_EMPTY_STATE_NO_APPS_RESOLVED) + .setStrings(mMetricsCategory) + .setBoolean(/*isPersonalProfile*/ mIsPersonalProfile) + .write(); + } + } +} diff --git a/java/src/com/android/intentresolver/emptystate/NoCrossProfileEmptyStateProvider.java b/java/src/com/android/intentresolver/emptystate/NoCrossProfileEmptyStateProvider.java new file mode 100644 index 00000000..686027c3 --- /dev/null +++ b/java/src/com/android/intentresolver/emptystate/NoCrossProfileEmptyStateProvider.java @@ -0,0 +1,134 @@ +/* + * Copyright (C) 2022 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.intentresolver.emptystate; + +import android.annotation.NonNull; +import android.annotation.Nullable; +import android.annotation.StringRes; +import android.app.admin.DevicePolicyEventLogger; +import android.app.admin.DevicePolicyManager; +import android.content.Context; +import android.os.UserHandle; + +import com.android.intentresolver.ResolverListAdapter; + +/** + * Empty state provider that does not allow cross profile sharing, it will return a blocker + * in case if the profile of the current tab is not the same as the profile of the calling app. + */ +public class NoCrossProfileEmptyStateProvider implements EmptyStateProvider { + + private final UserHandle mPersonalProfileUserHandle; + private final EmptyState mNoWorkToPersonalEmptyState; + private final EmptyState mNoPersonalToWorkEmptyState; + private final CrossProfileIntentsChecker mCrossProfileIntentsChecker; + private final UserHandle mTabOwnerUserHandleForLaunch; + + public NoCrossProfileEmptyStateProvider(UserHandle personalUserHandle, + EmptyState noWorkToPersonalEmptyState, + EmptyState noPersonalToWorkEmptyState, + CrossProfileIntentsChecker crossProfileIntentsChecker, + UserHandle tabOwnerUserHandleForLaunch) { + mPersonalProfileUserHandle = personalUserHandle; + mNoWorkToPersonalEmptyState = noWorkToPersonalEmptyState; + mNoPersonalToWorkEmptyState = noPersonalToWorkEmptyState; + mCrossProfileIntentsChecker = crossProfileIntentsChecker; + mTabOwnerUserHandleForLaunch = tabOwnerUserHandleForLaunch; + } + + @Nullable + @Override + public EmptyState getEmptyState(ResolverListAdapter resolverListAdapter) { + boolean shouldShowBlocker = + !mTabOwnerUserHandleForLaunch.equals(resolverListAdapter.getUserHandle()) + && !mCrossProfileIntentsChecker + .hasCrossProfileIntents(resolverListAdapter.getIntents(), + mTabOwnerUserHandleForLaunch.getIdentifier(), + resolverListAdapter.getUserHandle().getIdentifier()); + + if (!shouldShowBlocker) { + return null; + } + + if (resolverListAdapter.getUserHandle().equals(mPersonalProfileUserHandle)) { + return mNoWorkToPersonalEmptyState; + } else { + return mNoPersonalToWorkEmptyState; + } + } + + + /** + * Empty state that gets strings from the device policy manager and tracks events into + * event logger of the device policy events. + */ + public static class DevicePolicyBlockerEmptyState implements EmptyState { + + @NonNull + private final Context mContext; + private final String mDevicePolicyStringTitleId; + @StringRes + private final int mDefaultTitleResource; + private final String mDevicePolicyStringSubtitleId; + @StringRes + private final int mDefaultSubtitleResource; + private final int mEventId; + @NonNull + private final String mEventCategory; + + public DevicePolicyBlockerEmptyState(Context context, String devicePolicyStringTitleId, + @StringRes int defaultTitleResource, String devicePolicyStringSubtitleId, + @StringRes int defaultSubtitleResource, + int devicePolicyEventId, String devicePolicyEventCategory) { + mContext = context; + mDevicePolicyStringTitleId = devicePolicyStringTitleId; + mDefaultTitleResource = defaultTitleResource; + mDevicePolicyStringSubtitleId = devicePolicyStringSubtitleId; + mDefaultSubtitleResource = defaultSubtitleResource; + mEventId = devicePolicyEventId; + mEventCategory = devicePolicyEventCategory; + } + + @Nullable + @Override + public String getTitle() { + return mContext.getSystemService(DevicePolicyManager.class).getResources().getString( + mDevicePolicyStringTitleId, + () -> mContext.getString(mDefaultTitleResource)); + } + + @Nullable + @Override + public String getSubtitle() { + return mContext.getSystemService(DevicePolicyManager.class).getResources().getString( + mDevicePolicyStringSubtitleId, + () -> mContext.getString(mDefaultSubtitleResource)); + } + + @Override + public void onEmptyStateShown() { + DevicePolicyEventLogger.createEvent(mEventId) + .setStrings(mEventCategory) + .write(); + } + + @Override + public boolean shouldSkipDataRebuild() { + return true; + } + } +} diff --git a/java/src/com/android/intentresolver/emptystate/WorkProfilePausedEmptyStateProvider.java b/java/src/com/android/intentresolver/emptystate/WorkProfilePausedEmptyStateProvider.java new file mode 100644 index 00000000..ca04f1b7 --- /dev/null +++ b/java/src/com/android/intentresolver/emptystate/WorkProfilePausedEmptyStateProvider.java @@ -0,0 +1,113 @@ +/* + * Copyright (C) 2022 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.intentresolver.emptystate; + +import static android.app.admin.DevicePolicyResources.Strings.Core.RESOLVER_WORK_PAUSED_TITLE; + +import android.annotation.NonNull; +import android.annotation.Nullable; +import android.app.admin.DevicePolicyEventLogger; +import android.app.admin.DevicePolicyManager; +import android.content.Context; +import android.os.UserHandle; +import android.stats.devicepolicy.nano.DevicePolicyEnums; + +import com.android.intentresolver.MultiProfilePagerAdapter.OnSwitchOnWorkSelectedListener; +import com.android.intentresolver.R; +import com.android.intentresolver.ResolverListAdapter; +import com.android.intentresolver.WorkProfileAvailabilityManager; + +/** + * Chooser/ResolverActivity empty state provider that returns empty state which is shown when + * work profile is paused and we need to show a button to enable it. + */ +public class WorkProfilePausedEmptyStateProvider implements EmptyStateProvider { + + private final UserHandle mWorkProfileUserHandle; + private final WorkProfileAvailabilityManager mWorkProfileAvailability; + private final String mMetricsCategory; + private final OnSwitchOnWorkSelectedListener mOnSwitchOnWorkSelectedListener; + private final Context mContext; + + public WorkProfilePausedEmptyStateProvider(@NonNull Context context, + @Nullable UserHandle workProfileUserHandle, + @NonNull WorkProfileAvailabilityManager workProfileAvailability, + @Nullable OnSwitchOnWorkSelectedListener onSwitchOnWorkSelectedListener, + @NonNull String metricsCategory) { + mContext = context; + mWorkProfileUserHandle = workProfileUserHandle; + mWorkProfileAvailability = workProfileAvailability; + mMetricsCategory = metricsCategory; + mOnSwitchOnWorkSelectedListener = onSwitchOnWorkSelectedListener; + } + + @Nullable + @Override + public EmptyState getEmptyState(ResolverListAdapter resolverListAdapter) { + if (!resolverListAdapter.getUserHandle().equals(mWorkProfileUserHandle) + || !mWorkProfileAvailability.isQuietModeEnabled() + || resolverListAdapter.getCount() == 0) { + return null; + } + + final String title = mContext.getSystemService(DevicePolicyManager.class) + .getResources().getString(RESOLVER_WORK_PAUSED_TITLE, + () -> mContext.getString(R.string.resolver_turn_on_work_apps)); + + return new WorkProfileOffEmptyState(title, (tab) -> { + tab.showSpinner(); + if (mOnSwitchOnWorkSelectedListener != null) { + mOnSwitchOnWorkSelectedListener.onSwitchOnWorkSelected(); + } + mWorkProfileAvailability.requestQuietModeEnabled(false); + }, mMetricsCategory); + } + + public static class WorkProfileOffEmptyState implements EmptyState { + + private final String mTitle; + private final ClickListener mOnClick; + private final String mMetricsCategory; + + public WorkProfileOffEmptyState(String title, @NonNull ClickListener onClick, + @NonNull String metricsCategory) { + mTitle = title; + mOnClick = onClick; + mMetricsCategory = metricsCategory; + } + + @Nullable + @Override + public String getTitle() { + return mTitle; + } + + @Nullable + @Override + public ClickListener getButtonClickListener() { + return mOnClick; + } + + @Override + public void onEmptyStateShown() { + DevicePolicyEventLogger + .createEvent(DevicePolicyEnums.RESOLVER_EMPTY_STATE_WORK_APPS_DISABLED) + .setStrings(mMetricsCategory) + .write(); + } + } +} diff --git a/java/tests/src/com/android/intentresolver/ChooserActivityOverrideData.java b/java/tests/src/com/android/intentresolver/ChooserActivityOverrideData.java index 3bf144dd..3ee80c14 100644 --- a/java/tests/src/com/android/intentresolver/ChooserActivityOverrideData.java +++ b/java/tests/src/com/android/intentresolver/ChooserActivityOverrideData.java @@ -26,9 +26,9 @@ import android.content.res.Resources; import android.database.Cursor; import android.os.UserHandle; -import com.android.intentresolver.MultiProfilePagerAdapter.CrossProfileIntentsChecker; import com.android.intentresolver.chooser.TargetInfo; import com.android.intentresolver.contentpreview.ImageLoader; +import com.android.intentresolver.emptystate.CrossProfileIntentsChecker; import com.android.intentresolver.shortcuts.ShortcutLoader; import java.util.function.Consumer; diff --git a/java/tests/src/com/android/intentresolver/ChooserWrapperActivity.java b/java/tests/src/com/android/intentresolver/ChooserWrapperActivity.java index 64c4a50a..c9f47a33 100644 --- a/java/tests/src/com/android/intentresolver/ChooserWrapperActivity.java +++ b/java/tests/src/com/android/intentresolver/ChooserWrapperActivity.java @@ -34,9 +34,9 @@ import android.os.UserHandle; import androidx.lifecycle.ViewModelProvider; -import com.android.intentresolver.MultiProfilePagerAdapter.CrossProfileIntentsChecker; import com.android.intentresolver.chooser.DisplayResolveInfo; import com.android.intentresolver.chooser.TargetInfo; +import com.android.intentresolver.emptystate.CrossProfileIntentsChecker; import com.android.intentresolver.grid.ChooserGridAdapter; import com.android.intentresolver.icons.TargetDataLoader; import com.android.intentresolver.shortcuts.ShortcutLoader; diff --git a/java/tests/src/com/android/intentresolver/MultiProfilePagerAdapterTest.kt b/java/tests/src/com/android/intentresolver/MultiProfilePagerAdapterTest.kt index dcf53cea..56034f0a 100644 --- a/java/tests/src/com/android/intentresolver/MultiProfilePagerAdapterTest.kt +++ b/java/tests/src/com/android/intentresolver/MultiProfilePagerAdapterTest.kt @@ -22,6 +22,7 @@ import android.widget.ListView import androidx.test.platform.app.InstrumentationRegistry import com.android.intentresolver.MultiProfilePagerAdapter.PROFILE_PERSONAL import com.android.intentresolver.MultiProfilePagerAdapter.PROFILE_WORK +import com.android.intentresolver.emptystate.EmptyStateProvider import com.google.common.collect.ImmutableList import com.google.common.truth.Truth.assertThat import java.util.Optional @@ -46,7 +47,7 @@ class MultiProfilePagerAdapterTest { listView.setAdapter(bindAdapter) }, ImmutableList.of(personalListAdapter), - object : MultiProfilePagerAdapter.EmptyStateProvider {}, + object : EmptyStateProvider {}, { false }, PROFILE_PERSONAL, null, @@ -80,7 +81,7 @@ class MultiProfilePagerAdapterTest { listView.setAdapter(bindAdapter) }, ImmutableList.of(personalListAdapter, workListAdapter), - object : MultiProfilePagerAdapter.EmptyStateProvider {}, + object : EmptyStateProvider {}, { false }, PROFILE_PERSONAL, WORK_USER_HANDLE, // TODO: why does this test pass even if this is null? @@ -119,7 +120,7 @@ class MultiProfilePagerAdapterTest { listView.setAdapter(bindAdapter) }, ImmutableList.of(personalListAdapter, workListAdapter), - object : MultiProfilePagerAdapter.EmptyStateProvider {}, + object : EmptyStateProvider {}, { false }, PROFILE_WORK, // <-- This test specifically requests we start on work profile. WORK_USER_HANDLE, // TODO: why does this test pass even if this is null? @@ -159,7 +160,7 @@ class MultiProfilePagerAdapterTest { listView.setAdapter(bindAdapter) }, ImmutableList.of(), - object : MultiProfilePagerAdapter.EmptyStateProvider {}, + object : EmptyStateProvider {}, { false }, PROFILE_PERSONAL, null, @@ -187,7 +188,7 @@ class MultiProfilePagerAdapterTest { listView.setAdapter(bindAdapter) }, ImmutableList.of(), - object : MultiProfilePagerAdapter.EmptyStateProvider {}, + object : EmptyStateProvider {}, { false }, PROFILE_PERSONAL, null, @@ -221,7 +222,7 @@ class MultiProfilePagerAdapterTest { listView.setAdapter(bindAdapter) }, ImmutableList.of(personalListAdapter, workListAdapter), - object : MultiProfilePagerAdapter.EmptyStateProvider {}, + object : EmptyStateProvider {}, { true }, // <-- Work mode is quiet. PROFILE_WORK, WORK_USER_HANDLE, @@ -255,7 +256,7 @@ class MultiProfilePagerAdapterTest { listView.setAdapter(bindAdapter) }, ImmutableList.of(personalListAdapter, workListAdapter), - object : MultiProfilePagerAdapter.EmptyStateProvider {}, + object : EmptyStateProvider {}, { false }, // <-- Work mode is not quiet. PROFILE_WORK, WORK_USER_HANDLE, diff --git a/java/tests/src/com/android/intentresolver/ResolverWrapperActivity.java b/java/tests/src/com/android/intentresolver/ResolverWrapperActivity.java index d4bd123a..fbcfcd35 100644 --- a/java/tests/src/com/android/intentresolver/ResolverWrapperActivity.java +++ b/java/tests/src/com/android/intentresolver/ResolverWrapperActivity.java @@ -34,10 +34,10 @@ import android.util.Pair; import androidx.annotation.NonNull; import androidx.test.espresso.idling.CountingIdlingResource; -import com.android.intentresolver.MultiProfilePagerAdapter.CrossProfileIntentsChecker; import com.android.intentresolver.chooser.DisplayResolveInfo; import com.android.intentresolver.chooser.SelectableTargetInfo; import com.android.intentresolver.chooser.TargetInfo; +import com.android.intentresolver.emptystate.CrossProfileIntentsChecker; import com.android.intentresolver.icons.TargetDataLoader; import java.util.List; diff --git a/java/tests/src/com/android/intentresolver/emptystate/CompositeEmptyStateProviderTest.kt b/java/tests/src/com/android/intentresolver/emptystate/CompositeEmptyStateProviderTest.kt new file mode 100644 index 00000000..4c05dfb1 --- /dev/null +++ b/java/tests/src/com/android/intentresolver/emptystate/CompositeEmptyStateProviderTest.kt @@ -0,0 +1,65 @@ +/* + * Copyright (C) 2023 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.intentresolver.emptystate + +import com.android.intentresolver.ResolverListAdapter +import com.android.intentresolver.mock +import com.google.common.truth.Truth.assertThat +import org.junit.Test + +class CompositeEmptyStateProviderTest { + val listAdapter = mock() + + val emptyState1 = object : EmptyState {} + val emptyState2 = object : EmptyState {} + + val positiveEmptyStateProvider1 = + object : EmptyStateProvider { + override fun getEmptyState(listAdapter: ResolverListAdapter) = emptyState1 + } + val positiveEmptyStateProvider2 = + object : EmptyStateProvider { + override fun getEmptyState(listAdapter: ResolverListAdapter) = emptyState2 + } + val nullEmptyStateProvider = + object : EmptyStateProvider { + override fun getEmptyState(listAdapter: ResolverListAdapter) = null + } + + @Test + fun testComposedProvider_returnsFirstEmptyStateInOrder() { + val provider = + CompositeEmptyStateProvider( + nullEmptyStateProvider, + positiveEmptyStateProvider1, + positiveEmptyStateProvider2 + ) + assertThat(provider.getEmptyState(listAdapter)).isSameInstanceAs(emptyState1) + } + + @Test + fun testComposedProvider_allProvidersReturnNull_composedResultIsNull() { + val provider = CompositeEmptyStateProvider(nullEmptyStateProvider) + assertThat(provider.getEmptyState(listAdapter)).isNull() + } + + @Test + fun testComposedProvider_noEmptyStateIfNoDelegateProviders() { + val provider = CompositeEmptyStateProvider() + assertThat(provider.getEmptyState(listAdapter)).isNull() + } +} diff --git a/java/tests/src/com/android/intentresolver/emptystate/CrossProfileIntentsCheckerTest.kt b/java/tests/src/com/android/intentresolver/emptystate/CrossProfileIntentsCheckerTest.kt new file mode 100644 index 00000000..2bcddf59 --- /dev/null +++ b/java/tests/src/com/android/intentresolver/emptystate/CrossProfileIntentsCheckerTest.kt @@ -0,0 +1,84 @@ +/* + * Copyright (C) 2023 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.intentresolver.emptystate + +import android.content.ContentResolver +import android.content.Intent +import android.content.pm.IPackageManager +import com.android.intentresolver.mock +import com.android.intentresolver.whenever +import com.google.common.truth.Truth.assertThat +import org.junit.Test +import org.mockito.Mockito.any +import org.mockito.Mockito.anyInt +import org.mockito.Mockito.eq +import org.mockito.Mockito.nullable + +class CrossProfileIntentsCheckerTest { + private val PERSONAL_USER_ID = 10 + private val WORK_USER_ID = 20 + + private val contentResolver = mock() + + @Test + fun testChecker_hasCrossProfileIntents() { + val packageManager = + mock { + whenever( + canForwardTo( + any(Intent::class.java), + nullable(String::class.java), + eq(PERSONAL_USER_ID), + eq(WORK_USER_ID) + ) + ) + .thenReturn(true) + } + val checker = CrossProfileIntentsChecker(contentResolver, packageManager) + val intents = listOf(Intent()) + assertThat(checker.hasCrossProfileIntents(intents, PERSONAL_USER_ID, WORK_USER_ID)).isTrue() + } + + @Test + fun testChecker_noCrossProfileIntents() { + val packageManager = + mock { + whenever( + canForwardTo( + any(Intent::class.java), + nullable(String::class.java), + anyInt(), + anyInt() + ) + ) + .thenReturn(false) + } + val checker = CrossProfileIntentsChecker(contentResolver, packageManager) + val intents = listOf(Intent()) + assertThat(checker.hasCrossProfileIntents(intents, PERSONAL_USER_ID, WORK_USER_ID)) + .isFalse() + } + + @Test + fun testChecker_noIntents() { + val packageManager = mock() + val checker = CrossProfileIntentsChecker(contentResolver, packageManager) + val intents = listOf() + assertThat(checker.hasCrossProfileIntents(intents, PERSONAL_USER_ID, WORK_USER_ID)) + .isFalse() + } +} -- cgit v1.2.3-59-g8ed1b From 607f13d48aec6fdd8030273ae4a6cf7a712f4258 Mon Sep 17 00:00:00 2001 From: Andrey Epin Date: Tue, 19 Sep 2023 21:58:02 -0700 Subject: Add unit tests for initial intents in Resolver and Chooser adapters Plus some trivial changes to improve code readability Test: atest IntentResolverUnitTests:ResolverListAdapterTest Test: atest IntentResolverUnitTests:ChooserListAdapterDataTest Change-Id: Id32460fb3ea1d8a706e48361c9baa8cce67fd46f --- .../android/intentresolver/ChooserActivity.java | 24 ++- .../android/intentresolver/ChooserListAdapter.java | 49 ++++- .../intentresolver/ChooserListAdapterDataTest.kt | 179 ++++++++++++++++++ .../intentresolver/FakeResolverListCommunicator.kt | 56 ++++++ .../intentresolver/ResolverListAdapterTest.kt | 204 ++++++++++++++++----- .../intentresolver/util/TestImmediateHandler.kt | 42 ----- 6 files changed, 453 insertions(+), 101 deletions(-) create mode 100644 java/tests/src/com/android/intentresolver/ChooserListAdapterDataTest.kt create mode 100644 java/tests/src/com/android/intentresolver/FakeResolverListCommunicator.kt delete mode 100644 java/tests/src/com/android/intentresolver/util/TestImmediateHandler.kt (limited to 'java/src') diff --git a/java/src/com/android/intentresolver/ChooserActivity.java b/java/src/com/android/intentresolver/ChooserActivity.java index 7b4f4827..a6612216 100644 --- a/java/src/com/android/intentresolver/ChooserActivity.java +++ b/java/src/com/android/intentresolver/ChooserActivity.java @@ -68,6 +68,7 @@ import android.view.WindowInsets; import android.widget.TextView; import androidx.annotation.MainThread; +import androidx.annotation.NonNull; import androidx.lifecycle.ViewModelProvider; import androidx.recyclerview.widget.GridLayoutManager; import androidx.recyclerview.widget.RecyclerView; @@ -1017,7 +1018,7 @@ public class ChooserActivity extends Hilt_ChooserActivity implements mIsSuccessfullySelected = true; } - private void maybeRemoveSharedText(@androidx.annotation.NonNull TargetInfo targetInfo) { + private void maybeRemoveSharedText(@NonNull TargetInfo targetInfo) { Intent targetIntent = targetInfo.getTargetIntent(); if (targetIntent == null) { return; @@ -1498,19 +1499,21 @@ public class ChooserActivity extends Hilt_ChooserActivity implements } @Override - public void onListRebuilt(ResolverListAdapter listAdapter, boolean rebuildComplete) { + protected void onListRebuilt(ResolverListAdapter listAdapter, boolean rebuildComplete) { setupScrollListener(); maybeSetupGlobalLayoutListener(); ChooserListAdapter chooserListAdapter = (ChooserListAdapter) listAdapter; - if (chooserListAdapter.getUserHandle() - .equals(mChooserMultiProfilePagerAdapter.getCurrentUserHandle())) { + UserHandle listProfileUserHandle = chooserListAdapter.getUserHandle(); + if (listProfileUserHandle.equals(mChooserMultiProfilePagerAdapter.getCurrentUserHandle())) { mChooserMultiProfilePagerAdapter.getActiveAdapterView() .setAdapter(mChooserMultiProfilePagerAdapter.getCurrentRootAdapter()); mChooserMultiProfilePagerAdapter .setupListAdapter(mChooserMultiProfilePagerAdapter.getCurrentPage()); } + //TODO: move this block inside ChooserListAdapter (should be called when + // ResolverListAdapter#mPostListReadyRunnable is executed. if (chooserListAdapter.getDisplayResolveInfoCount() == 0) { chooserListAdapter.notifyDataSetChanged(); } else { @@ -1518,25 +1521,28 @@ public class ChooserActivity extends Hilt_ChooserActivity implements } if (rebuildComplete) { - long duration = Tracer.INSTANCE.endAppTargetLoadingSection(listAdapter.getUserHandle()); + long duration = Tracer.INSTANCE.endAppTargetLoadingSection(listProfileUserHandle); if (duration >= 0) { Log.d(TAG, "app target loading time " + duration + " ms"); } addCallerChooserTargets(); getEventLog().logSharesheetAppLoadComplete(); - maybeQueryAdditionalPostProcessingTargets(chooserListAdapter); + maybeQueryAdditionalPostProcessingTargets( + listProfileUserHandle, + chooserListAdapter.getDisplayResolveInfos()); mLatencyTracker.onActionEnd(ACTION_LOAD_SHARE_SHEET); } } - private void maybeQueryAdditionalPostProcessingTargets(ChooserListAdapter chooserListAdapter) { - UserHandle userHandle = chooserListAdapter.getUserHandle(); + private void maybeQueryAdditionalPostProcessingTargets( + UserHandle userHandle, + DisplayResolveInfo[] displayResolveInfos) { ProfileRecord record = getProfileRecord(userHandle); if (record == null || record.shortcutLoader == null) { return; } record.loadingStartTime = SystemClock.elapsedRealtime(); - record.shortcutLoader.updateAppTargets(chooserListAdapter.getDisplayResolveInfos()); + record.shortcutLoader.updateAppTargets(displayResolveInfos); } @MainThread diff --git a/java/src/com/android/intentresolver/ChooserListAdapter.java b/java/src/com/android/intentresolver/ChooserListAdapter.java index 2e1476d8..230c18b2 100644 --- a/java/src/com/android/intentresolver/ChooserListAdapter.java +++ b/java/src/com/android/intentresolver/ChooserListAdapter.java @@ -63,6 +63,7 @@ import java.util.List; import java.util.Map; import java.util.Objects; import java.util.Set; +import java.util.concurrent.Executor; import java.util.stream.Collectors; public class ChooserListAdapter extends ResolverListAdapter { @@ -99,7 +100,7 @@ public class ChooserListAdapter extends ResolverListAdapter { private final ShortcutSelectionLogic mShortcutSelectionLogic; // Sorted list of DisplayResolveInfos for the alphabetical app section. - private List mSortedList = new ArrayList<>(); + private final List mSortedList = new ArrayList<>(); private final ItemRevealAnimationTracker mAnimationTracker = new ItemRevealAnimationTracker(); @@ -150,6 +151,45 @@ public class ChooserListAdapter extends ResolverListAdapter { int maxRankedTargets, UserHandle initialIntentsUserSpace, TargetDataLoader targetDataLoader) { + this( + context, + payloadIntents, + initialIntents, + rList, + filterLastUsed, + resolverListController, + userHandle, + targetIntent, + resolverListCommunicator, + packageManager, + eventLog, + chooserRequest, + maxRankedTargets, + initialIntentsUserSpace, + targetDataLoader, + AsyncTask.SERIAL_EXECUTOR, + context.getMainExecutor()); + } + + @VisibleForTesting + public ChooserListAdapter( + Context context, + List payloadIntents, + Intent[] initialIntents, + List rList, + boolean filterLastUsed, + ResolverListController resolverListController, + UserHandle userHandle, + Intent targetIntent, + ResolverListCommunicator resolverListCommunicator, + PackageManager packageManager, + EventLog eventLog, + ChooserRequestParameters chooserRequest, + int maxRankedTargets, + UserHandle initialIntentsUserSpace, + TargetDataLoader targetDataLoader, + Executor bgExecutor, + Executor mainExecutor) { // Don't send the initial intents through the shared ResolverActivity path, // we want to separate them into a different section. super( @@ -163,7 +203,9 @@ public class ChooserListAdapter extends ResolverListAdapter { targetIntent, resolverListCommunicator, initialIntentsUserSpace, - targetDataLoader); + targetDataLoader, + bgExecutor, + mainExecutor); mChooserRequest = chooserRequest; mMaxRankedTargets = maxRankedTargets; @@ -413,7 +455,8 @@ public class ChooserListAdapter extends ResolverListAdapter { @Override protected void onPostExecute(List newList) { - mSortedList = newList; + mSortedList.clear(); + mSortedList.addAll(newList); notifyDataSetChanged(); } diff --git a/java/tests/src/com/android/intentresolver/ChooserListAdapterDataTest.kt b/java/tests/src/com/android/intentresolver/ChooserListAdapterDataTest.kt new file mode 100644 index 00000000..e5927e36 --- /dev/null +++ b/java/tests/src/com/android/intentresolver/ChooserListAdapterDataTest.kt @@ -0,0 +1,179 @@ +package com.android.intentresolver + +import android.content.Context +import android.content.Intent +import android.content.pm.PackageManager +import android.content.pm.PackageManager.ComponentInfoFlags +import android.os.UserHandle +import android.os.UserManager +import android.view.LayoutInflater +import com.android.intentresolver.ResolverDataProvider.createActivityInfo +import com.android.intentresolver.ResolverDataProvider.createResolvedComponentInfo +import com.android.intentresolver.icons.TargetDataLoader +import com.android.intentresolver.logging.FakeEventLog +import com.android.intentresolver.util.TestExecutor +import com.android.internal.logging.InstanceId +import com.google.common.truth.Truth.assertThat +import org.junit.Test +import org.mockito.Mockito + +class ChooserListAdapterDataTest { + private val layoutInflater = mock() + private val packageManager = mock() + private val userManager = mock { whenever(isManagedProfile).thenReturn(false) } + private val resources = + mock { + whenever(getInteger(R.integer.config_maxShortcutTargetsPerApp)).thenReturn(2) + } + private val context = + mock { + whenever(getSystemService(Context.LAYOUT_INFLATER_SERVICE)).thenReturn(layoutInflater) + whenever(getSystemService(Context.USER_SERVICE)).thenReturn(userManager) + whenever(packageManager).thenReturn(this@ChooserListAdapterDataTest.packageManager) + whenever(resources).thenReturn(this@ChooserListAdapterDataTest.resources) + } + private val targetIntent = Intent(Intent.ACTION_SEND) + private val payloadIntents = listOf(targetIntent) + private val resolverListController = + mock { + whenever(filterIneligibleActivities(any(), Mockito.anyBoolean())).thenReturn(null) + whenever(filterLowPriority(any(), Mockito.anyBoolean())).thenReturn(null) + } + private val resolverListCommunicator = FakeResolverListCommunicator() + private val userHandle = UserHandle.of(UserHandle.USER_CURRENT) + private val targetDataLoader = mock() + private val backgroundExecutor = TestExecutor() + private val immediateExecutor = TestExecutor(immediate = true) + private val chooserRequestParams = + ChooserRequestParameters( + Intent.createChooser(targetIntent, ""), + "org.referrer.package", + null + ) + + @Test + fun test_twoTargetsWithNonOverlappingInitialIntent_threeTargetsInResolverAdapter() { + val resolvedTargets = + listOf( + createResolvedComponentInfo(1), + createResolvedComponentInfo(2), + ) + val targetIntent = Intent(Intent.ACTION_SEND) + whenever( + resolverListController.getResolversForIntentAsUser( + true, + resolverListCommunicator.shouldGetActivityMetadata(), + resolverListCommunicator.shouldGetOnlyDefaultActivities(), + payloadIntents, + userHandle + ) + ) + .thenReturn(resolvedTargets) + val initialActivityInfo = createActivityInfo(3) + val initialIntents = + arrayOf( + Intent(Intent.ACTION_SEND).apply { component = initialActivityInfo.componentName } + ) + whenever( + packageManager.getActivityInfo( + eq(initialActivityInfo.componentName), + any() + ) + ) + .thenReturn(initialActivityInfo) + val testSubject = + ChooserListAdapter( + context, + payloadIntents, + initialIntents, + /*rList=*/ null, + /*filterLastUsed=*/ false, + resolverListController, + userHandle, + targetIntent, + resolverListCommunicator, + packageManager, + FakeEventLog(InstanceId.fakeInstanceId(1)), + chooserRequestParams, + /*maxRankedTargets=*/ 2, + /*initialIntentsUserSpace=*/ userHandle, + targetDataLoader, + backgroundExecutor, + immediateExecutor, + ) + val doPostProcessing = true + + val isLoaded = testSubject.rebuildList(doPostProcessing) + + assertThat(isLoaded).isFalse() + assertThat(testSubject.displayResolveInfoCount).isEqualTo(0) + assertThat(backgroundExecutor.pendingCommandCount).isEqualTo(1) + + backgroundExecutor.runUntilIdle() + + // we don't reset placeholder count (legacy logic, likely an oversight?) + assertThat(testSubject.displayResolveInfoCount).isEqualTo(resolvedTargets.size) + } + + @Test + fun test_twoTargetsWithOverlappingInitialIntent_oneTargetsInResolverAdapter() { + val resolvedTargets = + listOf( + createResolvedComponentInfo(1), + createResolvedComponentInfo(2), + ) + val targetIntent = Intent(Intent.ACTION_SEND) + whenever( + resolverListController.getResolversForIntentAsUser( + true, + resolverListCommunicator.shouldGetActivityMetadata(), + resolverListCommunicator.shouldGetOnlyDefaultActivities(), + payloadIntents, + userHandle + ) + ) + .thenReturn(resolvedTargets) + val activityInfo = resolvedTargets[1].getResolveInfoAt(0).activityInfo + val initialIntents = + arrayOf(Intent(Intent.ACTION_SEND).apply { component = activityInfo.componentName }) + whenever( + packageManager.getActivityInfo( + eq(activityInfo.componentName), + any() + ) + ) + .thenReturn(activityInfo) + val testSubject = + ChooserListAdapter( + context, + payloadIntents, + initialIntents, + /*rList=*/ null, + /*filterLastUsed=*/ false, + resolverListController, + userHandle, + targetIntent, + resolverListCommunicator, + packageManager, + FakeEventLog(InstanceId.fakeInstanceId(1)), + chooserRequestParams, + /*maxRankedTargets=*/ 2, + /*initialIntentsUserSpace=*/ userHandle, + targetDataLoader, + backgroundExecutor, + immediateExecutor, + ) + val doPostProcessing = true + + val isLoaded = testSubject.rebuildList(doPostProcessing) + + assertThat(isLoaded).isFalse() + assertThat(testSubject.displayResolveInfoCount).isEqualTo(0) + assertThat(backgroundExecutor.pendingCommandCount).isEqualTo(1) + + backgroundExecutor.runUntilIdle() + + // we don't reset placeholder count (legacy logic, likely an oversight?) + assertThat(testSubject.displayResolveInfoCount).isEqualTo(resolvedTargets.size - 1) + } +} diff --git a/java/tests/src/com/android/intentresolver/FakeResolverListCommunicator.kt b/java/tests/src/com/android/intentresolver/FakeResolverListCommunicator.kt new file mode 100644 index 00000000..5e9cd98f --- /dev/null +++ b/java/tests/src/com/android/intentresolver/FakeResolverListCommunicator.kt @@ -0,0 +1,56 @@ +/* + * Copyright (C) 2023 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.intentresolver + +import android.content.Intent +import android.content.pm.ActivityInfo +import java.util.concurrent.atomic.AtomicInteger + +class FakeResolverListCommunicator(private val layoutWithDefaults: Boolean = true) : + ResolverListAdapter.ResolverListCommunicator { + private val sendVoiceCounter = AtomicInteger() + private val updateProfileViewButtonCounter = AtomicInteger() + + val sendVoiceCommandCount + get() = sendVoiceCounter.get() + val updateProfileViewButtonCount + get() = updateProfileViewButtonCounter.get() + + override fun getReplacementIntent(activityInfo: ActivityInfo?, defIntent: Intent): Intent { + return defIntent + } + + override fun onPostListReady( + listAdapter: ResolverListAdapter?, + updateUi: Boolean, + rebuildCompleted: Boolean, + ) = Unit + + override fun sendVoiceChoicesIfNeeded() { + sendVoiceCounter.incrementAndGet() + } + + override fun updateProfileViewButton() { + updateProfileViewButtonCounter.incrementAndGet() + } + + override fun useLayoutWithDefault(): Boolean = layoutWithDefaults + + override fun shouldGetActivityMetadata(): Boolean = true + + override fun onHandlePackagesChanged(listAdapter: ResolverListAdapter?) {} +} diff --git a/java/tests/src/com/android/intentresolver/ResolverListAdapterTest.kt b/java/tests/src/com/android/intentresolver/ResolverListAdapterTest.kt index 53c90199..61b9fd9c 100644 --- a/java/tests/src/com/android/intentresolver/ResolverListAdapterTest.kt +++ b/java/tests/src/com/android/intentresolver/ResolverListAdapterTest.kt @@ -19,16 +19,16 @@ package com.android.intentresolver import android.content.ComponentName import android.content.Context import android.content.Intent -import android.content.pm.ActivityInfo -import android.content.pm.ApplicationInfo +import android.content.pm.PackageManager import android.content.pm.ResolveInfo import android.os.UserHandle +import android.os.UserManager import android.view.LayoutInflater +import com.android.intentresolver.ResolverDataProvider.createActivityInfo import com.android.intentresolver.ResolverListAdapter.ResolverListCommunicator import com.android.intentresolver.icons.TargetDataLoader import com.android.intentresolver.util.TestExecutor import com.google.common.truth.Truth.assertThat -import java.util.concurrent.atomic.AtomicInteger import org.junit.Test import org.mockito.Mockito.anyBoolean import org.mockito.Mockito.inOrder @@ -36,14 +36,19 @@ import org.mockito.Mockito.never import org.mockito.Mockito.verify private const val PKG_NAME = "org.pkg.app" -private const val PKG_NAME_TWO = "org.pkgtwo.app" +private const val PKG_NAME_TWO = "org.pkg.two.app" +private const val PKG_NAME_THREE = "org.pkg.three.app" private const val CLASS_NAME = "org.pkg.app.TheClass" class ResolverListAdapterTest { private val layoutInflater = mock() + private val packageManager = mock() + private val userManager = mock { whenever(isManagedProfile).thenReturn(false) } private val context = mock { whenever(getSystemService(Context.LAYOUT_INFLATER_SERVICE)).thenReturn(layoutInflater) + whenever(getSystemService(Context.USER_SERVICE)).thenReturn(userManager) + whenever(packageManager).thenReturn(this@ResolverListAdapterTest.packageManager) } private val targetIntent = Intent(Intent.ACTION_SEND) private val payloadIntents = listOf(targetIntent) @@ -53,7 +58,7 @@ class ResolverListAdapterTest { whenever(filterLowPriority(any(), anyBoolean())).thenReturn(null) } private val resolverListCommunicator = FakeResolverListCommunicator() - private val userHandle = UserHandle.of(0) + private val userHandle = UserHandle.of(UserHandle.USER_CURRENT) private val targetDataLoader = mock() private val backgroundExecutor = TestExecutor() private val immediateExecutor = TestExecutor(immediate = true) @@ -665,6 +670,150 @@ class ResolverListAdapterTest { assertThat(testSubject.unfilteredResolveList).hasSize(2) } + @Test + fun test_twoTargetsWithNonOverlappingInitialIntent_threeTargetsInAdapter() { + val resolvedTargets = + createResolvedComponents( + ComponentName(PKG_NAME, CLASS_NAME), + ComponentName(PKG_NAME_TWO, CLASS_NAME), + ) + whenever( + resolverListController.getResolversForIntentAsUser( + true, + resolverListCommunicator.shouldGetActivityMetadata(), + resolverListCommunicator.shouldGetOnlyDefaultActivities(), + payloadIntents, + userHandle + ) + ) + .thenReturn(resolvedTargets) + val initialComponent = ComponentName(PKG_NAME_THREE, CLASS_NAME) + val initialIntents = + arrayOf(Intent(Intent.ACTION_SEND).apply { component = initialComponent }) + whenever(packageManager.getActivityInfo(eq(initialComponent), eq(0))) + .thenReturn(createActivityInfo(initialComponent)) + val testSubject = + ResolverListAdapter( + context, + payloadIntents, + initialIntents, + /*rList=*/ null, + /*filterLastUsed=*/ true, + resolverListController, + userHandle, + targetIntent, + resolverListCommunicator, + /*initialIntentsUserSpace=*/ userHandle, + targetDataLoader, + backgroundExecutor, + immediateExecutor, + ) + val doPostProcessing = true + + val isLoaded = testSubject.rebuildList(doPostProcessing) + + assertThat(isLoaded).isFalse() + val placeholderCount = resolvedTargets.size - 1 + assertThat(testSubject.count).isEqualTo(placeholderCount) + assertThat(testSubject.placeholderCount).isEqualTo(placeholderCount) + assertThat(testSubject.hasFilteredItem()).isFalse() + assertThat(testSubject.filteredItem).isNull() + assertThat(testSubject.filteredPosition).isLessThan(0) + assertThat(testSubject.unfilteredResolveList).containsExactlyElementsIn(resolvedTargets) + assertThat(testSubject.isTabLoaded).isFalse() + assertThat(backgroundExecutor.pendingCommandCount).isEqualTo(1) + assertThat(resolverListCommunicator.updateProfileViewButtonCount).isEqualTo(0) + assertThat(resolverListCommunicator.sendVoiceCommandCount).isEqualTo(0) + + backgroundExecutor.runUntilIdle() + + // we don't reset placeholder count (legacy logic, likely an oversight?) + assertThat(testSubject.placeholderCount).isEqualTo(placeholderCount) + assertThat(testSubject.hasFilteredItem()).isFalse() + assertThat(testSubject.count).isEqualTo(resolvedTargets.size + initialIntents.size) + assertThat(testSubject.getItem(0)?.targetIntent?.component) + .isEqualTo(initialIntents[0].component) + assertThat(testSubject.filteredItem).isNull() + assertThat(testSubject.filteredPosition).isLessThan(0) + assertThat(testSubject.unfilteredResolveList).containsExactlyElementsIn(resolvedTargets) + assertThat(testSubject.isTabLoaded).isTrue() + assertThat(resolverListCommunicator.updateProfileViewButtonCount).isEqualTo(1) + assertThat(resolverListCommunicator.sendVoiceCommandCount).isEqualTo(1) + assertThat(backgroundExecutor.pendingCommandCount).isEqualTo(0) + } + + @Test + fun test_twoTargetsWithOverlappingInitialIntent_twoTargetsInAdapter() { + val resolvedTargets = + createResolvedComponents( + ComponentName(PKG_NAME, CLASS_NAME), + ComponentName(PKG_NAME_TWO, CLASS_NAME), + ) + whenever( + resolverListController.getResolversForIntentAsUser( + true, + resolverListCommunicator.shouldGetActivityMetadata(), + resolverListCommunicator.shouldGetOnlyDefaultActivities(), + payloadIntents, + userHandle + ) + ) + .thenReturn(resolvedTargets) + val initialComponent = ComponentName(PKG_NAME_TWO, CLASS_NAME) + val initialIntents = + arrayOf(Intent(Intent.ACTION_SEND).apply { component = initialComponent }) + whenever(packageManager.getActivityInfo(eq(initialComponent), eq(0))) + .thenReturn(createActivityInfo(initialComponent)) + val testSubject = + ResolverListAdapter( + context, + payloadIntents, + initialIntents, + /*rList=*/ null, + /*filterLastUsed=*/ true, + resolverListController, + userHandle, + targetIntent, + resolverListCommunicator, + /*initialIntentsUserSpace=*/ userHandle, + targetDataLoader, + backgroundExecutor, + immediateExecutor, + ) + val doPostProcessing = true + + val isLoaded = testSubject.rebuildList(doPostProcessing) + + assertThat(isLoaded).isFalse() + val placeholderCount = resolvedTargets.size - 1 + assertThat(testSubject.count).isEqualTo(placeholderCount) + assertThat(testSubject.placeholderCount).isEqualTo(placeholderCount) + assertThat(testSubject.hasFilteredItem()).isFalse() + assertThat(testSubject.filteredItem).isNull() + assertThat(testSubject.filteredPosition).isLessThan(0) + assertThat(testSubject.unfilteredResolveList).containsExactlyElementsIn(resolvedTargets) + assertThat(testSubject.isTabLoaded).isFalse() + assertThat(backgroundExecutor.pendingCommandCount).isEqualTo(1) + assertThat(resolverListCommunicator.updateProfileViewButtonCount).isEqualTo(0) + assertThat(resolverListCommunicator.sendVoiceCommandCount).isEqualTo(0) + + backgroundExecutor.runUntilIdle() + + // we don't reset placeholder count (legacy logic, likely an oversight?) + assertThat(testSubject.placeholderCount).isEqualTo(placeholderCount) + assertThat(testSubject.hasFilteredItem()).isFalse() + assertThat(testSubject.count).isEqualTo(resolvedTargets.size) + assertThat(testSubject.getItem(0)?.targetIntent?.component) + .isEqualTo(initialIntents[0].component) + assertThat(testSubject.filteredItem).isNull() + assertThat(testSubject.filteredPosition).isLessThan(0) + assertThat(testSubject.unfilteredResolveList).containsExactlyElementsIn(resolvedTargets) + assertThat(testSubject.isTabLoaded).isTrue() + assertThat(resolverListCommunicator.updateProfileViewButtonCount).isEqualTo(1) + assertThat(resolverListCommunicator.sendVoiceCommandCount).isEqualTo(1) + assertThat(backgroundExecutor.pendingCommandCount).isEqualTo(0) + } + @Test fun testPostListReadyAtEndOfRebuild_synchronous() { val communicator = mock {} @@ -892,47 +1041,8 @@ class ResolverListAdapterTest { private fun createResolveInfo(packageName: String, className: String): ResolveInfo = mock { - activityInfo = - ActivityInfo().apply { - name = className - this.packageName = packageName - applicationInfo = ApplicationInfo().apply { this.packageName = packageName } - } - targetUserId = UserHandle.USER_CURRENT + activityInfo = createActivityInfo(ComponentName(packageName, className)) + targetUserId = this@ResolverListAdapterTest.userHandle.identifier + userHandle = this@ResolverListAdapterTest.userHandle } } - -private class FakeResolverListCommunicator(private val layoutWithDefaults: Boolean = true) : - ResolverListAdapter.ResolverListCommunicator { - private val sendVoiceCounter = AtomicInteger() - private val updateProfileViewButtonCounter = AtomicInteger() - - val sendVoiceCommandCount - get() = sendVoiceCounter.get() - val updateProfileViewButtonCount - get() = updateProfileViewButtonCounter.get() - - override fun getReplacementIntent(activityInfo: ActivityInfo?, defIntent: Intent): Intent { - return defIntent - } - - override fun onPostListReady( - listAdapter: ResolverListAdapter?, - updateUi: Boolean, - rebuildCompleted: Boolean, - ) = Unit - - override fun sendVoiceChoicesIfNeeded() { - sendVoiceCounter.incrementAndGet() - } - - override fun updateProfileViewButton() { - updateProfileViewButtonCounter.incrementAndGet() - } - - override fun useLayoutWithDefault(): Boolean = layoutWithDefaults - - override fun shouldGetActivityMetadata(): Boolean = true - - override fun onHandlePackagesChanged(listAdapter: ResolverListAdapter?) {} -} diff --git a/java/tests/src/com/android/intentresolver/util/TestImmediateHandler.kt b/java/tests/src/com/android/intentresolver/util/TestImmediateHandler.kt deleted file mode 100644 index 9e6fc989..00000000 --- a/java/tests/src/com/android/intentresolver/util/TestImmediateHandler.kt +++ /dev/null @@ -1,42 +0,0 @@ -/* - * Copyright (C) 2023 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.android.intentresolver.util - -import android.os.Handler -import android.os.Looper -import android.os.Message - -/** - * A test Handler that executes posted [Runnable] immediately regardless of the target time (delay). - * Does not support messages. - */ -class TestImmediateHandler : Handler(createTestLooper()) { - override fun sendMessageAtTime(msg: Message, uptimeMillis: Long): Boolean { - msg.callback.run() - return true - } - - companion object { - private val looperConstructor by lazy { - Looper::class.java.getDeclaredConstructor(java.lang.Boolean.TYPE).apply { - isAccessible = true - } - } - - private fun createTestLooper(): Looper = looperConstructor.newInstance(true) - } -} -- cgit v1.2.3-59-g8ed1b From 3361faf975f292981567dd20f2725700e1325f20 Mon Sep 17 00:00:00 2001 From: Andrey Epin Date: Mon, 21 Aug 2023 15:30:42 -0700 Subject: A workaround for ChooserActivity memory leak Add ScopedAppTargetListCallback class to wrap AppPredictor callbacks that breaks the callback reference chain when the specified scope gets closed. Bug: 290971946 Test: Launch Chooser multiple times in a row, collect heapdump with a forced garbage collection, verify that: * there are no ChooserActivity objects in the dump; * there are multiple ScopedAppTargetListCallback instances in the dump with AppPredictor$CallbackWrapper in their GC root path. Test: manual functionality smoke tests: activity configuration changes, pinning targets (all triggers content reloading). Test: unit tests Change-Id: I5099eb7527098a90b3e00bb848eb41e2bc7d14d6 --- .../AppPredictionServiceResolverComparator.java | 56 ++++++++++------- .../shortcuts/ScopedAppTargetListCallback.kt | 58 ++++++++++++++++++ .../intentresolver/shortcuts/ShortcutLoader.kt | 4 +- .../shortcuts/ScopedAppTargetListCallbackTest.kt | 71 ++++++++++++++++++++++ 4 files changed, 166 insertions(+), 23 deletions(-) create mode 100644 java/src/com/android/intentresolver/shortcuts/ScopedAppTargetListCallback.kt create mode 100644 java/tests/src/com/android/intentresolver/shortcuts/ScopedAppTargetListCallbackTest.kt (limited to 'java/src') diff --git a/java/src/com/android/intentresolver/model/AppPredictionServiceResolverComparator.java b/java/src/com/android/intentresolver/model/AppPredictionServiceResolverComparator.java index 12fed698..15c0acc9 100644 --- a/java/src/com/android/intentresolver/model/AppPredictionServiceResolverComparator.java +++ b/java/src/com/android/intentresolver/model/AppPredictionServiceResolverComparator.java @@ -34,6 +34,7 @@ import android.util.Log; import com.android.intentresolver.ResolvedComponentInfo; import com.android.intentresolver.chooser.TargetInfo; import com.android.intentresolver.logging.EventLog; +import com.android.intentresolver.shortcuts.ScopedAppTargetListCallback; import com.google.android.collect.Lists; @@ -105,31 +106,42 @@ public class AppPredictionServiceResolverComparator extends AbstractResolverComp .setClassName(target.name.getClassName()) .build()); } - mAppPredictor.sortTargets(appTargets, Executors.newSingleThreadExecutor(), - sortedAppTargets -> { - if (sortedAppTargets.isEmpty()) { - Log.i(TAG, "AppPredictionService disabled. Using resolver."); - // APS for chooser is disabled. Fallback to resolver. - mResolverRankerService = - new ResolverRankerServiceResolverComparator( - mContext, - mIntent, - mReferrerPackage, - () -> mHandler.sendEmptyMessage(RANKER_SERVICE_RESULT), - getEventLog(), - mUser, - mPromoteToFirst); - mComparatorModel = buildUpdatedModel(); - mResolverRankerService.compute(targets); - } else { - Log.i(TAG, "AppPredictionService response received"); - // Skip sending to Handler which takes extra time to dispatch messages. - handleResult(sortedAppTargets); - } - } + mAppPredictor.sortTargets( + appTargets, + Executors.newSingleThreadExecutor(), + new ScopedAppTargetListCallback( + mContext, + sortedAppTargets -> { + onAppTargetsSorted(targets, sortedAppTargets); + return kotlin.Unit.INSTANCE; + }).toConsumer() ); } + private void onAppTargetsSorted( + List targets, List sortedAppTargets) { + if (sortedAppTargets.isEmpty()) { + Log.i(TAG, "AppPredictionService disabled. Using resolver."); + // APS for chooser is disabled. Fallback to resolver. + mResolverRankerService = + new ResolverRankerServiceResolverComparator( + mContext, + mIntent, + mReferrerPackage, + () -> mHandler.sendEmptyMessage(RANKER_SERVICE_RESULT), + getEventLog(), + mUser, + mPromoteToFirst); + mComparatorModel = buildUpdatedModel(); + mResolverRankerService.compute(targets); + } else { + Log.i(TAG, "AppPredictionService response received"); + // Skip sending to Handler which takes extra time to dispatch + // messages. + handleResult(sortedAppTargets); + } + } + @Override void handleResultMessage(Message msg) { // Null value is okay if we have defaulted to the ResolverRankerService. diff --git a/java/src/com/android/intentresolver/shortcuts/ScopedAppTargetListCallback.kt b/java/src/com/android/intentresolver/shortcuts/ScopedAppTargetListCallback.kt new file mode 100644 index 00000000..9606a6a1 --- /dev/null +++ b/java/src/com/android/intentresolver/shortcuts/ScopedAppTargetListCallback.kt @@ -0,0 +1,58 @@ +/* + * Copyright (C) 2023 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.intentresolver.shortcuts + +import android.app.prediction.AppPredictor +import android.app.prediction.AppTarget +import android.content.Context +import androidx.lifecycle.LifecycleOwner +import androidx.lifecycle.coroutineScope +import java.util.function.Consumer +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.awaitCancellation +import kotlinx.coroutines.launch + +/** + * A memory leak workaround for b/290971946. Drops the references to the actual [callback] when the + * [scope] is cancelled allowing it to be garbage-collected (and only leaking this instance). + */ +class ScopedAppTargetListCallback( + scope: CoroutineScope?, + callback: (List) -> Unit, +) { + + @Volatile private var callbackRef: ((List) -> Unit)? = callback + + constructor( + context: Context, + callback: (List) -> Unit, + ) : this((context as? LifecycleOwner)?.lifecycle?.coroutineScope, callback) + + init { + scope?.launch { awaitCancellation() }?.invokeOnCompletion { callbackRef = null } + } + + private fun notifyCallback(result: List) { + callbackRef?.invoke(result) + } + + fun toConsumer(): Consumer?> = + Consumer?> { notifyCallback(it ?: emptyList()) } + + fun toAppPredictorCallback(): AppPredictor.Callback = + AppPredictor.Callback { notifyCallback(it) } +} diff --git a/java/src/com/android/intentresolver/shortcuts/ShortcutLoader.kt b/java/src/com/android/intentresolver/shortcuts/ShortcutLoader.kt index e7f71661..a8b59fb0 100644 --- a/java/src/com/android/intentresolver/shortcuts/ShortcutLoader.kt +++ b/java/src/com/android/intentresolver/shortcuts/ShortcutLoader.kt @@ -75,7 +75,9 @@ constructor( ) { private val shortcutToChooserTargetConverter = ShortcutToChooserTargetConverter() private val userManager = context.getSystemService(Context.USER_SERVICE) as UserManager - private val appPredictorCallback = AppPredictor.Callback { onAppPredictorCallback(it) } + private val appPredictorCallback = + ScopedAppTargetListCallback(scope) { onAppPredictorCallback(it) }.toAppPredictorCallback() + private val appTargetSource = MutableSharedFlow?>( replay = 1, diff --git a/java/tests/src/com/android/intentresolver/shortcuts/ScopedAppTargetListCallbackTest.kt b/java/tests/src/com/android/intentresolver/shortcuts/ScopedAppTargetListCallbackTest.kt new file mode 100644 index 00000000..c81e88ab --- /dev/null +++ b/java/tests/src/com/android/intentresolver/shortcuts/ScopedAppTargetListCallbackTest.kt @@ -0,0 +1,71 @@ +/* + * Copyright (C) 2023 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.intentresolver.shortcuts + +import com.google.common.truth.Truth.assertThat +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.cancel +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import org.junit.Test + +@OptIn(ExperimentalCoroutinesApi::class) +class ScopedAppTargetListCallbackTest { + + @Test + fun test_consumerInvocations_onlyInvokedWhileScopeIsActive() { + val scope = TestScope(UnconfinedTestDispatcher()) + var counter = 0 + val testSubject = ScopedAppTargetListCallback(scope) { counter++ }.toConsumer() + + testSubject.accept(ArrayList()) + + assertThat(counter).isEqualTo(1) + + scope.cancel() + testSubject.accept(ArrayList()) + + assertThat(counter).isEqualTo(1) + } + + @Test + fun test_appPredictorCallbackInvocations_onlyInvokedWhileScopeIsActive() { + val scope = TestScope(UnconfinedTestDispatcher()) + var counter = 0 + val testSubject = ScopedAppTargetListCallback(scope) { counter++ }.toAppPredictorCallback() + + testSubject.onTargetsAvailable(ArrayList()) + + assertThat(counter).isEqualTo(1) + + scope.cancel() + testSubject.onTargetsAvailable(ArrayList()) + + assertThat(counter).isEqualTo(1) + } + + @Test + fun test_createdWithClosedScope_noCallbackInvocations() { + val scope = TestScope(UnconfinedTestDispatcher()).apply { cancel() } + var counter = 0 + val testSubject = ScopedAppTargetListCallback(scope) { counter++ }.toConsumer() + + testSubject.accept(ArrayList()) + + assertThat(counter).isEqualTo(0) + } +} -- cgit v1.2.3-59-g8ed1b From 61b4a7e489b3a13f0a012c8d338ed63a3d3cf505 Mon Sep 17 00:00:00 2001 From: Joshua Trask Date: Wed, 27 Sep 2023 18:51:45 +0000 Subject: Introduce EmptyStateUiHelper (& initial tests) Based on prototype work in ag/24516421, this CL introduces a new component to handle the empty-state UI implementation details that had previously been implemented in `MultiProfilePagerAdapter`, since those details significantly clutter the implementation of that adapter's other responsibilities. As in ag/24516421 patchset #4, this just sets up the boilerplate and kicks off with some "low-hanging-fruit" operations. Follow-up CLs will continue migrating these responsibilities as in ag/24516421 (except with more incremental testing). Bug: 302311217 Test: IntentResolverUnitTests, CtsSharesheetDeviceTest Change-Id: Ie9bb7f4e97836321521c3cf13c77cafc97b1a461 --- .../intentresolver/MultiProfilePagerAdapter.java | 38 ++----- .../emptystate/EmptyStateUiHelper.java | 63 ++++++++++++ .../intentresolver/MultiProfilePagerAdapterTest.kt | 21 ++-- .../emptystate/EmptyStateUiHelperTest.kt | 113 +++++++++++++++++++++ 4 files changed, 200 insertions(+), 35 deletions(-) create mode 100644 java/src/com/android/intentresolver/emptystate/EmptyStateUiHelper.java create mode 100644 java/tests/src/com/android/intentresolver/emptystate/EmptyStateUiHelperTest.kt (limited to 'java/src') diff --git a/java/src/com/android/intentresolver/MultiProfilePagerAdapter.java b/java/src/com/android/intentresolver/MultiProfilePagerAdapter.java index 2c98d89f..8c640dd3 100644 --- a/java/src/com/android/intentresolver/MultiProfilePagerAdapter.java +++ b/java/src/com/android/intentresolver/MultiProfilePagerAdapter.java @@ -29,6 +29,7 @@ import androidx.viewpager.widget.ViewPager; import com.android.intentresolver.emptystate.EmptyState; import com.android.intentresolver.emptystate.EmptyStateProvider; +import com.android.intentresolver.emptystate.EmptyStateUiHelper; import com.android.internal.annotations.VisibleForTesting; import com.google.common.collect.ImmutableList; @@ -424,7 +425,7 @@ public class MultiProfilePagerAdapter< clickListener = v -> emptyState.getButtonClickListener().onClick(() -> { ProfileDescriptor descriptor = getItem( userHandleToPageIndex(listAdapter.getUserHandle())); - MultiProfilePagerAdapter.this.showSpinner(descriptor.getEmptyStateView()); + descriptor.mEmptyStateUi.showSpinner(); }); } @@ -451,9 +452,9 @@ public class MultiProfilePagerAdapter< userHandleToPageIndex(activeListAdapter.getUserHandle())); descriptor.mRootView.findViewById( com.android.internal.R.id.resolver_list).setVisibility(View.GONE); + descriptor.mEmptyStateUi.resetViewVisibilities(); + ViewGroup emptyStateView = descriptor.getEmptyStateView(); - resetViewVisibilitiesForEmptyState(emptyStateView); - emptyStateView.setVisibility(View.VISIBLE); View container = emptyStateView.findViewById( com.android.internal.R.id.resolver_empty_state_container); @@ -504,36 +505,12 @@ public class MultiProfilePagerAdapter< paddingBottom)); } - private void showSpinner(View emptyStateView) { - emptyStateView.findViewById(com.android.internal.R.id.resolver_empty_state_title) - .setVisibility(View.INVISIBLE); - emptyStateView.findViewById(com.android.internal.R.id.resolver_empty_state_button) - .setVisibility(View.INVISIBLE); - emptyStateView.findViewById(com.android.internal.R.id.resolver_empty_state_progress) - .setVisibility(View.VISIBLE); - emptyStateView.findViewById(com.android.internal.R.id.empty).setVisibility(View.GONE); - } - - private void resetViewVisibilitiesForEmptyState(View emptyStateView) { - emptyStateView.findViewById(com.android.internal.R.id.resolver_empty_state_title) - .setVisibility(View.VISIBLE); - emptyStateView.findViewById(com.android.internal.R.id.resolver_empty_state_subtitle) - .setVisibility(View.VISIBLE); - emptyStateView.findViewById(com.android.internal.R.id.resolver_empty_state_button) - .setVisibility(View.INVISIBLE); - emptyStateView.findViewById(com.android.internal.R.id.resolver_empty_state_progress) - .setVisibility(View.GONE); - emptyStateView.findViewById(com.android.internal.R.id.empty).setVisibility(View.GONE); - } - protected void showListView(ListAdapterT activeListAdapter) { ProfileDescriptor descriptor = getItem( userHandleToPageIndex(activeListAdapter.getUserHandle())); descriptor.mRootView.findViewById( com.android.internal.R.id.resolver_list).setVisibility(View.VISIBLE); - View emptyStateView = descriptor.mRootView.findViewById( - com.android.internal.R.id.resolver_empty_state); - emptyStateView.setVisibility(View.GONE); + descriptor.mEmptyStateUi.hide(); } public boolean shouldShowEmptyStateScreen(ListAdapterT listAdapter) { @@ -547,6 +524,10 @@ public class MultiProfilePagerAdapter< // should be the owner of all per-profile data (especially now that the API is generic)? private static class ProfileDescriptor { final ViewGroup mRootView; + final EmptyStateUiHelper mEmptyStateUi; + + // TODO: post-refactoring, we may not need to retain these ivars directly (since they may + // be encapsulated within the `EmptyStateUiHelper`?). private final ViewGroup mEmptyStateView; private final SinglePageAdapterT mAdapter; @@ -557,6 +538,7 @@ public class MultiProfilePagerAdapter< mAdapter = adapter; mEmptyStateView = rootView.findViewById(com.android.internal.R.id.resolver_empty_state); mView = (PageViewT) rootView.findViewById(com.android.internal.R.id.resolver_list); + mEmptyStateUi = new EmptyStateUiHelper(rootView); } protected ViewGroup getEmptyStateView() { diff --git a/java/src/com/android/intentresolver/emptystate/EmptyStateUiHelper.java b/java/src/com/android/intentresolver/emptystate/EmptyStateUiHelper.java new file mode 100644 index 00000000..d7ef8c75 --- /dev/null +++ b/java/src/com/android/intentresolver/emptystate/EmptyStateUiHelper.java @@ -0,0 +1,63 @@ +/* + * Copyright (C) 2023 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.android.intentresolver.emptystate; + +import android.view.View; +import android.view.ViewGroup; + +/** + * Helper for building `MultiProfilePagerAdapter` tab UIs for profile tabs that are "blocked" by + * some empty-state status. + */ +public class EmptyStateUiHelper { + private final View mEmptyStateView; + + public EmptyStateUiHelper(ViewGroup rootView) { + mEmptyStateView = + rootView.requireViewById(com.android.internal.R.id.resolver_empty_state); + } + + public void resetViewVisibilities() { + mEmptyStateView.requireViewById(com.android.internal.R.id.resolver_empty_state_title) + .setVisibility(View.VISIBLE); + mEmptyStateView.requireViewById(com.android.internal.R.id.resolver_empty_state_subtitle) + .setVisibility(View.VISIBLE); + mEmptyStateView.requireViewById(com.android.internal.R.id.resolver_empty_state_button) + .setVisibility(View.INVISIBLE); + mEmptyStateView.requireViewById(com.android.internal.R.id.resolver_empty_state_progress) + .setVisibility(View.GONE); + mEmptyStateView.requireViewById(com.android.internal.R.id.empty) + .setVisibility(View.GONE); + mEmptyStateView.setVisibility(View.VISIBLE); + } + + public void showSpinner() { + mEmptyStateView.requireViewById(com.android.internal.R.id.resolver_empty_state_title) + .setVisibility(View.INVISIBLE); + // TODO: subtitle? + mEmptyStateView.requireViewById(com.android.internal.R.id.resolver_empty_state_button) + .setVisibility(View.INVISIBLE); + mEmptyStateView.requireViewById(com.android.internal.R.id.resolver_empty_state_progress) + .setVisibility(View.VISIBLE); + mEmptyStateView.requireViewById(com.android.internal.R.id.empty) + .setVisibility(View.GONE); + } + + public void hide() { + mEmptyStateView.setVisibility(View.GONE); + } +} + diff --git a/java/tests/src/com/android/intentresolver/MultiProfilePagerAdapterTest.kt b/java/tests/src/com/android/intentresolver/MultiProfilePagerAdapterTest.kt index 56034f0a..ed06f7d1 100644 --- a/java/tests/src/com/android/intentresolver/MultiProfilePagerAdapterTest.kt +++ b/java/tests/src/com/android/intentresolver/MultiProfilePagerAdapterTest.kt @@ -17,7 +17,9 @@ package com.android.intentresolver import android.os.UserHandle +import android.view.LayoutInflater import android.view.View +import android.view.ViewGroup import android.widget.ListView import androidx.test.platform.app.InstrumentationRegistry import com.android.intentresolver.MultiProfilePagerAdapter.PROFILE_PERSONAL @@ -26,6 +28,7 @@ import com.android.intentresolver.emptystate.EmptyStateProvider import com.google.common.collect.ImmutableList import com.google.common.truth.Truth.assertThat import java.util.Optional +import java.util.function.Supplier import org.junit.Test import org.mockito.Mockito.never import org.mockito.Mockito.verify @@ -35,6 +38,10 @@ class MultiProfilePagerAdapterTest { private val WORK_USER_HANDLE = UserHandle.of(20) private val context = InstrumentationRegistry.getInstrumentation().getContext() + private val inflater = Supplier { + LayoutInflater.from(context).inflate(R.layout.resolver_list_per_profile, null, false) + as ViewGroup + } @Test fun testSinglePageProfileAdapter() { @@ -52,7 +59,7 @@ class MultiProfilePagerAdapterTest { PROFILE_PERSONAL, null, null, - { ListView(context) }, + inflater, { Optional.empty() } ) assertThat(pagerAdapter.count).isEqualTo(1) @@ -86,7 +93,7 @@ class MultiProfilePagerAdapterTest { PROFILE_PERSONAL, WORK_USER_HANDLE, // TODO: why does this test pass even if this is null? null, - { ListView(context) }, + inflater, { Optional.empty() } ) assertThat(pagerAdapter.count).isEqualTo(2) @@ -125,7 +132,7 @@ class MultiProfilePagerAdapterTest { PROFILE_WORK, // <-- This test specifically requests we start on work profile. WORK_USER_HANDLE, // TODO: why does this test pass even if this is null? null, - { ListView(context) }, + inflater, { Optional.empty() } ) assertThat(pagerAdapter.count).isEqualTo(2) @@ -165,7 +172,7 @@ class MultiProfilePagerAdapterTest { PROFILE_PERSONAL, null, null, - { ListView(context) }, + inflater, { Optional.empty() } ) pagerAdapter.setupContainerPadding(container) @@ -193,7 +200,7 @@ class MultiProfilePagerAdapterTest { PROFILE_PERSONAL, null, null, - { ListView(context) }, + inflater, { Optional.of(42) } ) pagerAdapter.setupContainerPadding(container) @@ -227,7 +234,7 @@ class MultiProfilePagerAdapterTest { PROFILE_WORK, WORK_USER_HANDLE, null, - { ListView(context) }, + inflater, { Optional.empty() } ) assertThat(pagerAdapter.shouldShowEmptyStateScreen(workListAdapter)).isTrue() @@ -261,7 +268,7 @@ class MultiProfilePagerAdapterTest { PROFILE_WORK, WORK_USER_HANDLE, null, - { ListView(context) }, + inflater, { Optional.empty() } ) assertThat(pagerAdapter.shouldShowEmptyStateScreen(workListAdapter)).isFalse() diff --git a/java/tests/src/com/android/intentresolver/emptystate/EmptyStateUiHelperTest.kt b/java/tests/src/com/android/intentresolver/emptystate/EmptyStateUiHelperTest.kt new file mode 100644 index 00000000..bc5545db --- /dev/null +++ b/java/tests/src/com/android/intentresolver/emptystate/EmptyStateUiHelperTest.kt @@ -0,0 +1,113 @@ +/* + * Copyright (C) 2023 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.intentresolver.emptystate + +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.FrameLayout +import androidx.test.platform.app.InstrumentationRegistry +import com.google.common.truth.Truth.assertThat +import org.junit.Before +import org.junit.Test + +class EmptyStateUiHelperTest { + private val context = InstrumentationRegistry.getInstrumentation().getContext() + + lateinit var rootContainer: ViewGroup + lateinit var emptyStateTitleView: View + lateinit var emptyStateSubtitleView: View + lateinit var emptyStateButtonView: View + lateinit var emptyStateProgressView: View + lateinit var emptyStateDefaultTextView: View + lateinit var emptyStateContainerView: View + lateinit var emptyStateRootView: View + lateinit var emptyStateUiHelper: EmptyStateUiHelper + + @Before + fun setup() { + rootContainer = FrameLayout(context) + LayoutInflater.from(context) + .inflate( + com.android.intentresolver.R.layout.resolver_list_per_profile, + rootContainer, + true + ) + emptyStateRootView = + rootContainer.requireViewById(com.android.internal.R.id.resolver_empty_state) + emptyStateTitleView = + rootContainer.requireViewById(com.android.internal.R.id.resolver_empty_state_title) + emptyStateSubtitleView = rootContainer.requireViewById( + com.android.internal.R.id.resolver_empty_state_subtitle) + emptyStateButtonView = rootContainer.requireViewById( + com.android.internal.R.id.resolver_empty_state_button) + emptyStateProgressView = rootContainer.requireViewById( + com.android.internal.R.id.resolver_empty_state_progress) + emptyStateDefaultTextView = + rootContainer.requireViewById(com.android.internal.R.id.empty) + emptyStateContainerView = rootContainer.requireViewById( + com.android.internal.R.id.resolver_empty_state_container) + emptyStateUiHelper = EmptyStateUiHelper(rootContainer) + } + + @Test + fun testResetViewVisibilities() { + // First set each view's visibility to differ from the expected "reset" state so we can then + // assert that they're all reset afterward. + // TODO: for historic reasons "reset" doesn't cover `emptyStateContainerView`; should it? + emptyStateRootView.visibility = View.GONE + emptyStateTitleView.visibility = View.GONE + emptyStateSubtitleView.visibility = View.GONE + emptyStateButtonView.visibility = View.VISIBLE + emptyStateProgressView.visibility = View.VISIBLE + emptyStateDefaultTextView.visibility = View.VISIBLE + + emptyStateUiHelper.resetViewVisibilities() + + assertThat(emptyStateRootView.visibility).isEqualTo(View.VISIBLE) + assertThat(emptyStateTitleView.visibility).isEqualTo(View.VISIBLE) + assertThat(emptyStateSubtitleView.visibility).isEqualTo(View.VISIBLE) + assertThat(emptyStateButtonView.visibility).isEqualTo(View.INVISIBLE) + assertThat(emptyStateProgressView.visibility).isEqualTo(View.GONE) + assertThat(emptyStateDefaultTextView.visibility).isEqualTo(View.GONE) + } + + @Test + fun testShowSpinner() { + emptyStateTitleView.visibility = View.VISIBLE + emptyStateButtonView.visibility = View.VISIBLE + emptyStateProgressView.visibility = View.GONE + emptyStateDefaultTextView.visibility = View.VISIBLE + + emptyStateUiHelper.showSpinner() + + // TODO: should this cover any other views? Subtitle? + assertThat(emptyStateTitleView.visibility).isEqualTo(View.INVISIBLE) + assertThat(emptyStateButtonView.visibility).isEqualTo(View.INVISIBLE) + assertThat(emptyStateProgressView.visibility).isEqualTo(View.VISIBLE) + assertThat(emptyStateDefaultTextView.visibility).isEqualTo(View.GONE) + } + + @Test + fun testHide() { + emptyStateRootView.visibility = View.VISIBLE + + emptyStateUiHelper.hide() + + assertThat(emptyStateRootView.visibility).isEqualTo(View.GONE) + } +} -- cgit v1.2.3-59-g8ed1b From 2bcce186d5b4879e33aedd6bbac7f5b0b4ec9cd8 Mon Sep 17 00:00:00 2001 From: Govinda Wasserman Date: Fri, 29 Sep 2023 14:54:05 -0400 Subject: Hard fork of the ChoserActivity and ResolverActivity The forked versions are flag guarded by the ChooserSelector. Test: atest com.android.intentresolver Test: adb shell pm resolve-activity -a android.intent.action.CHOOSER Test: Observe that the action resolves to .ChooserActivity Test: adb shell device_config put intentresolver \ com.android.intentresolver.flags.modular_framework true Test: Reboot device Test: adb shell pm resolve-activity -a android.intent.action.CHOOSER Test: Observe that the action resolves to .v2.ChooserActivity BUG: 302113519 Change-Id: I59584ed4649fca754826b17055a41be45a32f326 --- AndroidManifest-app.xml | 30 + aconfig/FeatureFlags.aconfig | 7 + .../intentresolver/AnnotatedUserHandles.java | 4 +- .../android/intentresolver/ChooserActivity.java | 2 +- .../intentresolver/ChooserGridLayoutManager.java | 2 +- .../ChooserIntegratedDeviceComponents.java | 2 +- .../android/intentresolver/ChooserListAdapter.java | 2 +- .../ChooserMultiProfilePagerAdapter.java | 4 +- .../intentresolver/MultiProfilePagerAdapter.java | 26 +- .../intentresolver/ResolverListAdapter.java | 10 +- .../intentresolver/ResolverListController.java | 2 +- .../ResolverMultiProfilePagerAdapter.java | 18 +- .../android/intentresolver/ResolverViewPager.java | 2 +- .../android/intentresolver/v2/ChooserActivity.java | 1851 ++++++++++++ .../android/intentresolver/v2/ChooserSelector.kt | 36 + .../intentresolver/v2/ResolverActivity.java | 2426 +++++++++++++++ java/tests/AndroidManifest.xml | 2 + .../com/android/intentresolver/MatcherUtils.java | 2 +- .../intentresolver/ResolverDataProvider.java | 10 +- .../v2/ChooserActivityOverrideData.java | 131 + .../intentresolver/v2/ChooserWrapperActivity.java | 280 ++ .../intentresolver/v2/ResolverActivityTest.java | 1105 +++++++ .../intentresolver/v2/ResolverWrapperActivity.java | 285 ++ .../v2/UnbundledChooserActivityTest.java | 3160 ++++++++++++++++++++ .../UnbundledChooserActivityWorkProfileTest.java | 481 +++ 25 files changed, 9837 insertions(+), 43 deletions(-) create mode 100644 java/src/com/android/intentresolver/v2/ChooserActivity.java create mode 100644 java/src/com/android/intentresolver/v2/ChooserSelector.kt create mode 100644 java/src/com/android/intentresolver/v2/ResolverActivity.java create mode 100644 java/tests/src/com/android/intentresolver/v2/ChooserActivityOverrideData.java create mode 100644 java/tests/src/com/android/intentresolver/v2/ChooserWrapperActivity.java create mode 100644 java/tests/src/com/android/intentresolver/v2/ResolverActivityTest.java create mode 100644 java/tests/src/com/android/intentresolver/v2/ResolverWrapperActivity.java create mode 100644 java/tests/src/com/android/intentresolver/v2/UnbundledChooserActivityTest.java create mode 100644 java/tests/src/com/android/intentresolver/v2/UnbundledChooserActivityWorkProfileTest.java (limited to 'java/src') diff --git a/AndroidManifest-app.xml b/AndroidManifest-app.xml index 9efc7ab1..ec4fec85 100644 --- a/AndroidManifest-app.xml +++ b/AndroidManifest-app.xml @@ -60,6 +60,36 @@ android:visibleToInstantApps="true" android:exported="false"/> + + + + + + + + + + + + + + + + + allTargets = new ArrayList<>(); diff --git a/java/src/com/android/intentresolver/ChooserMultiProfilePagerAdapter.java b/java/src/com/android/intentresolver/ChooserMultiProfilePagerAdapter.java index 23a081d2..080f9d24 100644 --- a/java/src/com/android/intentresolver/ChooserMultiProfilePagerAdapter.java +++ b/java/src/com/android/intentresolver/ChooserMultiProfilePagerAdapter.java @@ -46,7 +46,7 @@ public class ChooserMultiProfilePagerAdapter extends MultiProfilePagerAdapter< private final ChooserProfileAdapterBinder mAdapterBinder; private final BottomPaddingOverrideSupplier mBottomPaddingOverrideSupplier; - ChooserMultiProfilePagerAdapter( + public ChooserMultiProfilePagerAdapter( Context context, ChooserGridAdapter adapter, EmptyStateProvider emptyStateProvider, @@ -68,7 +68,7 @@ public class ChooserMultiProfilePagerAdapter extends MultiProfilePagerAdapter< featureFlags); } - ChooserMultiProfilePagerAdapter( + public ChooserMultiProfilePagerAdapter( Context context, ChooserGridAdapter personalAdapter, ChooserGridAdapter workAdapter, diff --git a/java/src/com/android/intentresolver/MultiProfilePagerAdapter.java b/java/src/com/android/intentresolver/MultiProfilePagerAdapter.java index 8c640dd3..8ce42b28 100644 --- a/java/src/com/android/intentresolver/MultiProfilePagerAdapter.java +++ b/java/src/com/android/intentresolver/MultiProfilePagerAdapter.java @@ -79,11 +79,11 @@ public class MultiProfilePagerAdapter< void bind(PageViewT view, SinglePageAdapterT adapter); } - static final int PROFILE_PERSONAL = 0; - static final int PROFILE_WORK = 1; + public static final int PROFILE_PERSONAL = 0; + public static final int PROFILE_WORK = 1; @IntDef({PROFILE_PERSONAL, PROFILE_WORK}) - @interface Profile {} + public @interface Profile {} private final Function mListAdapterExtractor; private final AdapterBinder mAdapterBinder; @@ -197,7 +197,7 @@ public class MultiProfilePagerAdapter< return getItemCount(); } - protected int getCurrentPage() { + public int getCurrentPage() { return mCurrentPage; } @@ -234,7 +234,7 @@ public class MultiProfilePagerAdapter< return mItems.get(pageIndex); } - protected ViewGroup getEmptyStateView(int pageIndex) { + public ViewGroup getEmptyStateView(int pageIndex) { return getItem(pageIndex).getEmptyStateView(); } @@ -266,7 +266,7 @@ public class MultiProfilePagerAdapter< * Performs view-related initialization procedures for the adapter specified * by pageIndex. */ - protected final void setupListAdapter(int pageIndex) { + public final void setupListAdapter(int pageIndex) { mAdapterBinder.bind(getListViewForIndex(pageIndex), getAdapterForIndex(pageIndex)); } @@ -278,7 +278,7 @@ public class MultiProfilePagerAdapter< * with UserHandle.of(10) returns the work profile {@link ListAdapterT}. */ @Nullable - protected final ListAdapterT getListAdapterForUserHandle(UserHandle userHandle) { + public final ListAdapterT getListAdapterForUserHandle(UserHandle userHandle) { if (getPersonalListAdapter().getUserHandle().equals(userHandle) || userHandle.equals(getCloneUserHandle())) { return getPersonalListAdapter(); @@ -297,7 +297,7 @@ public class MultiProfilePagerAdapter< * @see #getInactiveListAdapter() */ @VisibleForTesting - protected final ListAdapterT getActiveListAdapter() { + public final ListAdapterT getActiveListAdapter() { return mListAdapterExtractor.apply(getAdapterForIndex(getCurrentPage())); } @@ -311,7 +311,7 @@ public class MultiProfilePagerAdapter< */ @VisibleForTesting @Nullable - protected final ListAdapterT getInactiveListAdapter() { + public final ListAdapterT getInactiveListAdapter() { if (getCount() < 2) { return null; } @@ -330,16 +330,16 @@ public class MultiProfilePagerAdapter< return mListAdapterExtractor.apply(getAdapterForIndex(PROFILE_WORK)); } - protected final SinglePageAdapterT getCurrentRootAdapter() { + public final SinglePageAdapterT getCurrentRootAdapter() { return getAdapterForIndex(getCurrentPage()); } - protected final PageViewT getActiveAdapterView() { + public final PageViewT getActiveAdapterView() { return getListViewForIndex(getCurrentPage()); } @Nullable - protected final PageViewT getInactiveAdapterView() { + public final PageViewT getInactiveAdapterView() { if (getCount() < 2) { return null; } @@ -505,7 +505,7 @@ public class MultiProfilePagerAdapter< paddingBottom)); } - protected void showListView(ListAdapterT activeListAdapter) { + public void showListView(ListAdapterT activeListAdapter) { ProfileDescriptor descriptor = getItem( userHandleToPageIndex(activeListAdapter.getUserHandle())); descriptor.mRootView.findViewById( diff --git a/java/src/com/android/intentresolver/ResolverListAdapter.java b/java/src/com/android/intentresolver/ResolverListAdapter.java index 95ed0d5c..8c0d414c 100644 --- a/java/src/com/android/intentresolver/ResolverListAdapter.java +++ b/java/src/com/android/intentresolver/ResolverListAdapter.java @@ -68,7 +68,7 @@ public class ResolverListAdapter extends BaseAdapter { protected final Context mContext; protected final LayoutInflater mInflater; protected final ResolverListCommunicator mResolverListCommunicator; - protected final ResolverListController mResolverListController; + public final ResolverListController mResolverListController; private final List mIntents; private final Intent[] mInitialIntents; @@ -229,7 +229,7 @@ public class ResolverListAdapter extends BaseAdapter { packageName, userHandle, action); } - List getUnfilteredResolveList() { + public List getUnfilteredResolveList() { return mUnfilteredResolveList; } @@ -808,7 +808,7 @@ public class ResolverListAdapter extends BaseAdapter { return mContext.getDrawable(R.drawable.resolver_icon_placeholder); } - void loadFilteredItemIconTaskAsync(@NonNull ImageView iconView) { + public void loadFilteredItemIconTaskAsync(@NonNull ImageView iconView) { final DisplayResolveInfo iconInfo = getFilteredItem(); if (iconInfo != null) { mTargetDataLoader.loadAppTargetIcon( @@ -834,7 +834,7 @@ public class ResolverListAdapter extends BaseAdapter { return mIntents; } - protected boolean isTabLoaded() { + public boolean isTabLoaded() { return mIsTabLoaded; } @@ -893,7 +893,7 @@ public class ResolverListAdapter extends BaseAdapter { * Necessary methods to communicate between {@link ResolverListAdapter} * and {@link ResolverActivity}. */ - interface ResolverListCommunicator { + public interface ResolverListCommunicator { Intent getReplacementIntent(ActivityInfo activityInfo, Intent defIntent); diff --git a/java/src/com/android/intentresolver/ResolverListController.java b/java/src/com/android/intentresolver/ResolverListController.java index cb56ab30..05121576 100644 --- a/java/src/com/android/intentresolver/ResolverListController.java +++ b/java/src/com/android/intentresolver/ResolverListController.java @@ -333,7 +333,7 @@ public class ResolverListController { && ai.name.equals(b.name.getClassName()); } - boolean isComponentFiltered(ComponentName componentName) { + public boolean isComponentFiltered(ComponentName componentName) { return false; } diff --git a/java/src/com/android/intentresolver/ResolverMultiProfilePagerAdapter.java b/java/src/com/android/intentresolver/ResolverMultiProfilePagerAdapter.java index e0c5380f..591c23b7 100644 --- a/java/src/com/android/intentresolver/ResolverMultiProfilePagerAdapter.java +++ b/java/src/com/android/intentresolver/ResolverMultiProfilePagerAdapter.java @@ -40,7 +40,7 @@ public class ResolverMultiProfilePagerAdapter extends MultiProfilePagerAdapter { private final BottomPaddingOverrideSupplier mBottomPaddingOverrideSupplier; - ResolverMultiProfilePagerAdapter( + public ResolverMultiProfilePagerAdapter( Context context, ResolverListAdapter adapter, EmptyStateProvider emptyStateProvider, @@ -58,14 +58,14 @@ public class ResolverMultiProfilePagerAdapter extends new BottomPaddingOverrideSupplier()); } - ResolverMultiProfilePagerAdapter(Context context, - ResolverListAdapter personalAdapter, - ResolverListAdapter workAdapter, - EmptyStateProvider emptyStateProvider, - Supplier workProfileQuietModeChecker, - @Profile int defaultProfile, - UserHandle workProfileUserHandle, - UserHandle cloneProfileUserHandle) { + public ResolverMultiProfilePagerAdapter(Context context, + ResolverListAdapter personalAdapter, + ResolverListAdapter workAdapter, + EmptyStateProvider emptyStateProvider, + Supplier workProfileQuietModeChecker, + @Profile int defaultProfile, + UserHandle workProfileUserHandle, + UserHandle cloneProfileUserHandle) { this( context, ImmutableList.of(personalAdapter, workAdapter), diff --git a/java/src/com/android/intentresolver/ResolverViewPager.java b/java/src/com/android/intentresolver/ResolverViewPager.java index 0804a2b8..0496579d 100644 --- a/java/src/com/android/intentresolver/ResolverViewPager.java +++ b/java/src/com/android/intentresolver/ResolverViewPager.java @@ -69,7 +69,7 @@ public class ResolverViewPager extends ViewPager { * Sets whether swiping sideways should happen. *

Note that swiping is always disabled for RTL layouts (b/159110029 for context). */ - void setSwipingEnabled(boolean swipingEnabled) { + public void setSwipingEnabled(boolean swipingEnabled) { mSwipingEnabled = swipingEnabled; } diff --git a/java/src/com/android/intentresolver/v2/ChooserActivity.java b/java/src/com/android/intentresolver/v2/ChooserActivity.java new file mode 100644 index 00000000..9e437010 --- /dev/null +++ b/java/src/com/android/intentresolver/v2/ChooserActivity.java @@ -0,0 +1,1851 @@ +/* + * Copyright (C) 2008 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.intentresolver.v2; + +import static android.app.admin.DevicePolicyResources.Strings.Core.RESOLVER_CANT_ACCESS_PERSONAL; +import static android.app.admin.DevicePolicyResources.Strings.Core.RESOLVER_CANT_ACCESS_WORK; +import static android.app.admin.DevicePolicyResources.Strings.Core.RESOLVER_CANT_SHARE_WITH_PERSONAL; +import static android.app.admin.DevicePolicyResources.Strings.Core.RESOLVER_CANT_SHARE_WITH_WORK; +import static android.app.admin.DevicePolicyResources.Strings.Core.RESOLVER_CROSS_PROFILE_BLOCKED_TITLE; +import static android.stats.devicepolicy.nano.DevicePolicyEnums.RESOLVER_EMPTY_STATE_NO_SHARING_TO_PERSONAL; +import static android.stats.devicepolicy.nano.DevicePolicyEnums.RESOLVER_EMPTY_STATE_NO_SHARING_TO_WORK; +import static androidx.lifecycle.LifecycleKt.getCoroutineScope; +import static com.android.intentresolver.v2.ResolverActivity.PROFILE_PERSONAL; +import static com.android.intentresolver.v2.ResolverActivity.PROFILE_WORK; +import static com.android.internal.util.LatencyTracker.ACTION_LOAD_SHARE_SHEET; + +import android.annotation.IntDef; +import android.annotation.Nullable; +import android.app.Activity; +import android.app.ActivityManager; +import android.app.ActivityOptions; +import android.app.prediction.AppPredictor; +import android.app.prediction.AppTarget; +import android.app.prediction.AppTargetEvent; +import android.app.prediction.AppTargetId; +import android.content.ComponentName; +import android.content.ContentResolver; +import android.content.Context; +import android.content.Intent; +import android.content.IntentFilter; +import android.content.IntentSender; +import android.content.SharedPreferences; +import android.content.pm.ActivityInfo; +import android.content.pm.PackageManager; +import android.content.pm.ResolveInfo; +import android.content.pm.ShortcutInfo; +import android.content.res.Configuration; +import android.database.Cursor; +import android.graphics.Insets; +import android.net.Uri; +import android.os.Bundle; +import android.os.SystemClock; +import android.os.UserHandle; +import android.os.UserManager; +import android.service.chooser.ChooserTarget; +import android.util.Log; +import android.util.Slog; +import android.util.SparseArray; +import android.view.View; +import android.view.ViewGroup; +import android.view.ViewGroup.LayoutParams; +import android.view.ViewTreeObserver; +import android.view.WindowInsets; +import android.widget.TextView; + +import androidx.annotation.MainThread; +import androidx.annotation.NonNull; +import androidx.lifecycle.ViewModelProvider; +import androidx.recyclerview.widget.GridLayoutManager; +import androidx.recyclerview.widget.RecyclerView; +import androidx.viewpager.widget.ViewPager; + +import com.android.intentresolver.ChooserActionFactory; +import com.android.intentresolver.ChooserGridLayoutManager; +import com.android.intentresolver.ChooserIntegratedDeviceComponents; +import com.android.intentresolver.ChooserListAdapter; +import com.android.intentresolver.ChooserMultiProfilePagerAdapter; +import com.android.intentresolver.ChooserRefinementManager; +import com.android.intentresolver.ChooserRequestParameters; +import com.android.intentresolver.ChooserStackedAppDialogFragment; +import com.android.intentresolver.ChooserTargetActionsDialogFragment; +import com.android.intentresolver.EnterTransitionAnimationDelegate; +import com.android.intentresolver.FeatureFlags; +import com.android.intentresolver.IntentForwarderActivity; +import com.android.intentresolver.R; +import com.android.intentresolver.ResolverListAdapter; +import com.android.intentresolver.ResolverListController; +import com.android.intentresolver.ResolverViewPager; +import com.android.intentresolver.SecureSettings; +import com.android.intentresolver.chooser.DisplayResolveInfo; +import com.android.intentresolver.chooser.MultiDisplayResolveInfo; +import com.android.intentresolver.chooser.TargetInfo; +import com.android.intentresolver.contentpreview.BasePreviewViewModel; +import com.android.intentresolver.contentpreview.ChooserContentPreviewUi; +import com.android.intentresolver.contentpreview.HeadlineGeneratorImpl; +import com.android.intentresolver.contentpreview.PreviewViewModel; +import com.android.intentresolver.emptystate.EmptyState; +import com.android.intentresolver.emptystate.EmptyStateProvider; +import com.android.intentresolver.emptystate.NoCrossProfileEmptyStateProvider; +import com.android.intentresolver.emptystate.NoCrossProfileEmptyStateProvider.DevicePolicyBlockerEmptyState; +import com.android.intentresolver.grid.ChooserGridAdapter; +import com.android.intentresolver.icons.DefaultTargetDataLoader; +import com.android.intentresolver.icons.TargetDataLoader; +import com.android.intentresolver.logging.EventLog; +import com.android.intentresolver.measurements.Tracer; +import com.android.intentresolver.model.AbstractResolverComparator; +import com.android.intentresolver.model.AppPredictionServiceResolverComparator; +import com.android.intentresolver.model.ResolverRankerServiceResolverComparator; +import com.android.intentresolver.shortcuts.AppPredictorFactory; +import com.android.intentresolver.shortcuts.ShortcutLoader; +import com.android.intentresolver.widget.ImagePreviewView; +import com.android.intentresolver.v2.Hilt_ChooserActivity; +import com.android.internal.annotations.VisibleForTesting; +import com.android.internal.content.PackageMonitor; +import com.android.internal.logging.nano.MetricsProto.MetricsEvent; + +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.text.Collator; +import java.util.ArrayList; +import java.util.Collections; +import java.util.Comparator; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.function.Consumer; + +import javax.inject.Inject; + +import dagger.hilt.android.AndroidEntryPoint; + +/** + * The Chooser Activity handles intent resolution specifically for sharing intents - + * for example, as generated by {@see android.content.Intent#createChooser(Intent, CharSequence)}. + * + */ +@AndroidEntryPoint(ResolverActivity.class) +public class ChooserActivity extends Hilt_ChooserActivity implements + ResolverListAdapter.ResolverListCommunicator { + private static final String TAG = "ChooserActivity"; + + /** + * Boolean extra to change the following behavior: Normally, ChooserActivity finishes itself + * in onStop when launched in a new task. If this extra is set to true, we do not finish + * ourselves when onStop gets called. + */ + public static final String EXTRA_PRIVATE_RETAIN_IN_ON_STOP + = "com.android.internal.app.ChooserActivity.EXTRA_PRIVATE_RETAIN_IN_ON_STOP"; + + /** + * Transition name for the first image preview. + * To be used for shared element transition into this activity. + * @hide + */ + public static final String FIRST_IMAGE_PREVIEW_TRANSITION_NAME = "screenshot_preview_image"; + + private static final boolean DEBUG = true; + + public static final String LAUNCH_LOCATION_DIRECT_SHARE = "direct_share"; + private static final String SHORTCUT_TARGET = "shortcut_target"; + + // TODO: these data structures are for one-time use in shuttling data from where they're + // populated in `ShortcutToChooserTargetConverter` to where they're consumed in + // `ShortcutSelectionLogic` which packs the appropriate elements into the final `TargetInfo`. + // That flow should be refactored so that `ChooserActivity` isn't responsible for holding their + // intermediate data, and then these members can be removed. + private final Map mDirectShareAppTargetCache = new HashMap<>(); + private final Map mDirectShareShortcutInfoCache = new HashMap<>(); + + public static final int TARGET_TYPE_DEFAULT = 0; + public static final int TARGET_TYPE_CHOOSER_TARGET = 1; + public static final int TARGET_TYPE_SHORTCUTS_FROM_SHORTCUT_MANAGER = 2; + public static final int TARGET_TYPE_SHORTCUTS_FROM_PREDICTION_SERVICE = 3; + + private static final int SCROLL_STATUS_IDLE = 0; + private static final int SCROLL_STATUS_SCROLLING_VERTICAL = 1; + private static final int SCROLL_STATUS_SCROLLING_HORIZONTAL = 2; + + @IntDef(flag = false, prefix = { "TARGET_TYPE_" }, value = { + TARGET_TYPE_DEFAULT, + TARGET_TYPE_CHOOSER_TARGET, + TARGET_TYPE_SHORTCUTS_FROM_SHORTCUT_MANAGER, + TARGET_TYPE_SHORTCUTS_FROM_PREDICTION_SERVICE + }) + @Retention(RetentionPolicy.SOURCE) + public @interface ShareTargetType {} + + @Inject public FeatureFlags mFeatureFlags; + @Inject public EventLog mEventLog; + + private ChooserIntegratedDeviceComponents mIntegratedDeviceComponents; + + /* TODO: this is `nullable` because we have to defer the assignment til onCreate(). We make the + * only assignment there, and expect it to be ready by the time we ever use it -- + * someday if we move all the usage to a component with a narrower lifecycle (something that + * matches our Activity's create/destroy lifecycle, not its Java object lifecycle) then we + * should be able to make this assignment as "final." + */ + @Nullable + private ChooserRequestParameters mChooserRequest; + + private ChooserRefinementManager mRefinementManager; + + private ChooserContentPreviewUi mChooserContentPreviewUi; + + private boolean mShouldDisplayLandscape; + private long mChooserShownTime; + protected boolean mIsSuccessfullySelected; + + private int mCurrAvailableWidth = 0; + private Insets mLastAppliedInsets = null; + private int mLastNumberOfChildren = -1; + private int mMaxTargetsPerRow = 1; + + private static final int MAX_LOG_RANK_POSITION = 12; + + // TODO: are these used anywhere? They should probably be migrated to ChooserRequestParameters. + private static final int MAX_EXTRA_INITIAL_INTENTS = 2; + private static final int MAX_EXTRA_CHOOSER_TARGETS = 2; + + private SharedPreferences mPinnedSharedPrefs; + private static final String PINNED_SHARED_PREFS_NAME = "chooser_pin_settings"; + + private final ExecutorService mBackgroundThreadPoolExecutor = Executors.newFixedThreadPool(5); + + private int mScrollStatus = SCROLL_STATUS_IDLE; + + @VisibleForTesting + protected ChooserMultiProfilePagerAdapter mChooserMultiProfilePagerAdapter; + private final EnterTransitionAnimationDelegate mEnterTransitionAnimationDelegate = + new EnterTransitionAnimationDelegate(this, () -> mResolverDrawerLayout); + + private View mContentView = null; + + private final SparseArray mProfileRecords = new SparseArray<>(); + + private boolean mExcludeSharedText = false; + /** + * When we intend to finish the activity with a shared element transition, we can't immediately + * finish() when the transition is invoked, as the receiving end may not be able to start the + * animation and the UI breaks if this takes too long. Instead we defer finishing until onStop + * in order to wait for the transition to begin. + */ + private boolean mFinishWhenStopped = false; + + @Override + protected void onCreate(Bundle savedInstanceState) { + Tracer.INSTANCE.markLaunched(); + final long intentReceivedTime = System.currentTimeMillis(); + mLatencyTracker.onActionStart(ACTION_LOAD_SHARE_SHEET); + + try { + mChooserRequest = new ChooserRequestParameters( + getIntent(), + getReferrerPackageName(), + getReferrer()); + } catch (IllegalArgumentException e) { + Log.e(TAG, "Caller provided invalid Chooser request parameters", e); + finish(); + super_onCreate(null); + return; + } + mPinnedSharedPrefs = getPinnedSharedPrefs(this); + mMaxTargetsPerRow = getResources().getInteger(R.integer.config_chooser_max_targets_per_row); + mShouldDisplayLandscape = + shouldDisplayLandscape(getResources().getConfiguration().orientation); + setRetainInOnStop(mChooserRequest.shouldRetainInOnStop()); + + createProfileRecords( + new AppPredictorFactory( + this, + mChooserRequest.getSharedText(), + mChooserRequest.getTargetIntentFilter()), + mChooserRequest.getTargetIntentFilter()); + + + super.onCreate( + savedInstanceState, + mChooserRequest.getTargetIntent(), + mChooserRequest.getAdditionalTargets(), + mChooserRequest.getTitle(), + mChooserRequest.getDefaultTitleResource(), + mChooserRequest.getInitialIntents(), + /* resolutionList= */ null, + /* supportsAlwaysUseOption= */ false, + new DefaultTargetDataLoader(this, getLifecycle(), false), + /* safeForwardingMode= */ true); + + getEventLog().logSharesheetTriggered(); + + mIntegratedDeviceComponents = getIntegratedDeviceComponents(); + + mRefinementManager = new ViewModelProvider(this).get(ChooserRefinementManager.class); + + mRefinementManager.getRefinementCompletion().observe(this, completion -> { + if (completion.consume()) { + TargetInfo targetInfo = completion.getTargetInfo(); + // targetInfo is non-null if the refinement process was successful. + if (targetInfo != null) { + maybeRemoveSharedText(targetInfo); + + // We already block suspended targets from going to refinement, and we probably + // can't recover a Chooser session if that's the reason the refined target fails + // to launch now. Fire-and-forget the refined launch; ignore the return value + // and just make sure the Sharesheet session gets cleaned up regardless. + ChooserActivity.super.onTargetSelected(targetInfo, false); + } + + finish(); + } + }); + + BasePreviewViewModel previewViewModel = + new ViewModelProvider(this, createPreviewViewModelFactory()) + .get(BasePreviewViewModel.class); + mChooserContentPreviewUi = new ChooserContentPreviewUi( + getLifecycle(), + previewViewModel.createOrReuseProvider(mChooserRequest), + mChooserRequest.getTargetIntent(), + previewViewModel.createOrReuseImageLoader(), + createChooserActionFactory(), + mEnterTransitionAnimationDelegate, + new HeadlineGeneratorImpl(this)); + + updateStickyContentPreview(); + if (shouldShowStickyContentPreview() + || mChooserMultiProfilePagerAdapter + .getCurrentRootAdapter().getSystemRowCount() != 0) { + getEventLog().logActionShareWithPreview( + mChooserContentPreviewUi.getPreferredContentPreview()); + } + + mChooserShownTime = System.currentTimeMillis(); + final long systemCost = mChooserShownTime - intentReceivedTime; + getEventLog().logChooserActivityShown( + isWorkProfile(), mChooserRequest.getTargetType(), systemCost); + + if (mResolverDrawerLayout != null) { + mResolverDrawerLayout.addOnLayoutChangeListener(this::handleLayoutChange); + + mResolverDrawerLayout.setOnCollapsedChangedListener( + isCollapsed -> { + mChooserMultiProfilePagerAdapter.setIsCollapsed(isCollapsed); + getEventLog().logSharesheetExpansionChanged(isCollapsed); + }); + } + + if (DEBUG) { + Log.d(TAG, "System Time Cost is " + systemCost); + } + + getEventLog().logShareStarted( + getReferrerPackageName(), + mChooserRequest.getTargetType(), + mChooserRequest.getCallerChooserTargets().size(), + (mChooserRequest.getInitialIntents() == null) + ? 0 : mChooserRequest.getInitialIntents().length, + isWorkProfile(), + mChooserContentPreviewUi.getPreferredContentPreview(), + mChooserRequest.getTargetAction(), + mChooserRequest.getChooserActions().size(), + mChooserRequest.getModifyShareAction() != null + ); + + mEnterTransitionAnimationDelegate.postponeTransition(); + } + + @VisibleForTesting + protected ChooserIntegratedDeviceComponents getIntegratedDeviceComponents() { + return ChooserIntegratedDeviceComponents.get(this, new SecureSettings()); + } + + @Override + protected int appliedThemeResId() { + return R.style.Theme_DeviceDefault_Chooser; + } + + private void createProfileRecords( + AppPredictorFactory factory, IntentFilter targetIntentFilter) { + UserHandle mainUserHandle = getAnnotatedUserHandles().personalProfileUserHandle; + ProfileRecord record = createProfileRecord(mainUserHandle, targetIntentFilter, factory); + if (record.shortcutLoader == null) { + Tracer.INSTANCE.endLaunchToShortcutTrace(); + } + + UserHandle workUserHandle = getAnnotatedUserHandles().workProfileUserHandle; + if (workUserHandle != null) { + createProfileRecord(workUserHandle, targetIntentFilter, factory); + } + } + + private ProfileRecord createProfileRecord( + UserHandle userHandle, IntentFilter targetIntentFilter, AppPredictorFactory factory) { + AppPredictor appPredictor = factory.create(userHandle); + ShortcutLoader shortcutLoader = ActivityManager.isLowRamDeviceStatic() + ? null + : createShortcutLoader( + this, + appPredictor, + userHandle, + targetIntentFilter, + shortcutsResult -> onShortcutsLoaded(userHandle, shortcutsResult)); + ProfileRecord record = new ProfileRecord(appPredictor, shortcutLoader); + mProfileRecords.put(userHandle.getIdentifier(), record); + return record; + } + + @Nullable + private ProfileRecord getProfileRecord(UserHandle userHandle) { + return mProfileRecords.get(userHandle.getIdentifier(), null); + } + + @VisibleForTesting + protected ShortcutLoader createShortcutLoader( + Context context, + AppPredictor appPredictor, + UserHandle userHandle, + IntentFilter targetIntentFilter, + Consumer callback) { + return new ShortcutLoader( + context, + getCoroutineScope(getLifecycle()), + appPredictor, + userHandle, + targetIntentFilter, + callback); + } + + static SharedPreferences getPinnedSharedPrefs(Context context) { + return context.getSharedPreferences(PINNED_SHARED_PREFS_NAME, MODE_PRIVATE); + } + + @Override + protected ChooserMultiProfilePagerAdapter createMultiProfilePagerAdapter( + Intent[] initialIntents, + List rList, + boolean filterLastUsed, + TargetDataLoader targetDataLoader) { + if (shouldShowTabs()) { + mChooserMultiProfilePagerAdapter = createChooserMultiProfilePagerAdapterForTwoProfiles( + initialIntents, rList, filterLastUsed, targetDataLoader); + } else { + mChooserMultiProfilePagerAdapter = createChooserMultiProfilePagerAdapterForOneProfile( + initialIntents, rList, filterLastUsed, targetDataLoader); + } + return mChooserMultiProfilePagerAdapter; + } + + @Override + protected EmptyStateProvider createBlockerEmptyStateProvider() { + final boolean isSendAction = mChooserRequest.isSendActionTarget(); + + final EmptyState noWorkToPersonalEmptyState = + new DevicePolicyBlockerEmptyState( + /* context= */ this, + /* devicePolicyStringTitleId= */ RESOLVER_CROSS_PROFILE_BLOCKED_TITLE, + /* defaultTitleResource= */ R.string.resolver_cross_profile_blocked, + /* devicePolicyStringSubtitleId= */ + isSendAction ? RESOLVER_CANT_SHARE_WITH_PERSONAL : RESOLVER_CANT_ACCESS_PERSONAL, + /* defaultSubtitleResource= */ + isSendAction ? R.string.resolver_cant_share_with_personal_apps_explanation + : R.string.resolver_cant_access_personal_apps_explanation, + /* devicePolicyEventId= */ RESOLVER_EMPTY_STATE_NO_SHARING_TO_PERSONAL, + /* devicePolicyEventCategory= */ ResolverActivity.METRICS_CATEGORY_CHOOSER); + + final EmptyState noPersonalToWorkEmptyState = + new DevicePolicyBlockerEmptyState( + /* context= */ this, + /* devicePolicyStringTitleId= */ RESOLVER_CROSS_PROFILE_BLOCKED_TITLE, + /* defaultTitleResource= */ R.string.resolver_cross_profile_blocked, + /* devicePolicyStringSubtitleId= */ + isSendAction ? RESOLVER_CANT_SHARE_WITH_WORK : RESOLVER_CANT_ACCESS_WORK, + /* defaultSubtitleResource= */ + isSendAction ? R.string.resolver_cant_share_with_work_apps_explanation + : R.string.resolver_cant_access_work_apps_explanation, + /* devicePolicyEventId= */ RESOLVER_EMPTY_STATE_NO_SHARING_TO_WORK, + /* devicePolicyEventCategory= */ ResolverActivity.METRICS_CATEGORY_CHOOSER); + + return new NoCrossProfileEmptyStateProvider( + getAnnotatedUserHandles().personalProfileUserHandle, + noWorkToPersonalEmptyState, + noPersonalToWorkEmptyState, + createCrossProfileIntentsChecker(), + getAnnotatedUserHandles().tabOwnerUserHandleForLaunch); + } + + private ChooserMultiProfilePagerAdapter createChooserMultiProfilePagerAdapterForOneProfile( + Intent[] initialIntents, + List rList, + boolean filterLastUsed, + TargetDataLoader targetDataLoader) { + ChooserGridAdapter adapter = createChooserGridAdapter( + /* context */ this, + /* payloadIntents */ mIntents, + initialIntents, + rList, + filterLastUsed, + /* userHandle */ getAnnotatedUserHandles().personalProfileUserHandle, + targetDataLoader); + return new ChooserMultiProfilePagerAdapter( + /* context */ this, + adapter, + createEmptyStateProvider(/* workProfileUserHandle= */ null), + /* workProfileQuietModeChecker= */ () -> false, + /* workProfileUserHandle= */ null, + getAnnotatedUserHandles().cloneProfileUserHandle, + mMaxTargetsPerRow, + mFeatureFlags); + } + + private ChooserMultiProfilePagerAdapter createChooserMultiProfilePagerAdapterForTwoProfiles( + Intent[] initialIntents, + List rList, + boolean filterLastUsed, + TargetDataLoader targetDataLoader) { + int selectedProfile = findSelectedProfile(); + ChooserGridAdapter personalAdapter = createChooserGridAdapter( + /* context */ this, + /* payloadIntents */ mIntents, + selectedProfile == PROFILE_PERSONAL ? initialIntents : null, + rList, + filterLastUsed, + /* userHandle */ getAnnotatedUserHandles().personalProfileUserHandle, + targetDataLoader); + ChooserGridAdapter workAdapter = createChooserGridAdapter( + /* context */ this, + /* payloadIntents */ mIntents, + selectedProfile == PROFILE_WORK ? initialIntents : null, + rList, + filterLastUsed, + /* userHandle */ getAnnotatedUserHandles().workProfileUserHandle, + targetDataLoader); + return new ChooserMultiProfilePagerAdapter( + /* context */ this, + personalAdapter, + workAdapter, + createEmptyStateProvider(getAnnotatedUserHandles().workProfileUserHandle), + () -> mWorkProfileAvailability.isQuietModeEnabled(), + selectedProfile, + getAnnotatedUserHandles().workProfileUserHandle, + getAnnotatedUserHandles().cloneProfileUserHandle, + mMaxTargetsPerRow, + mFeatureFlags); + } + + private int findSelectedProfile() { + int selectedProfile = getSelectedProfileExtra(); + if (selectedProfile == -1) { + selectedProfile = getProfileForUser( + getAnnotatedUserHandles().tabOwnerUserHandleForLaunch); + } + return selectedProfile; + } + + /** + * Check if the profile currently used is a work profile. + * @return true if it is work profile, false if it is parent profile (or no work profile is + * set up) + */ + protected boolean isWorkProfile() { + return getSystemService(UserManager.class) + .getUserInfo(UserHandle.myUserId()).isManagedProfile(); + } + + @Override + protected PackageMonitor createPackageMonitor(ResolverListAdapter listAdapter) { + return new PackageMonitor() { + @Override + public void onSomePackagesChanged() { + handlePackagesChanged(listAdapter); + } + }; + } + + /** + * Update UI to reflect changes in data. + */ + public void handlePackagesChanged() { + handlePackagesChanged(/* listAdapter */ null); + } + + /** + * Update UI to reflect changes in data. + *

If {@code listAdapter} is {@code null}, both profile list adapters are updated if + * available. + */ + private void handlePackagesChanged(@Nullable ResolverListAdapter listAdapter) { + // Refresh pinned items + mPinnedSharedPrefs = getPinnedSharedPrefs(this); + if (listAdapter == null) { + handlePackageChangePerProfile(mChooserMultiProfilePagerAdapter.getActiveListAdapter()); + if (mChooserMultiProfilePagerAdapter.getCount() > 1) { + handlePackageChangePerProfile( + mChooserMultiProfilePagerAdapter.getInactiveListAdapter()); + } + } else { + handlePackageChangePerProfile(listAdapter); + } + updateProfileViewButton(); + } + + private void handlePackageChangePerProfile(ResolverListAdapter adapter) { + ProfileRecord record = getProfileRecord(adapter.getUserHandle()); + if (record != null && record.shortcutLoader != null) { + record.shortcutLoader.reset(); + } + adapter.handlePackagesChanged(); + } + + @Override + protected void onResume() { + super.onResume(); + Log.d(TAG, "onResume: " + getComponentName().flattenToShortString()); + mFinishWhenStopped = false; + mRefinementManager.onActivityResume(); + } + + @Override + public void onConfigurationChanged(Configuration newConfig) { + super.onConfigurationChanged(newConfig); + ViewPager viewPager = findViewById(com.android.internal.R.id.profile_pager); + if (viewPager.isLayoutRtl()) { + mMultiProfilePagerAdapter.setupViewPager(viewPager); + } + + mShouldDisplayLandscape = shouldDisplayLandscape(newConfig.orientation); + mMaxTargetsPerRow = getResources().getInteger(R.integer.config_chooser_max_targets_per_row); + mChooserMultiProfilePagerAdapter.setMaxTargetsPerRow(mMaxTargetsPerRow); + adjustPreviewWidth(newConfig.orientation, null); + updateStickyContentPreview(); + updateTabPadding(); + } + + private boolean shouldDisplayLandscape(int orientation) { + // Sharesheet fixes the # of items per row and therefore can not correctly lay out + // when in the restricted size of multi-window mode. In the future, would be nice + // to use minimum dp size requirements instead + return orientation == Configuration.ORIENTATION_LANDSCAPE && !isInMultiWindowMode(); + } + + private void adjustPreviewWidth(int orientation, View parent) { + int width = -1; + if (mShouldDisplayLandscape) { + width = getResources().getDimensionPixelSize(R.dimen.chooser_preview_width); + } + + parent = parent == null ? getWindow().getDecorView() : parent; + + updateLayoutWidth(com.android.internal.R.id.content_preview_file_layout, width, parent); + } + + private void updateTabPadding() { + if (shouldShowTabs()) { + View tabs = findViewById(com.android.internal.R.id.tabs); + float iconSize = getResources().getDimension(R.dimen.chooser_icon_size); + // The entire width consists of icons or padding. Divide the item padding in half to get + // paddingHorizontal. + float padding = (tabs.getWidth() - mMaxTargetsPerRow * iconSize) + / mMaxTargetsPerRow / 2; + // Subtract the margin the buttons already have. + padding -= getResources().getDimension(R.dimen.resolver_profile_tab_margin); + tabs.setPadding((int) padding, 0, (int) padding, 0); + } + } + + private void updateLayoutWidth(int layoutResourceId, int width, View parent) { + View view = parent.findViewById(layoutResourceId); + if (view != null && view.getLayoutParams() != null) { + LayoutParams params = view.getLayoutParams(); + params.width = width; + view.setLayoutParams(params); + } + } + + /** + * Create a view that will be shown in the content preview area + * @param parent reference to the parent container where the view should be attached to + * @return content preview view + */ + protected ViewGroup createContentPreviewView(ViewGroup parent) { + ViewGroup layout = mChooserContentPreviewUi.displayContentPreview( + getResources(), + getLayoutInflater(), + parent, + mFeatureFlags.scrollablePreview() + ? findViewById(R.id.chooser_headline_row_container) + : null); + + if (layout != null) { + adjustPreviewWidth(getResources().getConfiguration().orientation, layout); + } + + return layout; + } + + @Nullable + private View getFirstVisibleImgPreviewView() { + View imagePreview = findViewById(R.id.scrollable_image_preview); + return imagePreview instanceof ImagePreviewView + ? ((ImagePreviewView) imagePreview).getTransitionView() + : null; + } + + /** + * Wrapping the ContentResolver call to expose for easier mocking, + * and to avoid mocking Android core classes. + */ + @VisibleForTesting + public Cursor queryResolver(ContentResolver resolver, Uri uri) { + return resolver.query(uri, null, null, null, null); + } + + @Override + protected void onStop() { + super.onStop(); + mRefinementManager.onActivityStop(isChangingConfigurations()); + + if (mFinishWhenStopped) { + mFinishWhenStopped = false; + finish(); + } + } + + @Override + protected void onDestroy() { + super.onDestroy(); + + if (isFinishing()) { + mLatencyTracker.onActionCancel(ACTION_LOAD_SHARE_SHEET); + } + + mBackgroundThreadPoolExecutor.shutdownNow(); + + destroyProfileRecords(); + } + + private void destroyProfileRecords() { + for (int i = 0; i < mProfileRecords.size(); ++i) { + mProfileRecords.valueAt(i).destroy(); + } + mProfileRecords.clear(); + } + + @Override // ResolverListCommunicator + public Intent getReplacementIntent(ActivityInfo aInfo, Intent defIntent) { + if (mChooserRequest == null) { + return defIntent; + } + + Intent result = defIntent; + if (mChooserRequest.getReplacementExtras() != null) { + final Bundle replExtras = + mChooserRequest.getReplacementExtras().getBundle(aInfo.packageName); + if (replExtras != null) { + result = new Intent(defIntent); + result.putExtras(replExtras); + } + } + if (aInfo.name.equals(IntentForwarderActivity.FORWARD_INTENT_TO_PARENT) + || aInfo.name.equals(IntentForwarderActivity.FORWARD_INTENT_TO_MANAGED_PROFILE)) { + result = Intent.createChooser(result, + getIntent().getCharSequenceExtra(Intent.EXTRA_TITLE)); + + // Don't auto-launch single intents if the intent is being forwarded. This is done + // because automatically launching a resolving application as a response to the user + // action of switching accounts is pretty unexpected. + result.putExtra(Intent.EXTRA_AUTO_LAUNCH_SINGLE_CHOICE, false); + } + return result; + } + + @Override + public void onActivityStarted(TargetInfo cti) { + if (mChooserRequest.getChosenComponentSender() != null) { + final ComponentName target = cti.getResolvedComponentName(); + if (target != null) { + final Intent fillIn = new Intent().putExtra(Intent.EXTRA_CHOSEN_COMPONENT, target); + try { + mChooserRequest.getChosenComponentSender().sendIntent( + this, Activity.RESULT_OK, fillIn, null, null); + } catch (IntentSender.SendIntentException e) { + Slog.e(TAG, "Unable to launch supplied IntentSender to report " + + "the chosen component: " + e); + } + } + } + } + + private void addCallerChooserTargets() { + if (!mChooserRequest.getCallerChooserTargets().isEmpty()) { + // Send the caller's chooser targets only to the default profile. + UserHandle defaultUser = (findSelectedProfile() == PROFILE_WORK) + ? getAnnotatedUserHandles().workProfileUserHandle + : getAnnotatedUserHandles().personalProfileUserHandle; + if (mChooserMultiProfilePagerAdapter.getCurrentUserHandle() == defaultUser) { + mChooserMultiProfilePagerAdapter.getActiveListAdapter().addServiceResults( + /* origTarget */ null, + new ArrayList<>(mChooserRequest.getCallerChooserTargets()), + TARGET_TYPE_DEFAULT, + /* directShareShortcutInfoCache */ Collections.emptyMap(), + /* directShareAppTargetCache */ Collections.emptyMap()); + } + } + } + + @Override + public int getLayoutResource() { + return mFeatureFlags.scrollablePreview() + ? R.layout.chooser_grid_scrollable_preview + : R.layout.chooser_grid; + } + + @Override // ResolverListCommunicator + public boolean shouldGetActivityMetadata() { + return true; + } + + @Override + public boolean shouldAutoLaunchSingleChoice(TargetInfo target) { + // Note that this is only safe because the Intent handled by the ChooserActivity is + // guaranteed to contain no extras unknown to the local ClassLoader. That is why this + // method can not be replaced in the ResolverActivity whole hog. + if (!super.shouldAutoLaunchSingleChoice(target)) { + return false; + } + + return getIntent().getBooleanExtra(Intent.EXTRA_AUTO_LAUNCH_SINGLE_CHOICE, true); + } + + private void showTargetDetails(TargetInfo targetInfo) { + if (targetInfo == null) return; + + List targetList = targetInfo.getAllDisplayTargets(); + if (targetList.isEmpty()) { + Log.e(TAG, "No displayable data to show target details"); + return; + } + + // TODO: implement these type-conditioned behaviors polymorphically, and consider moving + // the logic into `ChooserTargetActionsDialogFragment.show()`. + boolean isShortcutPinned = targetInfo.isSelectableTargetInfo() && targetInfo.isPinned(); + IntentFilter intentFilter = targetInfo.isSelectableTargetInfo() + ? mChooserRequest.getTargetIntentFilter() : null; + String shortcutTitle = targetInfo.isSelectableTargetInfo() + ? targetInfo.getDisplayLabel().toString() : null; + String shortcutIdKey = targetInfo.getDirectShareShortcutId(); + + ChooserTargetActionsDialogFragment.show( + getSupportFragmentManager(), + targetList, + // Adding userHandle from ResolveInfo allows the app icon in Dialog Box to be + // resolved correctly within the same tab. + targetInfo.getResolveInfo().userHandle, + shortcutIdKey, + shortcutTitle, + isShortcutPinned, + intentFilter); + } + + @Override + protected boolean onTargetSelected(TargetInfo target, boolean alwaysCheck) { + if (mRefinementManager.maybeHandleSelection( + target, + mChooserRequest.getRefinementIntentSender(), + getApplication(), + getMainThreadHandler())) { + return false; + } + updateModelAndChooserCounts(target); + maybeRemoveSharedText(target); + return super.onTargetSelected(target, alwaysCheck); + } + + @Override + public void startSelected(int which, boolean always, boolean filtered) { + ChooserListAdapter currentListAdapter = + mChooserMultiProfilePagerAdapter.getActiveListAdapter(); + TargetInfo targetInfo = currentListAdapter + .targetInfoForPosition(which, filtered); + if (targetInfo != null && targetInfo.isNotSelectableTargetInfo()) { + return; + } + + final long selectionCost = System.currentTimeMillis() - mChooserShownTime; + + if ((targetInfo != null) && targetInfo.isMultiDisplayResolveInfo()) { + MultiDisplayResolveInfo mti = (MultiDisplayResolveInfo) targetInfo; + if (!mti.hasSelected()) { + // Add userHandle based badge to the stackedAppDialogBox. + ChooserStackedAppDialogFragment.show( + getSupportFragmentManager(), + mti, + which, + targetInfo.getResolveInfo().userHandle); + return; + } + } + + super.startSelected(which, always, filtered); + + // TODO: both of the conditions around this switch logic *should* be redundant, and + // can be removed if certain invariants can be guaranteed. In particular, it seems + // like targetInfo (from `ChooserListAdapter.targetInfoForPosition()`) is *probably* + // expected to be null only at out-of-bounds indexes where `getPositionTargetType()` + // returns TARGET_BAD; then the switch falls through to a default no-op, and we don't + // need to null-check targetInfo. We only need the null check if it's possible that + // the ChooserListAdapter contains null elements "in the middle" of its list data, + // such that they're classified as belonging to one of the real target types. That + // should probably never happen. But why would this method ever be invoked with a + // null target at all? Even an out-of-bounds index should never be "selected"... + if ((currentListAdapter.getCount() > 0) && (targetInfo != null)) { + switch (currentListAdapter.getPositionTargetType(which)) { + case ChooserListAdapter.TARGET_SERVICE: + getEventLog().logShareTargetSelected( + EventLog.SELECTION_TYPE_SERVICE, + targetInfo.getResolveInfo().activityInfo.processName, + which, + /* directTargetAlsoRanked= */ getRankedPosition(targetInfo), + mChooserRequest.getCallerChooserTargets().size(), + targetInfo.getHashedTargetIdForMetrics(this), + targetInfo.isPinned(), + mIsSuccessfullySelected, + selectionCost + ); + return; + case ChooserListAdapter.TARGET_CALLER: + case ChooserListAdapter.TARGET_STANDARD: + getEventLog().logShareTargetSelected( + EventLog.SELECTION_TYPE_APP, + targetInfo.getResolveInfo().activityInfo.processName, + (which - currentListAdapter.getSurfacedTargetInfo().size()), + /* directTargetAlsoRanked= */ -1, + currentListAdapter.getCallerTargetCount(), + /* directTargetHashed= */ null, + targetInfo.isPinned(), + mIsSuccessfullySelected, + selectionCost + ); + return; + case ChooserListAdapter.TARGET_STANDARD_AZ: + // A-Z targets are unranked standard targets; we use a value of -1 to mark that + // they are from the alphabetical pool. + // TODO: why do we log a different selection type if the -1 value already + // designates the same condition? + getEventLog().logShareTargetSelected( + EventLog.SELECTION_TYPE_STANDARD, + targetInfo.getResolveInfo().activityInfo.processName, + /* value= */ -1, + /* directTargetAlsoRanked= */ -1, + /* numCallerProvided= */ 0, + /* directTargetHashed= */ null, + /* isPinned= */ false, + mIsSuccessfullySelected, + selectionCost + ); + return; + } + } + } + + private int getRankedPosition(TargetInfo targetInfo) { + String targetPackageName = + targetInfo.getChooserTargetComponentName().getPackageName(); + ChooserListAdapter currentListAdapter = + mChooserMultiProfilePagerAdapter.getActiveListAdapter(); + int maxRankedResults = Math.min( + currentListAdapter.getDisplayResolveInfoCount(), MAX_LOG_RANK_POSITION); + + for (int i = 0; i < maxRankedResults; i++) { + if (currentListAdapter.getDisplayResolveInfo(i) + .getResolveInfo().activityInfo.packageName.equals(targetPackageName)) { + return i; + } + } + return -1; + } + + @Override + protected boolean shouldAddFooterView() { + // To accommodate for window insets + return true; + } + + @Override + protected void applyFooterView(int height) { + int count = mChooserMultiProfilePagerAdapter.getItemCount(); + + for (int i = 0; i < count; i++) { + mChooserMultiProfilePagerAdapter.getAdapterForIndex(i).setFooterHeight(height); + } + } + + private void logDirectShareTargetReceived(UserHandle forUser) { + ProfileRecord profileRecord = getProfileRecord(forUser); + if (profileRecord == null) { + return; + } + getEventLog().logDirectShareTargetReceived( + MetricsEvent.ACTION_DIRECT_SHARE_TARGETS_LOADED_SHORTCUT_MANAGER, + (int) (SystemClock.elapsedRealtime() - profileRecord.loadingStartTime)); + } + + void updateModelAndChooserCounts(TargetInfo info) { + if (info != null && info.isMultiDisplayResolveInfo()) { + info = ((MultiDisplayResolveInfo) info).getSelectedTarget(); + } + if (info != null) { + sendClickToAppPredictor(info); + final ResolveInfo ri = info.getResolveInfo(); + Intent targetIntent = getTargetIntent(); + if (ri != null && ri.activityInfo != null && targetIntent != null) { + ChooserListAdapter currentListAdapter = + mChooserMultiProfilePagerAdapter.getActiveListAdapter(); + if (currentListAdapter != null) { + sendImpressionToAppPredictor(info, currentListAdapter); + currentListAdapter.updateModel(info); + currentListAdapter.updateChooserCounts( + ri.activityInfo.packageName, + targetIntent.getAction(), + ri.userHandle); + } + if (DEBUG) { + Log.d(TAG, "ResolveInfo Package is " + ri.activityInfo.packageName); + Log.d(TAG, "Action to be updated is " + targetIntent.getAction()); + } + } else if (DEBUG) { + Log.d(TAG, "Can not log Chooser Counts of null ResolveInfo"); + } + } + mIsSuccessfullySelected = true; + } + + private void maybeRemoveSharedText(@NonNull TargetInfo targetInfo) { + Intent targetIntent = targetInfo.getTargetIntent(); + if (targetIntent == null) { + return; + } + Intent originalTargetIntent = new Intent(mChooserRequest.getTargetIntent()); + // Our TargetInfo implementations add associated component to the intent, let's do the same + // for the sake of the comparison below. + if (targetIntent.getComponent() != null) { + originalTargetIntent.setComponent(targetIntent.getComponent()); + } + // Use filterEquals as a way to check that the primary intent is in use (and not an + // alternative one). For example, an app is sharing an image and a link with mime type + // "image/png" and provides an alternative intent to share only the link with mime type + // "text/uri". Should there be a target that accepts only the latter, the alternative intent + // will be used and we don't want to exclude the link from it. + if (mExcludeSharedText && originalTargetIntent.filterEquals(targetIntent)) { + targetIntent.removeExtra(Intent.EXTRA_TEXT); + } + } + + private void sendImpressionToAppPredictor(TargetInfo targetInfo, ChooserListAdapter adapter) { + // Send DS target impression info to AppPredictor, only when user chooses app share. + if (targetInfo.isChooserTargetInfo()) { + return; + } + + AppPredictor directShareAppPredictor = getAppPredictor( + mChooserMultiProfilePagerAdapter.getCurrentUserHandle()); + if (directShareAppPredictor == null) { + return; + } + List surfacedTargetInfo = adapter.getSurfacedTargetInfo(); + List targetIds = new ArrayList<>(); + for (TargetInfo chooserTargetInfo : surfacedTargetInfo) { + ShortcutInfo shortcutInfo = chooserTargetInfo.getDirectShareShortcutInfo(); + if (shortcutInfo != null) { + ComponentName componentName = + chooserTargetInfo.getChooserTargetComponentName(); + targetIds.add(new AppTargetId( + String.format( + "%s/%s/%s", + shortcutInfo.getId(), + componentName.flattenToString(), + SHORTCUT_TARGET))); + } + } + directShareAppPredictor.notifyLaunchLocationShown(LAUNCH_LOCATION_DIRECT_SHARE, targetIds); + } + + private void sendClickToAppPredictor(TargetInfo targetInfo) { + if (!targetInfo.isChooserTargetInfo()) { + return; + } + + AppPredictor directShareAppPredictor = getAppPredictor( + mChooserMultiProfilePagerAdapter.getCurrentUserHandle()); + if (directShareAppPredictor == null) { + return; + } + AppTarget appTarget = targetInfo.getDirectShareAppTarget(); + if (appTarget != null) { + // This is a direct share click that was provided by the APS + directShareAppPredictor.notifyAppTargetEvent( + new AppTargetEvent.Builder(appTarget, AppTargetEvent.ACTION_LAUNCH) + .setLaunchLocation(LAUNCH_LOCATION_DIRECT_SHARE) + .build()); + } + } + + @Nullable + private AppPredictor getAppPredictor(UserHandle userHandle) { + ProfileRecord record = getProfileRecord(userHandle); + // We cannot use APS service when clone profile is present as APS service cannot sort + // cross profile targets as of now. + return ((record == null) || (getAnnotatedUserHandles().cloneProfileUserHandle != null)) + ? null : record.appPredictor; + } + + /** + * Sort intents alphabetically based on display label. + */ + static class AzInfoComparator implements Comparator { + Comparator mComparator; + AzInfoComparator(Context context) { + Collator collator = Collator + .getInstance(context.getResources().getConfiguration().locale); + // Adding two stage comparator, first stage compares using displayLabel, next stage + // compares using resolveInfo.userHandle + mComparator = Comparator.comparing(DisplayResolveInfo::getDisplayLabel, collator) + .thenComparingInt(target -> target.getResolveInfo().userHandle.getIdentifier()); + } + + @Override + public int compare( + DisplayResolveInfo lhsp, DisplayResolveInfo rhsp) { + return mComparator.compare(lhsp, rhsp); + } + } + + protected EventLog getEventLog() { + return mEventLog; + } + + public class ChooserListController extends ResolverListController { + public ChooserListController( + Context context, + PackageManager pm, + Intent targetIntent, + String referrerPackageName, + int launchedFromUid, + AbstractResolverComparator resolverComparator, + UserHandle queryIntentsAsUser) { + super( + context, + pm, + targetIntent, + referrerPackageName, + launchedFromUid, + resolverComparator, + queryIntentsAsUser); + } + + @Override + public boolean isComponentFiltered(ComponentName name) { + return mChooserRequest.getFilteredComponentNames().contains(name); + } + + @Override + public boolean isComponentPinned(ComponentName name) { + return mPinnedSharedPrefs.getBoolean(name.flattenToString(), false); + } + } + + @VisibleForTesting + public ChooserGridAdapter createChooserGridAdapter( + Context context, + List payloadIntents, + Intent[] initialIntents, + List rList, + boolean filterLastUsed, + UserHandle userHandle, + TargetDataLoader targetDataLoader) { + ChooserListAdapter chooserListAdapter = createChooserListAdapter( + context, + payloadIntents, + initialIntents, + rList, + filterLastUsed, + createListController(userHandle), + userHandle, + getTargetIntent(), + mChooserRequest, + mMaxTargetsPerRow, + targetDataLoader); + + return new ChooserGridAdapter( + context, + new ChooserGridAdapter.ChooserActivityDelegate() { + @Override + public boolean shouldShowTabs() { + return ChooserActivity.this.shouldShowTabs(); + } + + @Override + public View buildContentPreview(ViewGroup parent) { + return createContentPreviewView(parent); + } + + @Override + public void onTargetSelected(int itemIndex) { + startSelected(itemIndex, false, true); + } + + @Override + public void onTargetLongPressed(int selectedPosition) { + final TargetInfo longPressedTargetInfo = + mChooserMultiProfilePagerAdapter + .getActiveListAdapter() + .targetInfoForPosition( + selectedPosition, /* filtered= */ true); + // Only a direct share target or an app target is expected + if (longPressedTargetInfo.isDisplayResolveInfo() + || longPressedTargetInfo.isSelectableTargetInfo()) { + showTargetDetails(longPressedTargetInfo); + } + } + + @Override + public void updateProfileViewButton(View newButtonFromProfileRow) { + mProfileView = newButtonFromProfileRow; + mProfileView.setOnClickListener(ChooserActivity.this::onProfileClick); + ChooserActivity.this.updateProfileViewButton(); + } + }, + chooserListAdapter, + shouldShowContentPreview(), + mMaxTargetsPerRow, + mFeatureFlags); + } + + @VisibleForTesting + public ChooserListAdapter createChooserListAdapter( + Context context, + List payloadIntents, + Intent[] initialIntents, + List rList, + boolean filterLastUsed, + ResolverListController resolverListController, + UserHandle userHandle, + Intent targetIntent, + ChooserRequestParameters chooserRequest, + int maxTargetsPerRow, + TargetDataLoader targetDataLoader) { + UserHandle initialIntentsUserSpace = isLaunchedAsCloneProfile() + && userHandle.equals(getAnnotatedUserHandles().personalProfileUserHandle) + ? getAnnotatedUserHandles().cloneProfileUserHandle : userHandle; + return new ChooserListAdapter( + context, + payloadIntents, + initialIntents, + rList, + filterLastUsed, + createListController(userHandle), + userHandle, + targetIntent, + this, + context.getPackageManager(), + getEventLog(), + chooserRequest, + maxTargetsPerRow, + initialIntentsUserSpace, + targetDataLoader); + } + + @Override + protected void onWorkProfileStatusUpdated() { + UserHandle workUser = getAnnotatedUserHandles().workProfileUserHandle; + ProfileRecord record = workUser == null ? null : getProfileRecord(workUser); + if (record != null && record.shortcutLoader != null) { + record.shortcutLoader.reset(); + } + super.onWorkProfileStatusUpdated(); + } + + @Override + @VisibleForTesting + protected ChooserListController createListController(UserHandle userHandle) { + AppPredictor appPredictor = getAppPredictor(userHandle); + AbstractResolverComparator resolverComparator; + if (appPredictor != null) { + resolverComparator = new AppPredictionServiceResolverComparator(this, getTargetIntent(), + getReferrerPackageName(), appPredictor, userHandle, getEventLog(), + getIntegratedDeviceComponents().getNearbySharingComponent()); + } else { + resolverComparator = + new ResolverRankerServiceResolverComparator( + this, + getTargetIntent(), + getReferrerPackageName(), + null, + getEventLog(), + getResolverRankerServiceUserHandleList(userHandle), + getIntegratedDeviceComponents().getNearbySharingComponent()); + } + + return new ChooserListController( + this, + mPm, + getTargetIntent(), + getReferrerPackageName(), + getAnnotatedUserHandles().userIdOfCallingApp, + resolverComparator, + getQueryIntentsUser(userHandle)); + } + + @VisibleForTesting + protected ViewModelProvider.Factory createPreviewViewModelFactory() { + return PreviewViewModel.Companion.getFactory(); + } + + private ChooserActionFactory createChooserActionFactory() { + return new ChooserActionFactory( + this, + mChooserRequest, + mIntegratedDeviceComponents, + getEventLog(), + (isExcluded) -> mExcludeSharedText = isExcluded, + this::getFirstVisibleImgPreviewView, + new ChooserActionFactory.ActionActivityStarter() { + @Override + public void safelyStartActivityAsPersonalProfileUser(TargetInfo targetInfo) { + safelyStartActivityAsUser( + targetInfo, getAnnotatedUserHandles().personalProfileUserHandle); + finish(); + } + + @Override + public void safelyStartActivityAsPersonalProfileUserWithSharedElementTransition( + TargetInfo targetInfo, View sharedElement, String sharedElementName) { + ActivityOptions options = ActivityOptions.makeSceneTransitionAnimation( + ChooserActivity.this, sharedElement, sharedElementName); + safelyStartActivityAsUser( + targetInfo, + getAnnotatedUserHandles().personalProfileUserHandle, + options.toBundle()); + // Can't finish right away because the shared element transition may not + // be ready to start. + mFinishWhenStopped = true; + } + }, + (status) -> { + if (status != null) { + setResult(status); + } + finish(); + }); + } + + /* + * Need to dynamically adjust how many icons can fit per row before we add them, + * which also means setting the correct offset to initially show the content + * preview area + 2 rows of targets + */ + private void handleLayoutChange(View v, int left, int top, int right, int bottom, int oldLeft, + int oldTop, int oldRight, int oldBottom) { + if (mChooserMultiProfilePagerAdapter == null) { + return; + } + RecyclerView recyclerView = mChooserMultiProfilePagerAdapter.getActiveAdapterView(); + ChooserGridAdapter gridAdapter = mChooserMultiProfilePagerAdapter.getCurrentRootAdapter(); + // Skip height calculation if recycler view was scrolled to prevent it inaccurately + // calculating the height, as the logic below does not account for the scrolled offset. + if (gridAdapter == null || recyclerView == null + || recyclerView.computeVerticalScrollOffset() != 0) { + return; + } + + final int availableWidth = right - left - v.getPaddingLeft() - v.getPaddingRight(); + boolean isLayoutUpdated = + gridAdapter.calculateChooserTargetWidth(availableWidth) + || recyclerView.getAdapter() == null + || availableWidth != mCurrAvailableWidth; + + boolean insetsChanged = !Objects.equals(mLastAppliedInsets, mSystemWindowInsets); + + if (isLayoutUpdated + || insetsChanged + || mLastNumberOfChildren != recyclerView.getChildCount()) { + mCurrAvailableWidth = availableWidth; + if (isLayoutUpdated) { + // It is very important we call setAdapter from here. Otherwise in some cases + // the resolver list doesn't get populated, such as b/150922090, b/150918223 + // and b/150936654 + recyclerView.setAdapter(gridAdapter); + ((GridLayoutManager) recyclerView.getLayoutManager()).setSpanCount( + mMaxTargetsPerRow); + + updateTabPadding(); + } + + UserHandle currentUserHandle = mChooserMultiProfilePagerAdapter.getCurrentUserHandle(); + int currentProfile = getProfileForUser(currentUserHandle); + int initialProfile = findSelectedProfile(); + if (currentProfile != initialProfile) { + return; + } + + if (mLastNumberOfChildren == recyclerView.getChildCount() && !insetsChanged) { + return; + } + + getMainThreadHandler().post(() -> { + if (mResolverDrawerLayout == null || gridAdapter == null) { + return; + } + int offset = calculateDrawerOffset(top, bottom, recyclerView, gridAdapter); + mResolverDrawerLayout.setCollapsibleHeightReserved(offset); + mEnterTransitionAnimationDelegate.markOffsetCalculated(); + mLastAppliedInsets = mSystemWindowInsets; + }); + } + } + + private int calculateDrawerOffset( + int top, int bottom, RecyclerView recyclerView, ChooserGridAdapter gridAdapter) { + + int offset = mSystemWindowInsets != null ? mSystemWindowInsets.bottom : 0; + int rowsToShow = gridAdapter.getSystemRowCount() + + gridAdapter.getProfileRowCount() + + gridAdapter.getServiceTargetRowCount() + + gridAdapter.getCallerAndRankedTargetRowCount(); + + // then this is most likely not a SEND_* action, so check + // the app target count + if (rowsToShow == 0) { + rowsToShow = gridAdapter.getRowCount(); + } + + // still zero? then use a default height and leave, which + // can happen when there are no targets to show + if (rowsToShow == 0 && !shouldShowStickyContentPreview()) { + offset += getResources().getDimensionPixelSize( + R.dimen.chooser_max_collapsed_height); + return offset; + } + + View stickyContentPreview = findViewById(com.android.internal.R.id.content_preview_container); + if (shouldShowStickyContentPreview() && isStickyContentPreviewShowing()) { + offset += stickyContentPreview.getHeight(); + } + + if (shouldShowTabs()) { + offset += findViewById(com.android.internal.R.id.tabs).getHeight(); + } + + if (recyclerView.getVisibility() == View.VISIBLE) { + rowsToShow = Math.min(4, rowsToShow); + boolean shouldShowExtraRow = shouldShowExtraRow(rowsToShow); + mLastNumberOfChildren = recyclerView.getChildCount(); + for (int i = 0, childCount = recyclerView.getChildCount(); + i < childCount && rowsToShow > 0; i++) { + View child = recyclerView.getChildAt(i); + if (((GridLayoutManager.LayoutParams) + child.getLayoutParams()).getSpanIndex() != 0) { + continue; + } + int height = child.getHeight(); + offset += height; + if (shouldShowExtraRow) { + offset += height; + } + rowsToShow--; + } + } else { + ViewGroup currentEmptyStateView = getActiveEmptyStateView(); + if (currentEmptyStateView.getVisibility() == View.VISIBLE) { + offset += currentEmptyStateView.getHeight(); + } + } + + return Math.min(offset, bottom - top); + } + + /** + * If we have a tabbed view and are showing 1 row in the current profile and an empty + * state screen in the other profile, to prevent cropping of the empty state screen we show + * a second row in the current profile. + */ + private boolean shouldShowExtraRow(int rowsToShow) { + return shouldShowTabs() + && rowsToShow == 1 + && mChooserMultiProfilePagerAdapter.shouldShowEmptyStateScreen( + mChooserMultiProfilePagerAdapter.getInactiveListAdapter()); + } + + /** + * Returns {@link #PROFILE_WORK}, if the given user handle matches work user handle. + * Returns {@link #PROFILE_PERSONAL}, otherwise. + **/ + private int getProfileForUser(UserHandle currentUserHandle) { + if (currentUserHandle.equals(getAnnotatedUserHandles().workProfileUserHandle)) { + return PROFILE_WORK; + } + // We return personal profile, as it is the default when there is no work profile, personal + // profile represents rootUser, clonedUser & secondaryUser, covering all use cases. + return PROFILE_PERSONAL; + } + + private ViewGroup getActiveEmptyStateView() { + int currentPage = mChooserMultiProfilePagerAdapter.getCurrentPage(); + return mChooserMultiProfilePagerAdapter.getEmptyStateView(currentPage); + } + + @Override // ResolverListCommunicator + public void onHandlePackagesChanged(ResolverListAdapter listAdapter) { + mChooserMultiProfilePagerAdapter.getActiveListAdapter().notifyDataSetChanged(); + super.onHandlePackagesChanged(listAdapter); + } + + @Override + protected void onListRebuilt(ResolverListAdapter listAdapter, boolean rebuildComplete) { + setupScrollListener(); + maybeSetupGlobalLayoutListener(); + + ChooserListAdapter chooserListAdapter = (ChooserListAdapter) listAdapter; + UserHandle listProfileUserHandle = chooserListAdapter.getUserHandle(); + if (listProfileUserHandle.equals(mChooserMultiProfilePagerAdapter.getCurrentUserHandle())) { + mChooserMultiProfilePagerAdapter.getActiveAdapterView() + .setAdapter(mChooserMultiProfilePagerAdapter.getCurrentRootAdapter()); + mChooserMultiProfilePagerAdapter + .setupListAdapter(mChooserMultiProfilePagerAdapter.getCurrentPage()); + } + + //TODO: move this block inside ChooserListAdapter (should be called when + // ResolverListAdapter#mPostListReadyRunnable is executed. + if (chooserListAdapter.getDisplayResolveInfoCount() == 0) { + chooserListAdapter.notifyDataSetChanged(); + } else { + chooserListAdapter.updateAlphabeticalList(); + } + + if (rebuildComplete) { + long duration = Tracer.INSTANCE.endAppTargetLoadingSection(listProfileUserHandle); + if (duration >= 0) { + Log.d(TAG, "app target loading time " + duration + " ms"); + } + addCallerChooserTargets(); + getEventLog().logSharesheetAppLoadComplete(); + maybeQueryAdditionalPostProcessingTargets( + listProfileUserHandle, + chooserListAdapter.getDisplayResolveInfos()); + mLatencyTracker.onActionEnd(ACTION_LOAD_SHARE_SHEET); + } + } + + private void maybeQueryAdditionalPostProcessingTargets( + UserHandle userHandle, + DisplayResolveInfo[] displayResolveInfos) { + ProfileRecord record = getProfileRecord(userHandle); + if (record == null || record.shortcutLoader == null) { + return; + } + record.loadingStartTime = SystemClock.elapsedRealtime(); + record.shortcutLoader.updateAppTargets(displayResolveInfos); + } + + @MainThread + private void onShortcutsLoaded(UserHandle userHandle, ShortcutLoader.Result result) { + if (DEBUG) { + Log.d(TAG, "onShortcutsLoaded for user: " + userHandle); + } + mDirectShareShortcutInfoCache.putAll(result.getDirectShareShortcutInfoCache()); + mDirectShareAppTargetCache.putAll(result.getDirectShareAppTargetCache()); + ChooserListAdapter adapter = + mChooserMultiProfilePagerAdapter.getListAdapterForUserHandle(userHandle); + if (adapter != null) { + for (ShortcutLoader.ShortcutResultInfo resultInfo : result.getShortcutsByApp()) { + adapter.addServiceResults( + resultInfo.getAppTarget(), + resultInfo.getShortcuts(), + result.isFromAppPredictor() + ? TARGET_TYPE_SHORTCUTS_FROM_PREDICTION_SERVICE + : TARGET_TYPE_SHORTCUTS_FROM_SHORTCUT_MANAGER, + mDirectShareShortcutInfoCache, + mDirectShareAppTargetCache); + } + adapter.completeServiceTargetLoading(); + } + + if (mMultiProfilePagerAdapter.getActiveListAdapter() == adapter) { + long duration = Tracer.INSTANCE.endLaunchToShortcutTrace(); + if (duration >= 0) { + Log.d(TAG, "stat to first shortcut time: " + duration + " ms"); + } + } + logDirectShareTargetReceived(userHandle); + sendVoiceChoicesIfNeeded(); + getEventLog().logSharesheetDirectLoadComplete(); + } + + private void setupScrollListener() { + if (mResolverDrawerLayout == null) { + return; + } + int elevatedViewResId = shouldShowTabs() ? com.android.internal.R.id.tabs : com.android.internal.R.id.chooser_header; + final View elevatedView = mResolverDrawerLayout.findViewById(elevatedViewResId); + final float defaultElevation = elevatedView.getElevation(); + final float chooserHeaderScrollElevation = + getResources().getDimensionPixelSize(R.dimen.chooser_header_scroll_elevation); + mChooserMultiProfilePagerAdapter.getActiveAdapterView().addOnScrollListener( + new RecyclerView.OnScrollListener() { + @Override + public void onScrollStateChanged(RecyclerView view, int scrollState) { + if (scrollState == RecyclerView.SCROLL_STATE_IDLE) { + if (mScrollStatus == SCROLL_STATUS_SCROLLING_VERTICAL) { + mScrollStatus = SCROLL_STATUS_IDLE; + setHorizontalScrollingEnabled(true); + } + } else if (scrollState == RecyclerView.SCROLL_STATE_DRAGGING) { + if (mScrollStatus == SCROLL_STATUS_IDLE) { + mScrollStatus = SCROLL_STATUS_SCROLLING_VERTICAL; + setHorizontalScrollingEnabled(false); + } + } + } + + @Override + public void onScrolled(RecyclerView view, int dx, int dy) { + if (view.getChildCount() > 0) { + View child = view.getLayoutManager().findViewByPosition(0); + if (child == null || child.getTop() < 0) { + elevatedView.setElevation(chooserHeaderScrollElevation); + return; + } + } + + elevatedView.setElevation(defaultElevation); + } + }); + } + + private void maybeSetupGlobalLayoutListener() { + if (shouldShowTabs()) { + return; + } + final View recyclerView = mChooserMultiProfilePagerAdapter.getActiveAdapterView(); + recyclerView.getViewTreeObserver() + .addOnGlobalLayoutListener(new ViewTreeObserver.OnGlobalLayoutListener() { + @Override + public void onGlobalLayout() { + // Fixes an issue were the accessibility border disappears on list creation. + recyclerView.getViewTreeObserver().removeOnGlobalLayoutListener(this); + final TextView titleView = findViewById(com.android.internal.R.id.title); + if (titleView != null) { + titleView.setFocusable(true); + titleView.setFocusableInTouchMode(true); + titleView.requestFocus(); + titleView.requestAccessibilityFocus(); + } + } + }); + } + + /** + * The sticky content preview is shown only when we have a tabbed view. It's shown above + * the tabs so it is not part of the scrollable list. If we are not in tabbed view, + * we instead show the content preview as a regular list item. + */ + private boolean shouldShowStickyContentPreview() { + return shouldShowStickyContentPreviewNoOrientationCheck(); + } + + private boolean shouldShowStickyContentPreviewNoOrientationCheck() { + if (!shouldShowContentPreview()) { + return false; + } + boolean isEmpty = mMultiProfilePagerAdapter.getListAdapterForUserHandle( + UserHandle.of(UserHandle.myUserId())).getCount() == 0; + return (mFeatureFlags.scrollablePreview() || shouldShowTabs()) + && (!isEmpty || shouldShowContentPreviewWhenEmpty()); + } + + /** + * This method could be used to override the default behavior when we hide the preview area + * when the current tab doesn't have any items. + * + * @return true if we want to show the content preview area even if the tab for the current + * user is empty + */ + protected boolean shouldShowContentPreviewWhenEmpty() { + return false; + } + + /** + * @return true if we want to show the content preview area + */ + protected boolean shouldShowContentPreview() { + return (mChooserRequest != null) && mChooserRequest.isSendActionTarget(); + } + + private void updateStickyContentPreview() { + if (shouldShowStickyContentPreviewNoOrientationCheck()) { + // The sticky content preview is only shown when we show the work and personal tabs. + // We don't show it in landscape as otherwise there is no room for scrolling. + // If the sticky content preview will be shown at some point with orientation change, + // then always preload it to avoid subsequent resizing of the share sheet. + ViewGroup contentPreviewContainer = + findViewById(com.android.internal.R.id.content_preview_container); + if (contentPreviewContainer.getChildCount() == 0) { + ViewGroup contentPreviewView = createContentPreviewView(contentPreviewContainer); + contentPreviewContainer.addView(contentPreviewView); + } + } + if (shouldShowStickyContentPreview()) { + showStickyContentPreview(); + } else { + hideStickyContentPreview(); + } + } + + private void showStickyContentPreview() { + if (isStickyContentPreviewShowing()) { + return; + } + ViewGroup contentPreviewContainer = findViewById(com.android.internal.R.id.content_preview_container); + contentPreviewContainer.setVisibility(View.VISIBLE); + } + + private boolean isStickyContentPreviewShowing() { + ViewGroup contentPreviewContainer = findViewById(com.android.internal.R.id.content_preview_container); + return contentPreviewContainer.getVisibility() == View.VISIBLE; + } + + private void hideStickyContentPreview() { + if (!isStickyContentPreviewShowing()) { + return; + } + ViewGroup contentPreviewContainer = findViewById(com.android.internal.R.id.content_preview_container); + contentPreviewContainer.setVisibility(View.GONE); + } + + private View findRootView() { + if (mContentView == null) { + mContentView = findViewById(android.R.id.content); + } + return mContentView; + } + + /** + * Intentionally override the {@link ResolverActivity} implementation as we only need that + * implementation for the intent resolver case. + */ + @Override + public void onButtonClick(View v) {} + + /** + * Intentionally override the {@link ResolverActivity} implementation as we only need that + * implementation for the intent resolver case. + */ + @Override + protected void resetButtonBar() {} + + @Override + protected String getMetricsCategory() { + return METRICS_CATEGORY_CHOOSER; + } + + @Override + protected void onProfileTabSelected() { + // This fixes an edge case where after performing a variety of gestures, vertical scrolling + // ends up disabled. That's because at some point the old tab's vertical scrolling is + // disabled and the new tab's is enabled. For context, see b/159997845 + setVerticalScrollEnabled(true); + if (mResolverDrawerLayout != null) { + mResolverDrawerLayout.scrollNestedScrollableChildBackToTop(); + } + } + + @Override + protected WindowInsets onApplyWindowInsets(View v, WindowInsets insets) { + if (shouldShowTabs()) { + mChooserMultiProfilePagerAdapter + .setEmptyStateBottomOffset(insets.getSystemWindowInsetBottom()); + mChooserMultiProfilePagerAdapter.setupContainerPadding( + getActiveEmptyStateView().findViewById(com.android.internal.R.id.resolver_empty_state_container)); + } + + WindowInsets result = super.onApplyWindowInsets(v, insets); + if (mResolverDrawerLayout != null) { + mResolverDrawerLayout.requestLayout(); + } + return result; + } + + private void setHorizontalScrollingEnabled(boolean enabled) { + ResolverViewPager viewPager = findViewById(com.android.internal.R.id.profile_pager); + viewPager.setSwipingEnabled(enabled); + } + + private void setVerticalScrollEnabled(boolean enabled) { + ChooserGridLayoutManager layoutManager = + (ChooserGridLayoutManager) mChooserMultiProfilePagerAdapter.getActiveAdapterView() + .getLayoutManager(); + layoutManager.setVerticalScrollEnabled(enabled); + } + + @Override + void onHorizontalSwipeStateChanged(int state) { + if (state == ViewPager.SCROLL_STATE_DRAGGING) { + if (mScrollStatus == SCROLL_STATUS_IDLE) { + mScrollStatus = SCROLL_STATUS_SCROLLING_HORIZONTAL; + setVerticalScrollEnabled(false); + } + } else if (state == ViewPager.SCROLL_STATE_IDLE) { + if (mScrollStatus == SCROLL_STATUS_SCROLLING_HORIZONTAL) { + mScrollStatus = SCROLL_STATUS_IDLE; + setVerticalScrollEnabled(true); + } + } + } + + @Override + protected void maybeLogProfileChange() { + getEventLog().logSharesheetProfileChanged(); + } + + private static class ProfileRecord { + /** The {@link AppPredictor} for this profile, if any. */ + @Nullable + public final AppPredictor appPredictor; + /** + * null if we should not load shortcuts. + */ + @Nullable + public final ShortcutLoader shortcutLoader; + public long loadingStartTime; + + private ProfileRecord( + @Nullable AppPredictor appPredictor, + @Nullable ShortcutLoader shortcutLoader) { + this.appPredictor = appPredictor; + this.shortcutLoader = shortcutLoader; + } + + public void destroy() { + if (appPredictor != null) { + appPredictor.destroy(); + } + } + } +} diff --git a/java/src/com/android/intentresolver/v2/ChooserSelector.kt b/java/src/com/android/intentresolver/v2/ChooserSelector.kt new file mode 100644 index 00000000..378bc06c --- /dev/null +++ b/java/src/com/android/intentresolver/v2/ChooserSelector.kt @@ -0,0 +1,36 @@ +package com.android.intentresolver.v2 + +import android.content.BroadcastReceiver +import android.content.ComponentName +import android.content.Context +import android.content.Intent +import android.content.pm.PackageManager +import com.android.intentresolver.FeatureFlags +import dagger.hilt.android.AndroidEntryPoint +import javax.inject.Inject + +@AndroidEntryPoint(BroadcastReceiver::class) +class ChooserSelector : Hilt_ChooserSelector() { + + @Inject lateinit var featureFlags: FeatureFlags + + override fun onReceive(context: Context, intent: Intent) { + super.onReceive(context, intent) + if (intent.action == Intent.ACTION_BOOT_COMPLETED) { + context.packageManager.setComponentEnabledSetting( + ComponentName(CHOOSER_PACKAGE, CHOOSER_PACKAGE + CHOOSER_CLASS), + if (featureFlags.modularFramework()) { + PackageManager.COMPONENT_ENABLED_STATE_ENABLED + } else { + PackageManager.COMPONENT_ENABLED_STATE_DEFAULT + }, + /* flags = */ 0, + ) + } + } + + companion object { + private const val CHOOSER_PACKAGE = "com.android.intentresolver" + private const val CHOOSER_CLASS = ".v2.ChooserActivity" + } +} diff --git a/java/src/com/android/intentresolver/v2/ResolverActivity.java b/java/src/com/android/intentresolver/v2/ResolverActivity.java new file mode 100644 index 00000000..dd6842aa --- /dev/null +++ b/java/src/com/android/intentresolver/v2/ResolverActivity.java @@ -0,0 +1,2426 @@ +/* + * Copyright (C) 2008 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.intentresolver.v2; + +import static android.Manifest.permission.INTERACT_ACROSS_PROFILES; +import static android.app.admin.DevicePolicyResources.Strings.Core.FORWARD_INTENT_TO_PERSONAL; +import static android.app.admin.DevicePolicyResources.Strings.Core.FORWARD_INTENT_TO_WORK; +import static android.app.admin.DevicePolicyResources.Strings.Core.RESOLVER_CANT_ACCESS_PERSONAL; +import static android.app.admin.DevicePolicyResources.Strings.Core.RESOLVER_CANT_ACCESS_WORK; +import static android.app.admin.DevicePolicyResources.Strings.Core.RESOLVER_CROSS_PROFILE_BLOCKED_TITLE; +import static android.app.admin.DevicePolicyResources.Strings.Core.RESOLVER_PERSONAL_TAB; +import static android.app.admin.DevicePolicyResources.Strings.Core.RESOLVER_PERSONAL_TAB_ACCESSIBILITY; +import static android.app.admin.DevicePolicyResources.Strings.Core.RESOLVER_WORK_PROFILE_NOT_SUPPORTED; +import static android.app.admin.DevicePolicyResources.Strings.Core.RESOLVER_WORK_TAB; +import static android.app.admin.DevicePolicyResources.Strings.Core.RESOLVER_WORK_TAB_ACCESSIBILITY; +import static android.content.Intent.FLAG_ACTIVITY_LAUNCH_ADJACENT; +import static android.content.Intent.FLAG_ACTIVITY_NEW_TASK; +import static android.content.PermissionChecker.PID_UNKNOWN; +import static android.stats.devicepolicy.nano.DevicePolicyEnums.RESOLVER_EMPTY_STATE_NO_SHARING_TO_PERSONAL; +import static android.stats.devicepolicy.nano.DevicePolicyEnums.RESOLVER_EMPTY_STATE_NO_SHARING_TO_WORK; +import static android.view.WindowManager.LayoutParams.SYSTEM_FLAG_HIDE_NON_SYSTEM_OVERLAY_WINDOWS; +import static com.android.internal.annotations.VisibleForTesting.Visibility.PROTECTED; + +import android.annotation.Nullable; +import android.annotation.StringRes; +import android.annotation.UiThread; +import android.app.Activity; +import android.app.ActivityManager; +import android.app.ActivityThread; +import android.app.VoiceInteractor.PickOptionRequest; +import android.app.VoiceInteractor.PickOptionRequest.Option; +import android.app.VoiceInteractor.Prompt; +import android.app.admin.DevicePolicyEventLogger; +import android.app.admin.DevicePolicyManager; +import android.content.ComponentName; +import android.content.Context; +import android.content.Intent; +import android.content.IntentFilter; +import android.content.PermissionChecker; +import android.content.pm.ActivityInfo; +import android.content.pm.ApplicationInfo; +import android.content.pm.PackageManager; +import android.content.pm.PackageManager.NameNotFoundException; +import android.content.pm.ResolveInfo; +import android.content.pm.UserInfo; +import android.content.res.Configuration; +import android.content.res.TypedArray; +import android.graphics.Insets; +import android.net.Uri; +import android.os.Build; +import android.os.Bundle; +import android.os.PatternMatcher; +import android.os.RemoteException; +import android.os.StrictMode; +import android.os.Trace; +import android.os.UserHandle; +import android.os.UserManager; +import android.provider.MediaStore; +import android.provider.Settings; +import android.stats.devicepolicy.DevicePolicyEnums; +import android.text.TextUtils; +import android.util.Log; +import android.util.Slog; +import android.view.Gravity; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.view.ViewGroup.LayoutParams; +import android.view.Window; +import android.view.WindowInsets; +import android.view.WindowManager; +import android.widget.AbsListView; +import android.widget.AdapterView; +import android.widget.Button; +import android.widget.FrameLayout; +import android.widget.ImageView; +import android.widget.ListView; +import android.widget.Space; +import android.widget.TabHost; +import android.widget.TabWidget; +import android.widget.TextView; +import android.widget.Toast; + +import androidx.fragment.app.FragmentActivity; +import androidx.viewpager.widget.ViewPager; + +import com.android.intentresolver.AnnotatedUserHandles; +import com.android.intentresolver.MultiProfilePagerAdapter; +import com.android.intentresolver.MultiProfilePagerAdapter.MyUserIdProvider; +import com.android.intentresolver.MultiProfilePagerAdapter.OnSwitchOnWorkSelectedListener; +import com.android.intentresolver.MultiProfilePagerAdapter.Profile; +import com.android.intentresolver.R; +import com.android.intentresolver.ResolverListAdapter; +import com.android.intentresolver.ResolverListController; +import com.android.intentresolver.ResolverMultiProfilePagerAdapter; +import com.android.intentresolver.WorkProfileAvailabilityManager; +import com.android.intentresolver.chooser.DisplayResolveInfo; +import com.android.intentresolver.chooser.TargetInfo; +import com.android.intentresolver.emptystate.CompositeEmptyStateProvider; +import com.android.intentresolver.emptystate.CrossProfileIntentsChecker; +import com.android.intentresolver.emptystate.EmptyState; +import com.android.intentresolver.emptystate.EmptyStateProvider; +import com.android.intentresolver.emptystate.NoAppsAvailableEmptyStateProvider; +import com.android.intentresolver.emptystate.NoCrossProfileEmptyStateProvider; +import com.android.intentresolver.emptystate.NoCrossProfileEmptyStateProvider.DevicePolicyBlockerEmptyState; +import com.android.intentresolver.emptystate.WorkProfilePausedEmptyStateProvider; +import com.android.intentresolver.icons.DefaultTargetDataLoader; +import com.android.intentresolver.icons.TargetDataLoader; +import com.android.intentresolver.model.ResolverRankerServiceResolverComparator; +import com.android.intentresolver.widget.ResolverDrawerLayout; +import com.android.internal.annotations.VisibleForTesting; +import com.android.internal.content.PackageMonitor; +import com.android.internal.logging.MetricsLogger; +import com.android.internal.logging.nano.MetricsProto; +import com.android.internal.util.LatencyTracker; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.Iterator; +import java.util.List; +import java.util.Objects; +import java.util.Set; +import java.util.function.Supplier; + +/** + * This is a copy of ResolverActivity to support IntentResolver's ChooserActivity. This code is + * *not* the resolver that is actually triggered by the system right now (you want + * frameworks/base/core/java/com/android/internal/app/ResolverActivity.java for that), the full + * migration is not complete. + */ +@UiThread +public class ResolverActivity extends FragmentActivity implements + ResolverListAdapter.ResolverListCommunicator { + + public ResolverActivity() { + mIsIntentPicker = getClass().equals(ResolverActivity.class); + } + + protected ResolverActivity(boolean isIntentPicker) { + mIsIntentPicker = isIntentPicker; + } + + /** + * Whether to enable a launch mode that is safe to use when forwarding intents received from + * applications and running in system processes. This mode uses Activity.startActivityAsCaller + * instead of the normal Activity.startActivity for launching the activity selected + * by the user. + */ + private boolean mSafeForwardingMode; + + private Button mAlwaysButton; + private Button mOnceButton; + protected View mProfileView; + private int mLastSelected = AbsListView.INVALID_POSITION; + private boolean mResolvingHome = false; + private String mProfileSwitchMessage; + private int mLayoutId; + @VisibleForTesting + protected final ArrayList mIntents = new ArrayList<>(); + private PickTargetOptionRequest mPickOptionRequest; + private String mReferrerPackage; + private CharSequence mTitle; + private int mDefaultTitleResId; + // Expected to be true if this object is ResolverActivity or is ResolverWrapperActivity. + private final boolean mIsIntentPicker; + + // Whether or not this activity supports choosing a default handler for the intent. + @VisibleForTesting + protected boolean mSupportsAlwaysUseOption; + protected ResolverDrawerLayout mResolverDrawerLayout; + protected PackageManager mPm; + + private static final String TAG = "ResolverActivity"; + private static final boolean DEBUG = false; + private static final String LAST_SHOWN_TAB_KEY = "last_shown_tab_key"; + + private boolean mRegistered; + + protected Insets mSystemWindowInsets = null; + private Space mFooterSpacer = null; + + /** See {@link #setRetainInOnStop}. */ + private boolean mRetainInOnStop; + + protected static final String METRICS_CATEGORY_RESOLVER = "intent_resolver"; + protected static final String METRICS_CATEGORY_CHOOSER = "intent_chooser"; + + /** Tracks if we should ignore future broadcasts telling us the work profile is enabled */ + private boolean mWorkProfileHasBeenEnabled = false; + + private static final String TAB_TAG_PERSONAL = "personal"; + private static final String TAB_TAG_WORK = "work"; + + private PackageMonitor mPersonalPackageMonitor; + private PackageMonitor mWorkPackageMonitor; + + private TargetDataLoader mTargetDataLoader; + + @VisibleForTesting + protected MultiProfilePagerAdapter mMultiProfilePagerAdapter; + + protected WorkProfileAvailabilityManager mWorkProfileAvailability; + + // Intent extra for connected audio devices + public static final String EXTRA_IS_AUDIO_CAPTURE_DEVICE = "is_audio_capture_device"; + + /** + * Integer extra to indicate which profile should be automatically selected. + *

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

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

This is not necessarily the same as {@link #getUserId()} or {@link UserHandle#myUserId()}, + * as there are edge cases when the intent resolver is launched in the other profile. + * For example, when we have 0 resolved apps in current profile and multiple resolved + * apps in the other profile, opening a link from the current profile launches the intent + * resolver in the other one. b/148536209 for more info. + */ + static final String EXTRA_CALLING_USER = + "com.android.internal.app.ResolverActivity.EXTRA_CALLING_USER"; + + protected static final int PROFILE_PERSONAL = MultiProfilePagerAdapter.PROFILE_PERSONAL; + protected static final int PROFILE_WORK = MultiProfilePagerAdapter.PROFILE_WORK; + + private UserHandle mHeaderCreatorUser; + + // User handle annotations are lazy-initialized to ensure that they're computed exactly once + // (even though they can't be computed prior to activity creation). + // TODO: use a less ad-hoc pattern for lazy initialization (by switching to Dagger or + // introducing a common `LazySingletonSupplier` API, etc), and/or migrate all dependents to a + // new component whose lifecycle is limited to the "created" Activity (so that we can just hold + // the annotations as a `final` ivar, which is a better way to show immutability). + private Supplier mLazyAnnotatedUserHandles = () -> { + final AnnotatedUserHandles result = computeAnnotatedUserHandles(); + mLazyAnnotatedUserHandles = () -> result; + return result; + }; + + // This method is called exactly once during creation to compute the immutable annotations + // accessible through the lazy supplier {@link mLazyAnnotatedUserHandles}. + // TODO: this is only defined so that tests can provide an override that injects fake + // annotations. Dagger could provide a cleaner model for our testing/injection requirements. + @VisibleForTesting(visibility = VisibleForTesting.Visibility.PRIVATE) + protected AnnotatedUserHandles computeAnnotatedUserHandles() { + return AnnotatedUserHandles.forShareActivity(this); + } + + @Nullable + private OnSwitchOnWorkSelectedListener mOnSwitchOnWorkSelectedListener; + + protected final LatencyTracker mLatencyTracker = getLatencyTracker(); + + private enum ActionTitle { + VIEW(Intent.ACTION_VIEW, + R.string.whichViewApplication, + R.string.whichViewApplicationNamed, + R.string.whichViewApplicationLabel), + EDIT(Intent.ACTION_EDIT, + R.string.whichEditApplication, + R.string.whichEditApplicationNamed, + R.string.whichEditApplicationLabel), + SEND(Intent.ACTION_SEND, + R.string.whichSendApplication, + R.string.whichSendApplicationNamed, + R.string.whichSendApplicationLabel), + SENDTO(Intent.ACTION_SENDTO, + R.string.whichSendToApplication, + R.string.whichSendToApplicationNamed, + R.string.whichSendToApplicationLabel), + SEND_MULTIPLE(Intent.ACTION_SEND_MULTIPLE, + R.string.whichSendApplication, + R.string.whichSendApplicationNamed, + R.string.whichSendApplicationLabel), + CAPTURE_IMAGE(MediaStore.ACTION_IMAGE_CAPTURE, + R.string.whichImageCaptureApplication, + R.string.whichImageCaptureApplicationNamed, + R.string.whichImageCaptureApplicationLabel), + DEFAULT(null, + R.string.whichApplication, + R.string.whichApplicationNamed, + R.string.whichApplicationLabel), + HOME(Intent.ACTION_MAIN, + R.string.whichHomeApplication, + R.string.whichHomeApplicationNamed, + R.string.whichHomeApplicationLabel); + + // titles for layout that deals with http(s) intents + public static final int BROWSABLE_TITLE_RES = R.string.whichOpenLinksWith; + public static final int BROWSABLE_HOST_TITLE_RES = R.string.whichOpenHostLinksWith; + public static final int BROWSABLE_HOST_APP_TITLE_RES = R.string.whichOpenHostLinksWithApp; + public static final int BROWSABLE_APP_TITLE_RES = R.string.whichOpenLinksWithApp; + + public final String action; + public final int titleRes; + public final int namedTitleRes; + public final @StringRes int labelRes; + + ActionTitle(String action, int titleRes, int namedTitleRes, @StringRes int labelRes) { + this.action = action; + this.titleRes = titleRes; + this.namedTitleRes = namedTitleRes; + this.labelRes = labelRes; + } + + public static ActionTitle forAction(String action) { + for (ActionTitle title : values()) { + if (title != HOME && action != null && action.equals(title.action)) { + return title; + } + } + return DEFAULT; + } + } + + protected PackageMonitor createPackageMonitor(ResolverListAdapter listAdapter) { + return new PackageMonitor() { + @Override + public void onSomePackagesChanged() { + listAdapter.handlePackagesChanged(); + updateProfileViewButton(); + } + + @Override + public boolean onPackageChanged(String packageName, int uid, String[] components) { + // We care about all package changes, not just the whole package itself which is + // default behavior. + return true; + } + }; + } + + @Override + protected void onCreate(Bundle savedInstanceState) { + // Use a specialized prompt when we're handling the 'Home' app startActivity() + final Intent intent = makeMyIntent(); + final Set categories = intent.getCategories(); + if (Intent.ACTION_MAIN.equals(intent.getAction()) + && categories != null + && categories.size() == 1 + && categories.contains(Intent.CATEGORY_HOME)) { + // Note: this field is not set to true in the compatibility version. + mResolvingHome = true; + } + + onCreate( + savedInstanceState, + intent, + /* additionalTargets= */ null, + /* title= */ null, + /* defaultTitleRes= */ 0, + /* initialIntents= */ null, + /* resolutionList= */ null, + /* supportsAlwaysUseOption= */ true, + createIconLoader(), + /* safeForwardingMode= */ true); + } + + /** + * Compatibility version for other bundled services that use this overload without + * a default title resource + */ + protected void onCreate( + Bundle savedInstanceState, + Intent intent, + CharSequence title, + Intent[] initialIntents, + List resolutionList, + boolean supportsAlwaysUseOption, + boolean safeForwardingMode) { + onCreate( + savedInstanceState, + intent, + null, + title, + 0, + initialIntents, + resolutionList, + supportsAlwaysUseOption, + createIconLoader(), + safeForwardingMode); + } + + protected void onCreate( + Bundle savedInstanceState, + Intent intent, + Intent[] additionalTargets, + CharSequence title, + int defaultTitleRes, + Intent[] initialIntents, + List resolutionList, + boolean supportsAlwaysUseOption, + TargetDataLoader targetDataLoader, + boolean safeForwardingMode) { + setTheme(appliedThemeResId()); + super.onCreate(savedInstanceState); + + // Determine whether we should show that intent is forwarded + // from managed profile to owner or other way around. + setProfileSwitchMessage(intent.getContentUserHint()); + + // Force computation of user handle annotations in order to validate the caller ID. (See the + // associated TODO comment to explain why this is structured as a lazy computation.) + AnnotatedUserHandles unusedReferenceToHandles = mLazyAnnotatedUserHandles.get(); + + mWorkProfileAvailability = createWorkProfileAvailabilityManager(); + + mPm = getPackageManager(); + + mReferrerPackage = getReferrerPackageName(); + + // The initial intent must come before any other targets that are to be added. + mIntents.add(0, new Intent(intent)); + if (additionalTargets != null) { + Collections.addAll(mIntents, additionalTargets); + } + + mTitle = title; + mDefaultTitleResId = defaultTitleRes; + + mSupportsAlwaysUseOption = supportsAlwaysUseOption; + mSafeForwardingMode = safeForwardingMode; + mTargetDataLoader = targetDataLoader; + + // The last argument of createResolverListAdapter is whether to do special handling + // of the last used choice to highlight it in the list. We need to always + // turn this off when running under voice interaction, since it results in + // a more complicated UI that the current voice interaction flow is not able + // to handle. We also turn it off when the work tab is shown to simplify the UX. + // We also turn it off when clonedProfile is present on the device, because we might have + // different "last chosen" activities in the different profiles, and PackageManager doesn't + // provide any more information to help us select between them. + boolean filterLastUsed = mSupportsAlwaysUseOption && !isVoiceInteraction() + && !shouldShowTabs() && !hasCloneProfile(); + mMultiProfilePagerAdapter = createMultiProfilePagerAdapter( + initialIntents, resolutionList, filterLastUsed, targetDataLoader); + if (configureContentView(targetDataLoader)) { + return; + } + + mPersonalPackageMonitor = createPackageMonitor( + mMultiProfilePagerAdapter.getPersonalListAdapter()); + mPersonalPackageMonitor.register( + this, getMainLooper(), getAnnotatedUserHandles().personalProfileUserHandle, false); + if (shouldShowTabs()) { + mWorkPackageMonitor = createPackageMonitor( + mMultiProfilePagerAdapter.getWorkListAdapter()); + mWorkPackageMonitor.register( + this, getMainLooper(), getAnnotatedUserHandles().workProfileUserHandle, false); + } + + mRegistered = true; + + final ResolverDrawerLayout rdl = findViewById(com.android.internal.R.id.contentPanel); + if (rdl != null) { + rdl.setOnDismissedListener(new ResolverDrawerLayout.OnDismissedListener() { + @Override + public void onDismissed() { + finish(); + } + }); + + boolean hasTouchScreen = getPackageManager() + .hasSystemFeature(PackageManager.FEATURE_TOUCHSCREEN); + + if (isVoiceInteraction() || !hasTouchScreen) { + rdl.setCollapsed(false); + } + + rdl.setSystemUiVisibility(View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION + | View.SYSTEM_UI_FLAG_LAYOUT_STABLE); + rdl.setOnApplyWindowInsetsListener(this::onApplyWindowInsets); + + mResolverDrawerLayout = rdl; + } + + mProfileView = findViewById(com.android.internal.R.id.profile_button); + if (mProfileView != null) { + mProfileView.setOnClickListener(this::onProfileClick); + updateProfileViewButton(); + } + + final Set categories = intent.getCategories(); + MetricsLogger.action(this, mMultiProfilePagerAdapter.getActiveListAdapter().hasFilteredItem() + ? MetricsProto.MetricsEvent.ACTION_SHOW_APP_DISAMBIG_APP_FEATURED + : MetricsProto.MetricsEvent.ACTION_SHOW_APP_DISAMBIG_NONE_FEATURED, + intent.getAction() + ":" + intent.getType() + ":" + + (categories != null ? Arrays.toString(categories.toArray()) : "")); + } + + protected MultiProfilePagerAdapter createMultiProfilePagerAdapter( + Intent[] initialIntents, + List resolutionList, + boolean filterLastUsed, + TargetDataLoader targetDataLoader) { + MultiProfilePagerAdapter resolverMultiProfilePagerAdapter = null; + if (shouldShowTabs()) { + resolverMultiProfilePagerAdapter = + createResolverMultiProfilePagerAdapterForTwoProfiles( + initialIntents, resolutionList, filterLastUsed, targetDataLoader); + } else { + resolverMultiProfilePagerAdapter = createResolverMultiProfilePagerAdapterForOneProfile( + initialIntents, resolutionList, filterLastUsed, targetDataLoader); + } + return resolverMultiProfilePagerAdapter; + } + + protected EmptyStateProvider createBlockerEmptyStateProvider() { + final boolean shouldShowNoCrossProfileIntentsEmptyState = getUser().equals(getIntentUser()); + + if (!shouldShowNoCrossProfileIntentsEmptyState) { + // Implementation that doesn't show any blockers + return new EmptyStateProvider() {}; + } + + final EmptyState noWorkToPersonalEmptyState = + new DevicePolicyBlockerEmptyState( + /* context= */ this, + /* devicePolicyStringTitleId= */ RESOLVER_CROSS_PROFILE_BLOCKED_TITLE, + /* defaultTitleResource= */ R.string.resolver_cross_profile_blocked, + /* devicePolicyStringSubtitleId= */ RESOLVER_CANT_ACCESS_PERSONAL, + /* defaultSubtitleResource= */ + R.string.resolver_cant_access_personal_apps_explanation, + /* devicePolicyEventId= */ RESOLVER_EMPTY_STATE_NO_SHARING_TO_PERSONAL, + /* devicePolicyEventCategory= */ + ResolverActivity.METRICS_CATEGORY_RESOLVER); + + final EmptyState noPersonalToWorkEmptyState = + new DevicePolicyBlockerEmptyState( + /* context= */ this, + /* devicePolicyStringTitleId= */ RESOLVER_CROSS_PROFILE_BLOCKED_TITLE, + /* defaultTitleResource= */ R.string.resolver_cross_profile_blocked, + /* devicePolicyStringSubtitleId= */ RESOLVER_CANT_ACCESS_WORK, + /* defaultSubtitleResource= */ + R.string.resolver_cant_access_work_apps_explanation, + /* devicePolicyEventId= */ RESOLVER_EMPTY_STATE_NO_SHARING_TO_WORK, + /* devicePolicyEventCategory= */ + ResolverActivity.METRICS_CATEGORY_RESOLVER); + + return new NoCrossProfileEmptyStateProvider( + getAnnotatedUserHandles().personalProfileUserHandle, + noWorkToPersonalEmptyState, + noPersonalToWorkEmptyState, + createCrossProfileIntentsChecker(), + getAnnotatedUserHandles().tabOwnerUserHandleForLaunch); + } + + protected int appliedThemeResId() { + return R.style.Theme_DeviceDefault_Resolver; + } + + /** + * Numerous layouts are supported, each with optional ViewGroups. + * Make sure the inset gets added to the correct View, using + * a footer for Lists so it can properly scroll under the navbar. + */ + protected boolean shouldAddFooterView() { + if (useLayoutWithDefault()) return true; + + View buttonBar = findViewById(com.android.internal.R.id.button_bar); + if (buttonBar == null || buttonBar.getVisibility() == View.GONE) return true; + + return false; + } + + protected void applyFooterView(int height) { + if (mFooterSpacer == null) { + mFooterSpacer = new Space(getApplicationContext()); + } else { + ((ResolverMultiProfilePagerAdapter) mMultiProfilePagerAdapter) + .getActiveAdapterView().removeFooterView(mFooterSpacer); + } + mFooterSpacer.setLayoutParams(new AbsListView.LayoutParams(LayoutParams.MATCH_PARENT, + mSystemWindowInsets.bottom)); + ((ResolverMultiProfilePagerAdapter) mMultiProfilePagerAdapter) + .getActiveAdapterView().addFooterView(mFooterSpacer); + } + + protected WindowInsets onApplyWindowInsets(View v, WindowInsets insets) { + mSystemWindowInsets = insets.getSystemWindowInsets(); + + mResolverDrawerLayout.setPadding(mSystemWindowInsets.left, mSystemWindowInsets.top, + mSystemWindowInsets.right, 0); + + resetButtonBar(); + + if (shouldUseMiniResolver()) { + View buttonContainer = findViewById(com.android.internal.R.id.button_bar_container); + buttonContainer.setPadding(0, 0, 0, mSystemWindowInsets.bottom + + getResources().getDimensionPixelOffset(R.dimen.resolver_button_bar_spacing)); + } + + // Need extra padding so the list can fully scroll up + if (shouldAddFooterView()) { + applyFooterView(mSystemWindowInsets.bottom); + } + + return insets.consumeSystemWindowInsets(); + } + + @Override + public void onConfigurationChanged(Configuration newConfig) { + super.onConfigurationChanged(newConfig); + mMultiProfilePagerAdapter.getActiveListAdapter().handlePackagesChanged(); + if (mIsIntentPicker && shouldShowTabs() && !useLayoutWithDefault() + && !shouldUseMiniResolver()) { + updateIntentPickerPaddings(); + } + + if (mSystemWindowInsets != null) { + mResolverDrawerLayout.setPadding(mSystemWindowInsets.left, mSystemWindowInsets.top, + mSystemWindowInsets.right, 0); + } + } + + public int getLayoutResource() { + return R.layout.resolver_list; + } + + @Override + protected void onStop() { + super.onStop(); + + final Window window = this.getWindow(); + final WindowManager.LayoutParams attrs = window.getAttributes(); + attrs.privateFlags &= ~SYSTEM_FLAG_HIDE_NON_SYSTEM_OVERLAY_WINDOWS; + window.setAttributes(attrs); + + if (mRegistered) { + mPersonalPackageMonitor.unregister(); + if (mWorkPackageMonitor != null) { + mWorkPackageMonitor.unregister(); + } + mRegistered = false; + } + final Intent intent = getIntent(); + if ((intent.getFlags() & FLAG_ACTIVITY_NEW_TASK) != 0 && !isVoiceInteraction() + && !mResolvingHome && !mRetainInOnStop) { + // This resolver is in the unusual situation where it has been + // launched at the top of a new task. We don't let it be added + // to the recent tasks shown to the user, and we need to make sure + // that each time we are launched we get the correct launching + // uid (not re-using the same resolver from an old launching uid), + // so we will now finish ourself since being no longer visible, + // the user probably can't get back to us. + if (!isChangingConfigurations()) { + finish(); + } + } + // TODO: should we clean up the work-profile manager before we potentially finish() above? + mWorkProfileAvailability.unregisterWorkProfileStateReceiver(this); + } + + @Override + protected void onDestroy() { + super.onDestroy(); + if (!isChangingConfigurations() && mPickOptionRequest != null) { + mPickOptionRequest.cancel(); + } + if (mMultiProfilePagerAdapter != null + && mMultiProfilePagerAdapter.getActiveListAdapter() != null) { + mMultiProfilePagerAdapter.getActiveListAdapter().onDestroy(); + } + } + + public void onButtonClick(View v) { + final int id = v.getId(); + ListView listView = (ListView) mMultiProfilePagerAdapter.getActiveAdapterView(); + ResolverListAdapter currentListAdapter = mMultiProfilePagerAdapter.getActiveListAdapter(); + int which = currentListAdapter.hasFilteredItem() + ? currentListAdapter.getFilteredPosition() + : listView.getCheckedItemPosition(); + boolean hasIndexBeenFiltered = !currentListAdapter.hasFilteredItem(); + startSelected(which, id == com.android.internal.R.id.button_always, hasIndexBeenFiltered); + } + + public void startSelected(int which, boolean always, boolean hasIndexBeenFiltered) { + if (isFinishing()) { + return; + } + ResolveInfo ri = mMultiProfilePagerAdapter.getActiveListAdapter() + .resolveInfoForPosition(which, hasIndexBeenFiltered); + if (mResolvingHome && hasManagedProfile() && !supportsManagedProfiles(ri)) { + Toast.makeText(this, + getWorkProfileNotSupportedMsg( + ri.activityInfo.loadLabel(getPackageManager()).toString()), + Toast.LENGTH_LONG).show(); + return; + } + + TargetInfo target = mMultiProfilePagerAdapter.getActiveListAdapter() + .targetInfoForPosition(which, hasIndexBeenFiltered); + if (target == null) { + return; + } + if (onTargetSelected(target, always)) { + if (always && mSupportsAlwaysUseOption) { + MetricsLogger.action( + this, MetricsProto.MetricsEvent.ACTION_APP_DISAMBIG_ALWAYS); + } else if (mSupportsAlwaysUseOption) { + MetricsLogger.action( + this, MetricsProto.MetricsEvent.ACTION_APP_DISAMBIG_JUST_ONCE); + } else { + MetricsLogger.action( + this, MetricsProto.MetricsEvent.ACTION_APP_DISAMBIG_TAP); + } + MetricsLogger.action(this, + mMultiProfilePagerAdapter.getActiveListAdapter().hasFilteredItem() + ? MetricsProto.MetricsEvent.ACTION_HIDE_APP_DISAMBIG_APP_FEATURED + : MetricsProto.MetricsEvent.ACTION_HIDE_APP_DISAMBIG_NONE_FEATURED); + finish(); + } + } + + /** + * Replace me in subclasses! + */ + @Override // ResolverListCommunicator + public Intent getReplacementIntent(ActivityInfo aInfo, Intent defIntent) { + return defIntent; + } + + protected void onListRebuilt(ResolverListAdapter listAdapter, boolean rebuildCompleted) { + final ItemClickListener listener = new ItemClickListener(); + setupAdapterListView((ListView) mMultiProfilePagerAdapter.getActiveAdapterView(), listener); + if (shouldShowTabs() && mIsIntentPicker) { + final ResolverDrawerLayout rdl = findViewById(com.android.internal.R.id.contentPanel); + if (rdl != null) { + rdl.setMaxCollapsedHeight(getResources() + .getDimensionPixelSize(useLayoutWithDefault() + ? R.dimen.resolver_max_collapsed_height_with_default_with_tabs + : R.dimen.resolver_max_collapsed_height_with_tabs)); + } + } + } + + protected boolean onTargetSelected(TargetInfo target, boolean always) { + final ResolveInfo ri = target.getResolveInfo(); + final Intent intent = target != null ? target.getResolvedIntent() : null; + + if (intent != null && (mSupportsAlwaysUseOption + || mMultiProfilePagerAdapter.getActiveListAdapter().hasFilteredItem()) + && mMultiProfilePagerAdapter.getActiveListAdapter().getUnfilteredResolveList() != null) { + // Build a reasonable intent filter, based on what matched. + IntentFilter filter = new IntentFilter(); + Intent filterIntent; + + if (intent.getSelector() != null) { + filterIntent = intent.getSelector(); + } else { + filterIntent = intent; + } + + String action = filterIntent.getAction(); + if (action != null) { + filter.addAction(action); + } + Set categories = filterIntent.getCategories(); + if (categories != null) { + for (String cat : categories) { + filter.addCategory(cat); + } + } + filter.addCategory(Intent.CATEGORY_DEFAULT); + + int cat = ri.match & IntentFilter.MATCH_CATEGORY_MASK; + Uri data = filterIntent.getData(); + if (cat == IntentFilter.MATCH_CATEGORY_TYPE) { + String mimeType = filterIntent.resolveType(this); + if (mimeType != null) { + try { + filter.addDataType(mimeType); + } catch (IntentFilter.MalformedMimeTypeException e) { + Log.w("ResolverActivity", e); + filter = null; + } + } + } + if (data != null && data.getScheme() != null) { + // We need the data specification if there was no type, + // OR if the scheme is not one of our magical "file:" + // or "content:" schemes (see IntentFilter for the reason). + if (cat != IntentFilter.MATCH_CATEGORY_TYPE + || (!"file".equals(data.getScheme()) + && !"content".equals(data.getScheme()))) { + filter.addDataScheme(data.getScheme()); + + // Look through the resolved filter to determine which part + // of it matched the original Intent. + Iterator pIt = ri.filter.schemeSpecificPartsIterator(); + if (pIt != null) { + String ssp = data.getSchemeSpecificPart(); + while (ssp != null && pIt.hasNext()) { + PatternMatcher p = pIt.next(); + if (p.match(ssp)) { + filter.addDataSchemeSpecificPart(p.getPath(), p.getType()); + break; + } + } + } + Iterator aIt = ri.filter.authoritiesIterator(); + if (aIt != null) { + while (aIt.hasNext()) { + IntentFilter.AuthorityEntry a = aIt.next(); + if (a.match(data) >= 0) { + int port = a.getPort(); + filter.addDataAuthority(a.getHost(), + port >= 0 ? Integer.toString(port) : null); + break; + } + } + } + pIt = ri.filter.pathsIterator(); + if (pIt != null) { + String path = data.getPath(); + while (path != null && pIt.hasNext()) { + PatternMatcher p = pIt.next(); + if (p.match(path)) { + filter.addDataPath(p.getPath(), p.getType()); + break; + } + } + } + } + } + + if (filter != null) { + final int N = mMultiProfilePagerAdapter.getActiveListAdapter() + .getUnfilteredResolveList().size(); + ComponentName[] set; + // If we don't add back in the component for forwarding the intent to a managed + // profile, the preferred activity may not be updated correctly (as the set of + // components we tell it we knew about will have changed). + final boolean needToAddBackProfileForwardingComponent = + mMultiProfilePagerAdapter.getActiveListAdapter().getOtherProfile() != null; + if (!needToAddBackProfileForwardingComponent) { + set = new ComponentName[N]; + } else { + set = new ComponentName[N + 1]; + } + + int bestMatch = 0; + for (int i=0; i bestMatch) bestMatch = r.match; + } + + if (needToAddBackProfileForwardingComponent) { + set[N] = mMultiProfilePagerAdapter.getActiveListAdapter() + .getOtherProfile().getResolvedComponentName(); + final int otherProfileMatch = mMultiProfilePagerAdapter.getActiveListAdapter() + .getOtherProfile().getResolveInfo().match; + if (otherProfileMatch > bestMatch) bestMatch = otherProfileMatch; + } + + if (always) { + final int userId = getUserId(); + final PackageManager pm = getPackageManager(); + + // Set the preferred Activity + pm.addUniquePreferredActivity(filter, bestMatch, set, intent.getComponent()); + + if (ri.handleAllWebDataURI) { + // Set default Browser if needed + final String packageName = pm.getDefaultBrowserPackageNameAsUser(userId); + if (TextUtils.isEmpty(packageName)) { + pm.setDefaultBrowserPackageNameAsUser(ri.activityInfo.packageName, userId); + } + } + } else { + try { + mMultiProfilePagerAdapter.getActiveListAdapter() + .mResolverListController.setLastChosen(intent, filter, bestMatch); + } catch (RemoteException re) { + Log.d(TAG, "Error calling setLastChosenActivity\n" + re); + } + } + } + } + + if (target != null) { + safelyStartActivity(target); + + // Rely on the ActivityManager to pop up a dialog regarding app suspension + // and return false + if (target.isSuspended()) { + return false; + } + } + + return true; + } + + public void onActivityStarted(TargetInfo cti) { + // Do nothing + } + + @Override // ResolverListCommunicator + public boolean shouldGetActivityMetadata() { + return false; + } + + public boolean shouldAutoLaunchSingleChoice(TargetInfo target) { + return !target.isSuspended(); + } + + // TODO: this method takes an unused `UserHandle` because the override in `ChooserActivity` uses + // that data to set up other components as dependencies of the controller. In reality, these + // methods don't require polymorphism, because they're only invoked from within their respective + // concrete class; `ResolverActivity` will never call this method expecting to get a + // `ChooserListController` (subclass) result, because `ResolverActivity` only invokes this + // method as part of handling `createMultiProfilePagerAdapter()`, which is itself overridden in + // `ChooserActivity`. A future refactoring could better express the coupling between the adapter + // and controller types; in the meantime, structuring as an override (with matching signatures) + // shows that these methods are *structurally* related, and helps to prevent any regressions in + // the future if resolver *were* to make any (non-overridden) calls to a version that used a + // different signature (and thus didn't return the subclass type). + @VisibleForTesting + protected ResolverListController createListController(UserHandle userHandle) { + ResolverRankerServiceResolverComparator resolverComparator = + new ResolverRankerServiceResolverComparator( + this, + getTargetIntent(), + getReferrerPackageName(), + null, + null, + getResolverRankerServiceUserHandleList(userHandle), + null); + return new ResolverListController( + this, + mPm, + getTargetIntent(), + getReferrerPackageName(), + getAnnotatedUserHandles().userIdOfCallingApp, + resolverComparator, + getQueryIntentsUser(userHandle)); + } + + /** + * Finishing procedures to be performed after the list has been rebuilt. + *

Subclasses must call postRebuildListInternal at the end of postRebuildList. + * @param rebuildCompleted + * @return true if the activity is finishing and creation should halt. + */ + protected boolean postRebuildList(boolean rebuildCompleted) { + return postRebuildListInternal(rebuildCompleted); + } + + void onHorizontalSwipeStateChanged(int state) {} + + /** + * Callback called when user changes the profile tab. + *

This method is intended to be overridden by subclasses. + */ + protected void onProfileTabSelected() { } + + /** + * Add a label to signify that the user can pick a different app. + * @param adapter The adapter used to provide data to item views. + */ + public void addUseDifferentAppLabelIfNecessary(ResolverListAdapter adapter) { + final boolean useHeader = adapter.hasFilteredItem(); + if (useHeader) { + FrameLayout stub = findViewById(com.android.internal.R.id.stub); + stub.setVisibility(View.VISIBLE); + TextView textView = (TextView) LayoutInflater.from(this).inflate( + R.layout.resolver_different_item_header, null, false); + if (shouldShowTabs()) { + textView.setGravity(Gravity.CENTER); + } + stub.addView(textView); + } + } + + protected void resetButtonBar() { + if (!mSupportsAlwaysUseOption) { + return; + } + final ViewGroup buttonLayout = findViewById(com.android.internal.R.id.button_bar); + if (buttonLayout == null) { + Log.e(TAG, "Layout unexpectedly does not have a button bar"); + return; + } + ResolverListAdapter activeListAdapter = + mMultiProfilePagerAdapter.getActiveListAdapter(); + View buttonBarDivider = findViewById(com.android.internal.R.id.resolver_button_bar_divider); + if (!useLayoutWithDefault()) { + int inset = mSystemWindowInsets != null ? mSystemWindowInsets.bottom : 0; + buttonLayout.setPadding(buttonLayout.getPaddingLeft(), buttonLayout.getPaddingTop(), + buttonLayout.getPaddingRight(), getResources().getDimensionPixelSize( + R.dimen.resolver_button_bar_spacing) + inset); + } + if (activeListAdapter.isTabLoaded() + && mMultiProfilePagerAdapter.shouldShowEmptyStateScreen(activeListAdapter) + && !useLayoutWithDefault()) { + buttonLayout.setVisibility(View.INVISIBLE); + if (buttonBarDivider != null) { + buttonBarDivider.setVisibility(View.INVISIBLE); + } + setButtonBarIgnoreOffset(/* ignoreOffset */ false); + return; + } + if (buttonBarDivider != null) { + buttonBarDivider.setVisibility(View.VISIBLE); + } + buttonLayout.setVisibility(View.VISIBLE); + setButtonBarIgnoreOffset(/* ignoreOffset */ true); + + mOnceButton = (Button) buttonLayout.findViewById(com.android.internal.R.id.button_once); + mAlwaysButton = (Button) buttonLayout.findViewById(com.android.internal.R.id.button_always); + + resetAlwaysOrOnceButtonBar(); + } + + protected String getMetricsCategory() { + return METRICS_CATEGORY_RESOLVER; + } + + @Override // ResolverListCommunicator + public void onHandlePackagesChanged(ResolverListAdapter listAdapter) { + if (listAdapter == mMultiProfilePagerAdapter.getActiveListAdapter()) { + if (listAdapter.getUserHandle().equals(getAnnotatedUserHandles().workProfileUserHandle) + && mWorkProfileAvailability.isWaitingToEnableWorkProfile()) { + // We have just turned on the work profile and entered the pass code to start it, + // now we are waiting to receive the ACTION_USER_UNLOCKED broadcast. There is no + // point in reloading the list now, since the work profile user is still + // turning on. + return; + } + boolean listRebuilt = mMultiProfilePagerAdapter.rebuildActiveTab(true); + if (listRebuilt) { + ResolverListAdapter activeListAdapter = + mMultiProfilePagerAdapter.getActiveListAdapter(); + activeListAdapter.notifyDataSetChanged(); + if (activeListAdapter.getCount() == 0 && !inactiveListAdapterHasItems()) { + // We no longer have any items... just finish the activity. + finish(); + } + } + } else { + mMultiProfilePagerAdapter.clearInactiveProfileCache(); + } + } + + protected void maybeLogProfileChange() {} + + // @NonFinalForTesting + @VisibleForTesting + protected MyUserIdProvider createMyUserIdProvider() { + return new MyUserIdProvider(); + } + + // @NonFinalForTesting + @VisibleForTesting + protected CrossProfileIntentsChecker createCrossProfileIntentsChecker() { + return new CrossProfileIntentsChecker(getContentResolver()); + } + + protected WorkProfileAvailabilityManager createWorkProfileAvailabilityManager() { + return new WorkProfileAvailabilityManager( + getSystemService(UserManager.class), + getAnnotatedUserHandles().workProfileUserHandle, + this::onWorkProfileStatusUpdated); + } + + protected void onWorkProfileStatusUpdated() { + if (mMultiProfilePagerAdapter.getCurrentUserHandle().equals( + getAnnotatedUserHandles().workProfileUserHandle)) { + mMultiProfilePagerAdapter.rebuildActiveTab(true); + } else { + mMultiProfilePagerAdapter.clearInactiveProfileCache(); + } + } + + // @NonFinalForTesting + @VisibleForTesting + protected ResolverListAdapter createResolverListAdapter( + Context context, + List payloadIntents, + Intent[] initialIntents, + List resolutionList, + boolean filterLastUsed, + UserHandle userHandle, + TargetDataLoader targetDataLoader) { + UserHandle initialIntentsUserSpace = isLaunchedAsCloneProfile() + && userHandle.equals(getAnnotatedUserHandles().personalProfileUserHandle) + ? getAnnotatedUserHandles().cloneProfileUserHandle : userHandle; + return new ResolverListAdapter( + context, + payloadIntents, + initialIntents, + resolutionList, + filterLastUsed, + createListController(userHandle), + userHandle, + getTargetIntent(), + this, + initialIntentsUserSpace, + targetDataLoader); + } + + private TargetDataLoader createIconLoader() { + Intent startIntent = getIntent(); + boolean isAudioCaptureDevice = + startIntent.getBooleanExtra(EXTRA_IS_AUDIO_CAPTURE_DEVICE, false); + return new DefaultTargetDataLoader(this, getLifecycle(), isAudioCaptureDevice); + } + + private LatencyTracker getLatencyTracker() { + return LatencyTracker.getInstance(this); + } + + /** + * Get the string resource to be used as a label for the link to the resolver activity for an + * action. + * + * @param action The action to resolve + * + * @return The string resource to be used as a label + */ + public static @StringRes int getLabelRes(String action) { + return ActionTitle.forAction(action).labelRes; + } + + protected final EmptyStateProvider createEmptyStateProvider( + @Nullable UserHandle workProfileUserHandle) { + final EmptyStateProvider blockerEmptyStateProvider = createBlockerEmptyStateProvider(); + + final EmptyStateProvider workProfileOffEmptyStateProvider = + new WorkProfilePausedEmptyStateProvider(this, workProfileUserHandle, + mWorkProfileAvailability, + /* onSwitchOnWorkSelectedListener= */ + () -> { + if (mOnSwitchOnWorkSelectedListener != null) { + mOnSwitchOnWorkSelectedListener.onSwitchOnWorkSelected(); + } + }, + getMetricsCategory()); + + final EmptyStateProvider noAppsEmptyStateProvider = new NoAppsAvailableEmptyStateProvider( + this, + workProfileUserHandle, + getAnnotatedUserHandles().personalProfileUserHandle, + getMetricsCategory(), + getAnnotatedUserHandles().tabOwnerUserHandleForLaunch + ); + + // Return composite provider, the order matters (the higher, the more priority) + return new CompositeEmptyStateProvider( + blockerEmptyStateProvider, + workProfileOffEmptyStateProvider, + noAppsEmptyStateProvider + ); + } + + private Intent makeMyIntent() { + Intent intent = new Intent(getIntent()); + intent.setComponent(null); + // The resolver activity is set to be hidden from recent tasks. + // we don't want this attribute to be propagated to the next activity + // being launched. Note that if the original Intent also had this + // flag set, we are now losing it. That should be a very rare case + // and we can live with this. + intent.setFlags(intent.getFlags() & ~Intent.FLAG_ACTIVITY_EXCLUDE_FROM_RECENTS); + + // If FLAG_ACTIVITY_LAUNCH_ADJACENT was set, ResolverActivity was opened in the alternate + // side, which means we want to open the target app on the same side as ResolverActivity. + if ((intent.getFlags() & FLAG_ACTIVITY_LAUNCH_ADJACENT) != 0) { + intent.setFlags(intent.getFlags() & ~FLAG_ACTIVITY_LAUNCH_ADJACENT); + } + return intent; + } + + /** + * Call {@link Activity#onCreate} without initializing anything further. This should + * only be used when the activity is about to be immediately finished to avoid wasting + * initializing steps and leaking resources. + */ + protected final void super_onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + } + + private ResolverMultiProfilePagerAdapter + createResolverMultiProfilePagerAdapterForOneProfile( + Intent[] initialIntents, + List resolutionList, + boolean filterLastUsed, + TargetDataLoader targetDataLoader) { + ResolverListAdapter adapter = createResolverListAdapter( + /* context */ this, + /* payloadIntents */ mIntents, + initialIntents, + resolutionList, + filterLastUsed, + /* userHandle */ getAnnotatedUserHandles().personalProfileUserHandle, + targetDataLoader); + return new ResolverMultiProfilePagerAdapter( + /* context */ this, + adapter, + createEmptyStateProvider(/* workProfileUserHandle= */ null), + /* workProfileQuietModeChecker= */ () -> false, + /* workProfileUserHandle= */ null, + getAnnotatedUserHandles().cloneProfileUserHandle); + } + + private UserHandle getIntentUser() { + return getIntent().hasExtra(EXTRA_CALLING_USER) + ? getIntent().getParcelableExtra(EXTRA_CALLING_USER) + : getAnnotatedUserHandles().tabOwnerUserHandleForLaunch; + } + + private ResolverMultiProfilePagerAdapter createResolverMultiProfilePagerAdapterForTwoProfiles( + Intent[] initialIntents, + List resolutionList, + boolean filterLastUsed, + TargetDataLoader targetDataLoader) { + // In the edge case when we have 0 apps in the current profile and >1 apps in the other, + // the intent resolver is started in the other profile. Since this is the only case when + // this happens, we check for it here and set the current profile's tab. + int selectedProfile = getCurrentProfile(); + UserHandle intentUser = getIntentUser(); + if (!getAnnotatedUserHandles().tabOwnerUserHandleForLaunch.equals(intentUser)) { + if (getAnnotatedUserHandles().personalProfileUserHandle.equals(intentUser)) { + selectedProfile = PROFILE_PERSONAL; + } else if (getAnnotatedUserHandles().workProfileUserHandle.equals(intentUser)) { + selectedProfile = PROFILE_WORK; + } + } else { + int selectedProfileExtra = getSelectedProfileExtra(); + if (selectedProfileExtra != -1) { + selectedProfile = selectedProfileExtra; + } + } + // We only show the default app for the profile of the current user. The filterLastUsed + // flag determines whether to show a default app and that app is not shown in the + // resolver list. So filterLastUsed should be false for the other profile. + ResolverListAdapter personalAdapter = createResolverListAdapter( + /* context */ this, + /* payloadIntents */ mIntents, + selectedProfile == PROFILE_PERSONAL ? initialIntents : null, + resolutionList, + (filterLastUsed && UserHandle.myUserId() + == getAnnotatedUserHandles().personalProfileUserHandle.getIdentifier()), + /* userHandle */ getAnnotatedUserHandles().personalProfileUserHandle, + targetDataLoader); + UserHandle workProfileUserHandle = getAnnotatedUserHandles().workProfileUserHandle; + ResolverListAdapter workAdapter = createResolverListAdapter( + /* context */ this, + /* payloadIntents */ mIntents, + selectedProfile == PROFILE_WORK ? initialIntents : null, + resolutionList, + (filterLastUsed && UserHandle.myUserId() + == workProfileUserHandle.getIdentifier()), + /* userHandle */ workProfileUserHandle, + targetDataLoader); + return new ResolverMultiProfilePagerAdapter( + /* context */ this, + personalAdapter, + workAdapter, + createEmptyStateProvider(workProfileUserHandle), + () -> mWorkProfileAvailability.isQuietModeEnabled(), + selectedProfile, + workProfileUserHandle, + getAnnotatedUserHandles().cloneProfileUserHandle); + } + + /** + * Returns {@link #PROFILE_PERSONAL} or {@link #PROFILE_WORK} if the {@link + * #EXTRA_SELECTED_PROFILE} extra was supplied, or {@code -1} if no extra was supplied. + * @throws IllegalArgumentException if the value passed to the {@link #EXTRA_SELECTED_PROFILE} + * extra is not {@link #PROFILE_PERSONAL} or {@link #PROFILE_WORK} + */ + final int getSelectedProfileExtra() { + int selectedProfile = -1; + if (getIntent().hasExtra(EXTRA_SELECTED_PROFILE)) { + selectedProfile = getIntent().getIntExtra(EXTRA_SELECTED_PROFILE, /* defValue = */ -1); + if (selectedProfile != PROFILE_PERSONAL && selectedProfile != PROFILE_WORK) { + throw new IllegalArgumentException(EXTRA_SELECTED_PROFILE + " has invalid value " + + selectedProfile + ". Must be either ResolverActivity.PROFILE_PERSONAL or " + + "ResolverActivity.PROFILE_WORK."); + } + } + return selectedProfile; + } + + protected final @Profile int getCurrentProfile() { + UserHandle launchUser = getAnnotatedUserHandles().tabOwnerUserHandleForLaunch; + UserHandle personalUser = getAnnotatedUserHandles().personalProfileUserHandle; + return launchUser.equals(personalUser) ? PROFILE_PERSONAL : PROFILE_WORK; + } + + protected final AnnotatedUserHandles getAnnotatedUserHandles() { + return mLazyAnnotatedUserHandles.get(); + } + + private boolean hasWorkProfile() { + return getAnnotatedUserHandles().workProfileUserHandle != null; + } + + private boolean hasCloneProfile() { + return getAnnotatedUserHandles().cloneProfileUserHandle != null; + } + + protected final boolean isLaunchedAsCloneProfile() { + UserHandle launchUser = getAnnotatedUserHandles().userHandleSharesheetLaunchedAs; + UserHandle cloneUser = getAnnotatedUserHandles().cloneProfileUserHandle; + return hasCloneProfile() && launchUser.equals(cloneUser); + } + + protected final boolean shouldShowTabs() { + return hasWorkProfile(); + } + + protected final void onProfileClick(View v) { + final DisplayResolveInfo dri = + mMultiProfilePagerAdapter.getActiveListAdapter().getOtherProfile(); + if (dri == null) { + return; + } + + // Do not show the profile switch message anymore. + mProfileSwitchMessage = null; + + onTargetSelected(dri, false); + finish(); + } + + private void updateIntentPickerPaddings() { + View titleCont = findViewById(com.android.internal.R.id.title_container); + titleCont.setPadding( + titleCont.getPaddingLeft(), + titleCont.getPaddingTop(), + titleCont.getPaddingRight(), + getResources().getDimensionPixelSize(R.dimen.resolver_title_padding_bottom)); + View buttonBar = findViewById(com.android.internal.R.id.button_bar); + buttonBar.setPadding( + buttonBar.getPaddingLeft(), + getResources().getDimensionPixelSize(R.dimen.resolver_button_bar_spacing), + buttonBar.getPaddingRight(), + getResources().getDimensionPixelSize(R.dimen.resolver_button_bar_spacing)); + } + + private void maybeLogCrossProfileTargetLaunch(TargetInfo cti, UserHandle currentUserHandle) { + if (!hasWorkProfile() || currentUserHandle.equals(getUser())) { + return; + } + DevicePolicyEventLogger + .createEvent(DevicePolicyEnums.RESOLVER_CROSS_PROFILE_TARGET_OPENED) + .setBoolean( + currentUserHandle.equals( + getAnnotatedUserHandles().personalProfileUserHandle)) + .setStrings(getMetricsCategory(), + cti.isInDirectShareMetricsCategory() ? "direct_share" : "other_target") + .write(); + } + + @Override // ResolverListCommunicator + public final void sendVoiceChoicesIfNeeded() { + if (!isVoiceInteraction()) { + // Clearly not needed. + return; + } + + int count = mMultiProfilePagerAdapter.getActiveListAdapter().getCount(); + final Option[] options = new Option[count]; + for (int i = 0; i < options.length; i++) { + TargetInfo target = mMultiProfilePagerAdapter.getActiveListAdapter().getItem(i); + if (target == null) { + // If this occurs, a new set of targets is being loaded. Let that complete, + // and have the next call to send voice choices proceed instead. + return; + } + options[i] = optionForChooserTarget(target, i); + } + + mPickOptionRequest = new PickTargetOptionRequest( + new Prompt(getTitle()), options, null); + getVoiceInteractor().submitRequest(mPickOptionRequest); + } + + final Option optionForChooserTarget(TargetInfo target, int index) { + return new Option(getOrLoadDisplayLabel(target), index); + } + + public final Intent getTargetIntent() { + return mIntents.isEmpty() ? null : mIntents.get(0); + } + + protected final String getReferrerPackageName() { + final Uri referrer = getReferrer(); + if (referrer != null && "android-app".equals(referrer.getScheme())) { + return referrer.getHost(); + } + return null; + } + + @Override // ResolverListCommunicator + public final void updateProfileViewButton() { + if (mProfileView == null) { + return; + } + + final DisplayResolveInfo dri = + mMultiProfilePagerAdapter.getActiveListAdapter().getOtherProfile(); + if (dri != null && !shouldShowTabs()) { + mProfileView.setVisibility(View.VISIBLE); + View text = mProfileView.findViewById(com.android.internal.R.id.profile_button); + if (!(text instanceof TextView)) { + text = mProfileView.findViewById(com.android.internal.R.id.text1); + } + ((TextView) text).setText(dri.getDisplayLabel()); + } else { + mProfileView.setVisibility(View.GONE); + } + } + + private void setProfileSwitchMessage(int contentUserHint) { + if ((contentUserHint != UserHandle.USER_CURRENT) + && (contentUserHint != UserHandle.myUserId())) { + UserManager userManager = (UserManager) getSystemService(Context.USER_SERVICE); + UserInfo originUserInfo = userManager.getUserInfo(contentUserHint); + boolean originIsManaged = originUserInfo != null ? originUserInfo.isManagedProfile() + : false; + boolean targetIsManaged = userManager.isManagedProfile(); + if (originIsManaged && !targetIsManaged) { + mProfileSwitchMessage = getForwardToPersonalMsg(); + } else if (!originIsManaged && targetIsManaged) { + mProfileSwitchMessage = getForwardToWorkMsg(); + } + } + } + + private String getForwardToPersonalMsg() { + return getSystemService(DevicePolicyManager.class).getResources().getString( + FORWARD_INTENT_TO_PERSONAL, + () -> getString(R.string.forward_intent_to_owner)); + } + + private String getForwardToWorkMsg() { + return getSystemService(DevicePolicyManager.class).getResources().getString( + FORWARD_INTENT_TO_WORK, + () -> getString(R.string.forward_intent_to_work)); + } + + protected final CharSequence getTitleForAction(Intent intent, int defaultTitleRes) { + final ActionTitle title = mResolvingHome + ? ActionTitle.HOME + : ActionTitle.forAction(intent.getAction()); + + // While there may already be a filtered item, we can only use it in the title if the list + // is already sorted and all information relevant to it is already in the list. + final boolean named = + mMultiProfilePagerAdapter.getActiveListAdapter().getFilteredPosition() >= 0; + if (title == ActionTitle.DEFAULT && defaultTitleRes != 0) { + return getString(defaultTitleRes); + } else { + return named + ? getString( + title.namedTitleRes, + getOrLoadDisplayLabel( + mMultiProfilePagerAdapter + .getActiveListAdapter().getFilteredItem())) + : getString(title.titleRes); + } + } + + final void dismiss() { + if (!isFinishing()) { + finish(); + } + } + + @Override + protected final void onRestart() { + super.onRestart(); + if (!mRegistered) { + mPersonalPackageMonitor.register( + this, + getMainLooper(), + getAnnotatedUserHandles().personalProfileUserHandle, + false); + if (shouldShowTabs()) { + if (mWorkPackageMonitor == null) { + mWorkPackageMonitor = createPackageMonitor( + mMultiProfilePagerAdapter.getWorkListAdapter()); + } + mWorkPackageMonitor.register( + this, + getMainLooper(), + getAnnotatedUserHandles().workProfileUserHandle, + false); + } + mRegistered = true; + } + if (shouldShowTabs() && mWorkProfileAvailability.isWaitingToEnableWorkProfile()) { + if (mWorkProfileAvailability.isQuietModeEnabled()) { + mWorkProfileAvailability.markWorkProfileEnabledBroadcastReceived(); + } + } + mMultiProfilePagerAdapter.getActiveListAdapter().handlePackagesChanged(); + updateProfileViewButton(); + } + + @Override + protected final void onStart() { + super.onStart(); + + this.getWindow().addSystemFlags(SYSTEM_FLAG_HIDE_NON_SYSTEM_OVERLAY_WINDOWS); + if (shouldShowTabs()) { + mWorkProfileAvailability.registerWorkProfileStateReceiver(this); + } + } + + @Override + protected final void onSaveInstanceState(Bundle outState) { + super.onSaveInstanceState(outState); + ViewPager viewPager = findViewById(com.android.internal.R.id.profile_pager); + if (viewPager != null) { + outState.putInt(LAST_SHOWN_TAB_KEY, viewPager.getCurrentItem()); + } + } + + @Override + protected final void onRestoreInstanceState(Bundle savedInstanceState) { + super.onRestoreInstanceState(savedInstanceState); + resetButtonBar(); + ViewPager viewPager = findViewById(com.android.internal.R.id.profile_pager); + if (viewPager != null) { + viewPager.setCurrentItem(savedInstanceState.getInt(LAST_SHOWN_TAB_KEY)); + } + mMultiProfilePagerAdapter.clearInactiveProfileCache(); + } + + private boolean hasManagedProfile() { + UserManager userManager = (UserManager) getSystemService(Context.USER_SERVICE); + if (userManager == null) { + return false; + } + + try { + List profiles = userManager.getProfiles(getUserId()); + for (UserInfo userInfo : profiles) { + if (userInfo != null && userInfo.isManagedProfile()) { + return true; + } + } + } catch (SecurityException e) { + return false; + } + return false; + } + + private boolean supportsManagedProfiles(ResolveInfo resolveInfo) { + try { + ApplicationInfo appInfo = getPackageManager().getApplicationInfo( + resolveInfo.activityInfo.packageName, 0 /* default flags */); + return appInfo.targetSdkVersion >= Build.VERSION_CODES.LOLLIPOP; + } catch (NameNotFoundException e) { + return false; + } + } + + private void setAlwaysButtonEnabled(boolean hasValidSelection, int checkedPos, + boolean filtered) { + if (!mMultiProfilePagerAdapter.getCurrentUserHandle().equals(getUser())) { + // Never allow the inactive profile to always open an app. + mAlwaysButton.setEnabled(false); + return; + } + // In case of clonedProfile being active, we do not allow the 'Always' option in the + // disambiguation dialog of Personal Profile as the package manager cannot distinguish + // between cross-profile preferred activities. + if (hasCloneProfile() && (mMultiProfilePagerAdapter.getCurrentPage() == PROFILE_PERSONAL)) { + mAlwaysButton.setEnabled(false); + return; + } + boolean enabled = false; + ResolveInfo ri = null; + if (hasValidSelection) { + ri = mMultiProfilePagerAdapter.getActiveListAdapter() + .resolveInfoForPosition(checkedPos, filtered); + if (ri == null) { + Log.e(TAG, "Invalid position supplied to setAlwaysButtonEnabled"); + return; + } else if (ri.targetUserId != UserHandle.USER_CURRENT) { + Log.e(TAG, "Attempted to set selection to resolve info for another user"); + return; + } else { + enabled = true; + } + + mAlwaysButton.setText(getResources() + .getString(R.string.activity_resolver_use_always)); + } + + if (ri != null) { + ActivityInfo activityInfo = ri.activityInfo; + + boolean hasRecordPermission = + mPm.checkPermission(android.Manifest.permission.RECORD_AUDIO, + activityInfo.packageName) + == PackageManager.PERMISSION_GRANTED; + + if (!hasRecordPermission) { + // OK, we know the record permission, is this a capture device + boolean hasAudioCapture = + getIntent().getBooleanExtra( + ResolverActivity.EXTRA_IS_AUDIO_CAPTURE_DEVICE, false); + enabled = !hasAudioCapture; + } + } + mAlwaysButton.setEnabled(enabled); + } + + private String getWorkProfileNotSupportedMsg(String launcherName) { + return getSystemService(DevicePolicyManager.class).getResources().getString( + RESOLVER_WORK_PROFILE_NOT_SUPPORTED, + () -> getString( + R.string.activity_resolver_work_profiles_support, + launcherName), + launcherName); + } + + @Override // ResolverListCommunicator + public final void onPostListReady(ResolverListAdapter listAdapter, boolean doPostProcessing, + boolean rebuildCompleted) { + if (isAutolaunching()) { + return; + } + if (mIsIntentPicker) { + ((ResolverMultiProfilePagerAdapter) mMultiProfilePagerAdapter) + .setUseLayoutWithDefault(useLayoutWithDefault()); + } + if (mMultiProfilePagerAdapter.shouldShowEmptyStateScreen(listAdapter)) { + mMultiProfilePagerAdapter.showEmptyResolverListEmptyState(listAdapter); + } else { + mMultiProfilePagerAdapter.showListView(listAdapter); + } + // showEmptyResolverListEmptyState can mark the tab as loaded, + // which is a precondition for auto launching + if (rebuildCompleted && maybeAutolaunchActivity()) { + return; + } + if (doPostProcessing) { + maybeCreateHeader(listAdapter); + resetButtonBar(); + onListRebuilt(listAdapter, rebuildCompleted); + } + } + + /** Start the activity specified by the {@link TargetInfo}.*/ + public final void safelyStartActivity(TargetInfo cti) { + // In case cloned apps are present, we would want to start those apps in cloned user + // space, which will not be same as the adapter's userHandle. resolveInfo.userHandle + // identifies the correct user space in such cases. + UserHandle activityUserHandle = cti.getResolveInfo().userHandle; + safelyStartActivityAsUser(cti, activityUserHandle, null); + } + + /** + * Start activity as a fixed user handle. + * @param cti TargetInfo to be launched. + * @param user User to launch this activity as. + */ + @VisibleForTesting(visibility = VisibleForTesting.Visibility.PROTECTED) + public final void safelyStartActivityAsUser(TargetInfo cti, UserHandle user) { + safelyStartActivityAsUser(cti, user, null); + } + + protected final void safelyStartActivityAsUser( + TargetInfo cti, UserHandle user, @Nullable Bundle options) { + // We're dispatching intents that might be coming from legacy apps, so + // don't kill ourselves. + StrictMode.disableDeathOnFileUriExposure(); + try { + safelyStartActivityInternal(cti, user, options); + } finally { + StrictMode.enableDeathOnFileUriExposure(); + } + } + + @VisibleForTesting + protected void safelyStartActivityInternal( + TargetInfo cti, UserHandle user, @Nullable Bundle options) { + // If the target is suspended, the activity will not be successfully launched. + // Do not unregister from package manager updates in this case + if (!cti.isSuspended() && mRegistered) { + if (mPersonalPackageMonitor != null) { + mPersonalPackageMonitor.unregister(); + } + if (mWorkPackageMonitor != null) { + mWorkPackageMonitor.unregister(); + } + mRegistered = false; + } + // If needed, show that intent is forwarded + // from managed profile to owner or other way around. + if (mProfileSwitchMessage != null) { + Toast.makeText(this, mProfileSwitchMessage, Toast.LENGTH_LONG).show(); + } + if (!mSafeForwardingMode) { + if (cti.startAsUser(this, options, user)) { + onActivityStarted(cti); + maybeLogCrossProfileTargetLaunch(cti, user); + } + return; + } + try { + if (cti.startAsCaller(this, options, user.getIdentifier())) { + onActivityStarted(cti); + maybeLogCrossProfileTargetLaunch(cti, user); + } + } catch (RuntimeException e) { + Slog.wtf(TAG, + "Unable to launch as uid " + getAnnotatedUserHandles().userIdOfCallingApp + + " package " + getLaunchedFromPackage() + ", while running in " + + ActivityThread.currentProcessName(), e); + } + } + + final void showTargetDetails(ResolveInfo ri) { + Intent in = new Intent().setAction(Settings.ACTION_APPLICATION_DETAILS_SETTINGS) + .setData(Uri.fromParts("package", ri.activityInfo.packageName, null)) + .addFlags(Intent.FLAG_ACTIVITY_NEW_DOCUMENT); + startActivityAsUser(in, mMultiProfilePagerAdapter.getCurrentUserHandle()); + } + + /** + * Sets up the content view. + * @return true if the activity is finishing and creation should halt. + */ + private boolean configureContentView(TargetDataLoader targetDataLoader) { + if (mMultiProfilePagerAdapter.getActiveListAdapter() == null) { + throw new IllegalStateException("mMultiProfilePagerAdapter.getCurrentListAdapter() " + + "cannot be null."); + } + Trace.beginSection("configureContentView"); + // We partially rebuild the inactive adapter to determine if we should auto launch + // isTabLoaded will be true here if the empty state screen is shown instead of the list. + boolean rebuildCompleted = mMultiProfilePagerAdapter.rebuildActiveTab(true) + || mMultiProfilePagerAdapter.getActiveListAdapter().isTabLoaded(); + if (shouldShowTabs()) { + boolean rebuildInactiveCompleted = mMultiProfilePagerAdapter.rebuildInactiveTab(false) + || mMultiProfilePagerAdapter.getInactiveListAdapter().isTabLoaded(); + rebuildCompleted = rebuildCompleted && rebuildInactiveCompleted; + } + + if (shouldUseMiniResolver()) { + configureMiniResolverContent(targetDataLoader); + Trace.endSection(); + return false; + } + + if (useLayoutWithDefault()) { + mLayoutId = R.layout.resolver_list_with_default; + } else { + mLayoutId = getLayoutResource(); + } + setContentView(mLayoutId); + mMultiProfilePagerAdapter.setupViewPager(findViewById(com.android.internal.R.id.profile_pager)); + boolean result = postRebuildList(rebuildCompleted); + Trace.endSection(); + return result; + } + + /** + * Mini resolver is shown when the user is choosing between browser[s] in this profile and a + * single app in the other profile (see shouldUseMiniResolver()). It shows the single app icon + * and asks the user if they'd like to open that cross-profile app or use the in-profile + * browser. + */ + private void configureMiniResolverContent(TargetDataLoader targetDataLoader) { + mLayoutId = R.layout.miniresolver; + setContentView(mLayoutId); + + DisplayResolveInfo sameProfileResolveInfo = + mMultiProfilePagerAdapter.getActiveListAdapter().getFirstDisplayResolveInfo(); + boolean inWorkProfile = getCurrentProfile() == PROFILE_WORK; + + final ResolverListAdapter inactiveAdapter = + mMultiProfilePagerAdapter.getInactiveListAdapter(); + final DisplayResolveInfo otherProfileResolveInfo = + inactiveAdapter.getFirstDisplayResolveInfo(); + + // Load the icon asynchronously + ImageView icon = findViewById(com.android.internal.R.id.icon); + targetDataLoader.loadAppTargetIcon( + otherProfileResolveInfo, + inactiveAdapter.getUserHandle(), + (drawable) -> { + if (!isDestroyed()) { + otherProfileResolveInfo.getDisplayIconHolder().setDisplayIcon(drawable); + new ResolverListAdapter.ViewHolder(icon).bindIcon(otherProfileResolveInfo); + } + }); + + ((TextView) findViewById(com.android.internal.R.id.open_cross_profile)).setText( + getResources().getString( + inWorkProfile + ? R.string.miniresolver_open_in_personal + : R.string.miniresolver_open_in_work, + getOrLoadDisplayLabel(otherProfileResolveInfo))); + ((Button) findViewById(com.android.internal.R.id.use_same_profile_browser)).setText( + inWorkProfile ? R.string.miniresolver_use_work_browser + : R.string.miniresolver_use_personal_browser); + + findViewById(com.android.internal.R.id.use_same_profile_browser).setOnClickListener( + v -> { + safelyStartActivity(sameProfileResolveInfo); + finish(); + }); + + findViewById(com.android.internal.R.id.button_open).setOnClickListener(v -> { + Intent intent = otherProfileResolveInfo.getResolvedIntent(); + safelyStartActivityAsUser(otherProfileResolveInfo, inactiveAdapter.getUserHandle()); + finish(); + }); + } + + /** + * Mini resolver should be used when all of the following are true: + * 1. This is the intent picker (ResolverActivity). + * 2. This profile only has web browser matches. + * 3. The other profile has a single non-browser match. + */ + private boolean shouldUseMiniResolver() { + if (!mIsIntentPicker) { + return false; + } + if (mMultiProfilePagerAdapter.getActiveListAdapter() == null + || mMultiProfilePagerAdapter.getInactiveListAdapter() == null) { + return false; + } + ResolverListAdapter sameProfileAdapter = + mMultiProfilePagerAdapter.getActiveListAdapter(); + ResolverListAdapter otherProfileAdapter = + mMultiProfilePagerAdapter.getInactiveListAdapter(); + + if (sameProfileAdapter.getDisplayResolveInfoCount() == 0) { + Log.d(TAG, "No targets in the current profile"); + return false; + } + + if (otherProfileAdapter.getDisplayResolveInfoCount() != 1) { + Log.d(TAG, "Other-profile count: " + otherProfileAdapter.getDisplayResolveInfoCount()); + return false; + } + + if (otherProfileAdapter.allResolveInfosHandleAllWebDataUri()) { + Log.d(TAG, "Other profile is a web browser"); + return false; + } + + if (!sameProfileAdapter.allResolveInfosHandleAllWebDataUri()) { + Log.d(TAG, "Non-browser found in this profile"); + return false; + } + + return true; + } + + /** + * Finishing procedures to be performed after the list has been rebuilt. + * @param rebuildCompleted + * @return true if the activity is finishing and creation should halt. + */ + final boolean postRebuildListInternal(boolean rebuildCompleted) { + int count = mMultiProfilePagerAdapter.getActiveListAdapter().getUnfilteredCount(); + + // We only rebuild asynchronously when we have multiple elements to sort. In the case where + // we're already done, we can check if we should auto-launch immediately. + if (rebuildCompleted && maybeAutolaunchActivity()) { + return true; + } + + setupViewVisibilities(); + + if (shouldShowTabs()) { + setupProfileTabs(); + } + + return false; + } + + private int isPermissionGranted(String permission, int uid) { + return ActivityManager.checkComponentPermission(permission, uid, + /* owningUid= */-1, /* exported= */ true); + } + + /** + * @return {@code true} if a resolved target is autolaunched, otherwise {@code false} + */ + private boolean maybeAutolaunchActivity() { + int numberOfProfiles = mMultiProfilePagerAdapter.getItemCount(); + if (numberOfProfiles == 1 && maybeAutolaunchIfSingleTarget()) { + return true; + } else if (numberOfProfiles == 2 + && mMultiProfilePagerAdapter.getActiveListAdapter().isTabLoaded() + && mMultiProfilePagerAdapter.getInactiveListAdapter().isTabLoaded() + && maybeAutolaunchIfCrossProfileSupported()) { + // TODO(b/280988288): If the ChooserActivity is shown we should consider showing the + // correct intent-picker UIs (e.g., mini-resolver) if it was launched without + // ACTION_SEND. + return true; + } + return false; + } + + private boolean maybeAutolaunchIfSingleTarget() { + int count = mMultiProfilePagerAdapter.getActiveListAdapter().getUnfilteredCount(); + if (count != 1) { + return false; + } + + if (mMultiProfilePagerAdapter.getActiveListAdapter().getOtherProfile() != null) { + return false; + } + + // Only one target, so we're a candidate to auto-launch! + final TargetInfo target = mMultiProfilePagerAdapter.getActiveListAdapter() + .targetInfoForPosition(0, false); + if (shouldAutoLaunchSingleChoice(target)) { + safelyStartActivity(target); + finish(); + return true; + } + return false; + } + + /** + * When we have a personal and a work profile, we auto launch in the following scenario: + * - There is 1 resolved target on each profile + * - That target is the same app on both profiles + * - The target app has permission to communicate cross profiles + * - The target app has declared it supports cross-profile communication via manifest metadata + */ + private boolean maybeAutolaunchIfCrossProfileSupported() { + ResolverListAdapter activeListAdapter = mMultiProfilePagerAdapter.getActiveListAdapter(); + int count = activeListAdapter.getUnfilteredCount(); + if (count != 1) { + return false; + } + ResolverListAdapter inactiveListAdapter = + mMultiProfilePagerAdapter.getInactiveListAdapter(); + if (inactiveListAdapter.getUnfilteredCount() != 1) { + return false; + } + TargetInfo activeProfileTarget = activeListAdapter + .targetInfoForPosition(0, false); + TargetInfo inactiveProfileTarget = inactiveListAdapter.targetInfoForPosition(0, false); + if (!Objects.equals(activeProfileTarget.getResolvedComponentName(), + inactiveProfileTarget.getResolvedComponentName())) { + return false; + } + if (!shouldAutoLaunchSingleChoice(activeProfileTarget)) { + return false; + } + String packageName = activeProfileTarget.getResolvedComponentName().getPackageName(); + if (!canAppInteractCrossProfiles(packageName)) { + return false; + } + + DevicePolicyEventLogger + .createEvent(DevicePolicyEnums.RESOLVER_AUTOLAUNCH_CROSS_PROFILE_TARGET) + .setBoolean(activeListAdapter.getUserHandle() + .equals(getAnnotatedUserHandles().personalProfileUserHandle)) + .setStrings(getMetricsCategory()) + .write(); + safelyStartActivity(activeProfileTarget); + finish(); + return true; + } + + /** + * Returns whether the package has the necessary permissions to interact across profiles on + * behalf of a given user. + * + *

This means meeting the following condition: + *

    + *
  • The app's {@link ApplicationInfo#crossProfile} flag must be true, and at least + * one of the following conditions must be fulfilled
  • + *
  • {@code Manifest.permission.INTERACT_ACROSS_USERS_FULL} granted.
  • + *
  • {@code Manifest.permission.INTERACT_ACROSS_USERS} granted.
  • + *
  • {@code Manifest.permission.INTERACT_ACROSS_PROFILES} granted, or the corresponding + * AppOps {@code android:interact_across_profiles} is set to "allow".
  • + *
+ * + */ + private boolean canAppInteractCrossProfiles(String packageName) { + ApplicationInfo applicationInfo; + try { + applicationInfo = getPackageManager().getApplicationInfo(packageName, 0); + } catch (NameNotFoundException e) { + Log.e(TAG, "Package " + packageName + " does not exist on current user."); + return false; + } + if (!applicationInfo.crossProfile) { + return false; + } + + int packageUid = applicationInfo.uid; + + if (isPermissionGranted(android.Manifest.permission.INTERACT_ACROSS_USERS_FULL, + packageUid) == PackageManager.PERMISSION_GRANTED) { + return true; + } + if (isPermissionGranted(android.Manifest.permission.INTERACT_ACROSS_USERS, packageUid) + == PackageManager.PERMISSION_GRANTED) { + return true; + } + if (PermissionChecker.checkPermissionForPreflight(this, INTERACT_ACROSS_PROFILES, + PID_UNKNOWN, packageUid, packageName) == PackageManager.PERMISSION_GRANTED) { + return true; + } + return false; + } + + private boolean isAutolaunching() { + return !mRegistered && isFinishing(); + } + + private void setupProfileTabs() { + maybeHideDivider(); + TabHost tabHost = findViewById(com.android.internal.R.id.profile_tabhost); + tabHost.setup(); + ViewPager viewPager = findViewById(com.android.internal.R.id.profile_pager); + viewPager.setSaveEnabled(false); + + Button personalButton = (Button) getLayoutInflater().inflate( + R.layout.resolver_profile_tab_button, tabHost.getTabWidget(), false); + personalButton.setText(getPersonalTabLabel()); + personalButton.setContentDescription(getPersonalTabAccessibilityLabel()); + + TabHost.TabSpec tabSpec = tabHost.newTabSpec(TAB_TAG_PERSONAL) + .setContent(com.android.internal.R.id.profile_pager) + .setIndicator(personalButton); + tabHost.addTab(tabSpec); + + Button workButton = (Button) getLayoutInflater().inflate( + R.layout.resolver_profile_tab_button, tabHost.getTabWidget(), false); + workButton.setText(getWorkTabLabel()); + workButton.setContentDescription(getWorkTabAccessibilityLabel()); + + tabSpec = tabHost.newTabSpec(TAB_TAG_WORK) + .setContent(com.android.internal.R.id.profile_pager) + .setIndicator(workButton); + tabHost.addTab(tabSpec); + + TabWidget tabWidget = tabHost.getTabWidget(); + tabWidget.setVisibility(View.VISIBLE); + updateActiveTabStyle(tabHost); + + tabHost.setOnTabChangedListener(tabId -> { + updateActiveTabStyle(tabHost); + if (TAB_TAG_PERSONAL.equals(tabId)) { + viewPager.setCurrentItem(0); + } else { + viewPager.setCurrentItem(1); + } + setupViewVisibilities(); + maybeLogProfileChange(); + onProfileTabSelected(); + DevicePolicyEventLogger + .createEvent(DevicePolicyEnums.RESOLVER_SWITCH_TABS) + .setInt(viewPager.getCurrentItem()) + .setStrings(getMetricsCategory()) + .write(); + }); + + viewPager.setVisibility(View.VISIBLE); + tabHost.setCurrentTab(mMultiProfilePagerAdapter.getCurrentPage()); + mMultiProfilePagerAdapter.setOnProfileSelectedListener( + new MultiProfilePagerAdapter.OnProfileSelectedListener() { + @Override + public void onProfileSelected(int index) { + tabHost.setCurrentTab(index); + resetButtonBar(); + resetCheckedItem(); + } + + @Override + public void onProfilePageStateChanged(int state) { + onHorizontalSwipeStateChanged(state); + } + }); + mOnSwitchOnWorkSelectedListener = () -> { + final View workTab = tabHost.getTabWidget().getChildAt(1); + workTab.setFocusable(true); + workTab.setFocusableInTouchMode(true); + workTab.requestFocus(); + }; + } + + private String getPersonalTabLabel() { + return getSystemService(DevicePolicyManager.class).getResources().getString( + RESOLVER_PERSONAL_TAB, () -> getString(R.string.resolver_personal_tab)); + } + + private String getWorkTabLabel() { + return getSystemService(DevicePolicyManager.class).getResources().getString( + RESOLVER_WORK_TAB, () -> getString(R.string.resolver_work_tab)); + } + + private void maybeHideDivider() { + if (!mIsIntentPicker) { + return; + } + final View divider = findViewById(com.android.internal.R.id.divider); + if (divider == null) { + return; + } + divider.setVisibility(View.GONE); + } + + private void resetCheckedItem() { + if (!mIsIntentPicker) { + return; + } + mLastSelected = ListView.INVALID_POSITION; + ListView inactiveListView = (ListView) mMultiProfilePagerAdapter.getInactiveAdapterView(); + if (inactiveListView.getCheckedItemCount() > 0) { + inactiveListView.setItemChecked(inactiveListView.getCheckedItemPosition(), false); + } + } + + private String getPersonalTabAccessibilityLabel() { + return getSystemService(DevicePolicyManager.class).getResources().getString( + RESOLVER_PERSONAL_TAB_ACCESSIBILITY, + () -> getString(R.string.resolver_personal_tab_accessibility)); + } + + private String getWorkTabAccessibilityLabel() { + return getSystemService(DevicePolicyManager.class).getResources().getString( + RESOLVER_WORK_TAB_ACCESSIBILITY, + () -> getString(R.string.resolver_work_tab_accessibility)); + } + + private static int getAttrColor(Context context, int attr) { + TypedArray ta = context.obtainStyledAttributes(new int[]{attr}); + int colorAccent = ta.getColor(0, 0); + ta.recycle(); + return colorAccent; + } + + private void updateActiveTabStyle(TabHost tabHost) { + int currentTab = tabHost.getCurrentTab(); + TextView selected = (TextView) tabHost.getTabWidget().getChildAt(currentTab); + TextView unselected = (TextView) tabHost.getTabWidget().getChildAt(1 - currentTab); + selected.setSelected(true); + unselected.setSelected(false); + } + + private void setupViewVisibilities() { + ResolverListAdapter activeListAdapter = mMultiProfilePagerAdapter.getActiveListAdapter(); + if (!mMultiProfilePagerAdapter.shouldShowEmptyStateScreen(activeListAdapter)) { + addUseDifferentAppLabelIfNecessary(activeListAdapter); + } + } + + /** + * Updates the button bar container {@code ignoreOffset} layout param. + *

Setting this to {@code true} means that the button bar will be glued to the bottom of + * the screen. + */ + private void setButtonBarIgnoreOffset(boolean ignoreOffset) { + View buttonBarContainer = findViewById(com.android.internal.R.id.button_bar_container); + if (buttonBarContainer != null) { + ResolverDrawerLayout.LayoutParams layoutParams = + (ResolverDrawerLayout.LayoutParams) buttonBarContainer.getLayoutParams(); + layoutParams.ignoreOffset = ignoreOffset; + buttonBarContainer.setLayoutParams(layoutParams); + } + } + + private void setupAdapterListView(ListView listView, ItemClickListener listener) { + listView.setOnItemClickListener(listener); + listView.setOnItemLongClickListener(listener); + + if (mSupportsAlwaysUseOption) { + listView.setChoiceMode(AbsListView.CHOICE_MODE_SINGLE); + } + } + + /** + * Configure the area above the app selection list (title, content preview, etc). + */ + private void maybeCreateHeader(ResolverListAdapter listAdapter) { + if (mHeaderCreatorUser != null + && !listAdapter.getUserHandle().equals(mHeaderCreatorUser)) { + return; + } + if (!shouldShowTabs() + && listAdapter.getCount() == 0 && listAdapter.getPlaceholderCount() == 0) { + final TextView titleView = findViewById(com.android.internal.R.id.title); + if (titleView != null) { + titleView.setVisibility(View.GONE); + } + } + + CharSequence title = mTitle != null + ? mTitle + : getTitleForAction(getTargetIntent(), mDefaultTitleResId); + + if (!TextUtils.isEmpty(title)) { + final TextView titleView = findViewById(com.android.internal.R.id.title); + if (titleView != null) { + titleView.setText(title); + } + setTitle(title); + } + + final ImageView iconView = findViewById(com.android.internal.R.id.icon); + if (iconView != null) { + listAdapter.loadFilteredItemIconTaskAsync(iconView); + } + mHeaderCreatorUser = listAdapter.getUserHandle(); + } + + private void resetAlwaysOrOnceButtonBar() { + // Disable both buttons initially + setAlwaysButtonEnabled(false, ListView.INVALID_POSITION, false); + mOnceButton.setEnabled(false); + + int filteredPosition = mMultiProfilePagerAdapter.getActiveListAdapter() + .getFilteredPosition(); + if (useLayoutWithDefault() && filteredPosition != ListView.INVALID_POSITION) { + setAlwaysButtonEnabled(true, filteredPosition, false); + mOnceButton.setEnabled(true); + // Focus the button if we already have the default option + mOnceButton.requestFocus(); + return; + } + + // When the items load in, if an item was already selected, enable the buttons + ListView currentAdapterView = (ListView) mMultiProfilePagerAdapter.getActiveAdapterView(); + if (currentAdapterView != null + && currentAdapterView.getCheckedItemPosition() != ListView.INVALID_POSITION) { + setAlwaysButtonEnabled(true, currentAdapterView.getCheckedItemPosition(), true); + mOnceButton.setEnabled(true); + } + } + + @Override // ResolverListCommunicator + public final boolean useLayoutWithDefault() { + // We only use the default app layout when the profile of the active user has a + // filtered item. We always show the same default app even in the inactive user profile. + boolean adapterForCurrentUserHasFilteredItem = + mMultiProfilePagerAdapter.getListAdapterForUserHandle( + getAnnotatedUserHandles().tabOwnerUserHandleForLaunch).hasFilteredItem(); + return mSupportsAlwaysUseOption && adapterForCurrentUserHasFilteredItem; + } + + /** + * If {@code retainInOnStop} is set to true, we will not finish ourselves when onStop gets + * called and we are launched in a new task. + */ + protected final void setRetainInOnStop(boolean retainInOnStop) { + mRetainInOnStop = retainInOnStop; + } + + private boolean inactiveListAdapterHasItems() { + if (!shouldShowTabs()) { + return false; + } + return mMultiProfilePagerAdapter.getInactiveListAdapter().getCount() > 0; + } + + final class ItemClickListener implements AdapterView.OnItemClickListener, + AdapterView.OnItemLongClickListener { + @Override + public void onItemClick(AdapterView parent, View view, int position, long id) { + final ListView listView = parent instanceof ListView ? (ListView) parent : null; + if (listView != null) { + position -= listView.getHeaderViewsCount(); + } + if (position < 0) { + // Header views don't count. + return; + } + // If we're still loading, we can't yet enable the buttons. + if (mMultiProfilePagerAdapter.getActiveListAdapter() + .resolveInfoForPosition(position, true) == null) { + return; + } + ListView currentAdapterView = + (ListView) mMultiProfilePagerAdapter.getActiveAdapterView(); + final int checkedPos = currentAdapterView.getCheckedItemPosition(); + final boolean hasValidSelection = checkedPos != ListView.INVALID_POSITION; + if (!useLayoutWithDefault() + && (!hasValidSelection || mLastSelected != checkedPos) + && mAlwaysButton != null) { + setAlwaysButtonEnabled(hasValidSelection, checkedPos, true); + mOnceButton.setEnabled(hasValidSelection); + if (hasValidSelection) { + currentAdapterView.smoothScrollToPosition(checkedPos); + mOnceButton.requestFocus(); + } + mLastSelected = checkedPos; + } else { + startSelected(position, false, true); + } + } + + @Override + public boolean onItemLongClick(AdapterView parent, View view, int position, long id) { + final ListView listView = parent instanceof ListView ? (ListView) parent : null; + if (listView != null) { + position -= listView.getHeaderViewsCount(); + } + if (position < 0) { + // Header views don't count. + return false; + } + ResolveInfo ri = mMultiProfilePagerAdapter.getActiveListAdapter() + .resolveInfoForPosition(position, true); + showTargetDetails(ri); + return true; + } + + } + + /** Determine whether a given match result is considered "specific" in our application. */ + public static final boolean isSpecificUriMatch(int match) { + match = (match & IntentFilter.MATCH_CATEGORY_MASK); + return match >= IntentFilter.MATCH_CATEGORY_HOST + && match <= IntentFilter.MATCH_CATEGORY_PATH; + } + + static final class PickTargetOptionRequest extends PickOptionRequest { + public PickTargetOptionRequest(@Nullable Prompt prompt, Option[] options, + @Nullable Bundle extras) { + super(prompt, options, extras); + } + + @Override + public void onCancel() { + super.onCancel(); + final ResolverActivity ra = (ResolverActivity) getActivity(); + if (ra != null) { + ra.mPickOptionRequest = null; + ra.finish(); + } + } + + @Override + public void onPickOptionResult(boolean finished, Option[] selections, Bundle result) { + super.onPickOptionResult(finished, selections, result); + if (selections.length != 1) { + // TODO In a better world we would filter the UI presented here and let the + // user refine. Maybe later. + return; + } + + final ResolverActivity ra = (ResolverActivity) getActivity(); + if (ra != null) { + final TargetInfo ti = ra.mMultiProfilePagerAdapter.getActiveListAdapter() + .getItem(selections[0].getIndex()); + if (ra.onTargetSelected(ti, false)) { + ra.mPickOptionRequest = null; + ra.finish(); + } + } + } + } + /** + * Returns the {@link UserHandle} to use when querying resolutions for intents in a + * {@link ResolverListController} configured for the provided {@code userHandle}. + */ + protected final UserHandle getQueryIntentsUser(UserHandle userHandle) { + return getAnnotatedUserHandles().getQueryIntentsUser(userHandle); + } + + /** + * Returns the {@link List} of {@link UserHandle} to pass on to the + * {@link ResolverRankerServiceResolverComparator} as per the provided {@code userHandle}. + */ + @VisibleForTesting(visibility = PROTECTED) + public final List getResolverRankerServiceUserHandleList(UserHandle userHandle) { + return getResolverRankerServiceUserHandleListInternal(userHandle); + } + + @VisibleForTesting + protected List getResolverRankerServiceUserHandleListInternal( + UserHandle userHandle) { + List userList = new ArrayList<>(); + userList.add(userHandle); + // Add clonedProfileUserHandle to the list only if we are: + // a. Building the Personal Tab. + // b. CloneProfile exists on the device. + if (userHandle.equals(getAnnotatedUserHandles().personalProfileUserHandle) + && hasCloneProfile()) { + userList.add(getAnnotatedUserHandles().cloneProfileUserHandle); + } + return userList; + } + + private CharSequence getOrLoadDisplayLabel(TargetInfo info) { + if (info.isDisplayResolveInfo()) { + mTargetDataLoader.getOrLoadLabel((DisplayResolveInfo) info); + } + CharSequence displayLabel = info.getDisplayLabel(); + return displayLabel == null ? "" : displayLabel; + } +} diff --git a/java/tests/AndroidManifest.xml b/java/tests/AndroidManifest.xml index 35dc2ee6..03e32c65 100644 --- a/java/tests/AndroidManifest.xml +++ b/java/tests/AndroidManifest.xml @@ -27,6 +27,8 @@ + + Matcher first(final Matcher matcher) { + public static Matcher first(final Matcher matcher) { return new BaseMatcher() { boolean isFirstMatch = true; diff --git a/java/tests/src/com/android/intentresolver/ResolverDataProvider.java b/java/tests/src/com/android/intentresolver/ResolverDataProvider.java index 1f8d9bee..4eb350fc 100644 --- a/java/tests/src/com/android/intentresolver/ResolverDataProvider.java +++ b/java/tests/src/com/android/intentresolver/ResolverDataProvider.java @@ -43,7 +43,7 @@ public class ResolverDataProvider { createResolveInfo(i, UserHandle.USER_CURRENT)); } - static ResolvedComponentInfo createResolvedComponentInfo(int i, + public static ResolvedComponentInfo createResolvedComponentInfo(int i, UserHandle resolvedForUser) { return new ResolvedComponentInfo( createComponentName(i), @@ -59,7 +59,7 @@ public class ResolverDataProvider { createResolveInfo(componentName, UserHandle.USER_CURRENT)); } - static ResolvedComponentInfo createResolvedComponentInfo( + public static ResolvedComponentInfo createResolvedComponentInfo( ComponentName componentName, Intent intent, UserHandle resolvedForUser) { return new ResolvedComponentInfo( componentName, @@ -74,8 +74,8 @@ public class ResolverDataProvider { createResolveInfo(i, USER_SOMEONE_ELSE)); } - static ResolvedComponentInfo createResolvedComponentInfoWithOtherId(int i, - UserHandle resolvedForUser) { + public static ResolvedComponentInfo createResolvedComponentInfoWithOtherId(int i, + UserHandle resolvedForUser) { return new ResolvedComponentInfo( createComponentName(i), createResolverIntent(i), @@ -89,7 +89,7 @@ public class ResolverDataProvider { createResolveInfo(i, userId)); } - static ResolvedComponentInfo createResolvedComponentInfoWithOtherId(int i, + public static ResolvedComponentInfo createResolvedComponentInfoWithOtherId(int i, int userId, UserHandle resolvedForUser) { return new ResolvedComponentInfo( createComponentName(i), diff --git a/java/tests/src/com/android/intentresolver/v2/ChooserActivityOverrideData.java b/java/tests/src/com/android/intentresolver/v2/ChooserActivityOverrideData.java new file mode 100644 index 00000000..32eabbed --- /dev/null +++ b/java/tests/src/com/android/intentresolver/v2/ChooserActivityOverrideData.java @@ -0,0 +1,131 @@ +/* + * Copyright (C) 2021 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.intentresolver.v2; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import android.content.pm.PackageManager; +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; +import com.android.intentresolver.shortcuts.ShortcutLoader; + +import java.util.function.Consumer; +import java.util.function.Function; + +import kotlin.jvm.functions.Function2; + +/** + * Singleton providing overrides to be applied by any {@code IChooserWrapper} used in testing. + * We cannot directly mock the activity created since instrumentation creates it, so instead we use + * this singleton to modify behavior. + */ +public class ChooserActivityOverrideData { + private static ChooserActivityOverrideData sInstance = null; + + public static ChooserActivityOverrideData getInstance() { + if (sInstance == null) { + sInstance = new ChooserActivityOverrideData(); + } + return sInstance; + } + + @SuppressWarnings("Since15") + public Function createPackageManager; + public Function onSafelyStartInternalCallback; + public Function onSafelyStartCallback; + public Function2, ShortcutLoader> + shortcutLoaderFactory = (userHandle, callback) -> null; + public ChooserActivity.ChooserListController resolverListController; + public ChooserActivity.ChooserListController workResolverListController; + public Boolean isVoiceInteraction; + public Cursor resolverCursor; + public boolean resolverForceException; + public ImageLoader imageLoader; + public int alternateProfileSetting; + public Resources resources; + public AnnotatedUserHandles annotatedUserHandles; + public boolean hasCrossProfileIntents; + public boolean isQuietModeEnabled; + public Integer myUserId; + public WorkProfileAvailabilityManager mWorkProfileAvailability; + public CrossProfileIntentsChecker mCrossProfileIntentsChecker; + public PackageManager packageManager; + + public void reset() { + onSafelyStartInternalCallback = null; + isVoiceInteraction = null; + createPackageManager = null; + imageLoader = null; + resolverCursor = null; + resolverForceException = false; + resolverListController = mock(ChooserActivity.ChooserListController.class); + workResolverListController = mock(ChooserActivity.ChooserListController.class); + alternateProfileSetting = 0; + resources = null; + annotatedUserHandles = AnnotatedUserHandles.newBuilder() + .setUserIdOfCallingApp(1234) // Must be non-negative. + .setUserHandleSharesheetLaunchedAs(UserHandle.SYSTEM) + .setPersonalProfileUserHandle(UserHandle.SYSTEM) + .build(); + hasCrossProfileIntents = true; + isQuietModeEnabled = false; + myUserId = null; + packageManager = null; + mWorkProfileAvailability = new WorkProfileAvailabilityManager(null, null, null) { + @Override + public boolean isQuietModeEnabled() { + return isQuietModeEnabled; + } + + @Override + public boolean isWorkProfileUserUnlocked() { + return true; + } + + @Override + public void requestQuietModeEnabled(boolean enabled) { + isQuietModeEnabled = enabled; + } + + @Override + public void markWorkProfileEnabledBroadcastReceived() {} + + @Override + public boolean isWaitingToEnableWorkProfile() { + return false; + } + }; + shortcutLoaderFactory = ((userHandle, resultConsumer) -> null); + + mCrossProfileIntentsChecker = mock(CrossProfileIntentsChecker.class); + when(mCrossProfileIntentsChecker.hasCrossProfileIntents(any(), anyInt(), anyInt())) + .thenAnswer(invocation -> hasCrossProfileIntents); + } + + private ChooserActivityOverrideData() {} +} + diff --git a/java/tests/src/com/android/intentresolver/v2/ChooserWrapperActivity.java b/java/tests/src/com/android/intentresolver/v2/ChooserWrapperActivity.java new file mode 100644 index 00000000..41b31d01 --- /dev/null +++ b/java/tests/src/com/android/intentresolver/v2/ChooserWrapperActivity.java @@ -0,0 +1,280 @@ +/* + * Copyright (C) 2008 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.intentresolver.v2; + +import android.annotation.Nullable; +import android.app.prediction.AppPredictor; +import android.app.usage.UsageStatsManager; +import android.content.ComponentName; +import android.content.ContentResolver; +import android.content.Context; +import android.content.Intent; +import android.content.IntentFilter; +import android.content.pm.PackageManager; +import android.content.pm.ResolveInfo; +import android.content.res.Resources; +import android.database.Cursor; +import android.net.Uri; +import android.os.Bundle; +import android.os.UserHandle; + +import androidx.lifecycle.ViewModelProvider; + +import com.android.intentresolver.AnnotatedUserHandles; +import com.android.intentresolver.ChooserIntegratedDeviceComponents; +import com.android.intentresolver.ChooserListAdapter; +import com.android.intentresolver.ChooserRequestParameters; +import com.android.intentresolver.IChooserWrapper; +import com.android.intentresolver.ResolverListController; +import com.android.intentresolver.TestContentPreviewViewModel; +import com.android.intentresolver.WorkProfileAvailabilityManager; +import com.android.intentresolver.chooser.DisplayResolveInfo; +import com.android.intentresolver.chooser.TargetInfo; +import com.android.intentresolver.emptystate.CrossProfileIntentsChecker; +import com.android.intentresolver.grid.ChooserGridAdapter; +import com.android.intentresolver.icons.TargetDataLoader; +import com.android.intentresolver.shortcuts.ShortcutLoader; +import com.android.internal.logging.nano.MetricsProto.MetricsEvent; + +import java.util.List; +import java.util.function.Consumer; + +/** + * Simple wrapper around chooser activity to be able to initiate it under test. For more + * information, see {@code com.android.internal.app.ChooserWrapperActivity}. + */ +public class ChooserWrapperActivity extends ChooserActivity implements IChooserWrapper { + static final ChooserActivityOverrideData sOverrides = ChooserActivityOverrideData.getInstance(); + private UsageStatsManager mUsm; + + // ResolverActivity (the base class of ChooserActivity) inspects the launched-from UID at + // onCreate and needs to see some non-negative value in the test. + @Override + public int getLaunchedFromUid() { + return 1234; + } + + @Override + public ChooserListAdapter createChooserListAdapter( + Context context, + List payloadIntents, + Intent[] initialIntents, + List rList, + boolean filterLastUsed, + ResolverListController resolverListController, + UserHandle userHandle, + Intent targetIntent, + ChooserRequestParameters chooserRequest, + int maxTargetsPerRow, + TargetDataLoader targetDataLoader) { + PackageManager packageManager = + sOverrides.packageManager == null ? context.getPackageManager() + : sOverrides.packageManager; + return new ChooserListAdapter( + context, + payloadIntents, + initialIntents, + rList, + filterLastUsed, + createListController(userHandle), + userHandle, + targetIntent, + this, + packageManager, + getEventLog(), + chooserRequest, + maxTargetsPerRow, + userHandle, + targetDataLoader); + } + + @Override + public ChooserListAdapter getAdapter() { + return mChooserMultiProfilePagerAdapter.getActiveListAdapter(); + } + + @Override + public ChooserListAdapter getPersonalListAdapter() { + return ((ChooserGridAdapter) mMultiProfilePagerAdapter.getAdapterForIndex(0)) + .getListAdapter(); + } + + @Override + public ChooserListAdapter getWorkListAdapter() { + if (mMultiProfilePagerAdapter.getInactiveListAdapter() == null) { + return null; + } + return ((ChooserGridAdapter) mMultiProfilePagerAdapter.getAdapterForIndex(1)) + .getListAdapter(); + } + + @Override + public boolean getIsSelected() { + return mIsSuccessfullySelected; + } + + @Override + protected ChooserIntegratedDeviceComponents getIntegratedDeviceComponents() { + return new ChooserIntegratedDeviceComponents( + /* editSharingComponent=*/ null, + // An arbitrary pre-installed activity that handles this type of intent: + /* nearbySharingComponent=*/ new ComponentName( + "com.google.android.apps.messaging", + ".ui.conversationlist.ShareIntentActivity")); + } + + @Override + public UsageStatsManager getUsageStatsManager() { + if (mUsm == null) { + mUsm = getSystemService(UsageStatsManager.class); + } + return mUsm; + } + + @Override + public boolean isVoiceInteraction() { + if (sOverrides.isVoiceInteraction != null) { + return sOverrides.isVoiceInteraction; + } + return super.isVoiceInteraction(); + } + + @Override + protected CrossProfileIntentsChecker createCrossProfileIntentsChecker() { + if (sOverrides.mCrossProfileIntentsChecker != null) { + return sOverrides.mCrossProfileIntentsChecker; + } + return super.createCrossProfileIntentsChecker(); + } + + @Override + protected WorkProfileAvailabilityManager createWorkProfileAvailabilityManager() { + if (sOverrides.mWorkProfileAvailability != null) { + return sOverrides.mWorkProfileAvailability; + } + return super.createWorkProfileAvailabilityManager(); + } + + @Override + public void safelyStartActivityInternal(TargetInfo cti, UserHandle user, + @Nullable Bundle options) { + if (sOverrides.onSafelyStartInternalCallback != null + && sOverrides.onSafelyStartInternalCallback.apply(cti)) { + return; + } + super.safelyStartActivityInternal(cti, user, options); + } + + @Override + protected ChooserListController createListController(UserHandle userHandle) { + if (userHandle == UserHandle.SYSTEM) { + return sOverrides.resolverListController; + } + return sOverrides.workResolverListController; + } + + @Override + public PackageManager getPackageManager() { + if (sOverrides.createPackageManager != null) { + return sOverrides.createPackageManager.apply(super.getPackageManager()); + } + return super.getPackageManager(); + } + + @Override + public Resources getResources() { + if (sOverrides.resources != null) { + return sOverrides.resources; + } + return super.getResources(); + } + + @Override + protected ViewModelProvider.Factory createPreviewViewModelFactory() { + return TestContentPreviewViewModel.Companion.wrap( + super.createPreviewViewModelFactory(), + sOverrides.imageLoader); + } + + @Override + public Cursor queryResolver(ContentResolver resolver, Uri uri) { + if (sOverrides.resolverCursor != null) { + return sOverrides.resolverCursor; + } + + if (sOverrides.resolverForceException) { + throw new SecurityException("Test exception handling"); + } + + return super.queryResolver(resolver, uri); + } + + @Override + protected boolean isWorkProfile() { + if (sOverrides.alternateProfileSetting != 0) { + return sOverrides.alternateProfileSetting == MetricsEvent.MANAGED_PROFILE; + } + return super.isWorkProfile(); + } + + @Override + public DisplayResolveInfo createTestDisplayResolveInfo( + Intent originalIntent, + ResolveInfo pri, + CharSequence pLabel, + CharSequence pInfo, + Intent replacementIntent) { + return DisplayResolveInfo.newDisplayResolveInfo( + originalIntent, + pri, + pLabel, + pInfo, + replacementIntent); + } + + @Override + protected AnnotatedUserHandles computeAnnotatedUserHandles() { + return sOverrides.annotatedUserHandles; + } + + @Override + public UserHandle getCurrentUserHandle() { + return mMultiProfilePagerAdapter.getCurrentUserHandle(); + } + + @Override + public Context createContextAsUser(UserHandle user, int flags) { + // return the current context as a work profile doesn't really exist in these tests + return this; + } + + @Override + protected ShortcutLoader createShortcutLoader( + Context context, + AppPredictor appPredictor, + UserHandle userHandle, + IntentFilter targetIntentFilter, + Consumer callback) { + ShortcutLoader shortcutLoader = + sOverrides.shortcutLoaderFactory.invoke(userHandle, callback); + if (shortcutLoader != null) { + return shortcutLoader; + } + return super.createShortcutLoader( + context, appPredictor, userHandle, targetIntentFilter, callback); + } +} diff --git a/java/tests/src/com/android/intentresolver/v2/ResolverActivityTest.java b/java/tests/src/com/android/intentresolver/v2/ResolverActivityTest.java new file mode 100644 index 00000000..f0911833 --- /dev/null +++ b/java/tests/src/com/android/intentresolver/v2/ResolverActivityTest.java @@ -0,0 +1,1105 @@ +/* + * Copyright (C) 2016 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.intentresolver.v2; + +import static androidx.test.espresso.Espresso.onView; +import static androidx.test.espresso.action.ViewActions.click; +import static androidx.test.espresso.action.ViewActions.swipeUp; +import static androidx.test.espresso.assertion.ViewAssertions.matches; +import static androidx.test.espresso.matcher.ViewMatchers.isCompletelyDisplayed; +import static androidx.test.espresso.matcher.ViewMatchers.isDisplayed; +import static androidx.test.espresso.matcher.ViewMatchers.isEnabled; +import static androidx.test.espresso.matcher.ViewMatchers.withId; +import static androidx.test.espresso.matcher.ViewMatchers.withText; +import static com.android.intentresolver.MatcherUtils.first; +import static com.android.intentresolver.v2.ResolverWrapperActivity.sOverrides; +import static org.hamcrest.CoreMatchers.allOf; +import static org.hamcrest.CoreMatchers.is; +import static org.hamcrest.CoreMatchers.not; +import static org.hamcrest.MatcherAssert.assertThat; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.fail; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.when; + +import android.content.Intent; +import android.content.pm.ResolveInfo; +import android.net.Uri; +import android.os.RemoteException; +import android.os.UserHandle; +import android.text.TextUtils; +import android.view.View; +import android.widget.RelativeLayout; +import android.widget.TextView; + +import androidx.test.InstrumentationRegistry; +import androidx.test.espresso.Espresso; +import androidx.test.espresso.NoMatchingViewException; +import androidx.test.rule.ActivityTestRule; +import androidx.test.runner.AndroidJUnit4; + +import com.android.intentresolver.AnnotatedUserHandles; +import com.android.intentresolver.R; +import com.android.intentresolver.ResolvedComponentInfo; +import com.android.intentresolver.ResolverDataProvider; +import com.android.intentresolver.widget.ResolverDrawerLayout; +import com.google.android.collect.Lists; + +import org.junit.Before; +import org.junit.Ignore; +import org.junit.Rule; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.mockito.Mockito; + +import java.util.ArrayList; +import java.util.List; + +/** + * Resolver activity instrumentation tests + */ +@RunWith(AndroidJUnit4.class) +public class ResolverActivityTest { + + private static final UserHandle PERSONAL_USER_HANDLE = androidx.test.platform.app + .InstrumentationRegistry.getInstrumentation().getTargetContext().getUser(); + private static final UserHandle WORK_PROFILE_USER_HANDLE = UserHandle.of(10); + private static final UserHandle CLONE_PROFILE_USER_HANDLE = UserHandle.of(11); + + protected Intent getConcreteIntentForLaunch(Intent clientIntent) { + clientIntent.setClass( + androidx.test.platform.app.InstrumentationRegistry.getInstrumentation().getTargetContext(), + ResolverWrapperActivity.class); + return clientIntent; + } + + @Rule + public ActivityTestRule mActivityRule = + new ActivityTestRule<>(ResolverWrapperActivity.class, false, false); + + @Before + public void setup() { + // TODO: use the other form of `adoptShellPermissionIdentity()` where we explicitly list the + // permissions we require (which we'll read from the manifest at runtime). + androidx.test.platform.app.InstrumentationRegistry + .getInstrumentation() + .getUiAutomation() + .adoptShellPermissionIdentity(); + + sOverrides.reset(); + } + + @Test + public void twoOptionsAndUserSelectsOne() throws InterruptedException { + Intent sendIntent = createSendImageIntent(); + List resolvedComponentInfos = createResolvedComponentsForTest(2, + PERSONAL_USER_HANDLE); + + setupResolverControllers(resolvedComponentInfos); + + final ResolverWrapperActivity activity = mActivityRule.launchActivity(sendIntent); + Espresso.registerIdlingResources(activity.getLabelIdlingResource()); + waitForIdle(); + + assertThat(activity.getAdapter().getCount(), is(2)); + + ResolveInfo[] chosen = new ResolveInfo[1]; + sOverrides.onSafelyStartInternalCallback = result -> { + chosen[0] = result.first.getResolveInfo(); + return true; + }; + + ResolveInfo toChoose = resolvedComponentInfos.get(0).getResolveInfoAt(0); + onView(withText(toChoose.activityInfo.name)) + .perform(click()); + onView(withId(com.android.internal.R.id.button_once)) + .perform(click()); + waitForIdle(); + assertThat(chosen[0], is(toChoose)); + } + + @Ignore // Failing - b/144929805 + @Test + public void setMaxHeight() throws Exception { + Intent sendIntent = createSendImageIntent(); + List resolvedComponentInfos = createResolvedComponentsForTest(2, + PERSONAL_USER_HANDLE); + + setupResolverControllers(resolvedComponentInfos); + waitForIdle(); + + final ResolverWrapperActivity activity = mActivityRule.launchActivity(sendIntent); + final View viewPager = activity.findViewById(com.android.internal.R.id.profile_pager); + final int initialResolverHeight = viewPager.getHeight(); + + activity.runOnUiThread(() -> { + ResolverDrawerLayout layout = (ResolverDrawerLayout) + activity.findViewById( + com.android.internal.R.id.contentPanel); + ((ResolverDrawerLayout.LayoutParams) viewPager.getLayoutParams()).maxHeight + = initialResolverHeight - 1; + // Force a relayout + layout.invalidate(); + layout.requestLayout(); + }); + waitForIdle(); + assertThat("Drawer should be capped at maxHeight", + viewPager.getHeight() == (initialResolverHeight - 1)); + + activity.runOnUiThread(() -> { + ResolverDrawerLayout layout = (ResolverDrawerLayout) + activity.findViewById( + com.android.internal.R.id.contentPanel); + ((ResolverDrawerLayout.LayoutParams) viewPager.getLayoutParams()).maxHeight + = initialResolverHeight + 1; + // Force a relayout + layout.invalidate(); + layout.requestLayout(); + }); + waitForIdle(); + assertThat("Drawer should not change height if its height is less than maxHeight", + viewPager.getHeight() == initialResolverHeight); + } + + @Ignore // Failing - b/144929805 + @Test + public void setShowAtTopToTrue() throws Exception { + Intent sendIntent = createSendImageIntent(); + List resolvedComponentInfos = createResolvedComponentsForTest(2, + PERSONAL_USER_HANDLE); + + setupResolverControllers(resolvedComponentInfos); + waitForIdle(); + + final ResolverWrapperActivity activity = mActivityRule.launchActivity(sendIntent); + final View viewPager = activity.findViewById(com.android.internal.R.id.profile_pager); + final View divider = activity.findViewById(com.android.internal.R.id.divider); + final RelativeLayout profileView = + (RelativeLayout) activity.findViewById(com.android.internal.R.id.profile_button) + .getParent(); + assertThat("Drawer should show at bottom by default", + profileView.getBottom() + divider.getHeight() == viewPager.getTop() + && profileView.getTop() > 0); + + activity.runOnUiThread(() -> { + ResolverDrawerLayout layout = (ResolverDrawerLayout) + activity.findViewById( + com.android.internal.R.id.contentPanel); + layout.setShowAtTop(true); + }); + waitForIdle(); + assertThat("Drawer should show at top with new attribute", + profileView.getBottom() + divider.getHeight() == viewPager.getTop() + && profileView.getTop() == 0); + } + + @Test + public void hasLastChosenActivity() throws Exception { + Intent sendIntent = createSendImageIntent(); + List resolvedComponentInfos = createResolvedComponentsForTest(2, + PERSONAL_USER_HANDLE); + ResolveInfo toChoose = resolvedComponentInfos.get(0).getResolveInfoAt(0); + + setupResolverControllers(resolvedComponentInfos); + when(sOverrides.resolverListController.getLastChosen()) + .thenReturn(resolvedComponentInfos.get(0).getResolveInfoAt(0)); + + final ResolverWrapperActivity activity = mActivityRule.launchActivity(sendIntent); + waitForIdle(); + + // The other entry is filtered to the last used slot + assertThat(activity.getAdapter().getCount(), is(1)); + assertThat(activity.getAdapter().getPlaceholderCount(), is(1)); + + ResolveInfo[] chosen = new ResolveInfo[1]; + sOverrides.onSafelyStartInternalCallback = result -> { + chosen[0] = result.first.getResolveInfo(); + return true; + }; + + onView(withId(com.android.internal.R.id.button_once)).perform(click()); + waitForIdle(); + assertThat(chosen[0], is(toChoose)); + } + + @Test + public void hasOtherProfileOneOption() throws Exception { + List personalResolvedComponentInfos = + createResolvedComponentsForTestWithOtherProfile(2, /* userId */ 10, + PERSONAL_USER_HANDLE); + markOtherProfileAvailability(/* workAvailable= */ true, /* cloneAvailable= */ false); + List workResolvedComponentInfos = createResolvedComponentsForTest(4, + WORK_PROFILE_USER_HANDLE); + setupResolverControllers(personalResolvedComponentInfos, workResolvedComponentInfos); + + ResolveInfo toChoose = personalResolvedComponentInfos.get(1).getResolveInfoAt(0); + Intent sendIntent = createSendImageIntent(); + final ResolverWrapperActivity activity = mActivityRule.launchActivity(sendIntent); + Espresso.registerIdlingResources(activity.getLabelIdlingResource()); + waitForIdle(); + + // The other entry is filtered to the last used slot + assertThat(activity.getAdapter().getCount(), is(1)); + + ResolveInfo[] chosen = new ResolveInfo[1]; + sOverrides.onSafelyStartInternalCallback = result -> { + chosen[0] = result.first.getResolveInfo(); + return true; + }; + // Make a stable copy of the components as the original list may be modified + List stableCopy = + createResolvedComponentsForTestWithOtherProfile(2, /* userId= */ 10, + PERSONAL_USER_HANDLE); + // We pick the first one as there is another one in the work profile side + onView(first(withText(stableCopy.get(1).getResolveInfoAt(0).activityInfo.name))) + .perform(click()); + onView(withId(com.android.internal.R.id.button_once)) + .perform(click()); + waitForIdle(); + assertThat(chosen[0], is(toChoose)); + } + + @Test + public void hasOtherProfileTwoOptionsAndUserSelectsOne() throws Exception { + Intent sendIntent = createSendImageIntent(); + List resolvedComponentInfos = + createResolvedComponentsForTestWithOtherProfile(3, PERSONAL_USER_HANDLE); + ResolveInfo toChoose = resolvedComponentInfos.get(1).getResolveInfoAt(0); + + setupResolverControllers(resolvedComponentInfos); + + final ResolverWrapperActivity activity = mActivityRule.launchActivity(sendIntent); + Espresso.registerIdlingResources(activity.getLabelIdlingResource()); + waitForIdle(); + + // The other entry is filtered to the other profile slot + assertThat(activity.getAdapter().getCount(), is(2)); + + ResolveInfo[] chosen = new ResolveInfo[1]; + sOverrides.onSafelyStartInternalCallback = result -> { + chosen[0] = result.first.getResolveInfo(); + return true; + }; + + // Confirm that the button bar is disabled by default + onView(withId(com.android.internal.R.id.button_once)).check(matches(not(isEnabled()))); + + // Make a stable copy of the components as the original list may be modified + List stableCopy = + createResolvedComponentsForTestWithOtherProfile(2, PERSONAL_USER_HANDLE); + + onView(withText(stableCopy.get(1).getResolveInfoAt(0).activityInfo.name)) + .perform(click()); + onView(withId(com.android.internal.R.id.button_once)).perform(click()); + waitForIdle(); + assertThat(chosen[0], is(toChoose)); + } + + + @Test + public void hasLastChosenActivityAndOtherProfile() throws Exception { + // In this case we prefer the other profile and don't display anything about the last + // chosen activity. + Intent sendIntent = createSendImageIntent(); + List resolvedComponentInfos = + createResolvedComponentsForTestWithOtherProfile(3, PERSONAL_USER_HANDLE); + ResolveInfo toChoose = resolvedComponentInfos.get(1).getResolveInfoAt(0); + + setupResolverControllers(resolvedComponentInfos); + when(sOverrides.resolverListController.getLastChosen()) + .thenReturn(resolvedComponentInfos.get(1).getResolveInfoAt(0)); + + final ResolverWrapperActivity activity = mActivityRule.launchActivity(sendIntent); + Espresso.registerIdlingResources(activity.getLabelIdlingResource()); + waitForIdle(); + + // The other entry is filtered to the other profile slot + assertThat(activity.getAdapter().getCount(), is(2)); + + ResolveInfo[] chosen = new ResolveInfo[1]; + sOverrides.onSafelyStartInternalCallback = result -> { + chosen[0] = result.first.getResolveInfo(); + return true; + }; + + // Confirm that the button bar is disabled by default + onView(withId(com.android.internal.R.id.button_once)).check(matches(not(isEnabled()))); + + // Make a stable copy of the components as the original list may be modified + List stableCopy = + createResolvedComponentsForTestWithOtherProfile(2, PERSONAL_USER_HANDLE); + + onView(withText(stableCopy.get(1).getResolveInfoAt(0).activityInfo.name)) + .perform(click()); + onView(withId(com.android.internal.R.id.button_once)).perform(click()); + waitForIdle(); + assertThat(chosen[0], is(toChoose)); + } + + @Test + public void testWorkTab_displayedWhenWorkProfileUserAvailable() { + Intent sendIntent = createSendImageIntent(); + markOtherProfileAvailability(/* workAvailable= */ true, /* cloneAvailable= */ false); + + mActivityRule.launchActivity(sendIntent); + waitForIdle(); + + onView(withId(com.android.internal.R.id.tabs)).check(matches(isDisplayed())); + } + + @Test + public void testWorkTab_hiddenWhenWorkProfileUserNotAvailable() { + Intent sendIntent = createSendImageIntent(); + + mActivityRule.launchActivity(sendIntent); + waitForIdle(); + + onView(withId(com.android.internal.R.id.tabs)).check(matches(not(isDisplayed()))); + } + + @Test + public void testWorkTab_workTabListPopulatedBeforeGoingToTab() throws InterruptedException { + List personalResolvedComponentInfos = + createResolvedComponentsForTestWithOtherProfile(3, /* userId = */ 10, + PERSONAL_USER_HANDLE); + markOtherProfileAvailability(/* workAvailable= */ true, /* cloneAvailable= */ false); + List workResolvedComponentInfos = createResolvedComponentsForTest(4, + WORK_PROFILE_USER_HANDLE); + setupResolverControllers(personalResolvedComponentInfos, + new ArrayList<>(workResolvedComponentInfos)); + Intent sendIntent = createSendImageIntent(); + + final ResolverWrapperActivity activity = mActivityRule.launchActivity(sendIntent); + waitForIdle(); + + assertThat(activity.getCurrentUserHandle().getIdentifier(), is(0)); + // The work list adapter must be populated in advance before tapping the other tab + assertThat(activity.getWorkListAdapter().getCount(), is(4)); + } + + @Test + public void testWorkTab_workTabUsesExpectedAdapter() { + List personalResolvedComponentInfos = + createResolvedComponentsForTestWithOtherProfile(3, /* userId */ 10, + PERSONAL_USER_HANDLE); + markOtherProfileAvailability(/* workAvailable= */ true, /* cloneAvailable= */ false); + List workResolvedComponentInfos = createResolvedComponentsForTest(4, + WORK_PROFILE_USER_HANDLE); + setupResolverControllers(personalResolvedComponentInfos, workResolvedComponentInfos); + Intent sendIntent = createSendImageIntent(); + markOtherProfileAvailability(/* workAvailable= */ true, /* cloneAvailable= */ false); + + final ResolverWrapperActivity activity = mActivityRule.launchActivity(sendIntent); + waitForIdle(); + onView(withText(R.string.resolver_work_tab)).perform(click()); + + assertThat(activity.getCurrentUserHandle().getIdentifier(), is(10)); + assertThat(activity.getWorkListAdapter().getCount(), is(4)); + } + + @Test + public void testWorkTab_personalTabUsesExpectedAdapter() { + List personalResolvedComponentInfos = + createResolvedComponentsForTestWithOtherProfile(3, PERSONAL_USER_HANDLE); + markOtherProfileAvailability(/* workAvailable= */ true, /* cloneAvailable= */ false); + List workResolvedComponentInfos = createResolvedComponentsForTest(4, + WORK_PROFILE_USER_HANDLE); + setupResolverControllers(personalResolvedComponentInfos, workResolvedComponentInfos); + Intent sendIntent = createSendImageIntent(); + + final ResolverWrapperActivity activity = mActivityRule.launchActivity(sendIntent); + waitForIdle(); + onView(withText(R.string.resolver_work_tab)).perform(click()); + + assertThat(activity.getCurrentUserHandle().getIdentifier(), is(10)); + assertThat(activity.getPersonalListAdapter().getCount(), is(2)); + } + + @Test + public void testWorkTab_workProfileHasExpectedNumberOfTargets() throws InterruptedException { + markOtherProfileAvailability(/* workAvailable= */ true, /* cloneAvailable= */ false); + List personalResolvedComponentInfos = + createResolvedComponentsForTestWithOtherProfile(3, /* userId */ 10, + PERSONAL_USER_HANDLE); + List workResolvedComponentInfos = createResolvedComponentsForTest(4, + WORK_PROFILE_USER_HANDLE); + setupResolverControllers(personalResolvedComponentInfos, workResolvedComponentInfos); + Intent sendIntent = createSendImageIntent(); + + final ResolverWrapperActivity activity = mActivityRule.launchActivity(sendIntent); + waitForIdle(); + + onView(withText(R.string.resolver_work_tab)) + .perform(click()); + waitForIdle(); + assertThat(activity.getWorkListAdapter().getCount(), is(4)); + } + + @Test + public void testWorkTab_selectingWorkTabAppOpensAppInWorkProfile() throws InterruptedException { + markOtherProfileAvailability(/* workAvailable= */ true, /* cloneAvailable= */ false); + List personalResolvedComponentInfos = + createResolvedComponentsForTestWithOtherProfile(3, /* userId */ 10, + PERSONAL_USER_HANDLE); + List workResolvedComponentInfos = createResolvedComponentsForTest(4, + WORK_PROFILE_USER_HANDLE); + setupResolverControllers(personalResolvedComponentInfos, workResolvedComponentInfos); + Intent sendIntent = createSendImageIntent(); + ResolveInfo[] chosen = new ResolveInfo[1]; + sOverrides.onSafelyStartInternalCallback = result -> { + chosen[0] = result.first.getResolveInfo(); + return true; + }; + + mActivityRule.launchActivity(sendIntent); + waitForIdle(); + onView(withText(R.string.resolver_work_tab)) + .perform(click()); + waitForIdle(); + onView(first(allOf(withText(workResolvedComponentInfos.get(0) + .getResolveInfoAt(0).activityInfo.applicationInfo.name), isCompletelyDisplayed()))) + .perform(click()); + onView(withId(com.android.internal.R.id.button_once)) + .perform(click()); + + waitForIdle(); + assertThat(chosen[0], is(workResolvedComponentInfos.get(0).getResolveInfoAt(0))); + } + + @Test + public void testWorkTab_noPersonalApps_workTabHasExpectedNumberOfTargets() + throws InterruptedException { + markOtherProfileAvailability(/* workAvailable= */ true, /* cloneAvailable= */ false); + List personalResolvedComponentInfos = + createResolvedComponentsForTestWithOtherProfile(1, PERSONAL_USER_HANDLE); + List workResolvedComponentInfos = createResolvedComponentsForTest(4, + WORK_PROFILE_USER_HANDLE); + setupResolverControllers(personalResolvedComponentInfos, workResolvedComponentInfos); + Intent sendIntent = createSendImageIntent(); + + final ResolverWrapperActivity activity = mActivityRule.launchActivity(sendIntent); + waitForIdle(); + onView(withText(R.string.resolver_work_tab)) + .perform(click()); + + waitForIdle(); + assertThat(activity.getWorkListAdapter().getCount(), is(4)); + } + + @Test + public void testWorkTab_headerIsVisibleInPersonalTab() { + markOtherProfileAvailability(/* workAvailable= */ true, /* cloneAvailable= */ false); + List personalResolvedComponentInfos = + createResolvedComponentsForTestWithOtherProfile(1, PERSONAL_USER_HANDLE); + List workResolvedComponentInfos = createResolvedComponentsForTest(4, + WORK_PROFILE_USER_HANDLE); + setupResolverControllers(personalResolvedComponentInfos, workResolvedComponentInfos); + Intent sendIntent = createOpenWebsiteIntent(); + + final ResolverWrapperActivity activity = mActivityRule.launchActivity(sendIntent); + waitForIdle(); + TextView headerText = activity.findViewById(com.android.internal.R.id.title); + String initialText = headerText.getText().toString(); + assertFalse("Header text is empty.", initialText.isEmpty()); + assertThat(headerText.getVisibility(), is(View.VISIBLE)); + } + + @Test + public void testWorkTab_switchTabs_headerStaysSame() { + markOtherProfileAvailability(/* workAvailable= */ true, /* cloneAvailable= */ false); + List personalResolvedComponentInfos = + createResolvedComponentsForTestWithOtherProfile(1, PERSONAL_USER_HANDLE); + List workResolvedComponentInfos = createResolvedComponentsForTest(4, + WORK_PROFILE_USER_HANDLE); + setupResolverControllers(personalResolvedComponentInfos, workResolvedComponentInfos); + Intent sendIntent = createOpenWebsiteIntent(); + + final ResolverWrapperActivity activity = mActivityRule.launchActivity(sendIntent); + waitForIdle(); + TextView headerText = activity.findViewById(com.android.internal.R.id.title); + String initialText = headerText.getText().toString(); + onView(withText(R.string.resolver_work_tab)) + .perform(click()); + + waitForIdle(); + String currentText = headerText.getText().toString(); + assertThat(headerText.getVisibility(), is(View.VISIBLE)); + assertThat(String.format("Header text is not the same when switching tabs, personal profile" + + " header was %s but work profile header is %s", initialText, currentText), + TextUtils.equals(initialText, currentText)); + } + + @Test + public void testWorkTab_noPersonalApps_canStartWorkApps() + throws InterruptedException { + markOtherProfileAvailability(/* workAvailable= */ true, /* cloneAvailable= */ false); + List personalResolvedComponentInfos = + createResolvedComponentsForTestWithOtherProfile(3, /* userId= */ 10, + PERSONAL_USER_HANDLE); + List workResolvedComponentInfos = createResolvedComponentsForTest(4, + WORK_PROFILE_USER_HANDLE); + setupResolverControllers(personalResolvedComponentInfos, workResolvedComponentInfos); + Intent sendIntent = createSendImageIntent(); + ResolveInfo[] chosen = new ResolveInfo[1]; + sOverrides.onSafelyStartInternalCallback = result -> { + chosen[0] = result.first.getResolveInfo(); + return true; + }; + + mActivityRule.launchActivity(sendIntent); + waitForIdle(); + onView(withText(R.string.resolver_work_tab)) + .perform(click()); + waitForIdle(); + onView(first(allOf( + withText(workResolvedComponentInfos.get(0) + .getResolveInfoAt(0).activityInfo.applicationInfo.name), + isDisplayed()))) + .perform(click()); + onView(withId(com.android.internal.R.id.button_once)) + .perform(click()); + waitForIdle(); + + assertThat(chosen[0], is(workResolvedComponentInfos.get(0).getResolveInfoAt(0))); + } + + @Test + public void testWorkTab_crossProfileIntentsDisabled_personalToWork_emptyStateShown() { + markOtherProfileAvailability(/* workAvailable= */ true, /* cloneAvailable= */ false); + int workProfileTargets = 4; + List personalResolvedComponentInfos = + createResolvedComponentsForTestWithOtherProfile(3, /* userId */ 10, + PERSONAL_USER_HANDLE); + List workResolvedComponentInfos = + createResolvedComponentsForTest(workProfileTargets, WORK_PROFILE_USER_HANDLE); + sOverrides.hasCrossProfileIntents = false; + setupResolverControllers(personalResolvedComponentInfos, workResolvedComponentInfos); + Intent sendIntent = createSendImageIntent(); + sendIntent.setType("TestType"); + + mActivityRule.launchActivity(sendIntent); + waitForIdle(); + onView(withText(R.string.resolver_work_tab)).perform(click()); + waitForIdle(); + onView(withId(com.android.internal.R.id.contentPanel)) + .perform(swipeUp()); + + onView(withText(R.string.resolver_cross_profile_blocked)) + .check(matches(isDisplayed())); + } + + @Test + public void testWorkTab_workProfileDisabled_emptyStateShown() { + markOtherProfileAvailability(/* workAvailable= */ true, /* cloneAvailable= */ false); + int workProfileTargets = 4; + List personalResolvedComponentInfos = + createResolvedComponentsForTestWithOtherProfile(3, /* userId */ 10, + PERSONAL_USER_HANDLE); + List workResolvedComponentInfos = + createResolvedComponentsForTest(workProfileTargets, WORK_PROFILE_USER_HANDLE); + sOverrides.isQuietModeEnabled = true; + setupResolverControllers(personalResolvedComponentInfos, workResolvedComponentInfos); + Intent sendIntent = createSendImageIntent(); + sendIntent.setType("TestType"); + + mActivityRule.launchActivity(sendIntent); + waitForIdle(); + onView(withId(com.android.internal.R.id.contentPanel)) + .perform(swipeUp()); + onView(withText(R.string.resolver_work_tab)).perform(click()); + waitForIdle(); + + onView(withText(R.string.resolver_turn_on_work_apps)) + .check(matches(isDisplayed())); + } + + @Test + public void testWorkTab_noWorkAppsAvailable_emptyStateShown() { + markOtherProfileAvailability(/* workAvailable= */ true, /* cloneAvailable= */ false); + List personalResolvedComponentInfos = + createResolvedComponentsForTest(3, PERSONAL_USER_HANDLE); + List workResolvedComponentInfos = + createResolvedComponentsForTest(0, WORK_PROFILE_USER_HANDLE); + setupResolverControllers(personalResolvedComponentInfos, workResolvedComponentInfos); + Intent sendIntent = createSendImageIntent(); + sendIntent.setType("TestType"); + + mActivityRule.launchActivity(sendIntent); + waitForIdle(); + onView(withId(com.android.internal.R.id.contentPanel)) + .perform(swipeUp()); + onView(withText(R.string.resolver_work_tab)).perform(click()); + waitForIdle(); + + onView(withText(R.string.resolver_no_work_apps_available)) + .check(matches(isDisplayed())); + } + + @Test + public void testWorkTab_xProfileOff_noAppsAvailable_workOff_xProfileOffEmptyStateShown() { + markOtherProfileAvailability(/* workAvailable= */ true, /* cloneAvailable= */ false); + List personalResolvedComponentInfos = + createResolvedComponentsForTest(3, PERSONAL_USER_HANDLE); + List workResolvedComponentInfos = + createResolvedComponentsForTest(0, WORK_PROFILE_USER_HANDLE); + setupResolverControllers(personalResolvedComponentInfos, workResolvedComponentInfos); + Intent sendIntent = createSendImageIntent(); + sendIntent.setType("TestType"); + sOverrides.isQuietModeEnabled = true; + sOverrides.hasCrossProfileIntents = false; + + mActivityRule.launchActivity(sendIntent); + waitForIdle(); + onView(withId(com.android.internal.R.id.contentPanel)) + .perform(swipeUp()); + onView(withText(R.string.resolver_work_tab)).perform(click()); + waitForIdle(); + + onView(withText(R.string.resolver_cross_profile_blocked)) + .check(matches(isDisplayed())); + } + + @Test + public void testMiniResolver() { + markOtherProfileAvailability(/* workAvailable= */ true, /* cloneAvailable= */ false); + List personalResolvedComponentInfos = + createResolvedComponentsForTest(1, PERSONAL_USER_HANDLE); + List workResolvedComponentInfos = + createResolvedComponentsForTest(1, WORK_PROFILE_USER_HANDLE); + // Personal profile only has a browser + personalResolvedComponentInfos.get(0).getResolveInfoAt(0).handleAllWebDataURI = true; + setupResolverControllers(personalResolvedComponentInfos, workResolvedComponentInfos); + Intent sendIntent = createSendImageIntent(); + sendIntent.setType("TestType"); + + mActivityRule.launchActivity(sendIntent); + waitForIdle(); + onView(withId(com.android.internal.R.id.open_cross_profile)).check(matches(isDisplayed())); + } + + @Test + public void testMiniResolver_noCurrentProfileTarget() { + markOtherProfileAvailability(/* workAvailable= */ true, /* cloneAvailable= */ false); + List personalResolvedComponentInfos = + createResolvedComponentsForTest(0, PERSONAL_USER_HANDLE); + List workResolvedComponentInfos = + createResolvedComponentsForTest(1, WORK_PROFILE_USER_HANDLE); + setupResolverControllers(personalResolvedComponentInfos, workResolvedComponentInfos); + Intent sendIntent = createSendImageIntent(); + sendIntent.setType("TestType"); + + mActivityRule.launchActivity(sendIntent); + waitForIdle(); + + // Need to ensure mini resolver doesn't trigger here. + assertNotMiniResolver(); + } + + private void assertNotMiniResolver() { + try { + onView(withId(com.android.internal.R.id.open_cross_profile)) + .check(matches(isDisplayed())); + } catch (NoMatchingViewException e) { + return; + } + fail("Mini resolver present but shouldn't be"); + } + + @Test + public void testWorkTab_noAppsAvailable_workOff_noAppsAvailableEmptyStateShown() { + markOtherProfileAvailability(/* workAvailable= */ true, /* cloneAvailable= */ false); + List personalResolvedComponentInfos = + createResolvedComponentsForTest(3, PERSONAL_USER_HANDLE); + List workResolvedComponentInfos = + createResolvedComponentsForTest(0, WORK_PROFILE_USER_HANDLE); + setupResolverControllers(personalResolvedComponentInfos, workResolvedComponentInfos); + Intent sendIntent = createSendImageIntent(); + sendIntent.setType("TestType"); + sOverrides.isQuietModeEnabled = true; + + mActivityRule.launchActivity(sendIntent); + waitForIdle(); + onView(withId(com.android.internal.R.id.contentPanel)) + .perform(swipeUp()); + onView(withText(R.string.resolver_work_tab)).perform(click()); + waitForIdle(); + + onView(withText(R.string.resolver_no_work_apps_available)) + .check(matches(isDisplayed())); + } + + @Test + public void testWorkTab_onePersonalTarget_emptyStateOnWorkTarget_doesNotAutoLaunch() { + markOtherProfileAvailability(/* workAvailable= */ true, /* cloneAvailable= */ false); + int workProfileTargets = 4; + List personalResolvedComponentInfos = + createResolvedComponentsForTestWithOtherProfile(2, /* userId */ 10, + PERSONAL_USER_HANDLE); + List workResolvedComponentInfos = + createResolvedComponentsForTest(workProfileTargets, WORK_PROFILE_USER_HANDLE); + sOverrides.hasCrossProfileIntents = false; + setupResolverControllers(personalResolvedComponentInfos, workResolvedComponentInfos); + Intent sendIntent = createSendImageIntent(); + sendIntent.setType("TestType"); + ResolveInfo[] chosen = new ResolveInfo[1]; + sOverrides.onSafelyStartInternalCallback = result -> { + chosen[0] = result.first.getResolveInfo(); + return true; + }; + + mActivityRule.launchActivity(sendIntent); + waitForIdle(); + + assertNull(chosen[0]); + } + + @Test + public void testLayoutWithDefault_withWorkTab_neverShown() throws RemoteException { + markOtherProfileAvailability(/* workAvailable= */ true, /* cloneAvailable= */ false); + + // In this case we prefer the other profile and don't display anything about the last + // chosen activity. + Intent sendIntent = createSendImageIntent(); + List resolvedComponentInfos = + createResolvedComponentsForTest(2, PERSONAL_USER_HANDLE); + + setupResolverControllers(resolvedComponentInfos); + when(sOverrides.resolverListController.getLastChosen()) + .thenReturn(resolvedComponentInfos.get(1).getResolveInfoAt(0)); + + final ResolverWrapperActivity activity = mActivityRule.launchActivity(sendIntent); + Espresso.registerIdlingResources(activity.getLabelIdlingResource()); + waitForIdle(); + + // The other entry is filtered to the last used slot + assertThat(activity.getAdapter().hasFilteredItem(), is(false)); + assertThat(activity.getAdapter().getCount(), is(2)); + assertThat(activity.getAdapter().getPlaceholderCount(), is(2)); + } + + @Test + public void testClonedProfilePresent_personalAdapterIsSetWithPersonalProfile() { + // enable cloneProfile + markOtherProfileAvailability(/* workAvailable= */ false, /* cloneAvailable= */ true); + List resolvedComponentInfos = + createResolvedComponentsWithCloneProfileForTest( + 3, + PERSONAL_USER_HANDLE, + CLONE_PROFILE_USER_HANDLE); + setupResolverControllers(resolvedComponentInfos); + Intent sendIntent = createSendImageIntent(); + + final ResolverWrapperActivity activity = mActivityRule.launchActivity(sendIntent); + waitForIdle(); + + assertThat(activity.getCurrentUserHandle(), is(PERSONAL_USER_HANDLE)); + assertThat(activity.getAdapter().getCount(), is(3)); + } + + @Test + public void testClonedProfilePresent_personalTabUsesExpectedAdapter() { + // enable cloneProfile + markOtherProfileAvailability(/* workAvailable= */ true, /* cloneAvailable= */ true); + List personalResolvedComponentInfos = + createResolvedComponentsWithCloneProfileForTest( + 3, + PERSONAL_USER_HANDLE, + CLONE_PROFILE_USER_HANDLE); + List workResolvedComponentInfos = createResolvedComponentsForTest(4, + WORK_PROFILE_USER_HANDLE); + setupResolverControllers(personalResolvedComponentInfos, workResolvedComponentInfos); + Intent sendIntent = createSendImageIntent(); + + final ResolverWrapperActivity activity = mActivityRule.launchActivity(sendIntent); + waitForIdle(); + + assertThat(activity.getCurrentUserHandle(), is(PERSONAL_USER_HANDLE)); + assertThat(activity.getAdapter().getCount(), is(3)); + } + + @Test + public void testClonedProfilePresent_layoutWithDefault_neverShown() throws Exception { + // enable cloneProfile + markOtherProfileAvailability(/* workAvailable= */ false, /* cloneAvailable= */ true); + Intent sendIntent = createSendImageIntent(); + List resolvedComponentInfos = + createResolvedComponentsWithCloneProfileForTest( + 2, + PERSONAL_USER_HANDLE, + CLONE_PROFILE_USER_HANDLE); + + setupResolverControllers(resolvedComponentInfos); + when(sOverrides.resolverListController.getLastChosen()) + .thenReturn(resolvedComponentInfos.get(0).getResolveInfoAt(0)); + + final ResolverWrapperActivity activity = mActivityRule.launchActivity(sendIntent); + Espresso.registerIdlingResources(activity.getLabelIdlingResource()); + waitForIdle(); + + assertThat(activity.getAdapter().hasFilteredItem(), is(false)); + assertThat(activity.getAdapter().getCount(), is(2)); + assertThat(activity.getAdapter().getPlaceholderCount(), is(2)); + } + + @Test + public void testClonedProfilePresent_alwaysButtonDisabled() throws Exception { + // enable cloneProfile + markOtherProfileAvailability(/* workAvailable= */ false, /* cloneAvailable= */ true); + Intent sendIntent = createSendImageIntent(); + List resolvedComponentInfos = + createResolvedComponentsWithCloneProfileForTest( + 3, + PERSONAL_USER_HANDLE, + CLONE_PROFILE_USER_HANDLE); + + setupResolverControllers(resolvedComponentInfos); + when(sOverrides.resolverListController.getLastChosen()) + .thenReturn(resolvedComponentInfos.get(0).getResolveInfoAt(0)); + + final ResolverWrapperActivity activity = mActivityRule.launchActivity(sendIntent); + waitForIdle(); + + // Confirm that the button bar is disabled by default + onView(withId(com.android.internal.R.id.button_once)).check(matches(not(isEnabled()))); + onView(withId(com.android.internal.R.id.button_always)).check(matches(not(isEnabled()))); + + // Make a stable copy of the components as the original list may be modified + List stableCopy = + createResolvedComponentsForTestWithOtherProfile(2, PERSONAL_USER_HANDLE); + + onView(withText(stableCopy.get(1).getResolveInfoAt(0).activityInfo.name)) + .perform(click()); + + onView(withId(com.android.internal.R.id.button_once)).check(matches(isEnabled())); + onView(withId(com.android.internal.R.id.button_always)).check(matches(not(isEnabled()))); + } + + @Test + public void testClonedProfilePresent_personalProfileActivityIsStartedInCorrectUser() + throws Exception { + // enable cloneProfile + markOtherProfileAvailability(/* workAvailable= */ true, /* cloneAvailable= */ true); + + List personalResolvedComponentInfos = + createResolvedComponentsWithCloneProfileForTest( + 3, + PERSONAL_USER_HANDLE, + CLONE_PROFILE_USER_HANDLE); + List workResolvedComponentInfos = + createResolvedComponentsForTest(3, WORK_PROFILE_USER_HANDLE); + sOverrides.hasCrossProfileIntents = false; + setupResolverControllers(personalResolvedComponentInfos, workResolvedComponentInfos); + Intent sendIntent = createSendImageIntent(); + sendIntent.setType("TestType"); + final UserHandle[] selectedActivityUserHandle = new UserHandle[1]; + sOverrides.onSafelyStartInternalCallback = result -> { + selectedActivityUserHandle[0] = result.second; + return true; + }; + + final ResolverWrapperActivity activity = mActivityRule.launchActivity(sendIntent); + waitForIdle(); + onView(first(allOf(withText(personalResolvedComponentInfos.get(0) + .getResolveInfoAt(0).activityInfo.applicationInfo.name), isCompletelyDisplayed()))) + .perform(click()); + onView(withId(com.android.internal.R.id.button_once)) + .perform(click()); + waitForIdle(); + + assertThat(selectedActivityUserHandle[0], is(activity.getAdapter().getUserHandle())); + } + + @Test + public void testClonedProfilePresent_workProfileActivityIsStartedInCorrectUser() + throws Exception { + // enable cloneProfile + markOtherProfileAvailability(/* workAvailable= */ true, /* cloneAvailable= */ true); + + List personalResolvedComponentInfos = + createResolvedComponentsWithCloneProfileForTest( + 3, + PERSONAL_USER_HANDLE, + CLONE_PROFILE_USER_HANDLE); + List workResolvedComponentInfos = + createResolvedComponentsForTest(3, WORK_PROFILE_USER_HANDLE); + setupResolverControllers(personalResolvedComponentInfos, workResolvedComponentInfos); + Intent sendIntent = createSendImageIntent(); + sendIntent.setType("TestType"); + final UserHandle[] selectedActivityUserHandle = new UserHandle[1]; + sOverrides.onSafelyStartInternalCallback = result -> { + selectedActivityUserHandle[0] = result.second; + return true; + }; + + final ResolverWrapperActivity activity = mActivityRule.launchActivity(sendIntent); + waitForIdle(); + onView(withText(R.string.resolver_work_tab)) + .perform(click()); + waitForIdle(); + onView(first(allOf(withText(workResolvedComponentInfos.get(0) + .getResolveInfoAt(0).activityInfo.applicationInfo.name), isCompletelyDisplayed()))) + .perform(click()); + onView(withId(com.android.internal.R.id.button_once)) + .perform(click()); + waitForIdle(); + + assertThat(selectedActivityUserHandle[0], is(activity.getAdapter().getUserHandle())); + } + + @Test + public void testClonedProfilePresent_personalProfileResolverComparatorHasCorrectUsers() + throws Exception { + // enable cloneProfile + markOtherProfileAvailability(/* workAvailable= */ false, /* cloneAvailable= */ true); + List resolvedComponentInfos = + createResolvedComponentsWithCloneProfileForTest( + 3, + PERSONAL_USER_HANDLE, + CLONE_PROFILE_USER_HANDLE); + setupResolverControllers(resolvedComponentInfos); + Intent sendIntent = createSendImageIntent(); + + final ResolverWrapperActivity activity = mActivityRule.launchActivity(sendIntent); + waitForIdle(); + List result = activity + .getResolverRankerServiceUserHandleList(PERSONAL_USER_HANDLE); + + assertThat(result.containsAll( + Lists.newArrayList(PERSONAL_USER_HANDLE, CLONE_PROFILE_USER_HANDLE)), is(true)); + } + + private Intent createSendImageIntent() { + Intent sendIntent = new Intent(); + sendIntent.setAction(Intent.ACTION_SEND); + sendIntent.putExtra(Intent.EXTRA_TEXT, "testing intent sending"); + sendIntent.setType("image/jpeg"); + return sendIntent; + } + + private Intent createOpenWebsiteIntent() { + Intent sendIntent = new Intent(); + sendIntent.setAction(Intent.ACTION_VIEW); + sendIntent.setData(Uri.parse("https://google.com")); + return sendIntent; + } + + private List createResolvedComponentsForTest(int numberOfResults, + UserHandle resolvedForUser) { + List infoList = new ArrayList<>(numberOfResults); + for (int i = 0; i < numberOfResults; i++) { + infoList.add(ResolverDataProvider.createResolvedComponentInfo(i, resolvedForUser)); + } + return infoList; + } + + private List createResolvedComponentsWithCloneProfileForTest( + int numberOfResults, + UserHandle resolvedForPersonalUser, + UserHandle resolvedForClonedUser) { + List infoList = new ArrayList<>(numberOfResults); + for (int i = 0; i < 1; i++) { + infoList.add(ResolverDataProvider.createResolvedComponentInfo(i, + resolvedForPersonalUser)); + } + for (int i = 1; i < numberOfResults; i++) { + infoList.add(ResolverDataProvider.createResolvedComponentInfo(i, + resolvedForClonedUser)); + } + return infoList; + } + + private List createResolvedComponentsForTestWithOtherProfile( + int numberOfResults, + UserHandle resolvedForUser) { + List infoList = new ArrayList<>(numberOfResults); + for (int i = 0; i < numberOfResults; i++) { + if (i == 0) { + infoList.add(ResolverDataProvider.createResolvedComponentInfoWithOtherId(i, + resolvedForUser)); + } else { + infoList.add(ResolverDataProvider.createResolvedComponentInfo(i, resolvedForUser)); + } + } + return infoList; + } + + private List createResolvedComponentsForTestWithOtherProfile( + int numberOfResults, int userId, UserHandle resolvedForUser) { + List infoList = new ArrayList<>(numberOfResults); + for (int i = 0; i < numberOfResults; i++) { + if (i == 0) { + infoList.add( + ResolverDataProvider.createResolvedComponentInfoWithOtherId(i, userId, + resolvedForUser)); + } else { + infoList.add(ResolverDataProvider.createResolvedComponentInfo(i, resolvedForUser)); + } + } + return infoList; + } + + private void waitForIdle() { + InstrumentationRegistry.getInstrumentation().waitForIdleSync(); + } + + private void markOtherProfileAvailability(boolean workAvailable, boolean cloneAvailable) { + AnnotatedUserHandles.Builder handles = AnnotatedUserHandles.newBuilder(); + handles + .setUserIdOfCallingApp(1234) // Must be non-negative. + .setUserHandleSharesheetLaunchedAs(PERSONAL_USER_HANDLE) + .setPersonalProfileUserHandle(PERSONAL_USER_HANDLE); + if (workAvailable) { + handles.setWorkProfileUserHandle(WORK_PROFILE_USER_HANDLE); + } + if (cloneAvailable) { + handles.setCloneProfileUserHandle(CLONE_PROFILE_USER_HANDLE); + } + sOverrides.annotatedUserHandles = handles.build(); + } + + private void setupResolverControllers( + List personalResolvedComponentInfos) { + setupResolverControllers(personalResolvedComponentInfos, new ArrayList<>()); + } + + private void setupResolverControllers( + List personalResolvedComponentInfos, + List workResolvedComponentInfos) { + when(sOverrides.resolverListController.getResolversForIntentAsUser( + Mockito.anyBoolean(), + Mockito.anyBoolean(), + Mockito.anyBoolean(), + Mockito.isA(List.class), + eq(UserHandle.SYSTEM))) + .thenReturn(new ArrayList<>(personalResolvedComponentInfos)); + when(sOverrides.workResolverListController.getResolversForIntentAsUser( + Mockito.anyBoolean(), + Mockito.anyBoolean(), + Mockito.anyBoolean(), + Mockito.isA(List.class), + eq(UserHandle.SYSTEM))) + .thenReturn(new ArrayList<>(personalResolvedComponentInfos)); + when(sOverrides.workResolverListController.getResolversForIntentAsUser( + Mockito.anyBoolean(), + Mockito.anyBoolean(), + Mockito.anyBoolean(), + Mockito.isA(List.class), + eq(UserHandle.of(10)))) + .thenReturn(new ArrayList<>(workResolvedComponentInfos)); + } +} diff --git a/java/tests/src/com/android/intentresolver/v2/ResolverWrapperActivity.java b/java/tests/src/com/android/intentresolver/v2/ResolverWrapperActivity.java new file mode 100644 index 00000000..610d031e --- /dev/null +++ b/java/tests/src/com/android/intentresolver/v2/ResolverWrapperActivity.java @@ -0,0 +1,285 @@ +/* + * Copyright (C) 2017 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.intentresolver.v2; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import android.annotation.Nullable; +import android.content.Context; +import android.content.Intent; +import android.content.pm.PackageManager; +import android.content.pm.ResolveInfo; +import android.graphics.drawable.Drawable; +import android.os.Bundle; +import android.os.UserHandle; +import android.util.Pair; + +import androidx.annotation.NonNull; +import androidx.test.espresso.idling.CountingIdlingResource; + +import com.android.intentresolver.AnnotatedUserHandles; +import com.android.intentresolver.ResolverListAdapter; +import com.android.intentresolver.ResolverListController; +import com.android.intentresolver.WorkProfileAvailabilityManager; +import com.android.intentresolver.chooser.DisplayResolveInfo; +import com.android.intentresolver.chooser.SelectableTargetInfo; +import com.android.intentresolver.chooser.TargetInfo; +import com.android.intentresolver.emptystate.CrossProfileIntentsChecker; +import com.android.intentresolver.icons.TargetDataLoader; + +import java.util.List; +import java.util.function.Consumer; +import java.util.function.Function; + +/* + * Simple wrapper around chooser activity to be able to initiate it under test + */ +public class ResolverWrapperActivity extends ResolverActivity { + static final OverrideData sOverrides = new OverrideData(); + + private final CountingIdlingResource mLabelIdlingResource = + new CountingIdlingResource("LoadLabelTask"); + + public ResolverWrapperActivity() { + super(/* isIntentPicker= */ true); + } + + public CountingIdlingResource getLabelIdlingResource() { + return mLabelIdlingResource; + } + + @Override + public ResolverListAdapter createResolverListAdapter( + Context context, + List payloadIntents, + Intent[] initialIntents, + List rList, + boolean filterLastUsed, + UserHandle userHandle, + TargetDataLoader targetDataLoader) { + return new ResolverListAdapter( + context, + payloadIntents, + initialIntents, + rList, + filterLastUsed, + createListController(userHandle), + userHandle, + payloadIntents.get(0), // TODO: extract upstream + this, + userHandle, + new TargetDataLoaderWrapper(targetDataLoader, mLabelIdlingResource)); + } + + @Override + protected CrossProfileIntentsChecker createCrossProfileIntentsChecker() { + if (sOverrides.mCrossProfileIntentsChecker != null) { + return sOverrides.mCrossProfileIntentsChecker; + } + return super.createCrossProfileIntentsChecker(); + } + + @Override + protected WorkProfileAvailabilityManager createWorkProfileAvailabilityManager() { + if (sOverrides.mWorkProfileAvailability != null) { + return sOverrides.mWorkProfileAvailability; + } + return super.createWorkProfileAvailabilityManager(); + } + + ResolverListAdapter getAdapter() { + return mMultiProfilePagerAdapter.getActiveListAdapter(); + } + + ResolverListAdapter getPersonalListAdapter() { + return ((ResolverListAdapter) mMultiProfilePagerAdapter.getAdapterForIndex(0)); + } + + ResolverListAdapter getWorkListAdapter() { + if (mMultiProfilePagerAdapter.getInactiveListAdapter() == null) { + return null; + } + return ((ResolverListAdapter) mMultiProfilePagerAdapter.getAdapterForIndex(1)); + } + + @Override + public boolean isVoiceInteraction() { + if (sOverrides.isVoiceInteraction != null) { + return sOverrides.isVoiceInteraction; + } + return super.isVoiceInteraction(); + } + + @Override + public void safelyStartActivityInternal(TargetInfo cti, UserHandle user, + @Nullable Bundle options) { + if (sOverrides.onSafelyStartInternalCallback != null + && sOverrides.onSafelyStartInternalCallback.apply(new Pair<>(cti, user))) { + return; + } + super.safelyStartActivityInternal(cti, user, options); + } + + @Override + protected ResolverListController createListController(UserHandle userHandle) { + if (userHandle == UserHandle.SYSTEM) { + return sOverrides.resolverListController; + } + return sOverrides.workResolverListController; + } + + @Override + public PackageManager getPackageManager() { + if (sOverrides.createPackageManager != null) { + return sOverrides.createPackageManager.apply(super.getPackageManager()); + } + return super.getPackageManager(); + } + + protected UserHandle getCurrentUserHandle() { + return mMultiProfilePagerAdapter.getCurrentUserHandle(); + } + + @Override + protected AnnotatedUserHandles computeAnnotatedUserHandles() { + return sOverrides.annotatedUserHandles; + } + + @Override + public void startActivityAsUser(Intent intent, Bundle options, UserHandle user) { + super.startActivityAsUser(intent, options, user); + } + + @Override + protected List getResolverRankerServiceUserHandleListInternal(UserHandle + userHandle) { + return super.getResolverRankerServiceUserHandleListInternal(userHandle); + } + + /** + * We cannot directly mock the activity created since instrumentation creates it. + *

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

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

+ */ +@RunWith(Parameterized.class) +@HiltAndroidTest +public class UnbundledChooserActivityTest { + + private static FakeEventLog getEventLog(ChooserWrapperActivity activity) { + return (FakeEventLog) activity.mEventLog; + } + + private static final UserHandle PERSONAL_USER_HANDLE = InstrumentationRegistry + .getInstrumentation().getTargetContext().getUser(); + private static final UserHandle WORK_PROFILE_USER_HANDLE = UserHandle.of(10); + private static final UserHandle CLONE_PROFILE_USER_HANDLE = UserHandle.of(11); + + private static final Function DEFAULT_PM = pm -> pm; + private static final Function NO_APP_PREDICTION_SERVICE_PM = + pm -> { + PackageManager mock = Mockito.spy(pm); + when(mock.getAppPredictionServicePackageName()).thenReturn(null); + return mock; + }; + + @Parameterized.Parameters + public static Collection packageManagers() { + return Arrays.asList(new Object[][] { + // Default PackageManager + { DEFAULT_PM }, + // No App Prediction Service + { NO_APP_PREDICTION_SERVICE_PM} + }); + } + + private static final String TEST_MIME_TYPE = "application/TestType"; + + private static final int CONTENT_PREVIEW_IMAGE = 1; + private static final int CONTENT_PREVIEW_FILE = 2; + private static final int CONTENT_PREVIEW_TEXT = 3; + + @Rule(order = 0) + public CheckFlagsRule mCheckFlagsRule = DeviceFlagsValueProvider.createCheckFlagsRule(); + + @Rule(order = 1) + public HiltAndroidRule mHiltAndroidRule = new HiltAndroidRule(this); + + @Rule(order = 2) + public ActivityTestRule mActivityRule = + new ActivityTestRule<>(ChooserWrapperActivity.class, false, false); + + @Before + public void setUp() { + // TODO: use the other form of `adoptShellPermissionIdentity()` where we explicitly list the + // permissions we require (which we'll read from the manifest at runtime). + InstrumentationRegistry + .getInstrumentation() + .getUiAutomation() + .adoptShellPermissionIdentity(); + + cleanOverrideData(); + mHiltAndroidRule.inject(); + } + + private final Function mPackageManagerOverride; + + public UnbundledChooserActivityTest( + Function packageManagerOverride) { + mPackageManagerOverride = packageManagerOverride; + } + + private void setDeviceConfigProperty( + @NonNull String propertyName, + @NonNull String value) { + // TODO: consider running with {@link #runWithShellPermissionIdentity()} to more narrowly + // request WRITE_DEVICE_CONFIG permissions if we get rid of the broad grant we currently + // configure in {@link #setup()}. + // TODO: is it really appropriate that this is always set with makeDefault=true? + boolean valueWasSet = DeviceConfig.setProperty( + DeviceConfig.NAMESPACE_SYSTEMUI, + propertyName, + value, + true /* makeDefault */); + if (!valueWasSet) { + throw new IllegalStateException( + "Could not set " + propertyName + " to " + value); + } + } + + public void cleanOverrideData() { + ChooserActivityOverrideData.getInstance().reset(); + ChooserActivityOverrideData.getInstance().createPackageManager = mPackageManagerOverride; + + setDeviceConfigProperty( + SystemUiDeviceConfigFlags.APPLY_SHARING_APP_LIMITS_IN_SYSUI, + Boolean.toString(true)); + } + + @Test + public void customTitle() throws InterruptedException { + Intent viewIntent = createViewTextIntent(); + List resolvedComponentInfos = createResolvedComponentsForTest(2); + + setupResolverControllers(resolvedComponentInfos); + final IChooserWrapper activity = (IChooserWrapper) mActivityRule.launchActivity( + Intent.createChooser(viewIntent, "chooser test")); + + waitForIdle(); + assertThat(activity.getAdapter().getCount(), is(2)); + assertThat(activity.getAdapter().getServiceTargetCount(), is(0)); + onView(withId(android.R.id.title)).check(matches(withText("chooser test"))); + } + + @Test + public void customTitleIgnoredForSendIntents() throws InterruptedException { + Intent sendIntent = createSendTextIntent(); + List resolvedComponentInfos = createResolvedComponentsForTest(2); + + setupResolverControllers(resolvedComponentInfos); + mActivityRule.launchActivity(Intent.createChooser(sendIntent, "chooser test")); + waitForIdle(); + onView(withId(android.R.id.title)) + .check(matches(withText(R.string.whichSendApplication))); + } + + @Test + public void emptyTitle() throws InterruptedException { + Intent sendIntent = createSendTextIntent(); + List resolvedComponentInfos = createResolvedComponentsForTest(2); + + setupResolverControllers(resolvedComponentInfos); + mActivityRule.launchActivity(Intent.createChooser(sendIntent, null)); + waitForIdle(); + onView(withId(android.R.id.title)) + .check(matches(withText(R.string.whichSendApplication))); + } + + @Test + public void test_shareRichTextWithRichTitle_richTextAndRichTitleDisplayed() { + CharSequence title = new SpannableStringBuilder() + .append("Rich", new UnderlineSpan(), Spannable.SPAN_INCLUSIVE_EXCLUSIVE) + .append( + "Title", + new ForegroundColorSpan(Color.RED), + Spannable.SPAN_INCLUSIVE_EXCLUSIVE); + CharSequence sharedText = new SpannableStringBuilder() + .append( + "Rich", + new BackgroundColorSpan(Color.YELLOW), + Spanned.SPAN_INCLUSIVE_EXCLUSIVE) + .append( + "Text", + new StyleSpan(Typeface.ITALIC), + Spanned.SPAN_INCLUSIVE_EXCLUSIVE); + Intent sendIntent = createSendTextIntent(); + sendIntent.putExtra(Intent.EXTRA_TEXT, sharedText); + sendIntent.putExtra(Intent.EXTRA_TITLE, title); + List resolvedComponentInfos = createResolvedComponentsForTest(2); + setupResolverControllers(resolvedComponentInfos); + + mActivityRule.launchActivity(Intent.createChooser(sendIntent, null)); + waitForIdle(); + + onView(withId(com.android.internal.R.id.content_preview_title)) + .check((view, e) -> { + assertThat(view).isInstanceOf(TextView.class); + CharSequence text = ((TextView) view).getText(); + assertThat(text).isInstanceOf(Spanned.class); + Spanned spanned = (Spanned) text; + assertThat(spanned.getSpans(0, spanned.length(), Object.class)) + .hasLength(2); + assertThat(spanned.getSpans(0, 4, UnderlineSpan.class)).hasLength(1); + assertThat(spanned.getSpans(4, spanned.length(), ForegroundColorSpan.class)) + .hasLength(1); + }); + + onView(withId(com.android.internal.R.id.content_preview_text)) + .check((view, e) -> { + assertThat(view).isInstanceOf(TextView.class); + CharSequence text = ((TextView) view).getText(); + assertThat(text).isInstanceOf(Spanned.class); + Spanned spanned = (Spanned) text; + assertThat(spanned.getSpans(0, spanned.length(), Object.class)) + .hasLength(2); + assertThat(spanned.getSpans(0, 4, BackgroundColorSpan.class)).hasLength(1); + assertThat(spanned.getSpans(4, spanned.length(), StyleSpan.class)).hasLength(1); + }); + } + + @Test + public void emptyPreviewTitleAndThumbnail() throws InterruptedException { + Intent sendIntent = createSendTextIntentWithPreview(null, null); + List resolvedComponentInfos = createResolvedComponentsForTest(2); + + setupResolverControllers(resolvedComponentInfos); + mActivityRule.launchActivity(Intent.createChooser(sendIntent, null)); + waitForIdle(); + onView(withId(com.android.internal.R.id.content_preview_title)) + .check(matches(not(isDisplayed()))); + onView(withId(com.android.internal.R.id.content_preview_thumbnail)) + .check(matches(not(isDisplayed()))); + } + + @Test + public void visiblePreviewTitleWithoutThumbnail() throws InterruptedException { + String previewTitle = "My Content Preview Title"; + Intent sendIntent = createSendTextIntentWithPreview(previewTitle, null); + List resolvedComponentInfos = createResolvedComponentsForTest(2); + + setupResolverControllers(resolvedComponentInfos); + mActivityRule.launchActivity(Intent.createChooser(sendIntent, null)); + waitForIdle(); + onView(withId(com.android.internal.R.id.content_preview_title)) + .check(matches(isDisplayed())); + onView(withId(com.android.internal.R.id.content_preview_title)) + .check(matches(withText(previewTitle))); + onView(withId(com.android.internal.R.id.content_preview_thumbnail)) + .check(matches(not(isDisplayed()))); + } + + @Test + public void visiblePreviewTitleWithInvalidThumbnail() throws InterruptedException { + String previewTitle = "My Content Preview Title"; + Intent sendIntent = createSendTextIntentWithPreview(previewTitle, + Uri.parse("tel:(+49)12345789")); + List resolvedComponentInfos = createResolvedComponentsForTest(2); + + setupResolverControllers(resolvedComponentInfos); + mActivityRule.launchActivity(Intent.createChooser(sendIntent, null)); + waitForIdle(); + onView(withId(com.android.internal.R.id.content_preview_title)) + .check(matches(isDisplayed())); + onView(withId(com.android.internal.R.id.content_preview_thumbnail)) + .check(matches(not(isDisplayed()))); + } + + @Test + public void visiblePreviewTitleAndThumbnail() throws InterruptedException { + String previewTitle = "My Content Preview Title"; + Uri uri = Uri.parse( + "android.resource://com.android.frameworks.coretests/" + + com.android.intentresolver.tests.R.drawable.test320x240); + Intent sendIntent = createSendTextIntentWithPreview(previewTitle, uri); + ChooserActivityOverrideData.getInstance().imageLoader = + createImageLoader(uri, createBitmap()); + List resolvedComponentInfos = createResolvedComponentsForTest(2); + + setupResolverControllers(resolvedComponentInfos); + mActivityRule.launchActivity(Intent.createChooser(sendIntent, null)); + waitForIdle(); + onView(withId(com.android.internal.R.id.content_preview_title)) + .check(matches(isDisplayed())); + onView(withId(com.android.internal.R.id.content_preview_thumbnail)) + .check(matches(isDisplayed())); + } + + @Test @Ignore + public void twoOptionsAndUserSelectsOne() throws InterruptedException { + Intent sendIntent = createSendTextIntent(); + List resolvedComponentInfos = createResolvedComponentsForTest(2); + + setupResolverControllers(resolvedComponentInfos); + + final IChooserWrapper activity = (IChooserWrapper) + mActivityRule.launchActivity(Intent.createChooser(sendIntent, null)); + waitForIdle(); + + assertThat(activity.getAdapter().getCount(), is(2)); + onView(withId(com.android.internal.R.id.profile_button)).check(doesNotExist()); + + ResolveInfo[] chosen = new ResolveInfo[1]; + ChooserActivityOverrideData.getInstance().onSafelyStartInternalCallback = targetInfo -> { + chosen[0] = targetInfo.getResolveInfo(); + return true; + }; + + ResolveInfo toChoose = resolvedComponentInfos.get(0).getResolveInfoAt(0); + onView(withText(toChoose.activityInfo.name)) + .perform(click()); + waitForIdle(); + assertThat(chosen[0], is(toChoose)); + } + + @Test @Ignore + public void fourOptionsStackedIntoOneTarget() throws InterruptedException { + Intent sendIntent = createSendTextIntent(); + + // create just enough targets to ensure the a-z list should be shown + List resolvedComponentInfos = createResolvedComponentsForTest(1); + + // next create 4 targets in a single app that should be stacked into a single target + String packageName = "xxx.yyy"; + String appName = "aaa"; + ComponentName cn = new ComponentName(packageName, appName); + Intent intent = new Intent("fakeIntent"); + List infosToStack = new ArrayList<>(); + for (int i = 0; i < 4; i++) { + ResolveInfo resolveInfo = ResolverDataProvider.createResolveInfo(i, + UserHandle.USER_CURRENT, PERSONAL_USER_HANDLE); + resolveInfo.activityInfo.applicationInfo.name = appName; + resolveInfo.activityInfo.applicationInfo.packageName = packageName; + resolveInfo.activityInfo.packageName = packageName; + resolveInfo.activityInfo.name = "ccc" + i; + infosToStack.add(new ResolvedComponentInfo(cn, intent, resolveInfo)); + } + resolvedComponentInfos.addAll(infosToStack); + + setupResolverControllers(resolvedComponentInfos); + + final IChooserWrapper activity = (IChooserWrapper) + mActivityRule.launchActivity(Intent.createChooser(sendIntent, null)); + waitForIdle(); + + // expect 1 unique targets + 1 group + 4 ranked app targets + assertThat(activity.getAdapter().getCount(), is(6)); + + ResolveInfo[] chosen = new ResolveInfo[1]; + ChooserActivityOverrideData.getInstance().onSafelyStartInternalCallback = targetInfo -> { + chosen[0] = targetInfo.getResolveInfo(); + return true; + }; + + onView(allOf(withText(appName), hasSibling(withText("")))).perform(click()); + waitForIdle(); + + // clicking will launch a dialog to choose the activity within the app + onView(withText(appName)).check(matches(isDisplayed())); + int i = 0; + for (ResolvedComponentInfo rci: infosToStack) { + onView(withText("ccc" + i)).check(matches(isDisplayed())); + ++i; + } + } + + @Test @Ignore + public void updateChooserCountsAndModelAfterUserSelection() throws InterruptedException { + Intent sendIntent = createSendTextIntent(); + List resolvedComponentInfos = createResolvedComponentsForTest(2); + + setupResolverControllers(resolvedComponentInfos); + + final IChooserWrapper activity = (IChooserWrapper) + mActivityRule.launchActivity(Intent.createChooser(sendIntent, null)); + waitForIdle(); + UsageStatsManager usm = activity.getUsageStatsManager(); + verify(ChooserActivityOverrideData.getInstance().resolverListController, times(1)) + .topK(any(List.class), anyInt()); + assertThat(activity.getIsSelected(), is(false)); + ChooserActivityOverrideData.getInstance().onSafelyStartInternalCallback = targetInfo -> { + return true; + }; + ResolveInfo toChoose = resolvedComponentInfos.get(0).getResolveInfoAt(0); + DisplayResolveInfo testDri = + activity.createTestDisplayResolveInfo( + sendIntent, toChoose, "testLabel", "testInfo", sendIntent); + onView(withText(toChoose.activityInfo.name)) + .perform(click()); + waitForIdle(); + verify(ChooserActivityOverrideData.getInstance().resolverListController, times(1)) + .updateChooserCounts(Mockito.anyString(), any(UserHandle.class), + Mockito.anyString()); + verify(ChooserActivityOverrideData.getInstance().resolverListController, times(1)) + .updateModel(testDri); + assertThat(activity.getIsSelected(), is(true)); + } + + @Ignore // b/148158199 + @Test + public void noResultsFromPackageManager() { + setupResolverControllers(null); + Intent sendIntent = createSendTextIntent(); + final ChooserActivity activity = + mActivityRule.launchActivity(Intent.createChooser(sendIntent, null)); + final IChooserWrapper wrapper = (IChooserWrapper) activity; + + waitForIdle(); + assertThat(activity.isFinishing(), is(false)); + + onView(withId(android.R.id.empty)).check(matches(isDisplayed())); + onView(withId(com.android.internal.R.id.profile_pager)).check(matches(not(isDisplayed()))); + InstrumentationRegistry.getInstrumentation().runOnMainSync( + () -> wrapper.getAdapter().handlePackagesChanged() + ); + // backward compatibility. looks like we finish when data is empty after package change + assertThat(activity.isFinishing(), is(true)); + } + + @Test + public void autoLaunchSingleResult() throws InterruptedException { + ResolveInfo[] chosen = new ResolveInfo[1]; + ChooserActivityOverrideData.getInstance().onSafelyStartInternalCallback = targetInfo -> { + chosen[0] = targetInfo.getResolveInfo(); + return true; + }; + + List resolvedComponentInfos = createResolvedComponentsForTest(1); + setupResolverControllers(resolvedComponentInfos); + + Intent sendIntent = createSendTextIntent(); + final ChooserActivity activity = + mActivityRule.launchActivity(Intent.createChooser(sendIntent, null)); + waitForIdle(); + + assertThat(chosen[0], is(resolvedComponentInfos.get(0).getResolveInfoAt(0))); + assertThat(activity.isFinishing(), is(true)); + } + + @Test @Ignore + public void hasOtherProfileOneOption() { + List personalResolvedComponentInfos = + createResolvedComponentsForTestWithOtherProfile(2, /* userId */ 10); + List workResolvedComponentInfos = createResolvedComponentsForTest(4); + setupResolverControllers(personalResolvedComponentInfos, workResolvedComponentInfos); + markOtherProfileAvailability(/* workAvailable= */ true, /* cloneAvailable= */ false); + + ResolveInfo toChoose = personalResolvedComponentInfos.get(1).getResolveInfoAt(0); + Intent sendIntent = createSendTextIntent(); + final IChooserWrapper activity = (IChooserWrapper) + mActivityRule.launchActivity(Intent.createChooser(sendIntent, null)); + waitForIdle(); + + // The other entry is filtered to the other profile slot + assertThat(activity.getAdapter().getCount(), is(1)); + + ResolveInfo[] chosen = new ResolveInfo[1]; + ChooserActivityOverrideData.getInstance().onSafelyStartInternalCallback = targetInfo -> { + chosen[0] = targetInfo.getResolveInfo(); + return true; + }; + + // Make a stable copy of the components as the original list may be modified + List stableCopy = + createResolvedComponentsForTestWithOtherProfile(2, /* userId= */ 10); + waitForIdle(); + + onView(first(withText(stableCopy.get(1).getResolveInfoAt(0).activityInfo.name))) + .perform(click()); + waitForIdle(); + assertThat(chosen[0], is(toChoose)); + } + + @Test @Ignore + public void hasOtherProfileTwoOptionsAndUserSelectsOne() throws Exception { + Intent sendIntent = createSendTextIntent(); + List resolvedComponentInfos = + createResolvedComponentsForTestWithOtherProfile(3); + ResolveInfo toChoose = resolvedComponentInfos.get(1).getResolveInfoAt(0); + + setupResolverControllers(resolvedComponentInfos); + when(ChooserActivityOverrideData.getInstance().resolverListController.getLastChosen()) + .thenReturn(resolvedComponentInfos.get(0).getResolveInfoAt(0)); + + final IChooserWrapper activity = (IChooserWrapper) + mActivityRule.launchActivity(Intent.createChooser(sendIntent, null)); + waitForIdle(); + + // The other entry is filtered to the other profile slot + assertThat(activity.getAdapter().getCount(), is(2)); + + ResolveInfo[] chosen = new ResolveInfo[1]; + ChooserActivityOverrideData.getInstance().onSafelyStartInternalCallback = targetInfo -> { + chosen[0] = targetInfo.getResolveInfo(); + return true; + }; + + // Make a stable copy of the components as the original list may be modified + List stableCopy = + createResolvedComponentsForTestWithOtherProfile(3); + onView(withText(stableCopy.get(1).getResolveInfoAt(0).activityInfo.name)) + .perform(click()); + waitForIdle(); + assertThat(chosen[0], is(toChoose)); + } + + @Test @Ignore + public void hasLastChosenActivityAndOtherProfile() throws Exception { + Intent sendIntent = createSendTextIntent(); + List resolvedComponentInfos = + createResolvedComponentsForTestWithOtherProfile(3); + ResolveInfo toChoose = resolvedComponentInfos.get(1).getResolveInfoAt(0); + + setupResolverControllers(resolvedComponentInfos); + + final IChooserWrapper activity = (IChooserWrapper) + mActivityRule.launchActivity(Intent.createChooser(sendIntent, null)); + waitForIdle(); + + // The other entry is filtered to the last used slot + assertThat(activity.getAdapter().getCount(), is(2)); + + ResolveInfo[] chosen = new ResolveInfo[1]; + ChooserActivityOverrideData.getInstance().onSafelyStartInternalCallback = targetInfo -> { + chosen[0] = targetInfo.getResolveInfo(); + return true; + }; + + // Make a stable copy of the components as the original list may be modified + List stableCopy = + createResolvedComponentsForTestWithOtherProfile(3); + onView(withText(stableCopy.get(1).getResolveInfoAt(0).activityInfo.name)) + .perform(click()); + waitForIdle(); + assertThat(chosen[0], is(toChoose)); + } + + @Test + @Ignore("b/285309527") + public void testFilePlusTextSharing_ExcludeText() { + Uri uri = createTestContentProviderUri(null, "image/png"); + Intent sendIntent = createSendImageIntent(uri); + ChooserActivityOverrideData.getInstance().imageLoader = + createImageLoader(uri, createBitmap()); + sendIntent.putExtra(Intent.EXTRA_TEXT, "https://google.com/search?q=google"); + + List resolvedComponentInfos = Arrays.asList( + ResolverDataProvider.createResolvedComponentInfo( + new ComponentName("org.imageviewer", "ImageTarget"), + sendIntent, PERSONAL_USER_HANDLE), + ResolverDataProvider.createResolvedComponentInfo( + new ComponentName("org.textviewer", "UriTarget"), + new Intent("VIEW_TEXT"), PERSONAL_USER_HANDLE) + ); + + setupResolverControllers(resolvedComponentInfos); + + mActivityRule.launchActivity(Intent.createChooser(sendIntent, null)); + waitForIdle(); + + onView(withId(R.id.include_text_action)) + .check(matches(isDisplayed())) + .perform(click()); + waitForIdle(); + + onView(withId(R.id.content_preview_text)).check(matches(withText("File only"))); + + AtomicReference launchedIntentRef = new AtomicReference<>(); + ChooserActivityOverrideData.getInstance().onSafelyStartInternalCallback = targetInfo -> { + launchedIntentRef.set(targetInfo.getTargetIntent()); + return true; + }; + + onView(withText(resolvedComponentInfos.get(0).getResolveInfoAt(0).activityInfo.name)) + .perform(click()); + waitForIdle(); + assertThat(launchedIntentRef.get().hasExtra(Intent.EXTRA_TEXT)).isFalse(); + } + + @Test + @Ignore("b/285309527") + public void testFilePlusTextSharing_RemoveAndAddBackText() { + Uri uri = createTestContentProviderUri("application/pdf", "image/png"); + Intent sendIntent = createSendImageIntent(uri); + ChooserActivityOverrideData.getInstance().imageLoader = + createImageLoader(uri, createBitmap()); + final String text = "https://google.com/search?q=google"; + sendIntent.putExtra(Intent.EXTRA_TEXT, text); + + List resolvedComponentInfos = Arrays.asList( + ResolverDataProvider.createResolvedComponentInfo( + new ComponentName("org.imageviewer", "ImageTarget"), + sendIntent, PERSONAL_USER_HANDLE), + ResolverDataProvider.createResolvedComponentInfo( + new ComponentName("org.textviewer", "UriTarget"), + new Intent("VIEW_TEXT"), PERSONAL_USER_HANDLE) + ); + + setupResolverControllers(resolvedComponentInfos); + + mActivityRule.launchActivity(Intent.createChooser(sendIntent, null)); + waitForIdle(); + + onView(withId(R.id.include_text_action)) + .check(matches(isDisplayed())) + .perform(click()); + waitForIdle(); + onView(withId(R.id.content_preview_text)).check(matches(withText("File only"))); + + onView(withId(R.id.include_text_action)) + .perform(click()); + waitForIdle(); + + onView(withId(R.id.content_preview_text)).check(matches(withText(text))); + + AtomicReference launchedIntentRef = new AtomicReference<>(); + ChooserActivityOverrideData.getInstance().onSafelyStartInternalCallback = targetInfo -> { + launchedIntentRef.set(targetInfo.getTargetIntent()); + return true; + }; + + onView(withText(resolvedComponentInfos.get(0).getResolveInfoAt(0).activityInfo.name)) + .perform(click()); + waitForIdle(); + assertThat(launchedIntentRef.get().getStringExtra(Intent.EXTRA_TEXT)).isEqualTo(text); + } + + @Test + @Ignore("b/285309527") + public void testFilePlusTextSharing_TextExclusionDoesNotAffectAlternativeIntent() { + Uri uri = createTestContentProviderUri("image/png", null); + Intent sendIntent = createSendImageIntent(uri); + ChooserActivityOverrideData.getInstance().imageLoader = + createImageLoader(uri, createBitmap()); + sendIntent.putExtra(Intent.EXTRA_TEXT, "https://google.com/search?q=google"); + + Intent alternativeIntent = createSendTextIntent(); + final String text = "alternative intent"; + alternativeIntent.putExtra(Intent.EXTRA_TEXT, text); + + List resolvedComponentInfos = Arrays.asList( + ResolverDataProvider.createResolvedComponentInfo( + new ComponentName("org.imageviewer", "ImageTarget"), + sendIntent, PERSONAL_USER_HANDLE), + ResolverDataProvider.createResolvedComponentInfo( + new ComponentName("org.textviewer", "UriTarget"), + alternativeIntent, PERSONAL_USER_HANDLE) + ); + + setupResolverControllers(resolvedComponentInfos); + + mActivityRule.launchActivity(Intent.createChooser(sendIntent, null)); + waitForIdle(); + + onView(withId(R.id.include_text_action)) + .check(matches(isDisplayed())) + .perform(click()); + waitForIdle(); + + AtomicReference launchedIntentRef = new AtomicReference<>(); + ChooserActivityOverrideData.getInstance().onSafelyStartInternalCallback = targetInfo -> { + launchedIntentRef.set(targetInfo.getTargetIntent()); + return true; + }; + + onView(withText(resolvedComponentInfos.get(1).getResolveInfoAt(0).activityInfo.name)) + .perform(click()); + waitForIdle(); + assertThat(launchedIntentRef.get().getStringExtra(Intent.EXTRA_TEXT)).isEqualTo(text); + } + + @Test + @Ignore("b/285309527") + public void testImagePlusTextSharing_failedThumbnailAndExcludedText_textChanges() { + Uri uri = createTestContentProviderUri("image/png", null); + Intent sendIntent = createSendImageIntent(uri); + ChooserActivityOverrideData.getInstance().imageLoader = + new TestPreviewImageLoader(Collections.emptyMap()); + sendIntent.putExtra(Intent.EXTRA_TEXT, "https://google.com/search?q=google"); + + List resolvedComponentInfos = Arrays.asList( + ResolverDataProvider.createResolvedComponentInfo( + new ComponentName("org.imageviewer", "ImageTarget"), + sendIntent, PERSONAL_USER_HANDLE), + ResolverDataProvider.createResolvedComponentInfo( + new ComponentName("org.textviewer", "UriTarget"), + new Intent("VIEW_TEXT"), PERSONAL_USER_HANDLE) + ); + + setupResolverControllers(resolvedComponentInfos); + + mActivityRule.launchActivity(Intent.createChooser(sendIntent, null)); + waitForIdle(); + + onView(withId(R.id.include_text_action)) + .check(matches(isDisplayed())) + .perform(click()); + waitForIdle(); + + onView(withId(R.id.image_view)) + .check(matches(withEffectiveVisibility(ViewMatchers.Visibility.GONE))); + onView(withId(R.id.content_preview_text)) + .check(matches(allOf(isDisplayed(), withText("Image only")))); + } + + @Test + public void copyTextToClipboard() { + Intent sendIntent = createSendTextIntent(); + List resolvedComponentInfos = createResolvedComponentsForTest(2); + + setupResolverControllers(resolvedComponentInfos); + + final ChooserActivity activity = + mActivityRule.launchActivity(Intent.createChooser(sendIntent, null)); + waitForIdle(); + + onView(withId(R.id.copy)).check(matches(isDisplayed())); + onView(withId(R.id.copy)).perform(click()); + ClipboardManager clipboard = (ClipboardManager) activity.getSystemService( + Context.CLIPBOARD_SERVICE); + ClipData clipData = clipboard.getPrimaryClip(); + assertThat(clipData).isNotNull(); + assertThat(clipData.getItemAt(0).getText()).isEqualTo("testing intent sending"); + + ClipDescription clipDescription = clipData.getDescription(); + assertThat("text/plain", is(clipDescription.getMimeType(0))); + + assertEquals(mActivityRule.getActivityResult().getResultCode(), RESULT_OK); + } + + @Test + public void copyTextToClipboardLogging() { + Intent sendIntent = createSendTextIntent(); + List resolvedComponentInfos = createResolvedComponentsForTest(2); + + setupResolverControllers(resolvedComponentInfos); + + ChooserWrapperActivity activity = + mActivityRule.launchActivity(Intent.createChooser(sendIntent, null)); + waitForIdle(); + + onView(withId(R.id.copy)).check(matches(isDisplayed())); + onView(withId(R.id.copy)).perform(click()); + FakeEventLog eventLog = getEventLog(activity); + assertThat(eventLog.getActionSelected()) + .isEqualTo(new FakeEventLog.ActionSelected( + /* targetType = */ EventLog.SELECTION_TYPE_COPY)); + } + + @Test + @Ignore + public void testNearbyShareLogging() throws Exception { + Intent sendIntent = createSendTextIntent(); + List resolvedComponentInfos = createResolvedComponentsForTest(2); + + setupResolverControllers(resolvedComponentInfos); + + mActivityRule.launchActivity(Intent.createChooser(sendIntent, null)); + waitForIdle(); + + onView(withId(com.android.internal.R.id.chooser_nearby_button)) + .check(matches(isDisplayed())); + onView(withId(com.android.internal.R.id.chooser_nearby_button)).perform(click()); + + // TODO(b/211669337): Determine the expected SHARESHEET_DIRECT_LOAD_COMPLETE events. + } + + + + @Test @Ignore + public void testEditImageLogs() { + Uri uri = createTestContentProviderUri("image/png", null); + Intent sendIntent = createSendImageIntent(uri); + ChooserActivityOverrideData.getInstance().imageLoader = + createImageLoader(uri, createBitmap()); + + List resolvedComponentInfos = createResolvedComponentsForTest(2); + + setupResolverControllers(resolvedComponentInfos); + + mActivityRule.launchActivity(Intent.createChooser(sendIntent, null)); + waitForIdle(); + + onView(withId(com.android.internal.R.id.chooser_edit_button)).check(matches(isDisplayed())); + onView(withId(com.android.internal.R.id.chooser_edit_button)).perform(click()); + + // TODO(b/211669337): Determine the expected SHARESHEET_DIRECT_LOAD_COMPLETE events. + } + + + @Test + public void oneVisibleImagePreview() { + Uri uri = createTestContentProviderUri("image/png", null); + + ArrayList uris = new ArrayList<>(); + uris.add(uri); + + Intent sendIntent = createSendUriIntentWithPreview(uris); + ChooserActivityOverrideData.getInstance().imageLoader = + createImageLoader(uri, createWideBitmap()); + + List resolvedComponentInfos = createResolvedComponentsForTest(2); + + setupResolverControllers(resolvedComponentInfos); + mActivityRule.launchActivity(Intent.createChooser(sendIntent, null)); + waitForIdle(); + onView(withId(R.id.scrollable_image_preview)) + .check((view, exception) -> { + if (exception != null) { + throw exception; + } + RecyclerView recyclerView = (RecyclerView) view; + assertThat(recyclerView.getAdapter().getItemCount(), is(1)); + assertThat(recyclerView.getChildCount(), is(1)); + View imageView = recyclerView.getChildAt(0); + Rect rect = new Rect(); + boolean isPartiallyVisible = imageView.getGlobalVisibleRect(rect); + assertThat( + "image preview view is not fully visible", + isPartiallyVisible + && rect.width() == imageView.getWidth() + && rect.height() == imageView.getHeight()); + }); + } + + @Test + public void allThumbnailsFailedToLoad_hidePreview() { + Uri uri = createTestContentProviderUri("image/jpg", null); + + ArrayList uris = new ArrayList<>(); + uris.add(uri); + uris.add(uri); + + Intent sendIntent = createSendUriIntentWithPreview(uris); + ChooserActivityOverrideData.getInstance().imageLoader = + new TestPreviewImageLoader(Collections.emptyMap()); + + List resolvedComponentInfos = createResolvedComponentsForTest(2); + + setupResolverControllers(resolvedComponentInfos); + mActivityRule.launchActivity(Intent.createChooser(sendIntent, null)); + waitForIdle(); + onView(withId(R.id.scrollable_image_preview)) + .check(matches(withEffectiveVisibility(ViewMatchers.Visibility.GONE))); + } + + @Test + public void testSlowUriMetadata_fallbackToFilePreview() throws InterruptedException { + Uri uri = createTestContentProviderUri( + "application/pdf", "image/png", /*streamTypeTimeout=*/4_000); + ArrayList uris = new ArrayList<>(1); + uris.add(uri); + Intent sendIntent = createSendUriIntentWithPreview(uris); + ChooserActivityOverrideData.getInstance().imageLoader = + createImageLoader(uri, createBitmap()); + + List resolvedComponentInfos = createResolvedComponentsForTest(2); + + setupResolverControllers(resolvedComponentInfos); + assertThat(launchActivityWithTimeout(Intent.createChooser(sendIntent, null), 2_000)) + .isTrue(); + waitForIdle(); + + onView(withId(R.id.content_preview_filename)).check(matches(isDisplayed())); + onView(withId(R.id.content_preview_filename)).check(matches(withText("image.png"))); + onView(withId(R.id.content_preview_file_icon)).check(matches(isDisplayed())); + } + + @Test + public void testSendManyFilesWithSmallMetadataDelayAndOneImage_fallbackToFilePreviewUi() + throws InterruptedException { + Uri fileUri = createTestContentProviderUri( + "application/pdf", "application/pdf", /*streamTypeTimeout=*/150); + Uri imageUri = createTestContentProviderUri("application/pdf", "image/png"); + ArrayList uris = new ArrayList<>(50); + for (int i = 0; i < 49; i++) { + uris.add(fileUri); + } + uris.add(imageUri); + Intent sendIntent = createSendUriIntentWithPreview(uris); + ChooserActivityOverrideData.getInstance().imageLoader = + createImageLoader(imageUri, createBitmap()); + + List resolvedComponentInfos = createResolvedComponentsForTest(2); + setupResolverControllers(resolvedComponentInfos); + assertThat(launchActivityWithTimeout(Intent.createChooser(sendIntent, null), 2_000)) + .isTrue(); + + waitForIdle(); + + onView(withId(R.id.content_preview_filename)).check(matches(isDisplayed())); + onView(withId(R.id.content_preview_filename)).check(matches(withText("image.png"))); + onView(withId(R.id.content_preview_file_icon)).check(matches(isDisplayed())); + } + + @Test + public void testManyVisibleImagePreview_ScrollableImagePreview() { + Uri uri = createTestContentProviderUri("image/png", null); + + ArrayList uris = new ArrayList<>(); + uris.add(uri); + uris.add(uri); + uris.add(uri); + uris.add(uri); + uris.add(uri); + uris.add(uri); + uris.add(uri); + uris.add(uri); + uris.add(uri); + uris.add(uri); + + Intent sendIntent = createSendUriIntentWithPreview(uris); + ChooserActivityOverrideData.getInstance().imageLoader = + createImageLoader(uri, createBitmap()); + + List resolvedComponentInfos = createResolvedComponentsForTest(2); + + setupResolverControllers(resolvedComponentInfos); + mActivityRule.launchActivity(Intent.createChooser(sendIntent, null)); + waitForIdle(); + onView(withId(R.id.scrollable_image_preview)) + .perform(RecyclerViewActions.scrollToLastPosition()) + .check((view, exception) -> { + if (exception != null) { + throw exception; + } + RecyclerView recyclerView = (RecyclerView) view; + assertThat(recyclerView.getAdapter().getItemCount(), is(uris.size())); + }); + } + + @Test + public void testPartiallyLoadedMetadata_previewIsShownForTheLoadedPart() + throws InterruptedException { + Uri imgOneUri = createTestContentProviderUri("image/png", null); + Uri imgTwoUri = createTestContentProviderUri("image/png", null) + .buildUpon() + .path("image-2.png") + .build(); + Uri docUri = createTestContentProviderUri("application/pdf", "image/png", 3_000); + ArrayList uris = new ArrayList<>(2); + // two large previews to fill the screen and be presented right away and one + // document that would be delayed by the URI metadata reading + uris.add(imgOneUri); + uris.add(imgTwoUri); + uris.add(docUri); + + Intent sendIntent = createSendUriIntentWithPreview(uris); + Map bitmaps = new HashMap<>(); + bitmaps.put(imgOneUri, createWideBitmap(Color.RED)); + bitmaps.put(imgTwoUri, createWideBitmap(Color.GREEN)); + bitmaps.put(docUri, createWideBitmap(Color.BLUE)); + ChooserActivityOverrideData.getInstance().imageLoader = + new TestPreviewImageLoader(bitmaps); + + List resolvedComponentInfos = createResolvedComponentsForTest(2); + setupResolverControllers(resolvedComponentInfos); + + assertThat(launchActivityWithTimeout(Intent.createChooser(sendIntent, null), 1_000)) + .isTrue(); + waitForIdle(); + + onView(withId(R.id.scrollable_image_preview)) + .check((view, exception) -> { + if (exception != null) { + throw exception; + } + RecyclerView recyclerView = (RecyclerView) view; + assertThat(recyclerView.getChildCount()).isAtLeast(1); + // the first view is a preview + View imageView = recyclerView.getChildAt(0).findViewById(R.id.image); + assertThat(imageView).isNotNull(); + }) + .perform(RecyclerViewActions.scrollToLastPosition()) + .check((view, exception) -> { + if (exception != null) { + throw exception; + } + RecyclerView recyclerView = (RecyclerView) view; + assertThat(recyclerView.getChildCount()).isAtLeast(1); + // check that the last view is a loading indicator + View loadingIndicator = + recyclerView.getChildAt(recyclerView.getChildCount() - 1); + assertThat(loadingIndicator).isNotNull(); + }); + waitForIdle(); + } + + @Test + public void testImageAndTextPreview() { + final Uri uri = createTestContentProviderUri("image/png", null); + final String sharedText = "text-" + System.currentTimeMillis(); + + ArrayList uris = new ArrayList<>(); + uris.add(uri); + + Intent sendIntent = createSendUriIntentWithPreview(uris); + sendIntent.putExtra(Intent.EXTRA_TEXT, sharedText); + ChooserActivityOverrideData.getInstance().imageLoader = + createImageLoader(uri, createBitmap()); + + List resolvedComponentInfos = createResolvedComponentsForTest(2); + + setupResolverControllers(resolvedComponentInfos); + mActivityRule.launchActivity(Intent.createChooser(sendIntent, null)); + waitForIdle(); + onView(withText(sharedText)) + .check(matches(isDisplayed())); + } + + @Test + public void test_shareImageWithRichText_RichTextIsDisplayed() { + final Uri uri = createTestContentProviderUri("image/png", null); + final CharSequence sharedText = new SpannableStringBuilder() + .append( + "text-", + new StyleSpan(Typeface.BOLD_ITALIC), + Spannable.SPAN_INCLUSIVE_EXCLUSIVE) + .append( + Long.toString(System.currentTimeMillis()), + new ForegroundColorSpan(Color.RED), + Spanned.SPAN_INCLUSIVE_EXCLUSIVE); + + ArrayList uris = new ArrayList<>(); + uris.add(uri); + + Intent sendIntent = createSendUriIntentWithPreview(uris); + sendIntent.putExtra(Intent.EXTRA_TEXT, sharedText); + ChooserActivityOverrideData.getInstance().imageLoader = + createImageLoader(uri, createBitmap()); + + List resolvedComponentInfos = createResolvedComponentsForTest(2); + + setupResolverControllers(resolvedComponentInfos); + mActivityRule.launchActivity(Intent.createChooser(sendIntent, null)); + waitForIdle(); + onView(withText(sharedText.toString())) + .check(matches(isDisplayed())) + .check((view, e) -> { + if (e != null) { + throw e; + } + assertThat(view).isInstanceOf(TextView.class); + CharSequence text = ((TextView) view).getText(); + assertThat(text).isInstanceOf(Spanned.class); + Spanned spanned = (Spanned) text; + Object[] spans = spanned.getSpans(0, text.length(), Object.class); + assertThat(spans).hasLength(2); + assertThat(spanned.getSpans(0, 5, StyleSpan.class)).hasLength(1); + assertThat(spanned.getSpans(5, text.length(), ForegroundColorSpan.class)) + .hasLength(1); + }); + } + + @Test + public void testTextPreviewWhenTextIsSharedWithMultipleImages() { + final Uri uri = createTestContentProviderUri("image/png", null); + final String sharedText = "text-" + System.currentTimeMillis(); + + ArrayList uris = new ArrayList<>(); + uris.add(uri); + uris.add(uri); + + Intent sendIntent = createSendUriIntentWithPreview(uris); + sendIntent.putExtra(Intent.EXTRA_TEXT, sharedText); + ChooserActivityOverrideData.getInstance().imageLoader = + createImageLoader(uri, createBitmap()); + + List resolvedComponentInfos = createResolvedComponentsForTest(2); + + when( + ChooserActivityOverrideData + .getInstance() + .resolverListController + .getResolversForIntentAsUser( + Mockito.anyBoolean(), + Mockito.anyBoolean(), + Mockito.anyBoolean(), + Mockito.isA(List.class), + Mockito.any(UserHandle.class))) + .thenReturn(resolvedComponentInfos); + mActivityRule.launchActivity(Intent.createChooser(sendIntent, null)); + waitForIdle(); + onView(withText(sharedText)).check(matches(isDisplayed())); + } + + @Test + public void testOnCreateLogging() { + Intent sendIntent = createSendTextIntent(); + sendIntent.setType(TEST_MIME_TYPE); + + ChooserWrapperActivity activity = + mActivityRule.launchActivity(Intent.createChooser(sendIntent, "logger test")); + waitForIdle(); + + FakeEventLog eventLog = getEventLog(activity); + FakeEventLog.ChooserActivityShown event = eventLog.getChooserActivityShown(); + assertThat(event).isNotNull(); + assertThat(event.isWorkProfile()).isFalse(); + assertThat(event.getTargetMimeType()).isEqualTo(TEST_MIME_TYPE); + } + + @Test + public void testOnCreateLoggingFromWorkProfile() { + Intent sendIntent = createSendTextIntent(); + sendIntent.setType(TEST_MIME_TYPE); + ChooserActivityOverrideData.getInstance().alternateProfileSetting = + MetricsEvent.MANAGED_PROFILE; + + ChooserWrapperActivity activity = + mActivityRule.launchActivity(Intent.createChooser(sendIntent, "logger test")); + waitForIdle(); + + FakeEventLog eventLog = getEventLog(activity); + FakeEventLog.ChooserActivityShown event = eventLog.getChooserActivityShown(); + assertThat(event).isNotNull(); + assertThat(event.isWorkProfile()).isTrue(); + assertThat(event.getTargetMimeType()).isEqualTo(TEST_MIME_TYPE); + } + + @Test + public void testEmptyPreviewLogging() { + Intent sendIntent = createSendTextIntentWithPreview(null, null); + + ChooserWrapperActivity activity = + mActivityRule.launchActivity(Intent.createChooser(sendIntent, + "empty preview logger test")); + waitForIdle(); + + FakeEventLog eventLog = getEventLog(activity); + FakeEventLog.ChooserActivityShown event = eventLog.getChooserActivityShown(); + assertThat(event).isNotNull(); + assertThat(event.isWorkProfile()).isFalse(); + assertThat(event.getTargetMimeType()).isNull(); + } + + @Test + public void testTitlePreviewLogging() { + Intent sendIntent = createSendTextIntentWithPreview("TestTitle", null); + + List resolvedComponentInfos = createResolvedComponentsForTest(2); + + setupResolverControllers(resolvedComponentInfos); + + ChooserWrapperActivity activity = + mActivityRule.launchActivity(Intent.createChooser(sendIntent, null)); + waitForIdle(); + + FakeEventLog eventLog = getEventLog(activity); + assertThat(eventLog.getActionShareWithPreview()) + .isEqualTo(new FakeEventLog.ActionShareWithPreview( + /* previewType = */ CONTENT_PREVIEW_TEXT)); + } + + @Test + public void testImagePreviewLogging() { + Uri uri = createTestContentProviderUri("image/png", null); + + ArrayList uris = new ArrayList<>(); + uris.add(uri); + + Intent sendIntent = createSendUriIntentWithPreview(uris); + ChooserActivityOverrideData.getInstance().imageLoader = + createImageLoader(uri, createBitmap()); + + List resolvedComponentInfos = createResolvedComponentsForTest(2); + + setupResolverControllers(resolvedComponentInfos); + + ChooserWrapperActivity activity = + mActivityRule.launchActivity(Intent.createChooser(sendIntent, null)); + waitForIdle(); + + FakeEventLog eventLog = getEventLog(activity); + assertThat(eventLog.getActionShareWithPreview()) + .isEqualTo(new FakeEventLog.ActionShareWithPreview( + /* previewType = */ CONTENT_PREVIEW_IMAGE)); + } + + @Test + public void oneVisibleFilePreview() throws InterruptedException { + Uri uri = Uri.parse("content://com.android.frameworks.coretests/app.pdf"); + + ArrayList uris = new ArrayList<>(); + uris.add(uri); + + Intent sendIntent = createSendUriIntentWithPreview(uris); + + List resolvedComponentInfos = createResolvedComponentsForTest(2); + + setupResolverControllers(resolvedComponentInfos); + mActivityRule.launchActivity(Intent.createChooser(sendIntent, null)); + waitForIdle(); + onView(withId(R.id.content_preview_filename)).check(matches(isDisplayed())); + onView(withId(R.id.content_preview_filename)).check(matches(withText("app.pdf"))); + onView(withId(R.id.content_preview_file_icon)).check(matches(isDisplayed())); + } + + + @Test + public void moreThanOneVisibleFilePreview() throws InterruptedException { + Uri uri = Uri.parse("content://com.android.frameworks.coretests/app.pdf"); + + ArrayList uris = new ArrayList<>(); + uris.add(uri); + uris.add(uri); + uris.add(uri); + + Intent sendIntent = createSendUriIntentWithPreview(uris); + + List resolvedComponentInfos = createResolvedComponentsForTest(2); + + setupResolverControllers(resolvedComponentInfos); + mActivityRule.launchActivity(Intent.createChooser(sendIntent, null)); + waitForIdle(); + onView(withId(R.id.content_preview_filename)).check(matches(isDisplayed())); + onView(withId(R.id.content_preview_filename)).check(matches(withText("app.pdf"))); + onView(withId(R.id.content_preview_more_files)).check(matches(isDisplayed())); + onView(withId(R.id.content_preview_more_files)).check(matches(withText("+ 2 more files"))); + onView(withId(R.id.content_preview_file_icon)).check(matches(isDisplayed())); + } + + @Test + public void contentProviderThrowSecurityException() throws InterruptedException { + Uri uri = Uri.parse("content://com.android.frameworks.coretests/app.pdf"); + + ArrayList uris = new ArrayList<>(); + uris.add(uri); + + Intent sendIntent = createSendUriIntentWithPreview(uris); + + List resolvedComponentInfos = createResolvedComponentsForTest(2); + setupResolverControllers(resolvedComponentInfos); + + ChooserActivityOverrideData.getInstance().resolverForceException = true; + + mActivityRule.launchActivity(Intent.createChooser(sendIntent, null)); + waitForIdle(); + onView(withId(R.id.content_preview_filename)).check(matches(isDisplayed())); + onView(withId(R.id.content_preview_filename)).check(matches(withText("app.pdf"))); + onView(withId(R.id.content_preview_file_icon)).check(matches(isDisplayed())); + } + + @Test + public void contentProviderReturnsNoColumns() throws InterruptedException { + Uri uri = Uri.parse("content://com.android.frameworks.coretests/app.pdf"); + + ArrayList uris = new ArrayList<>(); + uris.add(uri); + uris.add(uri); + + Intent sendIntent = createSendUriIntentWithPreview(uris); + + List resolvedComponentInfos = createResolvedComponentsForTest(2); + setupResolverControllers(resolvedComponentInfos); + + Cursor cursor = mock(Cursor.class); + when(cursor.getCount()).thenReturn(1); + Mockito.doNothing().when(cursor).close(); + when(cursor.moveToFirst()).thenReturn(true); + when(cursor.getColumnIndex(Mockito.anyString())).thenReturn(-1); + + ChooserActivityOverrideData.getInstance().resolverCursor = cursor; + + mActivityRule.launchActivity(Intent.createChooser(sendIntent, null)); + waitForIdle(); + onView(withId(R.id.content_preview_filename)).check(matches(isDisplayed())); + onView(withId(R.id.content_preview_filename)).check(matches(withText("app.pdf"))); + onView(withId(R.id.content_preview_more_files)).check(matches(isDisplayed())); + onView(withId(R.id.content_preview_more_files)).check(matches(withText("+ 1 more file"))); + onView(withId(R.id.content_preview_file_icon)).check(matches(isDisplayed())); + } + + @Test + public void testGetBaseScore() { + final float testBaseScore = 0.89f; + + Intent sendIntent = createSendTextIntent(); + List resolvedComponentInfos = createResolvedComponentsForTest(2); + + setupResolverControllers(resolvedComponentInfos); + + when( + ChooserActivityOverrideData + .getInstance() + .resolverListController + .getScore(Mockito.isA(DisplayResolveInfo.class))) + .thenReturn(testBaseScore); + + final IChooserWrapper activity = (IChooserWrapper) + mActivityRule.launchActivity(Intent.createChooser(sendIntent, null)); + waitForIdle(); + + final DisplayResolveInfo testDri = + activity.createTestDisplayResolveInfo( + sendIntent, + ResolverDataProvider.createResolveInfo(3, 0, PERSONAL_USER_HANDLE), + "testLabel", + "testInfo", + sendIntent); + final ChooserListAdapter adapter = activity.getAdapter(); + + assertThat(adapter.getBaseScore(null, 0), is(CALLER_TARGET_SCORE_BOOST)); + assertThat(adapter.getBaseScore(testDri, TARGET_TYPE_DEFAULT), is(testBaseScore)); + assertThat(adapter.getBaseScore(testDri, TARGET_TYPE_CHOOSER_TARGET), is(testBaseScore)); + assertThat(adapter.getBaseScore(testDri, TARGET_TYPE_SHORTCUTS_FROM_PREDICTION_SERVICE), + is(testBaseScore * SHORTCUT_TARGET_SCORE_BOOST)); + assertThat(adapter.getBaseScore(testDri, TARGET_TYPE_SHORTCUTS_FROM_SHORTCUT_MANAGER), + is(testBaseScore * SHORTCUT_TARGET_SCORE_BOOST)); + } + + // This test is too long and too slow and should not be taken as an example for future tests. + @Test + public void testDirectTargetSelectionLogging() { + Intent sendIntent = createSendTextIntent(); + // We need app targets for direct targets to get displayed + List resolvedComponentInfos = createResolvedComponentsForTest(2); + setupResolverControllers(resolvedComponentInfos); + + // create test shortcut loader factory, remember loaders and their callbacks + SparseArray>> shortcutLoaders = + createShortcutLoaderFactory(); + + // Start activity + ChooserWrapperActivity activity = + mActivityRule.launchActivity(Intent.createChooser(sendIntent, null)); + waitForIdle(); + + // verify that ShortcutLoader was queried + ArgumentCaptor appTargets = + ArgumentCaptor.forClass(DisplayResolveInfo[].class); + verify(shortcutLoaders.get(0).first, times(1)).updateAppTargets(appTargets.capture()); + + // send shortcuts + assertThat( + "Wrong number of app targets", + appTargets.getValue().length, + is(resolvedComponentInfos.size())); + List serviceTargets = createDirectShareTargets(1, ""); + ShortcutLoader.Result result = new ShortcutLoader.Result( + true, + appTargets.getValue(), + new ShortcutLoader.ShortcutResultInfo[] { + new ShortcutLoader.ShortcutResultInfo( + appTargets.getValue()[0], + serviceTargets + ) + }, + new HashMap<>(), + new HashMap<>() + ); + activity.getMainExecutor().execute(() -> shortcutLoaders.get(0).second.accept(result)); + waitForIdle(); + + final ChooserListAdapter activeAdapter = activity.getAdapter(); + assertThat( + "Chooser should have 3 targets (2 apps, 1 direct)", + activeAdapter.getCount(), + is(3)); + assertThat( + "Chooser should have exactly one selectable direct target", + activeAdapter.getSelectableServiceTargetCount(), + is(1)); + assertThat( + "The resolver info must match the resolver info used to create the target", + activeAdapter.getItem(0).getResolveInfo(), + is(resolvedComponentInfos.get(0).getResolveInfoAt(0))); + + // Click on the direct target + String name = serviceTargets.get(0).getTitle().toString(); + onView(withText(name)) + .perform(click()); + waitForIdle(); + + FakeEventLog eventLog = getEventLog(activity); + assertThat(eventLog.getShareTargetSelected()).hasSize(1); + FakeEventLog.ShareTargetSelected call = eventLog.getShareTargetSelected().get(0); + assertThat(call.getTargetType()).isEqualTo(EventLog.SELECTION_TYPE_SERVICE); + assertThat(call.getDirectTargetAlsoRanked()).isEqualTo(-1); + var hashResult = call.getDirectTargetHashed(); + var hash = hashResult == null ? "" : hashResult.hashedString; + assertWithMessage("Hash is not predictable but must be obfuscated") + .that(hash).isNotEqualTo(name); + } + + // This test is too long and too slow and should not be taken as an example for future tests. + @Test + public void testDirectTargetLoggingWithRankedAppTarget() { + Intent sendIntent = createSendTextIntent(); + // We need app targets for direct targets to get displayed + List resolvedComponentInfos = createResolvedComponentsForTest(2); + setupResolverControllers(resolvedComponentInfos); + + // create test shortcut loader factory, remember loaders and their callbacks + SparseArray>> shortcutLoaders = + createShortcutLoaderFactory(); + + // Start activity + ChooserWrapperActivity activity = + mActivityRule.launchActivity(Intent.createChooser(sendIntent, null)); + waitForIdle(); + + // verify that ShortcutLoader was queried + ArgumentCaptor appTargets = + ArgumentCaptor.forClass(DisplayResolveInfo[].class); + verify(shortcutLoaders.get(0).first, times(1)).updateAppTargets(appTargets.capture()); + + // send shortcuts + assertThat( + "Wrong number of app targets", + appTargets.getValue().length, + is(resolvedComponentInfos.size())); + List serviceTargets = createDirectShareTargets( + 1, + resolvedComponentInfos.get(0).getResolveInfoAt(0).activityInfo.packageName); + ShortcutLoader.Result result = new ShortcutLoader.Result( + true, + appTargets.getValue(), + new ShortcutLoader.ShortcutResultInfo[] { + new ShortcutLoader.ShortcutResultInfo( + appTargets.getValue()[0], + serviceTargets + ) + }, + new HashMap<>(), + new HashMap<>() + ); + activity.getMainExecutor().execute(() -> shortcutLoaders.get(0).second.accept(result)); + waitForIdle(); + + final ChooserListAdapter activeAdapter = activity.getAdapter(); + assertThat( + "Chooser should have 3 targets (2 apps, 1 direct)", + activeAdapter.getCount(), + is(3)); + assertThat( + "Chooser should have exactly one selectable direct target", + activeAdapter.getSelectableServiceTargetCount(), + is(1)); + assertThat( + "The resolver info must match the resolver info used to create the target", + activeAdapter.getItem(0).getResolveInfo(), + is(resolvedComponentInfos.get(0).getResolveInfoAt(0))); + + // Click on the direct target + String name = serviceTargets.get(0).getTitle().toString(); + onView(withText(name)) + .perform(click()); + waitForIdle(); + + FakeEventLog eventLog = getEventLog(activity); + assertThat(eventLog.getShareTargetSelected()).hasSize(1); + FakeEventLog.ShareTargetSelected call = eventLog.getShareTargetSelected().get(0); + + assertThat(call.getTargetType()).isEqualTo(EventLog.SELECTION_TYPE_SERVICE); + assertThat(call.getDirectTargetAlsoRanked()).isEqualTo(0); + } + + @Test + public void testShortcutTargetWithApplyAppLimits() { + // Set up resources + Resources resources = Mockito.spy( + InstrumentationRegistry.getInstrumentation().getContext().getResources()); + ChooserActivityOverrideData.getInstance().resources = resources; + doReturn(1).when(resources).getInteger(R.integer.config_maxShortcutTargetsPerApp); + Intent sendIntent = createSendTextIntent(); + // We need app targets for direct targets to get displayed + List resolvedComponentInfos = createResolvedComponentsForTest(2); + setupResolverControllers(resolvedComponentInfos); + + // create test shortcut loader factory, remember loaders and their callbacks + SparseArray>> shortcutLoaders = + createShortcutLoaderFactory(); + + // Start activity + final IChooserWrapper activity = (IChooserWrapper) mActivityRule + .launchActivity(Intent.createChooser(sendIntent, null)); + waitForIdle(); + + // verify that ShortcutLoader was queried + ArgumentCaptor appTargets = + ArgumentCaptor.forClass(DisplayResolveInfo[].class); + verify(shortcutLoaders.get(0).first, times(1)).updateAppTargets(appTargets.capture()); + + // send shortcuts + assertThat( + "Wrong number of app targets", + appTargets.getValue().length, + is(resolvedComponentInfos.size())); + List serviceTargets = createDirectShareTargets( + 2, + resolvedComponentInfos.get(0).getResolveInfoAt(0).activityInfo.packageName); + ShortcutLoader.Result result = new ShortcutLoader.Result( + true, + appTargets.getValue(), + new ShortcutLoader.ShortcutResultInfo[] { + new ShortcutLoader.ShortcutResultInfo( + appTargets.getValue()[0], + serviceTargets + ) + }, + new HashMap<>(), + new HashMap<>() + ); + activity.getMainExecutor().execute(() -> shortcutLoaders.get(0).second.accept(result)); + waitForIdle(); + + final ChooserListAdapter activeAdapter = activity.getAdapter(); + assertThat( + "Chooser should have 3 targets (2 apps, 1 direct)", + activeAdapter.getCount(), + is(3)); + assertThat( + "Chooser should have exactly one selectable direct target", + activeAdapter.getSelectableServiceTargetCount(), + is(1)); + assertThat( + "The resolver info must match the resolver info used to create the target", + activeAdapter.getItem(0).getResolveInfo(), + is(resolvedComponentInfos.get(0).getResolveInfoAt(0))); + assertThat( + "The display label must match", + activeAdapter.getItem(0).getDisplayLabel(), + is("testTitle0")); + } + + @Test + public void testShortcutTargetWithoutApplyAppLimits() { + setDeviceConfigProperty( + SystemUiDeviceConfigFlags.APPLY_SHARING_APP_LIMITS_IN_SYSUI, + Boolean.toString(false)); + // Set up resources + Resources resources = Mockito.spy( + InstrumentationRegistry.getInstrumentation().getContext().getResources()); + ChooserActivityOverrideData.getInstance().resources = resources; + doReturn(1).when(resources).getInteger(R.integer.config_maxShortcutTargetsPerApp); + Intent sendIntent = createSendTextIntent(); + // We need app targets for direct targets to get displayed + List resolvedComponentInfos = createResolvedComponentsForTest(2); + setupResolverControllers(resolvedComponentInfos); + + // create test shortcut loader factory, remember loaders and their callbacks + SparseArray>> shortcutLoaders = + createShortcutLoaderFactory(); + + // Start activity + final IChooserWrapper activity = (IChooserWrapper) + mActivityRule.launchActivity(Intent.createChooser(sendIntent, null)); + waitForIdle(); + + // verify that ShortcutLoader was queried + ArgumentCaptor appTargets = + ArgumentCaptor.forClass(DisplayResolveInfo[].class); + verify(shortcutLoaders.get(0).first, times(1)).updateAppTargets(appTargets.capture()); + + // send shortcuts + assertThat( + "Wrong number of app targets", + appTargets.getValue().length, + is(resolvedComponentInfos.size())); + List serviceTargets = createDirectShareTargets( + 2, + resolvedComponentInfos.get(0).getResolveInfoAt(0).activityInfo.packageName); + ShortcutLoader.Result result = new ShortcutLoader.Result( + true, + appTargets.getValue(), + new ShortcutLoader.ShortcutResultInfo[] { + new ShortcutLoader.ShortcutResultInfo( + appTargets.getValue()[0], + serviceTargets + ) + }, + new HashMap<>(), + new HashMap<>() + ); + activity.getMainExecutor().execute(() -> shortcutLoaders.get(0).second.accept(result)); + waitForIdle(); + + final ChooserListAdapter activeAdapter = activity.getAdapter(); + assertThat( + "Chooser should have 4 targets (2 apps, 2 direct)", + activeAdapter.getCount(), + is(4)); + assertThat( + "Chooser should have exactly two selectable direct target", + activeAdapter.getSelectableServiceTargetCount(), + is(2)); + assertThat( + "The resolver info must match the resolver info used to create the target", + activeAdapter.getItem(0).getResolveInfo(), + is(resolvedComponentInfos.get(0).getResolveInfoAt(0))); + assertThat( + "The display label must match", + activeAdapter.getItem(0).getDisplayLabel(), + is("testTitle0")); + assertThat( + "The display label must match", + activeAdapter.getItem(1).getDisplayLabel(), + is("testTitle1")); + } + + @Test + public void testLaunchWithCallerProvidedTarget() { + setDeviceConfigProperty( + SystemUiDeviceConfigFlags.APPLY_SHARING_APP_LIMITS_IN_SYSUI, + Boolean.toString(false)); + // Set up resources + Resources resources = Mockito.spy( + InstrumentationRegistry.getInstrumentation().getContext().getResources()); + ChooserActivityOverrideData.getInstance().resources = resources; + doReturn(1).when(resources).getInteger(R.integer.config_maxShortcutTargetsPerApp); + + // We need app targets for direct targets to get displayed + List resolvedComponentInfos = createResolvedComponentsForTest(2); + setupResolverControllers(resolvedComponentInfos, resolvedComponentInfos); + markOtherProfileAvailability(/* workAvailable= */ true, /* cloneAvailable= */ false); + + // set caller-provided target + Intent chooserIntent = Intent.createChooser(createSendTextIntent(), null); + String callerTargetLabel = "Caller Target"; + ChooserTarget[] targets = new ChooserTarget[] { + new ChooserTarget( + callerTargetLabel, + Icon.createWithBitmap(createBitmap()), + 0.1f, + resolvedComponentInfos.get(0).name, + new Bundle()) + }; + chooserIntent.putExtra(Intent.EXTRA_CHOOSER_TARGETS, targets); + + // create test shortcut loader factory, remember loaders and their callbacks + SparseArray>> shortcutLoaders = + createShortcutLoaderFactory(); + + // Start activity + final IChooserWrapper activity = (IChooserWrapper) + mActivityRule.launchActivity(chooserIntent); + waitForIdle(); + + // verify that ShortcutLoader was queried + ArgumentCaptor appTargets = + ArgumentCaptor.forClass(DisplayResolveInfo[].class); + verify(shortcutLoaders.get(0).first, times(1)).updateAppTargets(appTargets.capture()); + + // send shortcuts + assertThat( + "Wrong number of app targets", + appTargets.getValue().length, + is(resolvedComponentInfos.size())); + ShortcutLoader.Result result = new ShortcutLoader.Result( + true, + appTargets.getValue(), + new ShortcutLoader.ShortcutResultInfo[0], + new HashMap<>(), + new HashMap<>()); + activity.getMainExecutor().execute(() -> shortcutLoaders.get(0).second.accept(result)); + waitForIdle(); + + final ChooserListAdapter activeAdapter = activity.getAdapter(); + assertThat( + "Chooser should have 3 targets (2 apps, 1 direct)", + activeAdapter.getCount(), + is(3)); + assertThat( + "Chooser should have exactly two selectable direct target", + activeAdapter.getSelectableServiceTargetCount(), + is(1)); + assertThat( + "The display label must match", + activeAdapter.getItem(0).getDisplayLabel(), + is(callerTargetLabel)); + + // Switch to work profile and ensure that the target *doesn't* show up there. + onView(withText(R.string.resolver_work_tab)).perform(click()); + waitForIdle(); + + for (int i = 0; i < activity.getWorkListAdapter().getCount(); i++) { + assertThat( + "Chooser target should not show up in opposite profile", + activity.getWorkListAdapter().getItem(i).getDisplayLabel(), + not(callerTargetLabel)); + } + } + + @Test + public void testLaunchWithCustomAction() throws InterruptedException { + List resolvedComponentInfos = createResolvedComponentsForTest(2); + setupResolverControllers(resolvedComponentInfos); + + Context testContext = InstrumentationRegistry.getInstrumentation().getContext(); + final String customActionLabel = "Custom Action"; + final String testAction = "test-broadcast-receiver-action"; + Intent chooserIntent = Intent.createChooser(createSendTextIntent(), null); + chooserIntent.putExtra( + Intent.EXTRA_CHOOSER_CUSTOM_ACTIONS, + new ChooserAction[] { + new ChooserAction.Builder( + Icon.createWithResource("", Resources.ID_NULL), + customActionLabel, + PendingIntent.getBroadcast( + testContext, + 123, + new Intent(testAction), + PendingIntent.FLAG_IMMUTABLE | PendingIntent.FLAG_ONE_SHOT)) + .build() + }); + // Start activity + mActivityRule.launchActivity(chooserIntent); + waitForIdle(); + + final CountDownLatch broadcastInvoked = new CountDownLatch(1); + BroadcastReceiver testReceiver = new BroadcastReceiver() { + @Override + public void onReceive(Context context, Intent intent) { + broadcastInvoked.countDown(); + } + }; + testContext.registerReceiver(testReceiver, new IntentFilter(testAction), + Context.RECEIVER_EXPORTED); + + try { + onView(withText(customActionLabel)).perform(click()); + assertTrue("Timeout waiting for broadcast", + broadcastInvoked.await(5000, TimeUnit.MILLISECONDS)); + } finally { + testContext.unregisterReceiver(testReceiver); + } + } + + @Test + public void testLaunchWithShareModification() throws InterruptedException { + List resolvedComponentInfos = createResolvedComponentsForTest(2); + setupResolverControllers(resolvedComponentInfos); + + Context testContext = InstrumentationRegistry.getInstrumentation().getContext(); + final String modifyShareAction = "test-broadcast-receiver-action"; + Intent chooserIntent = Intent.createChooser(createSendTextIntent(), null); + String label = "modify share"; + PendingIntent pendingIntent = PendingIntent.getBroadcast( + testContext, + 123, + new Intent(modifyShareAction), + PendingIntent.FLAG_IMMUTABLE | PendingIntent.FLAG_ONE_SHOT); + ChooserAction action = new ChooserAction.Builder(Icon.createWithBitmap( + createBitmap()), label, pendingIntent).build(); + chooserIntent.putExtra( + Intent.EXTRA_CHOOSER_MODIFY_SHARE_ACTION, + action); + // Start activity + mActivityRule.launchActivity(chooserIntent); + waitForIdle(); + + final CountDownLatch broadcastInvoked = new CountDownLatch(1); + BroadcastReceiver testReceiver = new BroadcastReceiver() { + @Override + public void onReceive(Context context, Intent intent) { + broadcastInvoked.countDown(); + } + }; + testContext.registerReceiver(testReceiver, new IntentFilter(modifyShareAction), + Context.RECEIVER_EXPORTED); + + try { + onView(withText(label)).perform(click()); + assertTrue("Timeout waiting for broadcast", + broadcastInvoked.await(5000, TimeUnit.MILLISECONDS)); + + } finally { + testContext.unregisterReceiver(testReceiver); + } + } + + @Test + public void testUpdateMaxTargetsPerRow_columnCountIsUpdated() throws InterruptedException { + updateMaxTargetsPerRowResource(/* targetsPerRow= */ 4); + givenAppTargets(/* appCount= */ 16); + Intent sendIntent = createSendTextIntent(); + final ChooserActivity activity = + mActivityRule.launchActivity(Intent.createChooser(sendIntent, null)); + + updateMaxTargetsPerRowResource(/* targetsPerRow= */ 6); + InstrumentationRegistry.getInstrumentation() + .runOnMainSync(() -> activity.onConfigurationChanged( + InstrumentationRegistry.getInstrumentation() + .getContext().getResources().getConfiguration())); + + waitForIdle(); + onView(withId(com.android.internal.R.id.resolver_list)) + .check(matches(withGridColumnCount(6))); + } + + // This test is too long and too slow and should not be taken as an example for future tests. + @Test @Ignore + public void testDirectTargetLoggingWithAppTargetNotRankedPortrait() + throws InterruptedException { + testDirectTargetLoggingWithAppTargetNotRanked(Configuration.ORIENTATION_PORTRAIT, 4); + } + + @Test @Ignore + public void testDirectTargetLoggingWithAppTargetNotRankedLandscape() + throws InterruptedException { + testDirectTargetLoggingWithAppTargetNotRanked(Configuration.ORIENTATION_LANDSCAPE, 8); + } + + private void testDirectTargetLoggingWithAppTargetNotRanked( + int orientation, int appTargetsExpected) { + Configuration configuration = + new Configuration(InstrumentationRegistry.getInstrumentation().getContext() + .getResources().getConfiguration()); + configuration.orientation = orientation; + + Resources resources = Mockito.spy( + InstrumentationRegistry.getInstrumentation().getContext().getResources()); + ChooserActivityOverrideData.getInstance().resources = resources; + doReturn(configuration).when(resources).getConfiguration(); + + Intent sendIntent = createSendTextIntent(); + // We need app targets for direct targets to get displayed + List resolvedComponentInfos = createResolvedComponentsForTest(15); + setupResolverControllers(resolvedComponentInfos); + + // Create direct share target + List serviceTargets = createDirectShareTargets(1, + resolvedComponentInfos.get(14).getResolveInfoAt(0).activityInfo.packageName); + ResolveInfo ri = ResolverDataProvider.createResolveInfo(16, 0, PERSONAL_USER_HANDLE); + + // Start activity + ChooserWrapperActivity activity = + mActivityRule.launchActivity(Intent.createChooser(sendIntent, null)); + // Insert the direct share target + Map directShareToShortcutInfos = new HashMap<>(); + directShareToShortcutInfos.put(serviceTargets.get(0), null); + InstrumentationRegistry.getInstrumentation().runOnMainSync( + () -> activity.getAdapter().addServiceResults( + activity.createTestDisplayResolveInfo(sendIntent, + ri, + "testLabel", + "testInfo", + sendIntent), + serviceTargets, + TARGET_TYPE_CHOOSER_TARGET, + directShareToShortcutInfos, + /* directShareToAppTargets */ null) + ); + + assertThat( + String.format("Chooser should have %d targets (%d apps, 1 direct, 15 A-Z)", + appTargetsExpected + 16, appTargetsExpected), + activity.getAdapter().getCount(), is(appTargetsExpected + 16)); + assertThat("Chooser should have exactly one selectable direct target", + activity.getAdapter().getSelectableServiceTargetCount(), is(1)); + assertThat("The resolver info must match the resolver info used to create the target", + activity.getAdapter().getItem(0).getResolveInfo(), is(ri)); + + // Click on the direct target + String name = serviceTargets.get(0).getTitle().toString(); + onView(withText(name)) + .perform(click()); + waitForIdle(); + + FakeEventLog eventLog = getEventLog(activity); + var invocations = eventLog.getShareTargetSelected(); + assertWithMessage("Only one ShareTargetSelected event logged") + .that(invocations).hasSize(1); + FakeEventLog.ShareTargetSelected call = invocations.get(0); + assertWithMessage("targetType should be SELECTION_TYPE_SERVICE") + .that(call.getTargetType()).isEqualTo(EventLog.SELECTION_TYPE_SERVICE); + assertWithMessage( + "The packages shouldn't match for app target and direct target") + .that(call.getDirectTargetAlsoRanked()).isEqualTo(-1); + } + + @Test + public void testWorkTab_displayedWhenWorkProfileUserAvailable() { + Intent sendIntent = createSendTextIntent(); + sendIntent.setType(TEST_MIME_TYPE); + markOtherProfileAvailability(/* workAvailable= */ true, /* cloneAvailable= */ false); + + mActivityRule.launchActivity(Intent.createChooser(sendIntent, "work tab test")); + waitForIdle(); + + onView(withId(android.R.id.tabs)).check(matches(isDisplayed())); + } + + @Test + public void testWorkTab_hiddenWhenWorkProfileUserNotAvailable() { + Intent sendIntent = createSendTextIntent(); + sendIntent.setType(TEST_MIME_TYPE); + + mActivityRule.launchActivity(Intent.createChooser(sendIntent, "work tab test")); + waitForIdle(); + + onView(withId(android.R.id.tabs)).check(matches(not(isDisplayed()))); + } + + @Test + public void testWorkTab_eachTabUsesExpectedAdapter() { + int personalProfileTargets = 3; + int otherProfileTargets = 1; + List personalResolvedComponentInfos = + createResolvedComponentsForTestWithOtherProfile( + personalProfileTargets + otherProfileTargets, /* userID */ 10); + int workProfileTargets = 4; + List workResolvedComponentInfos = createResolvedComponentsForTest( + workProfileTargets); + setupResolverControllers(personalResolvedComponentInfos, workResolvedComponentInfos); + Intent sendIntent = createSendTextIntent(); + sendIntent.setType(TEST_MIME_TYPE); + markOtherProfileAvailability(/* workAvailable= */ true, /* cloneAvailable= */ false); + + final IChooserWrapper activity = (IChooserWrapper) + mActivityRule.launchActivity(Intent.createChooser(sendIntent, "work tab test")); + waitForIdle(); + + assertThat(activity.getCurrentUserHandle().getIdentifier(), is(0)); + onView(withText(R.string.resolver_work_tab)).perform(click()); + assertThat(activity.getCurrentUserHandle().getIdentifier(), is(10)); + assertThat(activity.getPersonalListAdapter().getCount(), is(personalProfileTargets)); + assertThat(activity.getWorkListAdapter().getCount(), is(workProfileTargets)); + } + + @Test + public void testWorkTab_workProfileHasExpectedNumberOfTargets() throws InterruptedException { + markOtherProfileAvailability(/* workAvailable= */ true, /* cloneAvailable= */ false); + int workProfileTargets = 4; + List personalResolvedComponentInfos = + createResolvedComponentsForTestWithOtherProfile(3, /* userId */ 10); + List workResolvedComponentInfos = + createResolvedComponentsForTest(workProfileTargets); + setupResolverControllers(personalResolvedComponentInfos, workResolvedComponentInfos); + Intent sendIntent = createSendTextIntent(); + sendIntent.setType(TEST_MIME_TYPE); + + final IChooserWrapper activity = (IChooserWrapper) + mActivityRule.launchActivity(Intent.createChooser(sendIntent, "work tab test")); + waitForIdle(); + onView(withText(R.string.resolver_work_tab)).perform(click()); + waitForIdle(); + + assertThat(activity.getWorkListAdapter().getCount(), is(workProfileTargets)); + } + + @Test @Ignore + public void testWorkTab_selectingWorkTabAppOpensAppInWorkProfile() { + markOtherProfileAvailability(/* workAvailable= */ true, /* cloneAvailable= */ false); + List personalResolvedComponentInfos = + createResolvedComponentsForTestWithOtherProfile(3, /* userId */ 10); + int workProfileTargets = 4; + List workResolvedComponentInfos = + createResolvedComponentsForTest(workProfileTargets); + setupResolverControllers(personalResolvedComponentInfos, workResolvedComponentInfos); + Intent sendIntent = createSendTextIntent(); + sendIntent.setType(TEST_MIME_TYPE); + ResolveInfo[] chosen = new ResolveInfo[1]; + ChooserActivityOverrideData.getInstance().onSafelyStartInternalCallback = targetInfo -> { + chosen[0] = targetInfo.getResolveInfo(); + return true; + }; + + mActivityRule.launchActivity(Intent.createChooser(sendIntent, "work tab test")); + waitForIdle(); + onView(withText(R.string.resolver_work_tab)).perform(click()); + waitForIdle(); + + onView(first(allOf( + withText(workResolvedComponentInfos.get(0) + .getResolveInfoAt(0).activityInfo.applicationInfo.name), + isDisplayed()))) + .perform(click()); + waitForIdle(); + assertThat(chosen[0], is(workResolvedComponentInfos.get(0).getResolveInfoAt(0))); + } + + @Test + public void testWorkTab_crossProfileIntentsDisabled_personalToWork_emptyStateShown() { + markOtherProfileAvailability(/* workAvailable= */ true, /* cloneAvailable= */ false); + int workProfileTargets = 4; + List personalResolvedComponentInfos = + createResolvedComponentsForTestWithOtherProfile(3, /* userId */ 10); + List workResolvedComponentInfos = + createResolvedComponentsForTest(workProfileTargets); + ChooserActivityOverrideData.getInstance().hasCrossProfileIntents = false; + setupResolverControllers(personalResolvedComponentInfos, workResolvedComponentInfos); + Intent sendIntent = createSendTextIntent(); + sendIntent.setType(TEST_MIME_TYPE); + + mActivityRule.launchActivity(Intent.createChooser(sendIntent, "work tab test")); + waitForIdle(); + onView(withText(R.string.resolver_work_tab)).perform(click()); + waitForIdle(); + onView(withId(com.android.internal.R.id.contentPanel)) + .perform(swipeUp()); + + onView(withText(R.string.resolver_cross_profile_blocked)) + .check(matches(isDisplayed())); + } + + @Test + public void testWorkTab_workProfileDisabled_emptyStateShown() { + markOtherProfileAvailability(/* workAvailable= */ true, /* cloneAvailable= */ false); + int workProfileTargets = 4; + List personalResolvedComponentInfos = + createResolvedComponentsForTestWithOtherProfile(3, /* userId */ 10); + List workResolvedComponentInfos = + createResolvedComponentsForTest(workProfileTargets); + ChooserActivityOverrideData.getInstance().isQuietModeEnabled = true; + setupResolverControllers(personalResolvedComponentInfos, workResolvedComponentInfos); + Intent sendIntent = createSendTextIntent(); + sendIntent.setType(TEST_MIME_TYPE); + + mActivityRule.launchActivity(Intent.createChooser(sendIntent, "work tab test")); + waitForIdle(); + onView(withId(com.android.internal.R.id.contentPanel)) + .perform(swipeUp()); + onView(withText(R.string.resolver_work_tab)).perform(click()); + waitForIdle(); + + onView(withText(R.string.resolver_turn_on_work_apps)) + .check(matches(isDisplayed())); + } + + @Test + public void testWorkTab_noWorkAppsAvailable_emptyStateShown() { + markOtherProfileAvailability(/* workAvailable= */ true, /* cloneAvailable= */ false); + List personalResolvedComponentInfos = + createResolvedComponentsForTest(3); + List workResolvedComponentInfos = + createResolvedComponentsForTest(0); + setupResolverControllers(personalResolvedComponentInfos, workResolvedComponentInfos); + Intent sendIntent = createSendTextIntent(); + sendIntent.setType(TEST_MIME_TYPE); + + mActivityRule.launchActivity(Intent.createChooser(sendIntent, "work tab test")); + waitForIdle(); + onView(withId(com.android.internal.R.id.contentPanel)) + .perform(swipeUp()); + onView(withText(R.string.resolver_work_tab)).perform(click()); + waitForIdle(); + + onView(withText(R.string.resolver_no_work_apps_available)) + .check(matches(isDisplayed())); + } + + @Test + @RequiresFlagsEnabled(Flags.FLAG_SCROLLABLE_PREVIEW) + public void testWorkTab_previewIsScrollable() { + markOtherProfileAvailability(/* workAvailable= */ true, /* cloneAvailable= */ false); + List personalResolvedComponentInfos = + createResolvedComponentsForTest(300); + List workResolvedComponentInfos = + createResolvedComponentsForTest(3); + setupResolverControllers(personalResolvedComponentInfos, workResolvedComponentInfos); + + Uri uri = createTestContentProviderUri("image/png", null); + + ArrayList uris = new ArrayList<>(); + uris.add(uri); + + Intent sendIntent = createSendUriIntentWithPreview(uris); + ChooserActivityOverrideData.getInstance().imageLoader = + createImageLoader(uri, createWideBitmap()); + + mActivityRule.launchActivity(Intent.createChooser(sendIntent, "Scrollable preview test")); + waitForIdle(); + + onView(withId(R.id.scrollable_image_preview)) + .check(matches(isDisplayed())); + + onView(withId(com.android.internal.R.id.contentPanel)).perform(swipeUp()); + waitForIdle(); + + onView(withId(R.id.chooser_headline_row_container)) + .check(matches(isCompletelyDisplayed())); + onView(withId(R.id.headline)) + .check(matches(isDisplayed())); + onView(withId(R.id.scrollable_image_preview)) + .check(matches(not(isDisplayed()))); + } + + @Ignore // b/220067877 + @Test + public void testWorkTab_xProfileOff_noAppsAvailable_workOff_xProfileOffEmptyStateShown() { + markOtherProfileAvailability(/* workAvailable= */ true, /* cloneAvailable= */ false); + List personalResolvedComponentInfos = + createResolvedComponentsForTest(3); + List workResolvedComponentInfos = + createResolvedComponentsForTest(0); + setupResolverControllers(personalResolvedComponentInfos, workResolvedComponentInfos); + ChooserActivityOverrideData.getInstance().isQuietModeEnabled = true; + ChooserActivityOverrideData.getInstance().hasCrossProfileIntents = false; + Intent sendIntent = createSendTextIntent(); + sendIntent.setType(TEST_MIME_TYPE); + + mActivityRule.launchActivity(Intent.createChooser(sendIntent, "work tab test")); + waitForIdle(); + onView(withId(com.android.internal.R.id.contentPanel)) + .perform(swipeUp()); + onView(withText(R.string.resolver_work_tab)).perform(click()); + waitForIdle(); + + onView(withText(R.string.resolver_cross_profile_blocked)) + .check(matches(isDisplayed())); + } + + @Test + public void testWorkTab_noAppsAvailable_workOff_noAppsAvailableEmptyStateShown() { + markOtherProfileAvailability(/* workAvailable= */ true, /* cloneAvailable= */ false); + List personalResolvedComponentInfos = + createResolvedComponentsForTest(3); + List workResolvedComponentInfos = + createResolvedComponentsForTest(0); + setupResolverControllers(personalResolvedComponentInfos, workResolvedComponentInfos); + ChooserActivityOverrideData.getInstance().isQuietModeEnabled = true; + Intent sendIntent = createSendTextIntent(); + sendIntent.setType(TEST_MIME_TYPE); + + mActivityRule.launchActivity(Intent.createChooser(sendIntent, "work tab test")); + waitForIdle(); + onView(withId(com.android.internal.R.id.contentPanel)) + .perform(swipeUp()); + onView(withText(R.string.resolver_work_tab)).perform(click()); + waitForIdle(); + + onView(withText(R.string.resolver_no_work_apps_available)) + .check(matches(isDisplayed())); + } + + @Test @Ignore("b/222124533") + public void testAppTargetLogging() throws InterruptedException { + Intent sendIntent = createSendTextIntent(); + List resolvedComponentInfos = createResolvedComponentsForTest(2); + + setupResolverControllers(resolvedComponentInfos); + + final IChooserWrapper activity = (IChooserWrapper) + mActivityRule.launchActivity(Intent.createChooser(sendIntent, null)); + waitForIdle(); + + // TODO(b/222124533): other test cases use a timeout to make sure that the UI is fully + // populated; without one, this test flakes. Ideally we should address the need for a + // timeout everywhere instead of introducing one to fix this particular test. + + assertThat(activity.getAdapter().getCount(), is(2)); + onView(withId(com.android.internal.R.id.profile_button)).check(doesNotExist()); + + ResolveInfo[] chosen = new ResolveInfo[1]; + ChooserActivityOverrideData.getInstance().onSafelyStartInternalCallback = targetInfo -> { + chosen[0] = targetInfo.getResolveInfo(); + return true; + }; + + ResolveInfo toChoose = resolvedComponentInfos.get(0).getResolveInfoAt(0); + onView(withText(toChoose.activityInfo.name)) + .perform(click()); + waitForIdle(); + + // TODO(b/211669337): Determine the expected SHARESHEET_DIRECT_LOAD_COMPLETE events. + } + + @Test + public void testDirectTargetLogging() { + Intent sendIntent = createSendTextIntent(); + // We need app targets for direct targets to get displayed + List resolvedComponentInfos = createResolvedComponentsForTest(2); + setupResolverControllers(resolvedComponentInfos); + + // create test shortcut loader factory, remember loaders and their callbacks + SparseArray>> shortcutLoaders = + new SparseArray<>(); + ChooserActivityOverrideData.getInstance().shortcutLoaderFactory = + (userHandle, callback) -> { + Pair> pair = + new Pair<>(mock(ShortcutLoader.class), callback); + shortcutLoaders.put(userHandle.getIdentifier(), pair); + return pair.first; + }; + + // Start activity + ChooserWrapperActivity activity = + mActivityRule.launchActivity(Intent.createChooser(sendIntent, null)); + waitForIdle(); + + // verify that ShortcutLoader was queried + ArgumentCaptor appTargets = + ArgumentCaptor.forClass(DisplayResolveInfo[].class); + verify(shortcutLoaders.get(0).first, times(1)) + .updateAppTargets(appTargets.capture()); + + // send shortcuts + assertThat( + "Wrong number of app targets", + appTargets.getValue().length, + is(resolvedComponentInfos.size())); + List serviceTargets = createDirectShareTargets(1, + resolvedComponentInfos.get(0).getResolveInfoAt(0).activityInfo.packageName); + ShortcutLoader.Result result = new ShortcutLoader.Result( + // TODO: test another value as well + false, + appTargets.getValue(), + new ShortcutLoader.ShortcutResultInfo[] { + new ShortcutLoader.ShortcutResultInfo( + appTargets.getValue()[0], + serviceTargets + ) + }, + new HashMap<>(), + new HashMap<>() + ); + activity.getMainExecutor().execute(() -> shortcutLoaders.get(0).second.accept(result)); + waitForIdle(); + + assertThat("Chooser should have 3 targets (2 apps, 1 direct)", + activity.getAdapter().getCount(), is(3)); + assertThat("Chooser should have exactly one selectable direct target", + activity.getAdapter().getSelectableServiceTargetCount(), is(1)); + assertThat( + "The resolver info must match the resolver info used to create the target", + activity.getAdapter().getItem(0).getResolveInfo(), + is(resolvedComponentInfos.get(0).getResolveInfoAt(0))); + + // Click on the direct target + String name = serviceTargets.get(0).getTitle().toString(); + onView(withText(name)) + .perform(click()); + waitForIdle(); + + FakeEventLog eventLog = getEventLog(activity); + assertThat(eventLog.getShareTargetSelected()).hasSize(1); + FakeEventLog.ShareTargetSelected call = eventLog.getShareTargetSelected().get(0); + assertThat(call.getTargetType()).isEqualTo(EventLog.SELECTION_TYPE_SERVICE); + } + + @Test + public void testDirectTargetPinningDialog() { + Intent sendIntent = createSendTextIntent(); + // We need app targets for direct targets to get displayed + List resolvedComponentInfos = createResolvedComponentsForTest(2); + setupResolverControllers(resolvedComponentInfos); + + // create test shortcut loader factory, remember loaders and their callbacks + SparseArray>> shortcutLoaders = + new SparseArray<>(); + ChooserActivityOverrideData.getInstance().shortcutLoaderFactory = + (userHandle, callback) -> { + Pair> pair = + new Pair<>(mock(ShortcutLoader.class), callback); + shortcutLoaders.put(userHandle.getIdentifier(), pair); + return pair.first; + }; + + // Start activity + final IChooserWrapper activity = (IChooserWrapper) + mActivityRule.launchActivity(Intent.createChooser(sendIntent, null)); + waitForIdle(); + + // verify that ShortcutLoader was queried + ArgumentCaptor appTargets = + ArgumentCaptor.forClass(DisplayResolveInfo[].class); + verify(shortcutLoaders.get(0).first, times(1)) + .updateAppTargets(appTargets.capture()); + + // send shortcuts + List serviceTargets = createDirectShareTargets( + 1, + resolvedComponentInfos.get(0).getResolveInfoAt(0).activityInfo.packageName); + ShortcutLoader.Result result = new ShortcutLoader.Result( + // TODO: test another value as well + false, + appTargets.getValue(), + new ShortcutLoader.ShortcutResultInfo[] { + new ShortcutLoader.ShortcutResultInfo( + appTargets.getValue()[0], + serviceTargets + ) + }, + new HashMap<>(), + new HashMap<>() + ); + activity.getMainExecutor().execute(() -> shortcutLoaders.get(0).second.accept(result)); + waitForIdle(); + + // Long-click on the direct target + String name = serviceTargets.get(0).getTitle().toString(); + onView(withText(name)).perform(longClick()); + waitForIdle(); + + onView(withId(R.id.chooser_dialog_content)).check(matches(isDisplayed())); + } + + @Test @Ignore + public void testEmptyDirectRowLogging() throws InterruptedException { + Intent sendIntent = createSendTextIntent(); + // We need app targets for direct targets to get displayed + List resolvedComponentInfos = createResolvedComponentsForTest(2); + setupResolverControllers(resolvedComponentInfos); + + // Start activity + final IChooserWrapper activity = (IChooserWrapper) + mActivityRule.launchActivity(Intent.createChooser(sendIntent, null)); + + // Thread.sleep shouldn't be a thing in an integration test but it's + // necessary here because of the way the code is structured + Thread.sleep(3000); + + assertThat("Chooser should have 2 app targets", + activity.getAdapter().getCount(), is(2)); + assertThat("Chooser should have no direct targets", + activity.getAdapter().getSelectableServiceTargetCount(), is(0)); + + // TODO(b/211669337): Determine the expected SHARESHEET_DIRECT_LOAD_COMPLETE events. + } + + @Ignore // b/220067877 + @Test + public void testCopyTextToClipboardLogging() throws Exception { + Intent sendIntent = createSendTextIntent(); + List resolvedComponentInfos = createResolvedComponentsForTest(2); + + setupResolverControllers(resolvedComponentInfos); + + final IChooserWrapper activity = (IChooserWrapper) + mActivityRule.launchActivity(Intent.createChooser(sendIntent, null)); + waitForIdle(); + + onView(withId(com.android.internal.R.id.chooser_copy_button)).check(matches(isDisplayed())); + onView(withId(com.android.internal.R.id.chooser_copy_button)).perform(click()); + + // TODO(b/211669337): Determine the expected SHARESHEET_DIRECT_LOAD_COMPLETE events. + } + + @Test @Ignore("b/222124533") + public void testSwitchProfileLogging() throws InterruptedException { + markOtherProfileAvailability(/* workAvailable= */ true, /* cloneAvailable= */ false); + int workProfileTargets = 4; + List personalResolvedComponentInfos = + createResolvedComponentsForTestWithOtherProfile(3, /* userId */ 10); + List workResolvedComponentInfos = + createResolvedComponentsForTest(workProfileTargets); + setupResolverControllers(personalResolvedComponentInfos, workResolvedComponentInfos); + Intent sendIntent = createSendTextIntent(); + sendIntent.setType(TEST_MIME_TYPE); + + final IChooserWrapper activity = (IChooserWrapper) + mActivityRule.launchActivity(Intent.createChooser(sendIntent, "work tab test")); + waitForIdle(); + onView(withText(R.string.resolver_work_tab)).perform(click()); + waitForIdle(); + onView(withText(R.string.resolver_personal_tab)).perform(click()); + waitForIdle(); + + // TODO(b/211669337): Determine the expected SHARESHEET_DIRECT_LOAD_COMPLETE events. + } + + @Test + public void testWorkTab_onePersonalTarget_emptyStateOnWorkTarget_doesNotAutoLaunch() { + markOtherProfileAvailability(/* workAvailable= */ true, /* cloneAvailable= */ false); + int workProfileTargets = 4; + List personalResolvedComponentInfos = + createResolvedComponentsForTestWithOtherProfile(2, /* userId */ 10); + List workResolvedComponentInfos = + createResolvedComponentsForTest(workProfileTargets); + ChooserActivityOverrideData.getInstance().hasCrossProfileIntents = false; + setupResolverControllers(personalResolvedComponentInfos, workResolvedComponentInfos); + Intent sendIntent = createSendTextIntent(); + ResolveInfo[] chosen = new ResolveInfo[1]; + ChooserActivityOverrideData.getInstance().onSafelyStartInternalCallback = targetInfo -> { + chosen[0] = targetInfo.getResolveInfo(); + return true; + }; + + mActivityRule.launchActivity(Intent.createChooser(sendIntent, "Test")); + waitForIdle(); + + assertNull(chosen[0]); + } + + @Test + public void testOneInitialIntent_noAutolaunch() { + List personalResolvedComponentInfos = + createResolvedComponentsForTest(1); + setupResolverControllers(personalResolvedComponentInfos); + Intent chooserIntent = createChooserIntent(createSendTextIntent(), + new Intent[] {new Intent("action.fake")}); + ResolveInfo[] chosen = new ResolveInfo[1]; + ChooserActivityOverrideData.getInstance().onSafelyStartInternalCallback = targetInfo -> { + chosen[0] = targetInfo.getResolveInfo(); + return true; + }; + ChooserActivityOverrideData.getInstance().packageManager = mock(PackageManager.class); + ResolveInfo ri = createFakeResolveInfo(); + when( + ChooserActivityOverrideData + .getInstance().packageManager + .resolveActivity(any(Intent.class), any())) + .thenReturn(ri); + waitForIdle(); + + IChooserWrapper activity = (IChooserWrapper) mActivityRule.launchActivity(chooserIntent); + waitForIdle(); + + assertNull(chosen[0]); + assertThat(activity + .getPersonalListAdapter().getCallerTargetCount(), is(1)); + } + + @Test + public void testWorkTab_withInitialIntents_workTabDoesNotIncludePersonalInitialIntents() { + markOtherProfileAvailability(/* workAvailable= */ true, /* cloneAvailable= */ false); + int workProfileTargets = 1; + List personalResolvedComponentInfos = + createResolvedComponentsForTestWithOtherProfile(2, /* userId */ 10); + List workResolvedComponentInfos = + createResolvedComponentsForTest(workProfileTargets); + setupResolverControllers(personalResolvedComponentInfos, workResolvedComponentInfos); + Intent[] initialIntents = { + new Intent("action.fake1"), + new Intent("action.fake2") + }; + Intent chooserIntent = createChooserIntent(createSendTextIntent(), initialIntents); + ChooserActivityOverrideData.getInstance().packageManager = mock(PackageManager.class); + when( + ChooserActivityOverrideData + .getInstance() + .packageManager + .resolveActivity(any(Intent.class), any())) + .thenReturn(createFakeResolveInfo()); + waitForIdle(); + + IChooserWrapper activity = (IChooserWrapper) mActivityRule.launchActivity(chooserIntent); + waitForIdle(); + + assertThat(activity.getPersonalListAdapter().getCallerTargetCount(), is(2)); + assertThat(activity.getWorkListAdapter().getCallerTargetCount(), is(0)); + } + + @Test + public void testWorkTab_xProfileIntentsDisabled_personalToWork_nonSendIntent_emptyStateShown() { + markOtherProfileAvailability(/* workAvailable= */ true, /* cloneAvailable= */ false); + int workProfileTargets = 4; + List personalResolvedComponentInfos = + createResolvedComponentsForTestWithOtherProfile(3, /* userId */ 10); + List workResolvedComponentInfos = + createResolvedComponentsForTest(workProfileTargets); + ChooserActivityOverrideData.getInstance().hasCrossProfileIntents = false; + setupResolverControllers(personalResolvedComponentInfos, workResolvedComponentInfos); + Intent[] initialIntents = { + new Intent("action.fake1"), + new Intent("action.fake2") + }; + Intent chooserIntent = createChooserIntent(new Intent(), initialIntents); + ChooserActivityOverrideData.getInstance().packageManager = mock(PackageManager.class); + when( + ChooserActivityOverrideData + .getInstance() + .packageManager + .resolveActivity(any(Intent.class), any())) + .thenReturn(createFakeResolveInfo()); + + mActivityRule.launchActivity(chooserIntent); + waitForIdle(); + onView(withText(R.string.resolver_work_tab)).perform(click()); + waitForIdle(); + onView(withId(com.android.internal.R.id.contentPanel)) + .perform(swipeUp()); + + onView(withText(R.string.resolver_cross_profile_blocked)) + .check(matches(isDisplayed())); + } + + @Test + public void testWorkTab_noWorkAppsAvailable_nonSendIntent_emptyStateShown() { + markOtherProfileAvailability(/* workAvailable= */ true, /* cloneAvailable= */ false); + List personalResolvedComponentInfos = + createResolvedComponentsForTest(3); + List workResolvedComponentInfos = + createResolvedComponentsForTest(0); + setupResolverControllers(personalResolvedComponentInfos, workResolvedComponentInfos); + Intent[] initialIntents = { + new Intent("action.fake1"), + new Intent("action.fake2") + }; + Intent chooserIntent = createChooserIntent(new Intent(), initialIntents); + ChooserActivityOverrideData.getInstance().packageManager = mock(PackageManager.class); + when( + ChooserActivityOverrideData + .getInstance() + .packageManager + .resolveActivity(any(Intent.class), any())) + .thenReturn(createFakeResolveInfo()); + + mActivityRule.launchActivity(chooserIntent); + waitForIdle(); + onView(withId(com.android.internal.R.id.contentPanel)) + .perform(swipeUp()); + onView(withText(R.string.resolver_work_tab)).perform(click()); + waitForIdle(); + + onView(withText(R.string.resolver_no_work_apps_available)) + .check(matches(isDisplayed())); + } + + @Test + public void testDeduplicateCallerTargetRankedTarget() { + // Create 4 ranked app targets. + List personalResolvedComponentInfos = + createResolvedComponentsForTest(4); + setupResolverControllers(personalResolvedComponentInfos); + // Create caller target which is duplicate with one of app targets + Intent chooserIntent = createChooserIntent(createSendTextIntent(), + new Intent[] {new Intent("action.fake")}); + ChooserActivityOverrideData.getInstance().packageManager = mock(PackageManager.class); + ResolveInfo ri = ResolverDataProvider.createResolveInfo(0, + UserHandle.USER_CURRENT, PERSONAL_USER_HANDLE); + when( + ChooserActivityOverrideData + .getInstance() + .packageManager + .resolveActivity(any(Intent.class), any())) + .thenReturn(ri); + waitForIdle(); + + IChooserWrapper activity = (IChooserWrapper) mActivityRule.launchActivity(chooserIntent); + waitForIdle(); + + // Total 4 targets (1 caller target, 3 ranked targets) + assertThat(activity.getAdapter().getCount(), is(4)); + assertThat(activity.getAdapter().getCallerTargetCount(), is(1)); + assertThat(activity.getAdapter().getRankedTargetCount(), is(3)); + } + + @Test + public void test_query_shortcut_loader_for_the_selected_tab() { + markOtherProfileAvailability(/* workAvailable= */ true, /* cloneAvailable= */ false); + List personalResolvedComponentInfos = + createResolvedComponentsForTestWithOtherProfile(3, /* userId */ 10); + List workResolvedComponentInfos = + createResolvedComponentsForTest(3); + setupResolverControllers(personalResolvedComponentInfos, workResolvedComponentInfos); + ShortcutLoader personalProfileShortcutLoader = mock(ShortcutLoader.class); + ShortcutLoader workProfileShortcutLoader = mock(ShortcutLoader.class); + final SparseArray shortcutLoaders = new SparseArray<>(); + shortcutLoaders.put(0, personalProfileShortcutLoader); + shortcutLoaders.put(10, workProfileShortcutLoader); + ChooserActivityOverrideData.getInstance().shortcutLoaderFactory = + (userHandle, callback) -> shortcutLoaders.get(userHandle.getIdentifier(), null); + Intent sendIntent = createSendTextIntent(); + sendIntent.setType(TEST_MIME_TYPE); + + mActivityRule.launchActivity(Intent.createChooser(sendIntent, "work tab test")); + waitForIdle(); + onView(withId(com.android.internal.R.id.contentPanel)) + .perform(swipeUp()); + waitForIdle(); + + verify(personalProfileShortcutLoader, times(1)).updateAppTargets(any()); + + onView(withText(R.string.resolver_work_tab)).perform(click()); + waitForIdle(); + + verify(workProfileShortcutLoader, times(1)).updateAppTargets(any()); + } + + @Test + public void testClonedProfilePresent_personalAdapterIsSetWithPersonalProfile() { + // enable cloneProfile + markOtherProfileAvailability(/* workAvailable= */ false, /* cloneAvailable= */ true); + List resolvedComponentInfos = + createResolvedComponentsWithCloneProfileForTest( + 3, + PERSONAL_USER_HANDLE, + CLONE_PROFILE_USER_HANDLE); + setupResolverControllers(resolvedComponentInfos); + Intent sendIntent = createSendTextIntent(); + + final IChooserWrapper activity = (IChooserWrapper) mActivityRule + .launchActivity(Intent.createChooser(sendIntent, "personalProfileTest")); + waitForIdle(); + + assertThat(activity.getPersonalListAdapter().getUserHandle(), is(PERSONAL_USER_HANDLE)); + assertThat(activity.getAdapter().getCount(), is(3)); + } + + @Test + public void testClonedProfilePresent_personalTabUsesExpectedAdapter() { + markOtherProfileAvailability(/* workAvailable= */ true, /* cloneAvailable= */ true); + List personalResolvedComponentInfos = + createResolvedComponentsForTest(3); + List workResolvedComponentInfos = createResolvedComponentsForTest( + 4); + setupResolverControllers(personalResolvedComponentInfos, workResolvedComponentInfos); + Intent sendIntent = createSendTextIntent(); + sendIntent.setType(TEST_MIME_TYPE); + + + final IChooserWrapper activity = (IChooserWrapper) + mActivityRule.launchActivity(Intent.createChooser(sendIntent, "multi tab test")); + waitForIdle(); + + assertThat(activity.getCurrentUserHandle(), is(PERSONAL_USER_HANDLE)); + } + + private Intent createChooserIntent(Intent intent, Intent[] initialIntents) { + Intent chooserIntent = new Intent(); + chooserIntent.setAction(Intent.ACTION_CHOOSER); + chooserIntent.putExtra(Intent.EXTRA_TEXT, "testing intent sending"); + chooserIntent.putExtra(Intent.EXTRA_TITLE, "some title"); + chooserIntent.putExtra(Intent.EXTRA_INTENT, intent); + chooserIntent.setType("text/plain"); + if (initialIntents != null) { + chooserIntent.putExtra(Intent.EXTRA_INITIAL_INTENTS, initialIntents); + } + return chooserIntent; + } + + /* This is a "test of a test" to make sure that our inherited test class + * is successfully configured to operate on the unbundled-equivalent + * ChooserWrapperActivity. + * + * TODO: remove after unbundling is complete. + */ + @Test + public void testWrapperActivityHasExpectedConcreteType() { + final ChooserActivity activity = mActivityRule.launchActivity( + Intent.createChooser(new Intent("ACTION_FOO"), "foo")); + waitForIdle(); + assertThat(activity).isInstanceOf(ChooserWrapperActivity.class); + } + + private ResolveInfo createFakeResolveInfo() { + ResolveInfo ri = new ResolveInfo(); + ri.activityInfo = new ActivityInfo(); + ri.activityInfo.name = "FakeActivityName"; + ri.activityInfo.packageName = "fake.package.name"; + ri.activityInfo.applicationInfo = new ApplicationInfo(); + ri.activityInfo.applicationInfo.packageName = "fake.package.name"; + ri.userHandle = UserHandle.CURRENT; + return ri; + } + + private Intent createSendTextIntent() { + Intent sendIntent = new Intent(); + sendIntent.setAction(Intent.ACTION_SEND); + sendIntent.putExtra(Intent.EXTRA_TEXT, "testing intent sending"); + sendIntent.setType("text/plain"); + return sendIntent; + } + + private Intent createSendImageIntent(Uri imageThumbnail) { + Intent sendIntent = new Intent(); + sendIntent.setAction(Intent.ACTION_SEND); + sendIntent.putExtra(Intent.EXTRA_STREAM, imageThumbnail); + sendIntent.setType("image/png"); + if (imageThumbnail != null) { + ClipData.Item clipItem = new ClipData.Item(imageThumbnail); + sendIntent.setClipData(new ClipData("Clip Label", new String[]{"image/png"}, clipItem)); + } + + return sendIntent; + } + + private Uri createTestContentProviderUri( + @Nullable String mimeType, @Nullable String streamType) { + return createTestContentProviderUri(mimeType, streamType, 0); + } + + private Uri createTestContentProviderUri( + @Nullable String mimeType, @Nullable String streamType, long streamTypeTimeout) { + String packageName = + InstrumentationRegistry.getInstrumentation().getContext().getPackageName(); + Uri.Builder builder = Uri.parse("content://" + packageName + "/image.png") + .buildUpon(); + if (mimeType != null) { + builder.appendQueryParameter(TestContentProvider.PARAM_MIME_TYPE, mimeType); + } + if (streamType != null) { + builder.appendQueryParameter(TestContentProvider.PARAM_STREAM_TYPE, streamType); + } + if (streamTypeTimeout > 0) { + builder.appendQueryParameter( + TestContentProvider.PARAM_STREAM_TYPE_TIMEOUT, + Long.toString(streamTypeTimeout)); + } + return builder.build(); + } + + private Intent createSendTextIntentWithPreview(String title, Uri imageThumbnail) { + Intent sendIntent = new Intent(); + sendIntent.setAction(Intent.ACTION_SEND); + sendIntent.putExtra(Intent.EXTRA_TEXT, "testing intent sending"); + sendIntent.putExtra(Intent.EXTRA_TITLE, title); + if (imageThumbnail != null) { + ClipData.Item clipItem = new ClipData.Item(imageThumbnail); + sendIntent.setClipData(new ClipData("Clip Label", new String[]{"image/png"}, clipItem)); + } + + return sendIntent; + } + + private Intent createSendUriIntentWithPreview(ArrayList uris) { + Intent sendIntent = new Intent(); + + if (uris.size() > 1) { + sendIntent.setAction(Intent.ACTION_SEND_MULTIPLE); + sendIntent.putExtra(Intent.EXTRA_STREAM, uris); + } else { + sendIntent.setAction(Intent.ACTION_SEND); + sendIntent.putExtra(Intent.EXTRA_STREAM, uris.get(0)); + } + + return sendIntent; + } + + private Intent createViewTextIntent() { + Intent viewIntent = new Intent(); + viewIntent.setAction(Intent.ACTION_VIEW); + viewIntent.putExtra(Intent.EXTRA_TEXT, "testing intent viewing"); + return viewIntent; + } + + private List createResolvedComponentsForTest(int numberOfResults) { + List infoList = new ArrayList<>(numberOfResults); + for (int i = 0; i < numberOfResults; i++) { + infoList.add(ResolverDataProvider.createResolvedComponentInfo(i, PERSONAL_USER_HANDLE)); + } + return infoList; + } + + private List createResolvedComponentsWithCloneProfileForTest( + int numberOfResults, + UserHandle resolvedForPersonalUser, + UserHandle resolvedForClonedUser) { + List infoList = new ArrayList<>(numberOfResults); + for (int i = 0; i < 1; i++) { + infoList.add(ResolverDataProvider.createResolvedComponentInfo(i, + resolvedForPersonalUser)); + } + for (int i = 1; i < numberOfResults; i++) { + infoList.add(ResolverDataProvider.createResolvedComponentInfo(i, + resolvedForClonedUser)); + } + return infoList; + } + + private List createResolvedComponentsForTestWithOtherProfile( + int numberOfResults) { + List infoList = new ArrayList<>(numberOfResults); + for (int i = 0; i < numberOfResults; i++) { + if (i == 0) { + infoList.add(ResolverDataProvider.createResolvedComponentInfoWithOtherId(i, + PERSONAL_USER_HANDLE)); + } else { + infoList.add(ResolverDataProvider.createResolvedComponentInfo(i, + PERSONAL_USER_HANDLE)); + } + } + return infoList; + } + + private List createResolvedComponentsForTestWithOtherProfile( + int numberOfResults, int userId) { + List infoList = new ArrayList<>(numberOfResults); + for (int i = 0; i < numberOfResults; i++) { + if (i == 0) { + infoList.add( + ResolverDataProvider.createResolvedComponentInfoWithOtherId(i, userId, + PERSONAL_USER_HANDLE)); + } else { + infoList.add(ResolverDataProvider.createResolvedComponentInfo(i, + PERSONAL_USER_HANDLE)); + } + } + return infoList; + } + + private List createResolvedComponentsForTestWithUserId( + int numberOfResults, int userId) { + List infoList = new ArrayList<>(numberOfResults); + for (int i = 0; i < numberOfResults; i++) { + infoList.add(ResolverDataProvider.createResolvedComponentInfoWithOtherId(i, userId, + PERSONAL_USER_HANDLE)); + } + return infoList; + } + + private List createDirectShareTargets(int numberOfResults, String packageName) { + Icon icon = Icon.createWithBitmap(createBitmap()); + String testTitle = "testTitle"; + List targets = new ArrayList<>(); + for (int i = 0; i < numberOfResults; i++) { + ComponentName componentName; + if (packageName.isEmpty()) { + componentName = ResolverDataProvider.createComponentName(i); + } else { + componentName = new ComponentName(packageName, packageName + ".class"); + } + ChooserTarget tempTarget = new ChooserTarget( + testTitle + i, + icon, + (float) (1 - ((i + 1) / 10.0)), + componentName, + null); + targets.add(tempTarget); + } + return targets; + } + + private void waitForIdle() { + InstrumentationRegistry.getInstrumentation().waitForIdleSync(); + } + + private boolean launchActivityWithTimeout(Intent intent, long timeout) + throws InterruptedException { + final int initialState = 0; + final int completedState = 1; + final int timeoutState = 2; + final AtomicInteger state = new AtomicInteger(initialState); + final CountDownLatch cdl = new CountDownLatch(1); + + ScheduledExecutorService executor = Executors.newScheduledThreadPool(2); + try { + executor.execute(() -> { + mActivityRule.launchActivity(intent); + state.compareAndSet(initialState, completedState); + cdl.countDown(); + }); + executor.schedule( + () -> { + state.compareAndSet(initialState, timeoutState); + cdl.countDown(); + }, + timeout, + TimeUnit.MILLISECONDS); + cdl.await(); + return state.get() == completedState; + } finally { + executor.shutdownNow(); + } + } + + private Bitmap createBitmap() { + return createBitmap(200, 200); + } + + private Bitmap createWideBitmap() { + return createWideBitmap(Color.RED); + } + + private Bitmap createWideBitmap(int bgColor) { + WindowManager windowManager = InstrumentationRegistry.getInstrumentation() + .getTargetContext() + .getSystemService(WindowManager.class); + int width = 3000; + if (windowManager != null) { + Rect bounds = windowManager.getMaximumWindowMetrics().getBounds(); + width = bounds.width() + 200; + } + return createBitmap(width, 100, bgColor); + } + + private Bitmap createBitmap(int width, int height) { + return createBitmap(width, height, Color.RED); + } + + private Bitmap createBitmap(int width, int height, int bgColor) { + Bitmap bitmap = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888); + Canvas canvas = new Canvas(bitmap); + + Paint paint = new Paint(); + paint.setColor(bgColor); + paint.setStyle(Paint.Style.FILL); + canvas.drawPaint(paint); + + paint.setColor(Color.WHITE); + paint.setAntiAlias(true); + paint.setTextSize(14.f); + paint.setTextAlign(Paint.Align.CENTER); + canvas.drawText("Hi!", (width / 2.f), (height / 2.f), paint); + + return bitmap; + } + + private List createShortcuts(Context context) { + Intent testIntent = new Intent("TestIntent"); + + List shortcuts = new ArrayList<>(); + shortcuts.add(new ShareShortcutInfo( + new ShortcutInfo.Builder(context, "shortcut1") + .setIntent(testIntent).setShortLabel("label1").setRank(3).build(), // 0 2 + new ComponentName("package1", "class1"))); + shortcuts.add(new ShareShortcutInfo( + new ShortcutInfo.Builder(context, "shortcut2") + .setIntent(testIntent).setShortLabel("label2").setRank(7).build(), // 1 3 + new ComponentName("package2", "class2"))); + shortcuts.add(new ShareShortcutInfo( + new ShortcutInfo.Builder(context, "shortcut3") + .setIntent(testIntent).setShortLabel("label3").setRank(1).build(), // 2 0 + new ComponentName("package3", "class3"))); + shortcuts.add(new ShareShortcutInfo( + new ShortcutInfo.Builder(context, "shortcut4") + .setIntent(testIntent).setShortLabel("label4").setRank(3).build(), // 3 2 + new ComponentName("package4", "class4"))); + + return shortcuts; + } + + private void markOtherProfileAvailability(boolean workAvailable, boolean cloneAvailable) { + AnnotatedUserHandles.Builder handles = AnnotatedUserHandles.newBuilder(); + handles + .setUserIdOfCallingApp(1234) // Must be non-negative. + .setUserHandleSharesheetLaunchedAs(PERSONAL_USER_HANDLE) + .setPersonalProfileUserHandle(PERSONAL_USER_HANDLE); + if (workAvailable) { + handles.setWorkProfileUserHandle(WORK_PROFILE_USER_HANDLE); + } + if (cloneAvailable) { + handles.setCloneProfileUserHandle(CLONE_PROFILE_USER_HANDLE); + } + ChooserWrapperActivity.sOverrides.annotatedUserHandles = handles.build(); + } + + private void setupResolverControllers( + List personalResolvedComponentInfos) { + setupResolverControllers(personalResolvedComponentInfos, new ArrayList<>()); + } + + private void setupResolverControllers( + List personalResolvedComponentInfos, + List workResolvedComponentInfos) { + when( + ChooserActivityOverrideData + .getInstance() + .resolverListController + .getResolversForIntentAsUser( + Mockito.anyBoolean(), + Mockito.anyBoolean(), + Mockito.anyBoolean(), + Mockito.isA(List.class), + eq(UserHandle.SYSTEM))) + .thenReturn(new ArrayList<>(personalResolvedComponentInfos)); + when( + ChooserActivityOverrideData + .getInstance() + .workResolverListController + .getResolversForIntentAsUser( + Mockito.anyBoolean(), + Mockito.anyBoolean(), + Mockito.anyBoolean(), + Mockito.isA(List.class), + eq(UserHandle.SYSTEM))) + .thenReturn(new ArrayList<>(personalResolvedComponentInfos)); + when( + ChooserActivityOverrideData + .getInstance() + .workResolverListController + .getResolversForIntentAsUser( + Mockito.anyBoolean(), + Mockito.anyBoolean(), + Mockito.anyBoolean(), + Mockito.isA(List.class), + eq(UserHandle.of(10)))) + .thenReturn(new ArrayList<>(workResolvedComponentInfos)); + } + + private static GridRecyclerSpanCountMatcher withGridColumnCount(int columnCount) { + return new GridRecyclerSpanCountMatcher(Matchers.is(columnCount)); + } + + private static class GridRecyclerSpanCountMatcher extends + BoundedDiagnosingMatcher { + + private final Matcher mIntegerMatcher; + + private GridRecyclerSpanCountMatcher(Matcher integerMatcher) { + super(RecyclerView.class); + this.mIntegerMatcher = integerMatcher; + } + + @Override + protected void describeMoreTo(Description description) { + description.appendText("RecyclerView grid layout span count to match: "); + this.mIntegerMatcher.describeTo(description); + } + + @Override + protected boolean matchesSafely(RecyclerView view, Description mismatchDescription) { + int spanCount = ((GridLayoutManager) view.getLayoutManager()).getSpanCount(); + if (this.mIntegerMatcher.matches(spanCount)) { + return true; + } else { + mismatchDescription.appendText("RecyclerView grid layout span count was ") + .appendValue(spanCount); + return false; + } + } + } + + private void givenAppTargets(int appCount) { + List resolvedComponentInfos = + createResolvedComponentsForTest(appCount); + setupResolverControllers(resolvedComponentInfos); + } + + private void updateMaxTargetsPerRowResource(int targetsPerRow) { + Resources resources = Mockito.spy( + InstrumentationRegistry.getInstrumentation().getContext().getResources()); + ChooserActivityOverrideData.getInstance().resources = resources; + doReturn(targetsPerRow).when(resources).getInteger( + R.integer.config_chooser_max_targets_per_row); + } + + private SparseArray>> + createShortcutLoaderFactory() { + SparseArray>> shortcutLoaders = + new SparseArray<>(); + ChooserActivityOverrideData.getInstance().shortcutLoaderFactory = + (userHandle, callback) -> { + Pair> pair = + new Pair<>(mock(ShortcutLoader.class), callback); + shortcutLoaders.put(userHandle.getIdentifier(), pair); + return pair.first; + }; + return shortcutLoaders; + } + + private static ImageLoader createImageLoader(Uri uri, Bitmap bitmap) { + return new TestPreviewImageLoader(Collections.singletonMap(uri, bitmap)); + } +} diff --git a/java/tests/src/com/android/intentresolver/v2/UnbundledChooserActivityWorkProfileTest.java b/java/tests/src/com/android/intentresolver/v2/UnbundledChooserActivityWorkProfileTest.java new file mode 100644 index 00000000..e4ec1776 --- /dev/null +++ b/java/tests/src/com/android/intentresolver/v2/UnbundledChooserActivityWorkProfileTest.java @@ -0,0 +1,481 @@ +/* + * Copyright (C) 2022 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.intentresolver.v2; + +import static android.testing.PollingCheck.waitFor; +import static androidx.test.espresso.Espresso.onView; +import static androidx.test.espresso.action.ViewActions.click; +import static androidx.test.espresso.action.ViewActions.swipeUp; +import static androidx.test.espresso.assertion.ViewAssertions.matches; +import static androidx.test.espresso.matcher.ViewMatchers.isDisplayed; +import static androidx.test.espresso.matcher.ViewMatchers.isSelected; +import static androidx.test.espresso.matcher.ViewMatchers.withId; +import static androidx.test.espresso.matcher.ViewMatchers.withText; +import static com.android.intentresolver.v2.ChooserWrapperActivity.sOverrides; +import static com.android.intentresolver.v2.UnbundledChooserActivityWorkProfileTest.TestCase.ExpectedBlocker.NO_BLOCKER; +import static com.android.intentresolver.v2.UnbundledChooserActivityWorkProfileTest.TestCase.ExpectedBlocker.PERSONAL_PROFILE_ACCESS_BLOCKER; +import static com.android.intentresolver.v2.UnbundledChooserActivityWorkProfileTest.TestCase.ExpectedBlocker.PERSONAL_PROFILE_SHARE_BLOCKER; +import static com.android.intentresolver.v2.UnbundledChooserActivityWorkProfileTest.TestCase.ExpectedBlocker.WORK_PROFILE_ACCESS_BLOCKER; +import static com.android.intentresolver.v2.UnbundledChooserActivityWorkProfileTest.TestCase.ExpectedBlocker.WORK_PROFILE_SHARE_BLOCKER; +import static com.android.intentresolver.v2.UnbundledChooserActivityWorkProfileTest.TestCase.Tab.PERSONAL; +import static com.android.intentresolver.v2.UnbundledChooserActivityWorkProfileTest.TestCase.Tab.WORK; +import static org.hamcrest.CoreMatchers.not; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.when; + +import android.companion.DeviceFilter; +import android.content.Intent; +import android.os.UserHandle; + +import androidx.test.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.v2.UnbundledChooserActivityWorkProfileTest.TestCase.Tab; + +import junit.framework.AssertionFailedError; + +import org.junit.Before; +import org.junit.Rule; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.junit.runners.Parameterized; +import org.mockito.Mockito; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collection; +import java.util.List; + +import dagger.hilt.android.testing.HiltAndroidRule; +import dagger.hilt.android.testing.HiltAndroidTest; + +@DeviceFilter.MediumType +@RunWith(Parameterized.class) +@HiltAndroidTest +public class UnbundledChooserActivityWorkProfileTest { + + private static final UserHandle PERSONAL_USER_HANDLE = InstrumentationRegistry + .getInstrumentation().getTargetContext().getUser(); + private static final UserHandle WORK_USER_HANDLE = UserHandle.of(10); + + @Rule(order = 0) + public HiltAndroidRule mHiltAndroidRule = new HiltAndroidRule(this); + + @Rule(order = 1) + public ActivityTestRule mActivityRule = + new ActivityTestRule<>(ChooserWrapperActivity.class, false, + false); + private final TestCase mTestCase; + + public UnbundledChooserActivityWorkProfileTest(TestCase testCase) { + mTestCase = testCase; + } + + @Before + public void cleanOverrideData() { + // TODO: use the other form of `adoptShellPermissionIdentity()` where we explicitly list the + // permissions we require (which we'll read from the manifest at runtime). + InstrumentationRegistry + .getInstrumentation() + .getUiAutomation() + .adoptShellPermissionIdentity(); + + sOverrides.reset(); + } + + @Test + public void testBlocker() { + setUpPersonalAndWorkComponentInfos(); + sOverrides.hasCrossProfileIntents = mTestCase.hasCrossProfileIntents(); + + launchActivity(mTestCase.getIsSendAction()); + switchToTab(mTestCase.getTab()); + + switch (mTestCase.getExpectedBlocker()) { + case NO_BLOCKER: + assertNoBlockerDisplayed(); + break; + case PERSONAL_PROFILE_SHARE_BLOCKER: + assertCantSharePersonalAppsBlockerDisplayed(); + break; + case WORK_PROFILE_SHARE_BLOCKER: + assertCantShareWorkAppsBlockerDisplayed(); + break; + case PERSONAL_PROFILE_ACCESS_BLOCKER: + assertCantAccessPersonalAppsBlockerDisplayed(); + break; + case WORK_PROFILE_ACCESS_BLOCKER: + assertCantAccessWorkAppsBlockerDisplayed(); + break; + } + } + + @Parameterized.Parameters(name = "{0}") + public static Collection tests() { + return Arrays.asList( + new TestCase( + /* isSendAction= */ true, + /* hasCrossProfileIntents= */ true, + /* myUserHandle= */ WORK_USER_HANDLE, + /* tab= */ WORK, + /* expectedBlocker= */ NO_BLOCKER + ), + new TestCase( + /* isSendAction= */ true, + /* hasCrossProfileIntents= */ false, + /* myUserHandle= */ WORK_USER_HANDLE, + /* tab= */ WORK, + /* expectedBlocker= */ NO_BLOCKER + ), + new TestCase( + /* isSendAction= */ true, + /* hasCrossProfileIntents= */ true, + /* myUserHandle= */ PERSONAL_USER_HANDLE, + /* tab= */ WORK, + /* expectedBlocker= */ NO_BLOCKER + ), + new TestCase( + /* isSendAction= */ true, + /* hasCrossProfileIntents= */ false, + /* myUserHandle= */ PERSONAL_USER_HANDLE, + /* tab= */ WORK, + /* expectedBlocker= */ WORK_PROFILE_SHARE_BLOCKER + ), + new TestCase( + /* isSendAction= */ true, + /* hasCrossProfileIntents= */ true, + /* myUserHandle= */ WORK_USER_HANDLE, + /* tab= */ PERSONAL, + /* expectedBlocker= */ NO_BLOCKER + ), + new TestCase( + /* isSendAction= */ true, + /* hasCrossProfileIntents= */ false, + /* myUserHandle= */ WORK_USER_HANDLE, + /* tab= */ PERSONAL, + /* expectedBlocker= */ PERSONAL_PROFILE_SHARE_BLOCKER + ), + new TestCase( + /* isSendAction= */ true, + /* hasCrossProfileIntents= */ true, + /* myUserHandle= */ PERSONAL_USER_HANDLE, + /* tab= */ PERSONAL, + /* expectedBlocker= */ NO_BLOCKER + ), + new TestCase( + /* isSendAction= */ true, + /* hasCrossProfileIntents= */ false, + /* myUserHandle= */ PERSONAL_USER_HANDLE, + /* tab= */ PERSONAL, + /* expectedBlocker= */ NO_BLOCKER + ), + new TestCase( + /* isSendAction= */ false, + /* hasCrossProfileIntents= */ true, + /* myUserHandle= */ WORK_USER_HANDLE, + /* tab= */ WORK, + /* expectedBlocker= */ NO_BLOCKER + ), + new TestCase( + /* isSendAction= */ false, + /* hasCrossProfileIntents= */ false, + /* myUserHandle= */ WORK_USER_HANDLE, + /* tab= */ WORK, + /* expectedBlocker= */ NO_BLOCKER + ), + new TestCase( + /* isSendAction= */ false, + /* hasCrossProfileIntents= */ true, + /* myUserHandle= */ PERSONAL_USER_HANDLE, + /* tab= */ WORK, + /* expectedBlocker= */ NO_BLOCKER + ), + new TestCase( + /* isSendAction= */ false, + /* hasCrossProfileIntents= */ false, + /* myUserHandle= */ PERSONAL_USER_HANDLE, + /* tab= */ WORK, + /* expectedBlocker= */ WORK_PROFILE_ACCESS_BLOCKER + ), + new TestCase( + /* isSendAction= */ false, + /* hasCrossProfileIntents= */ true, + /* myUserHandle= */ WORK_USER_HANDLE, + /* tab= */ PERSONAL, + /* expectedBlocker= */ NO_BLOCKER + ), + new TestCase( + /* isSendAction= */ false, + /* hasCrossProfileIntents= */ false, + /* myUserHandle= */ WORK_USER_HANDLE, + /* tab= */ PERSONAL, + /* expectedBlocker= */ PERSONAL_PROFILE_ACCESS_BLOCKER + ), + new TestCase( + /* isSendAction= */ false, + /* hasCrossProfileIntents= */ true, + /* myUserHandle= */ PERSONAL_USER_HANDLE, + /* tab= */ PERSONAL, + /* expectedBlocker= */ NO_BLOCKER + ), + new TestCase( + /* isSendAction= */ false, + /* hasCrossProfileIntents= */ false, + /* myUserHandle= */ PERSONAL_USER_HANDLE, + /* tab= */ PERSONAL, + /* expectedBlocker= */ NO_BLOCKER + ) + ); + } + + private List createResolvedComponentsForTestWithOtherProfile( + int numberOfResults, int userId, UserHandle resolvedForUser) { + List infoList = new ArrayList<>(numberOfResults); + for (int i = 0; i < numberOfResults; i++) { + infoList.add( + ResolverDataProvider + .createResolvedComponentInfoWithOtherId(i, userId, resolvedForUser)); + } + return infoList; + } + + private List createResolvedComponentsForTest(int numberOfResults, + UserHandle resolvedForUser) { + List infoList = new ArrayList<>(numberOfResults); + for (int i = 0; i < numberOfResults; i++) { + infoList.add(ResolverDataProvider.createResolvedComponentInfo(i, resolvedForUser)); + } + return infoList; + } + + private void setUpPersonalAndWorkComponentInfos() { + ChooserWrapperActivity.sOverrides.annotatedUserHandles = AnnotatedUserHandles.newBuilder() + .setUserIdOfCallingApp(1234) // Must be non-negative. + .setUserHandleSharesheetLaunchedAs(mTestCase.getMyUserHandle()) + .setPersonalProfileUserHandle(PERSONAL_USER_HANDLE) + .setWorkProfileUserHandle(WORK_USER_HANDLE) + .build(); + int workProfileTargets = 4; + List personalResolvedComponentInfos = + createResolvedComponentsForTestWithOtherProfile(3, + /* userId */ WORK_USER_HANDLE.getIdentifier(), PERSONAL_USER_HANDLE); + List workResolvedComponentInfos = + createResolvedComponentsForTest(workProfileTargets, WORK_USER_HANDLE); + setupResolverControllers(personalResolvedComponentInfos, workResolvedComponentInfos); + } + + private void setupResolverControllers( + List personalResolvedComponentInfos, + List workResolvedComponentInfos) { + when(sOverrides.resolverListController.getResolversForIntentAsUser( + Mockito.anyBoolean(), + Mockito.anyBoolean(), + Mockito.anyBoolean(), + Mockito.isA(List.class), + eq(UserHandle.SYSTEM))) + .thenReturn(new ArrayList<>(personalResolvedComponentInfos)); + when(sOverrides.workResolverListController.getResolversForIntentAsUser( + Mockito.anyBoolean(), + Mockito.anyBoolean(), + Mockito.anyBoolean(), + Mockito.isA(List.class), + eq(UserHandle.SYSTEM))) + .thenReturn(new ArrayList<>(personalResolvedComponentInfos)); + when(sOverrides.workResolverListController.getResolversForIntentAsUser( + Mockito.anyBoolean(), + Mockito.anyBoolean(), + Mockito.anyBoolean(), + Mockito.isA(List.class), + eq(WORK_USER_HANDLE))) + .thenReturn(new ArrayList<>(workResolvedComponentInfos)); + } + + private void waitForIdle() { + InstrumentationRegistry.getInstrumentation().waitForIdleSync(); + } + + private void assertCantAccessWorkAppsBlockerDisplayed() { + onView(withText(R.string.resolver_cross_profile_blocked)) + .check(matches(isDisplayed())); + onView(withText(R.string.resolver_cant_access_work_apps_explanation)) + .check(matches(isDisplayed())); + } + + private void assertCantAccessPersonalAppsBlockerDisplayed() { + onView(withText(R.string.resolver_cross_profile_blocked)) + .check(matches(isDisplayed())); + onView(withText(R.string.resolver_cant_access_personal_apps_explanation)) + .check(matches(isDisplayed())); + } + + private void assertCantShareWorkAppsBlockerDisplayed() { + onView(withText(R.string.resolver_cross_profile_blocked)) + .check(matches(isDisplayed())); + onView(withText(R.string.resolver_cant_share_with_work_apps_explanation)) + .check(matches(isDisplayed())); + } + + private void assertCantSharePersonalAppsBlockerDisplayed() { + onView(withText(R.string.resolver_cross_profile_blocked)) + .check(matches(isDisplayed())); + onView(withText(R.string.resolver_cant_share_with_personal_apps_explanation)) + .check(matches(isDisplayed())); + } + + private void assertNoBlockerDisplayed() { + try { + onView(withText(R.string.resolver_cross_profile_blocked)) + .check(matches(not(isDisplayed()))); + } catch (NoMatchingViewException ignored) { + } + } + + private void switchToTab(Tab tab) { + final int stringId = tab == Tab.WORK ? R.string.resolver_work_tab + : R.string.resolver_personal_tab; + + waitFor(() -> { + onView(withText(stringId)).perform(click()); + waitForIdle(); + + try { + onView(withText(stringId)).check(matches(isSelected())); + return true; + } catch (AssertionFailedError e) { + return false; + } + }); + + onView(withId(com.android.internal.R.id.contentPanel)) + .perform(swipeUp()); + waitForIdle(); + } + + private Intent createTextIntent(boolean isSendAction) { + Intent sendIntent = new Intent(); + if (isSendAction) { + sendIntent.setAction(Intent.ACTION_SEND); + } + sendIntent.putExtra(Intent.EXTRA_TEXT, "testing intent sending"); + sendIntent.setType("text/plain"); + return sendIntent; + } + + private void launchActivity(boolean isSendAction) { + Intent sendIntent = createTextIntent(isSendAction); + mActivityRule.launchActivity(Intent.createChooser(sendIntent, "Test")); + waitForIdle(); + } + + public static class TestCase { + private final boolean mIsSendAction; + private final boolean mHasCrossProfileIntents; + private final UserHandle mMyUserHandle; + private final Tab mTab; + private final ExpectedBlocker mExpectedBlocker; + + public enum ExpectedBlocker { + NO_BLOCKER, + PERSONAL_PROFILE_SHARE_BLOCKER, + WORK_PROFILE_SHARE_BLOCKER, + PERSONAL_PROFILE_ACCESS_BLOCKER, + WORK_PROFILE_ACCESS_BLOCKER + } + + public enum Tab { + WORK, + PERSONAL + } + + public TestCase(boolean isSendAction, boolean hasCrossProfileIntents, + UserHandle myUserHandle, Tab tab, ExpectedBlocker expectedBlocker) { + mIsSendAction = isSendAction; + mHasCrossProfileIntents = hasCrossProfileIntents; + mMyUserHandle = myUserHandle; + mTab = tab; + mExpectedBlocker = expectedBlocker; + } + + public boolean getIsSendAction() { + return mIsSendAction; + } + + public boolean hasCrossProfileIntents() { + return mHasCrossProfileIntents; + } + + public UserHandle getMyUserHandle() { + return mMyUserHandle; + } + + public Tab getTab() { + return mTab; + } + + public ExpectedBlocker getExpectedBlocker() { + return mExpectedBlocker; + } + + @Override + public String toString() { + StringBuilder result = new StringBuilder("test"); + + if (mTab == WORK) { + result.append("WorkTab_"); + } else { + result.append("PersonalTab_"); + } + + if (mIsSendAction) { + result.append("sendAction_"); + } else { + result.append("notSendAction_"); + } + + if (mHasCrossProfileIntents) { + result.append("hasCrossProfileIntents_"); + } else { + result.append("doesNotHaveCrossProfileIntents_"); + } + + if (mMyUserHandle.equals(PERSONAL_USER_HANDLE)) { + result.append("myUserIsPersonal_"); + } else { + result.append("myUserIsWork_"); + } + + if (mExpectedBlocker == ExpectedBlocker.NO_BLOCKER) { + result.append("thenNoBlocker"); + } else if (mExpectedBlocker == PERSONAL_PROFILE_ACCESS_BLOCKER) { + result.append("thenAccessBlockerOnPersonalProfile"); + } else if (mExpectedBlocker == PERSONAL_PROFILE_SHARE_BLOCKER) { + result.append("thenShareBlockerOnPersonalProfile"); + } else if (mExpectedBlocker == WORK_PROFILE_ACCESS_BLOCKER) { + result.append("thenAccessBlockerOnWorkProfile"); + } else if (mExpectedBlocker == WORK_PROFILE_SHARE_BLOCKER) { + result.append("thenShareBlockerOnWorkProfile"); + } + + return result.toString(); + } + } +} -- cgit v1.2.3-59-g8ed1b From 989de76169699beee2c92a8ce41bc636896a66b6 Mon Sep 17 00:00:00 2001 From: Joshua Trask Date: Wed, 4 Oct 2023 15:31:47 +0000 Subject: `v2` boilerplate for ongoing empty-state work. Just cutting over to our new experiment infrastructure before I continue porting over the changes that were originally prototyped in ag/24516421. Bug: 302311217 Test: IntentResolverUnitTests Change-Id: Ied1843bee2be6ffb42ba4f539f6168a9d07a77d9 --- .../android/intentresolver/ChooserListAdapter.java | 2 +- .../ChooserRecyclerViewAccessibilityDelegate.java | 4 +- .../intentresolver/ResolverListAdapter.java | 6 +- .../android/intentresolver/v2/ChooserActivity.java | 5 +- .../v2/ChooserMultiProfilePagerAdapter.java | 218 ++++++++ .../v2/MultiProfilePagerAdapter.java | 582 +++++++++++++++++++++ .../intentresolver/v2/ResolverActivity.java | 9 +- .../v2/ResolverMultiProfilePagerAdapter.java | 122 +++++ .../v2/emptystate/EmptyStateUiHelper.java | 63 +++ .../v2/MultiProfilePagerAdapterTest.kt | 282 ++++++++++ .../v2/emptystate/EmptyStateUiHelperTest.kt | 112 ++++ 11 files changed, 1392 insertions(+), 13 deletions(-) create mode 100644 java/src/com/android/intentresolver/v2/ChooserMultiProfilePagerAdapter.java create mode 100644 java/src/com/android/intentresolver/v2/MultiProfilePagerAdapter.java create mode 100644 java/src/com/android/intentresolver/v2/ResolverMultiProfilePagerAdapter.java create mode 100644 java/src/com/android/intentresolver/v2/emptystate/EmptyStateUiHelper.java create mode 100644 java/tests/src/com/android/intentresolver/v2/MultiProfilePagerAdapterTest.kt create mode 100644 java/tests/src/com/android/intentresolver/v2/emptystate/EmptyStateUiHelperTest.kt (limited to 'java/src') diff --git a/java/src/com/android/intentresolver/ChooserListAdapter.java b/java/src/com/android/intentresolver/ChooserListAdapter.java index ec8800b8..35258317 100644 --- a/java/src/com/android/intentresolver/ChooserListAdapter.java +++ b/java/src/com/android/intentresolver/ChooserListAdapter.java @@ -293,7 +293,7 @@ public class ChooserListAdapter extends ResolverListAdapter { } @Override - protected boolean rebuildList(boolean doPostProcessing) { + public boolean rebuildList(boolean doPostProcessing) { mAnimationTracker.reset(); mSortedList.clear(); boolean result = super.rebuildList(doPostProcessing); diff --git a/java/src/com/android/intentresolver/ChooserRecyclerViewAccessibilityDelegate.java b/java/src/com/android/intentresolver/ChooserRecyclerViewAccessibilityDelegate.java index 250b6827..3f6a3437 100644 --- a/java/src/com/android/intentresolver/ChooserRecyclerViewAccessibilityDelegate.java +++ b/java/src/com/android/intentresolver/ChooserRecyclerViewAccessibilityDelegate.java @@ -25,11 +25,11 @@ import android.view.accessibility.AccessibilityEvent; import androidx.recyclerview.widget.RecyclerView; import androidx.recyclerview.widget.RecyclerViewAccessibilityDelegate; -class ChooserRecyclerViewAccessibilityDelegate extends RecyclerViewAccessibilityDelegate { +public class ChooserRecyclerViewAccessibilityDelegate extends RecyclerViewAccessibilityDelegate { private final Rect mTempRect = new Rect(); private final int[] mConsumed = new int[2]; - ChooserRecyclerViewAccessibilityDelegate(RecyclerView recyclerView) { + public ChooserRecyclerViewAccessibilityDelegate(RecyclerView recyclerView) { super(recyclerView); } diff --git a/java/src/com/android/intentresolver/ResolverListAdapter.java b/java/src/com/android/intentresolver/ResolverListAdapter.java index 8c0d414c..d1e8c15b 100644 --- a/java/src/com/android/intentresolver/ResolverListAdapter.java +++ b/java/src/com/android/intentresolver/ResolverListAdapter.java @@ -252,7 +252,7 @@ public class ResolverListAdapter extends BaseAdapter { * with {@code rebuildCompleted} true at the end of some newly-launched asynchronous work. * Otherwise the callback is only queued once, with {@code rebuildCompleted} true. */ - protected boolean rebuildList(boolean doPostProcessing) { + public boolean rebuildList(boolean doPostProcessing) { Trace.beginSection("ResolverListAdapter#rebuildList"); mDisplayList.clear(); mIsTabLoaded = false; @@ -545,7 +545,7 @@ public class ResolverListAdapter extends BaseAdapter { * after the list has been rebuilt * @param rebuildCompleted Whether the list has been completely rebuilt */ - void postListReadyRunnable(boolean doPostProcessing, boolean rebuildCompleted) { + public void postListReadyRunnable(boolean doPostProcessing, boolean rebuildCompleted) { Runnable listReadyRunnable = new Runnable() { @Override public void run() { @@ -838,7 +838,7 @@ public class ResolverListAdapter extends BaseAdapter { return mIsTabLoaded; } - protected void markTabLoaded() { + public void markTabLoaded() { mIsTabLoaded = true; } diff --git a/java/src/com/android/intentresolver/v2/ChooserActivity.java b/java/src/com/android/intentresolver/v2/ChooserActivity.java index 9e437010..a755b9e9 100644 --- a/java/src/com/android/intentresolver/v2/ChooserActivity.java +++ b/java/src/com/android/intentresolver/v2/ChooserActivity.java @@ -23,7 +23,9 @@ import static android.app.admin.DevicePolicyResources.Strings.Core.RESOLVER_CANT import static android.app.admin.DevicePolicyResources.Strings.Core.RESOLVER_CROSS_PROFILE_BLOCKED_TITLE; import static android.stats.devicepolicy.nano.DevicePolicyEnums.RESOLVER_EMPTY_STATE_NO_SHARING_TO_PERSONAL; import static android.stats.devicepolicy.nano.DevicePolicyEnums.RESOLVER_EMPTY_STATE_NO_SHARING_TO_WORK; + import static androidx.lifecycle.LifecycleKt.getCoroutineScope; + import static com.android.intentresolver.v2.ResolverActivity.PROFILE_PERSONAL; import static com.android.intentresolver.v2.ResolverActivity.PROFILE_WORK; import static com.android.internal.util.LatencyTracker.ACTION_LOAD_SHARE_SHEET; @@ -78,7 +80,6 @@ import com.android.intentresolver.ChooserActionFactory; import com.android.intentresolver.ChooserGridLayoutManager; import com.android.intentresolver.ChooserIntegratedDeviceComponents; import com.android.intentresolver.ChooserListAdapter; -import com.android.intentresolver.ChooserMultiProfilePagerAdapter; import com.android.intentresolver.ChooserRefinementManager; import com.android.intentresolver.ChooserRequestParameters; import com.android.intentresolver.ChooserStackedAppDialogFragment; @@ -112,8 +113,8 @@ 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.widget.ImagePreviewView; import com.android.intentresolver.v2.Hilt_ChooserActivity; +import com.android.intentresolver.widget.ImagePreviewView; import com.android.internal.annotations.VisibleForTesting; import com.android.internal.content.PackageMonitor; import com.android.internal.logging.nano.MetricsProto.MetricsEvent; diff --git a/java/src/com/android/intentresolver/v2/ChooserMultiProfilePagerAdapter.java b/java/src/com/android/intentresolver/v2/ChooserMultiProfilePagerAdapter.java new file mode 100644 index 00000000..d3c9efea --- /dev/null +++ b/java/src/com/android/intentresolver/v2/ChooserMultiProfilePagerAdapter.java @@ -0,0 +1,218 @@ +/* + * Copyright (C) 2019 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.intentresolver.v2; + +import android.content.Context; +import android.os.UserHandle; +import android.view.LayoutInflater; +import android.view.ViewGroup; + +import androidx.recyclerview.widget.GridLayoutManager; +import androidx.recyclerview.widget.RecyclerView; +import androidx.viewpager.widget.PagerAdapter; + +import com.android.intentresolver.ChooserListAdapter; +import com.android.intentresolver.ChooserRecyclerViewAccessibilityDelegate; +import com.android.intentresolver.FeatureFlags; +import com.android.intentresolver.R; +import com.android.intentresolver.emptystate.EmptyStateProvider; +import com.android.intentresolver.grid.ChooserGridAdapter; +import com.android.intentresolver.measurements.Tracer; +import com.android.internal.annotations.VisibleForTesting; + +import com.google.common.collect.ImmutableList; + +import java.util.Optional; +import java.util.function.Supplier; + +/** + * A {@link PagerAdapter} which describes the work and personal profile share sheet screens. + */ +@VisibleForTesting +public class ChooserMultiProfilePagerAdapter extends MultiProfilePagerAdapter< + RecyclerView, ChooserGridAdapter, ChooserListAdapter> { + private static final int SINGLE_CELL_SPAN_SIZE = 1; + + private final ChooserProfileAdapterBinder mAdapterBinder; + private final BottomPaddingOverrideSupplier mBottomPaddingOverrideSupplier; + + public ChooserMultiProfilePagerAdapter( + Context context, + ChooserGridAdapter adapter, + EmptyStateProvider emptyStateProvider, + Supplier workProfileQuietModeChecker, + UserHandle workProfileUserHandle, + UserHandle cloneProfileUserHandle, + int maxTargetsPerRow, + FeatureFlags featureFlags) { + this( + context, + new ChooserProfileAdapterBinder(maxTargetsPerRow), + ImmutableList.of(adapter), + emptyStateProvider, + workProfileQuietModeChecker, + /* defaultProfile= */ 0, + workProfileUserHandle, + cloneProfileUserHandle, + new BottomPaddingOverrideSupplier(context), + featureFlags); + } + + public ChooserMultiProfilePagerAdapter( + Context context, + ChooserGridAdapter personalAdapter, + ChooserGridAdapter workAdapter, + EmptyStateProvider emptyStateProvider, + Supplier workProfileQuietModeChecker, + @Profile int defaultProfile, + UserHandle workProfileUserHandle, + UserHandle cloneProfileUserHandle, + int maxTargetsPerRow, + FeatureFlags featureFlags) { + this( + context, + new ChooserProfileAdapterBinder(maxTargetsPerRow), + ImmutableList.of(personalAdapter, workAdapter), + emptyStateProvider, + workProfileQuietModeChecker, + defaultProfile, + workProfileUserHandle, + cloneProfileUserHandle, + new BottomPaddingOverrideSupplier(context), + featureFlags); + } + + private ChooserMultiProfilePagerAdapter( + Context context, + ChooserProfileAdapterBinder adapterBinder, + ImmutableList gridAdapters, + EmptyStateProvider emptyStateProvider, + Supplier workProfileQuietModeChecker, + @Profile int defaultProfile, + UserHandle workProfileUserHandle, + UserHandle cloneProfileUserHandle, + BottomPaddingOverrideSupplier bottomPaddingOverrideSupplier, + FeatureFlags featureFlags) { + super( + gridAdapter -> gridAdapter.getListAdapter(), + adapterBinder, + gridAdapters, + emptyStateProvider, + workProfileQuietModeChecker, + defaultProfile, + workProfileUserHandle, + cloneProfileUserHandle, + () -> makeProfileView(context, featureFlags), + bottomPaddingOverrideSupplier); + mAdapterBinder = adapterBinder; + mBottomPaddingOverrideSupplier = bottomPaddingOverrideSupplier; + } + + public void setMaxTargetsPerRow(int maxTargetsPerRow) { + mAdapterBinder.setMaxTargetsPerRow(maxTargetsPerRow); + } + + public void setEmptyStateBottomOffset(int bottomOffset) { + mBottomPaddingOverrideSupplier.setEmptyStateBottomOffset(bottomOffset); + } + + /** + * Notify adapter about the drawer's collapse state. This will affect the app divider's + * visibility. + */ + public void setIsCollapsed(boolean isCollapsed) { + for (int i = 0, size = getItemCount(); i < size; i++) { + getAdapterForIndex(i).setAzLabelVisibility(!isCollapsed); + } + } + + private static ViewGroup makeProfileView( + Context context, FeatureFlags featureFlags) { + LayoutInflater inflater = LayoutInflater.from(context); + ViewGroup rootView = featureFlags.scrollablePreview() + ? (ViewGroup) inflater.inflate(R.layout.chooser_list_per_profile_wrap, null, false) + : (ViewGroup) inflater.inflate(R.layout.chooser_list_per_profile, null, false); + RecyclerView recyclerView = rootView.findViewById(com.android.internal.R.id.resolver_list); + recyclerView.setAccessibilityDelegateCompat( + new ChooserRecyclerViewAccessibilityDelegate(recyclerView)); + return rootView; + } + + @Override + public boolean rebuildActiveTab(boolean doPostProcessing) { + if (doPostProcessing) { + Tracer.INSTANCE.beginAppTargetLoadingSection(getActiveListAdapter().getUserHandle()); + } + return super.rebuildActiveTab(doPostProcessing); + } + + @Override + public boolean rebuildInactiveTab(boolean doPostProcessing) { + if (getItemCount() != 1 && doPostProcessing) { + Tracer.INSTANCE.beginAppTargetLoadingSection(getInactiveListAdapter().getUserHandle()); + } + return super.rebuildInactiveTab(doPostProcessing); + } + + private static class BottomPaddingOverrideSupplier implements Supplier> { + private final Context mContext; + private int mBottomOffset; + + BottomPaddingOverrideSupplier(Context context) { + mContext = context; + } + + public void setEmptyStateBottomOffset(int bottomOffset) { + mBottomOffset = bottomOffset; + } + + public Optional get() { + int initialBottomPadding = mContext.getResources().getDimensionPixelSize( + R.dimen.resolver_empty_state_container_padding_bottom); + return Optional.of(initialBottomPadding + mBottomOffset); + } + } + + private static class ChooserProfileAdapterBinder implements + AdapterBinder { + private int mMaxTargetsPerRow; + + ChooserProfileAdapterBinder(int maxTargetsPerRow) { + mMaxTargetsPerRow = maxTargetsPerRow; + } + + public void setMaxTargetsPerRow(int maxTargetsPerRow) { + mMaxTargetsPerRow = maxTargetsPerRow; + } + + @Override + public void bind( + RecyclerView recyclerView, ChooserGridAdapter chooserGridAdapter) { + GridLayoutManager glm = (GridLayoutManager) recyclerView.getLayoutManager(); + glm.setSpanCount(mMaxTargetsPerRow); + glm.setSpanSizeLookup( + new GridLayoutManager.SpanSizeLookup() { + @Override + public int getSpanSize(int position) { + return chooserGridAdapter.shouldCellSpan(position) + ? SINGLE_CELL_SPAN_SIZE + : glm.getSpanCount(); + } + }); + } + } +} diff --git a/java/src/com/android/intentresolver/v2/MultiProfilePagerAdapter.java b/java/src/com/android/intentresolver/v2/MultiProfilePagerAdapter.java new file mode 100644 index 00000000..b2a167e1 --- /dev/null +++ b/java/src/com/android/intentresolver/v2/MultiProfilePagerAdapter.java @@ -0,0 +1,582 @@ +/* + * Copyright (C) 2019 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.android.intentresolver.v2; + +import android.annotation.IntDef; +import android.annotation.Nullable; +import android.os.Trace; +import android.os.UserHandle; +import android.view.View; +import android.view.ViewGroup; +import android.widget.Button; +import android.widget.TextView; + +import androidx.viewpager.widget.PagerAdapter; +import androidx.viewpager.widget.ViewPager; + +import com.android.intentresolver.ResolverListAdapter; +import com.android.intentresolver.emptystate.EmptyState; +import com.android.intentresolver.emptystate.EmptyStateProvider; +import com.android.intentresolver.v2.emptystate.EmptyStateUiHelper; +import com.android.internal.annotations.VisibleForTesting; + +import com.google.common.collect.ImmutableList; + +import java.util.HashSet; +import java.util.Optional; +import java.util.Set; +import java.util.function.Function; +import java.util.function.Supplier; + +/** + * Skeletal {@link PagerAdapter} implementation for a UI with per-profile tabs (as in Sharesheet). + * + * TODO: attempt to further restrict visibility/improve encapsulation in the methods we expose. + * TODO: deprecate and audit/fix usages of any methods that refer to the "active" or "inactive" + * adapters; these were marked {@link VisibleForTesting} and their usage seems like an accident + * waiting to happen since clients seem to make assumptions about which adapter will be "active" in + * a particular context, and more explicit APIs would make sure those were valid. + * TODO: consider renaming legacy methods (e.g. why do we know it's a "list", not just a "page"?) + * + * @param the type of the widget that represents the contents of a page in this adapter + * @param the type of a "root" adapter class to be instantiated and included in + * the per-profile records. + * @param the concrete type of a {@link ResolverListAdapter} implementation to + * control the contents of a given per-profile list. This is provided for convenience, since it must + * be possible to get the list adapter from the page adapter via our {@link mListAdapterExtractor}. + * + * TODO: this is part of an in-progress refactor to merge with `GenericMultiProfilePagerAdapter`. + * As originally noted there, we've reduced explicit references to the `ResolverListAdapter` base + * type and may be able to drop the type constraint. + */ +public class MultiProfilePagerAdapter< + PageViewT extends ViewGroup, + SinglePageAdapterT, + ListAdapterT extends ResolverListAdapter> extends PagerAdapter { + + /** + * Delegate to set up a given adapter and page view to be used together. + * @param (as in {@link MultiProfilePagerAdapter}). + * @param (as in {@link MultiProfilePagerAdapter}). + */ + public interface AdapterBinder { + /** + * The given {@code view} will be associated with the given {@code adapter}. Do any work + * necessary to configure them compatibly, introduce them to each other, etc. + */ + void bind(PageViewT view, SinglePageAdapterT adapter); + } + + public static final int PROFILE_PERSONAL = 0; + public static final int PROFILE_WORK = 1; + + @IntDef({PROFILE_PERSONAL, PROFILE_WORK}) + public @interface Profile {} + + private final Function mListAdapterExtractor; + private final AdapterBinder mAdapterBinder; + private final Supplier mPageViewInflater; + private final Supplier> mContainerBottomPaddingOverrideSupplier; + + private final ImmutableList> mItems; + + private final EmptyStateProvider mEmptyStateProvider; + private final UserHandle mWorkProfileUserHandle; + private final UserHandle mCloneProfileUserHandle; + private final Supplier mWorkProfileQuietModeChecker; // True when work is quiet. + + private Set mLoadedPages; + private int mCurrentPage; + private OnProfileSelectedListener mOnProfileSelectedListener; + + protected MultiProfilePagerAdapter( + Function listAdapterExtractor, + AdapterBinder adapterBinder, + ImmutableList adapters, + EmptyStateProvider emptyStateProvider, + Supplier workProfileQuietModeChecker, + @Profile int defaultProfile, + UserHandle workProfileUserHandle, + UserHandle cloneProfileUserHandle, + Supplier pageViewInflater, + Supplier> containerBottomPaddingOverrideSupplier) { + mCurrentPage = defaultProfile; + mLoadedPages = new HashSet<>(); + mWorkProfileUserHandle = workProfileUserHandle; + mCloneProfileUserHandle = cloneProfileUserHandle; + mEmptyStateProvider = emptyStateProvider; + mWorkProfileQuietModeChecker = workProfileQuietModeChecker; + + mListAdapterExtractor = listAdapterExtractor; + mAdapterBinder = adapterBinder; + mPageViewInflater = pageViewInflater; + mContainerBottomPaddingOverrideSupplier = containerBottomPaddingOverrideSupplier; + + ImmutableList.Builder> items = + new ImmutableList.Builder<>(); + for (SinglePageAdapterT adapter : adapters) { + items.add(createProfileDescriptor(adapter)); + } + mItems = items.build(); + } + + private ProfileDescriptor createProfileDescriptor( + SinglePageAdapterT adapter) { + return new ProfileDescriptor<>(mPageViewInflater.get(), adapter); + } + + public void setOnProfileSelectedListener(OnProfileSelectedListener listener) { + mOnProfileSelectedListener = listener; + } + + /** + * Sets this instance of this class as {@link ViewPager}'s {@link PagerAdapter} and sets + * an {@link ViewPager.OnPageChangeListener} where it keeps track of the currently displayed + * page and rebuilds the list. + */ + public void setupViewPager(ViewPager viewPager) { + viewPager.setOnPageChangeListener(new ViewPager.SimpleOnPageChangeListener() { + @Override + public void onPageSelected(int position) { + mCurrentPage = position; + if (!mLoadedPages.contains(position)) { + rebuildActiveTab(true); + mLoadedPages.add(position); + } + if (mOnProfileSelectedListener != null) { + mOnProfileSelectedListener.onProfileSelected(position); + } + } + + @Override + public void onPageScrollStateChanged(int state) { + if (mOnProfileSelectedListener != null) { + mOnProfileSelectedListener.onProfilePageStateChanged(state); + } + } + }); + viewPager.setAdapter(this); + viewPager.setCurrentItem(mCurrentPage); + mLoadedPages.add(mCurrentPage); + } + + public void clearInactiveProfileCache() { + if (mLoadedPages.size() == 1) { + return; + } + mLoadedPages.remove(1 - mCurrentPage); + } + + @Override + public final ViewGroup instantiateItem(ViewGroup container, int position) { + setupListAdapter(position); + final ProfileDescriptor descriptor = getItem(position); + container.addView(descriptor.mRootView); + return descriptor.mRootView; + } + + @Override + public void destroyItem(ViewGroup container, int position, Object view) { + container.removeView((View) view); + } + + @Override + public int getCount() { + return getItemCount(); + } + + public int getCurrentPage() { + return mCurrentPage; + } + + @VisibleForTesting + public UserHandle getCurrentUserHandle() { + return getActiveListAdapter().getUserHandle(); + } + + @Override + public boolean isViewFromObject(View view, Object object) { + return view == object; + } + + @Override + public CharSequence getPageTitle(int position) { + return null; + } + + public UserHandle getCloneUserHandle() { + return mCloneProfileUserHandle; + } + + /** + * Returns the {@link ProfileDescriptor} relevant to the given pageIndex. + *

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

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

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

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

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

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

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

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

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

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

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

This callback is only called when the intent resolver or share sheet shows + * the work and personal profiles. + * @param profileIndex {@link #PROFILE_PERSONAL} if the personal profile was selected or + * {@link #PROFILE_WORK} if the work profile was selected. + */ + void onProfileSelected(int profileIndex); + + + /** + * Callback for when the scroll state changes. Useful for discovering when the user begins + * dragging, when the pager is automatically settling to the current page, or when it is + * fully stopped/idle. + * @param state {@link ViewPager#SCROLL_STATE_IDLE}, {@link ViewPager#SCROLL_STATE_DRAGGING} + * or {@link ViewPager#SCROLL_STATE_SETTLING} + * @see ViewPager.OnPageChangeListener#onPageScrollStateChanged + */ + void onProfilePageStateChanged(int state); + } + + /** + * Listener for when the user switches on the work profile from the work tab. + */ + public interface OnSwitchOnWorkSelectedListener { + /** + * Callback for when the user switches on the work profile from the work tab. + */ + void onSwitchOnWorkSelected(); + } +} diff --git a/java/src/com/android/intentresolver/v2/ResolverActivity.java b/java/src/com/android/intentresolver/v2/ResolverActivity.java index dd6842aa..03221c6c 100644 --- a/java/src/com/android/intentresolver/v2/ResolverActivity.java +++ b/java/src/com/android/intentresolver/v2/ResolverActivity.java @@ -33,6 +33,7 @@ import static android.content.PermissionChecker.PID_UNKNOWN; import static android.stats.devicepolicy.nano.DevicePolicyEnums.RESOLVER_EMPTY_STATE_NO_SHARING_TO_PERSONAL; import static android.stats.devicepolicy.nano.DevicePolicyEnums.RESOLVER_EMPTY_STATE_NO_SHARING_TO_WORK; import static android.view.WindowManager.LayoutParams.SYSTEM_FLAG_HIDE_NON_SYSTEM_OVERLAY_WINDOWS; + import static com.android.internal.annotations.VisibleForTesting.Visibility.PROTECTED; import android.annotation.Nullable; @@ -99,14 +100,9 @@ import androidx.fragment.app.FragmentActivity; import androidx.viewpager.widget.ViewPager; import com.android.intentresolver.AnnotatedUserHandles; -import com.android.intentresolver.MultiProfilePagerAdapter; -import com.android.intentresolver.MultiProfilePagerAdapter.MyUserIdProvider; -import com.android.intentresolver.MultiProfilePagerAdapter.OnSwitchOnWorkSelectedListener; -import com.android.intentresolver.MultiProfilePagerAdapter.Profile; import com.android.intentresolver.R; import com.android.intentresolver.ResolverListAdapter; import com.android.intentresolver.ResolverListController; -import com.android.intentresolver.ResolverMultiProfilePagerAdapter; import com.android.intentresolver.WorkProfileAvailabilityManager; import com.android.intentresolver.chooser.DisplayResolveInfo; import com.android.intentresolver.chooser.TargetInfo; @@ -121,6 +117,9 @@ import com.android.intentresolver.emptystate.WorkProfilePausedEmptyStateProvider import com.android.intentresolver.icons.DefaultTargetDataLoader; import com.android.intentresolver.icons.TargetDataLoader; import com.android.intentresolver.model.ResolverRankerServiceResolverComparator; +import com.android.intentresolver.v2.MultiProfilePagerAdapter.MyUserIdProvider; +import com.android.intentresolver.v2.MultiProfilePagerAdapter.OnSwitchOnWorkSelectedListener; +import com.android.intentresolver.v2.MultiProfilePagerAdapter.Profile; import com.android.intentresolver.widget.ResolverDrawerLayout; import com.android.internal.annotations.VisibleForTesting; import com.android.internal.content.PackageMonitor; diff --git a/java/src/com/android/intentresolver/v2/ResolverMultiProfilePagerAdapter.java b/java/src/com/android/intentresolver/v2/ResolverMultiProfilePagerAdapter.java new file mode 100644 index 00000000..dadc9c0f --- /dev/null +++ b/java/src/com/android/intentresolver/v2/ResolverMultiProfilePagerAdapter.java @@ -0,0 +1,122 @@ +/* + * Copyright (C) 2019 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.intentresolver.v2; + +import android.content.Context; +import android.os.UserHandle; +import android.view.LayoutInflater; +import android.view.ViewGroup; +import android.widget.ListView; + +import androidx.viewpager.widget.PagerAdapter; + +import com.android.intentresolver.R; +import com.android.intentresolver.ResolverListAdapter; +import com.android.intentresolver.emptystate.EmptyStateProvider; +import com.android.internal.annotations.VisibleForTesting; + +import com.google.common.collect.ImmutableList; + +import java.util.Optional; +import java.util.function.Supplier; + +/** + * A {@link PagerAdapter} which describes the work and personal profile intent resolver screens. + */ +@VisibleForTesting +public class ResolverMultiProfilePagerAdapter extends + MultiProfilePagerAdapter { + private final BottomPaddingOverrideSupplier mBottomPaddingOverrideSupplier; + + public ResolverMultiProfilePagerAdapter( + Context context, + ResolverListAdapter adapter, + EmptyStateProvider emptyStateProvider, + Supplier workProfileQuietModeChecker, + UserHandle workProfileUserHandle, + UserHandle cloneProfileUserHandle) { + this( + context, + ImmutableList.of(adapter), + emptyStateProvider, + workProfileQuietModeChecker, + /* defaultProfile= */ 0, + workProfileUserHandle, + cloneProfileUserHandle, + new BottomPaddingOverrideSupplier()); + } + + public ResolverMultiProfilePagerAdapter(Context context, + ResolverListAdapter personalAdapter, + ResolverListAdapter workAdapter, + EmptyStateProvider emptyStateProvider, + Supplier workProfileQuietModeChecker, + @Profile int defaultProfile, + UserHandle workProfileUserHandle, + UserHandle cloneProfileUserHandle) { + this( + context, + ImmutableList.of(personalAdapter, workAdapter), + emptyStateProvider, + workProfileQuietModeChecker, + defaultProfile, + workProfileUserHandle, + cloneProfileUserHandle, + new BottomPaddingOverrideSupplier()); + } + + private ResolverMultiProfilePagerAdapter( + Context context, + ImmutableList listAdapters, + EmptyStateProvider emptyStateProvider, + Supplier workProfileQuietModeChecker, + @Profile int defaultProfile, + UserHandle workProfileUserHandle, + UserHandle cloneProfileUserHandle, + BottomPaddingOverrideSupplier bottomPaddingOverrideSupplier) { + super( + listAdapter -> listAdapter, + (listView, bindAdapter) -> listView.setAdapter(bindAdapter), + listAdapters, + emptyStateProvider, + workProfileQuietModeChecker, + defaultProfile, + workProfileUserHandle, + cloneProfileUserHandle, + () -> (ViewGroup) LayoutInflater.from(context).inflate( + R.layout.resolver_list_per_profile, null, false), + bottomPaddingOverrideSupplier); + mBottomPaddingOverrideSupplier = bottomPaddingOverrideSupplier; + } + + public void setUseLayoutWithDefault(boolean useLayoutWithDefault) { + mBottomPaddingOverrideSupplier.setUseLayoutWithDefault(useLayoutWithDefault); + } + + private static class BottomPaddingOverrideSupplier implements Supplier> { + private boolean mUseLayoutWithDefault; + + public void setUseLayoutWithDefault(boolean useLayoutWithDefault) { + mUseLayoutWithDefault = useLayoutWithDefault; + } + + @Override + public Optional get() { + return mUseLayoutWithDefault ? Optional.empty() : Optional.of(0); + } + } +} diff --git a/java/src/com/android/intentresolver/v2/emptystate/EmptyStateUiHelper.java b/java/src/com/android/intentresolver/v2/emptystate/EmptyStateUiHelper.java new file mode 100644 index 00000000..7230b042 --- /dev/null +++ b/java/src/com/android/intentresolver/v2/emptystate/EmptyStateUiHelper.java @@ -0,0 +1,63 @@ +/* + * Copyright (C) 2023 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.android.intentresolver.v2.emptystate; + +import android.view.View; +import android.view.ViewGroup; + +/** + * Helper for building `MultiProfilePagerAdapter` tab UIs for profile tabs that are "blocked" by + * some empty-state status. + */ +public class EmptyStateUiHelper { + private final View mEmptyStateView; + + public EmptyStateUiHelper(ViewGroup rootView) { + mEmptyStateView = + rootView.requireViewById(com.android.internal.R.id.resolver_empty_state); + } + + public void resetViewVisibilities() { + mEmptyStateView.requireViewById(com.android.internal.R.id.resolver_empty_state_title) + .setVisibility(View.VISIBLE); + mEmptyStateView.requireViewById(com.android.internal.R.id.resolver_empty_state_subtitle) + .setVisibility(View.VISIBLE); + mEmptyStateView.requireViewById(com.android.internal.R.id.resolver_empty_state_button) + .setVisibility(View.INVISIBLE); + mEmptyStateView.requireViewById(com.android.internal.R.id.resolver_empty_state_progress) + .setVisibility(View.GONE); + mEmptyStateView.requireViewById(com.android.internal.R.id.empty) + .setVisibility(View.GONE); + mEmptyStateView.setVisibility(View.VISIBLE); + } + + public void showSpinner() { + mEmptyStateView.requireViewById(com.android.internal.R.id.resolver_empty_state_title) + .setVisibility(View.INVISIBLE); + // TODO: subtitle? + mEmptyStateView.requireViewById(com.android.internal.R.id.resolver_empty_state_button) + .setVisibility(View.INVISIBLE); + mEmptyStateView.requireViewById(com.android.internal.R.id.resolver_empty_state_progress) + .setVisibility(View.VISIBLE); + mEmptyStateView.requireViewById(com.android.internal.R.id.empty) + .setVisibility(View.GONE); + } + + public void hide() { + mEmptyStateView.setVisibility(View.GONE); + } +} + diff --git a/java/tests/src/com/android/intentresolver/v2/MultiProfilePagerAdapterTest.kt b/java/tests/src/com/android/intentresolver/v2/MultiProfilePagerAdapterTest.kt new file mode 100644 index 00000000..f1af9790 --- /dev/null +++ b/java/tests/src/com/android/intentresolver/v2/MultiProfilePagerAdapterTest.kt @@ -0,0 +1,282 @@ +/* + * Copyright (C) 2023 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.intentresolver.v2 + +import android.os.UserHandle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.ListView +import androidx.test.platform.app.InstrumentationRegistry +import com.android.intentresolver.MultiProfilePagerAdapter.PROFILE_PERSONAL +import com.android.intentresolver.MultiProfilePagerAdapter.PROFILE_WORK +import com.android.intentresolver.R +import com.android.intentresolver.ResolverListAdapter +import com.android.intentresolver.any +import com.android.intentresolver.emptystate.EmptyStateProvider +import com.android.intentresolver.mock +import com.android.intentresolver.whenever +import com.google.common.collect.ImmutableList +import com.google.common.truth.Truth.assertThat +import java.util.Optional +import java.util.function.Supplier +import org.junit.Test +import org.mockito.Mockito.never +import org.mockito.Mockito.verify + +class MultiProfilePagerAdapterTest { + private val PERSONAL_USER_HANDLE = UserHandle.of(10) + private val WORK_USER_HANDLE = UserHandle.of(20) + + private val context = InstrumentationRegistry.getInstrumentation().getContext() + private val inflater = Supplier { + LayoutInflater.from(context).inflate(R.layout.resolver_list_per_profile, null, false) + as ViewGroup + } + + @Test + fun testSinglePageProfileAdapter() { + val personalListAdapter = + mock { whenever(getUserHandle()).thenReturn(PERSONAL_USER_HANDLE) } + val pagerAdapter = + MultiProfilePagerAdapter( + { listAdapter: ResolverListAdapter -> listAdapter }, + { listView: ListView, bindAdapter: ResolverListAdapter -> + listView.setAdapter(bindAdapter) + }, + ImmutableList.of(personalListAdapter), + object : EmptyStateProvider {}, + { false }, + PROFILE_PERSONAL, + null, + null, + inflater, + { Optional.empty() } + ) + assertThat(pagerAdapter.count).isEqualTo(1) + assertThat(pagerAdapter.currentPage).isEqualTo(PROFILE_PERSONAL) + assertThat(pagerAdapter.currentUserHandle).isEqualTo(PERSONAL_USER_HANDLE) + assertThat(pagerAdapter.getAdapterForIndex(0)).isSameInstanceAs(personalListAdapter) + assertThat(pagerAdapter.activeListAdapter).isSameInstanceAs(personalListAdapter) + assertThat(pagerAdapter.inactiveListAdapter).isNull() + assertThat(pagerAdapter.personalListAdapter).isSameInstanceAs(personalListAdapter) + assertThat(pagerAdapter.workListAdapter).isNull() + assertThat(pagerAdapter.itemCount).isEqualTo(1) + // TODO: consider covering some of the package-private methods (and making them public?). + // TODO: consider exercising responsibilities as an implementation of a ViewPager adapter. + } + + @Test + fun testTwoProfilePagerAdapter() { + val personalListAdapter = + mock { whenever(getUserHandle()).thenReturn(PERSONAL_USER_HANDLE) } + val workListAdapter = + mock { whenever(getUserHandle()).thenReturn(WORK_USER_HANDLE) } + val pagerAdapter = + MultiProfilePagerAdapter( + { listAdapter: ResolverListAdapter -> listAdapter }, + { listView: ListView, bindAdapter: ResolverListAdapter -> + listView.setAdapter(bindAdapter) + }, + ImmutableList.of(personalListAdapter, workListAdapter), + object : EmptyStateProvider {}, + { false }, + PROFILE_PERSONAL, + WORK_USER_HANDLE, // TODO: why does this test pass even if this is null? + null, + inflater, + { Optional.empty() } + ) + assertThat(pagerAdapter.count).isEqualTo(2) + assertThat(pagerAdapter.currentPage).isEqualTo(PROFILE_PERSONAL) + assertThat(pagerAdapter.currentUserHandle).isEqualTo(PERSONAL_USER_HANDLE) + assertThat(pagerAdapter.getAdapterForIndex(0)).isSameInstanceAs(personalListAdapter) + assertThat(pagerAdapter.getAdapterForIndex(1)).isSameInstanceAs(workListAdapter) + assertThat(pagerAdapter.activeListAdapter).isSameInstanceAs(personalListAdapter) + assertThat(pagerAdapter.inactiveListAdapter).isSameInstanceAs(workListAdapter) + assertThat(pagerAdapter.personalListAdapter).isSameInstanceAs(personalListAdapter) + assertThat(pagerAdapter.workListAdapter).isSameInstanceAs(workListAdapter) + assertThat(pagerAdapter.itemCount).isEqualTo(2) + // TODO: consider covering some of the package-private methods (and making them public?). + // TODO: consider exercising responsibilities as an implementation of a ViewPager adapter; + // especially matching profiles to ListViews? + // TODO: test ProfileSelectedListener (and getters for "current" state) as the selected + // page changes. Currently there's no API to change the selected page directly; that's + // only possible through manipulation of the bound ViewPager. + } + + @Test + fun testTwoProfilePagerAdapter_workIsDefault() { + val personalListAdapter = + mock { whenever(getUserHandle()).thenReturn(PERSONAL_USER_HANDLE) } + val workListAdapter = + mock { whenever(getUserHandle()).thenReturn(WORK_USER_HANDLE) } + val pagerAdapter = + MultiProfilePagerAdapter( + { listAdapter: ResolverListAdapter -> listAdapter }, + { listView: ListView, bindAdapter: ResolverListAdapter -> + listView.setAdapter(bindAdapter) + }, + ImmutableList.of(personalListAdapter, workListAdapter), + object : EmptyStateProvider {}, + { false }, + PROFILE_WORK, // <-- This test specifically requests we start on work profile. + WORK_USER_HANDLE, // TODO: why does this test pass even if this is null? + null, + inflater, + { Optional.empty() } + ) + assertThat(pagerAdapter.count).isEqualTo(2) + assertThat(pagerAdapter.currentPage).isEqualTo(PROFILE_WORK) + assertThat(pagerAdapter.currentUserHandle).isEqualTo(WORK_USER_HANDLE) + assertThat(pagerAdapter.getAdapterForIndex(0)).isSameInstanceAs(personalListAdapter) + assertThat(pagerAdapter.getAdapterForIndex(1)).isSameInstanceAs(workListAdapter) + assertThat(pagerAdapter.activeListAdapter).isSameInstanceAs(workListAdapter) + assertThat(pagerAdapter.inactiveListAdapter).isSameInstanceAs(personalListAdapter) + assertThat(pagerAdapter.personalListAdapter).isSameInstanceAs(personalListAdapter) + assertThat(pagerAdapter.workListAdapter).isSameInstanceAs(workListAdapter) + assertThat(pagerAdapter.itemCount).isEqualTo(2) + // TODO: consider covering some of the package-private methods (and making them public?). + // TODO: test ProfileSelectedListener (and getters for "current" state) as the selected + // page changes. Currently there's no API to change the selected page directly; that's + // only possible through manipulation of the bound ViewPager. + } + + @Test + fun testBottomPaddingDelegate_default() { + val container = + mock { + whenever(getPaddingLeft()).thenReturn(1) + whenever(getPaddingTop()).thenReturn(2) + whenever(getPaddingRight()).thenReturn(3) + whenever(getPaddingBottom()).thenReturn(4) + } + val pagerAdapter = + MultiProfilePagerAdapter( + { listAdapter: ResolverListAdapter -> listAdapter }, + { listView: ListView, bindAdapter: ResolverListAdapter -> + listView.setAdapter(bindAdapter) + }, + ImmutableList.of(), + object : EmptyStateProvider {}, + { false }, + PROFILE_PERSONAL, + null, + null, + inflater, + { Optional.empty() } + ) + pagerAdapter.setupContainerPadding(container) + verify(container, never()).setPadding(any(), any(), any(), any()) + } + + @Test + fun testBottomPaddingDelegate_override() { + val container = + mock { + whenever(getPaddingLeft()).thenReturn(1) + whenever(getPaddingTop()).thenReturn(2) + whenever(getPaddingRight()).thenReturn(3) + whenever(getPaddingBottom()).thenReturn(4) + } + val pagerAdapter = + MultiProfilePagerAdapter( + { listAdapter: ResolverListAdapter -> listAdapter }, + { listView: ListView, bindAdapter: ResolverListAdapter -> + listView.setAdapter(bindAdapter) + }, + ImmutableList.of(), + object : EmptyStateProvider {}, + { false }, + PROFILE_PERSONAL, + null, + null, + inflater, + { Optional.of(42) } + ) + pagerAdapter.setupContainerPadding(container) + verify(container).setPadding(1, 2, 3, 42) + } + + @Test + fun testPresumedQuietModeEmptyStateForWorkProfile_whenQuiet() { + // TODO: this is "presumed" because the conditions to determine whether we "should" show an + // empty state aren't enforced to align with the conditions when we actually *would* -- I + // believe `shouldShowEmptyStateScreen` should be implemented in terms of the provider? + val personalListAdapter = + mock { + whenever(getUserHandle()).thenReturn(PERSONAL_USER_HANDLE) + whenever(getUnfilteredCount()).thenReturn(1) + } + val workListAdapter = + mock { + whenever(getUserHandle()).thenReturn(WORK_USER_HANDLE) + whenever(getUnfilteredCount()).thenReturn(1) + } + val pagerAdapter = + MultiProfilePagerAdapter( + { listAdapter: ResolverListAdapter -> listAdapter }, + { listView: ListView, bindAdapter: ResolverListAdapter -> + listView.setAdapter(bindAdapter) + }, + ImmutableList.of(personalListAdapter, workListAdapter), + object : EmptyStateProvider {}, + { true }, // <-- Work mode is quiet. + PROFILE_WORK, + WORK_USER_HANDLE, + null, + inflater, + { Optional.empty() } + ) + assertThat(pagerAdapter.shouldShowEmptyStateScreen(workListAdapter)).isTrue() + assertThat(pagerAdapter.shouldShowEmptyStateScreen(personalListAdapter)).isFalse() + } + + @Test + fun testPresumedQuietModeEmptyStateForWorkProfile_notWhenNotQuiet() { + // TODO: this is "presumed" because the conditions to determine whether we "should" show an + // empty state aren't enforced to align with the conditions when we actually *would* -- I + // believe `shouldShowEmptyStateScreen` should be implemented in terms of the provider? + val personalListAdapter = + mock { + whenever(getUserHandle()).thenReturn(PERSONAL_USER_HANDLE) + whenever(getUnfilteredCount()).thenReturn(1) + } + val workListAdapter = + mock { + whenever(getUserHandle()).thenReturn(WORK_USER_HANDLE) + whenever(getUnfilteredCount()).thenReturn(1) + } + val pagerAdapter = + MultiProfilePagerAdapter( + { listAdapter: ResolverListAdapter -> listAdapter }, + { listView: ListView, bindAdapter: ResolverListAdapter -> + listView.setAdapter(bindAdapter) + }, + ImmutableList.of(personalListAdapter, workListAdapter), + object : EmptyStateProvider {}, + { false }, // <-- Work mode is not quiet. + PROFILE_WORK, + WORK_USER_HANDLE, + null, + inflater, + { Optional.empty() } + ) + assertThat(pagerAdapter.shouldShowEmptyStateScreen(workListAdapter)).isFalse() + assertThat(pagerAdapter.shouldShowEmptyStateScreen(personalListAdapter)).isFalse() + } +} diff --git a/java/tests/src/com/android/intentresolver/v2/emptystate/EmptyStateUiHelperTest.kt b/java/tests/src/com/android/intentresolver/v2/emptystate/EmptyStateUiHelperTest.kt new file mode 100644 index 00000000..12943cd7 --- /dev/null +++ b/java/tests/src/com/android/intentresolver/v2/emptystate/EmptyStateUiHelperTest.kt @@ -0,0 +1,112 @@ +/* + * Copyright (C) 2023 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.intentresolver.v2.emptystate + +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.FrameLayout +import androidx.test.platform.app.InstrumentationRegistry +import com.google.common.truth.Truth.assertThat +import org.junit.Before +import org.junit.Test + +class EmptyStateUiHelperTest { + private val context = InstrumentationRegistry.getInstrumentation().getContext() + + lateinit var rootContainer: ViewGroup + lateinit var emptyStateTitleView: View + lateinit var emptyStateSubtitleView: View + lateinit var emptyStateButtonView: View + lateinit var emptyStateProgressView: View + lateinit var emptyStateDefaultTextView: View + lateinit var emptyStateContainerView: View + lateinit var emptyStateRootView: View + lateinit var emptyStateUiHelper: EmptyStateUiHelper + + @Before + fun setup() { + rootContainer = FrameLayout(context) + LayoutInflater.from(context) + .inflate( + com.android.intentresolver.R.layout.resolver_list_per_profile, + rootContainer, + true + ) + emptyStateRootView = + rootContainer.requireViewById(com.android.internal.R.id.resolver_empty_state) + emptyStateTitleView = + rootContainer.requireViewById(com.android.internal.R.id.resolver_empty_state_title) + emptyStateSubtitleView = + rootContainer.requireViewById(com.android.internal.R.id.resolver_empty_state_subtitle) + emptyStateButtonView = + rootContainer.requireViewById(com.android.internal.R.id.resolver_empty_state_button) + emptyStateProgressView = + rootContainer.requireViewById(com.android.internal.R.id.resolver_empty_state_progress) + emptyStateDefaultTextView = rootContainer.requireViewById(com.android.internal.R.id.empty) + emptyStateContainerView = + rootContainer.requireViewById(com.android.internal.R.id.resolver_empty_state_container) + emptyStateUiHelper = EmptyStateUiHelper(rootContainer) + } + + @Test + fun testResetViewVisibilities() { + // First set each view's visibility to differ from the expected "reset" state so we can then + // assert that they're all reset afterward. + // TODO: for historic reasons "reset" doesn't cover `emptyStateContainerView`; should it? + emptyStateRootView.visibility = View.GONE + emptyStateTitleView.visibility = View.GONE + emptyStateSubtitleView.visibility = View.GONE + emptyStateButtonView.visibility = View.VISIBLE + emptyStateProgressView.visibility = View.VISIBLE + emptyStateDefaultTextView.visibility = View.VISIBLE + + emptyStateUiHelper.resetViewVisibilities() + + assertThat(emptyStateRootView.visibility).isEqualTo(View.VISIBLE) + assertThat(emptyStateTitleView.visibility).isEqualTo(View.VISIBLE) + assertThat(emptyStateSubtitleView.visibility).isEqualTo(View.VISIBLE) + assertThat(emptyStateButtonView.visibility).isEqualTo(View.INVISIBLE) + assertThat(emptyStateProgressView.visibility).isEqualTo(View.GONE) + assertThat(emptyStateDefaultTextView.visibility).isEqualTo(View.GONE) + } + + @Test + fun testShowSpinner() { + emptyStateTitleView.visibility = View.VISIBLE + emptyStateButtonView.visibility = View.VISIBLE + emptyStateProgressView.visibility = View.GONE + emptyStateDefaultTextView.visibility = View.VISIBLE + + emptyStateUiHelper.showSpinner() + + // TODO: should this cover any other views? Subtitle? + assertThat(emptyStateTitleView.visibility).isEqualTo(View.INVISIBLE) + assertThat(emptyStateButtonView.visibility).isEqualTo(View.INVISIBLE) + assertThat(emptyStateProgressView.visibility).isEqualTo(View.VISIBLE) + assertThat(emptyStateDefaultTextView.visibility).isEqualTo(View.GONE) + } + + @Test + fun testHide() { + emptyStateRootView.visibility = View.VISIBLE + + emptyStateUiHelper.hide() + + assertThat(emptyStateRootView.visibility).isEqualTo(View.GONE) + } +} -- cgit v1.2.3-59-g8ed1b From c2f1d0cc70ef6083fd47e055a421fd74f620ea33 Mon Sep 17 00:00:00 2001 From: Joshua Trask Date: Thu, 5 Oct 2023 17:16:40 +0000 Subject: "Bottom padding overrides" -> EmptyStateUiHelper As part of ongoing work to pull low-level empty state responsibilities out of the pager adapter, this CL generally replicates the change prototyped in ag/24516421/4..5, in smaller incremental steps to "show the work." As originally described in that CL: "... moves ownership/usage of the 'container bottom padding override supplier' over to the UI helper; this PagerAdapter dependency is really only used in empty-state logic, so it can be handed over to the extracted component *instead of* retaining it as an ivar in the PagerAdapter." As presented in this CL, the "incremental steps" per-snapshot are: 1. Move `getActiveEmptyStateView()` from `ChooserActivity` to `MultiProfilePagerAdapter`, and expose it in favor of the broader `getEmptyStateView()` method that `ChooserActivity` previously used to implement the lower-level logic. 2. Remove `MultiProfilePagerAdapter.setupContainerPadding()` parameters since they're just derived from a combination of a single hard-coded (resource) symbol, and a concern that already belonged to the pager-adapter (`getActiveEmptyStateView()`). Switch tests to using one of our real layouts so that we can find an empty-state container with the expected View ID. Also update method visibility to show `setupContainerPadding()` isn't intended to be overridden in the more-modern "generic pager" design, and update Javadoc accordingly. 3. Inline `ChooserMultiProfilePagerAdapter.setupContainerPadding()` as part of its `setEmptyStateBottomOffset()`, since the latter method is never called anywhere else (and both concerns already belonged to the specialized pager-adapter). 4. Refactor the internal `setupContainerPadding()` to operate on profile descriptors instead of container Views. This is based on the observation that both callers of `setupContainerPadding()` were passing containers that they had to query from the descriptor's empty-state view anyways, and the lookup used the same View ID in both cases. This change encapsulates those shared concerns at a higher level of abstraction. 5. Move the implementation logic of `setupContainerPadding()` into the descriptor inner-class; given the override-supplier, the rest of the operation can be implemented just using the info available to the descriptor. 6. Move ownership of the override-supplier into the descriptor (to be shared among all descriptor instances). The outer pager-adapter had no remaining need to reference the supplier, and we can easily confirm that this results in `setupContainerPadding()` calls for each descriptor instance using the same supplier instance they would've used before. 7. Moves implementation logic (including [shared] ownership of the override-supplier) from the descriptor down to the empty-state helper which now encapsulates the entire `setupContainerPadding()` operation with zero args. This may require *slightly* more of a leap-of-faith than the other "steps," but note the extensive test coverage: the existing `MultiProfilePagerAdapterTest` covers this exact functionality as integrated into a broader config; parallel unit tests are newly-added in `EmptyStateUiHelper`; and even our "broad-scope" integration tests exercise empty-state logic to _some_ degree. 8. Inlines some client usages to simplify scaffolding that we don't really need after the earlier refactoring changes. Bug: 302311217 Test: IntentResolverUnitTests Change-Id: Icc460ede5b8a0314d5c807dd884e6e2b7044bee9 --- .../android/intentresolver/v2/ChooserActivity.java | 10 +---- .../v2/ChooserMultiProfilePagerAdapter.java | 1 + .../v2/MultiProfilePagerAdapter.java | 46 ++++++++++---------- .../v2/emptystate/EmptyStateUiHelper.java | 22 +++++++++- .../v2/MultiProfilePagerAdapterTest.kt | 49 ++++++++++++---------- .../v2/emptystate/EmptyStateUiHelperTest.kt | 36 +++++++++++++++- 6 files changed, 110 insertions(+), 54 deletions(-) (limited to 'java/src') diff --git a/java/src/com/android/intentresolver/v2/ChooserActivity.java b/java/src/com/android/intentresolver/v2/ChooserActivity.java index a755b9e9..9a8b0e2a 100644 --- a/java/src/com/android/intentresolver/v2/ChooserActivity.java +++ b/java/src/com/android/intentresolver/v2/ChooserActivity.java @@ -1473,7 +1473,8 @@ public class ChooserActivity extends Hilt_ChooserActivity implements rowsToShow--; } } else { - ViewGroup currentEmptyStateView = getActiveEmptyStateView(); + ViewGroup currentEmptyStateView = + mChooserMultiProfilePagerAdapter.getActiveEmptyStateView(); if (currentEmptyStateView.getVisibility() == View.VISIBLE) { offset += currentEmptyStateView.getHeight(); } @@ -1507,11 +1508,6 @@ public class ChooserActivity extends Hilt_ChooserActivity implements return PROFILE_PERSONAL; } - private ViewGroup getActiveEmptyStateView() { - int currentPage = mChooserMultiProfilePagerAdapter.getCurrentPage(); - return mChooserMultiProfilePagerAdapter.getEmptyStateView(currentPage); - } - @Override // ResolverListCommunicator public void onHandlePackagesChanged(ResolverListAdapter listAdapter) { mChooserMultiProfilePagerAdapter.getActiveListAdapter().notifyDataSetChanged(); @@ -1782,8 +1778,6 @@ public class ChooserActivity extends Hilt_ChooserActivity implements if (shouldShowTabs()) { mChooserMultiProfilePagerAdapter .setEmptyStateBottomOffset(insets.getSystemWindowInsetBottom()); - mChooserMultiProfilePagerAdapter.setupContainerPadding( - getActiveEmptyStateView().findViewById(com.android.internal.R.id.resolver_empty_state_container)); } WindowInsets result = super.onApplyWindowInsets(v, insets); diff --git a/java/src/com/android/intentresolver/v2/ChooserMultiProfilePagerAdapter.java b/java/src/com/android/intentresolver/v2/ChooserMultiProfilePagerAdapter.java index d3c9efea..8ca976bc 100644 --- a/java/src/com/android/intentresolver/v2/ChooserMultiProfilePagerAdapter.java +++ b/java/src/com/android/intentresolver/v2/ChooserMultiProfilePagerAdapter.java @@ -128,6 +128,7 @@ public class ChooserMultiProfilePagerAdapter extends MultiProfilePagerAdapter< public void setEmptyStateBottomOffset(int bottomOffset) { mBottomPaddingOverrideSupplier.setEmptyStateBottomOffset(bottomOffset); + setupContainerPadding(); } /** diff --git a/java/src/com/android/intentresolver/v2/MultiProfilePagerAdapter.java b/java/src/com/android/intentresolver/v2/MultiProfilePagerAdapter.java index b2a167e1..ad9614b9 100644 --- a/java/src/com/android/intentresolver/v2/MultiProfilePagerAdapter.java +++ b/java/src/com/android/intentresolver/v2/MultiProfilePagerAdapter.java @@ -89,7 +89,6 @@ public class MultiProfilePagerAdapter< private final Function mListAdapterExtractor; private final AdapterBinder mAdapterBinder; private final Supplier mPageViewInflater; - private final Supplier> mContainerBottomPaddingOverrideSupplier; private final ImmutableList> mItems; @@ -123,19 +122,20 @@ public class MultiProfilePagerAdapter< mListAdapterExtractor = listAdapterExtractor; mAdapterBinder = adapterBinder; mPageViewInflater = pageViewInflater; - mContainerBottomPaddingOverrideSupplier = containerBottomPaddingOverrideSupplier; ImmutableList.Builder> items = new ImmutableList.Builder<>(); for (SinglePageAdapterT adapter : adapters) { - items.add(createProfileDescriptor(adapter)); + items.add(createProfileDescriptor(adapter, containerBottomPaddingOverrideSupplier)); } mItems = items.build(); } private ProfileDescriptor createProfileDescriptor( - SinglePageAdapterT adapter) { - return new ProfileDescriptor<>(mPageViewInflater.get(), adapter); + SinglePageAdapterT adapter, + Supplier> containerBottomPaddingOverrideSupplier) { + return new ProfileDescriptor<>( + mPageViewInflater.get(), adapter, containerBottomPaddingOverrideSupplier); } public void setOnProfileSelectedListener(OnProfileSelectedListener listener) { @@ -235,10 +235,14 @@ public class MultiProfilePagerAdapter< return mItems.get(pageIndex); } - public ViewGroup getEmptyStateView(int pageIndex) { + private ViewGroup getEmptyStateView(int pageIndex) { return getItem(pageIndex).getEmptyStateView(); } + public ViewGroup getActiveEmptyStateView() { + return getEmptyStateView(getCurrentPage()); + } + /** * Returns the number of {@link ProfileDescriptor} objects. *

For a normal consumer device with only one user returns 1. @@ -454,12 +458,10 @@ public class MultiProfilePagerAdapter< descriptor.mRootView.findViewById( com.android.internal.R.id.resolver_list).setVisibility(View.GONE); descriptor.mEmptyStateUi.resetViewVisibilities(); + descriptor.setupContainerPadding(); ViewGroup emptyStateView = descriptor.getEmptyStateView(); - View container = emptyStateView.findViewById( - com.android.internal.R.id.resolver_empty_state_container); - setupContainerPadding(container); TextView titleView = emptyStateView.findViewById( com.android.internal.R.id.resolver_empty_state_title); @@ -493,17 +495,11 @@ public class MultiProfilePagerAdapter< } /** - * Sets up the padding of the view containing the empty state screens. - *

This method is meant to be overridden so that subclasses can customize the padding. + * Sets up the padding of the view containing the empty state screens for the current adapter + * view. */ - public void setupContainerPadding(View container) { - Optional bottomPaddingOverride = mContainerBottomPaddingOverrideSupplier.get(); - bottomPaddingOverride.ifPresent(paddingBottom -> - container.setPadding( - container.getPaddingLeft(), - container.getPaddingTop(), - container.getPaddingRight(), - paddingBottom)); + protected final void setupContainerPadding() { + getItem(getCurrentPage()).setupContainerPadding(); } public void showListView(ListAdapterT activeListAdapter) { @@ -534,17 +530,25 @@ public class MultiProfilePagerAdapter< private final SinglePageAdapterT mAdapter; private final PageViewT mView; - ProfileDescriptor(ViewGroup rootView, SinglePageAdapterT adapter) { + ProfileDescriptor( + ViewGroup rootView, + SinglePageAdapterT adapter, + Supplier> containerBottomPaddingOverrideSupplier) { mRootView = rootView; mAdapter = adapter; mEmptyStateView = rootView.findViewById(com.android.internal.R.id.resolver_empty_state); mView = (PageViewT) rootView.findViewById(com.android.internal.R.id.resolver_list); - mEmptyStateUi = new EmptyStateUiHelper(rootView); + mEmptyStateUi = + new EmptyStateUiHelper(rootView, containerBottomPaddingOverrideSupplier); } protected ViewGroup getEmptyStateView() { return mEmptyStateView; } + + private void setupContainerPadding() { + mEmptyStateUi.setupContainerPadding(); + } } /** Listener interface for changes between the per-profile UI tabs. */ diff --git a/java/src/com/android/intentresolver/v2/emptystate/EmptyStateUiHelper.java b/java/src/com/android/intentresolver/v2/emptystate/EmptyStateUiHelper.java index 7230b042..fc852f5c 100644 --- a/java/src/com/android/intentresolver/v2/emptystate/EmptyStateUiHelper.java +++ b/java/src/com/android/intentresolver/v2/emptystate/EmptyStateUiHelper.java @@ -18,16 +18,36 @@ package com.android.intentresolver.v2.emptystate; import android.view.View; import android.view.ViewGroup; +import java.util.Optional; +import java.util.function.Supplier; + /** * Helper for building `MultiProfilePagerAdapter` tab UIs for profile tabs that are "blocked" by * some empty-state status. */ public class EmptyStateUiHelper { private final View mEmptyStateView; + private final Supplier> mContainerBottomPaddingOverrideSupplier; - public EmptyStateUiHelper(ViewGroup rootView) { + public EmptyStateUiHelper( + ViewGroup rootView, + Supplier> containerBottomPaddingOverrideSupplier) { mEmptyStateView = rootView.requireViewById(com.android.internal.R.id.resolver_empty_state); + mContainerBottomPaddingOverrideSupplier = containerBottomPaddingOverrideSupplier; + } + + /** Sets up the padding of the view containing the empty state screens. */ + public void setupContainerPadding() { + View container = mEmptyStateView.requireViewById( + com.android.internal.R.id.resolver_empty_state_container); + Optional bottomPaddingOverride = mContainerBottomPaddingOverrideSupplier.get(); + bottomPaddingOverride.ifPresent(paddingBottom -> + container.setPadding( + container.getPaddingLeft(), + container.getPaddingTop(), + container.getPaddingRight(), + paddingBottom)); } public void resetViewVisibilities() { diff --git a/java/tests/src/com/android/intentresolver/v2/MultiProfilePagerAdapterTest.kt b/java/tests/src/com/android/intentresolver/v2/MultiProfilePagerAdapterTest.kt index f1af9790..f5dc0935 100644 --- a/java/tests/src/com/android/intentresolver/v2/MultiProfilePagerAdapterTest.kt +++ b/java/tests/src/com/android/intentresolver/v2/MultiProfilePagerAdapterTest.kt @@ -26,7 +26,6 @@ import com.android.intentresolver.MultiProfilePagerAdapter.PROFILE_PERSONAL import com.android.intentresolver.MultiProfilePagerAdapter.PROFILE_WORK import com.android.intentresolver.R import com.android.intentresolver.ResolverListAdapter -import com.android.intentresolver.any import com.android.intentresolver.emptystate.EmptyStateProvider import com.android.intentresolver.mock import com.android.intentresolver.whenever @@ -35,8 +34,6 @@ import com.google.common.truth.Truth.assertThat import java.util.Optional import java.util.function.Supplier import org.junit.Test -import org.mockito.Mockito.never -import org.mockito.Mockito.verify class MultiProfilePagerAdapterTest { private val PERSONAL_USER_HANDLE = UserHandle.of(10) @@ -158,20 +155,15 @@ class MultiProfilePagerAdapterTest { @Test fun testBottomPaddingDelegate_default() { - val container = - mock { - whenever(getPaddingLeft()).thenReturn(1) - whenever(getPaddingTop()).thenReturn(2) - whenever(getPaddingRight()).thenReturn(3) - whenever(getPaddingBottom()).thenReturn(4) - } + val personalListAdapter = + mock { whenever(getUserHandle()).thenReturn(PERSONAL_USER_HANDLE) } val pagerAdapter = MultiProfilePagerAdapter( { listAdapter: ResolverListAdapter -> listAdapter }, { listView: ListView, bindAdapter: ResolverListAdapter -> listView.setAdapter(bindAdapter) }, - ImmutableList.of(), + ImmutableList.of(personalListAdapter), object : EmptyStateProvider {}, { false }, PROFILE_PERSONAL, @@ -180,26 +172,29 @@ class MultiProfilePagerAdapterTest { inflater, { Optional.empty() } ) - pagerAdapter.setupContainerPadding(container) - verify(container, never()).setPadding(any(), any(), any(), any()) + val container = + pagerAdapter + .getActiveEmptyStateView() + .requireViewById(com.android.internal.R.id.resolver_empty_state_container) + container.setPadding(1, 2, 3, 4) + pagerAdapter.setupContainerPadding() + assertThat(container.paddingLeft).isEqualTo(1) + assertThat(container.paddingTop).isEqualTo(2) + assertThat(container.paddingRight).isEqualTo(3) + assertThat(container.paddingBottom).isEqualTo(4) } @Test fun testBottomPaddingDelegate_override() { - val container = - mock { - whenever(getPaddingLeft()).thenReturn(1) - whenever(getPaddingTop()).thenReturn(2) - whenever(getPaddingRight()).thenReturn(3) - whenever(getPaddingBottom()).thenReturn(4) - } + val personalListAdapter = + mock { whenever(getUserHandle()).thenReturn(PERSONAL_USER_HANDLE) } val pagerAdapter = MultiProfilePagerAdapter( { listAdapter: ResolverListAdapter -> listAdapter }, { listView: ListView, bindAdapter: ResolverListAdapter -> listView.setAdapter(bindAdapter) }, - ImmutableList.of(), + ImmutableList.of(personalListAdapter), object : EmptyStateProvider {}, { false }, PROFILE_PERSONAL, @@ -208,8 +203,16 @@ class MultiProfilePagerAdapterTest { inflater, { Optional.of(42) } ) - pagerAdapter.setupContainerPadding(container) - verify(container).setPadding(1, 2, 3, 42) + val container = + pagerAdapter + .getActiveEmptyStateView() + .requireViewById(com.android.internal.R.id.resolver_empty_state_container) + container.setPadding(1, 2, 3, 4) + pagerAdapter.setupContainerPadding() + assertThat(container.paddingLeft).isEqualTo(1) + assertThat(container.paddingTop).isEqualTo(2) + assertThat(container.paddingRight).isEqualTo(3) + assertThat(container.paddingBottom).isEqualTo(42) } @Test diff --git a/java/tests/src/com/android/intentresolver/v2/emptystate/EmptyStateUiHelperTest.kt b/java/tests/src/com/android/intentresolver/v2/emptystate/EmptyStateUiHelperTest.kt index 12943cd7..27ed7e38 100644 --- a/java/tests/src/com/android/intentresolver/v2/emptystate/EmptyStateUiHelperTest.kt +++ b/java/tests/src/com/android/intentresolver/v2/emptystate/EmptyStateUiHelperTest.kt @@ -22,12 +22,20 @@ import android.view.ViewGroup import android.widget.FrameLayout import androidx.test.platform.app.InstrumentationRegistry import com.google.common.truth.Truth.assertThat +import java.util.Optional +import java.util.function.Supplier import org.junit.Before import org.junit.Test class EmptyStateUiHelperTest { private val context = InstrumentationRegistry.getInstrumentation().getContext() + var shouldOverrideContainerPadding = false + val containerPaddingSupplier = + Supplier> { + Optional.ofNullable(if (shouldOverrideContainerPadding) 42 else null) + } + lateinit var rootContainer: ViewGroup lateinit var emptyStateTitleView: View lateinit var emptyStateSubtitleView: View @@ -60,7 +68,7 @@ class EmptyStateUiHelperTest { emptyStateDefaultTextView = rootContainer.requireViewById(com.android.internal.R.id.empty) emptyStateContainerView = rootContainer.requireViewById(com.android.internal.R.id.resolver_empty_state_container) - emptyStateUiHelper = EmptyStateUiHelper(rootContainer) + emptyStateUiHelper = EmptyStateUiHelper(rootContainer, containerPaddingSupplier) } @Test @@ -109,4 +117,30 @@ class EmptyStateUiHelperTest { assertThat(emptyStateRootView.visibility).isEqualTo(View.GONE) } + + @Test + fun testBottomPaddingDelegate_default() { + shouldOverrideContainerPadding = false + emptyStateContainerView.setPadding(1, 2, 3, 4) + + emptyStateUiHelper.setupContainerPadding() + + assertThat(emptyStateContainerView.paddingLeft).isEqualTo(1) + assertThat(emptyStateContainerView.paddingTop).isEqualTo(2) + assertThat(emptyStateContainerView.paddingRight).isEqualTo(3) + assertThat(emptyStateContainerView.paddingBottom).isEqualTo(4) + } + + @Test + fun testBottomPaddingDelegate_override() { + shouldOverrideContainerPadding = true // Set bottom padding to 42. + emptyStateContainerView.setPadding(1, 2, 3, 4) + + emptyStateUiHelper.setupContainerPadding() + + assertThat(emptyStateContainerView.paddingLeft).isEqualTo(1) + assertThat(emptyStateContainerView.paddingTop).isEqualTo(2) + assertThat(emptyStateContainerView.paddingRight).isEqualTo(3) + assertThat(emptyStateContainerView.paddingBottom).isEqualTo(42) + } } -- cgit v1.2.3-59-g8ed1b From 677a65d143ddf42de7bcab28100421bf5bbcd593 Mon Sep 17 00:00:00 2001 From: Joshua Trask Date: Thu, 5 Oct 2023 19:43:40 +0000 Subject: `showEmptyState()` -> EmptyStateUiHelper As part of ongoing work to pull low-level empty state responsibilities out of the pager adapter, this CL generally replicates the change prototyped in ag/24516421/5..7, in smaller incremental steps to "show the work." As originally described in that CL: "... moves most of the low-level logic of `showEmptyState()` into the UI helper. (...) also has the UI helper take responsibility for setting the visibility of the main "list view" in sync (opposite of) the empty state visibility." As presented in this CL, the "incremental steps" per-snapshot are: 1. Extract most of the implementation directly to the new method at `EmptyStateUiHelper.showEmptyState()`. The general functionality is covered by existing integration tests (e.g., commenting-out the new method body causes `UnbundledChooserActivityWorkProfileTest` to fail). New `EmptyStateUiHelper` unit tests cover finer points of the empty-state "button" conditions, and I've added a TODO comment at one place legacy behavior seemingly may not align with the original developer intent. 2. Also make the UI helper responsible for propagating empty-state visibility changes back to the main list view (hiding the main list when we show an empty state, and restoring it when the empty state is hidden). 3. Look up all the sub-views during `EmptyStateUiHelper` construction so we don't have to keep repeating their View IDs throughout. 4. Tighten visibility on `EmptyStateUiHelper.resetViewVisibilities()` now that it's a private `showEmptyState()` implementation detail (updated to package-protected/visible-for-testing). Also move the method to the end of the class (after all the public methods). Bug: 302311217 Test: IntentResolverUnitTests Change-Id: Iac0cf3d62e2c3bf22afa6a2796ae4e731b706c02 --- .../v2/MultiProfilePagerAdapter.java | 51 ++------- .../v2/emptystate/EmptyStateUiHelper.java | 118 +++++++++++++++------ .../v2/emptystate/EmptyStateUiHelperTest.kt | 88 ++++++++++++++- 3 files changed, 181 insertions(+), 76 deletions(-) (limited to 'java/src') diff --git a/java/src/com/android/intentresolver/v2/MultiProfilePagerAdapter.java b/java/src/com/android/intentresolver/v2/MultiProfilePagerAdapter.java index ad9614b9..391cce7a 100644 --- a/java/src/com/android/intentresolver/v2/MultiProfilePagerAdapter.java +++ b/java/src/com/android/intentresolver/v2/MultiProfilePagerAdapter.java @@ -21,8 +21,6 @@ import android.os.Trace; import android.os.UserHandle; import android.view.View; import android.view.ViewGroup; -import android.widget.Button; -import android.widget.TextView; import androidx.viewpager.widget.PagerAdapter; import androidx.viewpager.widget.ViewPager; @@ -414,6 +412,8 @@ public class MultiProfilePagerAdapter< * The intention is to prevent the user from having to turn * the work profile on if there will not be any apps resolved * anyway. + * + * TODO: move this comment to the place where we configure our composite provider. */ public void showEmptyResolverListEmptyState(ListAdapterT listAdapter) { final EmptyState emptyState = mEmptyStateProvider.getEmptyState(listAdapter); @@ -449,48 +449,13 @@ public class MultiProfilePagerAdapter< } } - protected void showEmptyState( + private void showEmptyState( ListAdapterT activeListAdapter, EmptyState emptyState, View.OnClickListener buttonOnClick) { ProfileDescriptor descriptor = getItem( userHandleToPageIndex(activeListAdapter.getUserHandle())); - descriptor.mRootView.findViewById( - com.android.internal.R.id.resolver_list).setVisibility(View.GONE); - descriptor.mEmptyStateUi.resetViewVisibilities(); - descriptor.setupContainerPadding(); - - ViewGroup emptyStateView = descriptor.getEmptyStateView(); - - - TextView titleView = emptyStateView.findViewById( - com.android.internal.R.id.resolver_empty_state_title); - String title = emptyState.getTitle(); - if (title != null) { - titleView.setVisibility(View.VISIBLE); - titleView.setText(title); - } else { - titleView.setVisibility(View.GONE); - } - - TextView subtitleView = emptyStateView.findViewById( - com.android.internal.R.id.resolver_empty_state_subtitle); - String subtitle = emptyState.getSubtitle(); - if (subtitle != null) { - subtitleView.setVisibility(View.VISIBLE); - subtitleView.setText(subtitle); - } else { - subtitleView.setVisibility(View.GONE); - } - - View defaultEmptyText = emptyStateView.findViewById(com.android.internal.R.id.empty); - defaultEmptyText.setVisibility(emptyState.useDefaultEmptyView() ? View.VISIBLE : View.GONE); - - Button button = emptyStateView.findViewById( - com.android.internal.R.id.resolver_empty_state_button); - button.setVisibility(buttonOnClick != null ? View.VISIBLE : View.GONE); - button.setOnClickListener(buttonOnClick); - + descriptor.mEmptyStateUi.showEmptyState(emptyState, buttonOnClick); activeListAdapter.markTabLoaded(); } @@ -505,8 +470,6 @@ public class MultiProfilePagerAdapter< public void showListView(ListAdapterT activeListAdapter) { ProfileDescriptor descriptor = getItem( userHandleToPageIndex(activeListAdapter.getUserHandle())); - descriptor.mRootView.findViewById( - com.android.internal.R.id.resolver_list).setVisibility(View.VISIBLE); descriptor.mEmptyStateUi.hide(); } @@ -538,8 +501,10 @@ public class MultiProfilePagerAdapter< mAdapter = adapter; mEmptyStateView = rootView.findViewById(com.android.internal.R.id.resolver_empty_state); mView = (PageViewT) rootView.findViewById(com.android.internal.R.id.resolver_list); - mEmptyStateUi = - new EmptyStateUiHelper(rootView, containerBottomPaddingOverrideSupplier); + mEmptyStateUi = new EmptyStateUiHelper( + rootView, + com.android.internal.R.id.resolver_list, + containerBottomPaddingOverrideSupplier); } protected ViewGroup getEmptyStateView() { diff --git a/java/src/com/android/intentresolver/v2/emptystate/EmptyStateUiHelper.java b/java/src/com/android/intentresolver/v2/emptystate/EmptyStateUiHelper.java index fc852f5c..2f1e1b59 100644 --- a/java/src/com/android/intentresolver/v2/emptystate/EmptyStateUiHelper.java +++ b/java/src/com/android/intentresolver/v2/emptystate/EmptyStateUiHelper.java @@ -17,6 +17,11 @@ package com.android.intentresolver.v2.emptystate; import android.view.View; import android.view.ViewGroup; +import android.widget.Button; +import android.widget.TextView; + +import com.android.intentresolver.emptystate.EmptyState; +import com.android.internal.annotations.VisibleForTesting; import java.util.Optional; import java.util.function.Supplier; @@ -26,58 +31,111 @@ import java.util.function.Supplier; * some empty-state status. */ public class EmptyStateUiHelper { - private final View mEmptyStateView; private final Supplier> mContainerBottomPaddingOverrideSupplier; + private final View mEmptyStateView; + private final View mListView; + private final View mEmptyStateContainerView; + private final TextView mEmptyStateTitleView; + private final TextView mEmptyStateSubtitleView; + private final Button mEmptyStateButtonView; + private final View mEmptyStateProgressView; + private final View mEmptyStateEmptyView; public EmptyStateUiHelper( ViewGroup rootView, + int listViewResourceId, Supplier> containerBottomPaddingOverrideSupplier) { + mContainerBottomPaddingOverrideSupplier = containerBottomPaddingOverrideSupplier; mEmptyStateView = rootView.requireViewById(com.android.internal.R.id.resolver_empty_state); - mContainerBottomPaddingOverrideSupplier = containerBottomPaddingOverrideSupplier; + mListView = rootView.requireViewById(listViewResourceId); + mEmptyStateContainerView = mEmptyStateView.requireViewById( + com.android.internal.R.id.resolver_empty_state_container); + mEmptyStateTitleView = mEmptyStateView.requireViewById( + com.android.internal.R.id.resolver_empty_state_title); + mEmptyStateSubtitleView = mEmptyStateView.requireViewById( + com.android.internal.R.id.resolver_empty_state_subtitle); + mEmptyStateButtonView = mEmptyStateView.requireViewById( + com.android.internal.R.id.resolver_empty_state_button); + mEmptyStateProgressView = mEmptyStateView.requireViewById( + com.android.internal.R.id.resolver_empty_state_progress); + mEmptyStateEmptyView = mEmptyStateView.requireViewById(com.android.internal.R.id.empty); + } + + /** + * Display the described empty state. + * @param emptyState the data describing the cause of this empty-state condition. + * @param buttonOnClick handler for a button that the user might be able to use to circumvent + * the empty-state condition. If null, no button will be displayed. + */ + public void showEmptyState(EmptyState emptyState, View.OnClickListener buttonOnClick) { + resetViewVisibilities(); + setupContainerPadding(); + + String title = emptyState.getTitle(); + if (title != null) { + mEmptyStateTitleView.setVisibility(View.VISIBLE); + mEmptyStateTitleView.setText(title); + } else { + mEmptyStateTitleView.setVisibility(View.GONE); + } + + String subtitle = emptyState.getSubtitle(); + if (subtitle != null) { + mEmptyStateSubtitleView.setVisibility(View.VISIBLE); + mEmptyStateSubtitleView.setText(subtitle); + } else { + mEmptyStateSubtitleView.setVisibility(View.GONE); + } + + mEmptyStateEmptyView.setVisibility( + emptyState.useDefaultEmptyView() ? View.VISIBLE : View.GONE); + // TODO: The EmptyState API says that if `useDefaultEmptyView()` is true, we'll ignore the + // state's specified title/subtitle; where (if anywhere) is that implemented? + + mEmptyStateButtonView.setVisibility(buttonOnClick != null ? View.VISIBLE : View.GONE); + mEmptyStateButtonView.setOnClickListener(buttonOnClick); + + // Don't show the main list view when we're showing an empty state. + mListView.setVisibility(View.GONE); } /** Sets up the padding of the view containing the empty state screens. */ public void setupContainerPadding() { - View container = mEmptyStateView.requireViewById( - com.android.internal.R.id.resolver_empty_state_container); Optional bottomPaddingOverride = mContainerBottomPaddingOverrideSupplier.get(); bottomPaddingOverride.ifPresent(paddingBottom -> - container.setPadding( - container.getPaddingLeft(), - container.getPaddingTop(), - container.getPaddingRight(), + mEmptyStateContainerView.setPadding( + mEmptyStateContainerView.getPaddingLeft(), + mEmptyStateContainerView.getPaddingTop(), + mEmptyStateContainerView.getPaddingRight(), paddingBottom)); } - public void resetViewVisibilities() { - mEmptyStateView.requireViewById(com.android.internal.R.id.resolver_empty_state_title) - .setVisibility(View.VISIBLE); - mEmptyStateView.requireViewById(com.android.internal.R.id.resolver_empty_state_subtitle) - .setVisibility(View.VISIBLE); - mEmptyStateView.requireViewById(com.android.internal.R.id.resolver_empty_state_button) - .setVisibility(View.INVISIBLE); - mEmptyStateView.requireViewById(com.android.internal.R.id.resolver_empty_state_progress) - .setVisibility(View.GONE); - mEmptyStateView.requireViewById(com.android.internal.R.id.empty) - .setVisibility(View.GONE); - mEmptyStateView.setVisibility(View.VISIBLE); - } - public void showSpinner() { - mEmptyStateView.requireViewById(com.android.internal.R.id.resolver_empty_state_title) - .setVisibility(View.INVISIBLE); + mEmptyStateTitleView.setVisibility(View.INVISIBLE); // TODO: subtitle? - mEmptyStateView.requireViewById(com.android.internal.R.id.resolver_empty_state_button) - .setVisibility(View.INVISIBLE); - mEmptyStateView.requireViewById(com.android.internal.R.id.resolver_empty_state_progress) - .setVisibility(View.VISIBLE); - mEmptyStateView.requireViewById(com.android.internal.R.id.empty) - .setVisibility(View.GONE); + mEmptyStateButtonView.setVisibility(View.INVISIBLE); + mEmptyStateProgressView.setVisibility(View.VISIBLE); + mEmptyStateEmptyView.setVisibility(View.GONE); } public void hide() { mEmptyStateView.setVisibility(View.GONE); + mListView.setVisibility(View.VISIBLE); + } + + // TODO: this is exposed for testing so we can thoroughly prepare initial conditions that let us + // observe the resulting change. In reality it's only invoked as part of `showEmptyState()` and + // we could consider setting up narrower "realistic" preconditions to make assertions about the + // higher-level operation. + @VisibleForTesting + void resetViewVisibilities() { + mEmptyStateTitleView.setVisibility(View.VISIBLE); + mEmptyStateSubtitleView.setVisibility(View.VISIBLE); + mEmptyStateButtonView.setVisibility(View.INVISIBLE); + mEmptyStateProgressView.setVisibility(View.GONE); + mEmptyStateEmptyView.setVisibility(View.GONE); + mEmptyStateView.setVisibility(View.VISIBLE); } } diff --git a/java/tests/src/com/android/intentresolver/v2/emptystate/EmptyStateUiHelperTest.kt b/java/tests/src/com/android/intentresolver/v2/emptystate/EmptyStateUiHelperTest.kt index 27ed7e38..696dd1fd 100644 --- a/java/tests/src/com/android/intentresolver/v2/emptystate/EmptyStateUiHelperTest.kt +++ b/java/tests/src/com/android/intentresolver/v2/emptystate/EmptyStateUiHelperTest.kt @@ -20,12 +20,18 @@ import android.view.LayoutInflater import android.view.View import android.view.ViewGroup import android.widget.FrameLayout +import android.widget.TextView import androidx.test.platform.app.InstrumentationRegistry +import com.android.intentresolver.any +import com.android.intentresolver.emptystate.EmptyState +import com.android.intentresolver.mock import com.google.common.truth.Truth.assertThat import java.util.Optional import java.util.function.Supplier import org.junit.Before import org.junit.Test +import org.mockito.Mockito.never +import org.mockito.Mockito.verify class EmptyStateUiHelperTest { private val context = InstrumentationRegistry.getInstrumentation().getContext() @@ -37,8 +43,9 @@ class EmptyStateUiHelperTest { } lateinit var rootContainer: ViewGroup - lateinit var emptyStateTitleView: View - lateinit var emptyStateSubtitleView: View + lateinit var mainListView: View // Visible when no empty state is showing. + lateinit var emptyStateTitleView: TextView + lateinit var emptyStateSubtitleView: TextView lateinit var emptyStateButtonView: View lateinit var emptyStateProgressView: View lateinit var emptyStateDefaultTextView: View @@ -55,6 +62,7 @@ class EmptyStateUiHelperTest { rootContainer, true ) + mainListView = rootContainer.requireViewById(com.android.internal.R.id.resolver_list) emptyStateRootView = rootContainer.requireViewById(com.android.internal.R.id.resolver_empty_state) emptyStateTitleView = @@ -68,7 +76,12 @@ class EmptyStateUiHelperTest { emptyStateDefaultTextView = rootContainer.requireViewById(com.android.internal.R.id.empty) emptyStateContainerView = rootContainer.requireViewById(com.android.internal.R.id.resolver_empty_state_container) - emptyStateUiHelper = EmptyStateUiHelper(rootContainer, containerPaddingSupplier) + emptyStateUiHelper = + EmptyStateUiHelper( + rootContainer, + com.android.internal.R.id.resolver_list, + containerPaddingSupplier + ) } @Test @@ -112,10 +125,12 @@ class EmptyStateUiHelperTest { @Test fun testHide() { emptyStateRootView.visibility = View.VISIBLE + mainListView.visibility = View.GONE emptyStateUiHelper.hide() assertThat(emptyStateRootView.visibility).isEqualTo(View.GONE) + assertThat(mainListView.visibility).isEqualTo(View.VISIBLE) } @Test @@ -143,4 +158,71 @@ class EmptyStateUiHelperTest { assertThat(emptyStateContainerView.paddingRight).isEqualTo(3) assertThat(emptyStateContainerView.paddingBottom).isEqualTo(42) } + + @Test + fun testShowEmptyState_noOnClickHandler() { + mainListView.visibility = View.VISIBLE + + // Note: an `EmptyState.ClickListener` isn't invoked directly by the UI helper; it has to be + // built into the "on-click handler" that's injected to implement the button-press. We won't + // display the button without a click "handler," even if it *does* have a `ClickListener`. + val clickListener = mock() + + val emptyState = + object : EmptyState { + override fun getTitle() = "Test title" + override fun getSubtitle() = "Test subtitle" + + override fun getButtonClickListener() = clickListener + } + emptyStateUiHelper.showEmptyState(emptyState, null) + + assertThat(mainListView.visibility).isEqualTo(View.GONE) + assertThat(emptyStateRootView.visibility).isEqualTo(View.VISIBLE) + assertThat(emptyStateTitleView.visibility).isEqualTo(View.VISIBLE) + assertThat(emptyStateSubtitleView.visibility).isEqualTo(View.VISIBLE) + assertThat(emptyStateButtonView.visibility).isEqualTo(View.GONE) + assertThat(emptyStateProgressView.visibility).isEqualTo(View.GONE) + assertThat(emptyStateDefaultTextView.visibility).isEqualTo(View.GONE) + + assertThat(emptyStateTitleView.text).isEqualTo("Test title") + assertThat(emptyStateSubtitleView.text).isEqualTo("Test subtitle") + + verify(clickListener, never()).onClick(any()) + } + + @Test + fun testShowEmptyState_withOnClickHandlerAndClickListener() { + mainListView.visibility = View.VISIBLE + + val clickListener = mock() + val onClickHandler = mock() + + val emptyState = + object : EmptyState { + override fun getTitle() = "Test title" + override fun getSubtitle() = "Test subtitle" + + override fun getButtonClickListener() = clickListener + } + emptyStateUiHelper.showEmptyState(emptyState, onClickHandler) + + assertThat(mainListView.visibility).isEqualTo(View.GONE) + assertThat(emptyStateRootView.visibility).isEqualTo(View.VISIBLE) + assertThat(emptyStateTitleView.visibility).isEqualTo(View.VISIBLE) + assertThat(emptyStateSubtitleView.visibility).isEqualTo(View.VISIBLE) + assertThat(emptyStateButtonView.visibility).isEqualTo(View.VISIBLE) // Now shown. + assertThat(emptyStateProgressView.visibility).isEqualTo(View.GONE) + assertThat(emptyStateDefaultTextView.visibility).isEqualTo(View.GONE) + + assertThat(emptyStateTitleView.text).isEqualTo("Test title") + assertThat(emptyStateSubtitleView.text).isEqualTo("Test subtitle") + + emptyStateButtonView.performClick() + + verify(onClickHandler).onClick(emptyStateButtonView) + // The test didn't explicitly configure its `OnClickListener` to relay the click event on + // to the `EmptyState.ClickListener`, so it still won't have fired here. + verify(clickListener, never()).onClick(any()) + } } -- cgit v1.2.3-59-g8ed1b From 35018884b00755252f5268507065a7ea6cb9404b Mon Sep 17 00:00:00 2001 From: Mark Renouf Date: Mon, 11 Sep 2023 10:53:02 -0400 Subject: Inject ComponentNames for image editor and nearby share Adds a SecureSettings fake Adds test coverage for new modules Removes another overload from ChooserActivityWrapper Uses @BindValue in tests to alter the configured editor component Test: atest --test-mapping packages/modules/IntentResolver Bug: 300157408 Bug: 302113519 Change-Id: Ie7d5fe12ad0d8e7fd074154641de35fe89d50ce6 --- Android.bp | 2 +- .../intentresolver/ChooserActionFactory.java | 11 +- .../intentresolver/inject/SingletonModule.kt | 9 +- .../intentresolver/v2/ChooserActionFactory.java | 392 +++++++++++++++++++++ .../android/intentresolver/v2/ChooserActivity.java | 30 +- .../v2/platform/ImageEditorModule.kt | 35 ++ .../v2/platform/NearbyShareModule.kt | 32 ++ .../v2/platform/PlatformSecureSettings.kt | 30 ++ .../intentresolver/v2/platform/SecureSettings.kt | 25 ++ .../v2/platform/SecureSettingsModule.kt | 14 + java/tests/Android.bp | 3 +- .../UnbundledChooserActivityTest.java | 2 +- .../intentresolver/v2/ChooserActionFactoryTest.kt | 232 ++++++++++++ .../intentresolver/v2/ChooserWrapperActivity.java | 12 - .../v2/UnbundledChooserActivityTest.java | 44 ++- .../v2/platform/FakeSecureSettings.kt | 44 +++ .../v2/platform/FakeSecureSettingsTest.kt | 61 ++++ .../v2/platform/NearbyShareModuleTest.kt | 83 +++++ 18 files changed, 1005 insertions(+), 56 deletions(-) create mode 100644 java/src/com/android/intentresolver/v2/ChooserActionFactory.java create mode 100644 java/src/com/android/intentresolver/v2/platform/ImageEditorModule.kt create mode 100644 java/src/com/android/intentresolver/v2/platform/NearbyShareModule.kt create mode 100644 java/src/com/android/intentresolver/v2/platform/PlatformSecureSettings.kt create mode 100644 java/src/com/android/intentresolver/v2/platform/SecureSettings.kt create mode 100644 java/src/com/android/intentresolver/v2/platform/SecureSettingsModule.kt create mode 100644 java/tests/src/com/android/intentresolver/v2/ChooserActionFactoryTest.kt create mode 100644 java/tests/src/com/android/intentresolver/v2/platform/FakeSecureSettings.kt create mode 100644 java/tests/src/com/android/intentresolver/v2/platform/FakeSecureSettingsTest.kt create mode 100644 java/tests/src/com/android/intentresolver/v2/platform/NearbyShareModuleTest.kt (limited to 'java/src') diff --git a/Android.bp b/Android.bp index 674aae1f..4e59deea 100644 --- a/Android.bp +++ b/Android.bp @@ -91,4 +91,4 @@ android_app { "com.android.intentresolver", "test_com.android.intentresolver", ], -} \ No newline at end of file +} diff --git a/java/src/com/android/intentresolver/ChooserActionFactory.java b/java/src/com/android/intentresolver/ChooserActionFactory.java index 6d56146d..c7c0beeb 100644 --- a/java/src/com/android/intentresolver/ChooserActionFactory.java +++ b/java/src/com/android/intentresolver/ChooserActionFactory.java @@ -103,7 +103,6 @@ public final class ChooserActionFactory implements ChooserContentPreviewUi.Actio /** * @param context * @param chooserRequest data about the invocation of the current Sharesheet session. - * @param integratedDeviceComponents info about other components that are available on this * device to implement the supported action types. * @param onUpdateSharedTextIsExcluded a delegate to be invoked when the "exclude shared text" * setting is updated. The argument is whether the shared text is to be excluded. @@ -239,7 +238,7 @@ public final class ChooserActionFactory implements ChooserContentPreviewUi.Actio clipData = extractTextToCopy(targetIntent); } catch (Throwable t) { Log.e(TAG, "Failed to extract data to copy", t); - return null; + return null; } if (clipData == null) { return null; @@ -372,10 +371,10 @@ public final class ChooserActionFactory implements ChooserContentPreviewUi.Actio null, null, ActivityOptions.makeCustomAnimation( - context, - R.anim.slide_in_right, - R.anim.slide_out_left) - .toBundle()); + context, + R.anim.slide_in_right, + R.anim.slide_out_left) + .toBundle()); } catch (PendingIntent.CanceledException e) { Log.d(TAG, "Custom action, " + action.getLabel() + ", has been cancelled"); } diff --git a/java/src/com/android/intentresolver/inject/SingletonModule.kt b/java/src/com/android/intentresolver/inject/SingletonModule.kt index fbda8be6..36adf06b 100644 --- a/java/src/com/android/intentresolver/inject/SingletonModule.kt +++ b/java/src/com/android/intentresolver/inject/SingletonModule.kt @@ -1,15 +1,22 @@ package com.android.intentresolver.inject +import android.content.Context import com.android.intentresolver.logging.EventLogImpl import dagger.Module import dagger.Provides +import dagger.Reusable import dagger.hilt.InstallIn +import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.components.SingletonComponent import javax.inject.Singleton @InstallIn(SingletonComponent::class) @Module class SingletonModule { - @Provides @Singleton fun instanceIdSequence() = EventLogImpl.newIdSequence() + + @Provides + @Reusable + @ApplicationOwned + fun resources(@ApplicationContext context: Context) = context.resources } diff --git a/java/src/com/android/intentresolver/v2/ChooserActionFactory.java b/java/src/com/android/intentresolver/v2/ChooserActionFactory.java new file mode 100644 index 00000000..2da194ca --- /dev/null +++ b/java/src/com/android/intentresolver/v2/ChooserActionFactory.java @@ -0,0 +1,392 @@ +/* + * Copyright (C) 2023 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.intentresolver.v2; + +import android.annotation.Nullable; +import android.app.Activity; +import android.app.ActivityOptions; +import android.app.PendingIntent; +import android.content.ClipData; +import android.content.ClipboardManager; +import android.content.ComponentName; +import android.content.Context; +import android.content.Intent; +import android.content.pm.PackageManager; +import android.content.pm.ResolveInfo; +import android.graphics.drawable.Drawable; +import android.net.Uri; +import android.service.chooser.ChooserAction; +import android.text.TextUtils; +import android.util.Log; +import android.view.View; + +import com.android.intentresolver.ChooserRequestParameters; +import com.android.intentresolver.R; +import com.android.intentresolver.chooser.DisplayResolveInfo; +import com.android.intentresolver.chooser.TargetInfo; +import com.android.intentresolver.contentpreview.ChooserContentPreviewUi; +import com.android.intentresolver.logging.EventLog; +import com.android.intentresolver.widget.ActionRow; +import com.android.internal.annotations.VisibleForTesting; + +import com.google.common.collect.ImmutableList; + +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; +import java.util.concurrent.Callable; +import java.util.function.Consumer; + +/** + * Implementation of {@link ChooserContentPreviewUi.ActionFactory} specialized to the application + * requirements of Sharesheet / {@link ChooserActivity}. + */ +public final class ChooserActionFactory implements ChooserContentPreviewUi.ActionFactory { + /** + * Delegate interface to launch activities when the actions are selected. + */ + public interface ActionActivityStarter { + /** + * Request an activity launch for the provided target. Implementations may choose to exit + * the current activity when the target is launched. + */ + void safelyStartActivityAsPersonalProfileUser(TargetInfo info); + + /** + * Request an activity launch for the provided target, optionally employing the specified + * shared element transition. Implementations may choose to exit the current activity when + * the target is launched. + */ + default void safelyStartActivityAsPersonalProfileUserWithSharedElementTransition( + TargetInfo info, View sharedElement, String sharedElementName) { + safelyStartActivityAsPersonalProfileUser(info); + } + } + + private static final String TAG = "ChooserActions"; + + private static final int URI_PERMISSION_INTENT_FLAGS = Intent.FLAG_GRANT_READ_URI_PERMISSION + | Intent.FLAG_GRANT_WRITE_URI_PERMISSION + | Intent.FLAG_GRANT_PERSISTABLE_URI_PERMISSION + | Intent.FLAG_GRANT_PREFIX_URI_PERMISSION; + + // Boolean extra used to inform the editor that it may want to customize the editing experience + // for the sharesheet editing flow. + private static final String EDIT_SOURCE = "edit_source"; + private static final String EDIT_SOURCE_SHARESHEET = "sharesheet"; + + private static final String CHIP_LABEL_METADATA_KEY = "android.service.chooser.chip_label"; + private static final String CHIP_ICON_METADATA_KEY = "android.service.chooser.chip_icon"; + + private static final String IMAGE_EDITOR_SHARED_ELEMENT = "screenshot_preview_image"; + + private final Context mContext; + + @Nullable + private final Runnable mCopyButtonRunnable; + private final Runnable mEditButtonRunnable; + private final ImmutableList mCustomActions; + private final @Nullable ChooserAction mModifyShareAction; + private final Consumer mExcludeSharedTextAction; + private final Consumer mFinishCallback; + private final EventLog mLog; + + /** + * @param context + * @param chooserRequest data about the invocation of the current Sharesheet session. + * @param imageEditor an explicit Activity to launch for editing images + * @param onUpdateSharedTextIsExcluded a delegate to be invoked when the "exclude shared text" + * setting is updated. The argument is whether the shared text is to be excluded. + * @param firstVisibleImageQuery a delegate that provides a reference to the first visible image + * View in the Sharesheet UI, if any, or null. + * @param activityStarter a delegate to launch activities when actions are selected. + * @param finishCallback a delegate to close the Sharesheet UI (e.g. because some action was + * completed). + */ + public ChooserActionFactory( + Context context, + ChooserRequestParameters chooserRequest, + Optional imageEditor, + EventLog log, + Consumer onUpdateSharedTextIsExcluded, + Callable firstVisibleImageQuery, + ActionActivityStarter activityStarter, + Consumer finishCallback) { + this( + context, + makeCopyButtonRunnable( + context, + chooserRequest.getTargetIntent(), + chooserRequest.getReferrerPackageName(), + finishCallback, + log), + makeEditButtonRunnable( + getEditSharingTarget( + context, + chooserRequest.getTargetIntent(), + imageEditor), + firstVisibleImageQuery, + activityStarter, + log), + chooserRequest.getChooserActions(), + chooserRequest.getModifyShareAction(), + onUpdateSharedTextIsExcluded, + log, + finishCallback); + } + + @VisibleForTesting + ChooserActionFactory( + Context context, + @Nullable Runnable copyButtonRunnable, + Runnable editButtonRunnable, + List customActions, + @Nullable ChooserAction modifyShareAction, + Consumer onUpdateSharedTextIsExcluded, + EventLog log, + Consumer finishCallback) { + mContext = context; + mCopyButtonRunnable = copyButtonRunnable; + mEditButtonRunnable = editButtonRunnable; + mCustomActions = ImmutableList.copyOf(customActions); + mModifyShareAction = modifyShareAction; + mExcludeSharedTextAction = onUpdateSharedTextIsExcluded; + mLog = log; + mFinishCallback = finishCallback; + } + + @Override + @Nullable + public Runnable getEditButtonRunnable() { + return mEditButtonRunnable; + } + + @Override + @Nullable + public Runnable getCopyButtonRunnable() { + return mCopyButtonRunnable; + } + + /** Create custom actions */ + @Override + public List createCustomActions() { + List actions = new ArrayList<>(); + for (int i = 0; i < mCustomActions.size(); i++) { + final int position = i; + ActionRow.Action actionRow = createCustomAction( + mContext, + mCustomActions.get(i), + mFinishCallback, + () -> { + mLog.logCustomActionSelected(position); + } + ); + if (actionRow != null) { + actions.add(actionRow); + } + } + return actions; + } + + /** + * Provides a share modification action, if any. + */ + @Override + @Nullable + public ActionRow.Action getModifyShareAction() { + return createCustomAction( + mContext, + mModifyShareAction, + mFinishCallback, + () -> { + mLog.logActionSelected(EventLog.SELECTION_TYPE_MODIFY_SHARE); + }); + } + + /** + *

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

+ *

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

+ */ + @Override + public Consumer getExcludeSharedTextAction() { + return mExcludeSharedTextAction; + } + + @Nullable + private static Runnable makeCopyButtonRunnable( + Context context, + Intent targetIntent, + String referrerPackageName, + Consumer finishCallback, + EventLog log) { + final ClipData clipData; + try { + clipData = extractTextToCopy(targetIntent); + } catch (Throwable t) { + Log.e(TAG, "Failed to extract data to copy", t); + return null; + } + if (clipData == null) { + return null; + } + return () -> { + ClipboardManager clipboardManager = (ClipboardManager) context.getSystemService( + Context.CLIPBOARD_SERVICE); + clipboardManager.setPrimaryClipAsPackage(clipData, referrerPackageName); + + log.logActionSelected(EventLog.SELECTION_TYPE_COPY); + finishCallback.accept(Activity.RESULT_OK); + }; + } + + @Nullable + private static ClipData extractTextToCopy(Intent targetIntent) { + if (targetIntent == null) { + return null; + } + + final String action = targetIntent.getAction(); + + ClipData clipData = null; + if (Intent.ACTION_SEND.equals(action)) { + String extraText = targetIntent.getStringExtra(Intent.EXTRA_TEXT); + + if (extraText != null) { + clipData = ClipData.newPlainText(null, extraText); + } else { + Log.w(TAG, "No data available to copy to clipboard"); + } + } else { + // expected to only be visible with ACTION_SEND (when a text is shared) + Log.d(TAG, "Action (" + action + ") not supported for copying to clipboard"); + } + return clipData; + } + + private static TargetInfo getEditSharingTarget( + Context context, + Intent originalIntent, + Optional imageEditor) { + + final Intent resolveIntent = new Intent(originalIntent); + // Retain only URI permission grant flags if present. Other flags may prevent the scene + // transition animation from running (i.e FLAG_ACTIVITY_NO_ANIMATION, + // FLAG_ACTIVITY_NEW_TASK, FLAG_ACTIVITY_NEW_DOCUMENT) but also not needed. + resolveIntent.setFlags(originalIntent.getFlags() & URI_PERMISSION_INTENT_FLAGS); + imageEditor.ifPresent(resolveIntent::setComponent); + resolveIntent.setAction(Intent.ACTION_EDIT); + resolveIntent.putExtra(EDIT_SOURCE, EDIT_SOURCE_SHARESHEET); + String originalAction = originalIntent.getAction(); + if (Intent.ACTION_SEND.equals(originalAction)) { + if (resolveIntent.getData() == null) { + Uri uri = resolveIntent.getParcelableExtra(Intent.EXTRA_STREAM); + if (uri != null) { + String mimeType = context.getContentResolver().getType(uri); + resolveIntent.setDataAndType(uri, mimeType); + } + } + } else { + Log.e(TAG, originalAction + " is not supported."); + return null; + } + final ResolveInfo ri = context.getPackageManager().resolveActivity( + resolveIntent, PackageManager.GET_META_DATA); + if (ri == null || ri.activityInfo == null) { + Log.e(TAG, "Device-specified editor (" + imageEditor + ") not available"); + return null; + } + + final DisplayResolveInfo dri = DisplayResolveInfo.newDisplayResolveInfo( + originalIntent, + ri, + context.getString(R.string.screenshot_edit), + "", + resolveIntent); + dri.getDisplayIconHolder().setDisplayIcon( + context.getDrawable(com.android.internal.R.drawable.ic_screenshot_edit)); + return dri; + } + + private static Runnable makeEditButtonRunnable( + TargetInfo editSharingTarget, + Callable firstVisibleImageQuery, + ActionActivityStarter activityStarter, + EventLog log) { + return () -> { + // Log share completion via edit. + log.logActionSelected(EventLog.SELECTION_TYPE_EDIT); + + View firstImageView = null; + try { + firstImageView = firstVisibleImageQuery.call(); + } catch (Exception e) { /* ignore */ } + // Action bar is user-independent; always start as primary. + if (firstImageView == null) { + activityStarter.safelyStartActivityAsPersonalProfileUser(editSharingTarget); + } else { + activityStarter.safelyStartActivityAsPersonalProfileUserWithSharedElementTransition( + editSharingTarget, firstImageView, IMAGE_EDITOR_SHARED_ELEMENT); + } + }; + } + + @Nullable + private static ActionRow.Action createCustomAction( + Context context, + ChooserAction action, + Consumer finishCallback, + Runnable loggingRunnable) { + if (action == null || action.getAction() == null) { + return null; + } + Drawable icon = action.getIcon().loadDrawable(context); + if (icon == null && TextUtils.isEmpty(action.getLabel())) { + return null; + } + return new ActionRow.Action( + action.getLabel(), + icon, + () -> { + try { + action.getAction().send( + null, + 0, + null, + null, + null, + null, + ActivityOptions.makeCustomAnimation( + context, + R.anim.slide_in_right, + R.anim.slide_out_left) + .toBundle()); + } catch (PendingIntent.CanceledException e) { + Log.d(TAG, "Custom action, " + action.getLabel() + ", has been cancelled"); + } + if (loggingRunnable != null) { + loggingRunnable.run(); + } + finishCallback.accept(Activity.RESULT_OK); + } + ); + } +} diff --git a/java/src/com/android/intentresolver/v2/ChooserActivity.java b/java/src/com/android/intentresolver/v2/ChooserActivity.java index 9a8b0e2a..12e708f6 100644 --- a/java/src/com/android/intentresolver/v2/ChooserActivity.java +++ b/java/src/com/android/intentresolver/v2/ChooserActivity.java @@ -26,8 +26,6 @@ import static android.stats.devicepolicy.nano.DevicePolicyEnums.RESOLVER_EMPTY_S import static androidx.lifecycle.LifecycleKt.getCoroutineScope; -import static com.android.intentresolver.v2.ResolverActivity.PROFILE_PERSONAL; -import static com.android.intentresolver.v2.ResolverActivity.PROFILE_WORK; import static com.android.internal.util.LatencyTracker.ACTION_LOAD_SHARE_SHEET; import android.annotation.IntDef; @@ -76,9 +74,7 @@ import androidx.recyclerview.widget.GridLayoutManager; import androidx.recyclerview.widget.RecyclerView; import androidx.viewpager.widget.ViewPager; -import com.android.intentresolver.ChooserActionFactory; import com.android.intentresolver.ChooserGridLayoutManager; -import com.android.intentresolver.ChooserIntegratedDeviceComponents; import com.android.intentresolver.ChooserListAdapter; import com.android.intentresolver.ChooserRefinementManager; import com.android.intentresolver.ChooserRequestParameters; @@ -91,7 +87,6 @@ import com.android.intentresolver.R; import com.android.intentresolver.ResolverListAdapter; import com.android.intentresolver.ResolverListController; import com.android.intentresolver.ResolverViewPager; -import com.android.intentresolver.SecureSettings; import com.android.intentresolver.chooser.DisplayResolveInfo; import com.android.intentresolver.chooser.MultiDisplayResolveInfo; import com.android.intentresolver.chooser.TargetInfo; @@ -113,12 +108,15 @@ 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.Hilt_ChooserActivity; +import com.android.intentresolver.v2.platform.ImageEditor; +import com.android.intentresolver.v2.platform.NearbyShare; import com.android.intentresolver.widget.ImagePreviewView; import com.android.internal.annotations.VisibleForTesting; import com.android.internal.content.PackageMonitor; import com.android.internal.logging.nano.MetricsProto.MetricsEvent; +import dagger.hilt.android.AndroidEntryPoint; + import java.lang.annotation.Retention; import java.lang.annotation.RetentionPolicy; import java.text.Collator; @@ -129,14 +127,13 @@ import java.util.HashMap; import java.util.List; import java.util.Map; import java.util.Objects; +import java.util.Optional; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; import java.util.function.Consumer; import javax.inject.Inject; -import dagger.hilt.android.AndroidEntryPoint; - /** * The Chooser Activity handles intent resolution specifically for sharing intents - * for example, as generated by {@see android.content.Intent#createChooser(Intent, CharSequence)}. @@ -195,8 +192,8 @@ public class ChooserActivity extends Hilt_ChooserActivity implements @Inject public FeatureFlags mFeatureFlags; @Inject public EventLog mEventLog; - - private ChooserIntegratedDeviceComponents mIntegratedDeviceComponents; + @Inject @ImageEditor public Optional mImageEditor; + @Inject @NearbyShare public Optional mNearbyShare; /* TODO: this is `nullable` because we have to defer the assignment til onCreate(). We make the * only assignment there, and expect it to be ready by the time we ever use it -- @@ -296,8 +293,6 @@ public class ChooserActivity extends Hilt_ChooserActivity implements getEventLog().logSharesheetTriggered(); - mIntegratedDeviceComponents = getIntegratedDeviceComponents(); - mRefinementManager = new ViewModelProvider(this).get(ChooserRefinementManager.class); mRefinementManager.getRefinementCompletion().observe(this, completion -> { @@ -373,11 +368,6 @@ public class ChooserActivity extends Hilt_ChooserActivity implements mEnterTransitionAnimationDelegate.postponeTransition(); } - @VisibleForTesting - protected ChooserIntegratedDeviceComponents getIntegratedDeviceComponents() { - return ChooserIntegratedDeviceComponents.get(this, new SecureSettings()); - } - @Override protected int appliedThemeResId() { return R.style.Theme_DeviceDefault_Chooser; @@ -1291,7 +1281,7 @@ public class ChooserActivity extends Hilt_ChooserActivity implements if (appPredictor != null) { resolverComparator = new AppPredictionServiceResolverComparator(this, getTargetIntent(), getReferrerPackageName(), appPredictor, userHandle, getEventLog(), - getIntegratedDeviceComponents().getNearbySharingComponent()); + mNearbyShare.orElse(null)); } else { resolverComparator = new ResolverRankerServiceResolverComparator( @@ -1301,7 +1291,7 @@ public class ChooserActivity extends Hilt_ChooserActivity implements null, getEventLog(), getResolverRankerServiceUserHandleList(userHandle), - getIntegratedDeviceComponents().getNearbySharingComponent()); + mNearbyShare.orElse(null)); } return new ChooserListController( @@ -1323,7 +1313,7 @@ public class ChooserActivity extends Hilt_ChooserActivity implements return new ChooserActionFactory( this, mChooserRequest, - mIntegratedDeviceComponents, + mImageEditor, getEventLog(), (isExcluded) -> mExcludeSharedText = isExcluded, this::getFirstVisibleImgPreviewView, diff --git a/java/src/com/android/intentresolver/v2/platform/ImageEditorModule.kt b/java/src/com/android/intentresolver/v2/platform/ImageEditorModule.kt new file mode 100644 index 00000000..efbf053e --- /dev/null +++ b/java/src/com/android/intentresolver/v2/platform/ImageEditorModule.kt @@ -0,0 +1,35 @@ +package com.android.intentresolver.v2.platform + +import android.content.ComponentName +import android.content.res.Resources +import androidx.annotation.StringRes +import com.android.intentresolver.R +import com.android.intentresolver.inject.ApplicationOwned +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import java.util.Optional +import javax.inject.Qualifier +import javax.inject.Singleton + +internal fun Resources.componentName(@StringRes resId: Int): ComponentName? { + check(getResourceTypeName(resId) == "string") { "resId must be a string" } + return ComponentName.unflattenFromString(getString(resId)) +} + +@Qualifier @MustBeDocumented @Retention(AnnotationRetention.RUNTIME) annotation class ImageEditor + +@Module +@InstallIn(SingletonComponent::class) +object ImageEditorModule { + /** + * The name of the preferred Activity to launch for editing images. This is added to Intents to + * edit images using Intent.ACTION_EDIT. + */ + @Provides + @Singleton + @ImageEditor + fun imageEditorComponent(@ApplicationOwned resources: Resources) = + Optional.ofNullable(resources.componentName(R.string.config_systemImageEditor)) +} diff --git a/java/src/com/android/intentresolver/v2/platform/NearbyShareModule.kt b/java/src/com/android/intentresolver/v2/platform/NearbyShareModule.kt new file mode 100644 index 00000000..25ee9198 --- /dev/null +++ b/java/src/com/android/intentresolver/v2/platform/NearbyShareModule.kt @@ -0,0 +1,32 @@ +package com.android.intentresolver.v2.platform + +import android.content.ComponentName +import android.content.res.Resources +import android.provider.Settings.Secure.NEARBY_SHARING_COMPONENT +import com.android.intentresolver.R +import com.android.intentresolver.inject.ApplicationOwned +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import java.util.Optional +import javax.inject.Qualifier +import javax.inject.Singleton + +@Qualifier @MustBeDocumented @Retention(AnnotationRetention.RUNTIME) annotation class NearbyShare + +@Module +@InstallIn(SingletonComponent::class) +object NearbyShareModule { + + @Provides + @Singleton + @NearbyShare + fun nearbyShareComponent(@ApplicationOwned resources: Resources, settings: SecureSettings) = + Optional.ofNullable( + ComponentName.unflattenFromString( + settings.getString(NEARBY_SHARING_COMPONENT)?.ifEmpty { null } + ?: resources.getString(R.string.config_defaultNearbySharingComponent), + ) + ) +} diff --git a/java/src/com/android/intentresolver/v2/platform/PlatformSecureSettings.kt b/java/src/com/android/intentresolver/v2/platform/PlatformSecureSettings.kt new file mode 100644 index 00000000..531152ba --- /dev/null +++ b/java/src/com/android/intentresolver/v2/platform/PlatformSecureSettings.kt @@ -0,0 +1,30 @@ +package com.android.intentresolver.v2.platform + +import android.content.ContentResolver +import android.provider.Settings +import javax.inject.Inject + +/** + * Implements [SecureSettings] backed by Settings.Secure and a ContentResolver. + * + * These methods make Binder calls and may block, so use on the Main thread should be avoided. + */ +class PlatformSecureSettings @Inject constructor(private val resolver: ContentResolver) : + SecureSettings { + + override fun getString(name: String): String? { + return Settings.Secure.getString(resolver, name) + } + + override fun getInt(name: String): Int? { + return runCatching { Settings.Secure.getInt(resolver, name) }.getOrNull() + } + + override fun getLong(name: String): Long? { + return runCatching { Settings.Secure.getLong(resolver, name) }.getOrNull() + } + + override fun getFloat(name: String): Float? { + return runCatching { Settings.Secure.getFloat(resolver, name) }.getOrNull() + } +} diff --git a/java/src/com/android/intentresolver/v2/platform/SecureSettings.kt b/java/src/com/android/intentresolver/v2/platform/SecureSettings.kt new file mode 100644 index 00000000..62ee8ae9 --- /dev/null +++ b/java/src/com/android/intentresolver/v2/platform/SecureSettings.kt @@ -0,0 +1,25 @@ +package com.android.intentresolver.v2.platform + +import android.provider.Settings.SettingNotFoundException + +/** + * A component which provides access to values from [android.provider.Settings.Secure]. + * + * All methods return nullable types instead of throwing [SettingNotFoundException] which yields + * cleaner, more idiomatic Kotlin code: + * + * // apply a default: val foo = settings.getInt(FOO) ?: DEFAULT_FOO + * + * // assert if missing: val required = settings.getInt(REQUIRED_VALUE) ?: error("required value + * missing") + */ +interface SecureSettings { + + fun getString(name: String): String? + + fun getInt(name: String): Int? + + fun getLong(name: String): Long? + + fun getFloat(name: String): Float? +} diff --git a/java/src/com/android/intentresolver/v2/platform/SecureSettingsModule.kt b/java/src/com/android/intentresolver/v2/platform/SecureSettingsModule.kt new file mode 100644 index 00000000..18f47023 --- /dev/null +++ b/java/src/com/android/intentresolver/v2/platform/SecureSettingsModule.kt @@ -0,0 +1,14 @@ +package com.android.intentresolver.v2.platform + +import dagger.Binds +import dagger.Module +import dagger.Reusable +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent + +@Module +@InstallIn(SingletonComponent::class) +interface SecureSettingsModule { + + @Binds @Reusable fun secureSettings(settings: PlatformSecureSettings): SecureSettings +} diff --git a/java/tests/Android.bp b/java/tests/Android.bp index 5244bf7b..a17400f8 100644 --- a/java/tests/Android.bp +++ b/java/tests/Android.bp @@ -50,7 +50,8 @@ android_test { "kotlinx_coroutines_test", "mockito-target-minus-junit4", "testables", - "truth-prebuilt", + "truth", + "truth-java8-extension", "flag-junit", "platform-test-annotations", ], diff --git a/java/tests/src/com/android/intentresolver/UnbundledChooserActivityTest.java b/java/tests/src/com/android/intentresolver/UnbundledChooserActivityTest.java index 73977f86..53a505df 100644 --- a/java/tests/src/com/android/intentresolver/UnbundledChooserActivityTest.java +++ b/java/tests/src/com/android/intentresolver/UnbundledChooserActivityTest.java @@ -2751,7 +2751,7 @@ public class UnbundledChooserActivityTest { final ChooserActivity activity = mActivityRule.launchActivity( Intent.createChooser(new Intent("ACTION_FOO"), "foo")); waitForIdle(); - assertThat(activity).isInstanceOf(com.android.intentresolver.ChooserWrapperActivity.class); + assertThat(activity).isInstanceOf(ChooserWrapperActivity.class); } private ResolveInfo createFakeResolveInfo() { diff --git a/java/tests/src/com/android/intentresolver/v2/ChooserActionFactoryTest.kt b/java/tests/src/com/android/intentresolver/v2/ChooserActionFactoryTest.kt new file mode 100644 index 00000000..a1a9bc92 --- /dev/null +++ b/java/tests/src/com/android/intentresolver/v2/ChooserActionFactoryTest.kt @@ -0,0 +1,232 @@ +/* + * Copyright (C) 2023 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.intentresolver.v2 + +import android.app.Activity +import android.app.PendingIntent +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Context.RECEIVER_EXPORTED +import android.content.Intent +import android.content.IntentFilter +import android.content.res.Resources +import android.graphics.drawable.Icon +import android.service.chooser.ChooserAction +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry +import com.android.intentresolver.ChooserRequestParameters +import com.android.intentresolver.logging.EventLog +import com.android.intentresolver.mock +import com.android.intentresolver.whenever +import com.google.common.collect.ImmutableList +import com.google.common.truth.Truth.assertThat +import java.util.Optional +import java.util.concurrent.CountDownLatch +import java.util.concurrent.TimeUnit +import java.util.function.Consumer +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.ArgumentMatchers.eq +import org.mockito.Mockito + +@RunWith(AndroidJUnit4::class) +class ChooserActionFactoryTest { + private val context = InstrumentationRegistry.getInstrumentation().context + + private val logger = mock() + private val actionLabel = "Action label" + private val modifyShareLabel = "Modify share" + private val testAction = "com.android.intentresolver.testaction" + private val countdown = CountDownLatch(1) + private val testReceiver: BroadcastReceiver = + object : BroadcastReceiver() { + override fun onReceive(context: Context, intent: Intent) { + // Just doing at most a single countdown per test. + countdown.countDown() + } + } + private val resultConsumer = + object : Consumer { + var latestReturn = Integer.MIN_VALUE + + override fun accept(resultCode: Int) { + latestReturn = resultCode + } + } + + @Before + fun setup() { + context.registerReceiver(testReceiver, IntentFilter(testAction), RECEIVER_EXPORTED) + } + + @After + fun teardown() { + context.unregisterReceiver(testReceiver) + } + + @Test + fun testCreateCustomActions() { + val factory = createFactory() + + val customActions = factory.createCustomActions() + + assertThat(customActions.size).isEqualTo(1) + assertThat(customActions[0].label).isEqualTo(actionLabel) + + // click it + customActions[0].onClicked.run() + + Mockito.verify(logger).logCustomActionSelected(eq(0)) + assertEquals(Activity.RESULT_OK, resultConsumer.latestReturn) + // Verify the pending intent has been called + assertTrue("Timed out waiting for broadcast", countdown.await(2500, TimeUnit.MILLISECONDS)) + } + + @Test + fun testNoModifyShareAction() { + val factory = createFactory(includeModifyShare = false) + + assertThat(factory.modifyShareAction).isNull() + } + + @Test + fun testModifyShareAction() { + val factory = createFactory(includeModifyShare = true) + + val action = factory.modifyShareAction ?: error("Modify share action should not be null") + action.onClicked.run() + + Mockito.verify(logger).logActionSelected(eq(EventLog.SELECTION_TYPE_MODIFY_SHARE)) + assertEquals(Activity.RESULT_OK, resultConsumer.latestReturn) + // Verify the pending intent has been called + assertTrue("Timed out waiting for broadcast", countdown.await(2500, TimeUnit.MILLISECONDS)) + } + + @Test + fun nonSendAction_noCopyRunnable() { + val targetIntent = + Intent(Intent.ACTION_SEND_MULTIPLE).apply { + putExtra(Intent.EXTRA_TEXT, "Text to show") + } + + val chooserRequest = + mock { + whenever(this.targetIntent).thenReturn(targetIntent) + whenever(chooserActions).thenReturn(ImmutableList.of()) + } + val testSubject = + ChooserActionFactory( + context, + chooserRequest, + Optional.empty(), + logger, + {}, + { null }, + mock(), + {}, + ) + assertThat(testSubject.copyButtonRunnable).isNull() + } + + @Test + fun sendActionNoText_noCopyRunnable() { + val targetIntent = Intent(Intent.ACTION_SEND) + + val chooserRequest = + mock { + whenever(this.targetIntent).thenReturn(targetIntent) + whenever(chooserActions).thenReturn(ImmutableList.of()) + } + val testSubject = + ChooserActionFactory( + context, + chooserRequest, + Optional.empty(), + logger, + {}, + { null }, + mock(), + {}, + ) + assertThat(testSubject.copyButtonRunnable).isNull() + } + + @Test + fun sendActionWithText_nonNullCopyRunnable() { + val targetIntent = Intent(Intent.ACTION_SEND).apply { putExtra(Intent.EXTRA_TEXT, "Text") } + + val chooserRequest = + mock { + whenever(this.targetIntent).thenReturn(targetIntent) + whenever(chooserActions).thenReturn(ImmutableList.of()) + } + val testSubject = + ChooserActionFactory( + context, + chooserRequest, + Optional.empty(), + logger, + {}, + { null }, + mock(), + {}, + ) + assertThat(testSubject.copyButtonRunnable).isNotNull() + } + + private fun createFactory(includeModifyShare: Boolean = false): ChooserActionFactory { + val testPendingIntent = + PendingIntent.getBroadcast(context, 0, Intent(testAction), PendingIntent.FLAG_IMMUTABLE) + val targetIntent = Intent() + val action = + ChooserAction.Builder( + Icon.createWithResource("", Resources.ID_NULL), + actionLabel, + testPendingIntent + ) + .build() + val chooserRequest = mock() + whenever(chooserRequest.targetIntent).thenReturn(targetIntent) + whenever(chooserRequest.chooserActions).thenReturn(ImmutableList.of(action)) + + if (includeModifyShare) { + val modifyShare = + ChooserAction.Builder( + Icon.createWithResource("", Resources.ID_NULL), + modifyShareLabel, + testPendingIntent + ) + .build() + whenever(chooserRequest.modifyShareAction).thenReturn(modifyShare) + } + + return ChooserActionFactory( + context, + chooserRequest, + Optional.empty(), + logger, + {}, + { null }, + mock(), + resultConsumer + ) + } +} diff --git a/java/tests/src/com/android/intentresolver/v2/ChooserWrapperActivity.java b/java/tests/src/com/android/intentresolver/v2/ChooserWrapperActivity.java index 41b31d01..65d33485 100644 --- a/java/tests/src/com/android/intentresolver/v2/ChooserWrapperActivity.java +++ b/java/tests/src/com/android/intentresolver/v2/ChooserWrapperActivity.java @@ -19,7 +19,6 @@ package com.android.intentresolver.v2; import android.annotation.Nullable; import android.app.prediction.AppPredictor; import android.app.usage.UsageStatsManager; -import android.content.ComponentName; import android.content.ContentResolver; import android.content.Context; import android.content.Intent; @@ -35,7 +34,6 @@ import android.os.UserHandle; import androidx.lifecycle.ViewModelProvider; import com.android.intentresolver.AnnotatedUserHandles; -import com.android.intentresolver.ChooserIntegratedDeviceComponents; import com.android.intentresolver.ChooserListAdapter; import com.android.intentresolver.ChooserRequestParameters; import com.android.intentresolver.IChooserWrapper; @@ -127,16 +125,6 @@ public class ChooserWrapperActivity extends ChooserActivity implements IChooserW return mIsSuccessfullySelected; } - @Override - protected ChooserIntegratedDeviceComponents getIntegratedDeviceComponents() { - return new ChooserIntegratedDeviceComponents( - /* editSharingComponent=*/ null, - // An arbitrary pre-installed activity that handles this type of intent: - /* nearbySharingComponent=*/ new ComponentName( - "com.google.android.apps.messaging", - ".ui.conversationlist.ShareIntentActivity")); - } - @Override public UsageStatsManager getUsageStatsManager() { if (mUsm == null) { diff --git a/java/tests/src/com/android/intentresolver/v2/UnbundledChooserActivityTest.java b/java/tests/src/com/android/intentresolver/v2/UnbundledChooserActivityTest.java index 1e74c7a5..4a8a5568 100644 --- a/java/tests/src/com/android/intentresolver/v2/UnbundledChooserActivityTest.java +++ b/java/tests/src/com/android/intentresolver/v2/UnbundledChooserActivityTest.java @@ -17,6 +17,7 @@ package com.android.intentresolver.v2; import static android.app.Activity.RESULT_OK; + import static androidx.test.espresso.Espresso.onView; import static androidx.test.espresso.action.ViewActions.click; import static androidx.test.espresso.action.ViewActions.longClick; @@ -29,16 +30,20 @@ import static androidx.test.espresso.matcher.ViewMatchers.isDisplayed; import static androidx.test.espresso.matcher.ViewMatchers.withEffectiveVisibility; import static androidx.test.espresso.matcher.ViewMatchers.withId; import static androidx.test.espresso.matcher.ViewMatchers.withText; + +import static com.android.intentresolver.ChooserActivity.TARGET_TYPE_CHOOSER_TARGET; +import static com.android.intentresolver.ChooserActivity.TARGET_TYPE_DEFAULT; +import static com.android.intentresolver.ChooserActivity.TARGET_TYPE_SHORTCUTS_FROM_PREDICTION_SERVICE; +import static com.android.intentresolver.ChooserActivity.TARGET_TYPE_SHORTCUTS_FROM_SHORTCUT_MANAGER; import static com.android.intentresolver.ChooserListAdapter.CALLER_TARGET_SCORE_BOOST; import static com.android.intentresolver.ChooserListAdapter.SHORTCUT_TARGET_SCORE_BOOST; import static com.android.intentresolver.MatcherUtils.first; -import static com.android.intentresolver.v2.ChooserActivity.TARGET_TYPE_CHOOSER_TARGET; -import static com.android.intentresolver.v2.ChooserActivity.TARGET_TYPE_DEFAULT; -import static com.android.intentresolver.v2.ChooserActivity.TARGET_TYPE_SHORTCUTS_FROM_PREDICTION_SERVICE; -import static com.android.intentresolver.v2.ChooserActivity.TARGET_TYPE_SHORTCUTS_FROM_SHORTCUT_MANAGER; + import static com.google.common.truth.Truth.assertThat; import static com.google.common.truth.Truth.assertWithMessage; + import static junit.framework.Assert.assertNull; + import static org.hamcrest.CoreMatchers.allOf; import static org.hamcrest.CoreMatchers.is; import static org.hamcrest.CoreMatchers.not; @@ -126,9 +131,16 @@ import com.android.intentresolver.contentpreview.ImageLoader; import com.android.intentresolver.logging.EventLog; import com.android.intentresolver.logging.FakeEventLog; import com.android.intentresolver.shortcuts.ShortcutLoader; +import com.android.intentresolver.v2.platform.ImageEditor; +import com.android.intentresolver.v2.platform.ImageEditorModule; import com.android.internal.config.sysui.SystemUiDeviceConfigFlags; import com.android.internal.logging.nano.MetricsProto.MetricsEvent; +import dagger.hilt.android.testing.BindValue; +import dagger.hilt.android.testing.HiltAndroidRule; +import dagger.hilt.android.testing.HiltAndroidTest; +import dagger.hilt.android.testing.UninstallModules; + import org.hamcrest.Description; import org.hamcrest.Matcher; import org.hamcrest.Matchers; @@ -148,6 +160,7 @@ import java.util.Collections; import java.util.HashMap; import java.util.List; import java.util.Map; +import java.util.Optional; import java.util.concurrent.CountDownLatch; import java.util.concurrent.Executors; import java.util.concurrent.ScheduledExecutorService; @@ -157,17 +170,14 @@ import java.util.concurrent.atomic.AtomicReference; import java.util.function.Consumer; import java.util.function.Function; -import dagger.hilt.android.testing.HiltAndroidRule; -import dagger.hilt.android.testing.HiltAndroidTest; - /** * Instrumentation tests for ChooserActivity. *

* Legacy test suite migrated from framework CoreTests. - *

*/ @RunWith(Parameterized.class) @HiltAndroidTest +@UninstallModules(ImageEditorModule.class) public class UnbundledChooserActivityTest { private static FakeEventLog getEventLog(ChooserWrapperActivity activity) { @@ -228,6 +238,13 @@ public class UnbundledChooserActivityTest { private final Function mPackageManagerOverride; + /** An arbitrary pre-installed activity that handles this type of intent. */ + @BindValue + @ImageEditor + final Optional mImageEditor = Optional.ofNullable( + ComponentName.unflattenFromString("com.google.android.apps.messaging/" + + ".ui.conversationlist.ShareIntentActivity")); + public UnbundledChooserActivityTest( Function packageManagerOverride) { mPackageManagerOverride = packageManagerOverride; @@ -897,10 +914,9 @@ public class UnbundledChooserActivityTest { // TODO(b/211669337): Determine the expected SHARESHEET_DIRECT_LOAD_COMPLETE events. } - - @Test @Ignore public void testEditImageLogs() { + Uri uri = createTestContentProviderUri("image/png", null); Intent sendIntent = createSendImageIntent(uri); ChooserActivityOverrideData.getInstance().imageLoader = @@ -2195,17 +2211,17 @@ public class UnbundledChooserActivityTest { mActivityRule.launchActivity(Intent.createChooser(sendIntent, "Scrollable preview test")); waitForIdle(); - onView(withId(R.id.scrollable_image_preview)) + onView(withId(com.android.intentresolver.R.id.scrollable_image_preview)) .check(matches(isDisplayed())); onView(withId(com.android.internal.R.id.contentPanel)).perform(swipeUp()); waitForIdle(); - onView(withId(R.id.chooser_headline_row_container)) + onView(withId(com.android.intentresolver.R.id.chooser_headline_row_container)) .check(matches(isCompletelyDisplayed())); - onView(withId(R.id.headline)) + onView(withId(com.android.intentresolver.R.id.headline)) .check(matches(isDisplayed())); - onView(withId(R.id.scrollable_image_preview)) + onView(withId(com.android.intentresolver.R.id.scrollable_image_preview)) .check(matches(not(isDisplayed()))); } diff --git a/java/tests/src/com/android/intentresolver/v2/platform/FakeSecureSettings.kt b/java/tests/src/com/android/intentresolver/v2/platform/FakeSecureSettings.kt new file mode 100644 index 00000000..4e279623 --- /dev/null +++ b/java/tests/src/com/android/intentresolver/v2/platform/FakeSecureSettings.kt @@ -0,0 +1,44 @@ +package com.android.intentresolver.v2.platform + +/** + * Creates a SecureSettings instance with predefined values: + * + * val settings = fakeSecureSettings { + * putString("stringValue", "example") + * putInt("intValue", 42) + * } + */ +fun fakeSecureSettings(block: FakeSecureSettings.Builder.() -> Unit): SecureSettings { + return FakeSecureSettings.Builder().apply(block).build() +} + +/** An in memory implementation of [SecureSettings]. */ +class FakeSecureSettings private constructor(private val map: Map) : + SecureSettings { + + override fun getString(name: String): String? = map[name] + override fun getInt(name: String): Int? = getString(name)?.toIntOrNull() + override fun getLong(name: String): Long? = getString(name)?.toLongOrNull() + override fun getFloat(name: String): Float? = getString(name)?.toFloatOrNull() + + class Builder { + private val map = mutableMapOf() + + fun putString(name: String, value: String) { + map[name] = value + } + fun putInt(name: String, value: Int) { + map[name] = value.toString() + } + fun putLong(name: String, value: Long) { + map[name] = value.toString() + } + fun putFloat(name: String, value: Float) { + map[name] = value.toString() + } + + fun build(): SecureSettings { + return FakeSecureSettings(map.toMap()) + } + } +} diff --git a/java/tests/src/com/android/intentresolver/v2/platform/FakeSecureSettingsTest.kt b/java/tests/src/com/android/intentresolver/v2/platform/FakeSecureSettingsTest.kt new file mode 100644 index 00000000..04c7093d --- /dev/null +++ b/java/tests/src/com/android/intentresolver/v2/platform/FakeSecureSettingsTest.kt @@ -0,0 +1,61 @@ +package com.android.intentresolver.v2.platform + +import com.google.common.truth.Truth.assertThat + +class FakeSecureSettingsTest { + + private val secureSettings = fakeSecureSettings { + putInt(intKey, intVal) + putString(stringKey, stringVal) + putFloat(floatKey, floatVal) + putLong(longKey, longVal) + } + + fun testExpectedValues_returned() { + assertThat(secureSettings.getInt(intKey)).isEqualTo(intVal) + assertThat(secureSettings.getString(stringKey)).isEqualTo(stringVal) + assertThat(secureSettings.getFloat(floatKey)).isEqualTo(floatVal) + assertThat(secureSettings.getLong(longKey)).isEqualTo(longVal) + } + + fun testUndefinedValues_returnNull() { + assertThat(secureSettings.getInt("unknown")).isNull() + assertThat(secureSettings.getString("unknown")).isNull() + assertThat(secureSettings.getFloat("unknown")).isNull() + assertThat(secureSettings.getLong("unknown")).isNull() + } + + /** + * FakeSecureSettings models the real secure settings by storing values in String form. The + * value is returned if/when it can be parsed from the string value, otherwise null. + */ + fun testMismatchedTypes() { + assertThat(secureSettings.getString(intKey)).isEqualTo(intVal.toString()) + assertThat(secureSettings.getString(floatKey)).isEqualTo(floatVal.toString()) + assertThat(secureSettings.getString(longKey)).isEqualTo(longVal.toString()) + + assertThat(secureSettings.getInt(stringKey)).isNull() + assertThat(secureSettings.getLong(stringKey)).isNull() + assertThat(secureSettings.getFloat(stringKey)).isNull() + + assertThat(secureSettings.getInt(longKey)).isNull() + assertThat(secureSettings.getFloat(longKey)).isNull() // TODO: verify Long.MAX > Float.MAX ? + + assertThat(secureSettings.getLong(floatKey)).isNull() // TODO: or is Float.MAX > Long.MAX? + assertThat(secureSettings.getInt(floatKey)).isNull() + } + + companion object Data { + const val intKey = "int" + const val intVal = Int.MAX_VALUE + + const val stringKey = "string" + const val stringVal = "String" + + const val floatKey = "float" + const val floatVal = Float.MAX_VALUE + + const val longKey = "long" + const val longVal = Long.MAX_VALUE + } +} diff --git a/java/tests/src/com/android/intentresolver/v2/platform/NearbyShareModuleTest.kt b/java/tests/src/com/android/intentresolver/v2/platform/NearbyShareModuleTest.kt new file mode 100644 index 00000000..fd5c8b3f --- /dev/null +++ b/java/tests/src/com/android/intentresolver/v2/platform/NearbyShareModuleTest.kt @@ -0,0 +1,83 @@ +package com.android.intentresolver.v2.platform + +import android.content.ComponentName +import android.content.Context +import android.content.res.Configuration +import android.provider.Settings +import android.testing.TestableResources + +import androidx.test.platform.app.InstrumentationRegistry + +import com.android.intentresolver.R + +import com.google.common.truth.Truth8.assertThat + +import org.junit.Before +import org.junit.Test + +class NearbyShareModuleTest { + + lateinit var context: Context + + /** Create Resources with overridden values. */ + private fun Context.fakeResources( + config: Configuration? = null, + block: TestableResources.() -> Unit + ) = + TestableResources(resources) + .apply { config?.let { overrideConfiguration(it) } } + .apply(block) + .resources + + @Before + fun setup() { + val instr = InstrumentationRegistry.getInstrumentation() + context = instr.context + } + + @Test + fun valueIsAbsent_whenUnset() { + val secureSettings = fakeSecureSettings {} + val resources = + context.fakeResources { addOverride(R.string.config_defaultNearbySharingComponent, "") } + + val componentName = NearbyShareModule.nearbyShareComponent(resources, secureSettings) + assertThat(componentName).isEmpty() + } + + @Test + fun defaultValue_readFromResources() { + val secureSettings = fakeSecureSettings {} + val resources = + context.fakeResources { + addOverride( + R.string.config_defaultNearbySharingComponent, + "com.example/.ComponentName" + ) + } + + val nearbyShareComponent = NearbyShareModule.nearbyShareComponent(resources, secureSettings) + + assertThat(nearbyShareComponent).hasValue( + ComponentName.unflattenFromString("com.example/.ComponentName")) + } + + @Test + fun secureSettings_overridesDefault() { + val secureSettings = fakeSecureSettings { + putString(Settings.Secure.NEARBY_SHARING_COMPONENT, "com.example/.BComponent") + } + val resources = + context.fakeResources { + addOverride( + R.string.config_defaultNearbySharingComponent, + "com.example/.AComponent" + ) + } + + val nearbyShareComponent = NearbyShareModule.nearbyShareComponent(resources, secureSettings) + + assertThat(nearbyShareComponent).hasValue( + ComponentName.unflattenFromString("com.example/.BComponent")) + } +} -- cgit v1.2.3-59-g8ed1b From 3b306cbbce743466212778fdda51cd1600b0711d Mon Sep 17 00:00:00 2001 From: Mark Renouf Date: Tue, 10 Oct 2023 12:28:19 -0400 Subject: Decouple initialization from framework lifecycle call chain This change decouples onCreate from the actual work of setting up the application, while maintaining independent function of ResolverActivity. This allows deferring initialization work until returning from super.onCreate, when injected members are available. Bug: 300157408 Bug: 302113519 Test: atest IntentResolverUnitTests#ResolverActivityTest Change-Id: I8ed34f44d4040dce8a1292f1490fe2e724dc8473 --- .../android/intentresolver/v2/ChooserActivity.java | 7 +- .../intentresolver/v2/ResolverActivity.java | 87 ++++++++-------------- 2 files changed, 32 insertions(+), 62 deletions(-) (limited to 'java/src') diff --git a/java/src/com/android/intentresolver/v2/ChooserActivity.java b/java/src/com/android/intentresolver/v2/ChooserActivity.java index 12e708f6..f13d87ce 100644 --- a/java/src/com/android/intentresolver/v2/ChooserActivity.java +++ b/java/src/com/android/intentresolver/v2/ChooserActivity.java @@ -251,6 +251,8 @@ public class ChooserActivity extends Hilt_ChooserActivity implements @Override protected void onCreate(Bundle savedInstanceState) { Tracer.INSTANCE.markLaunched(); + super.onCreate(savedInstanceState); + final long intentReceivedTime = System.currentTimeMillis(); mLatencyTracker.onActionStart(ACTION_LOAD_SHARE_SHEET); @@ -262,7 +264,6 @@ public class ChooserActivity extends Hilt_ChooserActivity implements } catch (IllegalArgumentException e) { Log.e(TAG, "Caller provided invalid Chooser request parameters", e); finish(); - super_onCreate(null); return; } mPinnedSharedPrefs = getPinnedSharedPrefs(this); @@ -278,9 +279,7 @@ public class ChooserActivity extends Hilt_ChooserActivity implements mChooserRequest.getTargetIntentFilter()), mChooserRequest.getTargetIntentFilter()); - - super.onCreate( - savedInstanceState, + init( mChooserRequest.getTargetIntent(), mChooserRequest.getAdditionalTargets(), mChooserRequest.getTitle(), diff --git a/java/src/com/android/intentresolver/v2/ResolverActivity.java b/java/src/com/android/intentresolver/v2/ResolverActivity.java index 03221c6c..9e6a15a8 100644 --- a/java/src/com/android/intentresolver/v2/ResolverActivity.java +++ b/java/src/com/android/intentresolver/v2/ResolverActivity.java @@ -350,57 +350,38 @@ public class ResolverActivity extends FragmentActivity implements @Override protected void onCreate(Bundle savedInstanceState) { - // Use a specialized prompt when we're handling the 'Home' app startActivity() - final Intent intent = makeMyIntent(); - final Set categories = intent.getCategories(); - if (Intent.ACTION_MAIN.equals(intent.getAction()) - && categories != null - && categories.size() == 1 - && categories.contains(Intent.CATEGORY_HOME)) { - // Note: this field is not set to true in the compatibility version. - mResolvingHome = true; - } - - onCreate( - savedInstanceState, - intent, - /* additionalTargets= */ null, - /* title= */ null, - /* defaultTitleRes= */ 0, - /* initialIntents= */ null, - /* resolutionList= */ null, - /* supportsAlwaysUseOption= */ true, - createIconLoader(), - /* safeForwardingMode= */ true); - } + super.onCreate(savedInstanceState); + if (isFinishing()) { + // Performing a clean exit: + // Skip initializing any additional resources. + return; + } + if (mIsIntentPicker) { + // Use a specialized prompt when we're handling the 'Home' app startActivity() + final Intent intent = makeMyIntent(); + final Set categories = intent.getCategories(); + if (Intent.ACTION_MAIN.equals(intent.getAction()) + && categories != null + && categories.size() == 1 + && categories.contains(Intent.CATEGORY_HOME)) { + // Note: this field is not set to true in the compatibility version. + mResolvingHome = true; + } - /** - * Compatibility version for other bundled services that use this overload without - * a default title resource - */ - protected void onCreate( - Bundle savedInstanceState, - Intent intent, - CharSequence title, - Intent[] initialIntents, - List resolutionList, - boolean supportsAlwaysUseOption, - boolean safeForwardingMode) { - onCreate( - savedInstanceState, - intent, - null, - title, - 0, - initialIntents, - resolutionList, - supportsAlwaysUseOption, - createIconLoader(), - safeForwardingMode); + init( + intent, + /* additionalTargets= */ null, + /* title= */ null, + /* defaultTitleRes= */ 0, + /* initialIntents= */ null, + /* resolutionList= */ null, + /* supportsAlwaysUseOption= */ true, + createIconLoader(), + /* safeForwardingMode= */ true); + } } - protected void onCreate( - Bundle savedInstanceState, + protected void init( Intent intent, Intent[] additionalTargets, CharSequence title, @@ -411,7 +392,6 @@ public class ResolverActivity extends FragmentActivity implements TargetDataLoader targetDataLoader, boolean safeForwardingMode) { setTheme(appliedThemeResId()); - super.onCreate(savedInstanceState); // Determine whether we should show that intent is forwarded // from managed profile to owner or other way around. @@ -1192,15 +1172,6 @@ public class ResolverActivity extends FragmentActivity implements return intent; } - /** - * Call {@link Activity#onCreate} without initializing anything further. This should - * only be used when the activity is about to be immediately finished to avoid wasting - * initializing steps and leaking resources. - */ - protected final void super_onCreate(Bundle savedInstanceState) { - super.onCreate(savedInstanceState); - } - private ResolverMultiProfilePagerAdapter createResolverMultiProfilePagerAdapterForOneProfile( Intent[] initialIntents, -- cgit v1.2.3-59-g8ed1b From cf8c54400d2d94d3f3a32e1ef0cc48fa576c75b4 Mon Sep 17 00:00:00 2001 From: Andrey Epin Date: Mon, 9 Oct 2023 12:08:49 -0700 Subject: Inject TargetDataLoader into ChooserActivity Bug: 285314844 Test: presubmits, funtionalityf smoke tests Change-Id: Ib27b76be3ca81f25a866eb49d6cb5ea0ce27de0c --- .../intentresolver/inject/FrameworkModule.kt | 7 ++-- .../android/intentresolver/v2/ChooserActivity.java | 4 +-- .../v2/icons/TargetDataLoaderModule.kt | 40 ++++++++++++++++++++++ 3 files changed, 46 insertions(+), 5 deletions(-) create mode 100644 java/src/com/android/intentresolver/v2/icons/TargetDataLoaderModule.kt (limited to 'java/src') diff --git a/java/src/com/android/intentresolver/inject/FrameworkModule.kt b/java/src/com/android/intentresolver/inject/FrameworkModule.kt index 39a2faf9..2f6cc6a0 100644 --- a/java/src/com/android/intentresolver/inject/FrameworkModule.kt +++ b/java/src/com/android/intentresolver/inject/FrameworkModule.kt @@ -21,7 +21,6 @@ import android.app.admin.DevicePolicyManager import android.content.ClipboardManager import android.content.Context import android.content.pm.LauncherApps -import android.content.pm.PackageManager import android.content.pm.ShortcutManager import android.os.UserManager import android.view.WindowManager @@ -39,7 +38,9 @@ private fun Context.requireSystemService(serviceClass: Class): T { @InstallIn(SingletonComponent::class) object FrameworkModule { - @Provides fun contentResolver(@ApplicationContext ctx: Context) = ctx.contentResolver!! + @Provides + fun contentResolver(@ApplicationContext ctx: Context) = + requireNotNull(ctx.contentResolver) { "ContentResolver is expected but missing" } @Provides fun activityManager(@ApplicationContext ctx: Context) = @@ -59,7 +60,7 @@ object FrameworkModule { @Provides fun packageManager(@ApplicationContext ctx: Context) = - ctx.requireSystemService(PackageManager::class.java) + requireNotNull(ctx.packageManager) { "PackageManager is expected but missing" } @Provides fun shortcutManager(@ApplicationContext ctx: Context) = diff --git a/java/src/com/android/intentresolver/v2/ChooserActivity.java b/java/src/com/android/intentresolver/v2/ChooserActivity.java index f13d87ce..6094e66a 100644 --- a/java/src/com/android/intentresolver/v2/ChooserActivity.java +++ b/java/src/com/android/intentresolver/v2/ChooserActivity.java @@ -99,7 +99,6 @@ import com.android.intentresolver.emptystate.EmptyStateProvider; import com.android.intentresolver.emptystate.NoCrossProfileEmptyStateProvider; import com.android.intentresolver.emptystate.NoCrossProfileEmptyStateProvider.DevicePolicyBlockerEmptyState; import com.android.intentresolver.grid.ChooserGridAdapter; -import com.android.intentresolver.icons.DefaultTargetDataLoader; import com.android.intentresolver.icons.TargetDataLoader; import com.android.intentresolver.logging.EventLog; import com.android.intentresolver.measurements.Tracer; @@ -194,6 +193,7 @@ public class ChooserActivity extends Hilt_ChooserActivity implements @Inject public EventLog mEventLog; @Inject @ImageEditor public Optional mImageEditor; @Inject @NearbyShare public Optional mNearbyShare; + @Inject public TargetDataLoader mTargetDataLoader; /* TODO: this is `nullable` because we have to defer the assignment til onCreate(). We make the * only assignment there, and expect it to be ready by the time we ever use it -- @@ -287,7 +287,7 @@ public class ChooserActivity extends Hilt_ChooserActivity implements mChooserRequest.getInitialIntents(), /* resolutionList= */ null, /* supportsAlwaysUseOption= */ false, - new DefaultTargetDataLoader(this, getLifecycle(), false), + mTargetDataLoader, /* safeForwardingMode= */ true); getEventLog().logSharesheetTriggered(); diff --git a/java/src/com/android/intentresolver/v2/icons/TargetDataLoaderModule.kt b/java/src/com/android/intentresolver/v2/icons/TargetDataLoaderModule.kt new file mode 100644 index 00000000..4e8783f8 --- /dev/null +++ b/java/src/com/android/intentresolver/v2/icons/TargetDataLoaderModule.kt @@ -0,0 +1,40 @@ +/* + * Copyright (C) 2023 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.intentresolver.v2.icons + +import android.content.Context +import androidx.lifecycle.Lifecycle +import com.android.intentresolver.icons.DefaultTargetDataLoader +import com.android.intentresolver.icons.TargetDataLoader +import com.android.intentresolver.inject.ActivityOwned +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.android.components.ActivityComponent +import dagger.hilt.android.qualifiers.ActivityContext +import dagger.hilt.android.scopes.ActivityScoped + +@Module +@InstallIn(ActivityComponent::class) +object TargetDataLoaderModule { + @Provides + @ActivityScoped + fun targetDataLoader( + @ActivityContext context: Context, + @ActivityOwned lifecycle: Lifecycle, + ): TargetDataLoader = DefaultTargetDataLoader(context, lifecycle, isAudioCaptureDevice = false) +} -- cgit v1.2.3-59-g8ed1b From 8ea89dd1b1a192710340d58eae28a003f63c804c Mon Sep 17 00:00:00 2001 From: Andrey Epin Date: Sun, 24 Sep 2023 11:49:28 -0700 Subject: Replace array of strings with a class Bug: 285314844 Test: presubmits, funtionalityf smoke tests Change-Id: Ic2226af153850c6855c08ca20063fcc65c8b958e --- .../intentresolver/ResolverListAdapter.java | 7 ++++--- .../icons/DefaultTargetDataLoader.kt | 6 +++--- .../com/android/intentresolver/icons/LabelInfo.kt | 19 +++++++++++++++++++ .../intentresolver/icons/LoadLabelTask.java | 22 ++++++++++------------ .../intentresolver/icons/TargetDataLoader.kt | 2 +- .../intentresolver/ResolverWrapperActivity.java | 3 ++- .../intentresolver/v2/ResolverWrapperActivity.java | 3 ++- 7 files changed, 41 insertions(+), 21 deletions(-) create mode 100644 java/src/com/android/intentresolver/icons/LabelInfo.kt (limited to 'java/src') diff --git a/java/src/com/android/intentresolver/ResolverListAdapter.java b/java/src/com/android/intentresolver/ResolverListAdapter.java index d1e8c15b..ab669f92 100644 --- a/java/src/com/android/intentresolver/ResolverListAdapter.java +++ b/java/src/com/android/intentresolver/ResolverListAdapter.java @@ -47,6 +47,7 @@ import androidx.annotation.WorkerThread; import com.android.intentresolver.chooser.DisplayResolveInfo; import com.android.intentresolver.chooser.TargetInfo; +import com.android.intentresolver.icons.LabelInfo; import com.android.intentresolver.icons.TargetDataLoader; import com.android.internal.annotations.VisibleForTesting; @@ -762,12 +763,12 @@ public class ResolverListAdapter extends BaseAdapter { } protected final void onLabelLoaded( - DisplayResolveInfo displayResolveInfo, CharSequence[] result) { + DisplayResolveInfo displayResolveInfo, LabelInfo result) { if (displayResolveInfo.hasDisplayLabel()) { return; } - displayResolveInfo.setDisplayLabel(result[0]); - displayResolveInfo.setExtendedInfo(result[1]); + displayResolveInfo.setDisplayLabel(result.getLabel()); + displayResolveInfo.setExtendedInfo(result.getSubLabel()); notifyDataSetChanged(); } diff --git a/java/src/com/android/intentresolver/icons/DefaultTargetDataLoader.kt b/java/src/com/android/intentresolver/icons/DefaultTargetDataLoader.kt index 646ca8e1..054fbe71 100644 --- a/java/src/com/android/intentresolver/icons/DefaultTargetDataLoader.kt +++ b/java/src/com/android/intentresolver/icons/DefaultTargetDataLoader.kt @@ -94,7 +94,7 @@ class DefaultTargetDataLoader( .executeOnExecutor(executor) } - override fun loadLabel(info: DisplayResolveInfo, callback: Consumer>) { + override fun loadLabel(info: DisplayResolveInfo, callback: Consumer) { val taskId = nextTaskId.getAndIncrement() LoadLabelTask(context, info, isAudioCaptureDevice, presentationFactory) { result -> removeTask(taskId) @@ -108,8 +108,8 @@ class DefaultTargetDataLoader( if (!info.hasDisplayLabel()) { val result = LoadLabelTask.loadLabel(context, info, isAudioCaptureDevice, presentationFactory) - info.displayLabel = result[0] - info.extendedInfo = result[1] + info.displayLabel = result.label + info.extendedInfo = result.subLabel } } diff --git a/java/src/com/android/intentresolver/icons/LabelInfo.kt b/java/src/com/android/intentresolver/icons/LabelInfo.kt new file mode 100644 index 00000000..a9c4cd77 --- /dev/null +++ b/java/src/com/android/intentresolver/icons/LabelInfo.kt @@ -0,0 +1,19 @@ +/* + * Copyright (C) 2023 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.intentresolver.icons + +class LabelInfo(val label: CharSequence?, val subLabel: CharSequence?) diff --git a/java/src/com/android/intentresolver/icons/LoadLabelTask.java b/java/src/com/android/intentresolver/icons/LoadLabelTask.java index b9a9d89d..6d443f78 100644 --- a/java/src/com/android/intentresolver/icons/LoadLabelTask.java +++ b/java/src/com/android/intentresolver/icons/LoadLabelTask.java @@ -28,16 +28,16 @@ import com.android.intentresolver.chooser.DisplayResolveInfo; import java.util.function.Consumer; -class LoadLabelTask extends AsyncTask { +class LoadLabelTask extends AsyncTask { private final Context mContext; private final DisplayResolveInfo mDisplayResolveInfo; private final boolean mIsAudioCaptureDevice; protected final TargetPresentationGetter.Factory mPresentationFactory; - private final Consumer mCallback; + private final Consumer mCallback; LoadLabelTask(Context context, DisplayResolveInfo dri, boolean isAudioCaptureDevice, TargetPresentationGetter.Factory presentationFactory, - Consumer callback) { + Consumer callback) { mContext = context; mDisplayResolveInfo = dri; mIsAudioCaptureDevice = isAudioCaptureDevice; @@ -46,7 +46,7 @@ class LoadLabelTask extends AsyncTask { } @Override - protected CharSequence[] doInBackground(Void... voids) { + protected LabelInfo doInBackground(Void... voids) { try { Trace.beginSection("app-label"); return loadLabel( @@ -56,7 +56,7 @@ class LoadLabelTask extends AsyncTask { } } - static CharSequence[] loadLabel( + static LabelInfo loadLabel( Context context, DisplayResolveInfo displayResolveInfo, boolean isAudioCaptureDevice, @@ -79,21 +79,19 @@ class LoadLabelTask extends AsyncTask { if (!hasRecordPermission) { // Doesn't have record permission, so warn the user - return new CharSequence[]{ + return new LabelInfo( pg.getLabel(), - context.getString(R.string.usb_device_resolve_prompt_warn) - }; + context.getString(R.string.usb_device_resolve_prompt_warn)); } } - return new CharSequence[]{ + return new LabelInfo( pg.getLabel(), - pg.getSubLabel() - }; + pg.getSubLabel()); } @Override - protected void onPostExecute(CharSequence[] result) { + protected void onPostExecute(LabelInfo result) { mCallback.accept(result); } } diff --git a/java/src/com/android/intentresolver/icons/TargetDataLoader.kt b/java/src/com/android/intentresolver/icons/TargetDataLoader.kt index 6186a5ab..07c62177 100644 --- a/java/src/com/android/intentresolver/icons/TargetDataLoader.kt +++ b/java/src/com/android/intentresolver/icons/TargetDataLoader.kt @@ -39,7 +39,7 @@ abstract class TargetDataLoader { ) /** Load target label */ - abstract fun loadLabel(info: DisplayResolveInfo, callback: Consumer>) + abstract fun loadLabel(info: DisplayResolveInfo, callback: Consumer) /** Loads DisplayResolveInfo's display label synchronously, if needed */ abstract fun getOrLoadLabel(info: DisplayResolveInfo) diff --git a/java/tests/src/com/android/intentresolver/ResolverWrapperActivity.java b/java/tests/src/com/android/intentresolver/ResolverWrapperActivity.java index fbcfcd35..7ffb90ce 100644 --- a/java/tests/src/com/android/intentresolver/ResolverWrapperActivity.java +++ b/java/tests/src/com/android/intentresolver/ResolverWrapperActivity.java @@ -38,6 +38,7 @@ import com.android.intentresolver.chooser.DisplayResolveInfo; import com.android.intentresolver.chooser.SelectableTargetInfo; import com.android.intentresolver.chooser.TargetInfo; import com.android.intentresolver.emptystate.CrossProfileIntentsChecker; +import com.android.intentresolver.icons.LabelInfo; import com.android.intentresolver.icons.TargetDataLoader; import java.util.List; @@ -263,7 +264,7 @@ public class ResolverWrapperActivity extends ResolverActivity { @Override public void loadLabel( @NonNull DisplayResolveInfo info, - @NonNull Consumer callback) { + @NonNull Consumer callback) { mLabelIdlingResource.increment(); mTargetDataLoader.loadLabel( info, diff --git a/java/tests/src/com/android/intentresolver/v2/ResolverWrapperActivity.java b/java/tests/src/com/android/intentresolver/v2/ResolverWrapperActivity.java index 610d031e..0fb77457 100644 --- a/java/tests/src/com/android/intentresolver/v2/ResolverWrapperActivity.java +++ b/java/tests/src/com/android/intentresolver/v2/ResolverWrapperActivity.java @@ -42,6 +42,7 @@ import com.android.intentresolver.chooser.DisplayResolveInfo; import com.android.intentresolver.chooser.SelectableTargetInfo; import com.android.intentresolver.chooser.TargetInfo; import com.android.intentresolver.emptystate.CrossProfileIntentsChecker; +import com.android.intentresolver.icons.LabelInfo; import com.android.intentresolver.icons.TargetDataLoader; import java.util.List; @@ -267,7 +268,7 @@ public class ResolverWrapperActivity extends ResolverActivity { @Override public void loadLabel( @NonNull DisplayResolveInfo info, - @NonNull Consumer callback) { + @NonNull Consumer callback) { mLabelIdlingResource.increment(); mTargetDataLoader.loadLabel( info, -- cgit v1.2.3-59-g8ed1b From 037da29462e8febb26dd88b77fc55063086708c8 Mon Sep 17 00:00:00 2001 From: Joshua Trask Date: Thu, 12 Oct 2023 15:42:40 +0000 Subject: 'v2' forks of some empty state providers The `NoAppsAvailable` and `WorkProfilePaused` providers are expected to need upcoming changes as prototyped in ag/24968615. That CL doesn't include any proposed changes to the `NoCrossProfile` provider, but we can reasonably expect to need such changes for our upcoming profile support work. Changes in this CL, by snapshot: 1. Copy the files over to 'v2', no changes whatsoever. 2. Change the package of the copied files to 'v2', and update references to them from 'v2' Chooser/Resolver. Bug: 302311217 Test: IntentResolverUnitTests Change-Id: I15be5a4f076320783a841d260eb08cacbd02df86 --- .../android/intentresolver/v2/ChooserActivity.java | 4 +- .../intentresolver/v2/ResolverActivity.java | 9 +- .../NoAppsAvailableEmptyStateProvider.java | 155 +++++++++++++++++++++ .../NoCrossProfileEmptyStateProvider.java | 137 ++++++++++++++++++ .../WorkProfilePausedEmptyStateProvider.java | 115 +++++++++++++++ 5 files changed, 413 insertions(+), 7 deletions(-) create mode 100644 java/src/com/android/intentresolver/v2/emptystate/NoAppsAvailableEmptyStateProvider.java create mode 100644 java/src/com/android/intentresolver/v2/emptystate/NoCrossProfileEmptyStateProvider.java create mode 100644 java/src/com/android/intentresolver/v2/emptystate/WorkProfilePausedEmptyStateProvider.java (limited to 'java/src') diff --git a/java/src/com/android/intentresolver/v2/ChooserActivity.java b/java/src/com/android/intentresolver/v2/ChooserActivity.java index f13d87ce..e49cc91e 100644 --- a/java/src/com/android/intentresolver/v2/ChooserActivity.java +++ b/java/src/com/android/intentresolver/v2/ChooserActivity.java @@ -96,8 +96,6 @@ import com.android.intentresolver.contentpreview.HeadlineGeneratorImpl; import com.android.intentresolver.contentpreview.PreviewViewModel; import com.android.intentresolver.emptystate.EmptyState; import com.android.intentresolver.emptystate.EmptyStateProvider; -import com.android.intentresolver.emptystate.NoCrossProfileEmptyStateProvider; -import com.android.intentresolver.emptystate.NoCrossProfileEmptyStateProvider.DevicePolicyBlockerEmptyState; import com.android.intentresolver.grid.ChooserGridAdapter; import com.android.intentresolver.icons.DefaultTargetDataLoader; import com.android.intentresolver.icons.TargetDataLoader; @@ -108,6 +106,8 @@ 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.emptystate.NoCrossProfileEmptyStateProvider; +import com.android.intentresolver.v2.emptystate.NoCrossProfileEmptyStateProvider.DevicePolicyBlockerEmptyState; import com.android.intentresolver.v2.platform.ImageEditor; import com.android.intentresolver.v2.platform.NearbyShare; import com.android.intentresolver.widget.ImagePreviewView; diff --git a/java/src/com/android/intentresolver/v2/ResolverActivity.java b/java/src/com/android/intentresolver/v2/ResolverActivity.java index 9e6a15a8..a0388456 100644 --- a/java/src/com/android/intentresolver/v2/ResolverActivity.java +++ b/java/src/com/android/intentresolver/v2/ResolverActivity.java @@ -39,7 +39,6 @@ import static com.android.internal.annotations.VisibleForTesting.Visibility.PROT import android.annotation.Nullable; import android.annotation.StringRes; import android.annotation.UiThread; -import android.app.Activity; import android.app.ActivityManager; import android.app.ActivityThread; import android.app.VoiceInteractor.PickOptionRequest; @@ -110,16 +109,16 @@ import com.android.intentresolver.emptystate.CompositeEmptyStateProvider; import com.android.intentresolver.emptystate.CrossProfileIntentsChecker; import com.android.intentresolver.emptystate.EmptyState; import com.android.intentresolver.emptystate.EmptyStateProvider; -import com.android.intentresolver.emptystate.NoAppsAvailableEmptyStateProvider; -import com.android.intentresolver.emptystate.NoCrossProfileEmptyStateProvider; -import com.android.intentresolver.emptystate.NoCrossProfileEmptyStateProvider.DevicePolicyBlockerEmptyState; -import com.android.intentresolver.emptystate.WorkProfilePausedEmptyStateProvider; import com.android.intentresolver.icons.DefaultTargetDataLoader; import com.android.intentresolver.icons.TargetDataLoader; import com.android.intentresolver.model.ResolverRankerServiceResolverComparator; import com.android.intentresolver.v2.MultiProfilePagerAdapter.MyUserIdProvider; import com.android.intentresolver.v2.MultiProfilePagerAdapter.OnSwitchOnWorkSelectedListener; import com.android.intentresolver.v2.MultiProfilePagerAdapter.Profile; +import com.android.intentresolver.v2.emptystate.NoAppsAvailableEmptyStateProvider; +import com.android.intentresolver.v2.emptystate.NoCrossProfileEmptyStateProvider; +import com.android.intentresolver.v2.emptystate.NoCrossProfileEmptyStateProvider.DevicePolicyBlockerEmptyState; +import com.android.intentresolver.v2.emptystate.WorkProfilePausedEmptyStateProvider; import com.android.intentresolver.widget.ResolverDrawerLayout; import com.android.internal.annotations.VisibleForTesting; import com.android.internal.content.PackageMonitor; diff --git a/java/src/com/android/intentresolver/v2/emptystate/NoAppsAvailableEmptyStateProvider.java b/java/src/com/android/intentresolver/v2/emptystate/NoAppsAvailableEmptyStateProvider.java new file mode 100644 index 00000000..c76f8a2d --- /dev/null +++ b/java/src/com/android/intentresolver/v2/emptystate/NoAppsAvailableEmptyStateProvider.java @@ -0,0 +1,155 @@ +/* + * Copyright (C) 2022 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.intentresolver.v2.emptystate; + +import static android.app.admin.DevicePolicyResources.Strings.Core.RESOLVER_NO_PERSONAL_APPS; +import static android.app.admin.DevicePolicyResources.Strings.Core.RESOLVER_NO_WORK_APPS; + +import android.annotation.NonNull; +import android.annotation.Nullable; +import android.app.admin.DevicePolicyEventLogger; +import android.app.admin.DevicePolicyManager; +import android.content.Context; +import android.content.pm.ResolveInfo; +import android.os.UserHandle; +import android.stats.devicepolicy.nano.DevicePolicyEnums; + +import com.android.intentresolver.ResolvedComponentInfo; +import com.android.intentresolver.ResolverListAdapter; +import com.android.intentresolver.emptystate.EmptyState; +import com.android.intentresolver.emptystate.EmptyStateProvider; +import com.android.internal.R; + +import java.util.List; + +/** + * Chooser/ResolverActivity empty state provider that returns empty state which is shown when + * there are no apps available. + */ +public class NoAppsAvailableEmptyStateProvider implements EmptyStateProvider { + + @NonNull + private final Context mContext; + @Nullable + private final UserHandle mWorkProfileUserHandle; + @Nullable + private final UserHandle mPersonalProfileUserHandle; + @NonNull + private final String mMetricsCategory; + @NonNull + private final UserHandle mTabOwnerUserHandleForLaunch; + + public NoAppsAvailableEmptyStateProvider(Context context, UserHandle workProfileUserHandle, + UserHandle personalProfileUserHandle, String metricsCategory, + UserHandle tabOwnerUserHandleForLaunch) { + mContext = context; + mWorkProfileUserHandle = workProfileUserHandle; + mPersonalProfileUserHandle = personalProfileUserHandle; + mMetricsCategory = metricsCategory; + mTabOwnerUserHandleForLaunch = tabOwnerUserHandleForLaunch; + } + + @Nullable + @Override + @SuppressWarnings("ReferenceEquality") + public EmptyState getEmptyState(ResolverListAdapter resolverListAdapter) { + UserHandle listUserHandle = resolverListAdapter.getUserHandle(); + + if (mWorkProfileUserHandle != null + && (mTabOwnerUserHandleForLaunch.equals(listUserHandle) + || !hasAppsInOtherProfile(resolverListAdapter))) { + + String title; + if (listUserHandle == mPersonalProfileUserHandle) { + title = mContext.getSystemService( + DevicePolicyManager.class).getResources().getString( + RESOLVER_NO_PERSONAL_APPS, + () -> mContext.getString(R.string.resolver_no_personal_apps_available)); + } else { + title = mContext.getSystemService( + DevicePolicyManager.class).getResources().getString( + RESOLVER_NO_WORK_APPS, + () -> mContext.getString(R.string.resolver_no_work_apps_available)); + } + + return new NoAppsAvailableEmptyState( + title, mMetricsCategory, + /* isPersonalProfile= */ listUserHandle == mPersonalProfileUserHandle + ); + } else if (mWorkProfileUserHandle == null) { + // Return default empty state without tracking + return new DefaultEmptyState(); + } + + return null; + } + + private boolean hasAppsInOtherProfile(ResolverListAdapter adapter) { + if (mWorkProfileUserHandle == null) { + return false; + } + List resolversForIntent = + adapter.getResolversForUser(mTabOwnerUserHandleForLaunch); + for (ResolvedComponentInfo info : resolversForIntent) { + ResolveInfo resolveInfo = info.getResolveInfoAt(0); + if (resolveInfo.targetUserId != UserHandle.USER_CURRENT) { + return true; + } + } + return false; + } + + public static class DefaultEmptyState implements EmptyState { + @Override + public boolean useDefaultEmptyView() { + return true; + } + } + + public static class NoAppsAvailableEmptyState implements EmptyState { + + @NonNull + private String mTitle; + + @NonNull + private String mMetricsCategory; + + private boolean mIsPersonalProfile; + + public NoAppsAvailableEmptyState(String title, String metricsCategory, + boolean isPersonalProfile) { + mTitle = title; + mMetricsCategory = metricsCategory; + mIsPersonalProfile = isPersonalProfile; + } + + @Nullable + @Override + public String getTitle() { + return mTitle; + } + + @Override + public void onEmptyStateShown() { + DevicePolicyEventLogger.createEvent( + DevicePolicyEnums.RESOLVER_EMPTY_STATE_NO_APPS_RESOLVED) + .setStrings(mMetricsCategory) + .setBoolean(/*isPersonalProfile*/ mIsPersonalProfile) + .write(); + } + } +} diff --git a/java/src/com/android/intentresolver/v2/emptystate/NoCrossProfileEmptyStateProvider.java b/java/src/com/android/intentresolver/v2/emptystate/NoCrossProfileEmptyStateProvider.java new file mode 100644 index 00000000..aef39cf4 --- /dev/null +++ b/java/src/com/android/intentresolver/v2/emptystate/NoCrossProfileEmptyStateProvider.java @@ -0,0 +1,137 @@ +/* + * 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.annotation.NonNull; +import android.annotation.Nullable; +import android.annotation.StringRes; +import android.app.admin.DevicePolicyEventLogger; +import android.app.admin.DevicePolicyManager; +import android.content.Context; +import android.os.UserHandle; + +import com.android.intentresolver.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 NoCrossProfileEmptyStateProvider implements EmptyStateProvider { + + private final UserHandle mPersonalProfileUserHandle; + private final EmptyState mNoWorkToPersonalEmptyState; + private final EmptyState mNoPersonalToWorkEmptyState; + private final CrossProfileIntentsChecker mCrossProfileIntentsChecker; + private final UserHandle mTabOwnerUserHandleForLaunch; + + public NoCrossProfileEmptyStateProvider(UserHandle personalUserHandle, + EmptyState noWorkToPersonalEmptyState, + EmptyState noPersonalToWorkEmptyState, + CrossProfileIntentsChecker crossProfileIntentsChecker, + UserHandle tabOwnerUserHandleForLaunch) { + mPersonalProfileUserHandle = personalUserHandle; + mNoWorkToPersonalEmptyState = noWorkToPersonalEmptyState; + mNoPersonalToWorkEmptyState = noPersonalToWorkEmptyState; + mCrossProfileIntentsChecker = crossProfileIntentsChecker; + mTabOwnerUserHandleForLaunch = tabOwnerUserHandleForLaunch; + } + + @Nullable + @Override + public EmptyState getEmptyState(ResolverListAdapter resolverListAdapter) { + boolean shouldShowBlocker = + !mTabOwnerUserHandleForLaunch.equals(resolverListAdapter.getUserHandle()) + && !mCrossProfileIntentsChecker + .hasCrossProfileIntents(resolverListAdapter.getIntents(), + mTabOwnerUserHandleForLaunch.getIdentifier(), + resolverListAdapter.getUserHandle().getIdentifier()); + + if (!shouldShowBlocker) { + return null; + } + + if (resolverListAdapter.getUserHandle().equals(mPersonalProfileUserHandle)) { + return mNoWorkToPersonalEmptyState; + } else { + return mNoPersonalToWorkEmptyState; + } + } + + + /** + * Empty state that gets strings from the device policy manager and tracks events into + * event logger of the device policy events. + */ + public static class DevicePolicyBlockerEmptyState implements EmptyState { + + @NonNull + private final Context mContext; + private final String mDevicePolicyStringTitleId; + @StringRes + private final int mDefaultTitleResource; + private final String mDevicePolicyStringSubtitleId; + @StringRes + private final int mDefaultSubtitleResource; + private final int mEventId; + @NonNull + private final String mEventCategory; + + public DevicePolicyBlockerEmptyState(Context context, String devicePolicyStringTitleId, + @StringRes int defaultTitleResource, String devicePolicyStringSubtitleId, + @StringRes int defaultSubtitleResource, + int devicePolicyEventId, String devicePolicyEventCategory) { + mContext = context; + mDevicePolicyStringTitleId = devicePolicyStringTitleId; + mDefaultTitleResource = defaultTitleResource; + mDevicePolicyStringSubtitleId = devicePolicyStringSubtitleId; + mDefaultSubtitleResource = defaultSubtitleResource; + mEventId = devicePolicyEventId; + mEventCategory = devicePolicyEventCategory; + } + + @Nullable + @Override + public String getTitle() { + return mContext.getSystemService(DevicePolicyManager.class).getResources().getString( + mDevicePolicyStringTitleId, + () -> mContext.getString(mDefaultTitleResource)); + } + + @Nullable + @Override + public String getSubtitle() { + return mContext.getSystemService(DevicePolicyManager.class).getResources().getString( + mDevicePolicyStringSubtitleId, + () -> mContext.getString(mDefaultSubtitleResource)); + } + + @Override + public void onEmptyStateShown() { + DevicePolicyEventLogger.createEvent(mEventId) + .setStrings(mEventCategory) + .write(); + } + + @Override + public boolean shouldSkipDataRebuild() { + return true; + } + } +} diff --git a/java/src/com/android/intentresolver/v2/emptystate/WorkProfilePausedEmptyStateProvider.java b/java/src/com/android/intentresolver/v2/emptystate/WorkProfilePausedEmptyStateProvider.java new file mode 100644 index 00000000..bc28fc30 --- /dev/null +++ b/java/src/com/android/intentresolver/v2/emptystate/WorkProfilePausedEmptyStateProvider.java @@ -0,0 +1,115 @@ +/* + * Copyright (C) 2022 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.intentresolver.v2.emptystate; + +import static android.app.admin.DevicePolicyResources.Strings.Core.RESOLVER_WORK_PAUSED_TITLE; + +import android.annotation.NonNull; +import android.annotation.Nullable; +import android.app.admin.DevicePolicyEventLogger; +import android.app.admin.DevicePolicyManager; +import android.content.Context; +import android.os.UserHandle; +import android.stats.devicepolicy.nano.DevicePolicyEnums; + +import com.android.intentresolver.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; + +/** + * Chooser/ResolverActivity empty state provider that returns empty state which is shown when + * work profile is paused and we need to show a button to enable it. + */ +public class WorkProfilePausedEmptyStateProvider implements EmptyStateProvider { + + private final UserHandle mWorkProfileUserHandle; + private final WorkProfileAvailabilityManager mWorkProfileAvailability; + private final String mMetricsCategory; + private final OnSwitchOnWorkSelectedListener mOnSwitchOnWorkSelectedListener; + private final Context mContext; + + public WorkProfilePausedEmptyStateProvider(@NonNull Context context, + @Nullable UserHandle workProfileUserHandle, + @NonNull WorkProfileAvailabilityManager workProfileAvailability, + @Nullable OnSwitchOnWorkSelectedListener onSwitchOnWorkSelectedListener, + @NonNull String metricsCategory) { + mContext = context; + mWorkProfileUserHandle = workProfileUserHandle; + mWorkProfileAvailability = workProfileAvailability; + mMetricsCategory = metricsCategory; + mOnSwitchOnWorkSelectedListener = onSwitchOnWorkSelectedListener; + } + + @Nullable + @Override + public EmptyState getEmptyState(ResolverListAdapter resolverListAdapter) { + if (!resolverListAdapter.getUserHandle().equals(mWorkProfileUserHandle) + || !mWorkProfileAvailability.isQuietModeEnabled() + || resolverListAdapter.getCount() == 0) { + return null; + } + + final String title = mContext.getSystemService(DevicePolicyManager.class) + .getResources().getString(RESOLVER_WORK_PAUSED_TITLE, + () -> mContext.getString(R.string.resolver_turn_on_work_apps)); + + return new WorkProfileOffEmptyState(title, (tab) -> { + tab.showSpinner(); + if (mOnSwitchOnWorkSelectedListener != null) { + mOnSwitchOnWorkSelectedListener.onSwitchOnWorkSelected(); + } + mWorkProfileAvailability.requestQuietModeEnabled(false); + }, mMetricsCategory); + } + + public static class WorkProfileOffEmptyState implements EmptyState { + + private final String mTitle; + private final ClickListener mOnClick; + private final String mMetricsCategory; + + public WorkProfileOffEmptyState(String title, @NonNull ClickListener onClick, + @NonNull String metricsCategory) { + mTitle = title; + mOnClick = onClick; + mMetricsCategory = metricsCategory; + } + + @Nullable + @Override + public String getTitle() { + return mTitle; + } + + @Nullable + @Override + public ClickListener getButtonClickListener() { + return mOnClick; + } + + @Override + public void onEmptyStateShown() { + DevicePolicyEventLogger + .createEvent(DevicePolicyEnums.RESOLVER_EMPTY_STATE_WORK_APPS_DISABLED) + .setStrings(mMetricsCategory) + .write(); + } + } +} -- cgit v1.2.3-59-g8ed1b From 5fc200698096eb4666247c8ab1bda9573cb075a6 Mon Sep 17 00:00:00 2001 From: Andrey Epin Date: Fri, 6 Oct 2023 16:23:56 -0700 Subject: Fix shortcuts loading state Caused by ag/24642135: legacy code, effectively, did not set background for the shortcut placeholder labels. Specifically, for a placeholder TargetInfo with isPlaceholderTargetInfo() returning true, neither isMultiDisplayResolveInfo() nor isPinned() returned true as well thus the background drawable set for the label got immediately reset by the following logic (and ag/24642135 removed this overriding logic). This change removes the placeholder label background drawable and related view size limitation. Fix: 302391707 Test: manual testing -- simulate long shortcut loading (by a code injection), observe no visual artifacts in the shortcuts row. Change-Id: I86975407b729b488a0a5c924859bc218abc4444e --- .../chooser_direct_share_label_placeholder.xml | 37 ---------------------- java/res/values/dimens.xml | 1 - .../android/intentresolver/ChooserListAdapter.java | 7 +--- .../intentresolver/ResolverListAdapter.java | 5 +-- 4 files changed, 2 insertions(+), 48 deletions(-) delete mode 100644 java/res/drawable/chooser_direct_share_label_placeholder.xml (limited to 'java/src') diff --git a/java/res/drawable/chooser_direct_share_label_placeholder.xml b/java/res/drawable/chooser_direct_share_label_placeholder.xml deleted file mode 100644 index b21444bf..00000000 --- a/java/res/drawable/chooser_direct_share_label_placeholder.xml +++ /dev/null @@ -1,37 +0,0 @@ - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/java/res/values/dimens.xml b/java/res/values/dimens.xml index ae80815b..8843c81a 100644 --- a/java/res/values/dimens.xml +++ b/java/res/values/dimens.xml @@ -33,7 +33,6 @@ 200dp 4dp 288dp - 72dp 56dp 22dp 32dp diff --git a/java/src/com/android/intentresolver/ChooserListAdapter.java b/java/src/com/android/intentresolver/ChooserListAdapter.java index 35258317..9a15c919 100644 --- a/java/src/com/android/intentresolver/ChooserListAdapter.java +++ b/java/src/com/android/intentresolver/ChooserListAdapter.java @@ -375,13 +375,8 @@ public class ChooserListAdapter extends ResolverListAdapter { } } - // If target is loading, show a special placeholder shape in the label, make unclickable if (info.isPlaceHolderTargetInfo()) { - int maxTextWidth = mContext.getResources().getDimensionPixelSize( - R.dimen.chooser_direct_share_label_placeholder_max_width); - Drawable placeholderDrawable = mContext.getResources().getDrawable( - R.drawable.chooser_direct_share_label_placeholder, mContext.getTheme()); - holder.bindPlaceholderDrawable(maxTextWidth, placeholderDrawable); + holder.bindPlaceholder(); } if (info.isMultiDisplayResolveInfo()) { diff --git a/java/src/com/android/intentresolver/ResolverListAdapter.java b/java/src/com/android/intentresolver/ResolverListAdapter.java index d1e8c15b..0dd89d87 100644 --- a/java/src/com/android/intentresolver/ResolverListAdapter.java +++ b/java/src/com/android/intentresolver/ResolverListAdapter.java @@ -993,10 +993,7 @@ public class ResolverListAdapter extends BaseAdapter { } } - public void bindPlaceholderDrawable(int maxTextWidth, Drawable drawable) { - text.setMaxWidth(maxTextWidth); - text.setBackground(drawable); - // Prevent rippling by removing background containing ripple + public void bindPlaceholder() { itemView.setBackground(null); } -- cgit v1.2.3-59-g8ed1b From 475d1d0fd9f042dcdc085e2ba095e8164dfd1c15 Mon Sep 17 00:00:00 2001 From: Andrey Epin Date: Thu, 26 Oct 2023 09:00:40 -0700 Subject: Use CoroutineScope as a depenency instead of Lifecycle In content preview classes, Lifecycele was used as coroutine provider. This change makes CoroutineScope as a depenedency for all those classes. Test: atest IntentResolverUnitTests Change-Id: I2d9f07a54d9bb5b56b20c19a821e4626c86da472 --- .../android/intentresolver/ChooserActivity.java | 2 +- .../contentpreview/ChooserContentPreviewUi.java | 26 +++++++++---------- .../FilesPlusTextContentPreviewUi.java | 11 ++++---- .../intentresolver/contentpreview/ImageLoader.kt | 4 +-- .../contentpreview/ImagePreviewImageLoader.kt | 6 ++--- .../contentpreview/PreviewDataProvider.kt | 6 ++--- .../contentpreview/TextContentPreviewUi.java | 11 ++++---- .../android/intentresolver/v2/ChooserActivity.java | 2 +- .../intentresolver/TestPreviewImageLoader.kt | 4 +-- .../contentpreview/ChooserContentPreviewUiTest.kt | 30 ++++++++-------------- .../FilesPlusTextContentPreviewUiTest.kt | 16 +++++++----- .../contentpreview/TextContentPreviewUiTest.kt | 8 +++--- 12 files changed, 58 insertions(+), 68 deletions(-) (limited to 'java/src') diff --git a/java/src/com/android/intentresolver/ChooserActivity.java b/java/src/com/android/intentresolver/ChooserActivity.java index 3a11bee2..707c64b7 100644 --- a/java/src/com/android/intentresolver/ChooserActivity.java +++ b/java/src/com/android/intentresolver/ChooserActivity.java @@ -303,7 +303,7 @@ public class ChooserActivity extends Hilt_ChooserActivity implements new ViewModelProvider(this, createPreviewViewModelFactory()) .get(BasePreviewViewModel.class); mChooserContentPreviewUi = new ChooserContentPreviewUi( - getLifecycle(), + getCoroutineScope(getLifecycle()), previewViewModel.createOrReuseProvider(mChooserRequest), mChooserRequest.getTargetIntent(), previewViewModel.createOrReuseImageLoader(), diff --git a/java/src/com/android/intentresolver/contentpreview/ChooserContentPreviewUi.java b/java/src/com/android/intentresolver/contentpreview/ChooserContentPreviewUi.java index 7226ae4a..a015147d 100644 --- a/java/src/com/android/intentresolver/contentpreview/ChooserContentPreviewUi.java +++ b/java/src/com/android/intentresolver/contentpreview/ChooserContentPreviewUi.java @@ -16,8 +16,6 @@ package com.android.intentresolver.contentpreview; -import static androidx.lifecycle.LifecycleKt.getCoroutineScope; - import static com.android.intentresolver.contentpreview.ContentPreviewType.CONTENT_PREVIEW_FILE; import static com.android.intentresolver.contentpreview.ContentPreviewType.CONTENT_PREVIEW_IMAGE; import static com.android.intentresolver.contentpreview.ContentPreviewType.CONTENT_PREVIEW_TEXT; @@ -33,7 +31,6 @@ import android.view.ViewGroup; import androidx.annotation.Nullable; import androidx.annotation.VisibleForTesting; -import androidx.lifecycle.Lifecycle; import com.android.intentresolver.widget.ActionRow; import com.android.intentresolver.widget.ImagePreviewView.TransitionElementStatusCallback; @@ -41,6 +38,8 @@ import com.android.intentresolver.widget.ImagePreviewView.TransitionElementStatu import java.util.List; import java.util.function.Consumer; +import kotlinx.coroutines.CoroutineScope; + /** * Collection of helpers for building the content preview UI displayed in * {@link com.android.intentresolver.ChooserActivity}. @@ -48,7 +47,7 @@ import java.util.function.Consumer; */ public final class ChooserContentPreviewUi { - private final Lifecycle mLifecycle; + private final CoroutineScope mScope; /** * Delegate to build the default system action buttons to display in the preview layout, if/when @@ -93,14 +92,14 @@ public final class ChooserContentPreviewUi { final ContentPreviewUi mContentPreviewUi; public ChooserContentPreviewUi( - Lifecycle lifecycle, + CoroutineScope scope, PreviewDataProvider previewData, Intent targetIntent, ImageLoader imageLoader, ActionFactory actionFactory, TransitionElementStatusCallback transitionElementStatusCallback, HeadlineGenerator headlineGenerator) { - mLifecycle = lifecycle; + mScope = scope; mContentPreviewUi = createContentPreview( previewData, targetIntent, @@ -126,7 +125,7 @@ public final class ChooserContentPreviewUi { int previewType = previewData.getPreviewType(); if (previewType == CONTENT_PREVIEW_TEXT) { return createTextPreview( - mLifecycle, + mScope, targetIntent, actionFactory, imageLoader, @@ -138,8 +137,7 @@ public final class ChooserContentPreviewUi { actionFactory, headlineGenerator); if (previewData.getUriCount() > 0) { - previewData.getFirstFileName( - mLifecycle, fileContentPreviewUi::setFirstFileName); + previewData.getFirstFileName(mScope, fileContentPreviewUi::setFirstFileName); } return fileContentPreviewUi; } @@ -149,7 +147,7 @@ public final class ChooserContentPreviewUi { if (!TextUtils.isEmpty(text)) { FilesPlusTextContentPreviewUi previewUi = new FilesPlusTextContentPreviewUi( - mLifecycle, + mScope, isSingleImageShare, previewData.getUriCount(), targetIntent.getCharSequenceExtra(Intent.EXTRA_TEXT), @@ -160,7 +158,7 @@ public final class ChooserContentPreviewUi { headlineGenerator); if (previewData.getUriCount() > 0) { JavaFlowHelper.collectToList( - getCoroutineScope(mLifecycle), + mScope, previewData.getImagePreviewFileInfoFlow(), previewUi::updatePreviewMetadata); } @@ -168,7 +166,7 @@ public final class ChooserContentPreviewUi { } return new UnifiedContentPreviewUi( - getCoroutineScope(mLifecycle), + mScope, isSingleImageShare, targetIntent.getType(), actionFactory, @@ -198,7 +196,7 @@ public final class ChooserContentPreviewUi { } private static TextContentPreviewUi createTextPreview( - Lifecycle lifecycle, + CoroutineScope scope, Intent targetIntent, ChooserContentPreviewUi.ActionFactory actionFactory, ImageLoader imageLoader, @@ -214,7 +212,7 @@ public final class ChooserContentPreviewUi { } } return new TextContentPreviewUi( - lifecycle, + scope, sharingText, previewTitle, previewThumbnail, diff --git a/java/src/com/android/intentresolver/contentpreview/FilesPlusTextContentPreviewUi.java b/java/src/com/android/intentresolver/contentpreview/FilesPlusTextContentPreviewUi.java index 1f84b348..78fc6586 100644 --- a/java/src/com/android/intentresolver/contentpreview/FilesPlusTextContentPreviewUi.java +++ b/java/src/com/android/intentresolver/contentpreview/FilesPlusTextContentPreviewUi.java @@ -31,7 +31,6 @@ import android.widget.ImageView; import android.widget.TextView; import androidx.annotation.Nullable; -import androidx.lifecycle.Lifecycle; import com.android.intentresolver.R; import com.android.intentresolver.widget.ActionRow; @@ -41,6 +40,8 @@ import java.util.HashMap; import java.util.List; import java.util.function.Consumer; +import kotlinx.coroutines.CoroutineScope; + /** * FilesPlusTextContentPreviewUi is shown when the user is sending 1 or more files along with * non-empty EXTRA_TEXT. The text can be toggled with a checkbox. If a single image file is being @@ -48,7 +49,7 @@ import java.util.function.Consumer; * file content). */ class FilesPlusTextContentPreviewUi extends ContentPreviewUi { - private final Lifecycle mLifecycle; + private final CoroutineScope mScope; @Nullable private final String mIntentMimeType; private final CharSequence mText; @@ -69,7 +70,7 @@ class FilesPlusTextContentPreviewUi extends ContentPreviewUi { private static final boolean SHOW_TOGGLE_CHECKMARK = false; FilesPlusTextContentPreviewUi( - Lifecycle lifecycle, + CoroutineScope scope, boolean isSingleImage, int fileCount, CharSequence text, @@ -82,7 +83,7 @@ class FilesPlusTextContentPreviewUi extends ContentPreviewUi { throw new IllegalArgumentException( "fileCount = " + fileCount + " and isSingleImage = true"); } - mLifecycle = lifecycle; + mScope = scope; mIntentMimeType = intentMimeType; mFileCount = fileCount; mIsSingleImage = isSingleImage; @@ -166,7 +167,7 @@ class FilesPlusTextContentPreviewUi extends ContentPreviewUi { ImageView imagePreview = mContentPreviewView.requireViewById(R.id.image_view); if (mIsSingleImage && mFirstFilePreviewUri != null) { mImageLoader.loadImage( - mLifecycle, + mScope, mFirstFilePreviewUri, bitmap -> { if (bitmap == null) { diff --git a/java/src/com/android/intentresolver/contentpreview/ImageLoader.kt b/java/src/com/android/intentresolver/contentpreview/ImageLoader.kt index 8d0fb84b..629651a3 100644 --- a/java/src/com/android/intentresolver/contentpreview/ImageLoader.kt +++ b/java/src/com/android/intentresolver/contentpreview/ImageLoader.kt @@ -18,8 +18,8 @@ package com.android.intentresolver.contentpreview import android.graphics.Bitmap import android.net.Uri -import androidx.lifecycle.Lifecycle import java.util.function.Consumer +import kotlinx.coroutines.CoroutineScope /** A content preview image loader. */ interface ImageLoader : suspend (Uri) -> Bitmap?, suspend (Uri, Boolean) -> Bitmap? { @@ -30,7 +30,7 @@ interface ImageLoader : suspend (Uri) -> Bitmap?, suspend (Uri, Boolean) -> Bitm * @param callback a callback that will be invoked with the loaded image or null if loading has * failed. */ - fun loadImage(callerLifecycle: Lifecycle, uri: Uri, callback: Consumer) + fun loadImage(callerScope: CoroutineScope, uri: Uri, callback: Consumer) /** Prepopulate the image loader cache. */ fun prePopulate(uris: List) diff --git a/java/src/com/android/intentresolver/contentpreview/ImagePreviewImageLoader.kt b/java/src/com/android/intentresolver/contentpreview/ImagePreviewImageLoader.kt index 22dd1125..572ccf0b 100644 --- a/java/src/com/android/intentresolver/contentpreview/ImagePreviewImageLoader.kt +++ b/java/src/com/android/intentresolver/contentpreview/ImagePreviewImageLoader.kt @@ -24,8 +24,6 @@ import android.util.Size import androidx.annotation.GuardedBy import androidx.annotation.VisibleForTesting import androidx.collection.LruCache -import androidx.lifecycle.Lifecycle -import androidx.lifecycle.coroutineScope import java.util.function.Consumer import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CompletableDeferred @@ -70,8 +68,8 @@ constructor( override suspend fun invoke(uri: Uri, caching: Boolean): Bitmap? = loadImageAsync(uri, caching) - override fun loadImage(callerLifecycle: Lifecycle, uri: Uri, callback: Consumer) { - callerLifecycle.coroutineScope.launch { + override fun loadImage(callerScope: CoroutineScope, uri: Uri, callback: Consumer) { + callerScope.launch { val image = loadImageAsync(uri, caching = true) if (isActive) { callback.accept(image) diff --git a/java/src/com/android/intentresolver/contentpreview/PreviewDataProvider.kt b/java/src/com/android/intentresolver/contentpreview/PreviewDataProvider.kt index bb303c7b..38918d79 100644 --- a/java/src/com/android/intentresolver/contentpreview/PreviewDataProvider.kt +++ b/java/src/com/android/intentresolver/contentpreview/PreviewDataProvider.kt @@ -29,8 +29,6 @@ import android.text.TextUtils import android.util.Log import androidx.annotation.OpenForTesting import androidx.annotation.VisibleForTesting -import androidx.lifecycle.Lifecycle -import androidx.lifecycle.coroutineScope import com.android.intentresolver.contentpreview.ContentPreviewType.CONTENT_PREVIEW_FILE import com.android.intentresolver.contentpreview.ContentPreviewType.CONTENT_PREVIEW_IMAGE import com.android.intentresolver.contentpreview.ContentPreviewType.CONTENT_PREVIEW_TEXT @@ -185,11 +183,11 @@ constructor( * is not provided, derived from the URI. */ @Throws(IndexOutOfBoundsException::class) - fun getFirstFileName(callerLifecycle: Lifecycle, callback: Consumer) { + fun getFirstFileName(callerScope: CoroutineScope, callback: Consumer) { if (records.isEmpty()) { throw IndexOutOfBoundsException("There are no shared URIs") } - callerLifecycle.coroutineScope.launch { + callerScope.launch { val result = scope.async { getFirstFileName() }.await() callback.accept(result) } diff --git a/java/src/com/android/intentresolver/contentpreview/TextContentPreviewUi.java b/java/src/com/android/intentresolver/contentpreview/TextContentPreviewUi.java index db7b261e..b0dc3c58 100644 --- a/java/src/com/android/intentresolver/contentpreview/TextContentPreviewUi.java +++ b/java/src/com/android/intentresolver/contentpreview/TextContentPreviewUi.java @@ -29,13 +29,14 @@ import android.widget.ImageView; import android.widget.TextView; import androidx.annotation.Nullable; -import androidx.lifecycle.Lifecycle; import com.android.intentresolver.R; import com.android.intentresolver.widget.ActionRow; +import kotlinx.coroutines.CoroutineScope; + class TextContentPreviewUi extends ContentPreviewUi { - private final Lifecycle mLifecycle; + private final CoroutineScope mScope; @Nullable private final CharSequence mSharingText; @Nullable @@ -47,14 +48,14 @@ class TextContentPreviewUi extends ContentPreviewUi { private final HeadlineGenerator mHeadlineGenerator; TextContentPreviewUi( - Lifecycle lifecycle, + CoroutineScope scope, @Nullable CharSequence sharingText, @Nullable CharSequence previewTitle, @Nullable Uri previewThumbnail, ChooserContentPreviewUi.ActionFactory actionFactory, ImageLoader imageLoader, HeadlineGenerator headlineGenerator) { - mLifecycle = lifecycle; + mScope = scope; mSharingText = sharingText; mPreviewTitle = previewTitle; mPreviewThumbnail = previewThumbnail; @@ -122,7 +123,7 @@ class TextContentPreviewUi extends ContentPreviewUi { previewThumbnailView.setVisibility(View.GONE); } else { mImageLoader.loadImage( - mLifecycle, + mScope, mPreviewThumbnail, (bitmap) -> updateViewWithImage( contentPreviewLayout.findViewById( diff --git a/java/src/com/android/intentresolver/v2/ChooserActivity.java b/java/src/com/android/intentresolver/v2/ChooserActivity.java index a3177028..c1d73e69 100644 --- a/java/src/com/android/intentresolver/v2/ChooserActivity.java +++ b/java/src/com/android/intentresolver/v2/ChooserActivity.java @@ -316,7 +316,7 @@ public class ChooserActivity extends Hilt_ChooserActivity implements new ViewModelProvider(this, createPreviewViewModelFactory()) .get(BasePreviewViewModel.class); mChooserContentPreviewUi = new ChooserContentPreviewUi( - getLifecycle(), + getCoroutineScope(getLifecycle()), previewViewModel.createOrReuseProvider(mChooserRequest), mChooserRequest.getTargetIntent(), previewViewModel.createOrReuseImageLoader(), diff --git a/java/tests/src/com/android/intentresolver/TestPreviewImageLoader.kt b/java/tests/src/com/android/intentresolver/TestPreviewImageLoader.kt index bf87ed8a..9c4d6187 100644 --- a/java/tests/src/com/android/intentresolver/TestPreviewImageLoader.kt +++ b/java/tests/src/com/android/intentresolver/TestPreviewImageLoader.kt @@ -18,12 +18,12 @@ package com.android.intentresolver import android.graphics.Bitmap import android.net.Uri -import androidx.lifecycle.Lifecycle import com.android.intentresolver.contentpreview.ImageLoader import java.util.function.Consumer +import kotlinx.coroutines.CoroutineScope internal class TestPreviewImageLoader(private val bitmaps: Map) : ImageLoader { - override fun loadImage(callerLifecycle: Lifecycle, uri: Uri, callback: Consumer) { + override fun loadImage(callerScope: CoroutineScope, uri: Uri, callback: Consumer) { callback.accept(bitmaps[uri]) } diff --git a/java/tests/src/com/android/intentresolver/contentpreview/ChooserContentPreviewUiTest.kt b/java/tests/src/com/android/intentresolver/contentpreview/ChooserContentPreviewUiTest.kt index dab1a956..55cde497 100644 --- a/java/tests/src/com/android/intentresolver/contentpreview/ChooserContentPreviewUiTest.kt +++ b/java/tests/src/com/android/intentresolver/contentpreview/ChooserContentPreviewUiTest.kt @@ -17,10 +17,8 @@ package com.android.intentresolver.contentpreview import android.content.Intent -import android.graphics.Bitmap import android.net.Uri -import androidx.lifecycle.Lifecycle -import androidx.lifecycle.testing.TestLifecycleOwner +import com.android.intentresolver.TestPreviewImageLoader import com.android.intentresolver.contentpreview.ChooserContentPreviewUi.ActionFactory import com.android.intentresolver.mock import com.android.intentresolver.whenever @@ -28,28 +26,20 @@ import com.android.intentresolver.widget.ActionRow import com.android.intentresolver.widget.ImagePreviewView import com.google.common.truth.Truth.assertThat import java.util.function.Consumer +import kotlin.coroutines.EmptyCoroutineContext import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.UnconfinedTestDispatcher import org.junit.Test import org.mockito.Mockito.never import org.mockito.Mockito.times import org.mockito.Mockito.verify class ChooserContentPreviewUiTest { - private val lifecycleOwner = TestLifecycleOwner() + private val testScope = TestScope(EmptyCoroutineContext + UnconfinedTestDispatcher()) private val previewData = mock() private val headlineGenerator = mock() - private val imageLoader = - object : ImageLoader { - override fun loadImage( - callerLifecycle: Lifecycle, - uri: Uri, - callback: Consumer, - ) { - callback.accept(null) - } - override fun prePopulate(uris: List) = Unit - override suspend fun invoke(uri: Uri, caching: Boolean): Bitmap? = null - } + private val imageLoader = TestPreviewImageLoader(emptyMap()) private val actionFactory = object : ActionFactory { override fun getCopyButtonRunnable(): Runnable? = null @@ -65,7 +55,7 @@ class ChooserContentPreviewUiTest { whenever(previewData.previewType).thenReturn(ContentPreviewType.CONTENT_PREVIEW_TEXT) val testSubject = ChooserContentPreviewUi( - lifecycleOwner.lifecycle, + testScope, previewData, Intent(Intent.ACTION_VIEW), imageLoader, @@ -84,7 +74,7 @@ class ChooserContentPreviewUiTest { whenever(previewData.previewType).thenReturn(ContentPreviewType.CONTENT_PREVIEW_FILE) val testSubject = ChooserContentPreviewUi( - lifecycleOwner.lifecycle, + testScope, previewData, Intent(Intent.ACTION_SEND), imageLoader, @@ -108,7 +98,7 @@ class ChooserContentPreviewUiTest { whenever(previewData.imagePreviewFileInfoFlow).thenReturn(MutableSharedFlow()) val testSubject = ChooserContentPreviewUi( - lifecycleOwner.lifecycle, + testScope, previewData, Intent(Intent.ACTION_SEND).apply { putExtra(Intent.EXTRA_TEXT, "Shared text") }, imageLoader, @@ -132,7 +122,7 @@ class ChooserContentPreviewUiTest { whenever(previewData.imagePreviewFileInfoFlow).thenReturn(MutableSharedFlow()) val testSubject = ChooserContentPreviewUi( - lifecycleOwner.lifecycle, + testScope, previewData, Intent(Intent.ACTION_SEND), imageLoader, diff --git a/java/tests/src/com/android/intentresolver/contentpreview/FilesPlusTextContentPreviewUiTest.kt b/java/tests/src/com/android/intentresolver/contentpreview/FilesPlusTextContentPreviewUiTest.kt index 0976dbf1..7cc0b4b2 100644 --- a/java/tests/src/com/android/intentresolver/contentpreview/FilesPlusTextContentPreviewUiTest.kt +++ b/java/tests/src/com/android/intentresolver/contentpreview/FilesPlusTextContentPreviewUiTest.kt @@ -21,7 +21,6 @@ import android.view.LayoutInflater import android.view.View import android.view.ViewGroup import android.widget.TextView -import androidx.lifecycle.testing.TestLifecycleOwner import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.platform.app.InstrumentationRegistry.getInstrumentation import com.android.intentresolver.R @@ -31,6 +30,9 @@ import com.android.intentresolver.widget.ActionRow import com.google.common.truth.Truth.assertThat import com.google.common.truth.Truth.assertWithMessage import java.util.function.Consumer +import kotlin.coroutines.EmptyCoroutineContext +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.UnconfinedTestDispatcher import org.junit.Test import org.junit.runner.RunWith import org.mockito.Mockito.anyInt @@ -45,7 +47,7 @@ private const val SHARED_TEXT = "Some text to share" @RunWith(AndroidJUnit4::class) class FilesPlusTextContentPreviewUiTest { - private val lifecycleOwner = TestLifecycleOwner() + private val testScope = TestScope(EmptyCoroutineContext + UnconfinedTestDispatcher()) private val actionFactory = object : ChooserContentPreviewUi.ActionFactory { override fun getEditButtonRunnable(): Runnable? = null @@ -63,7 +65,7 @@ class FilesPlusTextContentPreviewUiTest { } private val context - get() = getInstrumentation().getContext() + get() = getInstrumentation().context @Test fun test_displayImagesPlusTextWithoutUriMetadata_showImagesHeadline() { @@ -252,7 +254,7 @@ class FilesPlusTextContentPreviewUiTest { val sharedFileCount = 2 val testSubject = FilesPlusTextContentPreviewUi( - lifecycleOwner.lifecycle, + testScope, /*isSingleImage=*/ false, sharedFileCount, SHARED_TEXT, @@ -284,7 +286,7 @@ class FilesPlusTextContentPreviewUiTest { val sharedFileCount = 2 val testSubject = FilesPlusTextContentPreviewUi( - lifecycleOwner.lifecycle, + testScope, /*isSingleImage=*/ false, sharedFileCount, SHARED_TEXT, @@ -332,7 +334,7 @@ class FilesPlusTextContentPreviewUiTest { ): ViewGroup? { val testSubject = FilesPlusTextContentPreviewUi( - lifecycleOwner.lifecycle, + testScope, /*isSingleImage=*/ false, sharedFileCount, SHARED_TEXT, @@ -361,7 +363,7 @@ class FilesPlusTextContentPreviewUiTest { ): Pair { val testSubject = FilesPlusTextContentPreviewUi( - lifecycleOwner.lifecycle, + testScope, /*isSingleImage=*/ false, sharedFileCount, SHARED_TEXT, diff --git a/java/tests/src/com/android/intentresolver/contentpreview/TextContentPreviewUiTest.kt b/java/tests/src/com/android/intentresolver/contentpreview/TextContentPreviewUiTest.kt index b91ed436..35362401 100644 --- a/java/tests/src/com/android/intentresolver/contentpreview/TextContentPreviewUiTest.kt +++ b/java/tests/src/com/android/intentresolver/contentpreview/TextContentPreviewUiTest.kt @@ -20,7 +20,6 @@ import android.view.LayoutInflater import android.view.View import android.view.ViewGroup import android.widget.TextView -import androidx.lifecycle.testing.TestLifecycleOwner import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.platform.app.InstrumentationRegistry import com.android.intentresolver.R @@ -29,6 +28,9 @@ import com.android.intentresolver.whenever import com.android.intentresolver.widget.ActionRow import com.google.common.truth.Truth.assertThat import java.util.function.Consumer +import kotlin.coroutines.EmptyCoroutineContext +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.UnconfinedTestDispatcher import org.junit.Test import org.junit.runner.RunWith @@ -36,7 +38,7 @@ import org.junit.runner.RunWith class TextContentPreviewUiTest { private val text = "Shared Text" private val title = "Preview Title" - private val lifecycleOwner = TestLifecycleOwner() + private val testScope = TestScope(EmptyCoroutineContext + UnconfinedTestDispatcher()) private val actionFactory = object : ChooserContentPreviewUi.ActionFactory { override fun getEditButtonRunnable(): Runnable? = null @@ -54,7 +56,7 @@ class TextContentPreviewUiTest { private val testSubject = TextContentPreviewUi( - lifecycleOwner.lifecycle, + testScope, text, title, /*previewThumbnail=*/ null, -- cgit v1.2.3-59-g8ed1b From 505d41c41d1520f3e84f2cc13e53343d88e9f692 Mon Sep 17 00:00:00 2001 From: Govinda Wasserman Date: Fri, 27 Oct 2023 13:39:09 -0400 Subject: Starts splitting the Chooser and Resolver logic Creates two new interfaces that house previous activity logic: CommonActivityLogic and ActivityLogic, which extends it. Also creates a concrete instantiation of CommonActivityLogic and two concrete instantiations of ActivityLogic (one for each activity). The concreate ActivityLogic classes delegate to the concrete CommonActivityLogic for that interface surface. The intent is for these classes to act as a scaffolding for separating the Chooser and Resolver activities. Test: atest com.android.intentresolver.v2 BUG:302113519 Change-Id: I82084aad2d7f759763cf6a77c1bebbb3d3f82c5c --- .../com/android/intentresolver/v2/ActivityLogic.kt | 90 +++++++++++ .../android/intentresolver/v2/ChooserActivity.java | 164 +++++++++++---------- .../intentresolver/v2/ChooserActivityLogic.kt | 56 +++++++ .../intentresolver/v2/ResolverActivity.java | 150 +++++-------------- .../intentresolver/v2/ResolverActivityLogic.kt | 61 ++++++++ 5 files changed, 332 insertions(+), 189 deletions(-) create mode 100644 java/src/com/android/intentresolver/v2/ActivityLogic.kt create mode 100644 java/src/com/android/intentresolver/v2/ChooserActivityLogic.kt create mode 100644 java/src/com/android/intentresolver/v2/ResolverActivityLogic.kt (limited to 'java/src') diff --git a/java/src/com/android/intentresolver/v2/ActivityLogic.kt b/java/src/com/android/intentresolver/v2/ActivityLogic.kt new file mode 100644 index 00000000..0613882e --- /dev/null +++ b/java/src/com/android/intentresolver/v2/ActivityLogic.kt @@ -0,0 +1,90 @@ +package com.android.intentresolver.v2 + +import android.app.Activity +import android.content.Context +import android.content.Intent +import android.net.Uri +import androidx.activity.ComponentActivity +import com.android.intentresolver.icons.TargetDataLoader + +/** + * Logic for IntentResolver Activities. Anything that is not the same across activities (including + * test activities) should be in this interface. Expect there to be one implementation for each + * activity, including test activities, but all implementations should delegate to a + * CommonActivityLogic implementation. + */ +interface ActivityLogic : CommonActivityLogic { + /** The intent for the target. This will always come before [additionalTargets], if any. */ + val targetIntent: Intent + /** Whether the intent is for home. */ + val resolvingHome: Boolean + /** Intents for additional targets. These will always come after [targetIntent]. */ + val additionalTargets: List? + /** Custom title to display. */ + val title: CharSequence? + /** Resource ID for the title to display when there is no custom title. */ + val defaultTitleResId: Int + /** Intents received to be processed. */ + val initialIntents: List? + /** Whether or not this activity supports choosing a default handler for the intent. */ + val supportsAlwaysUseOption: Boolean + /** Fetches display info for processed candidates. */ + val targetDataLoader: TargetDataLoader + + /** + * Called after Activity superclass creation, but before any other onCreate logic is performed. + */ + fun preInitialization() +} + +/** + * Logic that is common to all IntentResolver activities. Anything that is the same across + * activities (including test activities), should live here. + */ +interface CommonActivityLogic { + /** A reference to the activity owning, and used by, this logic. */ + val activity: ComponentActivity + /** The name of the referring package. */ + val referrerPackageName: String? + + // TODO: For some reason the IDE complains about getting Activity fields from a + // ComponentActivity. These are a band-aid until the bug is fixed and should be removed when + // possible. + val ComponentActivity.context: Context + val ComponentActivity.intent: Intent + val ComponentActivity.referrer: Uri? +} + +/** + * Concrete implementation of the [CommonActivityLogic] interface meant to be delegated to by + * [ActivityLogic] implementations. Test implementations of [ActivityLogic] may need to create their + * own [CommonActivityLogic] implementation. + */ +class CommonActivityLogicImpl(activityProvider: () -> ComponentActivity) : CommonActivityLogic { + + override val activity: ComponentActivity by lazy { activityProvider() } + + override val referrerPackageName: String? by lazy { + activity.referrer.let { + if (ANDROID_APP_URI_SCHEME == it?.scheme) { + it.host + } else { + null + } + } + } + + companion object { + private const val ANDROID_APP_URI_SCHEME = "android-app" + } + + // TODO: For some reason the IDE complains about getting Activity fields from a + // ComponentActivity. These are a band-aid until the bug is fixed and should be removed when + // possible. + override val ComponentActivity.context: Context + get() = (this as Activity) + override val ComponentActivity.intent: Intent + get() = (this as Activity).intent + override val ComponentActivity.referrer: Uri? + get() = (this as Activity).referrer +} diff --git a/java/src/com/android/intentresolver/v2/ChooserActivity.java b/java/src/com/android/intentresolver/v2/ChooserActivity.java index c1d73e69..d2dabfb3 100644 --- a/java/src/com/android/intentresolver/v2/ChooserActivity.java +++ b/java/src/com/android/intentresolver/v2/ChooserActivity.java @@ -28,6 +28,8 @@ import static androidx.lifecycle.LifecycleKt.getCoroutineScope; import static com.android.internal.util.LatencyTracker.ACTION_LOAD_SHARE_SHEET; +import static java.util.Objects.requireNonNull; + import android.annotation.IntDef; import android.annotation.Nullable; import android.app.Activity; @@ -116,6 +118,8 @@ import com.android.internal.logging.nano.MetricsProto.MetricsEvent; import dagger.hilt.android.AndroidEntryPoint; +import kotlin.Unit; + import java.lang.annotation.Retention; import java.lang.annotation.RetentionPolicy; import java.text.Collator; @@ -129,6 +133,7 @@ import java.util.Objects; import java.util.Optional; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; +import java.util.concurrent.atomic.AtomicLong; import java.util.function.Consumer; import javax.inject.Inject; @@ -195,15 +200,6 @@ public class ChooserActivity extends Hilt_ChooserActivity implements @Inject @NearbyShare public Optional mNearbyShare; @Inject public TargetDataLoader mTargetDataLoader; - /* TODO: this is `nullable` because we have to defer the assignment til onCreate(). We make the - * only assignment there, and expect it to be ready by the time we ever use it -- - * someday if we move all the usage to a component with a narrower lifecycle (something that - * matches our Activity's create/destroy lifecycle, not its Java object lifecycle) then we - * should be able to make this assignment as "final." - */ - @Nullable - private ChooserRequestParameters mChooserRequest; - private ChooserRefinementManager mRefinementManager; private ChooserContentPreviewUi mChooserContentPreviewUi; @@ -251,44 +247,50 @@ public class ChooserActivity extends Hilt_ChooserActivity implements @Override protected void onCreate(Bundle savedInstanceState) { Tracer.INSTANCE.markLaunched(); + AtomicLong intentReceivedTime = new AtomicLong(-1); + mLogic = new ChooserActivityLogic( + TAG, + () -> this, + () -> mTargetDataLoader, + () -> { + intentReceivedTime.set(System.currentTimeMillis()); + mLatencyTracker.onActionStart(ACTION_LOAD_SHARE_SHEET); + + mPinnedSharedPrefs = getPinnedSharedPrefs(this); + mMaxTargetsPerRow = + getResources().getInteger(R.integer.config_chooser_max_targets_per_row); + mShouldDisplayLandscape = + shouldDisplayLandscape(getResources().getConfiguration().orientation); + + + ChooserRequestParameters chooserRequest = + ((ChooserActivityLogic) mLogic).getChooserRequestParameters(); + if (chooserRequest == null) { + return Unit.INSTANCE; + } + setRetainInOnStop(chooserRequest.shouldRetainInOnStop()); + + createProfileRecords( + new AppPredictorFactory( + this, + chooserRequest.getSharedText(), + chooserRequest.getTargetIntentFilter() + ), + chooserRequest.getTargetIntentFilter() + ); + return Unit.INSTANCE; + } + ); super.onCreate(savedInstanceState); - - final long intentReceivedTime = System.currentTimeMillis(); - mLatencyTracker.onActionStart(ACTION_LOAD_SHARE_SHEET); - - try { - mChooserRequest = new ChooserRequestParameters( - getIntent(), - getReferrerPackageName(), - getReferrer()); - } catch (IllegalArgumentException e) { - Log.e(TAG, "Caller provided invalid Chooser request parameters", e); + if (getChooserRequest() == null) { finish(); return; } - mPinnedSharedPrefs = getPinnedSharedPrefs(this); - mMaxTargetsPerRow = getResources().getInteger(R.integer.config_chooser_max_targets_per_row); - mShouldDisplayLandscape = - shouldDisplayLandscape(getResources().getConfiguration().orientation); - setRetainInOnStop(mChooserRequest.shouldRetainInOnStop()); - - createProfileRecords( - new AppPredictorFactory( - this, - mChooserRequest.getSharedText(), - mChooserRequest.getTargetIntentFilter()), - mChooserRequest.getTargetIntentFilter()); - - init( - mChooserRequest.getTargetIntent(), - mChooserRequest.getAdditionalTargets(), - mChooserRequest.getTitle(), - mChooserRequest.getDefaultTitleResource(), - mChooserRequest.getInitialIntents(), - /* resolutionList= */ null, - /* supportsAlwaysUseOption= */ false, - mTargetDataLoader, - /* safeForwardingMode= */ true); + if (isFinishing()) { + // Performing a clean exit: + // Skip initializing any additional resources. + return; + } getEventLog().logSharesheetTriggered(); @@ -315,10 +317,11 @@ public class ChooserActivity extends Hilt_ChooserActivity implements BasePreviewViewModel previewViewModel = new ViewModelProvider(this, createPreviewViewModelFactory()) .get(BasePreviewViewModel.class); + ChooserRequestParameters chooserRequest = requireChooserRequest(); mChooserContentPreviewUi = new ChooserContentPreviewUi( getCoroutineScope(getLifecycle()), - previewViewModel.createOrReuseProvider(mChooserRequest), - mChooserRequest.getTargetIntent(), + previewViewModel.createOrReuseProvider(chooserRequest), + chooserRequest.getTargetIntent(), previewViewModel.createOrReuseImageLoader(), createChooserActionFactory(), mEnterTransitionAnimationDelegate, @@ -333,9 +336,9 @@ public class ChooserActivity extends Hilt_ChooserActivity implements } mChooserShownTime = System.currentTimeMillis(); - final long systemCost = mChooserShownTime - intentReceivedTime; + final long systemCost = mChooserShownTime - intentReceivedTime.get(); getEventLog().logChooserActivityShown( - isWorkProfile(), mChooserRequest.getTargetType(), systemCost); + isWorkProfile(), chooserRequest.getTargetType(), systemCost); if (mResolverDrawerLayout != null) { mResolverDrawerLayout.addOnLayoutChangeListener(this::handleLayoutChange); @@ -352,21 +355,30 @@ public class ChooserActivity extends Hilt_ChooserActivity implements } getEventLog().logShareStarted( - getReferrerPackageName(), - mChooserRequest.getTargetType(), - mChooserRequest.getCallerChooserTargets().size(), - (mChooserRequest.getInitialIntents() == null) - ? 0 : mChooserRequest.getInitialIntents().length, + mLogic.getReferrerPackageName(), + chooserRequest.getTargetType(), + chooserRequest.getCallerChooserTargets().size(), + (chooserRequest.getInitialIntents() == null) + ? 0 : chooserRequest.getInitialIntents().length, isWorkProfile(), mChooserContentPreviewUi.getPreferredContentPreview(), - mChooserRequest.getTargetAction(), - mChooserRequest.getChooserActions().size(), - mChooserRequest.getModifyShareAction() != null + chooserRequest.getTargetAction(), + chooserRequest.getChooserActions().size(), + chooserRequest.getModifyShareAction() != null ); mEnterTransitionAnimationDelegate.postponeTransition(); } + @Nullable + private ChooserRequestParameters getChooserRequest() { + return ((ChooserActivityLogic) mLogic).getChooserRequestParameters(); + } + + private ChooserRequestParameters requireChooserRequest() { + return requireNonNull(getChooserRequest()); + } + @Override protected int appliedThemeResId() { return R.style.Theme_DeviceDefault_Chooser; @@ -445,7 +457,7 @@ public class ChooserActivity extends Hilt_ChooserActivity implements @Override protected EmptyStateProvider createBlockerEmptyStateProvider() { - final boolean isSendAction = mChooserRequest.isSendActionTarget(); + final boolean isSendAction = requireChooserRequest().isSendActionTarget(); final EmptyState noWorkToPersonalEmptyState = new DevicePolicyBlockerEmptyState( @@ -740,14 +752,15 @@ public class ChooserActivity extends Hilt_ChooserActivity implements @Override // ResolverListCommunicator public Intent getReplacementIntent(ActivityInfo aInfo, Intent defIntent) { - if (mChooserRequest == null) { + ChooserRequestParameters chooserRequest = getChooserRequest(); + if (chooserRequest == null) { return defIntent; } Intent result = defIntent; - if (mChooserRequest.getReplacementExtras() != null) { + if (chooserRequest.getReplacementExtras() != null) { final Bundle replExtras = - mChooserRequest.getReplacementExtras().getBundle(aInfo.packageName); + chooserRequest.getReplacementExtras().getBundle(aInfo.packageName); if (replExtras != null) { result = new Intent(defIntent); result.putExtras(replExtras); @@ -768,12 +781,13 @@ public class ChooserActivity extends Hilt_ChooserActivity implements @Override public void onActivityStarted(TargetInfo cti) { - if (mChooserRequest.getChosenComponentSender() != null) { + ChooserRequestParameters chooserRequest = requireChooserRequest(); + if (chooserRequest.getChosenComponentSender() != null) { final ComponentName target = cti.getResolvedComponentName(); if (target != null) { final Intent fillIn = new Intent().putExtra(Intent.EXTRA_CHOSEN_COMPONENT, target); try { - mChooserRequest.getChosenComponentSender().sendIntent( + chooserRequest.getChosenComponentSender().sendIntent( this, Activity.RESULT_OK, fillIn, null, null); } catch (IntentSender.SendIntentException e) { Slog.e(TAG, "Unable to launch supplied IntentSender to report " @@ -784,7 +798,8 @@ public class ChooserActivity extends Hilt_ChooserActivity implements } private void addCallerChooserTargets() { - if (!mChooserRequest.getCallerChooserTargets().isEmpty()) { + ChooserRequestParameters chooserRequest = requireChooserRequest(); + if (!chooserRequest.getCallerChooserTargets().isEmpty()) { // Send the caller's chooser targets only to the default profile. UserHandle defaultUser = (findSelectedProfile() == PROFILE_WORK) ? getAnnotatedUserHandles().workProfileUserHandle @@ -792,7 +807,7 @@ public class ChooserActivity extends Hilt_ChooserActivity implements if (mChooserMultiProfilePagerAdapter.getCurrentUserHandle() == defaultUser) { mChooserMultiProfilePagerAdapter.getActiveListAdapter().addServiceResults( /* origTarget */ null, - new ArrayList<>(mChooserRequest.getCallerChooserTargets()), + new ArrayList<>(chooserRequest.getCallerChooserTargets()), TARGET_TYPE_DEFAULT, /* directShareShortcutInfoCache */ Collections.emptyMap(), /* directShareAppTargetCache */ Collections.emptyMap()); @@ -837,7 +852,7 @@ public class ChooserActivity extends Hilt_ChooserActivity implements // the logic into `ChooserTargetActionsDialogFragment.show()`. boolean isShortcutPinned = targetInfo.isSelectableTargetInfo() && targetInfo.isPinned(); IntentFilter intentFilter = targetInfo.isSelectableTargetInfo() - ? mChooserRequest.getTargetIntentFilter() : null; + ? requireChooserRequest().getTargetIntentFilter() : null; String shortcutTitle = targetInfo.isSelectableTargetInfo() ? targetInfo.getDisplayLabel().toString() : null; String shortcutIdKey = targetInfo.getDirectShareShortcutId(); @@ -858,7 +873,7 @@ public class ChooserActivity extends Hilt_ChooserActivity implements protected boolean onTargetSelected(TargetInfo target, boolean alwaysCheck) { if (mRefinementManager.maybeHandleSelection( target, - mChooserRequest.getRefinementIntentSender(), + requireChooserRequest().getRefinementIntentSender(), getApplication(), getMainThreadHandler())) { return false; @@ -913,7 +928,7 @@ public class ChooserActivity extends Hilt_ChooserActivity implements targetInfo.getResolveInfo().activityInfo.processName, which, /* directTargetAlsoRanked= */ getRankedPosition(targetInfo), - mChooserRequest.getCallerChooserTargets().size(), + requireChooserRequest().getCallerChooserTargets().size(), targetInfo.getHashedTargetIdForMetrics(this), targetInfo.isPinned(), mIsSuccessfullySelected, @@ -1032,7 +1047,7 @@ public class ChooserActivity extends Hilt_ChooserActivity implements if (targetIntent == null) { return; } - Intent originalTargetIntent = new Intent(mChooserRequest.getTargetIntent()); + Intent originalTargetIntent = new Intent(requireChooserRequest().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) { @@ -1152,7 +1167,7 @@ public class ChooserActivity extends Hilt_ChooserActivity implements @Override public boolean isComponentFiltered(ComponentName name) { - return mChooserRequest.getFilteredComponentNames().contains(name); + return requireChooserRequest().getFilteredComponentNames().contains(name); } @Override @@ -1179,7 +1194,7 @@ public class ChooserActivity extends Hilt_ChooserActivity implements createListController(userHandle), userHandle, getTargetIntent(), - mChooserRequest, + requireChooserRequest(), mMaxTargetsPerRow, targetDataLoader); @@ -1279,14 +1294,14 @@ public class ChooserActivity extends Hilt_ChooserActivity implements AbstractResolverComparator resolverComparator; if (appPredictor != null) { resolverComparator = new AppPredictionServiceResolverComparator(this, getTargetIntent(), - getReferrerPackageName(), appPredictor, userHandle, getEventLog(), + mLogic.getReferrerPackageName(), appPredictor, userHandle, getEventLog(), mNearbyShare.orElse(null)); } else { resolverComparator = new ResolverRankerServiceResolverComparator( this, getTargetIntent(), - getReferrerPackageName(), + mLogic.getReferrerPackageName(), null, getEventLog(), getResolverRankerServiceUserHandleList(userHandle), @@ -1297,7 +1312,7 @@ public class ChooserActivity extends Hilt_ChooserActivity implements this, mPm, getTargetIntent(), - getReferrerPackageName(), + mLogic.getReferrerPackageName(), getAnnotatedUserHandles().userIdOfCallingApp, resolverComparator, getQueryIntentsUser(userHandle)); @@ -1311,7 +1326,7 @@ public class ChooserActivity extends Hilt_ChooserActivity implements private ChooserActionFactory createChooserActionFactory() { return new ChooserActionFactory( this, - mChooserRequest, + requireChooserRequest(), mImageEditor, getEventLog(), (isExcluded) -> mExcludeSharedText = isExcluded, @@ -1681,7 +1696,8 @@ public class ChooserActivity extends Hilt_ChooserActivity implements * @return true if we want to show the content preview area */ protected boolean shouldShowContentPreview() { - return (mChooserRequest != null) && mChooserRequest.isSendActionTarget(); + ChooserRequestParameters chooserRequest = getChooserRequest(); + return (chooserRequest != null) && chooserRequest.isSendActionTarget(); } private void updateStickyContentPreview() { diff --git a/java/src/com/android/intentresolver/v2/ChooserActivityLogic.kt b/java/src/com/android/intentresolver/v2/ChooserActivityLogic.kt new file mode 100644 index 00000000..1db3f407 --- /dev/null +++ b/java/src/com/android/intentresolver/v2/ChooserActivityLogic.kt @@ -0,0 +1,56 @@ +package com.android.intentresolver.v2 + +import android.app.Activity +import android.content.Intent +import android.util.Log +import androidx.activity.ComponentActivity +import com.android.intentresolver.ChooserRequestParameters +import com.android.intentresolver.icons.TargetDataLoader + +/** Activity logic for [ChooserActivity]. */ +class ChooserActivityLogic( + private val tag: String, + activityProvider: () -> ComponentActivity, + targetDataLoaderProvider: () -> TargetDataLoader, + private val onPreInitialization: () -> Unit, +) : ActivityLogic, CommonActivityLogic by CommonActivityLogicImpl(activityProvider) { + + override val targetIntent: Intent by lazy { chooserRequestParameters?.targetIntent ?: Intent() } + + override val resolvingHome: Boolean = false + + override val additionalTargets: List? by lazy { + chooserRequestParameters?.additionalTargets?.toList() + } + + override val title: CharSequence? by lazy { chooserRequestParameters?.title } + + override val defaultTitleResId: Int by lazy { + chooserRequestParameters?.defaultTitleResource ?: 0 + } + + override val initialIntents: List? by lazy { + chooserRequestParameters?.initialIntents?.toList() + } + + override val supportsAlwaysUseOption: Boolean = false + + override val targetDataLoader: TargetDataLoader by lazy { targetDataLoaderProvider() } + + val chooserRequestParameters: ChooserRequestParameters? by lazy { + try { + ChooserRequestParameters( + (activity as Activity).intent, + referrerPackageName, + (activity as Activity).referrer, + ) + } catch (e: IllegalArgumentException) { + Log.e(tag, "Caller provided invalid Chooser request parameters", e) + null + } + } + + override fun preInitialization() { + onPreInitialization() + } +} diff --git a/java/src/com/android/intentresolver/v2/ResolverActivity.java b/java/src/com/android/intentresolver/v2/ResolverActivity.java index a0388456..1c9ee99d 100644 --- a/java/src/com/android/intentresolver/v2/ResolverActivity.java +++ b/java/src/com/android/intentresolver/v2/ResolverActivity.java @@ -27,7 +27,6 @@ import static android.app.admin.DevicePolicyResources.Strings.Core.RESOLVER_PERS import static android.app.admin.DevicePolicyResources.Strings.Core.RESOLVER_WORK_PROFILE_NOT_SUPPORTED; import static android.app.admin.DevicePolicyResources.Strings.Core.RESOLVER_WORK_TAB; import static android.app.admin.DevicePolicyResources.Strings.Core.RESOLVER_WORK_TAB_ACCESSIBILITY; -import static android.content.Intent.FLAG_ACTIVITY_LAUNCH_ADJACENT; import static android.content.Intent.FLAG_ACTIVITY_NEW_TASK; import static android.content.PermissionChecker.PID_UNKNOWN; import static android.stats.devicepolicy.nano.DevicePolicyEnums.RESOLVER_EMPTY_STATE_NO_SHARING_TO_PERSONAL; @@ -109,7 +108,6 @@ import com.android.intentresolver.emptystate.CompositeEmptyStateProvider; import com.android.intentresolver.emptystate.CrossProfileIntentsChecker; import com.android.intentresolver.emptystate.EmptyState; import com.android.intentresolver.emptystate.EmptyStateProvider; -import com.android.intentresolver.icons.DefaultTargetDataLoader; import com.android.intentresolver.icons.TargetDataLoader; import com.android.intentresolver.model.ResolverRankerServiceResolverComparator; import com.android.intentresolver.v2.MultiProfilePagerAdapter.MyUserIdProvider; @@ -145,6 +143,8 @@ import java.util.function.Supplier; public class ResolverActivity extends FragmentActivity implements ResolverListAdapter.ResolverListCommunicator { + protected ActivityLogic mLogic = new ResolverActivityLogic(() -> this); + public ResolverActivity() { mIsIntentPicker = getClass().equals(ResolverActivity.class); } @@ -153,33 +153,17 @@ public class ResolverActivity extends FragmentActivity implements mIsIntentPicker = isIntentPicker; } - /** - * Whether to enable a launch mode that is safe to use when forwarding intents received from - * applications and running in system processes. This mode uses Activity.startActivityAsCaller - * instead of the normal Activity.startActivity for launching the activity selected - * by the user. - */ - private boolean mSafeForwardingMode; - private Button mAlwaysButton; private Button mOnceButton; protected View mProfileView; private int mLastSelected = AbsListView.INVALID_POSITION; - private boolean mResolvingHome = false; private String mProfileSwitchMessage; private int mLayoutId; @VisibleForTesting protected final ArrayList mIntents = new ArrayList<>(); private PickTargetOptionRequest mPickOptionRequest; - private String mReferrerPackage; - private CharSequence mTitle; - private int mDefaultTitleResId; // Expected to be true if this object is ResolverActivity or is ResolverWrapperActivity. private final boolean mIsIntentPicker; - - // Whether or not this activity supports choosing a default handler for the intent. - @VisibleForTesting - protected boolean mSupportsAlwaysUseOption; protected ResolverDrawerLayout mResolverDrawerLayout; protected PackageManager mPm; @@ -207,8 +191,6 @@ public class ResolverActivity extends FragmentActivity implements private PackageMonitor mPersonalPackageMonitor; private PackageMonitor mWorkPackageMonitor; - private TargetDataLoader mTargetDataLoader; - @VisibleForTesting protected MultiProfilePagerAdapter mMultiProfilePagerAdapter; @@ -355,41 +337,23 @@ public class ResolverActivity extends FragmentActivity implements // Skip initializing any additional resources. return; } - if (mIsIntentPicker) { - // Use a specialized prompt when we're handling the 'Home' app startActivity() - final Intent intent = makeMyIntent(); - final Set categories = intent.getCategories(); - if (Intent.ACTION_MAIN.equals(intent.getAction()) - && categories != null - && categories.size() == 1 - && categories.contains(Intent.CATEGORY_HOME)) { - // Note: this field is not set to true in the compatibility version. - mResolvingHome = true; - } - - init( - intent, - /* additionalTargets= */ null, - /* title= */ null, - /* defaultTitleRes= */ 0, - /* initialIntents= */ null, - /* resolutionList= */ null, - /* supportsAlwaysUseOption= */ true, - createIconLoader(), - /* safeForwardingMode= */ true); - } + mLogic.preInitialization(); + init( + mLogic.getTargetIntent(), + mLogic.getAdditionalTargets() == null + ? null : mLogic.getAdditionalTargets().toArray(new Intent[0]), + mLogic.getInitialIntents() == null + ? null : mLogic.getInitialIntents().toArray(new Intent[0]), + mLogic.getTargetDataLoader() + ); } protected void init( Intent intent, Intent[] additionalTargets, - CharSequence title, - int defaultTitleRes, Intent[] initialIntents, - List resolutionList, - boolean supportsAlwaysUseOption, - TargetDataLoader targetDataLoader, - boolean safeForwardingMode) { + TargetDataLoader targetDataLoader + ) { setTheme(appliedThemeResId()); // Determine whether we should show that intent is forwarded @@ -404,21 +368,12 @@ public class ResolverActivity extends FragmentActivity implements mPm = getPackageManager(); - mReferrerPackage = getReferrerPackageName(); - // The initial intent must come before any other targets that are to be added. mIntents.add(0, new Intent(intent)); if (additionalTargets != null) { Collections.addAll(mIntents, additionalTargets); } - mTitle = title; - mDefaultTitleResId = defaultTitleRes; - - mSupportsAlwaysUseOption = supportsAlwaysUseOption; - mSafeForwardingMode = safeForwardingMode; - mTargetDataLoader = targetDataLoader; - // The last argument of createResolverListAdapter is whether to do special handling // of the last used choice to highlight it in the list. We need to always // turn this off when running under voice interaction, since it results in @@ -427,10 +382,14 @@ public class ResolverActivity extends FragmentActivity implements // We also turn it off when clonedProfile is present on the device, because we might have // different "last chosen" activities in the different profiles, and PackageManager doesn't // provide any more information to help us select between them. - boolean filterLastUsed = mSupportsAlwaysUseOption && !isVoiceInteraction() + boolean filterLastUsed = mLogic.getSupportsAlwaysUseOption() && !isVoiceInteraction() && !shouldShowTabs() && !hasCloneProfile(); mMultiProfilePagerAdapter = createMultiProfilePagerAdapter( - initialIntents, resolutionList, filterLastUsed, targetDataLoader); + initialIntents, + /* resolutionList = */ null, + filterLastUsed, + targetDataLoader + ); if (configureContentView(targetDataLoader)) { return; } @@ -632,7 +591,7 @@ public class ResolverActivity extends FragmentActivity implements } final Intent intent = getIntent(); if ((intent.getFlags() & FLAG_ACTIVITY_NEW_TASK) != 0 && !isVoiceInteraction() - && !mResolvingHome && !mRetainInOnStop) { + && !mLogic.getResolvingHome() && !mRetainInOnStop) { // This resolver is in the unusual situation where it has been // launched at the top of a new task. We don't let it be added // to the recent tasks shown to the user, and we need to make sure @@ -677,7 +636,7 @@ public class ResolverActivity extends FragmentActivity implements } ResolveInfo ri = mMultiProfilePagerAdapter.getActiveListAdapter() .resolveInfoForPosition(which, hasIndexBeenFiltered); - if (mResolvingHome && hasManagedProfile() && !supportsManagedProfiles(ri)) { + if (mLogic.getResolvingHome() && hasManagedProfile() && !supportsManagedProfiles(ri)) { Toast.makeText(this, getWorkProfileNotSupportedMsg( ri.activityInfo.loadLabel(getPackageManager()).toString()), @@ -691,10 +650,10 @@ public class ResolverActivity extends FragmentActivity implements return; } if (onTargetSelected(target, always)) { - if (always && mSupportsAlwaysUseOption) { + if (always && mLogic.getSupportsAlwaysUseOption()) { MetricsLogger.action( this, MetricsProto.MetricsEvent.ACTION_APP_DISAMBIG_ALWAYS); - } else if (mSupportsAlwaysUseOption) { + } else if (mLogic.getSupportsAlwaysUseOption()) { MetricsLogger.action( this, MetricsProto.MetricsEvent.ACTION_APP_DISAMBIG_JUST_ONCE); } else { @@ -735,7 +694,7 @@ public class ResolverActivity extends FragmentActivity implements final ResolveInfo ri = target.getResolveInfo(); final Intent intent = target != null ? target.getResolvedIntent() : null; - if (intent != null && (mSupportsAlwaysUseOption + if (intent != null && (mLogic.getSupportsAlwaysUseOption() || mMultiProfilePagerAdapter.getActiveListAdapter().hasFilteredItem()) && mMultiProfilePagerAdapter.getActiveListAdapter().getUnfilteredResolveList() != null) { // Build a reasonable intent filter, based on what matched. @@ -921,7 +880,7 @@ public class ResolverActivity extends FragmentActivity implements new ResolverRankerServiceResolverComparator( this, getTargetIntent(), - getReferrerPackageName(), + mLogic.getReferrerPackageName(), null, null, getResolverRankerServiceUserHandleList(userHandle), @@ -930,7 +889,7 @@ public class ResolverActivity extends FragmentActivity implements this, mPm, getTargetIntent(), - getReferrerPackageName(), + mLogic.getReferrerPackageName(), getAnnotatedUserHandles().userIdOfCallingApp, resolverComparator, getQueryIntentsUser(userHandle)); @@ -973,7 +932,7 @@ public class ResolverActivity extends FragmentActivity implements } protected void resetButtonBar() { - if (!mSupportsAlwaysUseOption) { + if (!mLogic.getSupportsAlwaysUseOption()) { return; } final ViewGroup buttonLayout = findViewById(com.android.internal.R.id.button_bar); @@ -1099,13 +1058,6 @@ public class ResolverActivity extends FragmentActivity implements targetDataLoader); } - private TargetDataLoader createIconLoader() { - Intent startIntent = getIntent(); - boolean isAudioCaptureDevice = - startIntent.getBooleanExtra(EXTRA_IS_AUDIO_CAPTURE_DEVICE, false); - return new DefaultTargetDataLoader(this, getLifecycle(), isAudioCaptureDevice); - } - private LatencyTracker getLatencyTracker() { return LatencyTracker.getInstance(this); } @@ -1153,24 +1105,6 @@ public class ResolverActivity extends FragmentActivity implements ); } - private Intent makeMyIntent() { - Intent intent = new Intent(getIntent()); - intent.setComponent(null); - // The resolver activity is set to be hidden from recent tasks. - // we don't want this attribute to be propagated to the next activity - // being launched. Note that if the original Intent also had this - // flag set, we are now losing it. That should be a very rare case - // and we can live with this. - intent.setFlags(intent.getFlags() & ~Intent.FLAG_ACTIVITY_EXCLUDE_FROM_RECENTS); - - // If FLAG_ACTIVITY_LAUNCH_ADJACENT was set, ResolverActivity was opened in the alternate - // side, which means we want to open the target app on the same side as ResolverActivity. - if ((intent.getFlags() & FLAG_ACTIVITY_LAUNCH_ADJACENT) != 0) { - intent.setFlags(intent.getFlags() & ~FLAG_ACTIVITY_LAUNCH_ADJACENT); - } - return intent; - } - private ResolverMultiProfilePagerAdapter createResolverMultiProfilePagerAdapterForOneProfile( Intent[] initialIntents, @@ -1377,14 +1311,6 @@ public class ResolverActivity extends FragmentActivity implements return mIntents.isEmpty() ? null : mIntents.get(0); } - protected final String getReferrerPackageName() { - final Uri referrer = getReferrer(); - if (referrer != null && "android-app".equals(referrer.getScheme())) { - return referrer.getHost(); - } - return null; - } - @Override // ResolverListCommunicator public final void updateProfileViewButton() { if (mProfileView == null) { @@ -1434,7 +1360,7 @@ public class ResolverActivity extends FragmentActivity implements } protected final CharSequence getTitleForAction(Intent intent, int defaultTitleRes) { - final ActionTitle title = mResolvingHome + final ActionTitle title = mLogic.getResolvingHome() ? ActionTitle.HOME : ActionTitle.forAction(intent.getAction()); @@ -1689,13 +1615,6 @@ public class ResolverActivity extends FragmentActivity implements if (mProfileSwitchMessage != null) { Toast.makeText(this, mProfileSwitchMessage, Toast.LENGTH_LONG).show(); } - if (!mSafeForwardingMode) { - if (cti.startAsUser(this, options, user)) { - onActivityStarted(cti); - maybeLogCrossProfileTargetLaunch(cti, user); - } - return; - } try { if (cti.startAsCaller(this, options, user.getIdentifier())) { onActivityStarted(cti); @@ -2167,7 +2086,7 @@ public class ResolverActivity extends FragmentActivity implements listView.setOnItemClickListener(listener); listView.setOnItemLongClickListener(listener); - if (mSupportsAlwaysUseOption) { + if (mLogic.getSupportsAlwaysUseOption()) { listView.setChoiceMode(AbsListView.CHOICE_MODE_SINGLE); } } @@ -2188,9 +2107,10 @@ public class ResolverActivity extends FragmentActivity implements } } - CharSequence title = mTitle != null - ? mTitle - : getTitleForAction(getTargetIntent(), mDefaultTitleResId); + + CharSequence title = mLogic.getTitle() != null + ? mLogic.getTitle() + : getTitleForAction(getTargetIntent(), mLogic.getDefaultTitleResId()); if (!TextUtils.isEmpty(title)) { final TextView titleView = findViewById(com.android.internal.R.id.title); @@ -2238,7 +2158,7 @@ public class ResolverActivity extends FragmentActivity implements boolean adapterForCurrentUserHasFilteredItem = mMultiProfilePagerAdapter.getListAdapterForUserHandle( getAnnotatedUserHandles().tabOwnerUserHandleForLaunch).hasFilteredItem(); - return mSupportsAlwaysUseOption && adapterForCurrentUserHasFilteredItem; + return mLogic.getSupportsAlwaysUseOption() && adapterForCurrentUserHasFilteredItem; } /** @@ -2387,7 +2307,7 @@ public class ResolverActivity extends FragmentActivity implements private CharSequence getOrLoadDisplayLabel(TargetInfo info) { if (info.isDisplayResolveInfo()) { - mTargetDataLoader.getOrLoadLabel((DisplayResolveInfo) info); + mLogic.getTargetDataLoader().getOrLoadLabel((DisplayResolveInfo) info); } CharSequence displayLabel = info.getDisplayLabel(); return displayLabel == null ? "" : displayLabel; diff --git a/java/src/com/android/intentresolver/v2/ResolverActivityLogic.kt b/java/src/com/android/intentresolver/v2/ResolverActivityLogic.kt new file mode 100644 index 00000000..1d02e6c2 --- /dev/null +++ b/java/src/com/android/intentresolver/v2/ResolverActivityLogic.kt @@ -0,0 +1,61 @@ +package com.android.intentresolver.v2 + +import android.content.Intent +import androidx.activity.ComponentActivity +import com.android.intentresolver.icons.DefaultTargetDataLoader +import com.android.intentresolver.icons.TargetDataLoader + +/** Activity logic for [ResolverActivity]. */ +class ResolverActivityLogic( + activityProvider: () -> ComponentActivity, +) : ActivityLogic, CommonActivityLogic by CommonActivityLogicImpl(activityProvider) { + + override val targetIntent: Intent by lazy { + val intent = Intent(activity.intent) + intent.setComponent(null) + // The resolver activity is set to be hidden from recent tasks. + // we don't want this attribute to be propagated to the next activity + // being launched. Note that if the original Intent also had this + // flag set, we are now losing it. That should be a very rare case + // and we can live with this. + intent.setFlags(intent.flags and Intent.FLAG_ACTIVITY_EXCLUDE_FROM_RECENTS.inv()) + + // If FLAG_ACTIVITY_LAUNCH_ADJACENT was set, ResolverActivity was opened in the alternate + // side, which means we want to open the target app on the same side as ResolverActivity. + if (intent.flags and Intent.FLAG_ACTIVITY_LAUNCH_ADJACENT != 0) { + intent.setFlags(intent.flags and Intent.FLAG_ACTIVITY_LAUNCH_ADJACENT.inv()) + } + intent + } + + override val resolvingHome: Boolean by lazy { + Intent.ACTION_MAIN == targetIntent.action && + targetIntent.categories?.size == 1 && + targetIntent.categories.contains(Intent.CATEGORY_HOME) + } + + override val additionalTargets: List? = null + + override val title: CharSequence? = null + + override val defaultTitleResId: Int = 0 + + override val initialIntents: List? = null + + override val supportsAlwaysUseOption: Boolean = true + + override val targetDataLoader: TargetDataLoader by lazy { + DefaultTargetDataLoader( + activity.context, + activity.lifecycle, + activity.intent.getBooleanExtra( + ResolverActivity.EXTRA_IS_AUDIO_CAPTURE_DEVICE, + /* defaultValue = */ false, + ), + ) + } + + override fun preInitialization() { + // Do nothing + } +} -- cgit v1.2.3-59-g8ed1b From 29f2a6eaec0c37ac4d92bdb0b049447d0d00b70d Mon Sep 17 00:00:00 2001 From: Govinda Wasserman Date: Tue, 31 Oct 2023 12:39:10 -0400 Subject: Moves theme and profile switch message to ActivityLogic Test: atest com.android.intentresolver.v2 BUG:302113519 Change-Id: Idb7a269c55b9315646127c5b82e10edc48fbe230 --- .../com/android/intentresolver/v2/ActivityLogic.kt | 59 ++++++++++++++++++++++ .../android/intentresolver/v2/ChooserActivity.java | 6 +-- .../intentresolver/v2/ChooserActivityLogic.kt | 11 ++++ .../intentresolver/v2/ResolverActivity.java | 49 ++---------------- .../intentresolver/v2/ResolverActivityLogic.kt | 11 ++++ .../android/intentresolver/v2/util/MutableLazy.kt | 36 +++++++++++++ 6 files changed, 123 insertions(+), 49 deletions(-) create mode 100644 java/src/com/android/intentresolver/v2/util/MutableLazy.kt (limited to 'java/src') diff --git a/java/src/com/android/intentresolver/v2/ActivityLogic.kt b/java/src/com/android/intentresolver/v2/ActivityLogic.kt index 0613882e..a3e90033 100644 --- a/java/src/com/android/intentresolver/v2/ActivityLogic.kt +++ b/java/src/com/android/intentresolver/v2/ActivityLogic.kt @@ -1,10 +1,16 @@ package com.android.intentresolver.v2 import android.app.Activity +import android.app.admin.DevicePolicyManager +import android.app.admin.DevicePolicyResources.Strings.Core.FORWARD_INTENT_TO_PERSONAL +import android.app.admin.DevicePolicyResources.Strings.Core.FORWARD_INTENT_TO_WORK import android.content.Context import android.content.Intent import android.net.Uri +import android.os.UserHandle +import android.os.UserManager import androidx.activity.ComponentActivity +import com.android.intentresolver.R import com.android.intentresolver.icons.TargetDataLoader /** @@ -30,11 +36,20 @@ interface ActivityLogic : CommonActivityLogic { val supportsAlwaysUseOption: Boolean /** Fetches display info for processed candidates. */ val targetDataLoader: TargetDataLoader + /** The theme to use. */ + val themeResId: Int + /** + * Message showing that intent is forwarded from managed profile to owner or other way around. + */ + val profileSwitchMessage: String? /** * Called after Activity superclass creation, but before any other onCreate logic is performed. */ fun preInitialization() + + /** Sets [profileSwitchMessage] to null */ + fun clearProfileSwitchMessage() } /** @@ -46,6 +61,13 @@ interface CommonActivityLogic { val activity: ComponentActivity /** The name of the referring package. */ val referrerPackageName: String? + /** User manager system service. */ + val userManager: UserManager + /** Device policy manager system service. */ + val devicePolicyManager: DevicePolicyManager + + /** Returns display message indicating intent forwarding or null if not intent forwarding. */ + fun forwardMessageFor(intent: Intent): String? // TODO: For some reason the IDE complains about getting Activity fields from a // ComponentActivity. These are a band-aid until the bug is fixed and should be removed when @@ -74,6 +96,43 @@ class CommonActivityLogicImpl(activityProvider: () -> ComponentActivity) : Commo } } + override val userManager: UserManager by lazy { + activity.context.getSystemService(Context.USER_SERVICE) as UserManager + } + + override val devicePolicyManager: DevicePolicyManager by lazy { + activity.context.getSystemService(DevicePolicyManager::class.java)!! + } + + private val forwardToPersonalMessage: String? by lazy { + devicePolicyManager.resources.getString(FORWARD_INTENT_TO_PERSONAL) { + activity.context.getString(R.string.forward_intent_to_owner) + } + } + + private val forwardToWorkMessage: String? by lazy { + devicePolicyManager.resources.getString(FORWARD_INTENT_TO_WORK) { + activity.context.getString(R.string.forward_intent_to_work) + } + } + + override fun forwardMessageFor(intent: Intent): String? { + val contentUserHint = intent.contentUserHint + if ( + contentUserHint != UserHandle.USER_CURRENT && contentUserHint != UserHandle.myUserId() + ) { + val originUserInfo = userManager.getUserInfo(contentUserHint) + val originIsManaged = originUserInfo?.isManagedProfile ?: false + val targetIsManaged = userManager.isManagedProfile + return when { + originIsManaged && !targetIsManaged -> forwardToPersonalMessage + !originIsManaged && targetIsManaged -> forwardToWorkMessage + else -> null + } + } + return null + } + companion object { private const val ANDROID_APP_URI_SCHEME = "android-app" } diff --git a/java/src/com/android/intentresolver/v2/ChooserActivity.java b/java/src/com/android/intentresolver/v2/ChooserActivity.java index d2dabfb3..ef2d68bc 100644 --- a/java/src/com/android/intentresolver/v2/ChooserActivity.java +++ b/java/src/com/android/intentresolver/v2/ChooserActivity.java @@ -291,6 +291,7 @@ public class ChooserActivity extends Hilt_ChooserActivity implements // Skip initializing any additional resources. return; } + setTheme(mLogic.getThemeResId()); getEventLog().logSharesheetTriggered(); @@ -379,11 +380,6 @@ public class ChooserActivity extends Hilt_ChooserActivity implements return requireNonNull(getChooserRequest()); } - @Override - protected int appliedThemeResId() { - return R.style.Theme_DeviceDefault_Chooser; - } - private void createProfileRecords( AppPredictorFactory factory, IntentFilter targetIntentFilter) { UserHandle mainUserHandle = getAnnotatedUserHandles().personalProfileUserHandle; diff --git a/java/src/com/android/intentresolver/v2/ChooserActivityLogic.kt b/java/src/com/android/intentresolver/v2/ChooserActivityLogic.kt index 1db3f407..da0fa033 100644 --- a/java/src/com/android/intentresolver/v2/ChooserActivityLogic.kt +++ b/java/src/com/android/intentresolver/v2/ChooserActivityLogic.kt @@ -5,7 +5,9 @@ import android.content.Intent import android.util.Log import androidx.activity.ComponentActivity import com.android.intentresolver.ChooserRequestParameters +import com.android.intentresolver.R import com.android.intentresolver.icons.TargetDataLoader +import com.android.intentresolver.v2.util.mutableLazy /** Activity logic for [ChooserActivity]. */ class ChooserActivityLogic( @@ -37,6 +39,11 @@ class ChooserActivityLogic( override val targetDataLoader: TargetDataLoader by lazy { targetDataLoaderProvider() } + override val themeResId: Int = R.style.Theme_DeviceDefault_Chooser + + private val _profileSwitchMessage = mutableLazy { forwardMessageFor(targetIntent) } + override val profileSwitchMessage: String? by _profileSwitchMessage + val chooserRequestParameters: ChooserRequestParameters? by lazy { try { ChooserRequestParameters( @@ -53,4 +60,8 @@ class ChooserActivityLogic( override fun preInitialization() { onPreInitialization() } + + override fun clearProfileSwitchMessage() { + _profileSwitchMessage.setLazy(null) + } } diff --git a/java/src/com/android/intentresolver/v2/ResolverActivity.java b/java/src/com/android/intentresolver/v2/ResolverActivity.java index 1c9ee99d..6fdc2df3 100644 --- a/java/src/com/android/intentresolver/v2/ResolverActivity.java +++ b/java/src/com/android/intentresolver/v2/ResolverActivity.java @@ -17,8 +17,6 @@ package com.android.intentresolver.v2; import static android.Manifest.permission.INTERACT_ACROSS_PROFILES; -import static android.app.admin.DevicePolicyResources.Strings.Core.FORWARD_INTENT_TO_PERSONAL; -import static android.app.admin.DevicePolicyResources.Strings.Core.FORWARD_INTENT_TO_WORK; import static android.app.admin.DevicePolicyResources.Strings.Core.RESOLVER_CANT_ACCESS_PERSONAL; import static android.app.admin.DevicePolicyResources.Strings.Core.RESOLVER_CANT_ACCESS_WORK; import static android.app.admin.DevicePolicyResources.Strings.Core.RESOLVER_CROSS_PROFILE_BLOCKED_TITLE; @@ -157,7 +155,6 @@ public class ResolverActivity extends FragmentActivity implements private Button mOnceButton; protected View mProfileView; private int mLastSelected = AbsListView.INVALID_POSITION; - private String mProfileSwitchMessage; private int mLayoutId; @VisibleForTesting protected final ArrayList mIntents = new ArrayList<>(); @@ -337,6 +334,7 @@ public class ResolverActivity extends FragmentActivity implements // Skip initializing any additional resources. return; } + setTheme(mLogic.getThemeResId()); mLogic.preInitialization(); init( mLogic.getTargetIntent(), @@ -354,12 +352,6 @@ public class ResolverActivity extends FragmentActivity implements Intent[] initialIntents, TargetDataLoader targetDataLoader ) { - setTheme(appliedThemeResId()); - - // Determine whether we should show that intent is forwarded - // from managed profile to owner or other way around. - setProfileSwitchMessage(intent.getContentUserHint()); - // Force computation of user handle annotations in order to validate the caller ID. (See the // associated TODO comment to explain why this is structured as a lazy computation.) AnnotatedUserHandles unusedReferenceToHandles = mLazyAnnotatedUserHandles.get(); @@ -501,10 +493,6 @@ public class ResolverActivity extends FragmentActivity implements getAnnotatedUserHandles().tabOwnerUserHandleForLaunch); } - protected int appliedThemeResId() { - return R.style.Theme_DeviceDefault_Resolver; - } - /** * Numerous layouts are supported, each with optional ViewGroups. * Make sure the inset gets added to the correct View, using @@ -1244,7 +1232,7 @@ public class ResolverActivity extends FragmentActivity implements } // Do not show the profile switch message anymore. - mProfileSwitchMessage = null; + mLogic.clearProfileSwitchMessage(); onTargetSelected(dri, false); finish(); @@ -1331,34 +1319,6 @@ public class ResolverActivity extends FragmentActivity implements } } - private void setProfileSwitchMessage(int contentUserHint) { - if ((contentUserHint != UserHandle.USER_CURRENT) - && (contentUserHint != UserHandle.myUserId())) { - UserManager userManager = (UserManager) getSystemService(Context.USER_SERVICE); - UserInfo originUserInfo = userManager.getUserInfo(contentUserHint); - boolean originIsManaged = originUserInfo != null ? originUserInfo.isManagedProfile() - : false; - boolean targetIsManaged = userManager.isManagedProfile(); - if (originIsManaged && !targetIsManaged) { - mProfileSwitchMessage = getForwardToPersonalMsg(); - } else if (!originIsManaged && targetIsManaged) { - mProfileSwitchMessage = getForwardToWorkMsg(); - } - } - } - - private String getForwardToPersonalMsg() { - return getSystemService(DevicePolicyManager.class).getResources().getString( - FORWARD_INTENT_TO_PERSONAL, - () -> getString(R.string.forward_intent_to_owner)); - } - - private String getForwardToWorkMsg() { - return getSystemService(DevicePolicyManager.class).getResources().getString( - FORWARD_INTENT_TO_WORK, - () -> getString(R.string.forward_intent_to_work)); - } - protected final CharSequence getTitleForAction(Intent intent, int defaultTitleRes) { final ActionTitle title = mLogic.getResolvingHome() ? ActionTitle.HOME @@ -1612,8 +1572,9 @@ public class ResolverActivity extends FragmentActivity implements } // If needed, show that intent is forwarded // from managed profile to owner or other way around. - if (mProfileSwitchMessage != null) { - Toast.makeText(this, mProfileSwitchMessage, Toast.LENGTH_LONG).show(); + String profileSwitchMessage = mLogic.getProfileSwitchMessage(); + if (profileSwitchMessage != null) { + Toast.makeText(this, profileSwitchMessage, Toast.LENGTH_LONG).show(); } try { if (cti.startAsCaller(this, options, user.getIdentifier())) { diff --git a/java/src/com/android/intentresolver/v2/ResolverActivityLogic.kt b/java/src/com/android/intentresolver/v2/ResolverActivityLogic.kt index 1d02e6c2..c8f02885 100644 --- a/java/src/com/android/intentresolver/v2/ResolverActivityLogic.kt +++ b/java/src/com/android/intentresolver/v2/ResolverActivityLogic.kt @@ -2,8 +2,10 @@ package com.android.intentresolver.v2 import android.content.Intent import androidx.activity.ComponentActivity +import com.android.intentresolver.R import com.android.intentresolver.icons.DefaultTargetDataLoader import com.android.intentresolver.icons.TargetDataLoader +import com.android.intentresolver.v2.util.mutableLazy /** Activity logic for [ResolverActivity]. */ class ResolverActivityLogic( @@ -55,7 +57,16 @@ class ResolverActivityLogic( ) } + override val themeResId: Int = R.style.Theme_DeviceDefault_Resolver + + private val _profileSwitchMessage = mutableLazy { forwardMessageFor(targetIntent) } + override val profileSwitchMessage: String? by _profileSwitchMessage + override fun preInitialization() { // Do nothing } + + override fun clearProfileSwitchMessage() { + _profileSwitchMessage.setLazy(null) + } } diff --git a/java/src/com/android/intentresolver/v2/util/MutableLazy.kt b/java/src/com/android/intentresolver/v2/util/MutableLazy.kt new file mode 100644 index 00000000..4ce9b7fd --- /dev/null +++ b/java/src/com/android/intentresolver/v2/util/MutableLazy.kt @@ -0,0 +1,36 @@ +package com.android.intentresolver.v2.util + +import java.util.concurrent.atomic.AtomicReference +import kotlin.reflect.KProperty + +/** A lazy delegate that can be changed to a new lazy or null at any time. */ +class MutableLazy(initializer: () -> T?) : Lazy { + + override val value: T? + get() = lazy.get()?.value + + private var lazy: AtomicReference?> = AtomicReference(lazy(initializer)) + + override fun isInitialized(): Boolean = lazy.get()?.isInitialized() != false + + operator fun getValue(thisRef: Any?, property: KProperty<*>): T? = + lazy.get()?.getValue(thisRef, property) + + /** Replace the existing lazy logic with the [newLazy] */ + fun setLazy(newLazy: Lazy?) { + lazy.set(newLazy) + } + + /** Replace the existing lazy logic with a [Lazy] created from the [newInitializer]. */ + fun setLazy(newInitializer: () -> T?) { + lazy.set(lazy(newInitializer)) + } + + /** Set the lazy logic to null. */ + fun clear() { + lazy.set(null) + } +} + +/** Constructs a [MutableLazy] using the given [initializer] */ +fun mutableLazy(initializer: () -> T?) = MutableLazy(initializer) -- cgit v1.2.3-59-g8ed1b From 0842d845019153cc7138b0208a9eca0b5ac73784 Mon Sep 17 00:00:00 2001 From: Govinda Wasserman Date: Tue, 31 Oct 2023 14:19:26 -0400 Subject: Moves AnnotatedUserHandles to ActivityLogic Also creates test activity logic for wrapper activities to be compatible with the ActivityLogic structure. Test: atest com.android.intentresolver.v2 BUG: 302113519 Change-Id: If064e76015e90fa98d9e41fb3c8f38b93ccae789 --- .../com/android/intentresolver/v2/ActivityLogic.kt | 20 +++- .../android/intentresolver/v2/ChooserActivity.java | 123 ++++++++++++--------- .../intentresolver/v2/ChooserActivityLogic.kt | 16 ++- .../intentresolver/v2/ResolverActivity.java | 122 ++++++++++---------- .../intentresolver/v2/ResolverActivityLogic.kt | 3 +- .../intentresolver/v2/ChooserWrapperActivity.java | 17 ++- .../intentresolver/v2/ResolverWrapperActivity.java | 12 +- .../intentresolver/v2/TestChooserActivityLogic.kt | 25 +++++ .../intentresolver/v2/TestResolverActivityLogic.kt | 16 +++ 9 files changed, 216 insertions(+), 138 deletions(-) create mode 100644 java/tests/src/com/android/intentresolver/v2/TestChooserActivityLogic.kt create mode 100644 java/tests/src/com/android/intentresolver/v2/TestResolverActivityLogic.kt (limited to 'java/src') diff --git a/java/src/com/android/intentresolver/v2/ActivityLogic.kt b/java/src/com/android/intentresolver/v2/ActivityLogic.kt index a3e90033..e5b89dfa 100644 --- a/java/src/com/android/intentresolver/v2/ActivityLogic.kt +++ b/java/src/com/android/intentresolver/v2/ActivityLogic.kt @@ -9,7 +9,9 @@ import android.content.Intent import android.net.Uri import android.os.UserHandle import android.os.UserManager +import android.util.Log import androidx.activity.ComponentActivity +import com.android.intentresolver.AnnotatedUserHandles import com.android.intentresolver.R import com.android.intentresolver.icons.TargetDataLoader @@ -57,6 +59,8 @@ interface ActivityLogic : CommonActivityLogic { * activities (including test activities), should live here. */ interface CommonActivityLogic { + /** The tag to use when logging. */ + val tag: String /** A reference to the activity owning, and used by, this logic. */ val activity: ComponentActivity /** The name of the referring package. */ @@ -65,6 +69,8 @@ interface CommonActivityLogic { val userManager: UserManager /** Device policy manager system service. */ val devicePolicyManager: DevicePolicyManager + /** Current [UserHandle]s retrievable by type. */ + val annotatedUserHandles: AnnotatedUserHandles? /** Returns display message indicating intent forwarding or null if not intent forwarding. */ fun forwardMessageFor(intent: Intent): String? @@ -82,7 +88,10 @@ interface CommonActivityLogic { * [ActivityLogic] implementations. Test implementations of [ActivityLogic] may need to create their * own [CommonActivityLogic] implementation. */ -class CommonActivityLogicImpl(activityProvider: () -> ComponentActivity) : CommonActivityLogic { +class CommonActivityLogicImpl( + override val tag: String, + activityProvider: () -> ComponentActivity, +) : CommonActivityLogic { override val activity: ComponentActivity by lazy { activityProvider() } @@ -104,6 +113,15 @@ class CommonActivityLogicImpl(activityProvider: () -> ComponentActivity) : Commo activity.context.getSystemService(DevicePolicyManager::class.java)!! } + override val annotatedUserHandles: AnnotatedUserHandles? by lazy { + try { + AnnotatedUserHandles.forShareActivity(activity) + } catch (e: SecurityException) { + Log.e(tag, "Request from UID without necessary permissions", e) + null + } + } + private val forwardToPersonalMessage: String? by lazy { devicePolicyManager.resources.getString(FORWARD_INTENT_TO_PERSONAL) { activity.context.getString(R.string.forward_intent_to_owner) diff --git a/java/src/com/android/intentresolver/v2/ChooserActivity.java b/java/src/com/android/intentresolver/v2/ChooserActivity.java index ef2d68bc..36e0cad1 100644 --- a/java/src/com/android/intentresolver/v2/ChooserActivity.java +++ b/java/src/com/android/intentresolver/v2/ChooserActivity.java @@ -76,6 +76,7 @@ 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; @@ -244,43 +245,21 @@ public class ChooserActivity extends Hilt_ChooserActivity implements */ private boolean mFinishWhenStopped = false; - @Override - protected void onCreate(Bundle savedInstanceState) { - Tracer.INSTANCE.markLaunched(); - AtomicLong intentReceivedTime = new AtomicLong(-1); + private final AtomicLong mIntentReceivedTime = new AtomicLong(-1); + + ChooserActivity() { + super(); mLogic = new ChooserActivityLogic( TAG, () -> this, () -> mTargetDataLoader, - () -> { - intentReceivedTime.set(System.currentTimeMillis()); - mLatencyTracker.onActionStart(ACTION_LOAD_SHARE_SHEET); - - mPinnedSharedPrefs = getPinnedSharedPrefs(this); - mMaxTargetsPerRow = - getResources().getInteger(R.integer.config_chooser_max_targets_per_row); - mShouldDisplayLandscape = - shouldDisplayLandscape(getResources().getConfiguration().orientation); - - - ChooserRequestParameters chooserRequest = - ((ChooserActivityLogic) mLogic).getChooserRequestParameters(); - if (chooserRequest == null) { - return Unit.INSTANCE; - } - setRetainInOnStop(chooserRequest.shouldRetainInOnStop()); - - createProfileRecords( - new AppPredictorFactory( - this, - chooserRequest.getSharedText(), - chooserRequest.getTargetIntentFilter() - ), - chooserRequest.getTargetIntentFilter() - ); - return Unit.INSTANCE; - } + this::onPreinitialization ); + } + + @Override + protected void onCreate(Bundle savedInstanceState) { + Tracer.INSTANCE.markLaunched(); super.onCreate(savedInstanceState); if (getChooserRequest() == null) { finish(); @@ -337,7 +316,7 @@ public class ChooserActivity extends Hilt_ChooserActivity implements } mChooserShownTime = System.currentTimeMillis(); - final long systemCost = mChooserShownTime - intentReceivedTime.get(); + final long systemCost = mChooserShownTime - mIntentReceivedTime.get(); getEventLog().logChooserActivityShown( isWorkProfile(), chooserRequest.getTargetType(), systemCost); @@ -371,6 +350,34 @@ public class ChooserActivity extends Hilt_ChooserActivity implements mEnterTransitionAnimationDelegate.postponeTransition(); } + protected final Unit onPreinitialization() { + mIntentReceivedTime.set(System.currentTimeMillis()); + mLatencyTracker.onActionStart(ACTION_LOAD_SHARE_SHEET); + + mPinnedSharedPrefs = getPinnedSharedPrefs(this); + mMaxTargetsPerRow = + getResources().getInteger(R.integer.config_chooser_max_targets_per_row); + mShouldDisplayLandscape = + shouldDisplayLandscape(getResources().getConfiguration().orientation); + + + ChooserRequestParameters chooserRequest = getChooserRequest(); + if (chooserRequest == null) { + return Unit.INSTANCE; + } + setRetainInOnStop(chooserRequest.shouldRetainInOnStop()); + + createProfileRecords( + new AppPredictorFactory( + this, + chooserRequest.getSharedText(), + chooserRequest.getTargetIntentFilter() + ), + chooserRequest.getTargetIntentFilter() + ); + return Unit.INSTANCE; + } + @Nullable private ChooserRequestParameters getChooserRequest() { return ((ChooserActivityLogic) mLogic).getChooserRequestParameters(); @@ -380,15 +387,19 @@ public class ChooserActivity extends Hilt_ChooserActivity implements return requireNonNull(getChooserRequest()); } + private AnnotatedUserHandles requireAnnotatedUserHandles() { + return requireNonNull(mLogic.getAnnotatedUserHandles()); + } + private void createProfileRecords( AppPredictorFactory factory, IntentFilter targetIntentFilter) { - UserHandle mainUserHandle = getAnnotatedUserHandles().personalProfileUserHandle; + UserHandle mainUserHandle = requireAnnotatedUserHandles().personalProfileUserHandle; ProfileRecord record = createProfileRecord(mainUserHandle, targetIntentFilter, factory); if (record.shortcutLoader == null) { Tracer.INSTANCE.endLaunchToShortcutTrace(); } - UserHandle workUserHandle = getAnnotatedUserHandles().workProfileUserHandle; + UserHandle workUserHandle = requireAnnotatedUserHandles().workProfileUserHandle; if (workUserHandle != null) { createProfileRecord(workUserHandle, targetIntentFilter, factory); } @@ -482,11 +493,11 @@ public class ChooserActivity extends Hilt_ChooserActivity implements /* devicePolicyEventCategory= */ ResolverActivity.METRICS_CATEGORY_CHOOSER); return new NoCrossProfileEmptyStateProvider( - getAnnotatedUserHandles().personalProfileUserHandle, + requireAnnotatedUserHandles().personalProfileUserHandle, noWorkToPersonalEmptyState, noPersonalToWorkEmptyState, createCrossProfileIntentsChecker(), - getAnnotatedUserHandles().tabOwnerUserHandleForLaunch); + requireAnnotatedUserHandles().tabOwnerUserHandleForLaunch); } private ChooserMultiProfilePagerAdapter createChooserMultiProfilePagerAdapterForOneProfile( @@ -500,7 +511,7 @@ public class ChooserActivity extends Hilt_ChooserActivity implements initialIntents, rList, filterLastUsed, - /* userHandle */ getAnnotatedUserHandles().personalProfileUserHandle, + /* userHandle */ requireAnnotatedUserHandles().personalProfileUserHandle, targetDataLoader); return new ChooserMultiProfilePagerAdapter( /* context */ this, @@ -508,7 +519,7 @@ public class ChooserActivity extends Hilt_ChooserActivity implements createEmptyStateProvider(/* workProfileUserHandle= */ null), /* workProfileQuietModeChecker= */ () -> false, /* workProfileUserHandle= */ null, - getAnnotatedUserHandles().cloneProfileUserHandle, + requireAnnotatedUserHandles().cloneProfileUserHandle, mMaxTargetsPerRow, mFeatureFlags); } @@ -525,7 +536,7 @@ public class ChooserActivity extends Hilt_ChooserActivity implements selectedProfile == PROFILE_PERSONAL ? initialIntents : null, rList, filterLastUsed, - /* userHandle */ getAnnotatedUserHandles().personalProfileUserHandle, + /* userHandle */ requireAnnotatedUserHandles().personalProfileUserHandle, targetDataLoader); ChooserGridAdapter workAdapter = createChooserGridAdapter( /* context */ this, @@ -533,17 +544,17 @@ public class ChooserActivity extends Hilt_ChooserActivity implements selectedProfile == PROFILE_WORK ? initialIntents : null, rList, filterLastUsed, - /* userHandle */ getAnnotatedUserHandles().workProfileUserHandle, + /* userHandle */ requireAnnotatedUserHandles().workProfileUserHandle, targetDataLoader); return new ChooserMultiProfilePagerAdapter( /* context */ this, personalAdapter, workAdapter, - createEmptyStateProvider(getAnnotatedUserHandles().workProfileUserHandle), + createEmptyStateProvider(requireAnnotatedUserHandles().workProfileUserHandle), () -> mWorkProfileAvailability.isQuietModeEnabled(), selectedProfile, - getAnnotatedUserHandles().workProfileUserHandle, - getAnnotatedUserHandles().cloneProfileUserHandle, + requireAnnotatedUserHandles().workProfileUserHandle, + requireAnnotatedUserHandles().cloneProfileUserHandle, mMaxTargetsPerRow, mFeatureFlags); } @@ -552,7 +563,7 @@ public class ChooserActivity extends Hilt_ChooserActivity implements int selectedProfile = getSelectedProfileExtra(); if (selectedProfile == -1) { selectedProfile = getProfileForUser( - getAnnotatedUserHandles().tabOwnerUserHandleForLaunch); + requireAnnotatedUserHandles().tabOwnerUserHandleForLaunch); } return selectedProfile; } @@ -798,8 +809,8 @@ public class ChooserActivity extends Hilt_ChooserActivity implements if (!chooserRequest.getCallerChooserTargets().isEmpty()) { // Send the caller's chooser targets only to the default profile. UserHandle defaultUser = (findSelectedProfile() == PROFILE_WORK) - ? getAnnotatedUserHandles().workProfileUserHandle - : getAnnotatedUserHandles().personalProfileUserHandle; + ? requireAnnotatedUserHandles().workProfileUserHandle + : requireAnnotatedUserHandles().personalProfileUserHandle; if (mChooserMultiProfilePagerAdapter.getCurrentUserHandle() == defaultUser) { mChooserMultiProfilePagerAdapter.getActiveListAdapter().addServiceResults( /* origTarget */ null, @@ -1113,7 +1124,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) || (getAnnotatedUserHandles().cloneProfileUserHandle != null)) + return ((record == null) || (requireAnnotatedUserHandles().cloneProfileUserHandle != null)) ? null : record.appPredictor; } @@ -1253,8 +1264,8 @@ public class ChooserActivity extends Hilt_ChooserActivity implements int maxTargetsPerRow, TargetDataLoader targetDataLoader) { UserHandle initialIntentsUserSpace = isLaunchedAsCloneProfile() - && userHandle.equals(getAnnotatedUserHandles().personalProfileUserHandle) - ? getAnnotatedUserHandles().cloneProfileUserHandle : userHandle; + && userHandle.equals(requireAnnotatedUserHandles().personalProfileUserHandle) + ? requireAnnotatedUserHandles().cloneProfileUserHandle : userHandle; return new ChooserListAdapter( context, payloadIntents, @@ -1275,7 +1286,7 @@ public class ChooserActivity extends Hilt_ChooserActivity implements @Override protected void onWorkProfileStatusUpdated() { - UserHandle workUser = getAnnotatedUserHandles().workProfileUserHandle; + UserHandle workUser = requireAnnotatedUserHandles().workProfileUserHandle; ProfileRecord record = workUser == null ? null : getProfileRecord(workUser); if (record != null && record.shortcutLoader != null) { record.shortcutLoader.reset(); @@ -1309,7 +1320,7 @@ public class ChooserActivity extends Hilt_ChooserActivity implements mPm, getTargetIntent(), mLogic.getReferrerPackageName(), - getAnnotatedUserHandles().userIdOfCallingApp, + requireAnnotatedUserHandles().userIdOfCallingApp, resolverComparator, getQueryIntentsUser(userHandle)); } @@ -1331,7 +1342,9 @@ public class ChooserActivity extends Hilt_ChooserActivity implements @Override public void safelyStartActivityAsPersonalProfileUser(TargetInfo targetInfo) { safelyStartActivityAsUser( - targetInfo, getAnnotatedUserHandles().personalProfileUserHandle); + targetInfo, + requireAnnotatedUserHandles().personalProfileUserHandle + ); finish(); } @@ -1342,7 +1355,7 @@ public class ChooserActivity extends Hilt_ChooserActivity implements ChooserActivity.this, sharedElement, sharedElementName); safelyStartActivityAsUser( targetInfo, - getAnnotatedUserHandles().personalProfileUserHandle, + requireAnnotatedUserHandles().personalProfileUserHandle, options.toBundle()); // Can't finish right away because the shared element transition may not // be ready to start. @@ -1500,7 +1513,7 @@ public class ChooserActivity extends Hilt_ChooserActivity implements * Returns {@link #PROFILE_PERSONAL}, otherwise. **/ private int getProfileForUser(UserHandle currentUserHandle) { - if (currentUserHandle.equals(getAnnotatedUserHandles().workProfileUserHandle)) { + if (currentUserHandle.equals(requireAnnotatedUserHandles().workProfileUserHandle)) { return PROFILE_WORK; } // We return personal profile, as it is the default when there is no work profile, personal diff --git a/java/src/com/android/intentresolver/v2/ChooserActivityLogic.kt b/java/src/com/android/intentresolver/v2/ChooserActivityLogic.kt index da0fa033..838c39e2 100644 --- a/java/src/com/android/intentresolver/v2/ChooserActivityLogic.kt +++ b/java/src/com/android/intentresolver/v2/ChooserActivityLogic.kt @@ -4,18 +4,26 @@ import android.app.Activity import android.content.Intent import android.util.Log import androidx.activity.ComponentActivity +import androidx.annotation.OpenForTesting import com.android.intentresolver.ChooserRequestParameters import com.android.intentresolver.R import com.android.intentresolver.icons.TargetDataLoader import com.android.intentresolver.v2.util.mutableLazy -/** Activity logic for [ChooserActivity]. */ -class ChooserActivityLogic( - private val tag: String, +/** + * Activity logic for [ChooserActivity]. + * + * TODO: Make this class no longer open once [ChooserActivity] no longer needs to cast to access + * [chooserRequestParameters]. For now, this class being open is better than using reflection + * there. + */ +@OpenForTesting +open class ChooserActivityLogic( + tag: String, activityProvider: () -> ComponentActivity, targetDataLoaderProvider: () -> TargetDataLoader, private val onPreInitialization: () -> Unit, -) : ActivityLogic, CommonActivityLogic by CommonActivityLogicImpl(activityProvider) { +) : ActivityLogic, CommonActivityLogic by CommonActivityLogicImpl(tag, activityProvider) { override val targetIntent: Intent by lazy { chooserRequestParameters?.targetIntent ?: Intent() } diff --git a/java/src/com/android/intentresolver/v2/ResolverActivity.java b/java/src/com/android/intentresolver/v2/ResolverActivity.java index 6fdc2df3..b34ce16d 100644 --- a/java/src/com/android/intentresolver/v2/ResolverActivity.java +++ b/java/src/com/android/intentresolver/v2/ResolverActivity.java @@ -33,6 +33,8 @@ import static android.view.WindowManager.LayoutParams.SYSTEM_FLAG_HIDE_NON_SYSTE import static com.android.internal.annotations.VisibleForTesting.Visibility.PROTECTED; +import static java.util.Objects.requireNonNull; + import android.annotation.Nullable; import android.annotation.StringRes; import android.annotation.UiThread; @@ -129,7 +131,6 @@ import java.util.Iterator; import java.util.List; import java.util.Objects; import java.util.Set; -import java.util.function.Supplier; /** * This is a copy of ResolverActivity to support IntentResolver's ChooserActivity. This code is @@ -141,7 +142,7 @@ import java.util.function.Supplier; public class ResolverActivity extends FragmentActivity implements ResolverListAdapter.ResolverListCommunicator { - protected ActivityLogic mLogic = new ResolverActivityLogic(() -> this); + protected ActivityLogic mLogic = new ResolverActivityLogic(TAG, () -> this); public ResolverActivity() { mIsIntentPicker = getClass().equals(ResolverActivity.class); @@ -221,27 +222,6 @@ public class ResolverActivity extends FragmentActivity implements private UserHandle mHeaderCreatorUser; - // User handle annotations are lazy-initialized to ensure that they're computed exactly once - // (even though they can't be computed prior to activity creation). - // TODO: use a less ad-hoc pattern for lazy initialization (by switching to Dagger or - // introducing a common `LazySingletonSupplier` API, etc), and/or migrate all dependents to a - // new component whose lifecycle is limited to the "created" Activity (so that we can just hold - // the annotations as a `final` ivar, which is a better way to show immutability). - private Supplier mLazyAnnotatedUserHandles = () -> { - final AnnotatedUserHandles result = computeAnnotatedUserHandles(); - mLazyAnnotatedUserHandles = () -> result; - return result; - }; - - // This method is called exactly once during creation to compute the immutable annotations - // accessible through the lazy supplier {@link mLazyAnnotatedUserHandles}. - // TODO: this is only defined so that tests can provide an override that injects fake - // annotations. Dagger could provide a cleaner model for our testing/injection requirements. - @VisibleForTesting(visibility = VisibleForTesting.Visibility.PRIVATE) - protected AnnotatedUserHandles computeAnnotatedUserHandles() { - return AnnotatedUserHandles.forShareActivity(this); - } - @Nullable private OnSwitchOnWorkSelectedListener mOnSwitchOnWorkSelectedListener; @@ -352,9 +332,11 @@ public class ResolverActivity extends FragmentActivity implements Intent[] initialIntents, TargetDataLoader targetDataLoader ) { - // Force computation of user handle annotations in order to validate the caller ID. (See the - // associated TODO comment to explain why this is structured as a lazy computation.) - AnnotatedUserHandles unusedReferenceToHandles = mLazyAnnotatedUserHandles.get(); + // Calling UID did not have valid permissions + if (mLogic.getAnnotatedUserHandles() == null) { + finish(); + return; + } mWorkProfileAvailability = createWorkProfileAvailabilityManager(); @@ -389,12 +371,20 @@ public class ResolverActivity extends FragmentActivity implements mPersonalPackageMonitor = createPackageMonitor( mMultiProfilePagerAdapter.getPersonalListAdapter()); mPersonalPackageMonitor.register( - this, getMainLooper(), getAnnotatedUserHandles().personalProfileUserHandle, false); + this, + getMainLooper(), + requireAnnotatedUserHandles().personalProfileUserHandle, + false + ); if (shouldShowTabs()) { mWorkPackageMonitor = createPackageMonitor( mMultiProfilePagerAdapter.getWorkListAdapter()); mWorkPackageMonitor.register( - this, getMainLooper(), getAnnotatedUserHandles().workProfileUserHandle, false); + this, + getMainLooper(), + requireAnnotatedUserHandles().workProfileUserHandle, + false + ); } mRegistered = true; @@ -486,11 +476,11 @@ public class ResolverActivity extends FragmentActivity implements ResolverActivity.METRICS_CATEGORY_RESOLVER); return new NoCrossProfileEmptyStateProvider( - getAnnotatedUserHandles().personalProfileUserHandle, + requireAnnotatedUserHandles().personalProfileUserHandle, noWorkToPersonalEmptyState, noPersonalToWorkEmptyState, createCrossProfileIntentsChecker(), - getAnnotatedUserHandles().tabOwnerUserHandleForLaunch); + requireAnnotatedUserHandles().tabOwnerUserHandleForLaunch); } /** @@ -878,7 +868,7 @@ public class ResolverActivity extends FragmentActivity implements mPm, getTargetIntent(), mLogic.getReferrerPackageName(), - getAnnotatedUserHandles().userIdOfCallingApp, + requireAnnotatedUserHandles().userIdOfCallingApp, resolverComparator, getQueryIntentsUser(userHandle)); } @@ -966,7 +956,8 @@ public class ResolverActivity extends FragmentActivity implements @Override // ResolverListCommunicator public void onHandlePackagesChanged(ResolverListAdapter listAdapter) { if (listAdapter == mMultiProfilePagerAdapter.getActiveListAdapter()) { - if (listAdapter.getUserHandle().equals(getAnnotatedUserHandles().workProfileUserHandle) + if (listAdapter.getUserHandle().equals( + requireAnnotatedUserHandles().workProfileUserHandle) && mWorkProfileAvailability.isWaitingToEnableWorkProfile()) { // We have just turned on the work profile and entered the pass code to start it, // now we are waiting to receive the ACTION_USER_UNLOCKED broadcast. There is no @@ -1006,13 +997,13 @@ public class ResolverActivity extends FragmentActivity implements protected WorkProfileAvailabilityManager createWorkProfileAvailabilityManager() { return new WorkProfileAvailabilityManager( getSystemService(UserManager.class), - getAnnotatedUserHandles().workProfileUserHandle, + requireAnnotatedUserHandles().workProfileUserHandle, this::onWorkProfileStatusUpdated); } protected void onWorkProfileStatusUpdated() { if (mMultiProfilePagerAdapter.getCurrentUserHandle().equals( - getAnnotatedUserHandles().workProfileUserHandle)) { + requireAnnotatedUserHandles().workProfileUserHandle)) { mMultiProfilePagerAdapter.rebuildActiveTab(true); } else { mMultiProfilePagerAdapter.clearInactiveProfileCache(); @@ -1030,8 +1021,8 @@ public class ResolverActivity extends FragmentActivity implements UserHandle userHandle, TargetDataLoader targetDataLoader) { UserHandle initialIntentsUserSpace = isLaunchedAsCloneProfile() - && userHandle.equals(getAnnotatedUserHandles().personalProfileUserHandle) - ? getAnnotatedUserHandles().cloneProfileUserHandle : userHandle; + && userHandle.equals(requireAnnotatedUserHandles().personalProfileUserHandle) + ? requireAnnotatedUserHandles().cloneProfileUserHandle : userHandle; return new ResolverListAdapter( context, payloadIntents, @@ -1080,9 +1071,9 @@ public class ResolverActivity extends FragmentActivity implements final EmptyStateProvider noAppsEmptyStateProvider = new NoAppsAvailableEmptyStateProvider( this, workProfileUserHandle, - getAnnotatedUserHandles().personalProfileUserHandle, + requireAnnotatedUserHandles().personalProfileUserHandle, getMetricsCategory(), - getAnnotatedUserHandles().tabOwnerUserHandleForLaunch + requireAnnotatedUserHandles().tabOwnerUserHandleForLaunch ); // Return composite provider, the order matters (the higher, the more priority) @@ -1105,7 +1096,7 @@ public class ResolverActivity extends FragmentActivity implements initialIntents, resolutionList, filterLastUsed, - /* userHandle */ getAnnotatedUserHandles().personalProfileUserHandle, + /* userHandle */ requireAnnotatedUserHandles().personalProfileUserHandle, targetDataLoader); return new ResolverMultiProfilePagerAdapter( /* context */ this, @@ -1113,13 +1104,13 @@ public class ResolverActivity extends FragmentActivity implements createEmptyStateProvider(/* workProfileUserHandle= */ null), /* workProfileQuietModeChecker= */ () -> false, /* workProfileUserHandle= */ null, - getAnnotatedUserHandles().cloneProfileUserHandle); + requireAnnotatedUserHandles().cloneProfileUserHandle); } private UserHandle getIntentUser() { return getIntent().hasExtra(EXTRA_CALLING_USER) ? getIntent().getParcelableExtra(EXTRA_CALLING_USER) - : getAnnotatedUserHandles().tabOwnerUserHandleForLaunch; + : requireAnnotatedUserHandles().tabOwnerUserHandleForLaunch; } private ResolverMultiProfilePagerAdapter createResolverMultiProfilePagerAdapterForTwoProfiles( @@ -1132,10 +1123,10 @@ public class ResolverActivity extends FragmentActivity implements // this happens, we check for it here and set the current profile's tab. int selectedProfile = getCurrentProfile(); UserHandle intentUser = getIntentUser(); - if (!getAnnotatedUserHandles().tabOwnerUserHandleForLaunch.equals(intentUser)) { - if (getAnnotatedUserHandles().personalProfileUserHandle.equals(intentUser)) { + if (!requireAnnotatedUserHandles().tabOwnerUserHandleForLaunch.equals(intentUser)) { + if (requireAnnotatedUserHandles().personalProfileUserHandle.equals(intentUser)) { selectedProfile = PROFILE_PERSONAL; - } else if (getAnnotatedUserHandles().workProfileUserHandle.equals(intentUser)) { + } else if (requireAnnotatedUserHandles().workProfileUserHandle.equals(intentUser)) { selectedProfile = PROFILE_WORK; } } else { @@ -1153,10 +1144,10 @@ public class ResolverActivity extends FragmentActivity implements selectedProfile == PROFILE_PERSONAL ? initialIntents : null, resolutionList, (filterLastUsed && UserHandle.myUserId() - == getAnnotatedUserHandles().personalProfileUserHandle.getIdentifier()), - /* userHandle */ getAnnotatedUserHandles().personalProfileUserHandle, + == requireAnnotatedUserHandles().personalProfileUserHandle.getIdentifier()), + /* userHandle */ requireAnnotatedUserHandles().personalProfileUserHandle, targetDataLoader); - UserHandle workProfileUserHandle = getAnnotatedUserHandles().workProfileUserHandle; + UserHandle workProfileUserHandle = requireAnnotatedUserHandles().workProfileUserHandle; ResolverListAdapter workAdapter = createResolverListAdapter( /* context */ this, /* payloadIntents */ mIntents, @@ -1174,7 +1165,7 @@ public class ResolverActivity extends FragmentActivity implements () -> mWorkProfileAvailability.isQuietModeEnabled(), selectedProfile, workProfileUserHandle, - getAnnotatedUserHandles().cloneProfileUserHandle); + requireAnnotatedUserHandles().cloneProfileUserHandle); } /** @@ -1197,26 +1188,26 @@ public class ResolverActivity extends FragmentActivity implements } protected final @Profile int getCurrentProfile() { - UserHandle launchUser = getAnnotatedUserHandles().tabOwnerUserHandleForLaunch; - UserHandle personalUser = getAnnotatedUserHandles().personalProfileUserHandle; + UserHandle launchUser = requireAnnotatedUserHandles().tabOwnerUserHandleForLaunch; + UserHandle personalUser = requireAnnotatedUserHandles().personalProfileUserHandle; return launchUser.equals(personalUser) ? PROFILE_PERSONAL : PROFILE_WORK; } - protected final AnnotatedUserHandles getAnnotatedUserHandles() { - return mLazyAnnotatedUserHandles.get(); + private AnnotatedUserHandles requireAnnotatedUserHandles() { + return requireNonNull(mLogic.getAnnotatedUserHandles()); } private boolean hasWorkProfile() { - return getAnnotatedUserHandles().workProfileUserHandle != null; + return requireAnnotatedUserHandles().workProfileUserHandle != null; } private boolean hasCloneProfile() { - return getAnnotatedUserHandles().cloneProfileUserHandle != null; + return requireAnnotatedUserHandles().cloneProfileUserHandle != null; } protected final boolean isLaunchedAsCloneProfile() { - UserHandle launchUser = getAnnotatedUserHandles().userHandleSharesheetLaunchedAs; - UserHandle cloneUser = getAnnotatedUserHandles().cloneProfileUserHandle; + UserHandle launchUser = requireAnnotatedUserHandles().userHandleSharesheetLaunchedAs; + UserHandle cloneUser = requireAnnotatedUserHandles().cloneProfileUserHandle; return hasCloneProfile() && launchUser.equals(cloneUser); } @@ -1261,7 +1252,7 @@ public class ResolverActivity extends FragmentActivity implements .createEvent(DevicePolicyEnums.RESOLVER_CROSS_PROFILE_TARGET_OPENED) .setBoolean( currentUserHandle.equals( - getAnnotatedUserHandles().personalProfileUserHandle)) + requireAnnotatedUserHandles().personalProfileUserHandle)) .setStrings(getMetricsCategory(), cti.isInDirectShareMetricsCategory() ? "direct_share" : "other_target") .write(); @@ -1354,7 +1345,7 @@ public class ResolverActivity extends FragmentActivity implements mPersonalPackageMonitor.register( this, getMainLooper(), - getAnnotatedUserHandles().personalProfileUserHandle, + requireAnnotatedUserHandles().personalProfileUserHandle, false); if (shouldShowTabs()) { if (mWorkPackageMonitor == null) { @@ -1364,7 +1355,7 @@ public class ResolverActivity extends FragmentActivity implements mWorkPackageMonitor.register( this, getMainLooper(), - getAnnotatedUserHandles().workProfileUserHandle, + requireAnnotatedUserHandles().workProfileUserHandle, false); } mRegistered = true; @@ -1583,7 +1574,7 @@ public class ResolverActivity extends FragmentActivity implements } } catch (RuntimeException e) { Slog.wtf(TAG, - "Unable to launch as uid " + getAnnotatedUserHandles().userIdOfCallingApp + "Unable to launch as uid " + requireAnnotatedUserHandles().userIdOfCallingApp + " package " + getLaunchedFromPackage() + ", while running in " + ActivityThread.currentProcessName(), e); } @@ -1834,7 +1825,7 @@ public class ResolverActivity extends FragmentActivity implements DevicePolicyEventLogger .createEvent(DevicePolicyEnums.RESOLVER_AUTOLAUNCH_CROSS_PROFILE_TARGET) .setBoolean(activeListAdapter.getUserHandle() - .equals(getAnnotatedUserHandles().personalProfileUserHandle)) + .equals(requireAnnotatedUserHandles().personalProfileUserHandle)) .setStrings(getMetricsCategory()) .write(); safelyStartActivity(activeProfileTarget); @@ -2118,7 +2109,8 @@ public class ResolverActivity extends FragmentActivity implements // filtered item. We always show the same default app even in the inactive user profile. boolean adapterForCurrentUserHasFilteredItem = mMultiProfilePagerAdapter.getListAdapterForUserHandle( - getAnnotatedUserHandles().tabOwnerUserHandleForLaunch).hasFilteredItem(); + requireAnnotatedUserHandles().tabOwnerUserHandleForLaunch + ).hasFilteredItem(); return mLogic.getSupportsAlwaysUseOption() && adapterForCurrentUserHasFilteredItem; } @@ -2239,7 +2231,7 @@ public class ResolverActivity extends FragmentActivity implements * {@link ResolverListController} configured for the provided {@code userHandle}. */ protected final UserHandle getQueryIntentsUser(UserHandle userHandle) { - return getAnnotatedUserHandles().getQueryIntentsUser(userHandle); + return requireAnnotatedUserHandles().getQueryIntentsUser(userHandle); } /** @@ -2259,9 +2251,9 @@ public class ResolverActivity extends FragmentActivity implements // Add clonedProfileUserHandle to the list only if we are: // a. Building the Personal Tab. // b. CloneProfile exists on the device. - if (userHandle.equals(getAnnotatedUserHandles().personalProfileUserHandle) + if (userHandle.equals(requireAnnotatedUserHandles().personalProfileUserHandle) && hasCloneProfile()) { - userList.add(getAnnotatedUserHandles().cloneProfileUserHandle); + userList.add(requireAnnotatedUserHandles().cloneProfileUserHandle); } return userList; } diff --git a/java/src/com/android/intentresolver/v2/ResolverActivityLogic.kt b/java/src/com/android/intentresolver/v2/ResolverActivityLogic.kt index c8f02885..1b936159 100644 --- a/java/src/com/android/intentresolver/v2/ResolverActivityLogic.kt +++ b/java/src/com/android/intentresolver/v2/ResolverActivityLogic.kt @@ -9,8 +9,9 @@ import com.android.intentresolver.v2.util.mutableLazy /** Activity logic for [ResolverActivity]. */ class ResolverActivityLogic( + tag: String, activityProvider: () -> ComponentActivity, -) : ActivityLogic, CommonActivityLogic by CommonActivityLogicImpl(activityProvider) { +) : ActivityLogic, CommonActivityLogic by CommonActivityLogicImpl(tag, activityProvider) { override val targetIntent: Intent by lazy { val intent = Intent(activity.intent) diff --git a/java/tests/src/com/android/intentresolver/v2/ChooserWrapperActivity.java b/java/tests/src/com/android/intentresolver/v2/ChooserWrapperActivity.java index 65d33485..6fdba4c2 100644 --- a/java/tests/src/com/android/intentresolver/v2/ChooserWrapperActivity.java +++ b/java/tests/src/com/android/intentresolver/v2/ChooserWrapperActivity.java @@ -33,7 +33,6 @@ import android.os.UserHandle; import androidx.lifecycle.ViewModelProvider; -import com.android.intentresolver.AnnotatedUserHandles; import com.android.intentresolver.ChooserListAdapter; import com.android.intentresolver.ChooserRequestParameters; import com.android.intentresolver.IChooserWrapper; @@ -59,6 +58,17 @@ public class ChooserWrapperActivity extends ChooserActivity implements IChooserW static final ChooserActivityOverrideData sOverrides = ChooserActivityOverrideData.getInstance(); private UsageStatsManager mUsm; + public ChooserWrapperActivity() { + super(); + mLogic = new TestChooserActivityLogic( + "ChooserWrapper", + () -> this, + () -> mTargetDataLoader, + super::onPreinitialization, + sOverrides + ); + } + // ResolverActivity (the base class of ChooserActivity) inspects the launched-from UID at // onCreate and needs to see some non-negative value in the test. @Override @@ -234,11 +244,6 @@ public class ChooserWrapperActivity extends ChooserActivity implements IChooserW replacementIntent); } - @Override - protected AnnotatedUserHandles computeAnnotatedUserHandles() { - return sOverrides.annotatedUserHandles; - } - @Override public UserHandle getCurrentUserHandle() { return mMultiProfilePagerAdapter.getCurrentUserHandle(); diff --git a/java/tests/src/com/android/intentresolver/v2/ResolverWrapperActivity.java b/java/tests/src/com/android/intentresolver/v2/ResolverWrapperActivity.java index 0fb77457..e5617090 100644 --- a/java/tests/src/com/android/intentresolver/v2/ResolverWrapperActivity.java +++ b/java/tests/src/com/android/intentresolver/v2/ResolverWrapperActivity.java @@ -60,6 +60,11 @@ public class ResolverWrapperActivity extends ResolverActivity { public ResolverWrapperActivity() { super(/* isIntentPicker= */ true); + mLogic = new TestResolverActivityLogic( + "ResolverWrapper", + () -> this, + sOverrides + ); } public CountingIdlingResource getLabelIdlingResource() { @@ -158,11 +163,6 @@ public class ResolverWrapperActivity extends ResolverActivity { return mMultiProfilePagerAdapter.getCurrentUserHandle(); } - @Override - protected AnnotatedUserHandles computeAnnotatedUserHandles() { - return sOverrides.annotatedUserHandles; - } - @Override public void startActivityAsUser(Intent intent, Bundle options, UserHandle user) { super.startActivityAsUser(intent, options, user); @@ -179,7 +179,7 @@ public class ResolverWrapperActivity extends ResolverActivity { *

* Instead, we use static instances of this object to modify behavior. */ - static class OverrideData { + public static class OverrideData { @SuppressWarnings("Since15") public Function createPackageManager; public Function, Boolean> onSafelyStartInternalCallback; diff --git a/java/tests/src/com/android/intentresolver/v2/TestChooserActivityLogic.kt b/java/tests/src/com/android/intentresolver/v2/TestChooserActivityLogic.kt new file mode 100644 index 00000000..fb1eab6c --- /dev/null +++ b/java/tests/src/com/android/intentresolver/v2/TestChooserActivityLogic.kt @@ -0,0 +1,25 @@ +package com.android.intentresolver.v2 + +import androidx.activity.ComponentActivity +import com.android.intentresolver.AnnotatedUserHandles +import com.android.intentresolver.icons.TargetDataLoader + +/** Activity logic for use when testing [ChooserActivity]. */ +class TestChooserActivityLogic( + tag: String, + activityProvider: () -> ComponentActivity, + targetDataLoaderProvider: () -> TargetDataLoader, + onPreinitialization: () -> Unit, + overrideData: ChooserActivityOverrideData, +) : + ChooserActivityLogic( + tag, + activityProvider, + targetDataLoaderProvider, + onPreinitialization, + ) { + + override val annotatedUserHandles: AnnotatedUserHandles? by lazy { + overrideData.annotatedUserHandles + } +} diff --git a/java/tests/src/com/android/intentresolver/v2/TestResolverActivityLogic.kt b/java/tests/src/com/android/intentresolver/v2/TestResolverActivityLogic.kt new file mode 100644 index 00000000..7f8e6f70 --- /dev/null +++ b/java/tests/src/com/android/intentresolver/v2/TestResolverActivityLogic.kt @@ -0,0 +1,16 @@ +package com.android.intentresolver.v2 + +import androidx.activity.ComponentActivity +import com.android.intentresolver.AnnotatedUserHandles + +/** Activity logic for use when testing [ResolverActivity]. */ +class TestResolverActivityLogic( + tag: String, + activityProvider: () -> ComponentActivity, + overrideData: ResolverWrapperActivity.OverrideData, +) : ActivityLogic by ResolverActivityLogic(tag, activityProvider) { + + override val annotatedUserHandles: AnnotatedUserHandles? by lazy { + overrideData.annotatedUserHandles + } +} -- cgit v1.2.3-59-g8ed1b From 75e928b3330c383363096d9113a804215863fba5 Mon Sep 17 00:00:00 2001 From: Mark Renouf Date: Tue, 24 Oct 2023 09:36:48 -0400 Subject: Adds UserDataSource An abstraction of users and profiles, packaged up into an injectable interface. UserDataSource val users: Flow> fun isAvailable(@UserIdInt userId: Int): Flow Along with an interface and implementation this change introduces a data model, [User] to abstract from platform internal types. Bug: 309960444 Test: atest FakeUserManagerTest UserDataSourceImplTest Change-Id: I46681e5f5b40c0720f4b99c1bb13d05ab5da4211 --- .../android/intentresolver/inject/Qualifiers.kt | 7 + .../intentresolver/inject/SingletonModule.kt | 2 +- .../intentresolver/v2/data/BroadcastFlow.kt | 46 +++++ .../src/com/android/intentresolver/v2/data/User.kt | 75 +++++++ .../intentresolver/v2/data/UserDataSource.kt | 227 +++++++++++++++++++++ .../intentresolver/v2/data/UserDataSourceModule.kt | 34 +++ .../android/intentresolver/v2/coroutines/Flow.kt | 89 ++++++++ .../v2/data/UserDataSourceImplTest.kt | 194 ++++++++++++++++++ .../intentresolver/v2/platform/FakeUserManager.kt | 206 +++++++++++++++++++ .../v2/platform/FakeUserManagerTest.kt | 128 ++++++++++++ 10 files changed, 1007 insertions(+), 1 deletion(-) create mode 100644 java/src/com/android/intentresolver/v2/data/BroadcastFlow.kt create mode 100644 java/src/com/android/intentresolver/v2/data/User.kt create mode 100644 java/src/com/android/intentresolver/v2/data/UserDataSource.kt create mode 100644 java/src/com/android/intentresolver/v2/data/UserDataSourceModule.kt create mode 100644 java/tests/src/com/android/intentresolver/v2/coroutines/Flow.kt create mode 100644 java/tests/src/com/android/intentresolver/v2/data/UserDataSourceImplTest.kt create mode 100644 java/tests/src/com/android/intentresolver/v2/platform/FakeUserManager.kt create mode 100644 java/tests/src/com/android/intentresolver/v2/platform/FakeUserManagerTest.kt (limited to 'java/src') diff --git a/java/src/com/android/intentresolver/inject/Qualifiers.kt b/java/src/com/android/intentresolver/inject/Qualifiers.kt index fca1e896..157e8f76 100644 --- a/java/src/com/android/intentresolver/inject/Qualifiers.kt +++ b/java/src/com/android/intentresolver/inject/Qualifiers.kt @@ -25,6 +25,13 @@ import javax.inject.Qualifier @Retention(AnnotationRetention.RUNTIME) annotation class ApplicationOwned +@Qualifier +@MustBeDocumented +@Retention(AnnotationRetention.RUNTIME) +annotation class ApplicationUser + +@Qualifier @MustBeDocumented @Retention(AnnotationRetention.RUNTIME) annotation class ProfileParent + @Qualifier @MustBeDocumented @Retention(AnnotationRetention.RUNTIME) annotation class Background @Qualifier @MustBeDocumented @Retention(AnnotationRetention.RUNTIME) annotation class Default diff --git a/java/src/com/android/intentresolver/inject/SingletonModule.kt b/java/src/com/android/intentresolver/inject/SingletonModule.kt index 36adf06b..e517800d 100644 --- a/java/src/com/android/intentresolver/inject/SingletonModule.kt +++ b/java/src/com/android/intentresolver/inject/SingletonModule.kt @@ -12,7 +12,7 @@ import javax.inject.Singleton @InstallIn(SingletonComponent::class) @Module -class SingletonModule { +object SingletonModule { @Provides @Singleton fun instanceIdSequence() = EventLogImpl.newIdSequence() @Provides diff --git a/java/src/com/android/intentresolver/v2/data/BroadcastFlow.kt b/java/src/com/android/intentresolver/v2/data/BroadcastFlow.kt new file mode 100644 index 00000000..1a58afcb --- /dev/null +++ b/java/src/com/android/intentresolver/v2/data/BroadcastFlow.kt @@ -0,0 +1,46 @@ +package com.android.intentresolver.v2.data + +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.content.IntentFilter +import android.os.UserHandle +import android.util.Log +import kotlinx.coroutines.channels.awaitClose +import kotlinx.coroutines.channels.onFailure +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.callbackFlow + +private const val TAG = "BroadcastFlow" + +/** + * Returns a [callbackFlow] that, when collected, registers a broadcast receiver and emits a new + * value whenever broadcast matching _filter_ is received. The result value will be computed using + * [transform] and emitted if non-null. + */ +internal fun broadcastFlow( + context: Context, + filter: IntentFilter, + user: UserHandle, + transform: (Intent) -> T? +): Flow = callbackFlow { + val receiver = + object : BroadcastReceiver() { + override fun onReceive(context: Context, intent: Intent) { + transform(intent)?.also { result -> + trySend(result).onFailure { Log.e(TAG, "Failed to send $result", it) } + } + ?: Log.w(TAG, "Ignored broadcast $intent") + } + } + + context.registerReceiverAsUser( + receiver, + user, + IntentFilter(filter), + null, + null, + Context.RECEIVER_NOT_EXPORTED + ) + awaitClose { context.unregisterReceiver(receiver) } +} diff --git a/java/src/com/android/intentresolver/v2/data/User.kt b/java/src/com/android/intentresolver/v2/data/User.kt new file mode 100644 index 00000000..d8a4af74 --- /dev/null +++ b/java/src/com/android/intentresolver/v2/data/User.kt @@ -0,0 +1,75 @@ +package com.android.intentresolver.v2.data + +import android.annotation.UserIdInt +import android.content.pm.UserInfo +import android.os.UserHandle +import com.android.intentresolver.v2.data.User.Role +import com.android.intentresolver.v2.data.User.Type +import com.android.intentresolver.v2.data.User.Type.FULL +import com.android.intentresolver.v2.data.User.Type.PROFILE + +/** + * A User represents the owner of a distinct set of content. + * * maps 1:1 to a UserHandle or UserId (Int) value. + * * refers to either [Full][Type.FULL], or a [Profile][Type.PROFILE] user, as indicated by the + * [type] property. + * + * See + * [Users for system developers](https://android.googlesource.com/platform/frameworks/base/+/master/core/java/android/os/Users.md) + * + * ``` + * fun example() { + * User(id = 0, role = PERSONAL) + * User(id = 10, role = WORK) + * User(id = 11, role = CLONE) + * User(id = 12, role = PRIVATE) + * } + * ``` + */ +data class User( + @UserIdInt val id: Int, + val role: Role, +) { + val handle: UserHandle = UserHandle.of(id) + + 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) + } +} + +fun UserInfo.getSupportedUserRole(): Role? = + when { + isFull -> Role.PERSONAL + isManagedProfile -> Role.WORK + isCloneProfile -> Role.CLONE + isPrivateProfile -> Role.PRIVATE + else -> null + } + +/** + * Creates a [User], based on values from a [UserInfo]. + * + * ``` + * val users: List = + * getEnabledProfiles(user).map(::toUser).filterNotNull() + * ``` + * + * @return a [User] if the [UserInfo] matched a supported [Role], otherwise null + */ +fun UserInfo.toUser(): User? { + return getSupportedUserRole()?.let { role -> User(userHandle.identifier, role) } +} diff --git a/java/src/com/android/intentresolver/v2/data/UserDataSource.kt b/java/src/com/android/intentresolver/v2/data/UserDataSource.kt new file mode 100644 index 00000000..9eecc3be --- /dev/null +++ b/java/src/com/android/intentresolver/v2/data/UserDataSource.kt @@ -0,0 +1,227 @@ +package com.android.intentresolver.v2.data + +import android.content.Context +import android.content.Intent +import android.content.Intent.ACTION_MANAGED_PROFILE_AVAILABLE +import android.content.Intent.ACTION_MANAGED_PROFILE_UNAVAILABLE +import android.content.Intent.ACTION_PROFILE_ADDED +import android.content.Intent.ACTION_PROFILE_AVAILABLE +import android.content.Intent.ACTION_PROFILE_REMOVED +import android.content.Intent.ACTION_PROFILE_UNAVAILABLE +import android.content.Intent.EXTRA_QUIET_MODE +import android.content.Intent.EXTRA_USER +import android.content.IntentFilter +import android.content.pm.UserInfo +import android.os.UserHandle +import android.os.UserManager +import android.util.Log +import androidx.annotation.VisibleForTesting +import com.android.intentresolver.inject.Background +import com.android.intentresolver.inject.Main +import com.android.intentresolver.inject.ProfileParent +import com.android.intentresolver.v2.data.UserDataSourceImpl.UserEvent +import dagger.hilt.android.qualifiers.ApplicationContext +import javax.inject.Inject +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.filterNot +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.flow.onStart +import kotlinx.coroutines.flow.runningFold +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.withContext + +interface UserDataSource { + /** + * A [Flow] user profile groups. Each map contains the context user along with all members of + * the profile group. This includes the (Full) parent user, if the context user is a profile. + */ + val users: Flow> + + /** + * A [Flow] of availability. Only profile users may become unavailable. + * + * Availability is currently defined as not being in [quietMode][UserInfo.isQuietModeEnabled]. + */ + fun isAvailable(handle: UserHandle): Flow +} + +private const val TAG = "UserDataSource" + +private data class UserWithState(val user: User, val available: Boolean) + +private typealias UserStateMap = Map + +/** Tracks and publishes state for the parent user and associated profiles. */ +class UserDataSourceImpl +@VisibleForTesting +constructor( + private val profileParent: UserHandle, + private val userManager: UserManager, + /** A flow of events which represent user-state changes from [UserManager]. */ + private val userEvents: Flow, + scope: CoroutineScope, + private val backgroundDispatcher: CoroutineDispatcher +) : UserDataSource { + @Inject + constructor( + @ApplicationContext context: Context, + @ProfileParent profileParent: UserHandle, + userManager: UserManager, + @Main scope: CoroutineScope, + @Background background: CoroutineDispatcher + ) : this( + profileParent, + userManager, + userEvents = userBroadcastFlow(context, profileParent), + scope, + background + ) + + data class UserEvent(val action: String, val user: UserHandle, val quietMode: Boolean = false) + + /** + * An exception which indicates that an inconsistency exists between the user state map and the + * rest of the system. + */ + internal class UserStateException( + override val message: String, + val event: UserEvent, + override val cause: Throwable? = null + ) : RuntimeException("$message: event=$event", cause) + + private val usersWithState: Flow = + userEvents + .onStart { emit(UserEvent(INITIALIZE, profileParent)) } + .onEach { Log.i("UserDataSource", "userEvent: $it") } + .runningFold(emptyMap()) { users, event -> + try { + // Handle an action by performing some operation, then returning a new map + when (event.action) { + INITIALIZE -> createNewUserStateMap(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...") + createNewUserStateMap(profileParent) + } + } + .onEach { Log.i("UserDataSource", "userStateMap: $it") } + .stateIn(scope, SharingStarted.Eagerly, emptyMap()) + .filterNot { it.isEmpty() } + + override val users: Flow> = + usersWithState.map { map -> map.mapValues { it.value.user } }.distinctUntilChanged() + + private val availability: Flow> = + usersWithState.map { map -> map.mapValues { it.value.available } }.distinctUntilChanged() + + override fun isAvailable(handle: UserHandle): Flow { + return availability.map { it[handle] ?: false } + } + + private fun handleAvailability(event: UserEvent, current: UserStateMap): UserStateMap { + val userEntry = + current[event.user] + ?: throw UserStateException("User was not present in the map", event) + return current + (event.user to userEntry.copy(available = !event.quietMode)) + } + + private fun handleProfileRemoved(event: UserEvent, current: UserStateMap): UserStateMap { + if (!current.containsKey(event.user)) { + throw UserStateException("User was not present in the map", event) + } + return current.filterKeys { it != event.user } + } + + private suspend fun handleProfileAdded(event: UserEvent, current: UserStateMap): UserStateMap { + val user = + try { + requireNotNull(readUser(event.user)) + } catch (e: Exception) { + throw UserStateException("Failed to read user from UserManager", event, e) + } + return current + (event.user to UserWithState(user, !event.quietMode)) + } + + private suspend fun createNewUserStateMap(user: UserHandle): UserStateMap { + val profiles = readProfileGroup(user) + return profiles + .mapNotNull { userInfo -> + userInfo.toUser()?.let { user -> UserWithState(user, userInfo.isAvailable()) } + } + .associateBy { it.user.handle } + } + + private suspend fun readProfileGroup(handle: UserHandle): List { + return withContext(backgroundDispatcher) { + @Suppress("DEPRECATION") userManager.getEnabledProfiles(handle.identifier) + } + .toList() + } + + /** Read [UserInfo] from [UserManager], or null if not found or an unsupported type. */ + private suspend fun readUser(user: UserHandle): User? { + val userInfo = + withContext(backgroundDispatcher) { userManager.getUserInfo(user.identifier) } + return userInfo?.let { info -> + info.getSupportedUserRole()?.let { role -> User(info.id, role) } + } + } +} + +/** Used with [broadcastFlow] to transform a UserManager broadcast action into a [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) + } +} + +const val INITIALIZE = "INITIALIZE" + +private fun createFilter(actions: Iterable): IntentFilter { + return IntentFilter().apply { actions.forEach(::addAction) } +} + +private fun UserInfo?.isAvailable(): Boolean { + return this?.isQuietModeEnabled != true +} + +private fun userBroadcastFlow(context: Context, profileParent: UserHandle): Flow { + val userActions = + setOf( + ACTION_PROFILE_ADDED, + ACTION_PROFILE_REMOVED, + + // Quiet mode enabled/disabled for managed + // From: UserController.broadcastProfileAvailabilityChanges + // In response to setQuietModeEnabled + ACTION_MANAGED_PROFILE_AVAILABLE, // quiet mode, sent for manage profiles only + ACTION_MANAGED_PROFILE_UNAVAILABLE, // quiet mode, sent for manage profiles only + + // Quiet mode toggled for profile type, requires flag 'android.os.allow_private_profile + // true' + ACTION_PROFILE_AVAILABLE, // quiet mode, + ACTION_PROFILE_UNAVAILABLE, // quiet mode, sent for any profile type + ) + return broadcastFlow(context, createFilter(userActions), profileParent, Intent::toUserEvent) +} diff --git a/java/src/com/android/intentresolver/v2/data/UserDataSourceModule.kt b/java/src/com/android/intentresolver/v2/data/UserDataSourceModule.kt new file mode 100644 index 00000000..94f39eb7 --- /dev/null +++ b/java/src/com/android/intentresolver/v2/data/UserDataSourceModule.kt @@ -0,0 +1,34 @@ +package com.android.intentresolver.v2.data + +import android.content.Context +import android.os.UserHandle +import android.os.UserManager +import com.android.intentresolver.inject.ApplicationUser +import com.android.intentresolver.inject.ProfileParent +import dagger.Binds +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.android.qualifiers.ApplicationContext +import dagger.hilt.components.SingletonComponent +import javax.inject.Singleton + +@Module +@InstallIn(SingletonComponent::class) +interface UserDataSourceModule { + companion object { + @Provides + @Singleton + @ApplicationUser + fun applicationUser(@ApplicationContext context: Context): UserHandle = context.user + + @Provides + @Singleton + @ProfileParent + fun profileParent(@ApplicationUser user: UserHandle, userManager: UserManager): UserHandle { + return userManager.getProfileParent(user) ?: user + } + } + + @Binds @Singleton fun userDataSource(impl: UserDataSourceImpl): UserDataSource +} diff --git a/java/tests/src/com/android/intentresolver/v2/coroutines/Flow.kt b/java/tests/src/com/android/intentresolver/v2/coroutines/Flow.kt new file mode 100644 index 00000000..a5677d94 --- /dev/null +++ b/java/tests/src/com/android/intentresolver/v2/coroutines/Flow.kt @@ -0,0 +1,89 @@ +@file:Suppress("OPT_IN_USAGE") + +package com.android.intentresolver.v2.coroutines + +/* + * Copyright (C) 2022 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import kotlin.coroutines.CoroutineContext +import kotlin.coroutines.EmptyCoroutineContext +import kotlin.properties.ReadOnlyProperty +import kotlin.reflect.KProperty +import kotlinx.coroutines.CoroutineStart +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.runCurrent + +/** + * Collect [flow] in a new [Job] and return a getter for the last collected value. + * + * ``` + * fun myTest() = runTest { + * // ... + * val actual by collectLastValue(underTest.flow) + * assertThat(actual).isEqualTo(expected) + * } + * ``` + */ +fun TestScope.collectLastValue( + flow: Flow, + context: CoroutineContext = EmptyCoroutineContext, + start: CoroutineStart = CoroutineStart.DEFAULT, +): FlowValue { + val values by + collectValues( + flow = flow, + context = context, + start = start, + ) + return FlowValueImpl { values.lastOrNull() } +} + +/** + * Collect [flow] in a new [Job] and return a getter for the collection of values collected. + * + * ``` + * fun myTest() = runTest { + * // ... + * val values by collectValues(underTest.flow) + * assertThat(values).isEqualTo(listOf(expected1, expected2, ...)) + * } + * ``` + */ +fun TestScope.collectValues( + flow: Flow, + context: CoroutineContext = EmptyCoroutineContext, + start: CoroutineStart = CoroutineStart.DEFAULT, +): FlowValue> { + val values = mutableListOf() + backgroundScope.launch(context, start) { flow.collect(values::add) } + return FlowValueImpl { + runCurrent() + values.toList() + } +} + +/** @see collectLastValue */ +interface FlowValue : ReadOnlyProperty { + operator fun invoke(): T +} + +private class FlowValueImpl(private val block: () -> T) : FlowValue { + override operator fun invoke(): T = block() + override fun getValue(thisRef: Any?, property: KProperty<*>): T = invoke() +} diff --git a/java/tests/src/com/android/intentresolver/v2/data/UserDataSourceImplTest.kt b/java/tests/src/com/android/intentresolver/v2/data/UserDataSourceImplTest.kt new file mode 100644 index 00000000..56d5de35 --- /dev/null +++ b/java/tests/src/com/android/intentresolver/v2/data/UserDataSourceImplTest.kt @@ -0,0 +1,194 @@ +@file:OptIn(ExperimentalCoroutinesApi::class) + +package com.android.intentresolver.v2.data + +import android.content.Intent.ACTION_PROFILE_ADDED +import android.content.Intent.ACTION_PROFILE_AVAILABLE +import android.content.Intent.ACTION_PROFILE_REMOVED +import android.content.pm.UserInfo +import android.os.UserHandle +import android.os.UserHandle.USER_NULL +import android.os.UserManager +import com.android.intentresolver.mock +import com.android.intentresolver.v2.coroutines.collectLastValue +import com.android.intentresolver.v2.data.User.Role +import com.android.intentresolver.v2.data.UserDataSourceImpl.UserEvent +import com.android.intentresolver.v2.platform.FakeUserManager +import com.android.intentresolver.v2.platform.FakeUserManager.ProfileType +import com.android.intentresolver.whenever +import com.google.common.truth.Truth.assertThat +import com.google.common.truth.Truth.assertWithMessage +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.runTest +import org.junit.Test +import org.mockito.Mockito.anyInt +import org.mockito.Mockito.doReturn +import org.mockito.Mockito.eq + +internal class UserDataSourceImplTest { + private val userManager = FakeUserManager() + private val userState = userManager.state + + @Test + fun initialization() = runTest { + val dataSource = createUserDataSource(userManager) + val users by collectLastValue(dataSource.users) + + assertWithMessage("collectLast(dataSource.users)").that(users).isNotNull() + assertThat(users) + .containsExactly( + userState.primaryUserHandle, + User(userState.primaryUserHandle.identifier, Role.PERSONAL) + ) + } + + @Test + fun createProfile() = runTest { + val dataSource = createUserDataSource(userManager) + val users by collectLastValue(dataSource.users) + + assertWithMessage("collectLast(dataSource.users)").that(users).isNotNull() + assertThat(users!!.values.filter { it.role.type == User.Type.PROFILE }).isEmpty() + + val profile = userState.createProfile(ProfileType.WORK) + assertThat(users).containsEntry(profile, User(profile.identifier, Role.WORK)) + } + + @Test + fun removeProfile() = runTest { + val dataSource = createUserDataSource(userManager) + val users by collectLastValue(dataSource.users) + + assertWithMessage("collectLast(dataSource.users)").that(users).isNotNull() + val work = userState.createProfile(ProfileType.WORK) + assertThat(users).containsEntry(work, User(work.identifier, Role.WORK)) + + userState.removeProfile(work) + assertThat(users).doesNotContainEntry(work, User(work.identifier, Role.WORK)) + } + + @Test + fun isAvailable() = runTest { + val dataSource = createUserDataSource(userManager) + val work = userState.createProfile(ProfileType.WORK) + + val available by collectLastValue(dataSource.isAvailable(work)) + assertThat(available).isTrue() + + userState.setQuietMode(work, true) + assertThat(available).isFalse() + + userState.setQuietMode(work, false) + assertThat(available).isTrue() + } + + /** + * This and all the 'recovers_from_*' tests below all configure a static event flow instead of + * using [FakeUserManager]. These tests verify that a invalid broadcast causes the flow to + * reinitialize with the user profile group. + */ + @Test + fun recovers_from_invalid_profile_added_event() = runTest { + val userManager = + mockUserManager(validUser = UserHandle.USER_SYSTEM, invalidUser = USER_NULL) + val events = flowOf(UserEvent(ACTION_PROFILE_ADDED, UserHandle.of(USER_NULL))) + val dataSource = + UserDataSourceImpl( + profileParent = UserHandle.SYSTEM, + userManager = userManager, + userEvents = events, + scope = backgroundScope, + backgroundDispatcher = Dispatchers.Unconfined + ) + val users by collectLastValue(dataSource.users) + + assertWithMessage("collectLast(dataSource.users)").that(users).isNotNull() + assertThat(users) + .containsExactly(UserHandle.SYSTEM, User(UserHandle.USER_SYSTEM, Role.PERSONAL)) + } + + @Test + fun recovers_from_invalid_profile_removed_event() = runTest { + val userManager = + mockUserManager(validUser = UserHandle.USER_SYSTEM, invalidUser = USER_NULL) + val events = flowOf(UserEvent(ACTION_PROFILE_REMOVED, UserHandle.of(USER_NULL))) + val dataSource = + UserDataSourceImpl( + profileParent = UserHandle.SYSTEM, + userManager = userManager, + userEvents = events, + scope = backgroundScope, + backgroundDispatcher = Dispatchers.Unconfined + ) + val users by collectLastValue(dataSource.users) + + assertWithMessage("collectLast(dataSource.users)").that(users).isNotNull() + assertThat(users) + .containsExactly(UserHandle.SYSTEM, User(UserHandle.USER_SYSTEM, Role.PERSONAL)) + } + + @Test + fun recovers_from_invalid_profile_available_event() = runTest { + val userManager = + mockUserManager(validUser = UserHandle.USER_SYSTEM, invalidUser = USER_NULL) + val events = flowOf(UserEvent(ACTION_PROFILE_AVAILABLE, UserHandle.of(USER_NULL))) + val dataSource = + UserDataSourceImpl( + UserHandle.SYSTEM, + userManager, + events, + backgroundScope, + Dispatchers.Unconfined + ) + val users by collectLastValue(dataSource.users) + + assertWithMessage("collectLast(dataSource.users)").that(users).isNotNull() + assertThat(users) + .containsExactly(UserHandle.SYSTEM, User(UserHandle.USER_SYSTEM, Role.PERSONAL)) + } + + @Test + fun recovers_from_unknown_event() = runTest { + val userManager = + mockUserManager(validUser = UserHandle.USER_SYSTEM, invalidUser = USER_NULL) + val events = flowOf(UserEvent("UNKNOWN_EVENT", UserHandle.of(USER_NULL))) + val dataSource = + UserDataSourceImpl( + profileParent = UserHandle.SYSTEM, + userManager = userManager, + userEvents = events, + scope = backgroundScope, + backgroundDispatcher = Dispatchers.Unconfined + ) + val users by collectLastValue(dataSource.users) + + assertWithMessage("collectLast(dataSource.users)").that(users).isNotNull() + assertThat(users) + .containsExactly(UserHandle.SYSTEM, User(UserHandle.USER_SYSTEM, Role.PERSONAL)) + } +} + +@Suppress("SameParameterValue", "DEPRECATION") +private fun mockUserManager(validUser: Int, invalidUser: Int) = + mock { + val info = UserInfo(validUser, "", "", UserInfo.FLAG_FULL) + doReturn(listOf(info)).whenever(this).getEnabledProfiles(anyInt()) + + doReturn(info).whenever(this).getUserInfo(eq(validUser)) + + doReturn(listOf()).whenever(this).getEnabledProfiles(eq(invalidUser)) + + doReturn(null).whenever(this).getUserInfo(eq(invalidUser)) + } + +private fun TestScope.createUserDataSource(userManager: FakeUserManager) = + UserDataSourceImpl( + profileParent = userManager.state.primaryUserHandle, + userManager = userManager, + userEvents = userManager.state.userEvents, + scope = backgroundScope, + backgroundDispatcher = Dispatchers.Unconfined + ) diff --git a/java/tests/src/com/android/intentresolver/v2/platform/FakeUserManager.kt b/java/tests/src/com/android/intentresolver/v2/platform/FakeUserManager.kt new file mode 100644 index 00000000..ef1e5917 --- /dev/null +++ b/java/tests/src/com/android/intentresolver/v2/platform/FakeUserManager.kt @@ -0,0 +1,206 @@ +package com.android.intentresolver.v2.platform + +import android.content.Context +import android.content.Intent.ACTION_PROFILE_ADDED +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 +import android.content.pm.UserInfo.FLAG_PROFILE +import android.content.pm.UserInfo.NO_PROFILE_GROUP_ID +import android.os.IUserManager +import android.os.UserHandle +import android.os.UserManager +import com.android.intentresolver.THROWS_EXCEPTION +import com.android.intentresolver.mock +import com.android.intentresolver.v2.data.UserDataSourceImpl.UserEvent +import com.android.intentresolver.v2.platform.FakeUserManager.State +import com.android.intentresolver.whenever +import kotlin.random.Random +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.consumeAsFlow +import org.mockito.Mockito.RETURNS_SELF +import org.mockito.Mockito.doAnswer +import org.mockito.Mockito.doReturn +import org.mockito.Mockito.withSettings + +/** + * A stand-in for [UserManager] to support testing of data layer components which depend on it. + * + * This fake targets system applications which need to interact with any or all of the current + * user's associated profiles (as reported by [getEnabledProfiles]). Support for manipulating + * non-profile (full) secondary users (switching active foreground user, adding or removing users) + * is not included. + * + * Upon creation [FakeUserManager] contains a single primary (full) user with a randomized ID. This + * is available from [FakeUserManager.state] using [primaryUserHandle][State.primaryUserHandle] or + * [getPrimaryUser][State.getPrimaryUser]. + * + * To make state changes, use functions available from [FakeUserManager.state]: + * * [createProfile][State.createProfile] + * * [removeProfile][State.removeProfile] + * * [setQuietMode][State.setQuietMode] + * + * Any functionality not explicitly overridden here is guaranteed to throw an exception when + * accessed (access to the real system service is prevented). + */ +class FakeUserManager(val state: State = State()) : + UserManager(/* context = */ mockContext(), /* service = */ mockService()) { + + enum class ProfileType { + WORK, + CLONE, + PRIVATE + } + + override fun getProfileParent(userHandle: UserHandle): UserHandle? { + return state.getUserOrNull(userHandle)?.let { user -> + if (user.isProfile) { + state.getUserOrNull(UserHandle.of(user.profileGroupId))?.userHandle + } else { + null + } + } + } + + override fun getUserInfo(userId: Int): UserInfo? { + return state.getUserOrNull(UserHandle.of(userId)) + } + + @Suppress("OVERRIDE_DEPRECATION") + override fun getEnabledProfiles(userId: Int): List { + val user = state.users.single { it.id == userId } + return state.users.filter { other -> + user.id == other.id || user.profileGroupId == other.profileGroupId + } + } + + override fun isQuietModeEnabled(userHandle: UserHandle): Boolean { + return state.getUser(userHandle).isQuietModeEnabled + } + + override fun toString(): String { + return "FakeUserManager(state=$state)" + } + + class State { + private val eventChannel = Channel() + private val userInfoMap: MutableMap = mutableMapOf() + + /** The id of the primary/full/system user, which is automatically created. */ + val primaryUserHandle: UserHandle + + /** + * Retrieves the primary user. The value returned changes, but the values are immutable. + * + * Do not cache this value in tests, between operations. + */ + fun getPrimaryUser(): UserInfo = getUser(primaryUserHandle) + + private var nextUserId: Int = 100 + Random.nextInt(0, 900) + + /** + * A flow of [UserEvent] which emulates those normally generated from system broadcasts. + * + * Events are produced by calls to [createPrimaryUser], [createProfile], [removeProfile]. + */ + val userEvents: Flow + + val users: List + get() = userInfoMap.values.toList() + + val userHandles: List + get() = userInfoMap.keys.toList() + + init { + primaryUserHandle = createPrimaryUser(allocateNextId()) + userEvents = eventChannel.consumeAsFlow() + } + + private fun allocateNextId() = nextUserId++ + + private fun createPrimaryUser(id: Int): UserHandle { + val userInfo = + UserInfo(id, "", "", FLAG_INITIALIZED or FLAG_FULL, USER_TYPE_FULL_SYSTEM) + userInfoMap[userInfo.userHandle] = userInfo + return userInfo.userHandle + } + + fun getUserOrNull(handle: UserHandle): UserInfo? = userInfoMap[handle] + + fun getUser(handle: UserHandle): UserInfo = + requireNotNull(getUserOrNull(handle)) { + "Expected userInfoMap to contain an entry for $handle" + } + + fun setQuietMode(user: UserHandle, quietMode: Boolean) { + userInfoMap[user]?.also { it.flags = it.flags or UserInfo.FLAG_QUIET_MODE } + eventChannel.trySend(UserEvent(ACTION_PROFILE_UNAVAILABLE, user, quietMode)) + } + + fun createProfile(type: ProfileType, parent: UserHandle = primaryUserHandle): UserHandle { + val parentUser = getUser(parent) + require(!parentUser.isProfile) { "Parent user cannot be a profile" } + + // Ensure the parent user has a valid profileGroupId + if (parentUser.profileGroupId == NO_PROFILE_GROUP_ID) { + parentUser.profileGroupId = parentUser.id + } + val id = allocateNextId() + val userInfo = + UserInfo(id, "", "", FLAG_INITIALIZED or FLAG_PROFILE, type.toUserType()).apply { + profileGroupId = parentUser.profileGroupId + } + userInfoMap[userInfo.userHandle] = userInfo + eventChannel.trySend(UserEvent(ACTION_PROFILE_ADDED, userInfo.userHandle)) + return userInfo.userHandle + } + + fun removeProfile(handle: UserHandle): Boolean { + return userInfoMap[handle]?.let { user -> + require(user.isProfile) { "Only profiles can be removed" } + userInfoMap.remove(user.userHandle) + eventChannel.trySend(UserEvent(ACTION_PROFILE_REMOVED, user.userHandle)) + return true + } + ?: false + } + + override fun toString() = buildString { + append("State(nextUserId=$nextUserId, userInfoMap=[") + userInfoMap.entries.forEach { + append("UserHandle[${it.key.identifier}] = ${it.value.debugString},") + } + append("])") + } + } +} + +/** A safe mock of [Context] which throws on any unstubbed method call. */ +private fun mockContext(user: UserHandle = UserHandle.SYSTEM): Context { + return mock(withSettings().defaultAnswer(THROWS_EXCEPTION)) { + doAnswer(RETURNS_SELF).whenever(this).applicationContext + doReturn(user).whenever(this).user + doReturn(user.identifier).whenever(this).userId + } +} + +private fun FakeUserManager.ProfileType.toUserType(): String { + return when (this) { + FakeUserManager.ProfileType.WORK -> UserManager.USER_TYPE_PROFILE_MANAGED + FakeUserManager.ProfileType.CLONE -> UserManager.USER_TYPE_PROFILE_CLONE + FakeUserManager.ProfileType.PRIVATE -> UserManager.USER_TYPE_PROFILE_PRIVATE + } +} + +/** A safe mock of [IUserManager] which throws on any unstubbed method call. */ +fun mockService(): IUserManager { + return mock(withSettings().defaultAnswer(THROWS_EXCEPTION)) +} + +val UserInfo.debugString: String + get() = + "UserInfo(id=$id, profileGroupId=$profileGroupId, name=$name, " + + "type=$userType, flags=${UserInfo.flagsToString(flags)})" diff --git a/java/tests/src/com/android/intentresolver/v2/platform/FakeUserManagerTest.kt b/java/tests/src/com/android/intentresolver/v2/platform/FakeUserManagerTest.kt new file mode 100644 index 00000000..a2239192 --- /dev/null +++ b/java/tests/src/com/android/intentresolver/v2/platform/FakeUserManagerTest.kt @@ -0,0 +1,128 @@ +package com.android.intentresolver.v2.platform + +import android.content.pm.UserInfo +import android.content.pm.UserInfo.NO_PROFILE_GROUP_ID +import android.os.UserHandle +import android.os.UserManager +import com.android.intentresolver.v2.platform.FakeUserManager.ProfileType +import com.google.common.truth.Correspondence +import com.google.common.truth.Truth.assertThat +import com.google.common.truth.Truth.assertWithMessage +import org.junit.Assert.assertTrue +import org.junit.Test + +class FakeUserManagerTest { + private val userManager = FakeUserManager() + private val state = userManager.state + + @Test + fun initialState() { + val personal = userManager.getEnabledProfiles(state.primaryUserHandle.identifier).single() + + assertThat(personal.id).isEqualTo(state.primaryUserHandle.identifier) + assertThat(personal.userType).isEqualTo(UserManager.USER_TYPE_FULL_SYSTEM) + assertThat(personal.flags and UserInfo.FLAG_FULL).isEqualTo(UserInfo.FLAG_FULL) + } + + @Test + fun getProfileParent() { + val workHandle = state.createProfile(ProfileType.WORK) + + assertThat(userManager.getProfileParent(state.primaryUserHandle)).isNull() + assertThat(userManager.getProfileParent(workHandle)).isEqualTo(state.primaryUserHandle) + assertThat(userManager.getProfileParent(UserHandle.of(-1))).isNull() + } + + @Test + fun getUserInfo() { + val personalUser = + requireNotNull(userManager.getUserInfo(state.primaryUserHandle.identifier)) { + "Expected getUserInfo to return non-null" + } + assertTrue(userInfoAreEqual.apply(personalUser, state.getPrimaryUser())) + + val workHandle = state.createProfile(ProfileType.WORK) + + val workUser = + requireNotNull(userManager.getUserInfo(workHandle.identifier)) { + "Expected getUserInfo to return non-null" + } + assertTrue( + userInfoAreEqual.apply(workUser, userManager.getUserInfo(workHandle.identifier)!!) + ) + } + + @Test + fun getEnabledProfiles_usingParentId() { + val personal = state.primaryUserHandle + val work = state.createProfile(ProfileType.WORK) + val private = state.createProfile(ProfileType.PRIVATE) + + val enabledProfiles = userManager.getEnabledProfiles(personal.identifier) + + assertWithMessage("enabledProfiles: List") + .that(enabledProfiles) + .comparingElementsUsing(userInfoEquality) + .displayingDiffsPairedBy { it.id } + .containsExactly(state.getPrimaryUser(), state.getUser(work), state.getUser(private)) + } + + @Test + fun getEnabledProfiles_usingProfileId() { + val clone = state.createProfile(ProfileType.CLONE) + + val enabledProfiles = userManager.getEnabledProfiles(clone.identifier) + + assertWithMessage("getEnabledProfiles(clone.identifier)") + .that(enabledProfiles) + .comparingElementsUsing(userInfoEquality) + .displayingDiffsPairedBy { it.id } + .containsExactly(state.getPrimaryUser(), state.getUser(clone)) + } + + @Test + fun getUserOrNull() { + val personal = state.getPrimaryUser() + + assertThat(state.getUserOrNull(personal.userHandle)).isEqualTo(personal) + assertThat(state.getUserOrNull(UserHandle.of(personal.id - 1))).isNull() + } + + @Test + fun createProfile() { + // Order dependent: profile creation modifies the primary user + val workHandle = state.createProfile(ProfileType.WORK) + + val primaryUser = state.getPrimaryUser() + val workUser = state.getUser(workHandle) + + assertThat(primaryUser.profileGroupId).isNotEqualTo(NO_PROFILE_GROUP_ID) + assertThat(workUser.profileGroupId).isEqualTo(primaryUser.profileGroupId) + } + + @Test + fun removeProfile() { + val personal = state.getPrimaryUser() + val work = state.createProfile(ProfileType.WORK) + val private = state.createProfile(ProfileType.PRIVATE) + + state.removeProfile(private) + assertThat(state.userHandles).containsExactly(personal.userHandle, work) + } + + @Test(expected = IllegalArgumentException::class) + fun removeProfile_primaryNotAllowed() { + state.removeProfile(state.primaryUserHandle) + } +} + +private val userInfoAreEqual = + Correspondence.BinaryPredicate { actual, expected -> + actual.id == expected.id && + actual.profileGroupId == expected.profileGroupId && + actual.userType == expected.userType && + actual.flags == expected.flags + } + +val userInfoEquality: Correspondence = + Correspondence.from(userInfoAreEqual, "==") -- cgit v1.2.3-59-g8ed1b From 7b3cb6c1cee654303e0644139c636e8c36755ded Mon Sep 17 00:00:00 2001 From: Mark Renouf Date: Mon, 18 Sep 2023 17:04:02 -0400 Subject: Unify annotations packages, fix missing nullability info Automated cleanups: use androidx.annotation.* consistently across the codebase. corrects missing nullability annotations method parameters, constructors and and overridden methods return values. Test: atest IntentResolverUnitTests Bug: 300157408 Change-Id: Id48b7ce3e70400bd8ff5d51dabc77e8e04bbfc5c --- .../intentresolver/AnnotatedUserHandles.java | 14 +++---- .../intentresolver/ChooserActionFactory.java | 3 +- .../android/intentresolver/ChooserActivity.java | 12 +++--- .../ChooserIntegratedDeviceComponents.java | 8 ++-- .../android/intentresolver/ChooserListAdapter.java | 2 +- .../ChooserRecyclerViewAccessibilityDelegate.java | 2 +- .../intentresolver/ChooserRefinementManager.java | 5 ++- .../intentresolver/ChooserRequestParameters.java | 13 ++++--- .../ChooserStackedAppDialogFragment.java | 2 + .../ChooserTargetActionsDialogFragment.java | 4 +- .../intentresolver/IntentForwarderActivity.java | 3 +- .../intentresolver/MultiProfilePagerAdapter.java | 10 +++-- .../intentresolver/ResolvedComponentInfo.java | 4 +- .../android/intentresolver/ResolverActivity.java | 13 ++++--- .../intentresolver/ResolverListAdapter.java | 4 +- .../intentresolver/ResolverListController.java | 3 +- .../intentresolver/ShortcutSelectionLogic.java | 3 +- .../android/intentresolver/SimpleIconFactory.java | 19 ++++++--- .../intentresolver/TargetPresentationGetter.java | 5 ++- .../intentresolver/chooser/ChooserTargetInfo.java | 2 + .../intentresolver/chooser/DisplayResolveInfo.java | 5 ++- .../chooser/ImmutableTargetInfo.java | 19 ++++----- .../chooser/NotSelectableTargetInfo.java | 3 +- .../chooser/SelectableTargetInfo.java | 3 +- .../android/intentresolver/chooser/TargetInfo.java | 19 ++++++--- .../contentpreview/ContentPreviewType.java | 2 +- .../contentpreview/HeadlineGeneratorImpl.kt | 45 ++++++++++++++++------ .../NoAppsAvailableEmptyStateProvider.java | 19 +++++---- .../NoCrossProfileEmptyStateProvider.java | 17 +++++--- .../WorkProfilePausedEmptyStateProvider.java | 5 ++- .../intentresolver/grid/ChooserGridAdapter.java | 5 ++- .../icons/LoadDirectShareIconTask.java | 2 +- .../intentresolver/logging/EventLogImpl.java | 5 +-- .../model/AbstractResolverComparator.java | 3 +- .../AppPredictionServiceResolverComparator.java | 3 +- .../ResolverRankerServiceResolverComparator.java | 3 +- .../ShortcutToChooserTargetConverter.java | 5 ++- .../intentresolver/v2/ChooserActionFactory.java | 4 +- .../android/intentresolver/v2/ChooserActivity.java | 23 +++-------- .../v2/ChooserMultiProfilePagerAdapter.java | 1 + .../v2/MultiProfilePagerAdapter.java | 16 +++++--- .../intentresolver/v2/ResolverActivity.java | 6 +-- .../NoAppsAvailableEmptyStateProvider.java | 22 ++++++----- .../NoCrossProfileEmptyStateProvider.java | 15 ++++---- .../WorkProfilePausedEmptyStateProvider.java | 5 ++- .../widget/ResolverDrawerLayout.java | 5 ++- .../intentresolver/ChooserWrapperActivity.java | 4 +- .../android/intentresolver/IChooserWrapper.java | 4 +- .../intentresolver/ResolverDataProvider.java | 14 +++++-- .../intentresolver/ResolverWrapperActivity.java | 9 +++-- 50 files changed, 255 insertions(+), 167 deletions(-) (limited to 'java/src') diff --git a/java/src/com/android/intentresolver/AnnotatedUserHandles.java b/java/src/com/android/intentresolver/AnnotatedUserHandles.java index 5d559f5b..3565e757 100644 --- a/java/src/com/android/intentresolver/AnnotatedUserHandles.java +++ b/java/src/com/android/intentresolver/AnnotatedUserHandles.java @@ -16,12 +16,12 @@ package com.android.intentresolver; -import android.annotation.Nullable; import android.app.Activity; import android.app.ActivityManager; import android.os.UserHandle; import android.os.UserManager; +import androidx.annotation.Nullable; import androidx.annotation.VisibleForTesting; /** @@ -35,7 +35,7 @@ public final class AnnotatedUserHandles { /** * The {@link UserHandle} that launched Sharesheet. * TODO: I believe this would always be the handle corresponding to {@code userIdOfCallingApp} - * except possibly if the caller used {@link Activity#startActivityAsUser()} to launch + * except possibly if the caller used {@link Activity#startActivityAsUser} to launch * Sharesheet as a different user than they themselves were running as. Verify and document. */ public final UserHandle userHandleSharesheetLaunchedAs; @@ -57,21 +57,21 @@ public final class AnnotatedUserHandles { /** * The {@link UserHandle} that owns the "work tab" in a tabbed share UI. This is (an arbitrary) - * one of the "managed" profiles associated with {@link personalProfileUserHandle}. + * one of the "managed" profiles associated with {@link #personalProfileUserHandle}. */ @Nullable public final UserHandle workProfileUserHandle; /** - * The {@link UserHandle} of the clone profile belonging to {@link personalProfileUserHandle}. + * The {@link UserHandle} of the clone profile belonging to {@link #personalProfileUserHandle}. */ @Nullable public final UserHandle cloneProfileUserHandle; /** - * The "tab owner" user handle (i.e., either {@link personalProfileUserHandle} or - * {@link workProfileUserHandle}) that either matches or owns the profile of the - * {@link userHandleSharesheetLaunchedAs}. + * The "tab owner" user handle (i.e., either {@link #personalProfileUserHandle} or + * {@link #workProfileUserHandle}) that either matches or owns the profile of the + * {@link #userHandleSharesheetLaunchedAs}. * * In the current implementation, we can assert that this is the same as * `userHandleSharesheetLaunchedAs` except when the latter is the clone profile; then this is diff --git a/java/src/com/android/intentresolver/ChooserActionFactory.java b/java/src/com/android/intentresolver/ChooserActionFactory.java index c7c0beeb..310fcc27 100644 --- a/java/src/com/android/intentresolver/ChooserActionFactory.java +++ b/java/src/com/android/intentresolver/ChooserActionFactory.java @@ -16,7 +16,6 @@ package com.android.intentresolver; -import android.annotation.Nullable; import android.app.Activity; import android.app.ActivityOptions; import android.app.PendingIntent; @@ -34,6 +33,8 @@ import android.text.TextUtils; import android.util.Log; import android.view.View; +import androidx.annotation.Nullable; + import com.android.intentresolver.chooser.DisplayResolveInfo; import com.android.intentresolver.chooser.TargetInfo; import com.android.intentresolver.contentpreview.ChooserContentPreviewUi; diff --git a/java/src/com/android/intentresolver/ChooserActivity.java b/java/src/com/android/intentresolver/ChooserActivity.java index 707c64b7..2f950baa 100644 --- a/java/src/com/android/intentresolver/ChooserActivity.java +++ b/java/src/com/android/intentresolver/ChooserActivity.java @@ -28,8 +28,6 @@ import static androidx.lifecycle.LifecycleKt.getCoroutineScope; import static com.android.internal.util.LatencyTracker.ACTION_LOAD_SHARE_SHEET; -import android.annotation.IntDef; -import android.annotation.Nullable; import android.app.Activity; import android.app.ActivityManager; import android.app.ActivityOptions; @@ -67,8 +65,10 @@ import android.view.ViewTreeObserver; import android.view.WindowInsets; import android.widget.TextView; +import androidx.annotation.IntDef; import androidx.annotation.MainThread; import androidx.annotation.NonNull; +import androidx.annotation.Nullable; import androidx.lifecycle.ViewModelProvider; import androidx.recyclerview.widget.GridLayoutManager; import androidx.recyclerview.widget.RecyclerView; @@ -165,7 +165,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; - @IntDef(flag = false, prefix = { "TARGET_TYPE_" }, value = { + @IntDef({ TARGET_TYPE_DEFAULT, TARGET_TYPE_CHOOSER_TARGET, TARGET_TYPE_SHORTCUTS_FROM_SHORTCUT_MANAGER, @@ -605,7 +605,7 @@ public class ChooserActivity extends Hilt_ChooserActivity implements } @Override - public void onConfigurationChanged(Configuration newConfig) { + public void onConfigurationChanged(@NonNull Configuration newConfig) { super.onConfigurationChanged(newConfig); ViewPager viewPager = findViewById(com.android.internal.R.id.profile_pager); if (viewPager.isLayoutRtl()) { @@ -1592,7 +1592,7 @@ public class ChooserActivity extends Hilt_ChooserActivity implements mChooserMultiProfilePagerAdapter.getActiveAdapterView().addOnScrollListener( new RecyclerView.OnScrollListener() { @Override - public void onScrollStateChanged(RecyclerView view, int scrollState) { + public void onScrollStateChanged(@NonNull RecyclerView view, int scrollState) { if (scrollState == RecyclerView.SCROLL_STATE_IDLE) { if (mScrollStatus == SCROLL_STATUS_SCROLLING_VERTICAL) { mScrollStatus = SCROLL_STATUS_IDLE; @@ -1607,7 +1607,7 @@ public class ChooserActivity extends Hilt_ChooserActivity implements } @Override - public void onScrolled(RecyclerView view, int dx, int dy) { + public void onScrolled(@NonNull RecyclerView view, int dx, int dy) { if (view.getChildCount() > 0) { View child = view.getLayoutManager().findViewByPosition(0); if (child == null || child.getTop() < 0) { diff --git a/java/src/com/android/intentresolver/ChooserIntegratedDeviceComponents.java b/java/src/com/android/intentresolver/ChooserIntegratedDeviceComponents.java index df5a8dc8..7cd86bf4 100644 --- a/java/src/com/android/intentresolver/ChooserIntegratedDeviceComponents.java +++ b/java/src/com/android/intentresolver/ChooserIntegratedDeviceComponents.java @@ -16,12 +16,13 @@ package com.android.intentresolver; -import android.annotation.Nullable; import android.content.ComponentName; import android.content.Context; import android.provider.Settings; import android.text.TextUtils; +import androidx.annotation.Nullable; + import com.android.internal.annotations.VisibleForTesting; /** @@ -49,8 +50,9 @@ public class ChooserIntegratedDeviceComponents { } @VisibleForTesting - public ChooserIntegratedDeviceComponents( - ComponentName editSharingComponent, ComponentName nearbySharingComponent) { + ChooserIntegratedDeviceComponents( + @Nullable ComponentName editSharingComponent, + @Nullable ComponentName nearbySharingComponent) { mEditSharingComponent = editSharingComponent; mNearbySharingComponent = nearbySharingComponent; } diff --git a/java/src/com/android/intentresolver/ChooserListAdapter.java b/java/src/com/android/intentresolver/ChooserListAdapter.java index 9a15c919..823b5e13 100644 --- a/java/src/com/android/intentresolver/ChooserListAdapter.java +++ b/java/src/com/android/intentresolver/ChooserListAdapter.java @@ -19,7 +19,6 @@ package com.android.intentresolver; import static com.android.intentresolver.ChooserActivity.TARGET_TYPE_SHORTCUTS_FROM_PREDICTION_SERVICE; import static com.android.intentresolver.ChooserActivity.TARGET_TYPE_SHORTCUTS_FROM_SHORTCUT_MANAGER; -import android.annotation.Nullable; import android.app.ActivityManager; import android.app.prediction.AppTarget; import android.content.ComponentName; @@ -45,6 +44,7 @@ import android.view.ViewGroup; import android.widget.TextView; import androidx.annotation.MainThread; +import androidx.annotation.Nullable; import androidx.annotation.WorkerThread; import com.android.intentresolver.chooser.DisplayResolveInfo; diff --git a/java/src/com/android/intentresolver/ChooserRecyclerViewAccessibilityDelegate.java b/java/src/com/android/intentresolver/ChooserRecyclerViewAccessibilityDelegate.java index 3f6a3437..d6688d90 100644 --- a/java/src/com/android/intentresolver/ChooserRecyclerViewAccessibilityDelegate.java +++ b/java/src/com/android/intentresolver/ChooserRecyclerViewAccessibilityDelegate.java @@ -16,12 +16,12 @@ package com.android.intentresolver; -import android.annotation.NonNull; import android.graphics.Rect; import android.view.View; import android.view.ViewGroup; import android.view.accessibility.AccessibilityEvent; +import androidx.annotation.NonNull; import androidx.recyclerview.widget.RecyclerView; import androidx.recyclerview.widget.RecyclerViewAccessibilityDelegate; diff --git a/java/src/com/android/intentresolver/ChooserRefinementManager.java b/java/src/com/android/intentresolver/ChooserRefinementManager.java index b3b08de7..474b240f 100644 --- a/java/src/com/android/intentresolver/ChooserRefinementManager.java +++ b/java/src/com/android/intentresolver/ChooserRefinementManager.java @@ -16,8 +16,6 @@ package com.android.intentresolver; -import android.annotation.Nullable; -import android.annotation.UiThread; import android.app.Activity; import android.app.Application; import android.content.Intent; @@ -28,6 +26,8 @@ import android.os.Parcel; import android.os.ResultReceiver; import android.util.Log; +import androidx.annotation.Nullable; +import androidx.annotation.UiThread; import androidx.lifecycle.LiveData; import androidx.lifecycle.MutableLiveData; import androidx.lifecycle.ViewModel; @@ -41,6 +41,7 @@ import java.util.function.Consumer; import javax.inject.Inject; + /** * Helper class to manage Sharesheet's "refinement" flow, where callers supply a "refinement * activity" that will be invoked when a target is selected, allowing the calling app to add diff --git a/java/src/com/android/intentresolver/ChooserRequestParameters.java b/java/src/com/android/intentresolver/ChooserRequestParameters.java index b05d51b2..7ad809e9 100644 --- a/java/src/com/android/intentresolver/ChooserRequestParameters.java +++ b/java/src/com/android/intentresolver/ChooserRequestParameters.java @@ -16,8 +16,6 @@ package com.android.intentresolver; -import android.annotation.NonNull; -import android.annotation.Nullable; import android.content.ComponentName; import android.content.Intent; import android.content.IntentFilter; @@ -32,6 +30,9 @@ import android.text.TextUtils; import android.util.Log; import android.util.Pair; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + import com.android.intentresolver.util.UriFilters; import com.google.common.collect.ImmutableList; @@ -210,7 +211,7 @@ public class ChooserRequestParameters { /** * TODO: this returns a nullable array for convenience, but if the legacy APIs can be - * refactored, returning {@link mAdditionalTargets} directly is simpler and safer. + * refactored, returning {@link #mAdditionalTargets} directly is simpler and safer. */ @Nullable public Intent[] getAdditionalTargets() { @@ -224,7 +225,7 @@ public class ChooserRequestParameters { /** * TODO: this returns a nullable array for convenience, but if the legacy APIs can be - * refactored, returning {@link mInitialIntents} directly is simpler and safer. + * refactored, returning {@link #mInitialIntents} directly is simpler and safer. */ @Nullable public Intent[] getInitialIntents() { @@ -286,7 +287,7 @@ public class ChooserRequestParameters { * requested target wasn't a send action; otherwise it is null. The second value is * the resource ID of a default title string; this is nonzero only if the first value is null. * - * TODO: change the API for how these are passed up to {@link ResolverActivity#onCreate()}, or + * TODO: change the API for how these are passed up to {@link ResolverActivity#onCreate}, or * create a real type (not {@link Pair}) to express the semantics described in this comment. */ private static Pair makeTitleSpec( @@ -369,7 +370,7 @@ public class ChooserRequestParameters { * the required type. If false, throw an {@link IllegalArgumentException} if the extra is * non-null but can't be assigned to variables of type {@code T}. * @param streamEmptyIfNull Whether to return an empty stream if the optional extra isn't - * present in the intent (or if it had the wrong type, but {@link warnOnTypeError} is true). + * present in the intent (or if it had the wrong type, but warnOnTypeError is true). * If false, return null in these cases, and only return an empty stream if the intent * explicitly provided an empty array for the specified extra. */ diff --git a/java/src/com/android/intentresolver/ChooserStackedAppDialogFragment.java b/java/src/com/android/intentresolver/ChooserStackedAppDialogFragment.java index 2cfceeae..f0fcd149 100644 --- a/java/src/com/android/intentresolver/ChooserStackedAppDialogFragment.java +++ b/java/src/com/android/intentresolver/ChooserStackedAppDialogFragment.java @@ -22,6 +22,7 @@ import android.content.pm.PackageManager; import android.graphics.drawable.Drawable; import android.os.UserHandle; +import androidx.annotation.NonNull; import androidx.fragment.app.FragmentManager; import com.android.intentresolver.chooser.DisplayResolveInfo; @@ -66,6 +67,7 @@ public class ChooserStackedAppDialogFragment extends ChooserTargetActionsDialogF dismiss(); } + @NonNull @Override protected CharSequence getItemLabel(DisplayResolveInfo dri) { final PackageManager pm = getContext().getPackageManager(); diff --git a/java/src/com/android/intentresolver/ChooserTargetActionsDialogFragment.java b/java/src/com/android/intentresolver/ChooserTargetActionsDialogFragment.java index 4bfb21aa..b6b7de96 100644 --- a/java/src/com/android/intentresolver/ChooserTargetActionsDialogFragment.java +++ b/java/src/com/android/intentresolver/ChooserTargetActionsDialogFragment.java @@ -21,8 +21,6 @@ import static android.content.Context.ACTIVITY_SERVICE; import static java.util.stream.Collectors.toList; -import android.annotation.NonNull; -import android.annotation.Nullable; import android.app.ActivityManager; import android.app.Dialog; import android.content.ComponentName; @@ -46,6 +44,8 @@ import android.view.ViewGroup; import android.widget.ImageView; import android.widget.TextView; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; import androidx.fragment.app.DialogFragment; import androidx.fragment.app.FragmentManager; import androidx.recyclerview.widget.RecyclerView; diff --git a/java/src/com/android/intentresolver/IntentForwarderActivity.java b/java/src/com/android/intentresolver/IntentForwarderActivity.java index acee1316..15996d00 100644 --- a/java/src/com/android/intentresolver/IntentForwarderActivity.java +++ b/java/src/com/android/intentresolver/IntentForwarderActivity.java @@ -23,7 +23,6 @@ import static android.content.pm.PackageManager.MATCH_DEFAULT_ONLY; import static com.android.intentresolver.ResolverActivity.EXTRA_CALLING_USER; import static com.android.intentresolver.ResolverActivity.EXTRA_SELECTED_PROFILE; -import android.annotation.Nullable; import android.app.Activity; import android.app.ActivityThread; import android.app.AppGlobals; @@ -45,6 +44,8 @@ import android.provider.Settings; import android.util.Slog; import android.widget.Toast; +import androidx.annotation.Nullable; + import com.android.internal.annotations.VisibleForTesting; import com.android.internal.logging.MetricsLogger; import com.android.internal.logging.nano.MetricsProto.MetricsEvent; diff --git a/java/src/com/android/intentresolver/MultiProfilePagerAdapter.java b/java/src/com/android/intentresolver/MultiProfilePagerAdapter.java index 8ce42b28..42a29e55 100644 --- a/java/src/com/android/intentresolver/MultiProfilePagerAdapter.java +++ b/java/src/com/android/intentresolver/MultiProfilePagerAdapter.java @@ -15,8 +15,6 @@ */ package com.android.intentresolver; -import android.annotation.IntDef; -import android.annotation.Nullable; import android.os.Trace; import android.os.UserHandle; import android.view.View; @@ -24,6 +22,9 @@ import android.view.ViewGroup; import android.widget.Button; import android.widget.TextView; +import androidx.annotation.IntDef; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; import androidx.viewpager.widget.PagerAdapter; import androidx.viewpager.widget.ViewPager; @@ -179,6 +180,7 @@ public class MultiProfilePagerAdapter< mLoadedPages.remove(1 - mCurrentPage); } + @NonNull @Override public final ViewGroup instantiateItem(ViewGroup container, int position) { setupListAdapter(position); @@ -188,7 +190,7 @@ public class MultiProfilePagerAdapter< } @Override - public void destroyItem(ViewGroup container, int position, Object view) { + public void destroyItem(ViewGroup container, int position, @NonNull Object view) { container.removeView((View) view); } @@ -207,7 +209,7 @@ public class MultiProfilePagerAdapter< } @Override - public boolean isViewFromObject(View view, Object object) { + public boolean isViewFromObject(@NonNull View view, @NonNull Object object) { return view == object; } diff --git a/java/src/com/android/intentresolver/ResolvedComponentInfo.java b/java/src/com/android/intentresolver/ResolvedComponentInfo.java index ecb72cbf..aaa97c42 100644 --- a/java/src/com/android/intentresolver/ResolvedComponentInfo.java +++ b/java/src/com/android/intentresolver/ResolvedComponentInfo.java @@ -20,6 +20,8 @@ import android.content.ComponentName; import android.content.Intent; import android.content.pm.ResolveInfo; +import com.android.intentresolver.chooser.TargetInfo; + import java.util.ArrayList; import java.util.List; @@ -86,7 +88,7 @@ public final class ResolvedComponentInfo { } /** - * @return whether this component was pinned by a call to {@link #setPinned()}. + * @return whether this component was pinned by a call to {@link #setPinned}. * TODO: consolidate sources of pinning data and/or document how this differs from other places * we make a "pinning" determination. */ diff --git a/java/src/com/android/intentresolver/ResolverActivity.java b/java/src/com/android/intentresolver/ResolverActivity.java index aa9d051c..0331c33e 100644 --- a/java/src/com/android/intentresolver/ResolverActivity.java +++ b/java/src/com/android/intentresolver/ResolverActivity.java @@ -36,9 +36,6 @@ import static android.view.WindowManager.LayoutParams.SYSTEM_FLAG_HIDE_NON_SYSTE import static com.android.internal.annotations.VisibleForTesting.Visibility.PROTECTED; -import android.annotation.Nullable; -import android.annotation.StringRes; -import android.annotation.UiThread; import android.app.Activity; import android.app.ActivityManager; import android.app.ActivityThread; @@ -96,6 +93,10 @@ import android.widget.TabWidget; import android.widget.TextView; import android.widget.Toast; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.annotation.StringRes; +import androidx.annotation.UiThread; import androidx.fragment.app.FragmentActivity; import androidx.viewpager.widget.ViewPager; @@ -612,7 +613,7 @@ public class ResolverActivity extends FragmentActivity implements } @Override - public void onConfigurationChanged(Configuration newConfig) { + public void onConfigurationChanged(@NonNull Configuration newConfig) { super.onConfigurationChanged(newConfig); mMultiProfilePagerAdapter.getActiveListAdapter().handlePackagesChanged(); if (mIsIntentPicker && shouldShowTabs() && !useLayoutWithDefault() @@ -1528,7 +1529,7 @@ public class ResolverActivity extends FragmentActivity implements } @Override - protected final void onSaveInstanceState(Bundle outState) { + protected final void onSaveInstanceState(@NonNull Bundle outState) { super.onSaveInstanceState(outState); ViewPager viewPager = findViewById(com.android.internal.R.id.profile_pager); if (viewPager != null) { @@ -1537,7 +1538,7 @@ public class ResolverActivity extends FragmentActivity implements } @Override - protected final void onRestoreInstanceState(Bundle savedInstanceState) { + protected final void onRestoreInstanceState(@NonNull Bundle savedInstanceState) { super.onRestoreInstanceState(savedInstanceState); resetButtonBar(); ViewPager viewPager = findViewById(com.android.internal.R.id.profile_pager); diff --git a/java/src/com/android/intentresolver/ResolverListAdapter.java b/java/src/com/android/intentresolver/ResolverListAdapter.java index 6c17df1d..8f1f9275 100644 --- a/java/src/com/android/intentresolver/ResolverListAdapter.java +++ b/java/src/com/android/intentresolver/ResolverListAdapter.java @@ -16,8 +16,6 @@ package com.android.intentresolver; -import android.annotation.NonNull; -import android.annotation.Nullable; import android.content.Context; import android.content.Intent; import android.content.pm.ActivityInfo; @@ -43,6 +41,8 @@ import android.widget.ImageView; import android.widget.TextView; import androidx.annotation.MainThread; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; import androidx.annotation.WorkerThread; import com.android.intentresolver.chooser.DisplayResolveInfo; diff --git a/java/src/com/android/intentresolver/ResolverListController.java b/java/src/com/android/intentresolver/ResolverListController.java index 05121576..e88d766d 100644 --- a/java/src/com/android/intentresolver/ResolverListController.java +++ b/java/src/com/android/intentresolver/ResolverListController.java @@ -17,7 +17,6 @@ package com.android.intentresolver; -import android.annotation.WorkerThread; import android.app.ActivityManager; import android.app.AppGlobals; import android.content.ComponentName; @@ -31,6 +30,8 @@ import android.os.RemoteException; import android.os.UserHandle; import android.util.Log; +import androidx.annotation.WorkerThread; + import com.android.intentresolver.chooser.DisplayResolveInfo; import com.android.intentresolver.chooser.TargetInfo; import com.android.intentresolver.model.AbstractResolverComparator; diff --git a/java/src/com/android/intentresolver/ShortcutSelectionLogic.java b/java/src/com/android/intentresolver/ShortcutSelectionLogic.java index 645b9391..efaaf894 100644 --- a/java/src/com/android/intentresolver/ShortcutSelectionLogic.java +++ b/java/src/com/android/intentresolver/ShortcutSelectionLogic.java @@ -16,7 +16,6 @@ package com.android.intentresolver; -import android.annotation.Nullable; import android.app.prediction.AppTarget; import android.content.Context; import android.content.Intent; @@ -26,6 +25,8 @@ import android.content.pm.ShortcutInfo; import android.service.chooser.ChooserTarget; import android.util.Log; +import androidx.annotation.Nullable; + import com.android.intentresolver.chooser.DisplayResolveInfo; import com.android.intentresolver.chooser.SelectableTargetInfo; import com.android.intentresolver.chooser.TargetInfo; diff --git a/java/src/com/android/intentresolver/SimpleIconFactory.java b/java/src/com/android/intentresolver/SimpleIconFactory.java index ec5179ac..750b24ac 100644 --- a/java/src/com/android/intentresolver/SimpleIconFactory.java +++ b/java/src/com/android/intentresolver/SimpleIconFactory.java @@ -21,9 +21,6 @@ import static android.graphics.Paint.DITHER_FLAG; import static android.graphics.Paint.FILTER_BITMAP_FLAG; import static android.graphics.drawable.AdaptiveIconDrawable.getExtraInsetFraction; -import android.annotation.AttrRes; -import android.annotation.NonNull; -import android.annotation.Nullable; import android.app.ActivityManager; import android.content.Context; import android.content.pm.PackageManager; @@ -50,6 +47,10 @@ import android.util.AttributeSet; import android.util.Pools.SynchronizedPool; import android.util.TypedValue; +import androidx.annotation.AttrRes; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + import com.android.internal.annotations.VisibleForTesting; import org.xmlpull.v1.XmlPullParser; @@ -719,10 +720,18 @@ public class SimpleIconFactory { } @Override - public void inflate(Resources r, XmlPullParser parser, AttributeSet attrs) { } + public void inflate( + @NonNull Resources r, + @NonNull XmlPullParser parser, + @NonNull AttributeSet attrs) { + } @Override - public void inflate(Resources r, XmlPullParser parser, AttributeSet attrs, Theme theme) { } + public void inflate( + @NonNull Resources r, + @NonNull XmlPullParser parser, + @NonNull AttributeSet attrs, Theme theme) { + } /** * Sets the scale associated with this drawable diff --git a/java/src/com/android/intentresolver/TargetPresentationGetter.java b/java/src/com/android/intentresolver/TargetPresentationGetter.java index f8b36566..910c65c9 100644 --- a/java/src/com/android/intentresolver/TargetPresentationGetter.java +++ b/java/src/com/android/intentresolver/TargetPresentationGetter.java @@ -16,7 +16,6 @@ package com.android.intentresolver; -import android.annotation.Nullable; import android.content.Context; import android.content.pm.ActivityInfo; import android.content.pm.ApplicationInfo; @@ -30,6 +29,8 @@ import android.os.UserHandle; import android.text.TextUtils; import android.util.Log; +import androidx.annotation.Nullable; + /** * Loads the icon and label for the provided ApplicationInfo. Defaults to using the application icon * and label over any IntentFilter or Activity icon to increase user understanding, with an @@ -37,7 +38,7 @@ import android.util.Log; * resources over PackageManager loading mechanisms so badging can be done by iconloader. Uses * Strings to strip creative formatting. * - * Use one of the {@link TargetPresentationGetter#Factory} methods to create an instance of the + * Use one of the {@link TargetPresentationGetter.Factory} methods to create an instance of the * appropriate concrete type. * * TODO: once this component (and its tests) are merged, it should be possible to refactor and diff --git a/java/src/com/android/intentresolver/chooser/ChooserTargetInfo.java b/java/src/com/android/intentresolver/chooser/ChooserTargetInfo.java index 8b9bfb32..074537ef 100644 --- a/java/src/com/android/intentresolver/chooser/ChooserTargetInfo.java +++ b/java/src/com/android/intentresolver/chooser/ChooserTargetInfo.java @@ -16,6 +16,8 @@ package com.android.intentresolver.chooser; +import android.service.chooser.ChooserTarget; + import java.util.ArrayList; import java.util.Arrays; diff --git a/java/src/com/android/intentresolver/chooser/DisplayResolveInfo.java b/java/src/com/android/intentresolver/chooser/DisplayResolveInfo.java index 866da5f6..536f11ce 100644 --- a/java/src/com/android/intentresolver/chooser/DisplayResolveInfo.java +++ b/java/src/com/android/intentresolver/chooser/DisplayResolveInfo.java @@ -16,8 +16,6 @@ package com.android.intentresolver.chooser; -import android.annotation.NonNull; -import android.annotation.Nullable; import android.app.Activity; import android.content.ComponentName; import android.content.Intent; @@ -27,6 +25,9 @@ import android.content.pm.ResolveInfo; import android.os.Bundle; import android.os.UserHandle; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + import java.util.ArrayList; import java.util.List; diff --git a/java/src/com/android/intentresolver/chooser/ImmutableTargetInfo.java b/java/src/com/android/intentresolver/chooser/ImmutableTargetInfo.java index 10d4415a..50aaec0b 100644 --- a/java/src/com/android/intentresolver/chooser/ImmutableTargetInfo.java +++ b/java/src/com/android/intentresolver/chooser/ImmutableTargetInfo.java @@ -16,8 +16,6 @@ package com.android.intentresolver.chooser; -import android.annotation.NonNull; -import android.annotation.Nullable; import android.app.Activity; import android.app.prediction.AppTarget; import android.content.ComponentName; @@ -27,8 +25,11 @@ import android.content.pm.ResolveInfo; import android.content.pm.ShortcutInfo; import android.os.Bundle; import android.os.UserHandle; +import android.service.chooser.ChooserTarget; import android.util.HashedStringCache; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; import androidx.annotation.VisibleForTesting; import com.google.common.collect.ImmutableList; @@ -43,7 +44,7 @@ import java.util.List; public final class ImmutableTargetInfo implements TargetInfo { private static final String TAG = "TargetInfo"; - /** Delegate interface to implement {@link TargetInfo#getHashedTargetIdForMetrics()}. */ + /** Delegate interface to implement {@link TargetInfo#getHashedTargetIdForMetrics}. */ public interface TargetHashProvider { /** Request a hash for the specified {@code target}. */ HashedStringCache.HashResult getHashedTargetIdForMetrics( @@ -53,15 +54,15 @@ public final class ImmutableTargetInfo implements TargetInfo { /** Delegate interface to request that the target be launched by a particular API. */ public interface TargetActivityStarter { /** - * Request that the delegate use the {@link Activity#startAsCaller()} API to launch the - * specified {@code target}. + * Request that the delegate use the {@link Activity#startActivityAsCaller} API to launch + * the specified {@code target}. * * @return true if the target was launched successfully. */ boolean startAsCaller(TargetInfo target, Activity activity, Bundle options, int userId); /** - * Request that the delegate use the {@link Activity#startAsUser()} API to launch the + * Request that the delegate use the {@link Activity#startActivityAsUser} API to launch the * specified {@code target}. * * @return true if the target was launched successfully. @@ -145,7 +146,7 @@ public final class ImmutableTargetInfo implements TargetInfo { /** * Configure an {@link Intent} to be built in to the output target as the "base intent to * send," which may be a refinement of any of our source targets. This is private because - * it's only used internally by {@link #tryToCloneWithAppliedRefinement()}; if it's ever + * it's only used internally by {@link #tryToCloneWithAppliedRefinement}; if it's ever * expanded, the builder should probably be responsible for enforcing the refinement check. */ private Builder setBaseIntentToSend(Intent baseIntent) { @@ -229,8 +230,8 @@ public final class ImmutableTargetInfo implements TargetInfo { /** * Configure the full list of source intents we could resolve for this target. This is - * effectively the same as calling {@link #setResolvedIntent()} with the first element of - * the list, and {@link #setAlternateSourceIntents()} with the remainder (or clearing those + * effectively the same as calling {@link #setResolvedIntent} with the first element of + * the list, and {@link #setAlternateSourceIntents} with the remainder (or clearing those * fields on the builder if there are no corresponding elements in the list). */ public Builder setAllSourceIntents(List sourceIntents) { diff --git a/java/src/com/android/intentresolver/chooser/NotSelectableTargetInfo.java b/java/src/com/android/intentresolver/chooser/NotSelectableTargetInfo.java index 6444e13b..46803a04 100644 --- a/java/src/com/android/intentresolver/chooser/NotSelectableTargetInfo.java +++ b/java/src/com/android/intentresolver/chooser/NotSelectableTargetInfo.java @@ -16,7 +16,6 @@ package com.android.intentresolver.chooser; -import android.annotation.Nullable; import android.app.Activity; import android.content.Context; import android.graphics.drawable.AnimatedVectorDrawable; @@ -24,6 +23,8 @@ import android.graphics.drawable.Drawable; import android.os.Bundle; import android.os.UserHandle; +import androidx.annotation.Nullable; + import com.android.intentresolver.R; import java.util.function.Supplier; diff --git a/java/src/com/android/intentresolver/chooser/SelectableTargetInfo.java b/java/src/com/android/intentresolver/chooser/SelectableTargetInfo.java index 5766db0e..c4aa9021 100644 --- a/java/src/com/android/intentresolver/chooser/SelectableTargetInfo.java +++ b/java/src/com/android/intentresolver/chooser/SelectableTargetInfo.java @@ -16,7 +16,6 @@ package com.android.intentresolver.chooser; -import android.annotation.Nullable; import android.app.Activity; import android.app.prediction.AppTarget; import android.content.ComponentName; @@ -33,6 +32,8 @@ import android.text.SpannableStringBuilder; import android.util.HashedStringCache; import android.util.Log; +import androidx.annotation.Nullable; + import com.android.internal.config.sysui.SystemUiDeviceConfigFlags; import java.util.ArrayList; diff --git a/java/src/com/android/intentresolver/chooser/TargetInfo.java b/java/src/com/android/intentresolver/chooser/TargetInfo.java index 9d793994..ba6c3c05 100644 --- a/java/src/com/android/intentresolver/chooser/TargetInfo.java +++ b/java/src/com/android/intentresolver/chooser/TargetInfo.java @@ -17,14 +17,15 @@ package com.android.intentresolver.chooser; -import android.annotation.Nullable; import android.app.Activity; import android.app.prediction.AppTarget; import android.content.ComponentName; import android.content.Context; import android.content.Intent; +import android.content.SharedPreferences; import android.content.pm.ResolveInfo; import android.content.pm.ShortcutInfo; +import android.content.pm.ShortcutManager; import android.graphics.drawable.Drawable; import android.os.Bundle; import android.os.UserHandle; @@ -32,6 +33,12 @@ import android.service.chooser.ChooserTarget; import android.text.TextUtils; import android.util.HashedStringCache; +import androidx.annotation.Nullable; + +import com.android.intentresolver.ChooserListAdapter; +import com.android.intentresolver.ChooserRefinementManager; +import com.android.intentresolver.ResolverActivity; + import java.util.ArrayList; import java.util.List; import java.util.Objects; @@ -187,9 +194,9 @@ public interface TargetInfo { * Attempt to apply a {@code proposedRefinement} that the {@link ChooserRefinementManager} * received from the caller's refinement flow. This may succeed only if the target has a source * intent that matches the filtering parameters of the proposed refinement (according to - * {@link Intent#filterEquals()}). Then the first such match is the "base intent," and the - * proposed refinement is merged into that base (via {@link Intent#fillIn()}; this can never - * result in a change to the {@link Intent#filterEquals()} status of the base, but may e.g. add + * {@link Intent#filterEquals}). Then the first such match is the "base intent," and the + * proposed refinement is merged into that base (via {@link Intent#fillIn}; this can never + * result in a change to the {@link Intent#filterEquals} status of the base, but may e.g. add * new "extras" that weren't previously given in the base intent). * * @return a copy of this {@link TargetInfo} where the "base intent to send" is the result of @@ -280,7 +287,7 @@ public interface TargetInfo { } /** - * @return the {@link ShortcutManager} data for any shortcut associated with this target. + * @return the {@link ShortcutInfo} for any shortcut associated with this target. */ @Nullable default ShortcutInfo getDirectShareShortcutInfo() { @@ -422,7 +429,7 @@ public interface TargetInfo { /** * @return true if this target should be logged with the "direct_share" metrics category in - * {@link ResolverActivity#maybeLogCrossProfileTargetLaunch()}. This is defined for legacy + * {@link ResolverActivity#maybeLogCrossProfileTargetLaunch}. This is defined for legacy * compatibility and is not likely to be a good indicator of whether this is actually a * "direct share" target (e.g. because it historically also applies to "empty" and "placeholder" * targets). diff --git a/java/src/com/android/intentresolver/contentpreview/ContentPreviewType.java b/java/src/com/android/intentresolver/contentpreview/ContentPreviewType.java index ebab147d..ad1c6c01 100644 --- a/java/src/com/android/intentresolver/contentpreview/ContentPreviewType.java +++ b/java/src/com/android/intentresolver/contentpreview/ContentPreviewType.java @@ -18,7 +18,7 @@ package com.android.intentresolver.contentpreview; import static java.lang.annotation.RetentionPolicy.SOURCE; -import android.annotation.IntDef; +import androidx.annotation.IntDef; import java.lang.annotation.Retention; diff --git a/java/src/com/android/intentresolver/contentpreview/HeadlineGeneratorImpl.kt b/java/src/com/android/intentresolver/contentpreview/HeadlineGeneratorImpl.kt index 1aace8c3..ef1e55d8 100644 --- a/java/src/com/android/intentresolver/contentpreview/HeadlineGeneratorImpl.kt +++ b/java/src/com/android/intentresolver/contentpreview/HeadlineGeneratorImpl.kt @@ -16,36 +16,55 @@ package com.android.intentresolver.contentpreview -import android.annotation.StringRes import android.content.Context -import com.android.intentresolver.R import android.util.PluralsMessageFormatter +import androidx.annotation.StringRes +import com.android.intentresolver.R private const val PLURALS_COUNT = "count" /** - * HeadlineGenerator generates the text to show at the top of the sharesheet as a brief - * description of the content being shared. + * HeadlineGenerator generates the text to show at the top of the sharesheet as a brief description + * of the content being shared. */ class HeadlineGeneratorImpl(private val context: Context) : HeadlineGenerator { override fun getTextHeadline(text: CharSequence): String { return context.getString( - getTemplateResource(text, R.string.sharing_link, R.string.sharing_text)) + getTemplateResource(text, R.string.sharing_link, R.string.sharing_text) + ) } override fun getImagesWithTextHeadline(text: CharSequence, count: Int): String { - return getPluralString(getTemplateResource( - text, R.string.sharing_images_with_link, R.string.sharing_images_with_text), count) + return getPluralString( + getTemplateResource( + text, + R.string.sharing_images_with_link, + R.string.sharing_images_with_text + ), + count + ) } override fun getVideosWithTextHeadline(text: CharSequence, count: Int): String { - return getPluralString(getTemplateResource( - text, R.string.sharing_videos_with_link, R.string.sharing_videos_with_text), count) + return getPluralString( + getTemplateResource( + text, + R.string.sharing_videos_with_link, + R.string.sharing_videos_with_text + ), + count + ) } override fun getFilesWithTextHeadline(text: CharSequence, count: Int): String { - return getPluralString(getTemplateResource( - text, R.string.sharing_files_with_link, R.string.sharing_files_with_text), count) + return getPluralString( + getTemplateResource( + text, + R.string.sharing_files_with_link, + R.string.sharing_files_with_text + ), + count + ) } override fun getImagesHeadline(count: Int): String { @@ -70,7 +89,9 @@ class HeadlineGeneratorImpl(private val context: Context) : HeadlineGenerator { @StringRes private fun getTemplateResource( - text: CharSequence, @StringRes linkResource: Int, @StringRes nonLinkResource: Int + text: CharSequence, + @StringRes linkResource: Int, + @StringRes nonLinkResource: Int ): Int { return if (text.toString().isHttpUri()) linkResource else nonLinkResource } diff --git a/java/src/com/android/intentresolver/emptystate/NoAppsAvailableEmptyStateProvider.java b/java/src/com/android/intentresolver/emptystate/NoAppsAvailableEmptyStateProvider.java index b7084466..2653c560 100644 --- a/java/src/com/android/intentresolver/emptystate/NoAppsAvailableEmptyStateProvider.java +++ b/java/src/com/android/intentresolver/emptystate/NoAppsAvailableEmptyStateProvider.java @@ -19,8 +19,6 @@ package com.android.intentresolver.emptystate; import static android.app.admin.DevicePolicyResources.Strings.Core.RESOLVER_NO_PERSONAL_APPS; import static android.app.admin.DevicePolicyResources.Strings.Core.RESOLVER_NO_WORK_APPS; -import android.annotation.NonNull; -import android.annotation.Nullable; import android.app.admin.DevicePolicyEventLogger; import android.app.admin.DevicePolicyManager; import android.content.Context; @@ -28,6 +26,9 @@ import android.content.pm.ResolveInfo; import android.os.UserHandle; import android.stats.devicepolicy.nano.DevicePolicyEnums; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + import com.android.intentresolver.ResolvedComponentInfo; import com.android.intentresolver.ResolverListAdapter; import com.android.internal.R; @@ -51,9 +52,12 @@ public class NoAppsAvailableEmptyStateProvider implements EmptyStateProvider { @NonNull private final UserHandle mTabOwnerUserHandleForLaunch; - public NoAppsAvailableEmptyStateProvider(Context context, UserHandle workProfileUserHandle, - UserHandle personalProfileUserHandle, String metricsCategory, - UserHandle tabOwnerUserHandleForLaunch) { + public NoAppsAvailableEmptyStateProvider( + @NonNull Context context, + @Nullable UserHandle workProfileUserHandle, + @Nullable UserHandle personalProfileUserHandle, + @NonNull String metricsCategory, + @NonNull UserHandle tabOwnerUserHandleForLaunch) { mContext = context; mWorkProfileUserHandle = workProfileUserHandle; mPersonalProfileUserHandle = personalProfileUserHandle; @@ -128,8 +132,9 @@ public class NoAppsAvailableEmptyStateProvider implements EmptyStateProvider { private boolean mIsPersonalProfile; - public NoAppsAvailableEmptyState(String title, String metricsCategory, - boolean isPersonalProfile) { + public NoAppsAvailableEmptyState(@NonNull String title, + @NonNull String metricsCategory, + boolean isPersonalProfile) { mTitle = title; mMetricsCategory = metricsCategory; mIsPersonalProfile = isPersonalProfile; diff --git a/java/src/com/android/intentresolver/emptystate/NoCrossProfileEmptyStateProvider.java b/java/src/com/android/intentresolver/emptystate/NoCrossProfileEmptyStateProvider.java index 686027c3..ce7bd8d9 100644 --- a/java/src/com/android/intentresolver/emptystate/NoCrossProfileEmptyStateProvider.java +++ b/java/src/com/android/intentresolver/emptystate/NoCrossProfileEmptyStateProvider.java @@ -16,14 +16,15 @@ package com.android.intentresolver.emptystate; -import android.annotation.NonNull; -import android.annotation.Nullable; -import android.annotation.StringRes; import android.app.admin.DevicePolicyEventLogger; import android.app.admin.DevicePolicyManager; import android.content.Context; import android.os.UserHandle; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.annotation.StringRes; + import com.android.intentresolver.ResolverListAdapter; /** @@ -90,10 +91,14 @@ public class NoCrossProfileEmptyStateProvider implements EmptyStateProvider { @NonNull private final String mEventCategory; - public DevicePolicyBlockerEmptyState(Context context, String devicePolicyStringTitleId, - @StringRes int defaultTitleResource, String devicePolicyStringSubtitleId, + public DevicePolicyBlockerEmptyState( + @NonNull Context context, + String devicePolicyStringTitleId, + @StringRes int defaultTitleResource, + String devicePolicyStringSubtitleId, @StringRes int defaultSubtitleResource, - int devicePolicyEventId, String devicePolicyEventCategory) { + int devicePolicyEventId, + @NonNull String devicePolicyEventCategory) { mContext = context; mDevicePolicyStringTitleId = devicePolicyStringTitleId; mDefaultTitleResource = defaultTitleResource; diff --git a/java/src/com/android/intentresolver/emptystate/WorkProfilePausedEmptyStateProvider.java b/java/src/com/android/intentresolver/emptystate/WorkProfilePausedEmptyStateProvider.java index ca04f1b7..612828e0 100644 --- a/java/src/com/android/intentresolver/emptystate/WorkProfilePausedEmptyStateProvider.java +++ b/java/src/com/android/intentresolver/emptystate/WorkProfilePausedEmptyStateProvider.java @@ -18,14 +18,15 @@ package com.android.intentresolver.emptystate; import static android.app.admin.DevicePolicyResources.Strings.Core.RESOLVER_WORK_PAUSED_TITLE; -import android.annotation.NonNull; -import android.annotation.Nullable; import android.app.admin.DevicePolicyEventLogger; import android.app.admin.DevicePolicyManager; import android.content.Context; import android.os.UserHandle; import android.stats.devicepolicy.nano.DevicePolicyEnums; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + import com.android.intentresolver.MultiProfilePagerAdapter.OnSwitchOnWorkSelectedListener; import com.android.intentresolver.R; import com.android.intentresolver.ResolverListAdapter; diff --git a/java/src/com/android/intentresolver/grid/ChooserGridAdapter.java b/java/src/com/android/intentresolver/grid/ChooserGridAdapter.java index 2ab38f30..51d4e677 100644 --- a/java/src/com/android/intentresolver/grid/ChooserGridAdapter.java +++ b/java/src/com/android/intentresolver/grid/ChooserGridAdapter.java @@ -288,8 +288,9 @@ public final class ChooserGridAdapter extends RecyclerView.Adapter mDirectShareAppTargetCache = new HashMap<>(); private final Map mDirectShareShortcutInfoCache = new HashMap<>(); - public static final int TARGET_TYPE_DEFAULT = 0; - public static final int TARGET_TYPE_CHOOSER_TARGET = 1; - public static final int TARGET_TYPE_SHORTCUTS_FROM_SHORTCUT_MANAGER = 2; - public static final int TARGET_TYPE_SHORTCUTS_FROM_PREDICTION_SERVICE = 3; + private static final int TARGET_TYPE_DEFAULT = 0; + private static final int TARGET_TYPE_CHOOSER_TARGET = 1; + private static final int TARGET_TYPE_SHORTCUTS_FROM_SHORTCUT_MANAGER = 2; + private static final int TARGET_TYPE_SHORTCUTS_FROM_PREDICTION_SERVICE = 3; private static final int SCROLL_STATUS_IDLE = 0; private static final int SCROLL_STATUS_SCROLLING_VERTICAL = 1; private static final int SCROLL_STATUS_SCROLLING_HORIZONTAL = 2; - @IntDef(flag = false, prefix = { "TARGET_TYPE_" }, value = { - TARGET_TYPE_DEFAULT, - TARGET_TYPE_CHOOSER_TARGET, - TARGET_TYPE_SHORTCUTS_FROM_SHORTCUT_MANAGER, - TARGET_TYPE_SHORTCUTS_FROM_PREDICTION_SERVICE - }) - @Retention(RetentionPolicy.SOURCE) - public @interface ShareTargetType {} - @Inject public FeatureFlags mFeatureFlags; @Inject public EventLog mEventLog; @Inject @ImageEditor public Optional mImageEditor; diff --git a/java/src/com/android/intentresolver/v2/ChooserMultiProfilePagerAdapter.java b/java/src/com/android/intentresolver/v2/ChooserMultiProfilePagerAdapter.java index 8ca976bc..81797e9a 100644 --- a/java/src/com/android/intentresolver/v2/ChooserMultiProfilePagerAdapter.java +++ b/java/src/com/android/intentresolver/v2/ChooserMultiProfilePagerAdapter.java @@ -181,6 +181,7 @@ public class ChooserMultiProfilePagerAdapter extends MultiProfilePagerAdapter< mBottomOffset = bottomOffset; } + @Override public Optional get() { int initialBottomPadding = mContext.getResources().getDimensionPixelSize( R.dimen.resolver_empty_state_container_padding_bottom); diff --git a/java/src/com/android/intentresolver/v2/MultiProfilePagerAdapter.java b/java/src/com/android/intentresolver/v2/MultiProfilePagerAdapter.java index 391cce7a..0b64a0ee 100644 --- a/java/src/com/android/intentresolver/v2/MultiProfilePagerAdapter.java +++ b/java/src/com/android/intentresolver/v2/MultiProfilePagerAdapter.java @@ -41,24 +41,28 @@ import java.util.function.Supplier; /** * Skeletal {@link PagerAdapter} implementation for a UI with per-profile tabs (as in Sharesheet). - * + *

* TODO: attempt to further restrict visibility/improve encapsulation in the methods we expose. + *

* TODO: deprecate and audit/fix usages of any methods that refer to the "active" or "inactive" + *

* adapters; these were marked {@link VisibleForTesting} and their usage seems like an accident * waiting to happen since clients seem to make assumptions about which adapter will be "active" in * a particular context, and more explicit APIs would make sure those were valid. + *

* TODO: consider renaming legacy methods (e.g. why do we know it's a "list", not just a "page"?) + *

+ * TODO: this is part of an in-progress refactor to merge with `GenericMultiProfilePagerAdapter`. + * As originally noted there, we've reduced explicit references to the `ResolverListAdapter` base + * type and may be able to drop the type constraint. * * @param the type of the widget that represents the contents of a page in this adapter * @param the type of a "root" adapter class to be instantiated and included in * the per-profile records. * @param the concrete type of a {@link ResolverListAdapter} implementation to * control the contents of a given per-profile list. This is provided for convenience, since it must - * be possible to get the list adapter from the page adapter via our {@link mListAdapterExtractor}. - * - * TODO: this is part of an in-progress refactor to merge with `GenericMultiProfilePagerAdapter`. - * As originally noted there, we've reduced explicit references to the `ResolverListAdapter` base - * type and may be able to drop the type constraint. + * be possible to get the list adapter from the page adapter via our + * mListAdapterExtractor. */ public class MultiProfilePagerAdapter< PageViewT extends ViewGroup, diff --git a/java/src/com/android/intentresolver/v2/ResolverActivity.java b/java/src/com/android/intentresolver/v2/ResolverActivity.java index b34ce16d..a97ef962 100644 --- a/java/src/com/android/intentresolver/v2/ResolverActivity.java +++ b/java/src/com/android/intentresolver/v2/ResolverActivity.java @@ -35,9 +35,6 @@ import static com.android.internal.annotations.VisibleForTesting.Visibility.PROT import static java.util.Objects.requireNonNull; -import android.annotation.Nullable; -import android.annotation.StringRes; -import android.annotation.UiThread; import android.app.ActivityManager; import android.app.ActivityThread; import android.app.VoiceInteractor.PickOptionRequest; @@ -94,6 +91,9 @@ import android.widget.TabWidget; import android.widget.TextView; import android.widget.Toast; +import androidx.annotation.Nullable; +import androidx.annotation.StringRes; +import androidx.annotation.UiThread; import androidx.fragment.app.FragmentActivity; import androidx.viewpager.widget.ViewPager; diff --git a/java/src/com/android/intentresolver/v2/emptystate/NoAppsAvailableEmptyStateProvider.java b/java/src/com/android/intentresolver/v2/emptystate/NoAppsAvailableEmptyStateProvider.java index c76f8a2d..e9d1bb34 100644 --- a/java/src/com/android/intentresolver/v2/emptystate/NoAppsAvailableEmptyStateProvider.java +++ b/java/src/com/android/intentresolver/v2/emptystate/NoAppsAvailableEmptyStateProvider.java @@ -19,8 +19,6 @@ package com.android.intentresolver.v2.emptystate; import static android.app.admin.DevicePolicyResources.Strings.Core.RESOLVER_NO_PERSONAL_APPS; import static android.app.admin.DevicePolicyResources.Strings.Core.RESOLVER_NO_WORK_APPS; -import android.annotation.NonNull; -import android.annotation.Nullable; import android.app.admin.DevicePolicyEventLogger; import android.app.admin.DevicePolicyManager; import android.content.Context; @@ -28,6 +26,9 @@ import android.content.pm.ResolveInfo; import android.os.UserHandle; import android.stats.devicepolicy.nano.DevicePolicyEnums; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + import com.android.intentresolver.ResolvedComponentInfo; import com.android.intentresolver.ResolverListAdapter; import com.android.intentresolver.emptystate.EmptyState; @@ -53,9 +54,10 @@ public class NoAppsAvailableEmptyStateProvider implements EmptyStateProvider { @NonNull private final UserHandle mTabOwnerUserHandleForLaunch; - public NoAppsAvailableEmptyStateProvider(Context context, UserHandle workProfileUserHandle, - UserHandle personalProfileUserHandle, String metricsCategory, - UserHandle tabOwnerUserHandleForLaunch) { + public NoAppsAvailableEmptyStateProvider(@NonNull Context context, + @Nullable UserHandle workProfileUserHandle, + @Nullable UserHandle personalProfileUserHandle, @NonNull String metricsCategory, + @NonNull UserHandle tabOwnerUserHandleForLaunch) { mContext = context; mWorkProfileUserHandle = workProfileUserHandle; mPersonalProfileUserHandle = personalProfileUserHandle; @@ -123,21 +125,21 @@ public class NoAppsAvailableEmptyStateProvider implements EmptyStateProvider { public static class NoAppsAvailableEmptyState implements EmptyState { @NonNull - private String mTitle; + private final String mTitle; @NonNull - private String mMetricsCategory; + private final String mMetricsCategory; - private boolean mIsPersonalProfile; + private final boolean mIsPersonalProfile; - public NoAppsAvailableEmptyState(String title, String metricsCategory, + public NoAppsAvailableEmptyState(@NonNull String title, @NonNull String metricsCategory, boolean isPersonalProfile) { mTitle = title; mMetricsCategory = metricsCategory; mIsPersonalProfile = isPersonalProfile; } - @Nullable + @NonNull @Override public String getTitle() { return mTitle; diff --git a/java/src/com/android/intentresolver/v2/emptystate/NoCrossProfileEmptyStateProvider.java b/java/src/com/android/intentresolver/v2/emptystate/NoCrossProfileEmptyStateProvider.java index aef39cf4..b744c589 100644 --- a/java/src/com/android/intentresolver/v2/emptystate/NoCrossProfileEmptyStateProvider.java +++ b/java/src/com/android/intentresolver/v2/emptystate/NoCrossProfileEmptyStateProvider.java @@ -16,14 +16,15 @@ package com.android.intentresolver.v2.emptystate; -import android.annotation.NonNull; -import android.annotation.Nullable; -import android.annotation.StringRes; import android.app.admin.DevicePolicyEventLogger; import android.app.admin.DevicePolicyManager; import android.content.Context; import android.os.UserHandle; +import 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; @@ -93,10 +94,10 @@ public class NoCrossProfileEmptyStateProvider implements EmptyStateProvider { @NonNull private final String mEventCategory; - public DevicePolicyBlockerEmptyState(Context context, String devicePolicyStringTitleId, - @StringRes int defaultTitleResource, String devicePolicyStringSubtitleId, - @StringRes int defaultSubtitleResource, - int devicePolicyEventId, String devicePolicyEventCategory) { + public DevicePolicyBlockerEmptyState(@NonNull Context context, + String devicePolicyStringTitleId, @StringRes int defaultTitleResource, + String devicePolicyStringSubtitleId, @StringRes int defaultSubtitleResource, + int devicePolicyEventId, @NonNull String devicePolicyEventCategory) { mContext = context; mDevicePolicyStringTitleId = devicePolicyStringTitleId; mDefaultTitleResource = defaultTitleResource; diff --git a/java/src/com/android/intentresolver/v2/emptystate/WorkProfilePausedEmptyStateProvider.java b/java/src/com/android/intentresolver/v2/emptystate/WorkProfilePausedEmptyStateProvider.java index bc28fc30..a6fee3ec 100644 --- a/java/src/com/android/intentresolver/v2/emptystate/WorkProfilePausedEmptyStateProvider.java +++ b/java/src/com/android/intentresolver/v2/emptystate/WorkProfilePausedEmptyStateProvider.java @@ -18,14 +18,15 @@ package com.android.intentresolver.v2.emptystate; import static android.app.admin.DevicePolicyResources.Strings.Core.RESOLVER_WORK_PAUSED_TITLE; -import android.annotation.NonNull; -import android.annotation.Nullable; import android.app.admin.DevicePolicyEventLogger; import android.app.admin.DevicePolicyManager; import android.content.Context; import android.os.UserHandle; import android.stats.devicepolicy.nano.DevicePolicyEnums; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + import com.android.intentresolver.MultiProfilePagerAdapter.OnSwitchOnWorkSelectedListener; import com.android.intentresolver.R; import com.android.intentresolver.ResolverListAdapter; diff --git a/java/src/com/android/intentresolver/widget/ResolverDrawerLayout.java b/java/src/com/android/intentresolver/widget/ResolverDrawerLayout.java index b8fbedbf..2c8140d9 100644 --- a/java/src/com/android/intentresolver/widget/ResolverDrawerLayout.java +++ b/java/src/com/android/intentresolver/widget/ResolverDrawerLayout.java @@ -19,7 +19,6 @@ package com.android.intentresolver.widget; import static android.content.res.Resources.ID_NULL; -import android.annotation.IdRes; import android.content.Context; import android.content.res.TypedArray; import android.graphics.Canvas; @@ -45,6 +44,8 @@ import android.view.animation.AnimationUtils; import android.widget.AbsListView; import android.widget.OverScroller; +import androidx.annotation.IdRes; +import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.core.view.ScrollingView; import androidx.recyclerview.widget.RecyclerView; @@ -998,7 +999,7 @@ public class ResolverDrawerLayout extends ViewGroup { } @Override - public void onDrawForeground(Canvas canvas) { + public void onDrawForeground(@NonNull Canvas canvas) { if (mScrollIndicatorDrawable != null) { mScrollIndicatorDrawable.draw(canvas); } diff --git a/java/tests/src/com/android/intentresolver/ChooserWrapperActivity.java b/java/tests/src/com/android/intentresolver/ChooserWrapperActivity.java index c9f47a33..72f1f452 100644 --- a/java/tests/src/com/android/intentresolver/ChooserWrapperActivity.java +++ b/java/tests/src/com/android/intentresolver/ChooserWrapperActivity.java @@ -16,7 +16,6 @@ package com.android.intentresolver; -import android.annotation.Nullable; import android.app.prediction.AppPredictor; import android.app.usage.UsageStatsManager; import android.content.ComponentName; @@ -32,6 +31,8 @@ import android.net.Uri; import android.os.Bundle; import android.os.UserHandle; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; import androidx.lifecycle.ViewModelProvider; import com.android.intentresolver.chooser.DisplayResolveInfo; @@ -248,6 +249,7 @@ public class ChooserWrapperActivity extends ChooserActivity implements IChooserW return mMultiProfilePagerAdapter.getCurrentUserHandle(); } + @NonNull @Override public Context createContextAsUser(UserHandle user, int flags) { // return the current context as a work profile doesn't really exist in these tests diff --git a/java/tests/src/com/android/intentresolver/IChooserWrapper.java b/java/tests/src/com/android/intentresolver/IChooserWrapper.java index e34217a8..481cf3b2 100644 --- a/java/tests/src/com/android/intentresolver/IChooserWrapper.java +++ b/java/tests/src/com/android/intentresolver/IChooserWrapper.java @@ -21,6 +21,8 @@ import android.content.Intent; import android.content.pm.ResolveInfo; import android.os.UserHandle; +import androidx.annotation.Nullable; + import com.android.intentresolver.chooser.DisplayResolveInfo; import java.util.concurrent.Executor; @@ -41,7 +43,7 @@ public interface IChooserWrapper { ResolveInfo pri, CharSequence pLabel, CharSequence pInfo, - Intent replacementIntent); + @Nullable Intent replacementIntent); UserHandle getCurrentUserHandle(); Executor getMainExecutor(); } diff --git a/java/tests/src/com/android/intentresolver/ResolverDataProvider.java b/java/tests/src/com/android/intentresolver/ResolverDataProvider.java index 4eb350fc..db109941 100644 --- a/java/tests/src/com/android/intentresolver/ResolverDataProvider.java +++ b/java/tests/src/com/android/intentresolver/ResolverDataProvider.java @@ -29,6 +29,8 @@ import android.test.mock.MockContext; import android.test.mock.MockPackageManager; import android.test.mock.MockResources; +import androidx.annotation.NonNull; + /** * Utility class used by resolver tests to create mock data */ @@ -195,28 +197,31 @@ public class ResolverDataProvider { @Override public Resources getResources() { return new MockResources() { + @NonNull @Override public String getString(int id) throws NotFoundException { if (id == 1) return appLabel; if (id == 2) return activityLabel; if (id == 3) return resolveInfoLabel; - return null; + throw new NotFoundException(); } }; } }; ApplicationInfo appInfo = new ApplicationInfo() { + @NonNull @Override - public CharSequence loadLabel(PackageManager pm) { + public CharSequence loadLabel(@NonNull PackageManager pm) { return appLabel; } }; appInfo.labelRes = 1; ActivityInfo activityInfo = new ActivityInfo() { + @NonNull @Override - public CharSequence loadLabel(PackageManager pm) { + public CharSequence loadLabel(@NonNull PackageManager pm) { return activityLabel; } }; @@ -224,8 +229,9 @@ public class ResolverDataProvider { activityInfo.applicationInfo = appInfo; ResolveInfo resolveInfo = new ResolveInfo() { + @NonNull @Override - public CharSequence loadLabel(PackageManager pm) { + public CharSequence loadLabel(@NonNull PackageManager pm) { return resolveInfoLabel; } }; diff --git a/java/tests/src/com/android/intentresolver/ResolverWrapperActivity.java b/java/tests/src/com/android/intentresolver/ResolverWrapperActivity.java index 7ffb90ce..d1adfba9 100644 --- a/java/tests/src/com/android/intentresolver/ResolverWrapperActivity.java +++ b/java/tests/src/com/android/intentresolver/ResolverWrapperActivity.java @@ -21,7 +21,6 @@ import static org.mockito.ArgumentMatchers.anyInt; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.when; -import android.annotation.Nullable; import android.content.Context; import android.content.Intent; import android.content.pm.PackageManager; @@ -32,6 +31,7 @@ import android.os.UserHandle; import android.util.Pair; import androidx.annotation.NonNull; +import androidx.annotation.Nullable; import androidx.test.espresso.idling.CountingIdlingResource; import com.android.intentresolver.chooser.DisplayResolveInfo; @@ -158,9 +158,12 @@ public class ResolverWrapperActivity extends ResolverActivity { protected AnnotatedUserHandles computeAnnotatedUserHandles() { return sOverrides.annotatedUserHandles; } - @Override - public void startActivityAsUser(Intent intent, Bundle options, UserHandle user) { + public void startActivityAsUser( + @NonNull Intent intent, + Bundle options, + @NonNull UserHandle user + ) { super.startActivityAsUser(intent, options, user); } -- cgit v1.2.3-59-g8ed1b From e134a4bcdb9f09d5220bcfc69d1f72fe9c7ca5f3 Mon Sep 17 00:00:00 2001 From: Mark Renouf Date: Mon, 13 Nov 2023 15:46:24 -0500 Subject: Fix type issues with Kotlin and Context.getSystemService ``` import androidx.core.content.getSystemService val userManager: UserManager = lazy { context.systemService()!! } ``` Types are inferred as expected, (returns T?) Change-Id: I7ad3531397d6afea25f25bb27c9f29d31a470ca2 --- .../com/android/intentresolver/v2/ActivityLogic.kt | 33 ++++------------------ .../intentresolver/v2/ResolverActivityLogic.kt | 7 ++--- .../intentresolver/ResolverActivityTest.java | 7 ----- 3 files changed, 8 insertions(+), 39 deletions(-) (limited to 'java/src') diff --git a/java/src/com/android/intentresolver/v2/ActivityLogic.kt b/java/src/com/android/intentresolver/v2/ActivityLogic.kt index e5b89dfa..8999343e 100644 --- a/java/src/com/android/intentresolver/v2/ActivityLogic.kt +++ b/java/src/com/android/intentresolver/v2/ActivityLogic.kt @@ -1,16 +1,14 @@ package com.android.intentresolver.v2 -import android.app.Activity import android.app.admin.DevicePolicyManager import android.app.admin.DevicePolicyResources.Strings.Core.FORWARD_INTENT_TO_PERSONAL import android.app.admin.DevicePolicyResources.Strings.Core.FORWARD_INTENT_TO_WORK -import android.content.Context import android.content.Intent -import android.net.Uri import android.os.UserHandle import android.os.UserManager import android.util.Log import androidx.activity.ComponentActivity +import androidx.core.content.getSystemService import com.android.intentresolver.AnnotatedUserHandles import com.android.intentresolver.R import com.android.intentresolver.icons.TargetDataLoader @@ -74,13 +72,6 @@ interface CommonActivityLogic { /** Returns display message indicating intent forwarding or null if not intent forwarding. */ fun forwardMessageFor(intent: Intent): String? - - // TODO: For some reason the IDE complains about getting Activity fields from a - // ComponentActivity. These are a band-aid until the bug is fixed and should be removed when - // possible. - val ComponentActivity.context: Context - val ComponentActivity.intent: Intent - val ComponentActivity.referrer: Uri? } /** @@ -105,13 +96,9 @@ class CommonActivityLogicImpl( } } - override val userManager: UserManager by lazy { - activity.context.getSystemService(Context.USER_SERVICE) as UserManager - } + override val userManager: UserManager by lazy { activity.getSystemService()!! } - override val devicePolicyManager: DevicePolicyManager by lazy { - activity.context.getSystemService(DevicePolicyManager::class.java)!! - } + override val devicePolicyManager: DevicePolicyManager by lazy { activity.getSystemService()!! } override val annotatedUserHandles: AnnotatedUserHandles? by lazy { try { @@ -124,13 +111,13 @@ class CommonActivityLogicImpl( private val forwardToPersonalMessage: String? by lazy { devicePolicyManager.resources.getString(FORWARD_INTENT_TO_PERSONAL) { - activity.context.getString(R.string.forward_intent_to_owner) + activity.getString(R.string.forward_intent_to_owner) } } private val forwardToWorkMessage: String? by lazy { devicePolicyManager.resources.getString(FORWARD_INTENT_TO_WORK) { - activity.context.getString(R.string.forward_intent_to_work) + activity.getString(R.string.forward_intent_to_work) } } @@ -154,14 +141,4 @@ class CommonActivityLogicImpl( companion object { private const val ANDROID_APP_URI_SCHEME = "android-app" } - - // TODO: For some reason the IDE complains about getting Activity fields from a - // ComponentActivity. These are a band-aid until the bug is fixed and should be removed when - // possible. - override val ComponentActivity.context: Context - get() = (this as Activity) - override val ComponentActivity.intent: Intent - get() = (this as Activity).intent - override val ComponentActivity.referrer: Uri? - get() = (this as Activity).referrer } diff --git a/java/src/com/android/intentresolver/v2/ResolverActivityLogic.kt b/java/src/com/android/intentresolver/v2/ResolverActivityLogic.kt index 1b936159..bf8df12f 100644 --- a/java/src/com/android/intentresolver/v2/ResolverActivityLogic.kt +++ b/java/src/com/android/intentresolver/v2/ResolverActivityLogic.kt @@ -32,9 +32,8 @@ class ResolverActivityLogic( } override val resolvingHome: Boolean by lazy { - Intent.ACTION_MAIN == targetIntent.action && - targetIntent.categories?.size == 1 && - targetIntent.categories.contains(Intent.CATEGORY_HOME) + targetIntent.action == Intent.ACTION_MAIN && + targetIntent.categories.singleOrNull() == Intent.CATEGORY_HOME } override val additionalTargets: List? = null @@ -49,7 +48,7 @@ class ResolverActivityLogic( override val targetDataLoader: TargetDataLoader by lazy { DefaultTargetDataLoader( - activity.context, + activity, activity.lifecycle, activity.intent.getBooleanExtra( ResolverActivity.EXTRA_IS_AUDIO_CAPTURE_DEVICE, diff --git a/java/tests/src/com/android/intentresolver/ResolverActivityTest.java b/java/tests/src/com/android/intentresolver/ResolverActivityTest.java index 1ce1b3b0..dde2f980 100644 --- a/java/tests/src/com/android/intentresolver/ResolverActivityTest.java +++ b/java/tests/src/com/android/intentresolver/ResolverActivityTest.java @@ -80,13 +80,6 @@ public class ResolverActivityTest { private static final UserHandle WORK_PROFILE_USER_HANDLE = UserHandle.of(10); private static final UserHandle CLONE_PROFILE_USER_HANDLE = UserHandle.of(11); - protected Intent getConcreteIntentForLaunch(Intent clientIntent) { - clientIntent.setClass( - androidx.test.platform.app.InstrumentationRegistry.getInstrumentation().getTargetContext(), - ResolverWrapperActivity.class); - return clientIntent; - } - @Rule public ActivityTestRule mActivityRule = new ActivityTestRule<>(ResolverWrapperActivity.class, false, false); -- cgit v1.2.3-59-g8ed1b From ca5661d19c763e7c389a1494742258a370ab77d8 Mon Sep 17 00:00:00 2001 From: Govinda Wasserman Date: Wed, 1 Nov 2023 22:21:06 -0400 Subject: Moves targetIntent logic and WorkProfileAvailability into ActivityLogic Test: atest com.android.intentresolver.v2 BUG: 302113519 Change-Id: I590c10264220e5d328cad057215e5c4d8b120a4a --- .../com/android/intentresolver/v2/ActivityLogic.kt | 18 +++++- .../android/intentresolver/v2/ChooserActivity.java | 33 +++++++---- .../intentresolver/v2/ChooserActivityLogic.kt | 20 +++++-- .../intentresolver/v2/ResolverActivity.java | 69 ++++++++-------------- .../intentresolver/v2/ResolverActivityLogic.kt | 17 ++++-- .../intentresolver/v2/ChooserWrapperActivity.java | 12 +--- .../intentresolver/v2/ResolverWrapperActivity.java | 14 ++--- .../intentresolver/v2/TestChooserActivityLogic.kt | 9 ++- .../intentresolver/v2/TestResolverActivityLogic.kt | 10 +++- 9 files changed, 113 insertions(+), 89 deletions(-) (limited to 'java/src') diff --git a/java/src/com/android/intentresolver/v2/ActivityLogic.kt b/java/src/com/android/intentresolver/v2/ActivityLogic.kt index e5b89dfa..95f2bed4 100644 --- a/java/src/com/android/intentresolver/v2/ActivityLogic.kt +++ b/java/src/com/android/intentresolver/v2/ActivityLogic.kt @@ -13,6 +13,7 @@ import android.util.Log import androidx.activity.ComponentActivity import com.android.intentresolver.AnnotatedUserHandles import com.android.intentresolver.R +import com.android.intentresolver.WorkProfileAvailabilityManager import com.android.intentresolver.icons.TargetDataLoader /** @@ -22,12 +23,10 @@ import com.android.intentresolver.icons.TargetDataLoader * CommonActivityLogic implementation. */ interface ActivityLogic : CommonActivityLogic { - /** The intent for the target. This will always come before [additionalTargets], if any. */ + /** The intent for the target. This will always come before additional targets, if any. */ val targetIntent: Intent /** Whether the intent is for home. */ val resolvingHome: Boolean - /** Intents for additional targets. These will always come after [targetIntent]. */ - val additionalTargets: List? /** Custom title to display. */ val title: CharSequence? /** Resource ID for the title to display when there is no custom title. */ @@ -44,6 +43,8 @@ interface ActivityLogic : CommonActivityLogic { * Message showing that intent is forwarded from managed profile to owner or other way around. */ val profileSwitchMessage: String? + /** The intents for potential actual targets. [targetIntent] must be first. */ + val payloadIntents: List /** * Called after Activity superclass creation, but before any other onCreate logic is performed. @@ -71,6 +72,8 @@ interface CommonActivityLogic { val devicePolicyManager: DevicePolicyManager /** Current [UserHandle]s retrievable by type. */ val annotatedUserHandles: AnnotatedUserHandles? + /** Monitors for changes to work profile availability. */ + val workProfileAvailabilityManager: WorkProfileAvailabilityManager /** Returns display message indicating intent forwarding or null if not intent forwarding. */ fun forwardMessageFor(intent: Intent): String? @@ -91,6 +94,7 @@ interface CommonActivityLogic { class CommonActivityLogicImpl( override val tag: String, activityProvider: () -> ComponentActivity, + onWorkProfileStatusUpdated: () -> Unit, ) : CommonActivityLogic { override val activity: ComponentActivity by lazy { activityProvider() } @@ -122,6 +126,14 @@ class CommonActivityLogicImpl( } } + override val workProfileAvailabilityManager: WorkProfileAvailabilityManager by lazy { + WorkProfileAvailabilityManager( + userManager, + annotatedUserHandles?.workProfileUserHandle, + onWorkProfileStatusUpdated, + ) + } + private val forwardToPersonalMessage: String? by lazy { devicePolicyManager.resources.getString(FORWARD_INTENT_TO_PERSONAL) { activity.context.getString(R.string.forward_intent_to_owner) diff --git a/java/src/com/android/intentresolver/v2/ChooserActivity.java b/java/src/com/android/intentresolver/v2/ChooserActivity.java index 446577ef..84258850 100644 --- a/java/src/com/android/intentresolver/v2/ChooserActivity.java +++ b/java/src/com/android/intentresolver/v2/ChooserActivity.java @@ -241,6 +241,7 @@ public class ChooserActivity extends Hilt_ChooserActivity implements mLogic = new ChooserActivityLogic( TAG, () -> this, + this::onWorkProfileStatusUpdated, () -> mTargetDataLoader, this::onPreinitialization ); @@ -496,7 +497,7 @@ public class ChooserActivity extends Hilt_ChooserActivity implements TargetDataLoader targetDataLoader) { ChooserGridAdapter adapter = createChooserGridAdapter( /* context */ this, - /* payloadIntents */ mIntents, + mLogic.getPayloadIntents(), initialIntents, rList, filterLastUsed, @@ -521,7 +522,7 @@ public class ChooserActivity extends Hilt_ChooserActivity implements int selectedProfile = findSelectedProfile(); ChooserGridAdapter personalAdapter = createChooserGridAdapter( /* context */ this, - /* payloadIntents */ mIntents, + mLogic.getPayloadIntents(), selectedProfile == PROFILE_PERSONAL ? initialIntents : null, rList, filterLastUsed, @@ -529,7 +530,7 @@ public class ChooserActivity extends Hilt_ChooserActivity implements targetDataLoader); ChooserGridAdapter workAdapter = createChooserGridAdapter( /* context */ this, - /* payloadIntents */ mIntents, + mLogic.getPayloadIntents(), selectedProfile == PROFILE_WORK ? initialIntents : null, rList, filterLastUsed, @@ -540,7 +541,7 @@ public class ChooserActivity extends Hilt_ChooserActivity implements personalAdapter, workAdapter, createEmptyStateProvider(requireAnnotatedUserHandles().workProfileUserHandle), - () -> mWorkProfileAvailability.isQuietModeEnabled(), + () -> mLogic.getWorkProfileAvailabilityManager().isQuietModeEnabled(), selectedProfile, requireAnnotatedUserHandles().workProfileUserHandle, requireAnnotatedUserHandles().cloneProfileUserHandle, @@ -1015,7 +1016,7 @@ public class ChooserActivity extends Hilt_ChooserActivity implements if (info != null) { sendClickToAppPredictor(info); final ResolveInfo ri = info.getResolveInfo(); - Intent targetIntent = getTargetIntent(); + Intent targetIntent = mLogic.getTargetIntent(); if (ri != null && ri.activityInfo != null && targetIntent != null) { ChooserListAdapter currentListAdapter = mChooserMultiProfilePagerAdapter.getActiveListAdapter(); @@ -1189,7 +1190,7 @@ public class ChooserActivity extends Hilt_ChooserActivity implements filterLastUsed, createListController(userHandle), userHandle, - getTargetIntent(), + mLogic.getTargetIntent(), requireChooserRequest(), mMaxTargetsPerRow, targetDataLoader); @@ -1274,13 +1275,13 @@ public class ChooserActivity extends Hilt_ChooserActivity implements } @Override - protected void onWorkProfileStatusUpdated() { + protected Unit onWorkProfileStatusUpdated() { UserHandle workUser = requireAnnotatedUserHandles().workProfileUserHandle; ProfileRecord record = workUser == null ? null : getProfileRecord(workUser); if (record != null && record.shortcutLoader != null) { record.shortcutLoader.reset(); } - super.onWorkProfileStatusUpdated(); + return super.onWorkProfileStatusUpdated(); } @Override @@ -1289,14 +1290,20 @@ public class ChooserActivity extends Hilt_ChooserActivity implements AppPredictor appPredictor = getAppPredictor(userHandle); AbstractResolverComparator resolverComparator; if (appPredictor != null) { - resolverComparator = new AppPredictionServiceResolverComparator(this, getTargetIntent(), - mLogic.getReferrerPackageName(), appPredictor, userHandle, getEventLog(), - mNearbyShare.orElse(null)); + resolverComparator = new AppPredictionServiceResolverComparator( + this, + mLogic.getTargetIntent(), + mLogic.getReferrerPackageName(), + appPredictor, + userHandle, + getEventLog(), + mNearbyShare.orElse(null) + ); } else { resolverComparator = new ResolverRankerServiceResolverComparator( this, - getTargetIntent(), + mLogic.getTargetIntent(), mLogic.getReferrerPackageName(), null, getEventLog(), @@ -1307,7 +1314,7 @@ public class ChooserActivity extends Hilt_ChooserActivity implements return new ChooserListController( this, mPm, - getTargetIntent(), + mLogic.getTargetIntent(), mLogic.getReferrerPackageName(), requireAnnotatedUserHandles().userIdOfCallingApp, resolverComparator, diff --git a/java/src/com/android/intentresolver/v2/ChooserActivityLogic.kt b/java/src/com/android/intentresolver/v2/ChooserActivityLogic.kt index 838c39e2..5303a7e7 100644 --- a/java/src/com/android/intentresolver/v2/ChooserActivityLogic.kt +++ b/java/src/com/android/intentresolver/v2/ChooserActivityLogic.kt @@ -21,18 +21,21 @@ import com.android.intentresolver.v2.util.mutableLazy open class ChooserActivityLogic( tag: String, activityProvider: () -> ComponentActivity, + onWorkProfileStatusUpdated: () -> Unit, targetDataLoaderProvider: () -> TargetDataLoader, private val onPreInitialization: () -> Unit, -) : ActivityLogic, CommonActivityLogic by CommonActivityLogicImpl(tag, activityProvider) { +) : + ActivityLogic, + CommonActivityLogic by CommonActivityLogicImpl( + tag, + activityProvider, + onWorkProfileStatusUpdated, + ) { override val targetIntent: Intent by lazy { chooserRequestParameters?.targetIntent ?: Intent() } override val resolvingHome: Boolean = false - override val additionalTargets: List? by lazy { - chooserRequestParameters?.additionalTargets?.toList() - } - override val title: CharSequence? by lazy { chooserRequestParameters?.title } override val defaultTitleResId: Int by lazy { @@ -52,6 +55,13 @@ open class ChooserActivityLogic( private val _profileSwitchMessage = mutableLazy { forwardMessageFor(targetIntent) } override val profileSwitchMessage: String? by _profileSwitchMessage + override val payloadIntents: List by lazy { + buildList { + add(targetIntent) + chooserRequestParameters?.additionalTargets?.let { addAll(it) } + } + } + val chooserRequestParameters: ChooserRequestParameters? by lazy { try { ChooserRequestParameters( diff --git a/java/src/com/android/intentresolver/v2/ResolverActivity.java b/java/src/com/android/intentresolver/v2/ResolverActivity.java index a97ef962..a7f2047d 100644 --- a/java/src/com/android/intentresolver/v2/ResolverActivity.java +++ b/java/src/com/android/intentresolver/v2/ResolverActivity.java @@ -124,9 +124,10 @@ import com.android.internal.logging.MetricsLogger; import com.android.internal.logging.nano.MetricsProto; import com.android.internal.util.LatencyTracker; +import kotlin.Unit; + import java.util.ArrayList; import java.util.Arrays; -import java.util.Collections; import java.util.Iterator; import java.util.List; import java.util.Objects; @@ -142,7 +143,11 @@ import java.util.Set; public class ResolverActivity extends FragmentActivity implements ResolverListAdapter.ResolverListCommunicator { - protected ActivityLogic mLogic = new ResolverActivityLogic(TAG, () -> this); + protected ActivityLogic mLogic = new ResolverActivityLogic( + TAG, + () -> this, + this::onWorkProfileStatusUpdated + ); public ResolverActivity() { mIsIntentPicker = getClass().equals(ResolverActivity.class); @@ -157,8 +162,6 @@ public class ResolverActivity extends FragmentActivity implements protected View mProfileView; private int mLastSelected = AbsListView.INVALID_POSITION; private int mLayoutId; - @VisibleForTesting - protected final ArrayList mIntents = new ArrayList<>(); private PickTargetOptionRequest mPickOptionRequest; // Expected to be true if this object is ResolverActivity or is ResolverWrapperActivity. private final boolean mIsIntentPicker; @@ -192,7 +195,6 @@ public class ResolverActivity extends FragmentActivity implements @VisibleForTesting protected MultiProfilePagerAdapter mMultiProfilePagerAdapter; - protected WorkProfileAvailabilityManager mWorkProfileAvailability; // Intent extra for connected audio devices public static final String EXTRA_IS_AUDIO_CAPTURE_DEVICE = "is_audio_capture_device"; @@ -318,8 +320,6 @@ public class ResolverActivity extends FragmentActivity implements mLogic.preInitialization(); init( mLogic.getTargetIntent(), - mLogic.getAdditionalTargets() == null - ? null : mLogic.getAdditionalTargets().toArray(new Intent[0]), mLogic.getInitialIntents() == null ? null : mLogic.getInitialIntents().toArray(new Intent[0]), mLogic.getTargetDataLoader() @@ -328,7 +328,6 @@ public class ResolverActivity extends FragmentActivity implements protected void init( Intent intent, - Intent[] additionalTargets, Intent[] initialIntents, TargetDataLoader targetDataLoader ) { @@ -338,16 +337,8 @@ public class ResolverActivity extends FragmentActivity implements return; } - mWorkProfileAvailability = createWorkProfileAvailabilityManager(); - mPm = getPackageManager(); - // The initial intent must come before any other targets that are to be added. - mIntents.add(0, new Intent(intent)); - if (additionalTargets != null) { - Collections.addAll(mIntents, additionalTargets); - } - // The last argument of createResolverListAdapter is whether to do special handling // of the last used choice to highlight it in the list. We need to always // turn this off when running under voice interaction, since it results in @@ -582,7 +573,7 @@ public class ResolverActivity extends FragmentActivity implements } } // TODO: should we clean up the work-profile manager before we potentially finish() above? - mWorkProfileAvailability.unregisterWorkProfileStateReceiver(this); + mLogic.getWorkProfileAvailabilityManager().unregisterWorkProfileStateReceiver(this); } @Override @@ -857,7 +848,7 @@ public class ResolverActivity extends FragmentActivity implements ResolverRankerServiceResolverComparator resolverComparator = new ResolverRankerServiceResolverComparator( this, - getTargetIntent(), + mLogic.getTargetIntent(), mLogic.getReferrerPackageName(), null, null, @@ -866,7 +857,7 @@ public class ResolverActivity extends FragmentActivity implements return new ResolverListController( this, mPm, - getTargetIntent(), + mLogic.getTargetIntent(), mLogic.getReferrerPackageName(), requireAnnotatedUserHandles().userIdOfCallingApp, resolverComparator, @@ -958,7 +949,7 @@ public class ResolverActivity extends FragmentActivity implements if (listAdapter == mMultiProfilePagerAdapter.getActiveListAdapter()) { if (listAdapter.getUserHandle().equals( requireAnnotatedUserHandles().workProfileUserHandle) - && mWorkProfileAvailability.isWaitingToEnableWorkProfile()) { + && mLogic.getWorkProfileAvailabilityManager().isWaitingToEnableWorkProfile()) { // We have just turned on the work profile and entered the pass code to start it, // now we are waiting to receive the ACTION_USER_UNLOCKED broadcast. There is no // point in reloading the list now, since the work profile user is still @@ -994,20 +985,14 @@ public class ResolverActivity extends FragmentActivity implements return new CrossProfileIntentsChecker(getContentResolver()); } - protected WorkProfileAvailabilityManager createWorkProfileAvailabilityManager() { - return new WorkProfileAvailabilityManager( - getSystemService(UserManager.class), - requireAnnotatedUserHandles().workProfileUserHandle, - this::onWorkProfileStatusUpdated); - } - - protected void onWorkProfileStatusUpdated() { + protected Unit onWorkProfileStatusUpdated() { if (mMultiProfilePagerAdapter.getCurrentUserHandle().equals( requireAnnotatedUserHandles().workProfileUserHandle)) { mMultiProfilePagerAdapter.rebuildActiveTab(true); } else { mMultiProfilePagerAdapter.clearInactiveProfileCache(); } + return Unit.INSTANCE; } // @NonFinalForTesting @@ -1031,7 +1016,7 @@ public class ResolverActivity extends FragmentActivity implements filterLastUsed, createListController(userHandle), userHandle, - getTargetIntent(), + mLogic.getTargetIntent(), this, initialIntentsUserSpace, targetDataLoader); @@ -1059,7 +1044,7 @@ public class ResolverActivity extends FragmentActivity implements final EmptyStateProvider workProfileOffEmptyStateProvider = new WorkProfilePausedEmptyStateProvider(this, workProfileUserHandle, - mWorkProfileAvailability, + mLogic.getWorkProfileAvailabilityManager(), /* onSwitchOnWorkSelectedListener= */ () -> { if (mOnSwitchOnWorkSelectedListener != null) { @@ -1092,7 +1077,7 @@ public class ResolverActivity extends FragmentActivity implements TargetDataLoader targetDataLoader) { ResolverListAdapter adapter = createResolverListAdapter( /* context */ this, - /* payloadIntents */ mIntents, + mLogic.getPayloadIntents(), initialIntents, resolutionList, filterLastUsed, @@ -1140,7 +1125,7 @@ public class ResolverActivity extends FragmentActivity implements // resolver list. So filterLastUsed should be false for the other profile. ResolverListAdapter personalAdapter = createResolverListAdapter( /* context */ this, - /* payloadIntents */ mIntents, + mLogic.getPayloadIntents(), selectedProfile == PROFILE_PERSONAL ? initialIntents : null, resolutionList, (filterLastUsed && UserHandle.myUserId() @@ -1150,7 +1135,7 @@ public class ResolverActivity extends FragmentActivity implements UserHandle workProfileUserHandle = requireAnnotatedUserHandles().workProfileUserHandle; ResolverListAdapter workAdapter = createResolverListAdapter( /* context */ this, - /* payloadIntents */ mIntents, + mLogic.getPayloadIntents(), selectedProfile == PROFILE_WORK ? initialIntents : null, resolutionList, (filterLastUsed && UserHandle.myUserId() @@ -1162,7 +1147,7 @@ public class ResolverActivity extends FragmentActivity implements personalAdapter, workAdapter, createEmptyStateProvider(workProfileUserHandle), - () -> mWorkProfileAvailability.isQuietModeEnabled(), + () -> mLogic.getWorkProfileAvailabilityManager().isQuietModeEnabled(), selectedProfile, workProfileUserHandle, requireAnnotatedUserHandles().cloneProfileUserHandle); @@ -1286,10 +1271,6 @@ public class ResolverActivity extends FragmentActivity implements return new Option(getOrLoadDisplayLabel(target), index); } - public final Intent getTargetIntent() { - return mIntents.isEmpty() ? null : mIntents.get(0); - } - @Override // ResolverListCommunicator public final void updateProfileViewButton() { if (mProfileView == null) { @@ -1360,9 +1341,11 @@ public class ResolverActivity extends FragmentActivity implements } mRegistered = true; } - if (shouldShowTabs() && mWorkProfileAvailability.isWaitingToEnableWorkProfile()) { - if (mWorkProfileAvailability.isQuietModeEnabled()) { - mWorkProfileAvailability.markWorkProfileEnabledBroadcastReceived(); + WorkProfileAvailabilityManager workProfileAvailabilityManager = + mLogic.getWorkProfileAvailabilityManager(); + if (shouldShowTabs() && workProfileAvailabilityManager.isWaitingToEnableWorkProfile()) { + if (workProfileAvailabilityManager.isQuietModeEnabled()) { + workProfileAvailabilityManager.markWorkProfileEnabledBroadcastReceived(); } } mMultiProfilePagerAdapter.getActiveListAdapter().handlePackagesChanged(); @@ -1375,7 +1358,7 @@ public class ResolverActivity extends FragmentActivity implements this.getWindow().addSystemFlags(SYSTEM_FLAG_HIDE_NON_SYSTEM_OVERLAY_WINDOWS); if (shouldShowTabs()) { - mWorkProfileAvailability.registerWorkProfileStateReceiver(this); + mLogic.getWorkProfileAvailabilityManager().registerWorkProfileStateReceiver(this); } } @@ -2062,7 +2045,7 @@ public class ResolverActivity extends FragmentActivity implements CharSequence title = mLogic.getTitle() != null ? mLogic.getTitle() - : getTitleForAction(getTargetIntent(), mLogic.getDefaultTitleResId()); + : getTitleForAction(mLogic.getTargetIntent(), mLogic.getDefaultTitleResId()); if (!TextUtils.isEmpty(title)) { final TextView titleView = findViewById(com.android.internal.R.id.title); diff --git a/java/src/com/android/intentresolver/v2/ResolverActivityLogic.kt b/java/src/com/android/intentresolver/v2/ResolverActivityLogic.kt index 1b936159..8cca5b85 100644 --- a/java/src/com/android/intentresolver/v2/ResolverActivityLogic.kt +++ b/java/src/com/android/intentresolver/v2/ResolverActivityLogic.kt @@ -2,16 +2,25 @@ package com.android.intentresolver.v2 import android.content.Intent import androidx.activity.ComponentActivity +import androidx.annotation.OpenForTesting import com.android.intentresolver.R import com.android.intentresolver.icons.DefaultTargetDataLoader import com.android.intentresolver.icons.TargetDataLoader import com.android.intentresolver.v2.util.mutableLazy /** Activity logic for [ResolverActivity]. */ -class ResolverActivityLogic( +@OpenForTesting +open class ResolverActivityLogic( tag: String, activityProvider: () -> ComponentActivity, -) : ActivityLogic, CommonActivityLogic by CommonActivityLogicImpl(tag, activityProvider) { + onWorkProfileStatusUpdated: () -> Unit, +) : + ActivityLogic, + CommonActivityLogic by CommonActivityLogicImpl( + tag, + activityProvider, + onWorkProfileStatusUpdated, + ) { override val targetIntent: Intent by lazy { val intent = Intent(activity.intent) @@ -37,8 +46,6 @@ class ResolverActivityLogic( targetIntent.categories.contains(Intent.CATEGORY_HOME) } - override val additionalTargets: List? = null - override val title: CharSequence? = null override val defaultTitleResId: Int = 0 @@ -63,6 +70,8 @@ class ResolverActivityLogic( private val _profileSwitchMessage = mutableLazy { forwardMessageFor(targetIntent) } override val profileSwitchMessage: String? by _profileSwitchMessage + override val payloadIntents: List by lazy { listOf(targetIntent) } + override fun preInitialization() { // Do nothing } diff --git a/java/tests/src/com/android/intentresolver/v2/ChooserWrapperActivity.java b/java/tests/src/com/android/intentresolver/v2/ChooserWrapperActivity.java index 6fdba4c2..5572bb24 100644 --- a/java/tests/src/com/android/intentresolver/v2/ChooserWrapperActivity.java +++ b/java/tests/src/com/android/intentresolver/v2/ChooserWrapperActivity.java @@ -38,7 +38,6 @@ import com.android.intentresolver.ChooserRequestParameters; import com.android.intentresolver.IChooserWrapper; import com.android.intentresolver.ResolverListController; import com.android.intentresolver.TestContentPreviewViewModel; -import com.android.intentresolver.WorkProfileAvailabilityManager; import com.android.intentresolver.chooser.DisplayResolveInfo; import com.android.intentresolver.chooser.TargetInfo; import com.android.intentresolver.emptystate.CrossProfileIntentsChecker; @@ -63,8 +62,9 @@ public class ChooserWrapperActivity extends ChooserActivity implements IChooserW mLogic = new TestChooserActivityLogic( "ChooserWrapper", () -> this, + this::onWorkProfileStatusUpdated, () -> mTargetDataLoader, - super::onPreinitialization, + this::onPreinitialization, sOverrides ); } @@ -159,14 +159,6 @@ public class ChooserWrapperActivity extends ChooserActivity implements IChooserW return super.createCrossProfileIntentsChecker(); } - @Override - protected WorkProfileAvailabilityManager createWorkProfileAvailabilityManager() { - if (sOverrides.mWorkProfileAvailability != null) { - return sOverrides.mWorkProfileAvailability; - } - return super.createWorkProfileAvailabilityManager(); - } - @Override public void safelyStartActivityInternal(TargetInfo cti, UserHandle user, @Nullable Bundle options) { diff --git a/java/tests/src/com/android/intentresolver/v2/ResolverWrapperActivity.java b/java/tests/src/com/android/intentresolver/v2/ResolverWrapperActivity.java index e5617090..92b73d92 100644 --- a/java/tests/src/com/android/intentresolver/v2/ResolverWrapperActivity.java +++ b/java/tests/src/com/android/intentresolver/v2/ResolverWrapperActivity.java @@ -45,6 +45,8 @@ import com.android.intentresolver.emptystate.CrossProfileIntentsChecker; import com.android.intentresolver.icons.LabelInfo; import com.android.intentresolver.icons.TargetDataLoader; +import kotlin.Unit; + import java.util.List; import java.util.function.Consumer; import java.util.function.Function; @@ -63,6 +65,10 @@ public class ResolverWrapperActivity extends ResolverActivity { mLogic = new TestResolverActivityLogic( "ResolverWrapper", () -> this, + () -> { + onWorkProfileStatusUpdated(); + return Unit.INSTANCE; + }, sOverrides ); } @@ -102,14 +108,6 @@ public class ResolverWrapperActivity extends ResolverActivity { return super.createCrossProfileIntentsChecker(); } - @Override - protected WorkProfileAvailabilityManager createWorkProfileAvailabilityManager() { - if (sOverrides.mWorkProfileAvailability != null) { - return sOverrides.mWorkProfileAvailability; - } - return super.createWorkProfileAvailabilityManager(); - } - ResolverListAdapter getAdapter() { return mMultiProfilePagerAdapter.getActiveListAdapter(); } diff --git a/java/tests/src/com/android/intentresolver/v2/TestChooserActivityLogic.kt b/java/tests/src/com/android/intentresolver/v2/TestChooserActivityLogic.kt index fb1eab6c..198b9236 100644 --- a/java/tests/src/com/android/intentresolver/v2/TestChooserActivityLogic.kt +++ b/java/tests/src/com/android/intentresolver/v2/TestChooserActivityLogic.kt @@ -2,19 +2,22 @@ package com.android.intentresolver.v2 import androidx.activity.ComponentActivity import com.android.intentresolver.AnnotatedUserHandles +import com.android.intentresolver.WorkProfileAvailabilityManager import com.android.intentresolver.icons.TargetDataLoader /** Activity logic for use when testing [ChooserActivity]. */ class TestChooserActivityLogic( tag: String, activityProvider: () -> ComponentActivity, + onWorkProfileStatusUpdated: () -> Unit, targetDataLoaderProvider: () -> TargetDataLoader, onPreinitialization: () -> Unit, - overrideData: ChooserActivityOverrideData, + private val overrideData: ChooserActivityOverrideData, ) : ChooserActivityLogic( tag, activityProvider, + onWorkProfileStatusUpdated, targetDataLoaderProvider, onPreinitialization, ) { @@ -22,4 +25,8 @@ class TestChooserActivityLogic( override val annotatedUserHandles: AnnotatedUserHandles? by lazy { overrideData.annotatedUserHandles } + + override val workProfileAvailabilityManager: WorkProfileAvailabilityManager by lazy { + overrideData.mWorkProfileAvailability ?: super.workProfileAvailabilityManager + } } diff --git a/java/tests/src/com/android/intentresolver/v2/TestResolverActivityLogic.kt b/java/tests/src/com/android/intentresolver/v2/TestResolverActivityLogic.kt index 7f8e6f70..7581043e 100644 --- a/java/tests/src/com/android/intentresolver/v2/TestResolverActivityLogic.kt +++ b/java/tests/src/com/android/intentresolver/v2/TestResolverActivityLogic.kt @@ -2,15 +2,21 @@ package com.android.intentresolver.v2 import androidx.activity.ComponentActivity import com.android.intentresolver.AnnotatedUserHandles +import com.android.intentresolver.WorkProfileAvailabilityManager /** Activity logic for use when testing [ResolverActivity]. */ class TestResolverActivityLogic( tag: String, activityProvider: () -> ComponentActivity, - overrideData: ResolverWrapperActivity.OverrideData, -) : ActivityLogic by ResolverActivityLogic(tag, activityProvider) { + onWorkProfileStatusUpdated: () -> Unit, + private val overrideData: ResolverWrapperActivity.OverrideData, +) : ResolverActivityLogic(tag, activityProvider, onWorkProfileStatusUpdated) { override val annotatedUserHandles: AnnotatedUserHandles? by lazy { overrideData.annotatedUserHandles } + + override val workProfileAvailabilityManager: WorkProfileAvailabilityManager by lazy { + overrideData.mWorkProfileAvailability ?: super.workProfileAvailabilityManager + } } -- cgit v1.2.3-59-g8ed1b From 43fe454f23a95ec11f25620975340bf392ddb161 Mon Sep 17 00:00:00 2001 From: mrenouf Date: Tue, 14 Nov 2023 14:03:27 -0500 Subject: Rename UserDataSource to UserRepository Adds requestState to allow modification of user profile state, including availability (quiet mode). Test: UserRepositoryImplTest Change-Id: Ic38f24475c73390841ee599c48d965117981faa0 --- .../src/com/android/intentresolver/v2/data/User.kt | 75 ------ .../intentresolver/v2/data/UserDataSource.kt | 227 ------------------ .../intentresolver/v2/data/UserDataSourceModule.kt | 34 --- .../android/intentresolver/v2/data/model/User.kt | 50 ++++ .../v2/data/repository/UserInfoExt.kt | 29 +++ .../v2/data/repository/UserRepository.kt | 261 +++++++++++++++++++++ .../v2/data/repository/UserRepositoryModule.kt | 34 +++ .../v2/data/UserDataSourceImplTest.kt | 194 --------------- .../v2/data/repository/UserRepositoryImplTest.kt | 222 ++++++++++++++++++ .../intentresolver/v2/platform/FakeUserManager.kt | 39 ++- 10 files changed, 632 insertions(+), 533 deletions(-) delete mode 100644 java/src/com/android/intentresolver/v2/data/User.kt delete mode 100644 java/src/com/android/intentresolver/v2/data/UserDataSource.kt delete mode 100644 java/src/com/android/intentresolver/v2/data/UserDataSourceModule.kt create mode 100644 java/src/com/android/intentresolver/v2/data/model/User.kt create mode 100644 java/src/com/android/intentresolver/v2/data/repository/UserInfoExt.kt create mode 100644 java/src/com/android/intentresolver/v2/data/repository/UserRepository.kt create mode 100644 java/src/com/android/intentresolver/v2/data/repository/UserRepositoryModule.kt delete mode 100644 java/tests/src/com/android/intentresolver/v2/data/UserDataSourceImplTest.kt create mode 100644 java/tests/src/com/android/intentresolver/v2/data/repository/UserRepositoryImplTest.kt (limited to 'java/src') diff --git a/java/src/com/android/intentresolver/v2/data/User.kt b/java/src/com/android/intentresolver/v2/data/User.kt deleted file mode 100644 index d8a4af74..00000000 --- a/java/src/com/android/intentresolver/v2/data/User.kt +++ /dev/null @@ -1,75 +0,0 @@ -package com.android.intentresolver.v2.data - -import android.annotation.UserIdInt -import android.content.pm.UserInfo -import android.os.UserHandle -import com.android.intentresolver.v2.data.User.Role -import com.android.intentresolver.v2.data.User.Type -import com.android.intentresolver.v2.data.User.Type.FULL -import com.android.intentresolver.v2.data.User.Type.PROFILE - -/** - * A User represents the owner of a distinct set of content. - * * maps 1:1 to a UserHandle or UserId (Int) value. - * * refers to either [Full][Type.FULL], or a [Profile][Type.PROFILE] user, as indicated by the - * [type] property. - * - * See - * [Users for system developers](https://android.googlesource.com/platform/frameworks/base/+/master/core/java/android/os/Users.md) - * - * ``` - * fun example() { - * User(id = 0, role = PERSONAL) - * User(id = 10, role = WORK) - * User(id = 11, role = CLONE) - * User(id = 12, role = PRIVATE) - * } - * ``` - */ -data class User( - @UserIdInt val id: Int, - val role: Role, -) { - val handle: UserHandle = UserHandle.of(id) - - 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) - } -} - -fun UserInfo.getSupportedUserRole(): Role? = - when { - isFull -> Role.PERSONAL - isManagedProfile -> Role.WORK - isCloneProfile -> Role.CLONE - isPrivateProfile -> Role.PRIVATE - else -> null - } - -/** - * Creates a [User], based on values from a [UserInfo]. - * - * ``` - * val users: List = - * getEnabledProfiles(user).map(::toUser).filterNotNull() - * ``` - * - * @return a [User] if the [UserInfo] matched a supported [Role], otherwise null - */ -fun UserInfo.toUser(): User? { - return getSupportedUserRole()?.let { role -> User(userHandle.identifier, role) } -} diff --git a/java/src/com/android/intentresolver/v2/data/UserDataSource.kt b/java/src/com/android/intentresolver/v2/data/UserDataSource.kt deleted file mode 100644 index 9eecc3be..00000000 --- a/java/src/com/android/intentresolver/v2/data/UserDataSource.kt +++ /dev/null @@ -1,227 +0,0 @@ -package com.android.intentresolver.v2.data - -import android.content.Context -import android.content.Intent -import android.content.Intent.ACTION_MANAGED_PROFILE_AVAILABLE -import android.content.Intent.ACTION_MANAGED_PROFILE_UNAVAILABLE -import android.content.Intent.ACTION_PROFILE_ADDED -import android.content.Intent.ACTION_PROFILE_AVAILABLE -import android.content.Intent.ACTION_PROFILE_REMOVED -import android.content.Intent.ACTION_PROFILE_UNAVAILABLE -import android.content.Intent.EXTRA_QUIET_MODE -import android.content.Intent.EXTRA_USER -import android.content.IntentFilter -import android.content.pm.UserInfo -import android.os.UserHandle -import android.os.UserManager -import android.util.Log -import androidx.annotation.VisibleForTesting -import com.android.intentresolver.inject.Background -import com.android.intentresolver.inject.Main -import com.android.intentresolver.inject.ProfileParent -import com.android.intentresolver.v2.data.UserDataSourceImpl.UserEvent -import dagger.hilt.android.qualifiers.ApplicationContext -import javax.inject.Inject -import kotlinx.coroutines.CoroutineDispatcher -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.SharingStarted -import kotlinx.coroutines.flow.distinctUntilChanged -import kotlinx.coroutines.flow.filterNot -import kotlinx.coroutines.flow.map -import kotlinx.coroutines.flow.onEach -import kotlinx.coroutines.flow.onStart -import kotlinx.coroutines.flow.runningFold -import kotlinx.coroutines.flow.stateIn -import kotlinx.coroutines.withContext - -interface UserDataSource { - /** - * A [Flow] user profile groups. Each map contains the context user along with all members of - * the profile group. This includes the (Full) parent user, if the context user is a profile. - */ - val users: Flow> - - /** - * A [Flow] of availability. Only profile users may become unavailable. - * - * Availability is currently defined as not being in [quietMode][UserInfo.isQuietModeEnabled]. - */ - fun isAvailable(handle: UserHandle): Flow -} - -private const val TAG = "UserDataSource" - -private data class UserWithState(val user: User, val available: Boolean) - -private typealias UserStateMap = Map - -/** Tracks and publishes state for the parent user and associated profiles. */ -class UserDataSourceImpl -@VisibleForTesting -constructor( - private val profileParent: UserHandle, - private val userManager: UserManager, - /** A flow of events which represent user-state changes from [UserManager]. */ - private val userEvents: Flow, - scope: CoroutineScope, - private val backgroundDispatcher: CoroutineDispatcher -) : UserDataSource { - @Inject - constructor( - @ApplicationContext context: Context, - @ProfileParent profileParent: UserHandle, - userManager: UserManager, - @Main scope: CoroutineScope, - @Background background: CoroutineDispatcher - ) : this( - profileParent, - userManager, - userEvents = userBroadcastFlow(context, profileParent), - scope, - background - ) - - data class UserEvent(val action: String, val user: UserHandle, val quietMode: Boolean = false) - - /** - * An exception which indicates that an inconsistency exists between the user state map and the - * rest of the system. - */ - internal class UserStateException( - override val message: String, - val event: UserEvent, - override val cause: Throwable? = null - ) : RuntimeException("$message: event=$event", cause) - - private val usersWithState: Flow = - userEvents - .onStart { emit(UserEvent(INITIALIZE, profileParent)) } - .onEach { Log.i("UserDataSource", "userEvent: $it") } - .runningFold(emptyMap()) { users, event -> - try { - // Handle an action by performing some operation, then returning a new map - when (event.action) { - INITIALIZE -> createNewUserStateMap(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...") - createNewUserStateMap(profileParent) - } - } - .onEach { Log.i("UserDataSource", "userStateMap: $it") } - .stateIn(scope, SharingStarted.Eagerly, emptyMap()) - .filterNot { it.isEmpty() } - - override val users: Flow> = - usersWithState.map { map -> map.mapValues { it.value.user } }.distinctUntilChanged() - - private val availability: Flow> = - usersWithState.map { map -> map.mapValues { it.value.available } }.distinctUntilChanged() - - override fun isAvailable(handle: UserHandle): Flow { - return availability.map { it[handle] ?: false } - } - - private fun handleAvailability(event: UserEvent, current: UserStateMap): UserStateMap { - val userEntry = - current[event.user] - ?: throw UserStateException("User was not present in the map", event) - return current + (event.user to userEntry.copy(available = !event.quietMode)) - } - - private fun handleProfileRemoved(event: UserEvent, current: UserStateMap): UserStateMap { - if (!current.containsKey(event.user)) { - throw UserStateException("User was not present in the map", event) - } - return current.filterKeys { it != event.user } - } - - private suspend fun handleProfileAdded(event: UserEvent, current: UserStateMap): UserStateMap { - val user = - try { - requireNotNull(readUser(event.user)) - } catch (e: Exception) { - throw UserStateException("Failed to read user from UserManager", event, e) - } - return current + (event.user to UserWithState(user, !event.quietMode)) - } - - private suspend fun createNewUserStateMap(user: UserHandle): UserStateMap { - val profiles = readProfileGroup(user) - return profiles - .mapNotNull { userInfo -> - userInfo.toUser()?.let { user -> UserWithState(user, userInfo.isAvailable()) } - } - .associateBy { it.user.handle } - } - - private suspend fun readProfileGroup(handle: UserHandle): List { - return withContext(backgroundDispatcher) { - @Suppress("DEPRECATION") userManager.getEnabledProfiles(handle.identifier) - } - .toList() - } - - /** Read [UserInfo] from [UserManager], or null if not found or an unsupported type. */ - private suspend fun readUser(user: UserHandle): User? { - val userInfo = - withContext(backgroundDispatcher) { userManager.getUserInfo(user.identifier) } - return userInfo?.let { info -> - info.getSupportedUserRole()?.let { role -> User(info.id, role) } - } - } -} - -/** Used with [broadcastFlow] to transform a UserManager broadcast action into a [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) - } -} - -const val INITIALIZE = "INITIALIZE" - -private fun createFilter(actions: Iterable): IntentFilter { - return IntentFilter().apply { actions.forEach(::addAction) } -} - -private fun UserInfo?.isAvailable(): Boolean { - return this?.isQuietModeEnabled != true -} - -private fun userBroadcastFlow(context: Context, profileParent: UserHandle): Flow { - val userActions = - setOf( - ACTION_PROFILE_ADDED, - ACTION_PROFILE_REMOVED, - - // Quiet mode enabled/disabled for managed - // From: UserController.broadcastProfileAvailabilityChanges - // In response to setQuietModeEnabled - ACTION_MANAGED_PROFILE_AVAILABLE, // quiet mode, sent for manage profiles only - ACTION_MANAGED_PROFILE_UNAVAILABLE, // quiet mode, sent for manage profiles only - - // Quiet mode toggled for profile type, requires flag 'android.os.allow_private_profile - // true' - ACTION_PROFILE_AVAILABLE, // quiet mode, - ACTION_PROFILE_UNAVAILABLE, // quiet mode, sent for any profile type - ) - return broadcastFlow(context, createFilter(userActions), profileParent, Intent::toUserEvent) -} diff --git a/java/src/com/android/intentresolver/v2/data/UserDataSourceModule.kt b/java/src/com/android/intentresolver/v2/data/UserDataSourceModule.kt deleted file mode 100644 index 94f39eb7..00000000 --- a/java/src/com/android/intentresolver/v2/data/UserDataSourceModule.kt +++ /dev/null @@ -1,34 +0,0 @@ -package com.android.intentresolver.v2.data - -import android.content.Context -import android.os.UserHandle -import android.os.UserManager -import com.android.intentresolver.inject.ApplicationUser -import com.android.intentresolver.inject.ProfileParent -import dagger.Binds -import dagger.Module -import dagger.Provides -import dagger.hilt.InstallIn -import dagger.hilt.android.qualifiers.ApplicationContext -import dagger.hilt.components.SingletonComponent -import javax.inject.Singleton - -@Module -@InstallIn(SingletonComponent::class) -interface UserDataSourceModule { - companion object { - @Provides - @Singleton - @ApplicationUser - fun applicationUser(@ApplicationContext context: Context): UserHandle = context.user - - @Provides - @Singleton - @ProfileParent - fun profileParent(@ApplicationUser user: UserHandle, userManager: UserManager): UserHandle { - return userManager.getProfileParent(user) ?: user - } - } - - @Binds @Singleton fun userDataSource(impl: UserDataSourceImpl): UserDataSource -} diff --git a/java/src/com/android/intentresolver/v2/data/model/User.kt b/java/src/com/android/intentresolver/v2/data/model/User.kt new file mode 100644 index 00000000..504b04c8 --- /dev/null +++ b/java/src/com/android/intentresolver/v2/data/model/User.kt @@ -0,0 +1,50 @@ +package com.android.intentresolver.v2.data.model + +import android.annotation.UserIdInt +import android.os.UserHandle +import com.android.intentresolver.v2.data.model.User.Type +import com.android.intentresolver.v2.data.model.User.Type.FULL +import com.android.intentresolver.v2.data.model.User.Type.PROFILE + +/** + * A User represents the owner of a distinct set of content. + * * maps 1:1 to a UserHandle or UserId (Int) value. + * * refers to either [Full][Type.FULL], or a [Profile][Type.PROFILE] user, as indicated by the + * [type] property. + * + * See + * [Users for system developers](https://android.googlesource.com/platform/frameworks/base/+/master/core/java/android/os/Users.md) + * + * ``` + * val users = listOf( + * User(id = 0, role = PERSONAL), + * User(id = 10, role = WORK), + * User(id = 11, role = CLONE), + * User(id = 12, role = PRIVATE), + * ) + * ``` + */ +data class User( + @UserIdInt val id: Int, + val role: Role, +) { + val handle: UserHandle = UserHandle.of(id) + + 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) + } +} diff --git a/java/src/com/android/intentresolver/v2/data/repository/UserInfoExt.kt b/java/src/com/android/intentresolver/v2/data/repository/UserInfoExt.kt new file mode 100644 index 00000000..fc82efee --- /dev/null +++ b/java/src/com/android/intentresolver/v2/data/repository/UserInfoExt.kt @@ -0,0 +1,29 @@ +package com.android.intentresolver.v2.data.repository + +import android.content.pm.UserInfo +import com.android.intentresolver.v2.data.model.User +import com.android.intentresolver.v2.data.model.User.Role + +/** Maps the UserInfo to one of the defined [Roles][User.Role], if possible. */ +fun UserInfo.getSupportedUserRole(): Role? = + when { + isFull -> Role.PERSONAL + isManagedProfile -> Role.WORK + isCloneProfile -> Role.CLONE + isPrivateProfile -> Role.PRIVATE + else -> null + } + +/** + * Creates a [User], based on values from a [UserInfo]. + * + * ``` + * val users: List = + * getEnabledProfiles(user).map(::toUser).filterNotNull() + * ``` + * + * @return a [User] if the [UserInfo] matched a supported [Role], otherwise null + */ +fun UserInfo.toUser(): User? { + return getSupportedUserRole()?.let { role -> User(userHandle.identifier, role) } +} diff --git a/java/src/com/android/intentresolver/v2/data/repository/UserRepository.kt b/java/src/com/android/intentresolver/v2/data/repository/UserRepository.kt new file mode 100644 index 00000000..dc809b46 --- /dev/null +++ b/java/src/com/android/intentresolver/v2/data/repository/UserRepository.kt @@ -0,0 +1,261 @@ +package com.android.intentresolver.v2.data.repository + +import android.content.Context +import android.content.Intent +import android.content.Intent.ACTION_MANAGED_PROFILE_AVAILABLE +import android.content.Intent.ACTION_MANAGED_PROFILE_UNAVAILABLE +import android.content.Intent.ACTION_PROFILE_ADDED +import android.content.Intent.ACTION_PROFILE_AVAILABLE +import android.content.Intent.ACTION_PROFILE_REMOVED +import android.content.Intent.ACTION_PROFILE_UNAVAILABLE +import android.content.Intent.EXTRA_QUIET_MODE +import android.content.Intent.EXTRA_USER +import android.content.IntentFilter +import android.content.pm.UserInfo +import android.os.UserHandle +import android.os.UserManager +import android.util.Log +import androidx.annotation.VisibleForTesting +import com.android.intentresolver.inject.Background +import com.android.intentresolver.inject.Main +import com.android.intentresolver.inject.ProfileParent +import com.android.intentresolver.v2.data.broadcastFlow +import com.android.intentresolver.v2.data.model.User +import com.android.intentresolver.v2.data.repository.UserRepositoryImpl.UserEvent +import dagger.hilt.android.qualifiers.ApplicationContext +import javax.inject.Inject +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.filterNot +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.flow.onStart +import kotlinx.coroutines.flow.runningFold +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.withContext + +interface UserRepository { + /** + * A [Flow] user profile groups. Each map contains the context user along with all members of + * the profile group. This includes the (Full) parent user, if the context user is a profile. + */ + val users: Flow> + + /** + * A [Flow] of availability. Only profile users may become unavailable. + * + * Availability is currently defined as not being in [quietMode][UserInfo.isQuietModeEnabled]. + */ + fun isAvailable(user: User): Flow + + /** + * Request that availability be updated to the requested state. This currently includes toggling + * quiet mode as needed. This may involve additional background actions, such as starting or + * stopping a profile user (along with their many associated processes). + * + * If successful, the change will be applied after the call returns and can be observed using + * [UserRepository.isAvailable] for the given user. + * + * No actions are taken if the user is already in requested state. + * + * @throws IllegalArgumentException if called for an unsupported user type + */ + suspend fun requestState(user: User, available: Boolean) +} + +private const val TAG = "UserRepository" + +private data class UserWithState(val user: User, val available: Boolean) + +private typealias UserStateMap = Map + +/** Tracks and publishes state for the parent user and associated profiles. */ +class UserRepositoryImpl +@VisibleForTesting +constructor( + private val profileParent: UserHandle, + private val userManager: UserManager, + /** A flow of events which represent user-state changes from [UserManager]. */ + private val userEvents: Flow, + scope: CoroutineScope, + private val backgroundDispatcher: CoroutineDispatcher +) : UserRepository { + @Inject + constructor( + @ApplicationContext context: Context, + @ProfileParent profileParent: UserHandle, + userManager: UserManager, + @Main scope: CoroutineScope, + @Background background: CoroutineDispatcher + ) : this( + profileParent, + userManager, + userEvents = userBroadcastFlow(context, profileParent), + scope, + background + ) + + data class UserEvent(val action: String, val user: UserHandle, val quietMode: Boolean = false) + + /** + * An exception which indicates that an inconsistency exists between the user state map and the + * rest of the system. + */ + internal class UserStateException( + override val message: String, + val event: UserEvent, + override val cause: Throwable? = null + ) : RuntimeException("$message: event=$event", cause) + + private val usersWithState: Flow = + userEvents + .onStart { emit(UserEvent(INITIALIZE, profileParent)) } + .onEach { Log.i("UserDataSource", "userEvent: $it") } + .runningFold(emptyMap()) { users, event -> + try { + // Handle an action by performing some operation, then returning a new map + when (event.action) { + INITIALIZE -> createNewUserStateMap(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...") + createNewUserStateMap(profileParent) + } + } + .onEach { Log.i("UserDataSource", "userStateMap: $it") } + .stateIn(scope, SharingStarted.Eagerly, emptyMap()) + .filterNot { it.isEmpty() } + + override val users: Flow> = + usersWithState.map { map -> map.mapValues { it.value.user } }.distinctUntilChanged() + + private val availability: Flow> = + usersWithState.map { map -> map.mapValues { it.value.available } }.distinctUntilChanged() + + override fun isAvailable(user: User): Flow { + return isAvailable(user.handle) + } + + @VisibleForTesting + fun isAvailable(handle: UserHandle): Flow { + return availability.map { it[handle] ?: false } + } + + override suspend fun requestState(user: User, available: Boolean) { + require(user.type == User.Type.PROFILE) { "Only profile users are supported" } + return requestState(user.handle, available) + } + + @VisibleForTesting + suspend fun requestState(user: UserHandle, available: Boolean) { + return withContext(backgroundDispatcher) { + Log.i(TAG, "requestQuietModeEnabled: ${!available} for user $user") + userManager.requestQuietModeEnabled(/* enableQuietMode = */ !available, user) + } + } + + private fun handleAvailability(event: UserEvent, current: UserStateMap): UserStateMap { + val userEntry = + current[event.user] + ?: throw UserStateException("User was not present in the map", event) + return current + (event.user to userEntry.copy(available = !event.quietMode)) + } + + private fun handleProfileRemoved(event: UserEvent, current: UserStateMap): UserStateMap { + if (!current.containsKey(event.user)) { + throw UserStateException("User was not present in the map", event) + } + return current.filterKeys { it != event.user } + } + + private suspend fun handleProfileAdded(event: UserEvent, current: UserStateMap): UserStateMap { + val user = + try { + requireNotNull(readUser(event.user)) + } catch (e: Exception) { + throw UserStateException("Failed to read user from UserManager", event, e) + } + return current + (event.user to UserWithState(user, !event.quietMode)) + } + + private suspend fun createNewUserStateMap(user: UserHandle): UserStateMap { + val profiles = readProfileGroup(user) + return profiles + .mapNotNull { userInfo -> + userInfo.toUser()?.let { user -> UserWithState(user, userInfo.isAvailable()) } + } + .associateBy { it.user.handle } + } + + private suspend fun readProfileGroup(handle: UserHandle): List { + return withContext(backgroundDispatcher) { + @Suppress("DEPRECATION") userManager.getEnabledProfiles(handle.identifier) + } + .toList() + } + + /** Read [UserInfo] from [UserManager], or null if not found or an unsupported type. */ + private suspend fun readUser(user: UserHandle): User? { + val userInfo = + withContext(backgroundDispatcher) { userManager.getUserInfo(user.identifier) } + return userInfo?.let { info -> + info.getSupportedUserRole()?.let { role -> User(info.id, role) } + } + } +} + +/** Used with [broadcastFlow] to transform a UserManager broadcast action into a [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) + } +} + +const val INITIALIZE = "INITIALIZE" + +private fun createFilter(actions: Iterable): IntentFilter { + return IntentFilter().apply { actions.forEach(::addAction) } +} + +private fun UserInfo?.isAvailable(): Boolean { + return this?.isQuietModeEnabled != true +} + +private fun userBroadcastFlow(context: Context, profileParent: UserHandle): Flow { + val userActions = + setOf( + ACTION_PROFILE_ADDED, + ACTION_PROFILE_REMOVED, + + // Quiet mode enabled/disabled for managed + // From: UserController.broadcastProfileAvailabilityChanges + // In response to setQuietModeEnabled + ACTION_MANAGED_PROFILE_AVAILABLE, // quiet mode, sent for manage profiles only + ACTION_MANAGED_PROFILE_UNAVAILABLE, // quiet mode, sent for manage profiles only + + // Quiet mode toggled for profile type, requires flag 'android.os.allow_private_profile + // true' + ACTION_PROFILE_AVAILABLE, // quiet mode, + ACTION_PROFILE_UNAVAILABLE, // quiet mode, sent for any profile type + ) + return broadcastFlow(context, createFilter(userActions), profileParent, Intent::toUserEvent) +} diff --git a/java/src/com/android/intentresolver/v2/data/repository/UserRepositoryModule.kt b/java/src/com/android/intentresolver/v2/data/repository/UserRepositoryModule.kt new file mode 100644 index 00000000..94f985e7 --- /dev/null +++ b/java/src/com/android/intentresolver/v2/data/repository/UserRepositoryModule.kt @@ -0,0 +1,34 @@ +package com.android.intentresolver.v2.data.repository + +import android.content.Context +import android.os.UserHandle +import android.os.UserManager +import com.android.intentresolver.inject.ApplicationUser +import com.android.intentresolver.inject.ProfileParent +import dagger.Binds +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.android.qualifiers.ApplicationContext +import dagger.hilt.components.SingletonComponent +import javax.inject.Singleton + +@Module +@InstallIn(SingletonComponent::class) +interface UserRepositoryModule { + companion object { + @Provides + @Singleton + @ApplicationUser + fun applicationUser(@ApplicationContext context: Context): UserHandle = context.user + + @Provides + @Singleton + @ProfileParent + fun profileParent(@ApplicationUser user: UserHandle, userManager: UserManager): UserHandle { + return userManager.getProfileParent(user) ?: user + } + } + + @Binds @Singleton fun userRepository(impl: UserRepositoryImpl): UserRepository +} diff --git a/java/tests/src/com/android/intentresolver/v2/data/UserDataSourceImplTest.kt b/java/tests/src/com/android/intentresolver/v2/data/UserDataSourceImplTest.kt deleted file mode 100644 index 56d5de35..00000000 --- a/java/tests/src/com/android/intentresolver/v2/data/UserDataSourceImplTest.kt +++ /dev/null @@ -1,194 +0,0 @@ -@file:OptIn(ExperimentalCoroutinesApi::class) - -package com.android.intentresolver.v2.data - -import android.content.Intent.ACTION_PROFILE_ADDED -import android.content.Intent.ACTION_PROFILE_AVAILABLE -import android.content.Intent.ACTION_PROFILE_REMOVED -import android.content.pm.UserInfo -import android.os.UserHandle -import android.os.UserHandle.USER_NULL -import android.os.UserManager -import com.android.intentresolver.mock -import com.android.intentresolver.v2.coroutines.collectLastValue -import com.android.intentresolver.v2.data.User.Role -import com.android.intentresolver.v2.data.UserDataSourceImpl.UserEvent -import com.android.intentresolver.v2.platform.FakeUserManager -import com.android.intentresolver.v2.platform.FakeUserManager.ProfileType -import com.android.intentresolver.whenever -import com.google.common.truth.Truth.assertThat -import com.google.common.truth.Truth.assertWithMessage -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.flow.flowOf -import kotlinx.coroutines.test.TestScope -import kotlinx.coroutines.test.runTest -import org.junit.Test -import org.mockito.Mockito.anyInt -import org.mockito.Mockito.doReturn -import org.mockito.Mockito.eq - -internal class UserDataSourceImplTest { - private val userManager = FakeUserManager() - private val userState = userManager.state - - @Test - fun initialization() = runTest { - val dataSource = createUserDataSource(userManager) - val users by collectLastValue(dataSource.users) - - assertWithMessage("collectLast(dataSource.users)").that(users).isNotNull() - assertThat(users) - .containsExactly( - userState.primaryUserHandle, - User(userState.primaryUserHandle.identifier, Role.PERSONAL) - ) - } - - @Test - fun createProfile() = runTest { - val dataSource = createUserDataSource(userManager) - val users by collectLastValue(dataSource.users) - - assertWithMessage("collectLast(dataSource.users)").that(users).isNotNull() - assertThat(users!!.values.filter { it.role.type == User.Type.PROFILE }).isEmpty() - - val profile = userState.createProfile(ProfileType.WORK) - assertThat(users).containsEntry(profile, User(profile.identifier, Role.WORK)) - } - - @Test - fun removeProfile() = runTest { - val dataSource = createUserDataSource(userManager) - val users by collectLastValue(dataSource.users) - - assertWithMessage("collectLast(dataSource.users)").that(users).isNotNull() - val work = userState.createProfile(ProfileType.WORK) - assertThat(users).containsEntry(work, User(work.identifier, Role.WORK)) - - userState.removeProfile(work) - assertThat(users).doesNotContainEntry(work, User(work.identifier, Role.WORK)) - } - - @Test - fun isAvailable() = runTest { - val dataSource = createUserDataSource(userManager) - val work = userState.createProfile(ProfileType.WORK) - - val available by collectLastValue(dataSource.isAvailable(work)) - assertThat(available).isTrue() - - userState.setQuietMode(work, true) - assertThat(available).isFalse() - - userState.setQuietMode(work, false) - assertThat(available).isTrue() - } - - /** - * This and all the 'recovers_from_*' tests below all configure a static event flow instead of - * using [FakeUserManager]. These tests verify that a invalid broadcast causes the flow to - * reinitialize with the user profile group. - */ - @Test - fun recovers_from_invalid_profile_added_event() = runTest { - val userManager = - mockUserManager(validUser = UserHandle.USER_SYSTEM, invalidUser = USER_NULL) - val events = flowOf(UserEvent(ACTION_PROFILE_ADDED, UserHandle.of(USER_NULL))) - val dataSource = - UserDataSourceImpl( - profileParent = UserHandle.SYSTEM, - userManager = userManager, - userEvents = events, - scope = backgroundScope, - backgroundDispatcher = Dispatchers.Unconfined - ) - val users by collectLastValue(dataSource.users) - - assertWithMessage("collectLast(dataSource.users)").that(users).isNotNull() - assertThat(users) - .containsExactly(UserHandle.SYSTEM, User(UserHandle.USER_SYSTEM, Role.PERSONAL)) - } - - @Test - fun recovers_from_invalid_profile_removed_event() = runTest { - val userManager = - mockUserManager(validUser = UserHandle.USER_SYSTEM, invalidUser = USER_NULL) - val events = flowOf(UserEvent(ACTION_PROFILE_REMOVED, UserHandle.of(USER_NULL))) - val dataSource = - UserDataSourceImpl( - profileParent = UserHandle.SYSTEM, - userManager = userManager, - userEvents = events, - scope = backgroundScope, - backgroundDispatcher = Dispatchers.Unconfined - ) - val users by collectLastValue(dataSource.users) - - assertWithMessage("collectLast(dataSource.users)").that(users).isNotNull() - assertThat(users) - .containsExactly(UserHandle.SYSTEM, User(UserHandle.USER_SYSTEM, Role.PERSONAL)) - } - - @Test - fun recovers_from_invalid_profile_available_event() = runTest { - val userManager = - mockUserManager(validUser = UserHandle.USER_SYSTEM, invalidUser = USER_NULL) - val events = flowOf(UserEvent(ACTION_PROFILE_AVAILABLE, UserHandle.of(USER_NULL))) - val dataSource = - UserDataSourceImpl( - UserHandle.SYSTEM, - userManager, - events, - backgroundScope, - Dispatchers.Unconfined - ) - val users by collectLastValue(dataSource.users) - - assertWithMessage("collectLast(dataSource.users)").that(users).isNotNull() - assertThat(users) - .containsExactly(UserHandle.SYSTEM, User(UserHandle.USER_SYSTEM, Role.PERSONAL)) - } - - @Test - fun recovers_from_unknown_event() = runTest { - val userManager = - mockUserManager(validUser = UserHandle.USER_SYSTEM, invalidUser = USER_NULL) - val events = flowOf(UserEvent("UNKNOWN_EVENT", UserHandle.of(USER_NULL))) - val dataSource = - UserDataSourceImpl( - profileParent = UserHandle.SYSTEM, - userManager = userManager, - userEvents = events, - scope = backgroundScope, - backgroundDispatcher = Dispatchers.Unconfined - ) - val users by collectLastValue(dataSource.users) - - assertWithMessage("collectLast(dataSource.users)").that(users).isNotNull() - assertThat(users) - .containsExactly(UserHandle.SYSTEM, User(UserHandle.USER_SYSTEM, Role.PERSONAL)) - } -} - -@Suppress("SameParameterValue", "DEPRECATION") -private fun mockUserManager(validUser: Int, invalidUser: Int) = - mock { - val info = UserInfo(validUser, "", "", UserInfo.FLAG_FULL) - doReturn(listOf(info)).whenever(this).getEnabledProfiles(anyInt()) - - doReturn(info).whenever(this).getUserInfo(eq(validUser)) - - doReturn(listOf()).whenever(this).getEnabledProfiles(eq(invalidUser)) - - doReturn(null).whenever(this).getUserInfo(eq(invalidUser)) - } - -private fun TestScope.createUserDataSource(userManager: FakeUserManager) = - UserDataSourceImpl( - profileParent = userManager.state.primaryUserHandle, - userManager = userManager, - userEvents = userManager.state.userEvents, - scope = backgroundScope, - backgroundDispatcher = Dispatchers.Unconfined - ) diff --git a/java/tests/src/com/android/intentresolver/v2/data/repository/UserRepositoryImplTest.kt b/java/tests/src/com/android/intentresolver/v2/data/repository/UserRepositoryImplTest.kt new file mode 100644 index 00000000..4f514db5 --- /dev/null +++ b/java/tests/src/com/android/intentresolver/v2/data/repository/UserRepositoryImplTest.kt @@ -0,0 +1,222 @@ +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 +import android.os.UserHandle.USER_SYSTEM +import android.os.UserManager +import com.android.intentresolver.mock +import com.android.intentresolver.v2.coroutines.collectLastValue +import com.android.intentresolver.v2.data.model.User +import com.android.intentresolver.v2.data.model.User.Role +import com.android.intentresolver.v2.platform.FakeUserManager +import com.android.intentresolver.v2.platform.FakeUserManager.ProfileType +import com.android.intentresolver.whenever +import com.google.common.truth.Truth.assertThat +import com.google.common.truth.Truth.assertWithMessage +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.runTest +import org.junit.Test +import org.mockito.Mockito +import org.mockito.Mockito.doReturn + +internal class UserRepositoryImplTest { + private val userManager = FakeUserManager() + private val userState = userManager.state + + @Test + fun initialization() = runTest { + val repo = createUserRepository(userManager) + val users by collectLastValue(repo.users) + + assertWithMessage("collectLastValue(repo.users)").that(users).isNotNull() + assertThat(users) + .containsExactly( + userState.primaryUserHandle, + User(userState.primaryUserHandle.identifier, Role.PERSONAL) + ) + } + + @Test + fun createProfile() = runTest { + val repo = createUserRepository(userManager) + val users by collectLastValue(repo.users) + + assertWithMessage("collectLastValue(repo.users)").that(users).isNotNull() + assertThat(users!!.values.filter { it.role.type == User.Type.PROFILE }).isEmpty() + + val profile = userState.createProfile(ProfileType.WORK) + assertThat(users).containsEntry(profile, User(profile.identifier, Role.WORK)) + } + + @Test + fun removeProfile() = runTest { + val repo = createUserRepository(userManager) + val users by collectLastValue(repo.users) + + assertWithMessage("collectLastValue(repo.users)").that(users).isNotNull() + val work = userState.createProfile(ProfileType.WORK) + assertThat(users).containsEntry(work, User(work.identifier, Role.WORK)) + + userState.removeProfile(work) + assertThat(users).doesNotContainEntry(work, User(work.identifier, Role.WORK)) + } + + @Test + fun isAvailable() = runTest { + val repo = createUserRepository(userManager) + val work = userState.createProfile(ProfileType.WORK) + + val available by collectLastValue(repo.isAvailable(work)) + assertThat(available).isTrue() + + userState.setQuietMode(work, true) + assertThat(available).isFalse() + + userState.setQuietMode(work, false) + assertThat(available).isTrue() + } + + @Test + fun requestState() = runTest { + val repo = createUserRepository(userManager) + val work = userState.createProfile(ProfileType.WORK) + + val available by collectLastValue(repo.isAvailable(work)) + assertThat(available).isTrue() + + repo.requestState(work, false) + assertThat(available).isFalse() + + repo.requestState(work, true) + assertThat(available).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 + * reinitialize with the user profile group. + */ + @Test + fun recovers_from_invalid_profile_added_event() = runTest { + val userManager = + mockUserManager(validUser = USER_SYSTEM, invalidUser = UserHandle.USER_NULL) + val events = + flowOf( + UserRepositoryImpl.UserEvent( + Intent.ACTION_PROFILE_ADDED, + UserHandle.of(UserHandle.USER_NULL) + ) + ) + val repo = + UserRepositoryImpl( + profileParent = SYSTEM, + userManager = userManager, + userEvents = events, + scope = backgroundScope, + backgroundDispatcher = Dispatchers.Unconfined + ) + val users by collectLastValue(repo.users) + + assertWithMessage("collectLastValue(repo.users)").that(users).isNotNull() + assertThat(users).containsExactly(SYSTEM, User(USER_SYSTEM, Role.PERSONAL)) + } + + @Test + fun recovers_from_invalid_profile_removed_event() = runTest { + val userManager = + mockUserManager(validUser = USER_SYSTEM, invalidUser = UserHandle.USER_NULL) + val events = + flowOf( + UserRepositoryImpl.UserEvent( + Intent.ACTION_PROFILE_REMOVED, + UserHandle.of(UserHandle.USER_NULL) + ) + ) + val repo = + UserRepositoryImpl( + profileParent = SYSTEM, + userManager = userManager, + userEvents = events, + scope = backgroundScope, + backgroundDispatcher = Dispatchers.Unconfined + ) + val users by collectLastValue(repo.users) + + assertWithMessage("collectLastValue(repo.users)").that(users).isNotNull() + assertThat(users).containsExactly(SYSTEM, User(USER_SYSTEM, Role.PERSONAL)) + } + + @Test + fun recovers_from_invalid_profile_available_event() = runTest { + val userManager = + mockUserManager(validUser = USER_SYSTEM, invalidUser = UserHandle.USER_NULL) + val events = + flowOf( + UserRepositoryImpl.UserEvent( + Intent.ACTION_PROFILE_AVAILABLE, + UserHandle.of(UserHandle.USER_NULL) + ) + ) + val repo = + UserRepositoryImpl(SYSTEM, userManager, events, backgroundScope, Dispatchers.Unconfined) + val users by collectLastValue(repo.users) + + assertWithMessage("collectLastValue(repo.users)").that(users).isNotNull() + assertThat(users).containsExactly(SYSTEM, User(USER_SYSTEM, Role.PERSONAL)) + } + + @Test + fun recovers_from_unknown_event() = runTest { + val userManager = + mockUserManager(validUser = USER_SYSTEM, invalidUser = UserHandle.USER_NULL) + val events = + flowOf( + UserRepositoryImpl.UserEvent("UNKNOWN_EVENT", UserHandle.of(UserHandle.USER_NULL)) + ) + val repo = + UserRepositoryImpl( + profileParent = SYSTEM, + userManager = userManager, + userEvents = events, + scope = backgroundScope, + backgroundDispatcher = Dispatchers.Unconfined + ) + val users by collectLastValue(repo.users) + + assertWithMessage("collectLastValue(repo.users)").that(users).isNotNull() + assertThat(users).containsExactly(SYSTEM, User(USER_SYSTEM, Role.PERSONAL)) + } +} + +@Suppress("SameParameterValue", "DEPRECATION") +private fun mockUserManager(validUser: Int, invalidUser: Int) = + mock { + val info = UserInfo(validUser, "", "", UserInfo.FLAG_FULL) + doReturn(listOf(info)).whenever(this).getEnabledProfiles(Mockito.anyInt()) + + doReturn(info).whenever(this).getUserInfo(Mockito.eq(validUser)) + + doReturn(listOf()).whenever(this).getEnabledProfiles(Mockito.eq(invalidUser)) + + doReturn(null).whenever(this).getUserInfo(Mockito.eq(invalidUser)) + } + +private fun TestScope.createUserRepository(userManager: FakeUserManager) = + UserRepositoryImpl( + profileParent = userManager.state.primaryUserHandle, + userManager = userManager, + userEvents = userManager.state.userEvents, + scope = backgroundScope, + backgroundDispatcher = Dispatchers.Unconfined + ) diff --git a/java/tests/src/com/android/intentresolver/v2/platform/FakeUserManager.kt b/java/tests/src/com/android/intentresolver/v2/platform/FakeUserManager.kt index ef1e5917..370e5a00 100644 --- a/java/tests/src/com/android/intentresolver/v2/platform/FakeUserManager.kt +++ b/java/tests/src/com/android/intentresolver/v2/platform/FakeUserManager.kt @@ -1,7 +1,10 @@ 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 @@ -12,9 +15,10 @@ import android.content.pm.UserInfo.NO_PROFILE_GROUP_ID import android.os.IUserManager import android.os.UserHandle import android.os.UserManager +import androidx.annotation.NonNull import com.android.intentresolver.THROWS_EXCEPTION import com.android.intentresolver.mock -import com.android.intentresolver.v2.data.UserDataSourceImpl.UserEvent +import com.android.intentresolver.v2.data.repository.UserRepositoryImpl.UserEvent import com.android.intentresolver.v2.platform.FakeUserManager.State import com.android.intentresolver.whenever import kotlin.random.Random @@ -77,6 +81,14 @@ class FakeUserManager(val state: State = State()) : } } + override fun requestQuietModeEnabled( + enableQuietMode: Boolean, + @NonNull userHandle: UserHandle + ): Boolean { + state.setQuietMode(userHandle, enableQuietMode) + return true + } + override fun isQuietModeEnabled(userHandle: UserHandle): Boolean { return state.getUser(userHandle).isQuietModeEnabled } @@ -136,8 +148,29 @@ class FakeUserManager(val state: State = State()) : } fun setQuietMode(user: UserHandle, quietMode: Boolean) { - userInfoMap[user]?.also { it.flags = it.flags or UserInfo.FLAG_QUIET_MODE } - eventChannel.trySend(UserEvent(ACTION_PROFILE_UNAVAILABLE, user, quietMode)) + userInfoMap[user]?.also { + it.flags = + if (quietMode) { + it.flags or UserInfo.FLAG_QUIET_MODE + } else { + it.flags and UserInfo.FLAG_QUIET_MODE.inv() + } + val actions = mutableListOf() + if (quietMode) { + actions += ACTION_PROFILE_UNAVAILABLE + if (it.isManagedProfile) { + actions += ACTION_MANAGED_PROFILE_UNAVAILABLE + } + } else { + actions += ACTION_PROFILE_AVAILABLE + if (it.isManagedProfile) { + actions += ACTION_MANAGED_PROFILE_AVAILABLE + } + } + actions.forEach { action -> + eventChannel.trySend(UserEvent(action, user, quietMode)) + } + } } fun createProfile(type: ProfileType, parent: UserHandle = primaryUserHandle): UserHandle { -- cgit v1.2.3-59-g8ed1b From d43875a34e203f2ee25abfe7dba1f98d5a980d83 Mon Sep 17 00:00:00 2001 From: mrenouf Date: Mon, 20 Nov 2023 10:38:28 -0500 Subject: UserScopedService Provides cached instances of a system service created with the context of a specified user. This allows more natural interaction with APIs which do not accept a direct UserId parameter. Bug: 309960444 Bug: 300157408 Change-Id: Ib0abbd308a197b59c1d9ba37c7ce22d19d4dde9c --- .../v2/data/repository/UserScopedService.kt | 46 ++++++++++++++++++++++ 1 file changed, 46 insertions(+) create mode 100644 java/src/com/android/intentresolver/v2/data/repository/UserScopedService.kt (limited to 'java/src') diff --git a/java/src/com/android/intentresolver/v2/data/repository/UserScopedService.kt b/java/src/com/android/intentresolver/v2/data/repository/UserScopedService.kt new file mode 100644 index 00000000..7ee78d91 --- /dev/null +++ b/java/src/com/android/intentresolver/v2/data/repository/UserScopedService.kt @@ -0,0 +1,46 @@ +package com.android.intentresolver.v2.data.repository + +import android.content.Context +import androidx.core.content.getSystemService +import com.android.intentresolver.v2.data.model.User + +/** + * Provides cached 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 + * [Context.getUser], the context used to retrieve the service. This utility helps adapt a per-user + * API model to work in multi-user manner. + * + * Example usage: + * ``` + * val usageStats = userScopedService(context) + * + * fun getStatsForUser( + * user: User, + * from: Long, + * to: Long + * ): UsageStats { + * return usageStats.forUser(user) + * .queryUsageStats(INTERVAL_BEST, from, to) + * } + * ``` + */ +interface UserScopedService { + fun forUser(user: User): T +} + +inline fun userScopedService(context: Context): UserScopedService { + return object : UserScopedService { + private val map = mutableMapOf() + + override fun forUser(user: User): T { + return synchronized(this) { + map.getOrPut(user) { + val userContext = context.createContextAsUser(user.handle, 0) + requireNotNull(userContext.getSystemService()) + } + } + } + } +} -- cgit v1.2.3-59-g8ed1b From b34ae5d8650a1a4f074db68cb64c3b9648cb9275 Mon Sep 17 00:00:00 2001 From: Govinda Wasserman Date: Wed, 22 Nov 2023 09:21:08 -0500 Subject: Splits list controller into interfaces Each interface has a single concern, allowing for list controllers to be built by composition. New list controllers are not currently in use, but will be in a future change once resolver comparators and list adapters get updated. Test: atest com.android.intentresolver.v2.listcontroller BUG: 302113519 Change-Id: Ie1d24571c07d1408aa80f8a86311d0fee5e78255 --- .../model/AbstractResolverComparator.java | 8 +- .../AppPredictionServiceResolverComparator.java | 6 +- .../v2/listcontroller/FilterableComponents.kt | 39 ++ .../v2/listcontroller/IntentResolver.kt | 70 +++ .../v2/listcontroller/LastChosenManager.kt | 77 ++++ .../v2/listcontroller/ListController.kt | 21 + .../v2/listcontroller/PermissionChecker.kt | 34 ++ .../v2/listcontroller/PinnableComponents.kt | 39 ++ .../v2/listcontroller/ResolveListDeduper.kt | 69 +++ .../listcontroller/ResolvedComponentFiltering.kt | 121 +++++ .../v2/listcontroller/ResolvedComponentSorting.kt | 108 +++++ .../model/AbstractResolverComparatorTest.java | 6 +- .../ChooserRequestFilteredComponentsTest.kt | 61 +++ .../v2/listcontroller/FakeResolverComparator.kt | 83 ++++ .../v2/listcontroller/FilterableComponentsTest.kt | 77 ++++ .../v2/listcontroller/IntentResolverTest.kt | 499 +++++++++++++++++++++ .../v2/listcontroller/LastChosenManagerTest.kt | 111 +++++ .../v2/listcontroller/PinnableComponentsTest.kt | 74 +++ .../v2/listcontroller/ResolveListDeduperTest.kt | 125 ++++++ .../ResolvedComponentFilteringTest.kt | 309 +++++++++++++ .../listcontroller/ResolvedComponentSortingTest.kt | 197 ++++++++ .../SharedPreferencesPinnedComponentsTest.kt | 63 +++ 22 files changed, 2187 insertions(+), 10 deletions(-) create mode 100644 java/src/com/android/intentresolver/v2/listcontroller/FilterableComponents.kt create mode 100644 java/src/com/android/intentresolver/v2/listcontroller/IntentResolver.kt create mode 100644 java/src/com/android/intentresolver/v2/listcontroller/LastChosenManager.kt create mode 100644 java/src/com/android/intentresolver/v2/listcontroller/ListController.kt create mode 100644 java/src/com/android/intentresolver/v2/listcontroller/PermissionChecker.kt create mode 100644 java/src/com/android/intentresolver/v2/listcontroller/PinnableComponents.kt create mode 100644 java/src/com/android/intentresolver/v2/listcontroller/ResolveListDeduper.kt create mode 100644 java/src/com/android/intentresolver/v2/listcontroller/ResolvedComponentFiltering.kt create mode 100644 java/src/com/android/intentresolver/v2/listcontroller/ResolvedComponentSorting.kt create mode 100644 java/tests/src/com/android/intentresolver/v2/listcontroller/ChooserRequestFilteredComponentsTest.kt create mode 100644 java/tests/src/com/android/intentresolver/v2/listcontroller/FakeResolverComparator.kt create mode 100644 java/tests/src/com/android/intentresolver/v2/listcontroller/FilterableComponentsTest.kt create mode 100644 java/tests/src/com/android/intentresolver/v2/listcontroller/IntentResolverTest.kt create mode 100644 java/tests/src/com/android/intentresolver/v2/listcontroller/LastChosenManagerTest.kt create mode 100644 java/tests/src/com/android/intentresolver/v2/listcontroller/PinnableComponentsTest.kt create mode 100644 java/tests/src/com/android/intentresolver/v2/listcontroller/ResolveListDeduperTest.kt create mode 100644 java/tests/src/com/android/intentresolver/v2/listcontroller/ResolvedComponentFilteringTest.kt create mode 100644 java/tests/src/com/android/intentresolver/v2/listcontroller/ResolvedComponentSortingTest.kt create mode 100644 java/tests/src/com/android/intentresolver/v2/listcontroller/SharedPreferencesPinnedComponentsTest.kt (limited to 'java/src') diff --git a/java/src/com/android/intentresolver/model/AbstractResolverComparator.java b/java/src/com/android/intentresolver/model/AbstractResolverComparator.java index 131aa8d9..724fa849 100644 --- a/java/src/com/android/intentresolver/model/AbstractResolverComparator.java +++ b/java/src/com/android/intentresolver/model/AbstractResolverComparator.java @@ -232,7 +232,7 @@ public abstract class AbstractResolverComparator implements Comparator targets); + public abstract void doCompute(List targets); /** * Returns the score that was calculated for the corresponding {@link ResolvedComponentInfo} @@ -257,12 +257,12 @@ public abstract class AbstractResolverComparator implements Comparator targets) { + public void doCompute(List targets) { if (targets.isEmpty()) { mHandler.sendEmptyMessage(RANKER_SERVICE_RESULT); return; @@ -144,7 +144,7 @@ public class AppPredictionServiceResolverComparator extends AbstractResolverComp } @Override - void handleResultMessage(Message msg) { + public void handleResultMessage(Message msg) { // Null value is okay if we have defaulted to the ResolverRankerService. if (msg.what == RANKER_SERVICE_RESULT && msg.obj != null) { final List sortedAppTargets = (List) msg.obj; diff --git a/java/src/com/android/intentresolver/v2/listcontroller/FilterableComponents.kt b/java/src/com/android/intentresolver/v2/listcontroller/FilterableComponents.kt new file mode 100644 index 00000000..5855e2fc --- /dev/null +++ b/java/src/com/android/intentresolver/v2/listcontroller/FilterableComponents.kt @@ -0,0 +1,39 @@ +/* + * Copyright (C) 2023 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.intentresolver.v2.listcontroller + +import android.content.ComponentName +import com.android.intentresolver.ChooserRequestParameters + +/** A class that is able to identify components that should be hidden from the user. */ +interface FilterableComponents { + /** Whether this component should hidden from the user. */ + fun isComponentFiltered(name: ComponentName): Boolean +} + +/** A class that never filters components. */ +class NoComponentFiltering : FilterableComponents { + override fun isComponentFiltered(name: ComponentName): Boolean = false +} + +/** A class that filters components by chooser request filter. */ +class ChooserRequestFilteredComponents( + private val chooserRequestParameters: ChooserRequestParameters, +) : FilterableComponents { + override fun isComponentFiltered(name: ComponentName): Boolean = + chooserRequestParameters.filteredComponentNames.contains(name) +} diff --git a/java/src/com/android/intentresolver/v2/listcontroller/IntentResolver.kt b/java/src/com/android/intentresolver/v2/listcontroller/IntentResolver.kt new file mode 100644 index 00000000..bb9394b4 --- /dev/null +++ b/java/src/com/android/intentresolver/v2/listcontroller/IntentResolver.kt @@ -0,0 +1,70 @@ +package com.android.intentresolver.v2.listcontroller + +import android.content.Intent +import android.content.pm.PackageManager +import android.os.UserHandle +import com.android.intentresolver.ResolvedComponentInfo + +/** A class for translating [Intent]s to [ResolvedComponentInfo]s. */ +interface IntentResolver { + /** + * Get data about all the ways the user with the specified handle can resolve any of the + * provided `intents`. + */ + fun getResolversForIntentAsUser( + shouldGetResolvedFilter: Boolean, + shouldGetActivityMetadata: Boolean, + shouldGetOnlyDefaultActivities: Boolean, + intents: List, + userHandle: UserHandle, + ): List +} + +/** Resolves [Intent]s using the [packageManager], deduping using the given [ResolveListDeduper]. */ +class IntentResolverImpl( + private val packageManager: PackageManager, + resolveListDeduper: ResolveListDeduper, +) : IntentResolver, ResolveListDeduper by resolveListDeduper { + override fun getResolversForIntentAsUser( + shouldGetResolvedFilter: Boolean, + shouldGetActivityMetadata: Boolean, + shouldGetOnlyDefaultActivities: Boolean, + intents: List, + userHandle: UserHandle, + ): List { + val baseFlags = + ((if (shouldGetOnlyDefaultActivities) PackageManager.MATCH_DEFAULT_ONLY else 0) or + PackageManager.MATCH_DIRECT_BOOT_AWARE or + PackageManager.MATCH_DIRECT_BOOT_UNAWARE or + (if (shouldGetResolvedFilter) PackageManager.GET_RESOLVED_FILTER else 0) or + (if (shouldGetActivityMetadata) PackageManager.GET_META_DATA else 0) or + PackageManager.MATCH_CLONE_PROFILE) + return getResolversForIntentAsUserInternal( + intents, + userHandle, + baseFlags, + ) + } + + private fun getResolversForIntentAsUserInternal( + intents: List, + userHandle: UserHandle, + baseFlags: Int, + ): List = buildList { + for (intent in intents) { + var flags = baseFlags + if (intent.isWebIntent || intent.flags and Intent.FLAG_ACTIVITY_MATCH_EXTERNAL != 0) { + flags = flags or PackageManager.MATCH_INSTANT + } + // Because of AIDL bug, queryIntentActivitiesAsUser can't accept subclasses of Intent. + val fixedIntent = + if (intent.javaClass != Intent::class.java) { + Intent(intent) + } else { + intent + } + val infos = packageManager.queryIntentActivitiesAsUser(fixedIntent, flags, userHandle) + addToResolveListWithDedupe(this, fixedIntent, infos) + } + } +} diff --git a/java/src/com/android/intentresolver/v2/listcontroller/LastChosenManager.kt b/java/src/com/android/intentresolver/v2/listcontroller/LastChosenManager.kt new file mode 100644 index 00000000..b2856526 --- /dev/null +++ b/java/src/com/android/intentresolver/v2/listcontroller/LastChosenManager.kt @@ -0,0 +1,77 @@ +/* + * Copyright (C) 2023 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.intentresolver.v2.listcontroller + +import android.app.AppGlobals +import android.content.ContentResolver +import android.content.Intent +import android.content.IntentFilter +import android.content.pm.IPackageManager +import android.content.pm.PackageManager +import android.content.pm.ResolveInfo +import android.os.RemoteException +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.withContext + +/** Class that stores and retrieves the most recently chosen resolutions. */ +interface LastChosenManager { + + /** Returns the most recently chosen resolution. */ + suspend fun getLastChosen(): ResolveInfo + + /** Sets the most recently chosen resolution. */ + suspend fun setLastChosen(intent: Intent, filter: IntentFilter, match: Int) +} + +/** + * Stores and retrieves the most recently chosen resolutions using the [PackageManager] provided by + * the [packageManagerProvider]. + */ +class PackageManagerLastChosenManager( + private val contentResolver: ContentResolver, + private val bgDispatcher: CoroutineDispatcher, + private val targetIntent: Intent, + private val packageManagerProvider: () -> IPackageManager = AppGlobals::getPackageManager, +) : LastChosenManager { + + @Throws(RemoteException::class) + override suspend fun getLastChosen(): ResolveInfo { + return withContext(bgDispatcher) { + packageManagerProvider() + .getLastChosenActivity( + targetIntent, + targetIntent.resolveTypeIfNeeded(contentResolver), + PackageManager.MATCH_DEFAULT_ONLY, + ) + } + } + + @Throws(RemoteException::class) + override suspend fun setLastChosen(intent: Intent, filter: IntentFilter, match: Int) { + return withContext(bgDispatcher) { + packageManagerProvider() + .setLastChosenActivity( + intent, + intent.resolveType(contentResolver), + PackageManager.MATCH_DEFAULT_ONLY, + filter, + match, + intent.component, + ) + } + } +} diff --git a/java/src/com/android/intentresolver/v2/listcontroller/ListController.kt b/java/src/com/android/intentresolver/v2/listcontroller/ListController.kt new file mode 100644 index 00000000..4ddab755 --- /dev/null +++ b/java/src/com/android/intentresolver/v2/listcontroller/ListController.kt @@ -0,0 +1,21 @@ +/* + * Copyright (C) 2023 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.intentresolver.v2.listcontroller + +/** Controller for managing lists of [com.android.intentresolver.ResolvedComponentInfo]s. */ +interface ListController : + LastChosenManager, IntentResolver, ResolvedComponentFiltering, ResolvedComponentSorting diff --git a/java/src/com/android/intentresolver/v2/listcontroller/PermissionChecker.kt b/java/src/com/android/intentresolver/v2/listcontroller/PermissionChecker.kt new file mode 100644 index 00000000..cae2af95 --- /dev/null +++ b/java/src/com/android/intentresolver/v2/listcontroller/PermissionChecker.kt @@ -0,0 +1,34 @@ +package com.android.intentresolver.v2.listcontroller + +import android.app.ActivityManager +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.withContext + +/** Class for checking if a permission has been granted. */ +interface PermissionChecker { + /** Checks if the given [permission] has been granted. */ + suspend fun checkComponentPermission( + permission: String, + uid: Int, + owningUid: Int, + exported: Boolean, + ): Int +} + +/** + * Class for checking if a permission has been granted using the static + * [ActivityManager.checkComponentPermission]. + */ +class ActivityManagerPermissionChecker( + private val bgDispatcher: CoroutineDispatcher, +) : PermissionChecker { + override suspend fun checkComponentPermission( + permission: String, + uid: Int, + owningUid: Int, + exported: Boolean, + ): Int = + withContext(bgDispatcher) { + ActivityManager.checkComponentPermission(permission, uid, owningUid, exported) + } +} diff --git a/java/src/com/android/intentresolver/v2/listcontroller/PinnableComponents.kt b/java/src/com/android/intentresolver/v2/listcontroller/PinnableComponents.kt new file mode 100644 index 00000000..8be45ba2 --- /dev/null +++ b/java/src/com/android/intentresolver/v2/listcontroller/PinnableComponents.kt @@ -0,0 +1,39 @@ +/* + * Copyright (C) 2023 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.intentresolver.v2.listcontroller + +import android.content.ComponentName +import android.content.SharedPreferences + +/** A class that is able to identify components that should be pinned for the user. */ +interface PinnableComponents { + /** Whether this component is pinned by the user. */ + fun isComponentPinned(name: ComponentName): Boolean +} + +/** A class that never pins components. */ +class NoComponentPinning : PinnableComponents { + override fun isComponentPinned(name: ComponentName): Boolean = false +} + +/** A class that determines pinnable components by user preferences. */ +class SharedPreferencesPinnedComponents( + private val pinnedSharedPreferences: SharedPreferences, +) : PinnableComponents { + override fun isComponentPinned(name: ComponentName): Boolean = + pinnedSharedPreferences.getBoolean(name.flattenToString(), false) +} diff --git a/java/src/com/android/intentresolver/v2/listcontroller/ResolveListDeduper.kt b/java/src/com/android/intentresolver/v2/listcontroller/ResolveListDeduper.kt new file mode 100644 index 00000000..f0b4bf3f --- /dev/null +++ b/java/src/com/android/intentresolver/v2/listcontroller/ResolveListDeduper.kt @@ -0,0 +1,69 @@ +package com.android.intentresolver.v2.listcontroller + +import android.content.ComponentName +import android.content.Intent +import android.content.pm.ResolveInfo +import android.util.Log +import com.android.intentresolver.ResolvedComponentInfo + +/** A class for adding [ResolveInfo]s to a list of [ResolvedComponentInfo]s without duplicates. */ +interface ResolveListDeduper { + /** + * Adds [ResolveInfo]s in [from] to [ResolvedComponentInfo]s in [into], creating new + * [ResolvedComponentInfo]s when there is not already a corresponding one. + * + * This method may be destructive to both the given [into] list and the underlying + * [ResolvedComponentInfo]s. + */ + fun addToResolveListWithDedupe( + into: MutableList, + intent: Intent, + from: List, + ) +} + +/** + * Default implementation for adding [ResolveInfo]s to a list of [ResolvedComponentInfo]s without + * duplicates. Uses the given [PinnableComponents] to determine the pinning state of newly created + * [ResolvedComponentInfo]s. + */ +class ResolveListDeduperImpl(pinnableComponents: PinnableComponents) : + ResolveListDeduper, PinnableComponents by pinnableComponents { + override fun addToResolveListWithDedupe( + into: MutableList, + intent: Intent, + from: List, + ) { + from.forEach { newInfo -> + if (newInfo.userHandle == null) { + Log.w(TAG, "Skipping ResolveInfo with no userHandle: $newInfo") + return@forEach + } + val oldInfo = into.firstOrNull { isSameResolvedComponent(newInfo, it) } + // If existing resolution found, add to existing and filter out + if (oldInfo != null) { + oldInfo.add(intent, newInfo) + } else { + with(newInfo.activityInfo) { + into.add( + ResolvedComponentInfo( + ComponentName(packageName, name), + intent, + newInfo, + ) + .apply { isPinned = isComponentPinned(name) }, + ) + } + } + } + } + + private fun isSameResolvedComponent(a: ResolveInfo, b: ResolvedComponentInfo): Boolean { + val ai = a.activityInfo + return ai.packageName == b.name.packageName && ai.name == b.name.className + } + + companion object { + const val TAG = "ResolveListDeduper" + } +} diff --git a/java/src/com/android/intentresolver/v2/listcontroller/ResolvedComponentFiltering.kt b/java/src/com/android/intentresolver/v2/listcontroller/ResolvedComponentFiltering.kt new file mode 100644 index 00000000..e78bff00 --- /dev/null +++ b/java/src/com/android/intentresolver/v2/listcontroller/ResolvedComponentFiltering.kt @@ -0,0 +1,121 @@ +package com.android.intentresolver.v2.listcontroller + +import android.content.pm.PackageManager +import android.util.Log +import com.android.intentresolver.ResolvedComponentInfo +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.coroutineScope + +/** Provides filtering methods for lists of [ResolvedComponentInfo]. */ +interface ResolvedComponentFiltering { + /** + * Returns a list with all the [ResolvedComponentInfo] in [inputList], less the ones that are + * not eligible. + */ + suspend fun filterIneligibleActivities( + inputList: List, + ): List + + /** Filter out any low priority items. */ + fun filterLowPriority(inputList: List): List +} + +/** + * Default instantiation of the filtering methods for lists of [ResolvedComponentInfo]. + * + * Binder calls are performed on the given [bgDispatcher] and permissions are checked as if launched + * from the given [launchedFromUid] UID. Component filtering is handled by the given + * [FilterableComponents] and permission checking is handled by the given [PermissionChecker]. + */ +class ResolvedComponentFilteringImpl( + private val launchedFromUid: Int, + filterableComponents: FilterableComponents, + permissionChecker: PermissionChecker, +) : + ResolvedComponentFiltering, + PermissionChecker by permissionChecker, + FilterableComponents by filterableComponents { + constructor( + bgDispatcher: CoroutineDispatcher, + launchedFromUid: Int, + filterableComponents: FilterableComponents, + ) : this( + launchedFromUid = launchedFromUid, + filterableComponents = filterableComponents, + permissionChecker = ActivityManagerPermissionChecker(bgDispatcher), + ) + + /** + * Filter out items that are filtered by [FilterableComponents] or do not have the necessary + * permissions. + */ + override suspend fun filterIneligibleActivities( + inputList: List, + ): List = coroutineScope { + inputList + .map { + val activityInfo = it.getResolveInfoAt(0).activityInfo + if (isComponentFiltered(activityInfo.componentName)) { + CompletableDeferred(value = null) + } else { + // Do all permission checks in parallel + async { + val granted = + checkComponentPermission( + activityInfo.permission, + launchedFromUid, + activityInfo.applicationInfo.uid, + activityInfo.exported, + ) == PackageManager.PERMISSION_GRANTED + if (granted) it else null + } + } + } + .awaitAll() + .filterNotNull() + } + + /** + * Filters out all elements starting with the first elements with a different priority or + * default status than the first element. + */ + override fun filterLowPriority( + inputList: List, + ): List { + val firstResolveInfo = inputList[0].getResolveInfoAt(0) + // Only display the first matches that are either of equal + // priority or have asked to be default options. + val firstDiffIndex = + inputList.indexOfFirst { resolvedComponentInfo -> + val resolveInfo = resolvedComponentInfo.getResolveInfoAt(0) + if (firstResolveInfo == resolveInfo) { + false + } else { + if (DEBUG) { + Log.v( + TAG, + "${firstResolveInfo?.activityInfo?.name}=" + + "${firstResolveInfo?.priority}/${firstResolveInfo?.isDefault}" + + " vs ${resolveInfo?.activityInfo?.name}=" + + "${resolveInfo?.priority}/${resolveInfo?.isDefault}" + ) + } + firstResolveInfo!!.priority != resolveInfo!!.priority || + firstResolveInfo.isDefault != resolveInfo.isDefault + } + } + return if (firstDiffIndex == -1) { + inputList + } else { + inputList.subList(0, firstDiffIndex) + } + } + + companion object { + private const val TAG = "ResolvedComponentFilter" + private const val DEBUG = false + } +} diff --git a/java/src/com/android/intentresolver/v2/listcontroller/ResolvedComponentSorting.kt b/java/src/com/android/intentresolver/v2/listcontroller/ResolvedComponentSorting.kt new file mode 100644 index 00000000..8ab41ef0 --- /dev/null +++ b/java/src/com/android/intentresolver/v2/listcontroller/ResolvedComponentSorting.kt @@ -0,0 +1,108 @@ +package com.android.intentresolver.v2.listcontroller + +import android.os.UserHandle +import android.util.Log +import com.android.intentresolver.ResolvedComponentInfo +import com.android.intentresolver.chooser.DisplayResolveInfo +import com.android.intentresolver.chooser.TargetInfo +import com.android.intentresolver.model.AbstractResolverComparator +import java.util.concurrent.atomic.AtomicReference +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.withContext + +/** Provides sorting methods for lists of [ResolvedComponentInfo]. */ +interface ResolvedComponentSorting { + /** Returns the a copy of the [inputList] sorted by app share score. */ + suspend fun sorted(inputList: List?): List? + + /** Returns the app share score of the [target]. */ + fun getScore(target: DisplayResolveInfo): Float + + /** Returns the app share score of the [targetInfo]. */ + fun getScore(targetInfo: TargetInfo): Float + + /** Updates the model about [targetInfo]. */ + suspend fun updateModel(targetInfo: TargetInfo) + + /** Updates the model about Activity selection. */ + suspend fun updateChooserCounts(packageName: String, user: UserHandle, action: String) + + /** Cleans up resources. Nothing should be called after calling this. */ + fun destroy() +} + +/** + * Provides sorting methods using the given [resolverComparator]. + * + * Long calculations and binder calls are performed on the given [bgDispatcher]. + */ +class ResolvedComponentSortingImpl( + private val bgDispatcher: CoroutineDispatcher, + private val resolverComparator: AbstractResolverComparator, +) : ResolvedComponentSorting { + + private val computeComplete = AtomicReference?>(null) + + @Throws(InterruptedException::class) + private suspend fun computeIfNeeded(inputList: List) { + if (computeComplete.compareAndSet(null, CompletableDeferred())) { + resolverComparator.setCallBack { computeComplete.get()!!.complete(Unit) } + resolverComparator.compute(inputList) + } + with(computeComplete.get()!!) { if (isCompleted) return else return await() } + } + + override suspend fun sorted( + inputList: List?, + ): List? { + if (inputList.isNullOrEmpty()) return inputList + + return withContext(bgDispatcher) { + try { + val beforeRank = System.currentTimeMillis() + computeIfNeeded(inputList) + val sorted = inputList.sortedWith(resolverComparator) + val afterRank = System.currentTimeMillis() + if (DEBUG) { + Log.d(TAG, "Time Cost: ${afterRank - beforeRank}") + } + sorted + } catch (e: InterruptedException) { + Log.e(TAG, "Compute & Sort was interrupted: $e") + null + } + } + } + + override fun getScore(target: DisplayResolveInfo): Float { + return resolverComparator.getScore(target) + } + + override fun getScore(targetInfo: TargetInfo): Float { + return resolverComparator.getScore(targetInfo) + } + + override suspend fun updateModel(targetInfo: TargetInfo) { + withContext(bgDispatcher) { resolverComparator.updateModel(targetInfo) } + } + + override suspend fun updateChooserCounts( + packageName: String, + user: UserHandle, + action: String, + ) { + withContext(bgDispatcher) { + resolverComparator.updateChooserCounts(packageName, user, action) + } + } + + override fun destroy() { + resolverComparator.destroy() + } + + companion object { + private const val TAG = "ResolvedComponentSort" + private const val DEBUG = false + } +} diff --git a/java/tests/src/com/android/intentresolver/model/AbstractResolverComparatorTest.java b/java/tests/src/com/android/intentresolver/model/AbstractResolverComparatorTest.java index 5f0ead7b..2140a67d 100644 --- a/java/tests/src/com/android/intentresolver/model/AbstractResolverComparatorTest.java +++ b/java/tests/src/com/android/intentresolver/model/AbstractResolverComparatorTest.java @@ -118,14 +118,14 @@ public class AbstractResolverComparatorTest { Lists.newArrayList(context.getUser()), promoteToFirst) { @Override - int compare(ResolveInfo lhs, ResolveInfo rhs) { + public int compare(ResolveInfo lhs, ResolveInfo rhs) { // Used for testing pinning, so we should never get here --- the overrides // should determine the result instead. return 1; } @Override - void doCompute(List targets) {} + public void doCompute(List targets) {} @Override public float getScore(TargetInfo targetInfo) { @@ -133,7 +133,7 @@ public class AbstractResolverComparatorTest { } @Override - void handleResultMessage(Message message) {} + public void handleResultMessage(Message message) {} }; return testComparator; } diff --git a/java/tests/src/com/android/intentresolver/v2/listcontroller/ChooserRequestFilteredComponentsTest.kt b/java/tests/src/com/android/intentresolver/v2/listcontroller/ChooserRequestFilteredComponentsTest.kt new file mode 100644 index 00000000..59494bed --- /dev/null +++ b/java/tests/src/com/android/intentresolver/v2/listcontroller/ChooserRequestFilteredComponentsTest.kt @@ -0,0 +1,61 @@ +/* + * Copyright (C) 2023 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.intentresolver.v2.listcontroller + +import android.content.ComponentName +import com.android.intentresolver.ChooserRequestParameters +import com.android.intentresolver.whenever +import com.google.common.collect.ImmutableList +import com.google.common.truth.Truth.assertThat +import org.junit.Before +import org.junit.Test +import org.mockito.Mock +import org.mockito.MockitoAnnotations + +class ChooserRequestFilteredComponentsTest { + + @Mock lateinit var mockChooserRequestParameters: ChooserRequestParameters + + private lateinit var chooserRequestFilteredComponents: ChooserRequestFilteredComponents + + @Before + fun setup() { + MockitoAnnotations.initMocks(this) + + chooserRequestFilteredComponents = + ChooserRequestFilteredComponents(mockChooserRequestParameters) + } + + @Test + fun isComponentFiltered_returnsRequestParametersFilteredState() { + // Arrange + whenever(mockChooserRequestParameters.filteredComponentNames) + .thenReturn( + ImmutableList.of(ComponentName("FilteredPackage", "FilteredClass")), + ) + val testComponent = ComponentName("TestPackage", "TestClass") + val filteredComponent = ComponentName("FilteredPackage", "FilteredClass") + + // Act + val result = chooserRequestFilteredComponents.isComponentFiltered(testComponent) + val filteredResult = chooserRequestFilteredComponents.isComponentFiltered(filteredComponent) + + // Assert + assertThat(result).isFalse() + assertThat(filteredResult).isTrue() + } +} diff --git a/java/tests/src/com/android/intentresolver/v2/listcontroller/FakeResolverComparator.kt b/java/tests/src/com/android/intentresolver/v2/listcontroller/FakeResolverComparator.kt new file mode 100644 index 00000000..ce40567e --- /dev/null +++ b/java/tests/src/com/android/intentresolver/v2/listcontroller/FakeResolverComparator.kt @@ -0,0 +1,83 @@ +/* + * Copyright (C) 2023 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.intentresolver.v2.listcontroller + +import android.content.ComponentName +import android.content.Context +import android.content.Intent +import android.content.pm.ResolveInfo +import android.content.res.Configuration +import android.content.res.Resources +import android.os.Message +import android.os.UserHandle +import com.android.intentresolver.ResolvedComponentInfo +import com.android.intentresolver.chooser.TargetInfo +import com.android.intentresolver.model.AbstractResolverComparator +import com.android.intentresolver.whenever +import java.util.Locale +import org.mockito.Mockito + +class FakeResolverComparator( + context: Context = + Mockito.mock(Context::class.java).also { + val mockResources = Mockito.mock(Resources::class.java) + whenever(it.resources).thenReturn(mockResources) + whenever(mockResources.configuration) + .thenReturn(Configuration().apply { setLocale(Locale.US) }) + }, + targetIntent: Intent = Intent("TestAction"), + resolvedActivityUserSpaceList: List = emptyList(), + promoteToFirst: ComponentName? = null, +) : + AbstractResolverComparator( + context, + targetIntent, + resolvedActivityUserSpaceList, + promoteToFirst, + ) { + var lastUpdateModel: TargetInfo? = null + private set + var lastUpdateChooserCounts: Triple? = null + private set + var destroyCalled = false + private set + + override fun compare(lhs: ResolveInfo?, rhs: ResolveInfo?): Int = + lhs!!.activityInfo.packageName.compareTo(rhs!!.activityInfo.packageName) + + override fun doCompute(targets: MutableList?) {} + + override fun getScore(targetInfo: TargetInfo?): Float = 1.23f + + override fun handleResultMessage(message: Message?) {} + + override fun updateModel(targetInfo: TargetInfo?) { + lastUpdateModel = targetInfo + } + + override fun updateChooserCounts( + packageName: String, + user: UserHandle, + action: String, + ) { + lastUpdateChooserCounts = Triple(packageName, user, action) + } + + override fun destroy() { + destroyCalled = true + } +} diff --git a/java/tests/src/com/android/intentresolver/v2/listcontroller/FilterableComponentsTest.kt b/java/tests/src/com/android/intentresolver/v2/listcontroller/FilterableComponentsTest.kt new file mode 100644 index 00000000..396505e6 --- /dev/null +++ b/java/tests/src/com/android/intentresolver/v2/listcontroller/FilterableComponentsTest.kt @@ -0,0 +1,77 @@ +/* + * Copyright (C) 2023 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.intentresolver.v2.listcontroller + +import android.content.ComponentName +import com.android.intentresolver.ChooserRequestParameters +import com.android.intentresolver.whenever +import com.google.common.collect.ImmutableList +import com.google.common.truth.Truth.assertThat +import org.junit.Before +import org.junit.Test +import org.mockito.Mock +import org.mockito.MockitoAnnotations + +class FilterableComponentsTest { + + @Mock lateinit var mockChooserRequestParameters: ChooserRequestParameters + + private val unfilteredComponent = ComponentName("TestPackage", "TestClass") + private val filteredComponent = ComponentName("FilteredPackage", "FilteredClass") + private val noComponentFiltering = NoComponentFiltering() + + private lateinit var chooserRequestFilteredComponents: ChooserRequestFilteredComponents + + @Before + fun setup() { + MockitoAnnotations.initMocks(this) + + chooserRequestFilteredComponents = + ChooserRequestFilteredComponents(mockChooserRequestParameters) + } + + @Test + fun isComponentFiltered_noComponentFiltering_neverFilters() { + // Arrange + + // Act + val unfilteredResult = noComponentFiltering.isComponentFiltered(unfilteredComponent) + val filteredResult = noComponentFiltering.isComponentFiltered(filteredComponent) + + // Assert + assertThat(unfilteredResult).isFalse() + assertThat(filteredResult).isFalse() + } + + @Test + fun isComponentFiltered_chooserRequestFilteredComponents_filtersAccordingToChooserRequest() { + // Arrange + whenever(mockChooserRequestParameters.filteredComponentNames) + .thenReturn( + ImmutableList.of(filteredComponent), + ) + + // Act + val unfilteredResult = + chooserRequestFilteredComponents.isComponentFiltered(unfilteredComponent) + val filteredResult = chooserRequestFilteredComponents.isComponentFiltered(filteredComponent) + + // Assert + assertThat(unfilteredResult).isFalse() + assertThat(filteredResult).isTrue() + } +} diff --git a/java/tests/src/com/android/intentresolver/v2/listcontroller/IntentResolverTest.kt b/java/tests/src/com/android/intentresolver/v2/listcontroller/IntentResolverTest.kt new file mode 100644 index 00000000..09f6d373 --- /dev/null +++ b/java/tests/src/com/android/intentresolver/v2/listcontroller/IntentResolverTest.kt @@ -0,0 +1,499 @@ +/* + * Copyright (C) 2023 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.intentresolver.v2.listcontroller + +import android.content.ComponentName +import android.content.Intent +import android.content.IntentFilter +import android.content.pm.ActivityInfo +import android.content.pm.PackageManager +import android.content.pm.ResolveInfo +import android.net.Uri +import android.os.UserHandle +import com.android.intentresolver.any +import com.android.intentresolver.eq +import com.android.intentresolver.kotlinArgumentCaptor +import com.android.intentresolver.whenever +import com.google.common.truth.Truth.assertThat +import java.lang.IndexOutOfBoundsException +import org.junit.Assert.assertThrows +import org.junit.Before +import org.junit.Test +import org.mockito.Mock +import org.mockito.Mockito.anyInt +import org.mockito.Mockito.verify +import org.mockito.MockitoAnnotations + +class IntentResolverTest { + + @Mock lateinit var mockPackageManager: PackageManager + + private lateinit var intentResolver: IntentResolver + + private val fakePinnableComponents = + object : PinnableComponents { + override fun isComponentPinned(name: ComponentName): Boolean { + return name.packageName == "PinnedPackage" + } + } + + @Before + fun setup() { + MockitoAnnotations.initMocks(this) + + intentResolver = + IntentResolverImpl(mockPackageManager, ResolveListDeduperImpl(fakePinnableComponents)) + } + + @Test + fun getResolversForIntentAsUser_noIntents_returnsEmptyList() { + // Arrange + val testIntents = emptyList() + + // Act + val result = + intentResolver.getResolversForIntentAsUser( + shouldGetResolvedFilter = false, + shouldGetActivityMetadata = false, + shouldGetOnlyDefaultActivities = false, + intents = testIntents, + userHandle = UserHandle(456), + ) + + // Assert + assertThat(result).isEmpty() + } + + @Test + fun getResolversForIntentAsUser_noResolveInfo_returnsEmptyList() { + // Arrange + val testIntents = listOf(Intent("TestAction")) + val testResolveInfos = emptyList() + whenever(mockPackageManager.queryIntentActivitiesAsUser(any(), anyInt(), any())) + .thenReturn(testResolveInfos) + + // Act + val result = + intentResolver.getResolversForIntentAsUser( + shouldGetResolvedFilter = false, + shouldGetActivityMetadata = false, + shouldGetOnlyDefaultActivities = false, + intents = testIntents, + userHandle = UserHandle(456), + ) + + // Assert + assertThat(result).isEmpty() + } + + @Test + fun getResolversForIntentAsUser_returnsAllResolveComponentInfo() { + // Arrange + val testIntent1 = Intent("TestAction1") + val testIntent2 = Intent("TestAction2") + val testIntents = listOf(testIntent1, testIntent2) + val testResolveInfos1 = + listOf( + ResolveInfo().apply { + userHandle = UserHandle(456) + activityInfo = ActivityInfo() + activityInfo.packageName = "TestPackage1" + activityInfo.name = "TestClass1" + }, + ResolveInfo().apply { + userHandle = UserHandle(456) + activityInfo = ActivityInfo() + activityInfo.packageName = "TestPackage2" + activityInfo.name = "TestClass2" + }, + ) + val testResolveInfos2 = + listOf( + ResolveInfo().apply { + userHandle = UserHandle(456) + activityInfo = ActivityInfo() + activityInfo.packageName = "TestPackage3" + activityInfo.name = "TestClass3" + }, + ResolveInfo().apply { + userHandle = UserHandle(456) + activityInfo = ActivityInfo() + activityInfo.packageName = "TestPackage4" + activityInfo.name = "TestClass4" + }, + ) + whenever( + mockPackageManager.queryIntentActivitiesAsUser( + eq(testIntent1), + anyInt(), + any(), + ) + ) + .thenReturn(testResolveInfos1) + whenever( + mockPackageManager.queryIntentActivitiesAsUser( + eq(testIntent2), + anyInt(), + any(), + ) + ) + .thenReturn(testResolveInfos2) + + // Act + val result = + intentResolver.getResolversForIntentAsUser( + shouldGetResolvedFilter = false, + shouldGetActivityMetadata = false, + shouldGetOnlyDefaultActivities = false, + intents = testIntents, + userHandle = UserHandle(456), + ) + + // Assert + result.forEachIndexed { index, it -> + val postfix = index + 1 + assertThat(it.name.packageName).isEqualTo("TestPackage$postfix") + assertThat(it.name.className).isEqualTo("TestClass$postfix") + assertThrows(IndexOutOfBoundsException::class.java) { it.getIntentAt(1) } + } + assertThat(result.map { it.getIntentAt(0) }) + .containsExactly( + testIntent1, + testIntent1, + testIntent2, + testIntent2, + ) + } + + @Test + fun getResolversForIntentAsUser_resolveInfoWithoutUserHandle_isSkipped() { + // Arrange + val testIntent = Intent("TestAction") + val testIntents = listOf(testIntent) + val testResolveInfos = + listOf( + ResolveInfo().apply { + activityInfo = ActivityInfo() + activityInfo.packageName = "TestPackage" + activityInfo.name = "TestClass" + }, + ) + whenever( + mockPackageManager.queryIntentActivitiesAsUser( + any(), + anyInt(), + any(), + ) + ) + .thenReturn(testResolveInfos) + + // Act + val result = + intentResolver.getResolversForIntentAsUser( + shouldGetResolvedFilter = false, + shouldGetActivityMetadata = false, + shouldGetOnlyDefaultActivities = false, + intents = testIntents, + userHandle = UserHandle(456), + ) + + // Assert + assertThat(result).isEmpty() + } + + @Test + fun getResolversForIntentAsUser_duplicateComponents_areCombined() { + // Arrange + val testIntent1 = Intent("TestAction1") + val testIntent2 = Intent("TestAction2") + val testIntents = listOf(testIntent1, testIntent2) + val testResolveInfos1 = + listOf( + ResolveInfo().apply { + userHandle = UserHandle(456) + activityInfo = ActivityInfo() + activityInfo.packageName = "DuplicatePackage" + activityInfo.name = "DuplicateClass" + }, + ) + val testResolveInfos2 = + listOf( + ResolveInfo().apply { + userHandle = UserHandle(456) + activityInfo = ActivityInfo() + activityInfo.packageName = "DuplicatePackage" + activityInfo.name = "DuplicateClass" + }, + ) + whenever( + mockPackageManager.queryIntentActivitiesAsUser( + eq(testIntent1), + anyInt(), + any(), + ) + ) + .thenReturn(testResolveInfos1) + whenever( + mockPackageManager.queryIntentActivitiesAsUser( + eq(testIntent2), + anyInt(), + any(), + ) + ) + .thenReturn(testResolveInfos2) + + // Act + val result = + intentResolver.getResolversForIntentAsUser( + shouldGetResolvedFilter = false, + shouldGetActivityMetadata = false, + shouldGetOnlyDefaultActivities = false, + intents = testIntents, + userHandle = UserHandle(456), + ) + + // Assert + assertThat(result).hasSize(1) + with(result.first()) { + assertThat(name.packageName).isEqualTo("DuplicatePackage") + assertThat(name.className).isEqualTo("DuplicateClass") + assertThat(getIntentAt(0)).isEqualTo(testIntent1) + assertThat(getIntentAt(1)).isEqualTo(testIntent2) + assertThrows(IndexOutOfBoundsException::class.java) { getIntentAt(2) } + } + } + + @Test + fun getResolversForIntentAsUser_pinnedComponentsArePinned() { + // Arrange + val testIntent1 = Intent("TestAction1") + val testIntent2 = Intent("TestAction2") + val testIntents = listOf(testIntent1, testIntent2) + val testResolveInfos1 = + listOf( + ResolveInfo().apply { + userHandle = UserHandle(456) + activityInfo = ActivityInfo() + activityInfo.packageName = "UnpinnedPackage" + activityInfo.name = "UnpinnedClass" + }, + ) + val testResolveInfos2 = + listOf( + ResolveInfo().apply { + userHandle = UserHandle(456) + activityInfo = ActivityInfo() + activityInfo.packageName = "PinnedPackage" + activityInfo.name = "PinnedClass" + }, + ) + whenever( + mockPackageManager.queryIntentActivitiesAsUser( + eq(testIntent1), + anyInt(), + any(), + ) + ) + .thenReturn(testResolveInfos1) + whenever( + mockPackageManager.queryIntentActivitiesAsUser( + eq(testIntent2), + anyInt(), + any(), + ) + ) + .thenReturn(testResolveInfos2) + + // Act + val result = + intentResolver.getResolversForIntentAsUser( + shouldGetResolvedFilter = false, + shouldGetActivityMetadata = false, + shouldGetOnlyDefaultActivities = false, + intents = testIntents, + userHandle = UserHandle(456), + ) + + // Assert + assertThat(result.map { it.isPinned }).containsExactly(false, true) + } + + @Test + fun getResolversForIntentAsUser_whenNoExtraBehavior_usesBaseFlags() { + // Arrange + val baseFlags = + PackageManager.MATCH_DIRECT_BOOT_AWARE or + PackageManager.MATCH_DIRECT_BOOT_UNAWARE or + PackageManager.MATCH_CLONE_PROFILE + val testIntent = Intent() + val testIntents = listOf(testIntent) + + // Act + intentResolver.getResolversForIntentAsUser( + shouldGetResolvedFilter = false, + shouldGetActivityMetadata = false, + shouldGetOnlyDefaultActivities = false, + intents = testIntents, + userHandle = UserHandle(456), + ) + + // Assert + val flags = kotlinArgumentCaptor() + verify(mockPackageManager) + .queryIntentActivitiesAsUser( + any(), + flags.capture(), + any(), + ) + assertThat(flags.value).isEqualTo(baseFlags) + } + + @Test + fun getResolversForIntentAsUser_whenShouldGetResolvedFilter_usesGetResolvedFilterFlag() { + // Arrange + val testIntent = Intent() + val testIntents = listOf(testIntent) + + // Act + intentResolver.getResolversForIntentAsUser( + shouldGetResolvedFilter = true, + shouldGetActivityMetadata = false, + shouldGetOnlyDefaultActivities = false, + intents = testIntents, + userHandle = UserHandle(456), + ) + + // Assert + val flags = kotlinArgumentCaptor() + verify(mockPackageManager) + .queryIntentActivitiesAsUser( + any(), + flags.capture(), + any(), + ) + assertThat(flags.value and PackageManager.GET_RESOLVED_FILTER) + .isEqualTo(PackageManager.GET_RESOLVED_FILTER) + } + + @Test + fun getResolversForIntentAsUser_whenShouldGetActivityMetadata_usesGetMetaDataFlag() { + // Arrange + val testIntent = Intent() + val testIntents = listOf(testIntent) + + // Act + intentResolver.getResolversForIntentAsUser( + shouldGetResolvedFilter = false, + shouldGetActivityMetadata = true, + shouldGetOnlyDefaultActivities = false, + intents = testIntents, + userHandle = UserHandle(456), + ) + + // Assert + val flags = kotlinArgumentCaptor() + verify(mockPackageManager) + .queryIntentActivitiesAsUser( + any(), + flags.capture(), + any(), + ) + assertThat(flags.value and PackageManager.GET_META_DATA) + .isEqualTo(PackageManager.GET_META_DATA) + } + + @Test + fun getResolversForIntentAsUser_whenShouldGetOnlyDefaultActivities_usesMatchDefaultOnlyFlag() { + // Arrange + val testIntent = Intent() + val testIntents = listOf(testIntent) + + // Act + intentResolver.getResolversForIntentAsUser( + shouldGetResolvedFilter = false, + shouldGetActivityMetadata = false, + shouldGetOnlyDefaultActivities = true, + intents = testIntents, + userHandle = UserHandle(456), + ) + + // Assert + val flags = kotlinArgumentCaptor() + verify(mockPackageManager) + .queryIntentActivitiesAsUser( + any(), + flags.capture(), + any(), + ) + assertThat(flags.value and PackageManager.MATCH_DEFAULT_ONLY) + .isEqualTo(PackageManager.MATCH_DEFAULT_ONLY) + } + + @Test + fun getResolversForIntentAsUser_whenWebIntent_usesMatchInstantFlag() { + // Arrange + val testIntent = Intent(Intent.ACTION_VIEW, Uri.fromParts(IntentFilter.SCHEME_HTTP, "", "")) + val testIntents = listOf(testIntent) + + // Act + intentResolver.getResolversForIntentAsUser( + shouldGetResolvedFilter = false, + shouldGetActivityMetadata = false, + shouldGetOnlyDefaultActivities = false, + intents = testIntents, + userHandle = UserHandle(456), + ) + + // Assert + val flags = kotlinArgumentCaptor() + verify(mockPackageManager) + .queryIntentActivitiesAsUser( + any(), + flags.capture(), + any(), + ) + assertThat(flags.value and PackageManager.MATCH_INSTANT) + .isEqualTo(PackageManager.MATCH_INSTANT) + } + + @Test + fun getResolversForIntentAsUser_whenActivityMatchExternalFlag_usesMatchInstantFlag() { + // Arrange + val testIntent = Intent().addFlags(Intent.FLAG_ACTIVITY_MATCH_EXTERNAL) + val testIntents = listOf(testIntent) + + // Act + intentResolver.getResolversForIntentAsUser( + shouldGetResolvedFilter = false, + shouldGetActivityMetadata = false, + shouldGetOnlyDefaultActivities = false, + intents = testIntents, + userHandle = UserHandle(456), + ) + + // Assert + val flags = kotlinArgumentCaptor() + verify(mockPackageManager) + .queryIntentActivitiesAsUser( + any(), + flags.capture(), + any(), + ) + assertThat(flags.value and PackageManager.MATCH_INSTANT) + .isEqualTo(PackageManager.MATCH_INSTANT) + } +} diff --git a/java/tests/src/com/android/intentresolver/v2/listcontroller/LastChosenManagerTest.kt b/java/tests/src/com/android/intentresolver/v2/listcontroller/LastChosenManagerTest.kt new file mode 100644 index 00000000..ce5e52b1 --- /dev/null +++ b/java/tests/src/com/android/intentresolver/v2/listcontroller/LastChosenManagerTest.kt @@ -0,0 +1,111 @@ +/* + * Copyright (C) 2023 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.intentresolver.v2.listcontroller + +import android.content.ComponentName +import android.content.ContentResolver +import android.content.Intent +import android.content.IntentFilter +import android.content.pm.IPackageManager +import android.content.pm.PackageManager +import android.content.pm.ResolveInfo +import com.android.intentresolver.any +import com.android.intentresolver.eq +import com.android.intentresolver.nullable +import com.android.intentresolver.whenever +import com.google.common.truth.Truth.assertThat +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import org.junit.Before +import org.junit.Test +import org.mockito.Mock +import org.mockito.Mockito.isNull +import org.mockito.Mockito.verify +import org.mockito.MockitoAnnotations + +@OptIn(ExperimentalCoroutinesApi::class) +class LastChosenManagerTest { + + private val testDispatcher = UnconfinedTestDispatcher() + private val testScope = TestScope(testDispatcher) + private val testTargetIntent = Intent("TestAction") + + @Mock lateinit var mockContentResolver: ContentResolver + @Mock lateinit var mockIPackageManager: IPackageManager + + private lateinit var lastChosenManager: LastChosenManager + + @Before + fun setup() { + MockitoAnnotations.initMocks(this) + + lastChosenManager = + PackageManagerLastChosenManager(mockContentResolver, testDispatcher, testTargetIntent) { + mockIPackageManager + } + } + + @Test + fun getLastChosen_returnsLastChosenActivity() = + testScope.runTest { + // Arrange + val testResolveInfo = ResolveInfo() + whenever(mockIPackageManager.getLastChosenActivity(any(), nullable(), any())) + .thenReturn(testResolveInfo) + + // Act + val lastChosen = lastChosenManager.getLastChosen() + runCurrent() + + // Assert + verify(mockIPackageManager) + .getLastChosenActivity( + eq(testTargetIntent), + isNull(), + eq(PackageManager.MATCH_DEFAULT_ONLY), + ) + assertThat(lastChosen).isSameInstanceAs(testResolveInfo) + } + + @Test + fun setLastChosen_setsLastChosenActivity() = + testScope.runTest { + // Arrange + val testComponent = ComponentName("TestPackage", "TestClass") + val testIntent = Intent().apply { component = testComponent } + val testIntentFilter = IntentFilter() + val testMatch = 456 + + // Act + lastChosenManager.setLastChosen(testIntent, testIntentFilter, testMatch) + runCurrent() + + // Assert + verify(mockIPackageManager) + .setLastChosenActivity( + eq(testIntent), + isNull(), + eq(PackageManager.MATCH_DEFAULT_ONLY), + eq(testIntentFilter), + eq(testMatch), + eq(testComponent), + ) + } +} diff --git a/java/tests/src/com/android/intentresolver/v2/listcontroller/PinnableComponentsTest.kt b/java/tests/src/com/android/intentresolver/v2/listcontroller/PinnableComponentsTest.kt new file mode 100644 index 00000000..112342ad --- /dev/null +++ b/java/tests/src/com/android/intentresolver/v2/listcontroller/PinnableComponentsTest.kt @@ -0,0 +1,74 @@ +/* + * Copyright (C) 2023 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.intentresolver.v2.listcontroller + +import android.content.ComponentName +import android.content.SharedPreferences +import com.android.intentresolver.any +import com.android.intentresolver.eq +import com.android.intentresolver.whenever +import com.google.common.truth.Truth.assertThat +import org.junit.Before +import org.junit.Test +import org.mockito.Mock +import org.mockito.MockitoAnnotations + +class PinnableComponentsTest { + + @Mock lateinit var mockSharedPreferences: SharedPreferences + + private val unpinnedComponent = ComponentName("TestPackage", "TestClass") + private val pinnedComponent = ComponentName("PinnedPackage", "PinnedClass") + private val noComponentPinning = NoComponentPinning() + + private lateinit var sharedPreferencesPinnedComponents: PinnableComponents + + @Before + fun setup() { + MockitoAnnotations.initMocks(this) + + sharedPreferencesPinnedComponents = SharedPreferencesPinnedComponents(mockSharedPreferences) + } + + @Test + fun isComponentPinned_noComponentPinning_neverPins() { + // Arrange + + // Act + val unpinnedResult = noComponentPinning.isComponentPinned(unpinnedComponent) + val pinnedResult = noComponentPinning.isComponentPinned(pinnedComponent) + + // Assert + assertThat(unpinnedResult).isFalse() + assertThat(pinnedResult).isFalse() + } + + @Test + fun isComponentFiltered_chooserRequestFilteredComponents_filtersAccordingToChooserRequest() { + // Arrange + whenever(mockSharedPreferences.getBoolean(eq(pinnedComponent.flattenToString()), any())) + .thenReturn(true) + + // Act + val unpinnedResult = sharedPreferencesPinnedComponents.isComponentPinned(unpinnedComponent) + val pinnedResult = sharedPreferencesPinnedComponents.isComponentPinned(pinnedComponent) + + // Assert + assertThat(unpinnedResult).isFalse() + assertThat(pinnedResult).isTrue() + } +} diff --git a/java/tests/src/com/android/intentresolver/v2/listcontroller/ResolveListDeduperTest.kt b/java/tests/src/com/android/intentresolver/v2/listcontroller/ResolveListDeduperTest.kt new file mode 100644 index 00000000..26f0199e --- /dev/null +++ b/java/tests/src/com/android/intentresolver/v2/listcontroller/ResolveListDeduperTest.kt @@ -0,0 +1,125 @@ +/* + * Copyright (C) 2023 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.intentresolver.v2.listcontroller + +import android.content.ComponentName +import android.content.Intent +import android.content.pm.ActivityInfo +import android.content.pm.ResolveInfo +import android.os.UserHandle +import com.android.intentresolver.ResolvedComponentInfo +import com.google.common.truth.Truth.assertThat +import java.lang.IndexOutOfBoundsException +import org.junit.Assert.assertThrows +import org.junit.Before +import org.junit.Test + +class ResolveListDeduperTest { + + private lateinit var resolveListDeduper: ResolveListDeduper + + @Before + fun setup() { + resolveListDeduper = ResolveListDeduperImpl(NoComponentPinning()) + } + + @Test + fun addResolveListDedupe_addsDifferentComponents() { + // Arrange + val testIntent = Intent() + val testResolveInfo1 = + ResolveInfo().apply { + userHandle = UserHandle(456) + activityInfo = ActivityInfo() + activityInfo.packageName = "TestPackage1" + activityInfo.name = "TestClass1" + } + val testResolveInfo2 = + ResolveInfo().apply { + userHandle = UserHandle(456) + activityInfo = ActivityInfo() + activityInfo.packageName = "TestPackage2" + activityInfo.name = "TestClass2" + } + val testResolvedComponentInfo1 = + ResolvedComponentInfo( + ComponentName("TestPackage1", "TestClass1"), + testIntent, + testResolveInfo1, + ) + .apply { isPinned = false } + val listUnderTest = mutableListOf(testResolvedComponentInfo1) + val listToAdd = listOf(testResolveInfo2) + + // Act + resolveListDeduper.addToResolveListWithDedupe( + into = listUnderTest, + intent = testIntent, + from = listToAdd, + ) + + // Assert + listUnderTest.forEachIndexed { index, it -> + val postfix = index + 1 + assertThat(it.name.packageName).isEqualTo("TestPackage$postfix") + assertThat(it.name.className).isEqualTo("TestClass$postfix") + assertThat(it.getIntentAt(0)).isEqualTo(testIntent) + assertThrows(IndexOutOfBoundsException::class.java) { it.getIntentAt(1) } + } + } + + @Test + fun addResolveListDedupe_combinesDuplicateComponents() { + // Arrange + val testIntent = Intent() + val testResolveInfo1 = + ResolveInfo().apply { + userHandle = UserHandle(456) + activityInfo = ActivityInfo() + activityInfo.packageName = "DuplicatePackage" + activityInfo.name = "DuplicateClass" + } + val testResolveInfo2 = + ResolveInfo().apply { + userHandle = UserHandle(456) + activityInfo = ActivityInfo() + activityInfo.packageName = "DuplicatePackage" + activityInfo.name = "DuplicateClass" + } + val testResolvedComponentInfo1 = + ResolvedComponentInfo( + ComponentName("DuplicatePackage", "DuplicateClass"), + testIntent, + testResolveInfo1, + ) + .apply { isPinned = false } + val listUnderTest = mutableListOf(testResolvedComponentInfo1) + val listToAdd = listOf(testResolveInfo2) + + // Act + resolveListDeduper.addToResolveListWithDedupe( + into = listUnderTest, + intent = testIntent, + from = listToAdd, + ) + + // Assert + assertThat(listUnderTest).containsExactly(testResolvedComponentInfo1) + assertThat(testResolvedComponentInfo1.getResolveInfoAt(0)).isEqualTo(testResolveInfo1) + assertThat(testResolvedComponentInfo1.getResolveInfoAt(1)).isEqualTo(testResolveInfo2) + } +} diff --git a/java/tests/src/com/android/intentresolver/v2/listcontroller/ResolvedComponentFilteringTest.kt b/java/tests/src/com/android/intentresolver/v2/listcontroller/ResolvedComponentFilteringTest.kt new file mode 100644 index 00000000..9786b801 --- /dev/null +++ b/java/tests/src/com/android/intentresolver/v2/listcontroller/ResolvedComponentFilteringTest.kt @@ -0,0 +1,309 @@ +/* + * Copyright (C) 2023 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.intentresolver.v2.listcontroller + +import android.content.ComponentName +import android.content.Intent +import android.content.pm.ActivityInfo +import android.content.pm.ApplicationInfo +import android.content.pm.PackageManager +import android.content.pm.ResolveInfo +import com.android.intentresolver.ResolvedComponentInfo +import com.google.common.truth.Truth.assertThat +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertThrows +import org.junit.Before +import org.junit.Test + +class ResolvedComponentFilteringTest { + + private lateinit var resolvedComponentFiltering: ResolvedComponentFiltering + + private val fakeFilterableComponents = + object : FilterableComponents { + override fun isComponentFiltered(name: ComponentName): Boolean { + return name.packageName == "FilteredPackage" + } + } + + private val fakePermissionChecker = + object : PermissionChecker { + override suspend fun checkComponentPermission( + permission: String, + uid: Int, + owningUid: Int, + exported: Boolean + ): Int { + return if (permission == "MissingPermission") { + PackageManager.PERMISSION_DENIED + } else { + PackageManager.PERMISSION_GRANTED + } + } + } + + @Before + fun setup() { + resolvedComponentFiltering = + ResolvedComponentFilteringImpl( + launchedFromUid = 123, + filterableComponents = fakeFilterableComponents, + permissionChecker = fakePermissionChecker, + ) + } + + @Test + fun filterIneligibleActivities_returnsListWithoutFilteredComponents() = runTest { + // Arrange + val testIntent = Intent("TestAction") + val testResolveInfo = + ResolveInfo().apply { + activityInfo = ActivityInfo() + activityInfo.packageName = "TestPackage" + activityInfo.name = "TestClass" + activityInfo.permission = "TestPermission" + activityInfo.applicationInfo = ApplicationInfo() + activityInfo.applicationInfo.uid = 456 + activityInfo.exported = false + } + val filteredResolveInfo = + ResolveInfo().apply { + activityInfo = ActivityInfo() + activityInfo.packageName = "FilteredPackage" + activityInfo.name = "FilteredClass" + activityInfo.permission = "TestPermission" + activityInfo.applicationInfo = ApplicationInfo() + activityInfo.applicationInfo.uid = 456 + activityInfo.exported = false + } + val missingPermissionResolveInfo = + ResolveInfo().apply { + activityInfo = ActivityInfo() + activityInfo.packageName = "NoPermissionPackage" + activityInfo.name = "NoPermissionClass" + activityInfo.permission = "MissingPermission" + activityInfo.applicationInfo = ApplicationInfo() + activityInfo.applicationInfo.uid = 456 + activityInfo.exported = false + } + val testInput = + listOf( + ResolvedComponentInfo( + ComponentName("TestPackage", "TestClass"), + testIntent, + testResolveInfo, + ), + ResolvedComponentInfo( + ComponentName("FilteredPackage", "FilteredClass"), + testIntent, + filteredResolveInfo, + ), + ResolvedComponentInfo( + ComponentName("NoPermissionPackage", "NoPermissionClass"), + testIntent, + missingPermissionResolveInfo, + ) + ) + + // Act + val result = resolvedComponentFiltering.filterIneligibleActivities(testInput) + + // Assert + assertThat(result).hasSize(1) + with(result.first()) { + assertThat(name.packageName).isEqualTo("TestPackage") + assertThat(name.className).isEqualTo("TestClass") + assertThat(getIntentAt(0)).isEqualTo(testIntent) + assertThrows(IndexOutOfBoundsException::class.java) { getIntentAt(1) } + assertThat(getResolveInfoAt(0)).isEqualTo(testResolveInfo) + assertThrows(IndexOutOfBoundsException::class.java) { getResolveInfoAt(1) } + } + } + + @Test + fun filterLowPriority_filtersAfterFirstDifferentPriority() { + // Arrange + val testIntent = Intent("TestAction") + val testResolveInfo = + ResolveInfo().apply { + priority = 1 + isDefault = true + } + val equalResolveInfo = + ResolveInfo().apply { + priority = 1 + isDefault = true + } + val diffResolveInfo = + ResolveInfo().apply { + priority = 2 + isDefault = true + } + val testInput = + listOf( + ResolvedComponentInfo( + ComponentName("TestPackage", "TestClass"), + testIntent, + testResolveInfo, + ), + ResolvedComponentInfo( + ComponentName("EqualPackage", "EqualClass"), + testIntent, + equalResolveInfo, + ), + ResolvedComponentInfo( + ComponentName("DiffPackage", "DiffClass"), + testIntent, + diffResolveInfo, + ), + ) + + // Act + val result = resolvedComponentFiltering.filterLowPriority(testInput) + + // Assert + assertThat(result).hasSize(2) + with(result.first()) { + assertThat(name.packageName).isEqualTo("TestPackage") + assertThat(name.className).isEqualTo("TestClass") + assertThat(getIntentAt(0)).isEqualTo(testIntent) + assertThrows(IndexOutOfBoundsException::class.java) { getIntentAt(1) } + assertThat(getResolveInfoAt(0)).isEqualTo(testResolveInfo) + assertThrows(IndexOutOfBoundsException::class.java) { getResolveInfoAt(1) } + } + with(result[1]) { + assertThat(name.packageName).isEqualTo("EqualPackage") + assertThat(name.className).isEqualTo("EqualClass") + assertThat(getIntentAt(0)).isEqualTo(testIntent) + assertThrows(IndexOutOfBoundsException::class.java) { getIntentAt(1) } + assertThat(getResolveInfoAt(0)).isEqualTo(equalResolveInfo) + assertThrows(IndexOutOfBoundsException::class.java) { getResolveInfoAt(1) } + } + } + + @Test + fun filterLowPriority_filtersAfterFirstDifferentDefault() { + // Arrange + val testIntent = Intent("TestAction") + val testResolveInfo = + ResolveInfo().apply { + priority = 1 + isDefault = true + } + val equalResolveInfo = + ResolveInfo().apply { + priority = 1 + isDefault = true + } + val diffResolveInfo = + ResolveInfo().apply { + priority = 1 + isDefault = false + } + val testInput = + listOf( + ResolvedComponentInfo( + ComponentName("TestPackage", "TestClass"), + testIntent, + testResolveInfo, + ), + ResolvedComponentInfo( + ComponentName("EqualPackage", "EqualClass"), + testIntent, + equalResolveInfo, + ), + ResolvedComponentInfo( + ComponentName("DiffPackage", "DiffClass"), + testIntent, + diffResolveInfo, + ), + ) + + // Act + val result = resolvedComponentFiltering.filterLowPriority(testInput) + + // Assert + assertThat(result).hasSize(2) + with(result.first()) { + assertThat(name.packageName).isEqualTo("TestPackage") + assertThat(name.className).isEqualTo("TestClass") + assertThat(getIntentAt(0)).isEqualTo(testIntent) + assertThrows(IndexOutOfBoundsException::class.java) { getIntentAt(1) } + assertThat(getResolveInfoAt(0)).isEqualTo(testResolveInfo) + assertThrows(IndexOutOfBoundsException::class.java) { getResolveInfoAt(1) } + } + with(result[1]) { + assertThat(name.packageName).isEqualTo("EqualPackage") + assertThat(name.className).isEqualTo("EqualClass") + assertThat(getIntentAt(0)).isEqualTo(testIntent) + assertThrows(IndexOutOfBoundsException::class.java) { getIntentAt(1) } + assertThat(getResolveInfoAt(0)).isEqualTo(equalResolveInfo) + assertThrows(IndexOutOfBoundsException::class.java) { getResolveInfoAt(1) } + } + } + + @Test + fun filterLowPriority_whenNoDifference_returnsOriginal() { + // Arrange + val testIntent = Intent("TestAction") + val testResolveInfo = + ResolveInfo().apply { + priority = 1 + isDefault = true + } + val equalResolveInfo = + ResolveInfo().apply { + priority = 1 + isDefault = true + } + val testInput = + listOf( + ResolvedComponentInfo( + ComponentName("TestPackage", "TestClass"), + testIntent, + testResolveInfo, + ), + ResolvedComponentInfo( + ComponentName("EqualPackage", "EqualClass"), + testIntent, + equalResolveInfo, + ), + ) + + // Act + val result = resolvedComponentFiltering.filterLowPriority(testInput) + + // Assert + assertThat(result).hasSize(2) + with(result.first()) { + assertThat(name.packageName).isEqualTo("TestPackage") + assertThat(name.className).isEqualTo("TestClass") + assertThat(getIntentAt(0)).isEqualTo(testIntent) + assertThrows(IndexOutOfBoundsException::class.java) { getIntentAt(1) } + assertThat(getResolveInfoAt(0)).isEqualTo(testResolveInfo) + assertThrows(IndexOutOfBoundsException::class.java) { getResolveInfoAt(1) } + } + with(result[1]) { + assertThat(name.packageName).isEqualTo("EqualPackage") + assertThat(name.className).isEqualTo("EqualClass") + assertThat(getIntentAt(0)).isEqualTo(testIntent) + assertThrows(IndexOutOfBoundsException::class.java) { getIntentAt(1) } + assertThat(getResolveInfoAt(0)).isEqualTo(equalResolveInfo) + assertThrows(IndexOutOfBoundsException::class.java) { getResolveInfoAt(1) } + } + } +} diff --git a/java/tests/src/com/android/intentresolver/v2/listcontroller/ResolvedComponentSortingTest.kt b/java/tests/src/com/android/intentresolver/v2/listcontroller/ResolvedComponentSortingTest.kt new file mode 100644 index 00000000..39b328ee --- /dev/null +++ b/java/tests/src/com/android/intentresolver/v2/listcontroller/ResolvedComponentSortingTest.kt @@ -0,0 +1,197 @@ +/* + * Copyright (C) 2023 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.intentresolver.v2.listcontroller + +import android.content.ComponentName +import android.content.Intent +import android.content.pm.ActivityInfo +import android.content.pm.ApplicationInfo +import android.content.pm.ResolveInfo +import android.os.UserHandle +import com.android.intentresolver.ResolvedComponentInfo +import com.android.intentresolver.chooser.DisplayResolveInfo +import com.android.intentresolver.chooser.TargetInfo +import com.google.common.truth.Truth.assertThat +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.async +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import org.junit.Test +import org.mockito.Mockito + +@OptIn(ExperimentalCoroutinesApi::class) +class ResolvedComponentSortingTest { + + private val testDispatcher = UnconfinedTestDispatcher() + private val testScope = TestScope(testDispatcher) + + private val fakeResolverComparator = FakeResolverComparator() + + private val resolvedComponentSorting = + ResolvedComponentSortingImpl(testDispatcher, fakeResolverComparator) + + @Test + fun sorted_onNullList_returnsNull() = + testScope.runTest { + // Arrange + val testInput: List? = null + + // Act + val result = resolvedComponentSorting.sorted(testInput) + runCurrent() + + // Assert + assertThat(result).isNull() + } + + @Test + fun sorted_onEmptyList_returnsEmptyList() = + testScope.runTest { + // Arrange + val testInput = emptyList() + + // Act + val result = resolvedComponentSorting.sorted(testInput) + runCurrent() + + // Assert + assertThat(result).isEmpty() + } + + @Test + fun sorted_returnsListSortedByGivenComparator() = + testScope.runTest { + // Arrange + val testIntent = Intent("TestAction") + val testInput = + listOf( + ResolveInfo().apply { + activityInfo = ActivityInfo() + activityInfo.packageName = "TestPackage3" + activityInfo.name = "TestClass3" + }, + ResolveInfo().apply { + activityInfo = ActivityInfo() + activityInfo.packageName = "TestPackage1" + activityInfo.name = "TestClass1" + }, + ResolveInfo().apply { + activityInfo = ActivityInfo() + activityInfo.packageName = "TestPackage2" + activityInfo.name = "TestClass2" + }, + ) + .map { + it.targetUserId = UserHandle.USER_CURRENT + ResolvedComponentInfo( + ComponentName(it.activityInfo.packageName, it.activityInfo.name), + testIntent, + it, + ) + } + + // Act + val result = async { resolvedComponentSorting.sorted(testInput) } + runCurrent() + + // Assert + assertThat(result.await()?.map { it.name.packageName }) + .containsExactly("TestPackage1", "TestPackage2", "TestPackage3") + .inOrder() + } + + @Test + fun getScore_displayResolveInfo_returnsTheScoreAccordingToTheResolverComparator() { + // Arrange + val testTarget = + DisplayResolveInfo.newDisplayResolveInfo( + Intent(), + ResolveInfo().apply { + activityInfo = ActivityInfo() + activityInfo.name = "TestClass" + activityInfo.applicationInfo = ApplicationInfo() + activityInfo.applicationInfo.packageName = "TestPackage" + }, + Intent(), + ) + + // Act + val result = resolvedComponentSorting.getScore(testTarget) + + // Assert + assertThat(result).isEqualTo(1.23f) + } + + @Test + fun getScore_targetInfo_returnsTheScoreAccordingToTheResolverComparator() { + // Arrange + val mockTargetInfo = Mockito.mock(TargetInfo::class.java) + + // Act + val result = resolvedComponentSorting.getScore(mockTargetInfo) + + // Assert + assertThat(result).isEqualTo(1.23f) + } + + @Test + fun updateModel_updatesResolverComparatorModel() = + testScope.runTest { + // Arrange + val mockTargetInfo = Mockito.mock(TargetInfo::class.java) + assertThat(fakeResolverComparator.lastUpdateModel).isNull() + + // Act + resolvedComponentSorting.updateModel(mockTargetInfo) + runCurrent() + + // Assert + assertThat(fakeResolverComparator.lastUpdateModel).isSameInstanceAs(mockTargetInfo) + } + + @Test + fun updateChooserCounts_updatesResolverComparaterChooserCounts() = + testScope.runTest { + // Arrange + val testPackageName = "TestPackage" + val testUser = UserHandle(456) + val testAction = "TestAction" + assertThat(fakeResolverComparator.lastUpdateChooserCounts).isNull() + + // Act + resolvedComponentSorting.updateChooserCounts(testPackageName, testUser, testAction) + runCurrent() + + // Assert + assertThat(fakeResolverComparator.lastUpdateChooserCounts) + .isEqualTo(Triple(testPackageName, testUser, testAction)) + } + + @Test + fun destroy_destroysResolverComparator() { + // Arrange + assertThat(fakeResolverComparator.destroyCalled).isFalse() + + // Act + resolvedComponentSorting.destroy() + + // Assert + assertThat(fakeResolverComparator.destroyCalled).isTrue() + } +} diff --git a/java/tests/src/com/android/intentresolver/v2/listcontroller/SharedPreferencesPinnedComponentsTest.kt b/java/tests/src/com/android/intentresolver/v2/listcontroller/SharedPreferencesPinnedComponentsTest.kt new file mode 100644 index 00000000..9d6394fa --- /dev/null +++ b/java/tests/src/com/android/intentresolver/v2/listcontroller/SharedPreferencesPinnedComponentsTest.kt @@ -0,0 +1,63 @@ +/* + * Copyright (C) 2023 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.intentresolver.v2.listcontroller + +import android.content.ComponentName +import android.content.SharedPreferences +import com.android.intentresolver.any +import com.android.intentresolver.eq +import com.android.intentresolver.whenever +import com.google.common.truth.Truth +import org.junit.Before +import org.junit.Test +import org.mockito.Mock +import org.mockito.Mockito +import org.mockito.MockitoAnnotations + +class SharedPreferencesPinnedComponentsTest { + + @Mock lateinit var mockSharedPreferences: SharedPreferences + + private lateinit var sharedPreferencesPinnedComponents: SharedPreferencesPinnedComponents + + @Before + fun setup() { + MockitoAnnotations.initMocks(this) + + sharedPreferencesPinnedComponents = SharedPreferencesPinnedComponents(mockSharedPreferences) + } + + @Test + fun isComponentPinned_returnsSavedPinnedState() { + // Arrange + val testComponent = ComponentName("TestPackage", "TestClass") + val pinnedComponent = ComponentName("PinnedPackage", "PinnedClass") + whenever(mockSharedPreferences.getBoolean(eq(pinnedComponent.flattenToString()), any())) + .thenReturn(true) + + // Act + val result = sharedPreferencesPinnedComponents.isComponentPinned(testComponent) + val pinnedResult = sharedPreferencesPinnedComponents.isComponentPinned(pinnedComponent) + + // Assert + Mockito.verify(mockSharedPreferences).getBoolean(eq(testComponent.flattenToString()), any()) + Mockito.verify(mockSharedPreferences) + .getBoolean(eq(pinnedComponent.flattenToString()), any()) + Truth.assertThat(result).isFalse() + Truth.assertThat(pinnedResult).isTrue() + } +} -- cgit v1.2.3-59-g8ed1b From 7cd9654666193f5ea2ba8394dfba23ed65f68c41 Mon Sep 17 00:00:00 2001 From: Govinda Wasserman Date: Mon, 27 Nov 2023 13:30:11 -0500 Subject: Makes ChooserActivity constructor public This was made package private by mistake. Test: tested locally by flipping aconfig flag BUG: 302113519 Change-Id: I640e3aea61d35f0ccb93920a487ee7d8911fdf65 --- java/src/com/android/intentresolver/v2/ChooserActivity.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) (limited to 'java/src') diff --git a/java/src/com/android/intentresolver/v2/ChooserActivity.java b/java/src/com/android/intentresolver/v2/ChooserActivity.java index 84258850..a854c9e0 100644 --- a/java/src/com/android/intentresolver/v2/ChooserActivity.java +++ b/java/src/com/android/intentresolver/v2/ChooserActivity.java @@ -236,7 +236,7 @@ public class ChooserActivity extends Hilt_ChooserActivity implements private final AtomicLong mIntentReceivedTime = new AtomicLong(-1); - ChooserActivity() { + public ChooserActivity() { super(); mLogic = new ChooserActivityLogic( TAG, -- cgit v1.2.3-59-g8ed1b From 441be5d729aee09aee7b6f6d03f0761b7c1460bd Mon Sep 17 00:00:00 2001 From: Mark Renouf Date: Thu, 30 Nov 2023 15:47:38 -0500 Subject: Narrow dependencies to actual used data Currently PreviewViewModel uses only the targetIntent. This change narrows the dependencies to that specific value to reduce coupling and aid cleanup efforts. Bug: 300157408 Test: atest com.android.intentresolver Change-Id: I63ceda3c460f039939a4193970ee4fe078a96467 --- java/src/com/android/intentresolver/ChooserActivity.java | 2 +- .../android/intentresolver/contentpreview/BasePreviewViewModel.kt | 3 ++- .../com/android/intentresolver/contentpreview/PreviewViewModel.kt | 5 +++-- java/src/com/android/intentresolver/v2/ChooserActivity.java | 2 +- 4 files changed, 7 insertions(+), 5 deletions(-) (limited to 'java/src') diff --git a/java/src/com/android/intentresolver/ChooserActivity.java b/java/src/com/android/intentresolver/ChooserActivity.java index 2f950baa..aa02be37 100644 --- a/java/src/com/android/intentresolver/ChooserActivity.java +++ b/java/src/com/android/intentresolver/ChooserActivity.java @@ -304,7 +304,7 @@ public class ChooserActivity extends Hilt_ChooserActivity implements .get(BasePreviewViewModel.class); mChooserContentPreviewUi = new ChooserContentPreviewUi( getCoroutineScope(getLifecycle()), - previewViewModel.createOrReuseProvider(mChooserRequest), + previewViewModel.createOrReuseProvider(mChooserRequest.getTargetIntent()), mChooserRequest.getTargetIntent(), previewViewModel.createOrReuseImageLoader(), createChooserActionFactory(), diff --git a/java/src/com/android/intentresolver/contentpreview/BasePreviewViewModel.kt b/java/src/com/android/intentresolver/contentpreview/BasePreviewViewModel.kt index 103e8bf4..10ee5af1 100644 --- a/java/src/com/android/intentresolver/contentpreview/BasePreviewViewModel.kt +++ b/java/src/com/android/intentresolver/contentpreview/BasePreviewViewModel.kt @@ -16,6 +16,7 @@ package com.android.intentresolver.contentpreview +import android.content.Intent import androidx.annotation.MainThread import androidx.lifecycle.ViewModel import com.android.intentresolver.ChooserRequestParameters @@ -24,7 +25,7 @@ import com.android.intentresolver.ChooserRequestParameters abstract class BasePreviewViewModel : ViewModel() { @MainThread abstract fun createOrReuseProvider( - chooserRequest: ChooserRequestParameters + targetIntent: Intent ): PreviewDataProvider @MainThread abstract fun createOrReuseImageLoader(): ImageLoader diff --git a/java/src/com/android/intentresolver/contentpreview/PreviewViewModel.kt b/java/src/com/android/intentresolver/contentpreview/PreviewViewModel.kt index b55b8b38..6350756e 100644 --- a/java/src/com/android/intentresolver/contentpreview/PreviewViewModel.kt +++ b/java/src/com/android/intentresolver/contentpreview/PreviewViewModel.kt @@ -17,6 +17,7 @@ package com.android.intentresolver.contentpreview import android.app.Application +import android.content.Intent import androidx.annotation.MainThread import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModelProvider @@ -45,12 +46,12 @@ constructor( @MainThread override fun createOrReuseProvider( - chooserRequest: ChooserRequestParameters + targetIntent: Intent ): PreviewDataProvider = previewDataProvider ?: PreviewDataProvider( viewModelScope + dispatcher, - chooserRequest.targetIntent, + targetIntent, application.contentResolver ) .also { previewDataProvider = it } diff --git a/java/src/com/android/intentresolver/v2/ChooserActivity.java b/java/src/com/android/intentresolver/v2/ChooserActivity.java index a854c9e0..c7a8ebab 100644 --- a/java/src/com/android/intentresolver/v2/ChooserActivity.java +++ b/java/src/com/android/intentresolver/v2/ChooserActivity.java @@ -290,7 +290,7 @@ public class ChooserActivity extends Hilt_ChooserActivity implements ChooserRequestParameters chooserRequest = requireChooserRequest(); mChooserContentPreviewUi = new ChooserContentPreviewUi( getCoroutineScope(getLifecycle()), - previewViewModel.createOrReuseProvider(chooserRequest), + previewViewModel.createOrReuseProvider(chooserRequest.getTargetIntent()), chooserRequest.getTargetIntent(), previewViewModel.createOrReuseImageLoader(), createChooserActionFactory(), -- cgit v1.2.3-59-g8ed1b From acbd4647f757cf4d651a15cb72df2acb5ea59a68 Mon Sep 17 00:00:00 2001 From: Mark Renouf Date: Thu, 30 Nov 2023 16:39:15 -0500 Subject: Narrow dependencies to actual used data (2/n) This replaces ChooserRequestParameters with a handful of values used in each remaining reference outside of v2-forked code. Bug: 300157408 Test: atest com.android.intentresolver Change-Id: I4dd1f17ec171d92b6134a9b803344d3d2485a2b6 --- .../android/intentresolver/ChooserActivity.java | 6 +++--- .../android/intentresolver/ChooserListAdapter.java | 24 ++++++++++++++-------- .../intentresolver/ResolverListAdapter.java | 5 +++++ .../intentresolver/v2/ChooserActionFactory.java | 17 +++++++-------- .../android/intentresolver/v2/ChooserActivity.java | 13 ++++++++---- .../intentresolver/ChooserWrapperActivity.java | 4 ++-- .../intentresolver/v2/ChooserWrapperActivity.java | 4 ++-- .../intentresolver/TestContentPreviewViewModel.kt | 5 +++-- .../intentresolver/ChooserListAdapterDataTest.kt | 11 +++------- .../intentresolver/ChooserListAdapterTest.kt | 2 +- .../intentresolver/v2/ChooserActionFactoryTest.kt | 20 ++++++++++++++---- 11 files changed, 69 insertions(+), 42 deletions(-) (limited to 'java/src') diff --git a/java/src/com/android/intentresolver/ChooserActivity.java b/java/src/com/android/intentresolver/ChooserActivity.java index aa02be37..50ca5d0d 100644 --- a/java/src/com/android/intentresolver/ChooserActivity.java +++ b/java/src/com/android/intentresolver/ChooserActivity.java @@ -1171,7 +1171,7 @@ public class ChooserActivity extends Hilt_ChooserActivity implements createListController(userHandle), userHandle, getTargetIntent(), - mChooserRequest, + mChooserRequest.getReferrerFillInIntent(), mMaxTargetsPerRow, targetDataLoader); @@ -1230,7 +1230,7 @@ public class ChooserActivity extends Hilt_ChooserActivity implements ResolverListController resolverListController, UserHandle userHandle, Intent targetIntent, - ChooserRequestParameters chooserRequest, + Intent referrerFillInIntent, int maxTargetsPerRow, TargetDataLoader targetDataLoader) { UserHandle initialIntentsUserSpace = isLaunchedAsCloneProfile() @@ -1245,10 +1245,10 @@ public class ChooserActivity extends Hilt_ChooserActivity implements createListController(userHandle), userHandle, targetIntent, + referrerFillInIntent, this, context.getPackageManager(), getEventLog(), - chooserRequest, maxTargetsPerRow, initialIntentsUserSpace, targetDataLoader); diff --git a/java/src/com/android/intentresolver/ChooserListAdapter.java b/java/src/com/android/intentresolver/ChooserListAdapter.java index 823b5e13..3af8a3a7 100644 --- a/java/src/com/android/intentresolver/ChooserListAdapter.java +++ b/java/src/com/android/intentresolver/ChooserListAdapter.java @@ -30,6 +30,7 @@ import android.content.pm.PackageManager; import android.content.pm.ResolveInfo; import android.content.pm.ShortcutInfo; import android.graphics.drawable.Drawable; +import android.net.Uri; import android.os.AsyncTask; import android.os.Trace; import android.os.UserHandle; @@ -84,7 +85,8 @@ public class ChooserListAdapter extends ResolverListAdapter { /** {@link #getBaseScore} */ public static final float SHORTCUT_TARGET_SCORE_BOOST = 90.f; - private final ChooserRequestParameters mChooserRequest; + private final Intent mReferrerFillInIntent; + private final int mMaxRankedTargets; private final EventLog mEventLog; @@ -144,10 +146,10 @@ public class ChooserListAdapter extends ResolverListAdapter { ResolverListController resolverListController, UserHandle userHandle, Intent targetIntent, + Intent referrerFillInIntent, ResolverListCommunicator resolverListCommunicator, PackageManager packageManager, EventLog eventLog, - ChooserRequestParameters chooserRequest, int maxRankedTargets, UserHandle initialIntentsUserSpace, TargetDataLoader targetDataLoader) { @@ -160,10 +162,10 @@ public class ChooserListAdapter extends ResolverListAdapter { resolverListController, userHandle, targetIntent, + referrerFillInIntent, resolverListCommunicator, packageManager, eventLog, - chooserRequest, maxRankedTargets, initialIntentsUserSpace, targetDataLoader, @@ -181,10 +183,10 @@ public class ChooserListAdapter extends ResolverListAdapter { ResolverListController resolverListController, UserHandle userHandle, Intent targetIntent, + Intent referrerFillInIntent, ResolverListCommunicator resolverListCommunicator, PackageManager packageManager, EventLog eventLog, - ChooserRequestParameters chooserRequest, int maxRankedTargets, UserHandle initialIntentsUserSpace, TargetDataLoader targetDataLoader, @@ -207,8 +209,8 @@ public class ChooserListAdapter extends ResolverListAdapter { bgExecutor, mainExecutor); - mChooserRequest = chooserRequest; mMaxRankedTargets = maxRankedTargets; + mReferrerFillInIntent = referrerFillInIntent; mPlaceHolderTargetInfo = NotSelectableTargetInfo.newPlaceHolderTargetInfo(context); mTargetDataLoader = targetDataLoader; @@ -497,8 +499,14 @@ public class ChooserListAdapter extends ResolverListAdapter { return count; } + private static boolean hasSendAction(Intent intent) { + String action = intent.getAction(); + return Intent.ACTION_SEND.equals(action) + || Intent.ACTION_SEND_MULTIPLE.equals(action); + } + public int getServiceTargetCount() { - if (mChooserRequest.isSendActionTarget() && !ActivityManager.isLowRamDeviceStatic()) { + if (hasSendAction(getTargetIntent()) && !ActivityManager.isLowRamDeviceStatic()) { return Math.min(mServiceTargets.size(), mMaxRankedTargets); } @@ -653,8 +661,8 @@ public class ChooserListAdapter extends ResolverListAdapter { directShareToShortcutInfos, directShareToAppTargets, mContext.createContextAsUser(getUserHandle(), 0), - mChooserRequest.getTargetIntent(), - mChooserRequest.getReferrerFillInIntent(), + getTargetIntent(), + mReferrerFillInIntent, mMaxRankedTargets, mServiceTargets); if (isUpdated) { diff --git a/java/src/com/android/intentresolver/ResolverListAdapter.java b/java/src/com/android/intentresolver/ResolverListAdapter.java index 8f1f9275..564d8d19 100644 --- a/java/src/com/android/intentresolver/ResolverListAdapter.java +++ b/java/src/com/android/intentresolver/ResolverListAdapter.java @@ -25,6 +25,7 @@ import android.content.pm.ResolveInfo; import android.graphics.ColorMatrix; import android.graphics.ColorMatrixColorFilter; import android.graphics.drawable.Drawable; +import android.net.Uri; import android.os.AsyncTask; import android.os.RemoteException; import android.os.Trace; @@ -160,6 +161,10 @@ public class ResolverListAdapter extends BaseAdapter { mCallbackExecutor = callbackExecutor; } + protected Intent getTargetIntent() { + return mTargetIntent; + } + public final DisplayResolveInfo getFirstDisplayResolveInfo() { return mDisplayList.get(0); } diff --git a/java/src/com/android/intentresolver/v2/ChooserActionFactory.java b/java/src/com/android/intentresolver/v2/ChooserActionFactory.java index a4a6d670..db840387 100644 --- a/java/src/com/android/intentresolver/v2/ChooserActionFactory.java +++ b/java/src/com/android/intentresolver/v2/ChooserActionFactory.java @@ -35,7 +35,6 @@ import android.view.View; import androidx.annotation.Nullable; -import com.android.intentresolver.ChooserRequestParameters; import com.android.intentresolver.R; import com.android.intentresolver.chooser.DisplayResolveInfo; import com.android.intentresolver.chooser.TargetInfo; @@ -109,7 +108,6 @@ public final class ChooserActionFactory implements ChooserContentPreviewUi.Actio /** * @param context - * @param chooserRequest data about the invocation of the current Sharesheet session. * @param imageEditor an explicit Activity to launch for editing images * @param onUpdateSharedTextIsExcluded a delegate to be invoked when the "exclude shared text" * setting is updated. The argument is whether the shared text is to be excluded. @@ -121,7 +119,10 @@ public final class ChooserActionFactory implements ChooserContentPreviewUi.Actio */ public ChooserActionFactory( Context context, - ChooserRequestParameters chooserRequest, + Intent targetIntent, + String referrerPackageName, + List chooserActions, + ChooserAction modifyShareAction, Optional imageEditor, EventLog log, Consumer onUpdateSharedTextIsExcluded, @@ -132,20 +133,20 @@ public final class ChooserActionFactory implements ChooserContentPreviewUi.Actio context, makeCopyButtonRunnable( context, - chooserRequest.getTargetIntent(), - chooserRequest.getReferrerPackageName(), + targetIntent, + referrerPackageName, finishCallback, log), makeEditButtonRunnable( getEditSharingTarget( context, - chooserRequest.getTargetIntent(), + targetIntent, imageEditor), firstVisibleImageQuery, activityStarter, log), - chooserRequest.getChooserActions(), - chooserRequest.getModifyShareAction(), + chooserActions, + modifyShareAction, onUpdateSharedTextIsExcluded, log, finishCallback); diff --git a/java/src/com/android/intentresolver/v2/ChooserActivity.java b/java/src/com/android/intentresolver/v2/ChooserActivity.java index c7a8ebab..95eedf47 100644 --- a/java/src/com/android/intentresolver/v2/ChooserActivity.java +++ b/java/src/com/android/intentresolver/v2/ChooserActivity.java @@ -1182,6 +1182,7 @@ public class ChooserActivity extends Hilt_ChooserActivity implements boolean filterLastUsed, UserHandle userHandle, TargetDataLoader targetDataLoader) { + ChooserRequestParameters parameters = requireChooserRequest(); ChooserListAdapter chooserListAdapter = createChooserListAdapter( context, payloadIntents, @@ -1191,7 +1192,7 @@ public class ChooserActivity extends Hilt_ChooserActivity implements createListController(userHandle), userHandle, mLogic.getTargetIntent(), - requireChooserRequest(), + parameters.getReferrerFillInIntent(), mMaxTargetsPerRow, targetDataLoader); @@ -1250,7 +1251,7 @@ public class ChooserActivity extends Hilt_ChooserActivity implements ResolverListController resolverListController, UserHandle userHandle, Intent targetIntent, - ChooserRequestParameters chooserRequest, + Intent referrerFillInIntent, int maxTargetsPerRow, TargetDataLoader targetDataLoader) { UserHandle initialIntentsUserSpace = isLaunchedAsCloneProfile() @@ -1265,10 +1266,10 @@ public class ChooserActivity extends Hilt_ChooserActivity implements createListController(userHandle), userHandle, targetIntent, + referrerFillInIntent, this, context.getPackageManager(), getEventLog(), - chooserRequest, maxTargetsPerRow, initialIntentsUserSpace, targetDataLoader); @@ -1327,9 +1328,13 @@ public class ChooserActivity extends Hilt_ChooserActivity implements } private ChooserActionFactory createChooserActionFactory() { + ChooserRequestParameters request = requireChooserRequest(); return new ChooserActionFactory( this, - requireChooserRequest(), + request.getTargetIntent(), + request.getReferrerPackageName(), + request.getChooserActions(), + request.getModifyShareAction(), mImageEditor, getEventLog(), (isExcluded) -> mExcludeSharedText = isExcluded, diff --git a/tests/activity/src/com/android/intentresolver/ChooserWrapperActivity.java b/tests/activity/src/com/android/intentresolver/ChooserWrapperActivity.java index 72f1f452..27d50adf 100644 --- a/tests/activity/src/com/android/intentresolver/ChooserWrapperActivity.java +++ b/tests/activity/src/com/android/intentresolver/ChooserWrapperActivity.java @@ -71,7 +71,7 @@ public class ChooserWrapperActivity extends ChooserActivity implements IChooserW ResolverListController resolverListController, UserHandle userHandle, Intent targetIntent, - ChooserRequestParameters chooserRequest, + Intent referrrerFillInIntent, int maxTargetsPerRow, TargetDataLoader targetDataLoader) { PackageManager packageManager = @@ -86,10 +86,10 @@ public class ChooserWrapperActivity extends ChooserActivity implements IChooserW createListController(userHandle), userHandle, targetIntent, + referrrerFillInIntent, this, packageManager, getEventLog(), - chooserRequest, maxTargetsPerRow, userHandle, targetDataLoader); diff --git a/tests/activity/src/com/android/intentresolver/v2/ChooserWrapperActivity.java b/tests/activity/src/com/android/intentresolver/v2/ChooserWrapperActivity.java index 5572bb24..a314ee97 100644 --- a/tests/activity/src/com/android/intentresolver/v2/ChooserWrapperActivity.java +++ b/tests/activity/src/com/android/intentresolver/v2/ChooserWrapperActivity.java @@ -86,7 +86,7 @@ public class ChooserWrapperActivity extends ChooserActivity implements IChooserW ResolverListController resolverListController, UserHandle userHandle, Intent targetIntent, - ChooserRequestParameters chooserRequest, + Intent referrerFillInIntent, int maxTargetsPerRow, TargetDataLoader targetDataLoader) { PackageManager packageManager = @@ -101,10 +101,10 @@ public class ChooserWrapperActivity extends ChooserActivity implements IChooserW createListController(userHandle), userHandle, targetIntent, + referrerFillInIntent, this, packageManager, getEventLog(), - chooserRequest, maxTargetsPerRow, userHandle, targetDataLoader); diff --git a/tests/shared/src/com/android/intentresolver/TestContentPreviewViewModel.kt b/tests/shared/src/com/android/intentresolver/TestContentPreviewViewModel.kt index d239f612..888fc161 100644 --- a/tests/shared/src/com/android/intentresolver/TestContentPreviewViewModel.kt +++ b/tests/shared/src/com/android/intentresolver/TestContentPreviewViewModel.kt @@ -16,6 +16,7 @@ package com.android.intentresolver +import android.content.Intent import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.viewmodel.CreationExtras @@ -29,8 +30,8 @@ class TestContentPreviewViewModel( private val imageLoader: ImageLoader? = null, ) : BasePreviewViewModel() { override fun createOrReuseProvider( - chooserRequest: ChooserRequestParameters - ): PreviewDataProvider = viewModel.createOrReuseProvider(chooserRequest) + targetIntent: Intent + ): PreviewDataProvider = viewModel.createOrReuseProvider(targetIntent) override fun createOrReuseImageLoader(): ImageLoader = imageLoader ?: viewModel.createOrReuseImageLoader() diff --git a/tests/unit/src/com/android/intentresolver/ChooserListAdapterDataTest.kt b/tests/unit/src/com/android/intentresolver/ChooserListAdapterDataTest.kt index e5927e36..9864d0bd 100644 --- a/tests/unit/src/com/android/intentresolver/ChooserListAdapterDataTest.kt +++ b/tests/unit/src/com/android/intentresolver/ChooserListAdapterDataTest.kt @@ -44,12 +44,7 @@ class ChooserListAdapterDataTest { private val targetDataLoader = mock() private val backgroundExecutor = TestExecutor() private val immediateExecutor = TestExecutor(immediate = true) - private val chooserRequestParams = - ChooserRequestParameters( - Intent.createChooser(targetIntent, ""), - "org.referrer.package", - null - ) + private val referrerFillInIntent = Intent().putExtra(Intent.EXTRA_REFERRER, "org.referrer.package") @Test fun test_twoTargetsWithNonOverlappingInitialIntent_threeTargetsInResolverAdapter() { @@ -91,10 +86,10 @@ class ChooserListAdapterDataTest { resolverListController, userHandle, targetIntent, + referrerFillInIntent, resolverListCommunicator, packageManager, FakeEventLog(InstanceId.fakeInstanceId(1)), - chooserRequestParams, /*maxRankedTargets=*/ 2, /*initialIntentsUserSpace=*/ userHandle, targetDataLoader, @@ -153,10 +148,10 @@ class ChooserListAdapterDataTest { resolverListController, userHandle, targetIntent, + referrerFillInIntent, resolverListCommunicator, packageManager, FakeEventLog(InstanceId.fakeInstanceId(1)), - chooserRequestParams, /*maxRankedTargets=*/ 2, /*initialIntentsUserSpace=*/ userHandle, targetDataLoader, diff --git a/tests/unit/src/com/android/intentresolver/ChooserListAdapterTest.kt b/tests/unit/src/com/android/intentresolver/ChooserListAdapterTest.kt index a4078365..a12c9ec1 100644 --- a/tests/unit/src/com/android/intentresolver/ChooserListAdapterTest.kt +++ b/tests/unit/src/com/android/intentresolver/ChooserListAdapterTest.kt @@ -67,10 +67,10 @@ class ChooserListAdapterTest { resolverListController, userHandle, Intent(), + Intent(), mock(), packageManager, mEventLog, - mock(), 0, null, mTargetDataLoader diff --git a/tests/unit/src/com/android/intentresolver/v2/ChooserActionFactoryTest.kt b/tests/unit/src/com/android/intentresolver/v2/ChooserActionFactoryTest.kt index a1a9bc92..b3486bb1 100644 --- a/tests/unit/src/com/android/intentresolver/v2/ChooserActionFactoryTest.kt +++ b/tests/unit/src/com/android/intentresolver/v2/ChooserActionFactoryTest.kt @@ -135,7 +135,10 @@ class ChooserActionFactoryTest { val testSubject = ChooserActionFactory( context, - chooserRequest, + chooserRequest.targetIntent, + chooserRequest.referrerPackageName, + chooserRequest.chooserActions, + chooserRequest.modifyShareAction, Optional.empty(), logger, {}, @@ -158,7 +161,10 @@ class ChooserActionFactoryTest { val testSubject = ChooserActionFactory( context, - chooserRequest, + chooserRequest.targetIntent, + chooserRequest.referrerPackageName, + chooserRequest.chooserActions, + chooserRequest.modifyShareAction, Optional.empty(), logger, {}, @@ -181,7 +187,10 @@ class ChooserActionFactoryTest { val testSubject = ChooserActionFactory( context, - chooserRequest, + chooserRequest.targetIntent, + chooserRequest.referrerPackageName, + chooserRequest.chooserActions, + chooserRequest.modifyShareAction, Optional.empty(), logger, {}, @@ -220,7 +229,10 @@ class ChooserActionFactoryTest { return ChooserActionFactory( context, - chooserRequest, + chooserRequest.targetIntent, + chooserRequest.referrerPackageName, + chooserRequest.chooserActions, + chooserRequest.modifyShareAction, Optional.empty(), logger, {}, -- cgit v1.2.3-59-g8ed1b From 702b0bcb8cde313c97cdd461e6c96b8075cf0b6d Mon Sep 17 00:00:00 2001 From: Joshua Trask Date: Mon, 4 Dec 2023 17:56:09 +0000 Subject: Chooser pager-adapter: override generic rebuildTab (This is the "miscellaneous" change from go/chooser-ntab-refactoring / "snapshot 19" from ag/25335069). TLDR: we want to burn down external references to having an "inactive" tab, and this is one "low-hanging fruit" case where we really didn't need to distinguish active/inactive status in the first place. As originally noted in ag/25335069, this is essentially a no-op change; there may appear to be a discrepancy compared to the legacy `rebuildInactiveTab()` with regards to the old `getItemCount() != 1` condition, but the distinction is vacuous since the only caller of `rebuildInactiveTab()` (`ResolverActivity.configureContentView()`) already verifies `shouldShowTabs()` as a precondition to the rebuild, which implies `getItemCount() > 1`. FWIW this CL *does* invert the "nesting" of the two different trace mechanisms. In the original implementation, the chooser override's "app target loading section" would begin prior to the call up to the super `MultiProfilePagerAdapter.rebuild{Active,Inactive}Tab()`, "outside" of the tracing that starts in those methods; now the subclass trace section is moved "inside," to `rebuildTab()`, where the superclass tracing has already started. IMO this distinction should be negligible. It's hard to test this kind of "structure-only" change in isolation, especially when we expect more upcoming changes to redefine the APIs that we're taking care to preserve here (namely via other anticipated changes from ag/25335069, if nothing else in the "v2 restructuring"). For the sake of being thorough I did confirm that we have *some* meaningful test coverage that exercises this rebuild flow, as I was able to induce test failures (in `IntentResolver-tests-activity`) by by deliberately omitting the superclass call in the override `ChooserMultiProfilePagerAdapter.rebuildTab()`. Otherwise, I believe the equivalence should be clear from code-reading. Bug: 310211468 Test: `IntentResolver-tests-{unit,activity,integration}`. See note ^ Change-Id: I938984e5f2fade24d1f4c58cf0d99e60e4932e3d --- .../intentresolver/v2/ChooserMultiProfilePagerAdapter.java | 14 +++----------- .../intentresolver/v2/MultiProfilePagerAdapter.java | 6 +++--- 2 files changed, 6 insertions(+), 14 deletions(-) (limited to 'java/src') diff --git a/java/src/com/android/intentresolver/v2/ChooserMultiProfilePagerAdapter.java b/java/src/com/android/intentresolver/v2/ChooserMultiProfilePagerAdapter.java index 81797e9a..87b3b201 100644 --- a/java/src/com/android/intentresolver/v2/ChooserMultiProfilePagerAdapter.java +++ b/java/src/com/android/intentresolver/v2/ChooserMultiProfilePagerAdapter.java @@ -154,19 +154,11 @@ public class ChooserMultiProfilePagerAdapter extends MultiProfilePagerAdapter< } @Override - public boolean rebuildActiveTab(boolean doPostProcessing) { + protected final boolean rebuildTab(ChooserListAdapter listAdapter, boolean doPostProcessing) { if (doPostProcessing) { - Tracer.INSTANCE.beginAppTargetLoadingSection(getActiveListAdapter().getUserHandle()); + Tracer.INSTANCE.beginAppTargetLoadingSection(listAdapter.getUserHandle()); } - return super.rebuildActiveTab(doPostProcessing); - } - - @Override - public boolean rebuildInactiveTab(boolean doPostProcessing) { - if (getItemCount() != 1 && doPostProcessing) { - Tracer.INSTANCE.beginAppTargetLoadingSection(getInactiveListAdapter().getUserHandle()); - } - return super.rebuildInactiveTab(doPostProcessing); + return super.rebuildTab(listAdapter, doPostProcessing); } private static class BottomPaddingOverrideSupplier implements Supplier> { diff --git a/java/src/com/android/intentresolver/v2/MultiProfilePagerAdapter.java b/java/src/com/android/intentresolver/v2/MultiProfilePagerAdapter.java index 0b64a0ee..a600d4ad 100644 --- a/java/src/com/android/intentresolver/v2/MultiProfilePagerAdapter.java +++ b/java/src/com/android/intentresolver/v2/MultiProfilePagerAdapter.java @@ -357,7 +357,7 @@ public class MultiProfilePagerAdapter< * Rebuilds the tab that is currently visible to the user. *

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

Returns {@code true} if rebuild has completed. */ - public boolean rebuildInactiveTab(boolean doPostProcessing) { + public final boolean rebuildInactiveTab(boolean doPostProcessing) { Trace.beginSection("MultiProfilePagerAdapter#rebuildInactiveTab"); if (getItemCount() == 1) { Trace.endSection(); @@ -387,7 +387,7 @@ public class MultiProfilePagerAdapter< } } - private boolean rebuildTab(ListAdapterT activeListAdapter, boolean doPostProcessing) { + protected boolean rebuildTab(ListAdapterT activeListAdapter, boolean doPostProcessing) { if (shouldSkipRebuild(activeListAdapter)) { activeListAdapter.postListReadyRunnable(doPostProcessing, /* rebuildCompleted */ true); return false; -- cgit v1.2.3-59-g8ed1b From 6f0eeb452ae640a1adfa87aacefb50c083441b46 Mon Sep 17 00:00:00 2001 From: Joshua Trask Date: Tue, 5 Dec 2023 19:55:41 +0000 Subject: Move per-profile operations to pager-adapter APIs These are methods previously implemented in the activities, which operate generically on all "profile tabs" (or the "active" or "inactive" tabs) -- as opposed to special-case behaviors for personal/work/clone/private/etc. The pager-adapter API exposed low-level access to its data model, allowing clients to implement their own algorithms that implicitly inherited our historic "2-tab" assumption. By pulling these methods into the pager-adapter (which was generally supposed to own this data model anyways), we limit the scope of the remaining cleanup to relax the "2-tab" assumption. These changes are as prototyped in ag/25335069 and described in go/chooser-ntab-refactoring (starting with "snapshot #7"; note according to go/chooser-ntab-refactoring, this "arc" begins with "snapshot #6" of ag/25335069, but that particular change builds on other changes reproduced in ag/25560839, so here we skip that one change to get a "clean start" for parallel review). Snapshot #1 (from ag/25335069 "snapshot #7"): Generalize `ChooserActivity.shouldShowExtraRow()` to consider the presence of empty-state screens in "any" inactive adapter, while pushing the logic for that determination into the pager-adapter. The logic isn't yet updated to evaluate multiple inactive profiles, but it's at least consolidated into the `MultiProfilePagerAdapter` component to be fixed later. Note the refactoring omits the old `shouldShowTabs()` condition; this is implied since the new "any-inactive" logic returns false if there are no inactive tabs. There's seemingly no coverage for this "extra-row" functionality; I'm open to suggestions but maybe we can wait for more requirements from the private-space work. For now the equivalence should still be clear from code-reading (but I'd reluctantly be willing to revert the removal of the `shouldShowTabs()` in consideration of the scant testing). Snapshot #2 (from ag/25335069 "snapshot #8"): Instead of letting `ResolverActivity` use low-level pager-adapter methods to access the "active" and "inactive" tabs to rebuild, have the activity request a "tab rebuild" where the pager-adapter is responsible for deciding the applicable tabs according to its internal data model. The equivalence of this change should be clear from code-reading, but in the interest of completeness I confirmed that we have some existing test coverage exercising this flow -- an early return (true or false) from the new `rebuildTabs()` causes a test failure, as does an inversion of the 'partial-rebuild' condtion (i.e., starting with `includePartialRebuildOfInactiveTabs = !..."). Snapshot #3 (from ag/25335069 "snapshot #9"): Move a concrete-`ResolverActivity`-specific operation into its subclass pager-adapter since it generically manipulates "inactive tabs." This test method is exercised by the legacy `ResolverActivityTest` but we don't seem to make any assertions about its behavior (do we need to add more test coverage now? It may be hard to set up, and we expect significant architectural changes in the near future anyways...) Snapshot #4 (from ag/25335069 "snapshot #17"): Move a `ChooserActivity`-specific operation into its subclass pager-adapter since it generically manipulates *all* tabs. As in the previous snapshot, this method is only nominally covered in tests, but we don't make specific assertions about its behavior. Bug: 310211468 Test: `IntentResolver-tests-{unit,activity,integration}`. See notes ^ Change-Id: Ie4a0e99a59872ec16f8491bfa6ffb548e37db1da --- .../android/intentresolver/v2/ChooserActivity.java | 15 +++------ .../v2/ChooserMultiProfilePagerAdapter.java | 7 +++++ .../v2/MultiProfilePagerAdapter.java | 36 +++++++++++++++++++++- .../intentresolver/v2/ResolverActivity.java | 14 ++------- .../v2/ResolverMultiProfilePagerAdapter.java | 9 ++++++ 5 files changed, 59 insertions(+), 22 deletions(-) (limited to 'java/src') diff --git a/java/src/com/android/intentresolver/v2/ChooserActivity.java b/java/src/com/android/intentresolver/v2/ChooserActivity.java index 95eedf47..19032b31 100644 --- a/java/src/com/android/intentresolver/v2/ChooserActivity.java +++ b/java/src/com/android/intentresolver/v2/ChooserActivity.java @@ -992,11 +992,7 @@ public class ChooserActivity extends Hilt_ChooserActivity implements @Override protected void applyFooterView(int height) { - int count = mChooserMultiProfilePagerAdapter.getItemCount(); - - for (int i = 0; i < count; i++) { - mChooserMultiProfilePagerAdapter.getAdapterForIndex(i).setFooterHeight(height); - } + mChooserMultiProfilePagerAdapter.setFooterHeightInEveryAdapter(height); } private void logDirectShareTargetReceived(UserHandle forUser) { @@ -1499,14 +1495,13 @@ public class ChooserActivity extends Hilt_ChooserActivity implements /** * If we have a tabbed view and are showing 1 row in the current profile and an empty - * state screen in the other profile, to prevent cropping of the empty state screen we show + * state screen in another profile, to prevent cropping of the empty state screen we show * a second row in the current profile. */ private boolean shouldShowExtraRow(int rowsToShow) { - return shouldShowTabs() - && rowsToShow == 1 - && mChooserMultiProfilePagerAdapter.shouldShowEmptyStateScreen( - mChooserMultiProfilePagerAdapter.getInactiveListAdapter()); + return rowsToShow == 1 + && mChooserMultiProfilePagerAdapter + .shouldShowEmptyStateScreenInAnyInactiveAdapter(); } /** diff --git a/java/src/com/android/intentresolver/v2/ChooserMultiProfilePagerAdapter.java b/java/src/com/android/intentresolver/v2/ChooserMultiProfilePagerAdapter.java index 87b3b201..7ea78d14 100644 --- a/java/src/com/android/intentresolver/v2/ChooserMultiProfilePagerAdapter.java +++ b/java/src/com/android/intentresolver/v2/ChooserMultiProfilePagerAdapter.java @@ -161,6 +161,13 @@ public class ChooserMultiProfilePagerAdapter extends MultiProfilePagerAdapter< return super.rebuildTab(listAdapter, doPostProcessing); } + /** Apply the specified {@code height} as the footer in each tab's adapter. */ + public void setFooterHeightInEveryAdapter(int height) { + for (int i = 0; i < getItemCount(); ++i) { + getAdapterForIndex(i).setFooterHeight(height); + } + } + private static class BottomPaddingOverrideSupplier implements Supplier> { private final Context mContext; private int mBottomOffset; diff --git a/java/src/com/android/intentresolver/v2/MultiProfilePagerAdapter.java b/java/src/com/android/intentresolver/v2/MultiProfilePagerAdapter.java index a600d4ad..212bf3b4 100644 --- a/java/src/com/android/intentresolver/v2/MultiProfilePagerAdapter.java +++ b/java/src/com/android/intentresolver/v2/MultiProfilePagerAdapter.java @@ -353,6 +353,28 @@ public class MultiProfilePagerAdapter< return getListViewForIndex(1 - getCurrentPage()); } + /** + * Fully-rebuild the active tab and, if specified, partially-rebuild any other inactive tabs. + */ + public boolean rebuildTabs(boolean includePartialRebuildOfInactiveTabs) { + // TODO: we may be able to determine `includePartialRebuildOfInactiveTabs` ourselves as + // a function of our own instance state. OTOH the purpose of this "partial rebuild" is to + // be able to evaluate the intermediate state of one particular profile tab (i.e. work + // profile) that may not generalize well when we have other "inactive tabs." I.e., either we + // rebuild *all* the inactive tabs just to evaluate some auto-launch conditions that only + // depend on personal and/or work tabs, or we have to explicitly specify the ones we care + // about. It's not the pager-adapter's business to know "which ones we care about," so maybe + // they should be rebuilt lazily when-and-if it comes up (e.g. during the evaluation of + // autolaunch conditions). + boolean rebuildCompleted = rebuildActiveTab(true) || getActiveListAdapter().isTabLoaded(); + if (includePartialRebuildOfInactiveTabs) { + boolean rebuildInactiveCompleted = + rebuildInactiveTab(false) || getInactiveListAdapter().isTabLoaded(); + rebuildCompleted = rebuildCompleted && rebuildInactiveCompleted; + } + return rebuildCompleted; + } + /** * Rebuilds the tab that is currently visible to the user. *

Returns {@code true} if rebuild has completed. @@ -368,7 +390,7 @@ public class MultiProfilePagerAdapter< * Rebuilds the tab that is not currently visible to the user, if such one exists. *

Returns {@code true} if rebuild has completed. */ - public final boolean rebuildInactiveTab(boolean doPostProcessing) { + private boolean rebuildInactiveTab(boolean doPostProcessing) { Trace.beginSection("MultiProfilePagerAdapter#rebuildInactiveTab"); if (getItemCount() == 1) { Trace.endSection(); @@ -477,6 +499,18 @@ public class MultiProfilePagerAdapter< descriptor.mEmptyStateUi.hide(); } + /** + * @return whether any "inactive" tab's adapter would show an empty-state screen in our current + * application state. + */ + public final boolean shouldShowEmptyStateScreenInAnyInactiveAdapter() { + if (getCount() < 2) { + return false; + } + // TODO: check against *any* inactive adapter; for now we only have one. + return shouldShowEmptyStateScreen(getInactiveListAdapter()); + } + public boolean shouldShowEmptyStateScreen(ListAdapterT listAdapter) { int count = listAdapter.getUnfilteredCount(); return (count == 0 && listAdapter.getPlaceholderCount() == 0) diff --git a/java/src/com/android/intentresolver/v2/ResolverActivity.java b/java/src/com/android/intentresolver/v2/ResolverActivity.java index a7f2047d..2c1497f0 100644 --- a/java/src/com/android/intentresolver/v2/ResolverActivity.java +++ b/java/src/com/android/intentresolver/v2/ResolverActivity.java @@ -1582,13 +1582,7 @@ public class ResolverActivity extends FragmentActivity implements Trace.beginSection("configureContentView"); // We partially rebuild the inactive adapter to determine if we should auto launch // isTabLoaded will be true here if the empty state screen is shown instead of the list. - boolean rebuildCompleted = mMultiProfilePagerAdapter.rebuildActiveTab(true) - || mMultiProfilePagerAdapter.getActiveListAdapter().isTabLoaded(); - if (shouldShowTabs()) { - boolean rebuildInactiveCompleted = mMultiProfilePagerAdapter.rebuildInactiveTab(false) - || mMultiProfilePagerAdapter.getInactiveListAdapter().isTabLoaded(); - rebuildCompleted = rebuildCompleted && rebuildInactiveCompleted; - } + boolean rebuildCompleted = mMultiProfilePagerAdapter.rebuildTabs(shouldShowTabs()); if (shouldUseMiniResolver()) { configureMiniResolverContent(targetDataLoader); @@ -1962,10 +1956,8 @@ public class ResolverActivity extends FragmentActivity implements return; } mLastSelected = ListView.INVALID_POSITION; - ListView inactiveListView = (ListView) mMultiProfilePagerAdapter.getInactiveAdapterView(); - if (inactiveListView.getCheckedItemCount() > 0) { - inactiveListView.setItemChecked(inactiveListView.getCheckedItemPosition(), false); - } + ((ResolverMultiProfilePagerAdapter) mMultiProfilePagerAdapter) + .clearCheckedItemsInInactiveProfiles(); } private String getPersonalTabAccessibilityLabel() { diff --git a/java/src/com/android/intentresolver/v2/ResolverMultiProfilePagerAdapter.java b/java/src/com/android/intentresolver/v2/ResolverMultiProfilePagerAdapter.java index dadc9c0f..d96fd15a 100644 --- a/java/src/com/android/intentresolver/v2/ResolverMultiProfilePagerAdapter.java +++ b/java/src/com/android/intentresolver/v2/ResolverMultiProfilePagerAdapter.java @@ -107,6 +107,15 @@ public class ResolverMultiProfilePagerAdapter extends mBottomPaddingOverrideSupplier.setUseLayoutWithDefault(useLayoutWithDefault); } + /** Un-check any item(s) that may be checked in any of our inactive adapter(s). */ + public void clearCheckedItemsInInactiveProfiles() { + // TODO: apply to all inactive adapters; for now we just have the one. + ListView inactiveListView = getInactiveAdapterView(); + if (inactiveListView.getCheckedItemCount() > 0) { + inactiveListView.setItemChecked(inactiveListView.getCheckedItemPosition(), false); + } + } + private static class BottomPaddingOverrideSupplier implements Supplier> { private boolean mUseLayoutWithDefault; -- cgit v1.2.3-59-g8ed1b From 0d5016ae11db6ec551fa8e1b25f034446e266a92 Mon Sep 17 00:00:00 2001 From: mrenouf Date: Tue, 5 Dec 2023 17:05:04 -0500 Subject: Defer most init to onPostCreate Change-Id: I321eaa5280e8bbeda4e0e1ecef89ad8f9829afe6 --- .../android/intentresolver/v2/ChooserActivity.java | 21 ++++---- .../intentresolver/v2/ChooserActivityLogic.kt | 2 + .../intentresolver/v2/ResolverActivity.java | 59 +++++++++++++++------- .../intentresolver/v2/ChooserWrapperActivity.java | 20 ++++---- .../intentresolver/v2/ResolverWrapperActivity.java | 9 +++- 5 files changed, 70 insertions(+), 41 deletions(-) (limited to 'java/src') diff --git a/java/src/com/android/intentresolver/v2/ChooserActivity.java b/java/src/com/android/intentresolver/v2/ChooserActivity.java index 95eedf47..60ea1b1c 100644 --- a/java/src/com/android/intentresolver/v2/ChooserActivity.java +++ b/java/src/com/android/intentresolver/v2/ChooserActivity.java @@ -236,21 +236,20 @@ public class ChooserActivity extends Hilt_ChooserActivity implements private final AtomicLong mIntentReceivedTime = new AtomicLong(-1); - public ChooserActivity() { - super(); - mLogic = new ChooserActivityLogic( + @Override + protected void onCreate(Bundle savedInstanceState) { + Tracer.INSTANCE.markLaunched(); + super.onCreate(savedInstanceState); + setLogic(new ChooserActivityLogic( TAG, () -> this, this::onWorkProfileStatusUpdated, () -> mTargetDataLoader, - this::onPreinitialization - ); + this::onPreinitialization)); + addInitializer(this::init); } - @Override - protected void onCreate(Bundle savedInstanceState) { - Tracer.INSTANCE.markLaunched(); - super.onCreate(savedInstanceState); + private void init() { if (getChooserRequest() == null) { finish(); return; @@ -719,7 +718,9 @@ public class ChooserActivity extends Hilt_ChooserActivity implements @Override protected void onStop() { super.onStop(); - mRefinementManager.onActivityStop(isChangingConfigurations()); + if (mRefinementManager != null) { + mRefinementManager.onActivityStop(isChangingConfigurations()); + } if (mFinishWhenStopped) { mFinishWhenStopped = false; diff --git a/java/src/com/android/intentresolver/v2/ChooserActivityLogic.kt b/java/src/com/android/intentresolver/v2/ChooserActivityLogic.kt index 5303a7e7..7bc39a24 100644 --- a/java/src/com/android/intentresolver/v2/ChooserActivityLogic.kt +++ b/java/src/com/android/intentresolver/v2/ChooserActivityLogic.kt @@ -10,6 +10,8 @@ import com.android.intentresolver.R import com.android.intentresolver.icons.TargetDataLoader import com.android.intentresolver.v2.util.mutableLazy +private const val TAG = "ChooserActivityLogic" + /** * Activity logic for [ChooserActivity]. * diff --git a/java/src/com/android/intentresolver/v2/ResolverActivity.java b/java/src/com/android/intentresolver/v2/ResolverActivity.java index a7f2047d..8f8a7d0d 100644 --- a/java/src/com/android/intentresolver/v2/ResolverActivity.java +++ b/java/src/com/android/intentresolver/v2/ResolverActivity.java @@ -33,7 +33,9 @@ import static android.view.WindowManager.LayoutParams.SYSTEM_FLAG_HIDE_NON_SYSTE import static com.android.internal.annotations.VisibleForTesting.Visibility.PROTECTED; +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.ActivityThread; @@ -91,6 +93,7 @@ import android.widget.TabWidget; import android.widget.TextView; import android.widget.Toast; +import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.annotation.StringRes; import androidx.annotation.UiThread; @@ -129,9 +132,12 @@ import kotlin.Unit; import java.util.ArrayList; import java.util.Arrays; import java.util.Iterator; +import java.util.LinkedList; import java.util.List; import java.util.Objects; +import java.util.Queue; import java.util.Set; +import java.util.function.Consumer; /** * This is a copy of ResolverActivity to support IntentResolver's ChooserActivity. This code is @@ -143,11 +149,9 @@ import java.util.Set; public class ResolverActivity extends FragmentActivity implements ResolverListAdapter.ResolverListCommunicator { - protected ActivityLogic mLogic = new ResolverActivityLogic( - TAG, - () -> this, - this::onWorkProfileStatusUpdated - ); + private final List mInit = new ArrayList<>(); + + protected ActivityLogic mLogic; public ResolverActivity() { mIsIntentPicker = getClass().equals(ResolverActivity.class); @@ -307,30 +311,47 @@ public class ResolverActivity extends FragmentActivity implements } }; } + protected interface Initializer { + void initialize(ActivityLogic value); + } + + protected void setLogic(ActivityLogic logic) { + mLogic = logic; + } + + protected void addInitializer(Runnable initializer) { + mInit.add(initializer); + } @Override protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); if (isFinishing()) { // Performing a clean exit: - // Skip initializing any additional resources. + // Skip initializing anything. return; } + setLogic(new ResolverActivityLogic( + TAG, + () -> this, + this::onWorkProfileStatusUpdated)); + addInitializer(this::init); + } + + @Override + protected final void onPostCreate(@Nullable Bundle savedInstanceState) { + super.onPostCreate(savedInstanceState); + mInit.forEach(Runnable::run); + } + + private void init() { setTheme(mLogic.getThemeResId()); mLogic.preInitialization(); - init( - mLogic.getTargetIntent(), - mLogic.getInitialIntents() == null - ? null : mLogic.getInitialIntents().toArray(new Intent[0]), - mLogic.getTargetDataLoader() - ); - } - protected void init( - Intent intent, - Intent[] initialIntents, - TargetDataLoader targetDataLoader - ) { + Intent intent = mLogic.getTargetIntent(); + List initialIntents = mLogic.getInitialIntents(); + TargetDataLoader targetDataLoader = mLogic.getTargetDataLoader(); + // Calling UID did not have valid permissions if (mLogic.getAnnotatedUserHandles() == null) { finish(); @@ -350,7 +371,7 @@ public class ResolverActivity extends FragmentActivity implements boolean filterLastUsed = mLogic.getSupportsAlwaysUseOption() && !isVoiceInteraction() && !shouldShowTabs() && !hasCloneProfile(); mMultiProfilePagerAdapter = createMultiProfilePagerAdapter( - initialIntents, + requireNonNullElse(initialIntents, emptyList()).toArray(new Intent[0]), /* resolutionList = */ null, filterLastUsed, targetDataLoader diff --git a/tests/activity/src/com/android/intentresolver/v2/ChooserWrapperActivity.java b/tests/activity/src/com/android/intentresolver/v2/ChooserWrapperActivity.java index a314ee97..6eace9f4 100644 --- a/tests/activity/src/com/android/intentresolver/v2/ChooserWrapperActivity.java +++ b/tests/activity/src/com/android/intentresolver/v2/ChooserWrapperActivity.java @@ -57,16 +57,16 @@ public class ChooserWrapperActivity extends ChooserActivity implements IChooserW static final ChooserActivityOverrideData sOverrides = ChooserActivityOverrideData.getInstance(); private UsageStatsManager mUsm; - public ChooserWrapperActivity() { - super(); - mLogic = new TestChooserActivityLogic( - "ChooserWrapper", - () -> this, - this::onWorkProfileStatusUpdated, - () -> mTargetDataLoader, - this::onPreinitialization, - sOverrides - ); + @Override + protected void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + setLogic(new TestChooserActivityLogic( + "ChooserWrapper", + () -> this, + this::onWorkProfileStatusUpdated, + () -> mTargetDataLoader, + this::onPreinitialization, + sOverrides)); } // ResolverActivity (the base class of ChooserActivity) inspects the launched-from UID at diff --git a/tests/activity/src/com/android/intentresolver/v2/ResolverWrapperActivity.java b/tests/activity/src/com/android/intentresolver/v2/ResolverWrapperActivity.java index 92b73d92..7ae58254 100644 --- a/tests/activity/src/com/android/intentresolver/v2/ResolverWrapperActivity.java +++ b/tests/activity/src/com/android/intentresolver/v2/ResolverWrapperActivity.java @@ -62,7 +62,12 @@ public class ResolverWrapperActivity extends ResolverActivity { public ResolverWrapperActivity() { super(/* isIntentPicker= */ true); - mLogic = new TestResolverActivityLogic( + } + + @Override + protected void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + setLogic(new TestResolverActivityLogic( "ResolverWrapper", () -> this, () -> { @@ -70,7 +75,7 @@ public class ResolverWrapperActivity extends ResolverActivity { return Unit.INSTANCE; }, sOverrides - ); + )); } public CountingIdlingResource getLabelIdlingResource() { -- cgit v1.2.3-59-g8ed1b From 2722e36e5591398cdf96be94639fe9ccc055a3d6 Mon Sep 17 00:00:00 2001 From: mrenouf Date: Fri, 1 Dec 2023 13:47:36 -0500 Subject: Validation framework This pattern grew out of existing Intent data extraction code. Goals * abstract away the details of processing * provide informative errors and warnings * self documenting usage * able to report multiple findings (warnings) * fail assertions in acontrolled manner, not by throwing Sample usage: ``` fun readArgs( val bundle: Bundle ) = validateFrom(bundle::get): ValidationResult { val intValue = required(value("KEY_1")) val stringValue = required(value("KEY_2")) val arrayValue = optional(array("KEY_3")) val doubleValue = optional(value("KEY_4")) ExampleObject(intValue, stringValue, arrayValue, doubleValue) } ``` Bug: 300157408 Test: atest com.android.intentresolver.validation Change-Id: I517e70df84c28e42023f19c8616804bc46884b49 --- .../intentresolver/v2/validation/Findings.kt | 113 ++++++++++++++++++ .../intentresolver/v2/validation/Validation.kt | 129 +++++++++++++++++++++ .../v2/validation/ValidationResult.kt | 39 +++++++ .../v2/validation/types/IntentOrUri.kt | 59 ++++++++++ .../v2/validation/types/ParceledArray.kt | 83 +++++++++++++ .../v2/validation/types/SimpleValue.kt | 54 +++++++++ .../v2/validation/types/Validators.kt | 45 +++++++ tests/shared/Android.bp | 1 + .../v2/validation/ValidationResultSubject.kt | 22 ++++ .../intentresolver/v2/validation/ValidationTest.kt | 99 ++++++++++++++++ .../v2/validation/types/IntentOrUriTest.kt | 107 +++++++++++++++++ .../v2/validation/types/ParceledArrayTest.kt | 93 +++++++++++++++ .../v2/validation/types/SimpleValueTest.kt | 52 +++++++++ 13 files changed, 896 insertions(+) create mode 100644 java/src/com/android/intentresolver/v2/validation/Findings.kt create mode 100644 java/src/com/android/intentresolver/v2/validation/Validation.kt create mode 100644 java/src/com/android/intentresolver/v2/validation/ValidationResult.kt create mode 100644 java/src/com/android/intentresolver/v2/validation/types/IntentOrUri.kt create mode 100644 java/src/com/android/intentresolver/v2/validation/types/ParceledArray.kt create mode 100644 java/src/com/android/intentresolver/v2/validation/types/SimpleValue.kt create mode 100644 java/src/com/android/intentresolver/v2/validation/types/Validators.kt create mode 100644 tests/shared/src/com/android/intentresolver/v2/validation/ValidationResultSubject.kt create mode 100644 tests/unit/src/com/android/intentresolver/v2/validation/ValidationTest.kt create mode 100644 tests/unit/src/com/android/intentresolver/v2/validation/types/IntentOrUriTest.kt create mode 100644 tests/unit/src/com/android/intentresolver/v2/validation/types/ParceledArrayTest.kt create mode 100644 tests/unit/src/com/android/intentresolver/v2/validation/types/SimpleValueTest.kt (limited to 'java/src') diff --git a/java/src/com/android/intentresolver/v2/validation/Findings.kt b/java/src/com/android/intentresolver/v2/validation/Findings.kt new file mode 100644 index 00000000..9a3cc9c7 --- /dev/null +++ b/java/src/com/android/intentresolver/v2/validation/Findings.kt @@ -0,0 +1,113 @@ +/* + * Copyright (C) 2023 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.android.intentresolver.v2.validation + +import android.util.Log +import com.android.intentresolver.v2.validation.Importance.CRITICAL +import com.android.intentresolver.v2.validation.Importance.WARNING +import kotlin.reflect.KClass + +sealed interface Finding { + val importance: Importance + val message: String +} + +enum class Importance { + CRITICAL, + WARNING, +} + +val Finding.logcatPriority + get() = + when (importance) { + CRITICAL -> Log.ERROR + else -> Log.WARN + } + +private fun formatMessage(key: String? = null, msg: String) = buildString { + key?.also { append("['$key']: ") } + append(msg) +} + +data class IgnoredValue( + val key: String, + val reason: String, +) : Finding { + override val importance = WARNING + + override val message: String + get() = formatMessage(key, "Ignored. $reason") +} + +data class RequiredValueMissing( + val key: String, + val allowedType: KClass<*>, +) : Finding { + + override val importance = CRITICAL + + override val message: String + get() = + formatMessage( + key, + "expected value of ${allowedType.simpleName}, " + "but no value was present" + ) +} + +data class WrongElementType( + val key: String, + override val importance: Importance, + val container: KClass<*>, + val actualType: KClass<*>, + val expectedType: KClass<*> +) : Finding { + override val message: String + get() = + formatMessage( + key, + "${container.simpleName} expected with elements of " + + "${expectedType.simpleName} " + + "but found ${actualType.simpleName} values instead" + ) +} + +data class ValueIsWrongType( + val key: String, + override val importance: Importance, + val actualType: KClass<*>, + val allowedTypes: List>, +) : Finding { + + override val message: String + get() = + formatMessage( + key, + "expected value of ${allowedTypes.map(KClass<*>::simpleName)} " + + "but was ${actualType.simpleName}" + ) +} + +data class UncaughtException(val thrown: Throwable, val key: String? = null) : Finding { + override val importance: Importance + get() = CRITICAL + override val message: String + get() = + formatMessage( + key, + "An unhandled exception was caught during validation: " + + thrown.stackTraceToString() + ) +} diff --git a/java/src/com/android/intentresolver/v2/validation/Validation.kt b/java/src/com/android/intentresolver/v2/validation/Validation.kt new file mode 100644 index 00000000..46939602 --- /dev/null +++ b/java/src/com/android/intentresolver/v2/validation/Validation.kt @@ -0,0 +1,129 @@ +/* + * Copyright (C) 2023 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.android.intentresolver.v2.validation + +import com.android.intentresolver.v2.validation.Importance.CRITICAL +import com.android.intentresolver.v2.validation.Importance.WARNING + +/** + * Provides a mechanism for validating a result from a set of properties. + * + * The results of validation are provided as [findings]. + */ +interface Validation { + val findings: List + + /** + * Require a valid property. + * + * If [property] is not valid, this [Validation] will be immediately completed as [Invalid]. + * + * @param property the required property + * @return a valid **T** + */ + @Throws(InvalidResultError::class) fun required(property: Validator): T + + /** + * Request an optional value for a property. + * + * If [property] is not valid, this [Validation] will be immediately completed as [Invalid]. + * + * @param property the required property + * @return a valid **T** + */ + fun optional(property: Validator): T? + + /** + * Report a property as __ignored__. + * + * The presence of any value will report a warning citing [reason]. + */ + fun ignored(property: Validator, reason: String) +} + +/** Performs validation for a specific key -> value pair. */ +interface Validator { + val key: String + + /** + * Performs validation on a specific value from [source]. + * + * @param source a source for reading the property value. Values are intentionally untyped + * (Any?) to avoid upstream code from making type assertions through type inference. Types are + * asserted later using a [Validator]. + * @param importance the importance of any findings + */ + fun validate(source: (String) -> Any?, importance: Importance): ValidationResult +} + +internal class InvalidResultError internal constructor() : Error() + +/** + * Perform a number of validations on the source, assembling and returning a Result. + * + * When an exception is thrown by [validate], it is caught here. In response, a failed + * [ValidationResult] is returned containing a [CRITICAL] [Finding] for the exception. + * + * @param validate perform validations and return a [ValidationResult] + */ +fun validateFrom(source: (String) -> Any?, validate: Validation.() -> T): ValidationResult { + val validation = ValidationImpl(source) + return runCatching { validate(validation) } + .fold( + onSuccess = { result -> Valid(result, validation.findings) }, + onFailure = { + when (it) { + // A validator has interrupted validation. Return the findings. + is InvalidResultError -> Invalid(validation.findings) + + // Some other exception was thrown from [validate], + else -> Invalid(findings = listOf(UncaughtException(it))) + } + } + ) +} + +private class ValidationImpl(val source: (String) -> Any?) : Validation { + override val findings = mutableListOf() + + override fun optional(property: Validator): T? = validate(property, WARNING) + + override fun required(property: Validator): T { + return validate(property, CRITICAL) ?: throw InvalidResultError() + } + + override fun ignored(property: Validator, reason: String) { + val result = property.validate(source, WARNING) + if (result.value != null) { + // Note: Any findings about the value (result.findings) are ignored. + findings += IgnoredValue(property.key, reason) + } + } + + private fun validate(property: Validator, importance: Importance): T? { + return runCatching { property.validate(source, importance) } + .fold( + onSuccess = { result -> + findings += result.findings + result.value + }, + onFailure = { + findings += UncaughtException(it, property.key) + null + } + ) + } +} diff --git a/java/src/com/android/intentresolver/v2/validation/ValidationResult.kt b/java/src/com/android/intentresolver/v2/validation/ValidationResult.kt new file mode 100644 index 00000000..092cabe8 --- /dev/null +++ b/java/src/com/android/intentresolver/v2/validation/ValidationResult.kt @@ -0,0 +1,39 @@ +/* + * Copyright (C) 2023 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.android.intentresolver.v2.validation + +import android.util.Log + +sealed interface ValidationResult { + val value: T? + val findings: List + + fun isSuccess() = value != null + + fun getOrThrow(): T = + checkNotNull(value) { "The result was invalid: " + findings.joinToString(separator = "\n") } + + fun reportToLogcat(tag: String) { + findings.forEach { Log.println(it.logcatPriority, tag, it.toString()) } + } +} + +data class Valid(override val value: T?, override val findings: List = emptyList()) : + ValidationResult + +data class Invalid(override val findings: List) : ValidationResult { + override val value: T? = null +} diff --git a/java/src/com/android/intentresolver/v2/validation/types/IntentOrUri.kt b/java/src/com/android/intentresolver/v2/validation/types/IntentOrUri.kt new file mode 100644 index 00000000..3cefeb15 --- /dev/null +++ b/java/src/com/android/intentresolver/v2/validation/types/IntentOrUri.kt @@ -0,0 +1,59 @@ +/* + * Copyright (C) 2023 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.android.intentresolver.v2.validation.types + +import android.content.Intent +import android.net.Uri +import com.android.intentresolver.v2.validation.Importance +import com.android.intentresolver.v2.validation.RequiredValueMissing +import com.android.intentresolver.v2.validation.Valid +import com.android.intentresolver.v2.validation.ValidationResult +import com.android.intentresolver.v2.validation.Validator +import com.android.intentresolver.v2.validation.ValueIsWrongType + +class IntentOrUri(override val key: String) : Validator { + + override fun validate( + source: (String) -> Any?, + importance: Importance + ): ValidationResult { + + return when (val value = source(key)) { + // An intent, return it. + is Intent -> Valid(value) + + // A Uri was supplied. + // Unfortunately, converting Uri -> Intent requires a toString(). + is Uri -> Valid(Intent.parseUri(value.toString(), Intent.URI_INTENT_SCHEME)) + + // No value present. + null -> createResult(importance, RequiredValueMissing(key, Intent::class)) + + // Some other type. + else -> { + return createResult( + importance, + ValueIsWrongType( + key, + importance, + actualType = value::class, + allowedTypes = listOf(Intent::class, Uri::class) + ) + ) + } + } + } +} diff --git a/java/src/com/android/intentresolver/v2/validation/types/ParceledArray.kt b/java/src/com/android/intentresolver/v2/validation/types/ParceledArray.kt new file mode 100644 index 00000000..c6c4abba --- /dev/null +++ b/java/src/com/android/intentresolver/v2/validation/types/ParceledArray.kt @@ -0,0 +1,83 @@ +/* + * Copyright (C) 2023 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.android.intentresolver.v2.validation.types + +import com.android.intentresolver.v2.validation.Importance +import com.android.intentresolver.v2.validation.RequiredValueMissing +import com.android.intentresolver.v2.validation.Valid +import com.android.intentresolver.v2.validation.ValidationResult +import com.android.intentresolver.v2.validation.Validator +import com.android.intentresolver.v2.validation.ValueIsWrongType +import com.android.intentresolver.v2.validation.WrongElementType +import kotlin.reflect.KClass +import kotlin.reflect.cast + +class ParceledArray( + override val key: String, + private val elementType: KClass, +) : Validator> { + + override fun validate( + source: (String) -> Any?, + importance: Importance + ): ValidationResult> { + + return when (val value: Any? = source(key)) { + // No value present. + null -> createResult(importance, RequiredValueMissing(key, elementType)) + + // A parcel does not transfer the element type information for parcelable + // arrays. This leads to a restored type of Array, which is + // incompatible with Array. + + // To handle this safely, treat as Array<*>, assert contents of the expected + // parcelable type, and return as a list. + + is Array<*> -> { + val invalid = value.filterNotNull().firstOrNull { !elementType.isInstance(it) } + when (invalid) { + // No invalid elements, result is ok. + null -> Valid(value.map { elementType.cast(it) }) + + // At least one incorrect element type found. + else -> + createResult( + importance, + WrongElementType( + key, + importance, + actualType = invalid::class, + container = Array::class, + expectedType = elementType + ) + ) + } + } + + // The value is not an Array at all. + else -> + createResult( + importance, + ValueIsWrongType( + key, + importance, + actualType = value::class, + allowedTypes = listOf(elementType) + ) + ) + } + } +} diff --git a/java/src/com/android/intentresolver/v2/validation/types/SimpleValue.kt b/java/src/com/android/intentresolver/v2/validation/types/SimpleValue.kt new file mode 100644 index 00000000..3287b84b --- /dev/null +++ b/java/src/com/android/intentresolver/v2/validation/types/SimpleValue.kt @@ -0,0 +1,54 @@ +/* + * Copyright (C) 2023 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.android.intentresolver.v2.validation.types + +import com.android.intentresolver.v2.validation.Importance +import com.android.intentresolver.v2.validation.RequiredValueMissing +import com.android.intentresolver.v2.validation.Valid +import com.android.intentresolver.v2.validation.ValidationResult +import com.android.intentresolver.v2.validation.Validator +import com.android.intentresolver.v2.validation.ValueIsWrongType +import kotlin.reflect.KClass +import kotlin.reflect.cast + +class SimpleValue( + override val key: String, + private val expected: KClass, +) : Validator { + + override fun validate(source: (String) -> Any?, importance: Importance): ValidationResult { + val value: Any? = source(key) + return when { + // The value is present and of the expected type. + expected.isInstance(value) -> return Valid(expected.cast(value)) + + // No value is present. + value == null -> createResult(importance, RequiredValueMissing(key, expected)) + + // The value is some other type. + else -> + createResult( + importance, + ValueIsWrongType( + key, + importance, + actualType = value::class, + allowedTypes = listOf(expected) + ) + ) + } + } +} diff --git a/java/src/com/android/intentresolver/v2/validation/types/Validators.kt b/java/src/com/android/intentresolver/v2/validation/types/Validators.kt new file mode 100644 index 00000000..4e6e5dff --- /dev/null +++ b/java/src/com/android/intentresolver/v2/validation/types/Validators.kt @@ -0,0 +1,45 @@ +/* + * Copyright (C) 2023 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.android.intentresolver.v2.validation.types + +import com.android.intentresolver.v2.validation.Finding +import com.android.intentresolver.v2.validation.Importance +import com.android.intentresolver.v2.validation.Importance.CRITICAL +import com.android.intentresolver.v2.validation.Importance.WARNING +import com.android.intentresolver.v2.validation.Invalid +import com.android.intentresolver.v2.validation.Valid +import com.android.intentresolver.v2.validation.ValidationResult +import com.android.intentresolver.v2.validation.Validator + +inline fun value(key: String): Validator { + return SimpleValue(key, T::class) +} + +inline fun array(key: String): Validator> { + return ParceledArray(key, T::class) +} + +/** + * Convenience function to wrap a finding in an appropriate result type. + * + * An error [finding] is suppressed when [importance] == [WARNING] + */ +internal fun createResult(importance: Importance, finding: Finding): ValidationResult { + return when (importance) { + WARNING -> Valid(null, listOf(finding).filter { it.importance == WARNING }) + CRITICAL -> Invalid(listOf(finding)) + } +} diff --git a/tests/shared/Android.bp b/tests/shared/Android.bp index dbd68b12..55188ee3 100644 --- a/tests/shared/Android.bp +++ b/tests/shared/Android.bp @@ -32,5 +32,6 @@ java_library { "hamcrest", "IntentResolver-core", "mockito-target-minus-junit4", + "truth" ], } diff --git a/tests/shared/src/com/android/intentresolver/v2/validation/ValidationResultSubject.kt b/tests/shared/src/com/android/intentresolver/v2/validation/ValidationResultSubject.kt new file mode 100644 index 00000000..1ff0ce8e --- /dev/null +++ b/tests/shared/src/com/android/intentresolver/v2/validation/ValidationResultSubject.kt @@ -0,0 +1,22 @@ +package com.android.intentresolver.v2.validation + +import com.google.common.truth.FailureMetadata +import com.google.common.truth.IterableSubject +import com.google.common.truth.Subject +import com.google.common.truth.Truth.assertAbout + +class ValidationResultSubject(metadata: FailureMetadata, private val actual: ValidationResult<*>?) : + Subject(metadata, actual) { + + fun isSuccess() = check("isSuccess()").that(actual?.isSuccess()).isTrue() + fun isFailure() = check("isSuccess()").that(actual?.isSuccess()).isFalse() + + fun value(): Subject = check("value").that(actual?.value) + + fun findings(): IterableSubject = check("findings").that(actual?.findings) + + companion object { + fun assertThat(input: ValidationResult<*>): ValidationResultSubject = + assertAbout(::ValidationResultSubject).that(input) + } +} diff --git a/tests/unit/src/com/android/intentresolver/v2/validation/ValidationTest.kt b/tests/unit/src/com/android/intentresolver/v2/validation/ValidationTest.kt new file mode 100644 index 00000000..43fb448c --- /dev/null +++ b/tests/unit/src/com/android/intentresolver/v2/validation/ValidationTest.kt @@ -0,0 +1,99 @@ +package com.android.intentresolver.v2.validation + +import com.android.intentresolver.v2.validation.ValidationResultSubject.Companion.assertThat +import com.android.intentresolver.v2.validation.types.value +import com.google.common.truth.Truth.assertThat +import org.junit.Assert.fail +import org.junit.Test + +class ValidationTest { + + /** Test required values. */ + @Test + fun required_valuePresent() { + val result: ValidationResult = + validateFrom({ 1 }) { + val required: Int = required(value("key")) + "return value: $required" + } + assertThat(result).value().isEqualTo("return value: 1") + assertThat(result).findings().isEmpty() + } + + /** Test reporting of absent required values. */ + @Test + fun required_valueAbsent() { + val result: ValidationResult = + validateFrom({ null }) { + required(value("key")) + fail("'required' should have thrown an exception") + "return value" + } + assertThat(result).isFailure() + assertThat(result).findings().containsExactly( + RequiredValueMissing("key", Int::class)) + } + + /** Test optional values are ignored when absent. */ + @Test + fun optional_valuePresent() { + val result: ValidationResult = + validateFrom({ 1 }) { + val optional: Int? = optional(value("key")) + "return value: $optional" + } + assertThat(result).value().isEqualTo("return value: 1") + assertThat(result).findings().isEmpty() + } + + /** Test optional values are ignored when absent. */ + @Test + fun optional_valueAbsent() { + val result: ValidationResult = + validateFrom({ null }) { + val optional: String? = optional(value("key")) + "return value: $optional" + } + assertThat(result).isSuccess() + assertThat(result).findings().isEmpty() + } + + /** Test reporting of ignored values. */ + @Test + fun ignored_valuePresent() { + val result: ValidationResult = + validateFrom(mapOf("key" to 1)::get) { + ignored(value("key"), "no longer supported") + "result value" + } + assertThat(result).value().isEqualTo("result value") + assertThat(result) + .findings() + .containsExactly(IgnoredValue("key", "no longer supported")) + } + + /** Test reporting of ignored values. */ + @Test + fun ignored_valueAbsent() { + val result: ValidationResult = + validateFrom({ null }) { + ignored(value("key"), "ignored when option foo is set") + "result value" + } + assertThat(result).value().isEqualTo("result value") + assertThat(result).findings().isEmpty() + } + + /** Test handling of exceptions in the validation function. */ + @Test + fun thrown_exception() { + val result: ValidationResult = + validateFrom({ null }) { + error("something") + } + assertThat(result).isFailure() + val findingTypes = result.findings.map { it::class } + assertThat(findingTypes.first()).isEqualTo(UncaughtException::class) + } + +} diff --git a/tests/unit/src/com/android/intentresolver/v2/validation/types/IntentOrUriTest.kt b/tests/unit/src/com/android/intentresolver/v2/validation/types/IntentOrUriTest.kt new file mode 100644 index 00000000..ad230488 --- /dev/null +++ b/tests/unit/src/com/android/intentresolver/v2/validation/types/IntentOrUriTest.kt @@ -0,0 +1,107 @@ +package com.android.intentresolver.v2.validation.types + +import android.content.Intent +import android.content.Intent.URI_INTENT_SCHEME +import android.net.Uri +import androidx.core.net.toUri +import androidx.test.ext.truth.content.IntentSubject.assertThat +import com.android.intentresolver.v2.validation.Importance.CRITICAL +import com.android.intentresolver.v2.validation.Importance.WARNING +import com.android.intentresolver.v2.validation.RequiredValueMissing +import com.android.intentresolver.v2.validation.ValidationResultSubject.Companion.assertThat +import com.android.intentresolver.v2.validation.ValueIsWrongType +import com.google.common.truth.Truth.assertThat +import org.junit.Test + +class IntentOrUriTest { + + /** Test for validation success when the value is an Intent. */ + @Test + fun intent() { + val keyValidator = IntentOrUri("key") + val values = mapOf("key" to Intent("GO")) + + val result = keyValidator.validate(values::get, CRITICAL) + assertThat(result).findings().isEmpty() + assertThat(result.value).hasAction("GO") + } + + /** Test for validation success when the value is a Uri. */ + @Test + fun uri() { + val keyValidator = IntentOrUri("key") + val values = mapOf("key" to Intent("GO").toUri(URI_INTENT_SCHEME).toUri()) + + val result = keyValidator.validate(values::get, CRITICAL) + assertThat(result).findings().isEmpty() + assertThat(result.value).hasAction("GO") + } + + /** Test the failure result when the value is missing. */ + @Test + fun missing() { + val keyValidator = IntentOrUri("key") + + val result = keyValidator.validate({ null }, CRITICAL) + + assertThat(result).value().isNull() + assertThat(result).findings().containsExactly(RequiredValueMissing("key", Intent::class)) + } + + /** Check validation passes when value is null and importance is [WARNING] (optional). */ + @Test + fun optional() { + val keyValidator = ParceledArray("key", Intent::class) + + val result = keyValidator.validate(source = { null }, WARNING) + + assertThat(result).findings().isEmpty() + assertThat(result.value).isNull() + } + + /** + * Test for failure result when the value is neither Intent nor Uri, with importance CRITICAL. + */ + @Test + fun wrongType_required() { + val keyValidator = IntentOrUri("key") + val values = mapOf("key" to 1) + + val result = keyValidator.validate(values::get, CRITICAL) + + assertThat(result).value().isNull() + assertThat(result) + .findings() + .containsExactly( + ValueIsWrongType( + "key", + importance = CRITICAL, + actualType = Int::class, + allowedTypes = listOf(Intent::class, Uri::class) + ) + ) + } + + /** + * Test for warnings when the value is neither Intent nor Uri, with importance WARNING. + */ + @Test + fun wrongType_optional() { + val keyValidator = IntentOrUri("key") + val values = mapOf("key" to 1) + + val result = keyValidator.validate(values::get, WARNING) + + assertThat(result).value().isNull() + assertThat(result) + .findings() + .containsExactly( + ValueIsWrongType( + "key", + importance = WARNING, + actualType = Int::class, + allowedTypes = listOf(Intent::class, Uri::class) + ) + ) + } +} diff --git a/tests/unit/src/com/android/intentresolver/v2/validation/types/ParceledArrayTest.kt b/tests/unit/src/com/android/intentresolver/v2/validation/types/ParceledArrayTest.kt new file mode 100644 index 00000000..d4dca01b --- /dev/null +++ b/tests/unit/src/com/android/intentresolver/v2/validation/types/ParceledArrayTest.kt @@ -0,0 +1,93 @@ +package com.android.intentresolver.v2.validation.types + +import android.content.Intent +import android.graphics.Point +import com.android.intentresolver.v2.validation.Importance.CRITICAL +import com.android.intentresolver.v2.validation.Importance.WARNING +import com.android.intentresolver.v2.validation.RequiredValueMissing +import com.android.intentresolver.v2.validation.ValidationResultSubject.Companion.assertThat +import com.android.intentresolver.v2.validation.ValueIsWrongType +import com.android.intentresolver.v2.validation.WrongElementType +import com.google.common.truth.Truth.assertThat +import org.junit.Test + +class ParceledArrayTest { + + /** Check that a array is handled correctly when valid. */ + @Test + fun valid() { + val keyValidator = ParceledArray("key", elementType = String::class) + val values = mapOf("key" to arrayOf("String")) + + val result = keyValidator.validate(values::get, CRITICAL) + + assertThat(result).findings().isEmpty() + assertThat(result.value).containsExactly("String") + } + + /** Check correct failure result when an array has the wrong element type. */ + @Test + fun wrongElementType() { + val keyValidator = ParceledArray("key", elementType = Intent::class) + val values = mapOf("key" to arrayOf(Point())) + + val result = keyValidator.validate(values::get, CRITICAL) + + assertThat(result).value().isNull() + assertThat(result) + .findings() + .containsExactly( + // TODO: report with a new class `WrongElementType` to improve clarity + WrongElementType( + "key", + importance = CRITICAL, + container = Array::class, + actualType = Point::class, + expectedType = Intent::class + ) + ) + } + + /** Check correct failure result when an array value is missing. */ + @Test + fun missing() { + val keyValidator = ParceledArray("key", Intent::class) + + val result = keyValidator.validate(source = { null }, CRITICAL) + + assertThat(result).value().isNull() + assertThat(result).findings().containsExactly(RequiredValueMissing("key", Intent::class)) + } + + /** Check validation passes when value is null and importance is [WARNING] (optional). */ + @Test + fun optional() { + val keyValidator = ParceledArray("key", Intent::class) + + val result = keyValidator.validate(source = { null }, WARNING) + + assertThat(result).findings().isEmpty() + assertThat(result.value).isNull() + } + + /** Check correct failure result when the array value itself is the wrong type. */ + @Test + fun wrongType() { + val keyValidator = ParceledArray("key", Intent::class) + val values = mapOf("key" to 1) + + val result = keyValidator.validate(values::get, CRITICAL) + + assertThat(result).value().isNull() + assertThat(result) + .findings() + .containsExactly( + ValueIsWrongType( + "key", + importance = CRITICAL, + actualType = Int::class, + allowedTypes = listOf(Intent::class) + ) + ) + } +} diff --git a/tests/unit/src/com/android/intentresolver/v2/validation/types/SimpleValueTest.kt b/tests/unit/src/com/android/intentresolver/v2/validation/types/SimpleValueTest.kt new file mode 100644 index 00000000..13bb4b33 --- /dev/null +++ b/tests/unit/src/com/android/intentresolver/v2/validation/types/SimpleValueTest.kt @@ -0,0 +1,52 @@ +package com.android.intentresolver.v2.validation.types + +import com.android.intentresolver.v2.validation.Importance.CRITICAL +import com.android.intentresolver.v2.validation.RequiredValueMissing +import com.android.intentresolver.v2.validation.ValidationResultSubject.Companion.assertThat +import com.android.intentresolver.v2.validation.ValueIsWrongType +import org.junit.Test + +class SimpleValueTest { + + /** Test for validation success when the value is present and the correct type. */ + @Test + fun present() { + val keyValidator = SimpleValue("key", expected = Double::class) + val values = mapOf("key" to Math.PI) + + val result = keyValidator.validate(values::get, CRITICAL) + assertThat(result).findings().isEmpty() + assertThat(result).value().isEqualTo(Math.PI) + } + + /** Test for validation success when the value is present and the correct type. */ + @Test + fun wrongType() { + val keyValidator = SimpleValue("key", expected = Double::class) + val values = mapOf("key" to "Apple Pie") + + val result = keyValidator.validate(values::get, CRITICAL) + assertThat(result).value().isNull() + assertThat(result) + .findings() + .containsExactly( + ValueIsWrongType( + "key", + importance = CRITICAL, + actualType = String::class, + allowedTypes = listOf(Double::class) + ) + ) + } + + /** Test the failure result when the value is missing. */ + @Test + fun missing() { + val keyValidator = SimpleValue("key", expected = Double::class) + + val result = keyValidator.validate(source = { null }, CRITICAL) + + assertThat(result).value().isNull() + assertThat(result).findings().containsExactly(RequiredValueMissing("key", Double::class)) + } +} -- cgit v1.2.3-59-g8ed1b From df53ad197e45ccc9ff7a8bf6d63e15c61cb51a5a Mon Sep 17 00:00:00 2001 From: Joshua Trask Date: Thu, 7 Dec 2023 18:19:14 +0000 Subject: Move Chooser package-change shortcut reset to list adapter The shortcut-resetting logic was implemented inline in ChooserActivity's handler, and this CL moves the v2 implementation to a new callback in `ChooserListAdapter`. The v1 implementation is left as-is, so the design change is a no-op. This makes the activity-side handling more generic so it can be moved to the pager-adapter (go/chooser-ntab-refactoring), and it fixes a minor bug (b/315460869). This is based on "snapshot 4" and "5" of ag/25335069 (for more context, see that CL or go/chooser-ntab-refactoring). Bug: 310211468, 315460869 Test: `IntentResolver-tests-{unit,activity,integration}`. Change-Id: I29e10c801db553edc8132aaf8372140b4c35740e --- .../android/intentresolver/ChooserActivity.java | 3 ++- .../android/intentresolver/ChooserListAdapter.java | 24 ++++++++++++++++++++-- .../android/intentresolver/v2/ChooserActivity.java | 23 +++++++++------------ .../intentresolver/ChooserWrapperActivity.java | 3 ++- .../intentresolver/v2/ChooserWrapperActivity.java | 4 ++-- .../intentresolver/ChooserListAdapterDataTest.kt | 5 ++++- .../intentresolver/ChooserListAdapterTest.kt | 10 ++++++++- 7 files changed, 51 insertions(+), 21 deletions(-) (limited to 'java/src') diff --git a/java/src/com/android/intentresolver/ChooserActivity.java b/java/src/com/android/intentresolver/ChooserActivity.java index 50ca5d0d..9000ab3a 100644 --- a/java/src/com/android/intentresolver/ChooserActivity.java +++ b/java/src/com/android/intentresolver/ChooserActivity.java @@ -1251,7 +1251,8 @@ public class ChooserActivity extends Hilt_ChooserActivity implements getEventLog(), maxTargetsPerRow, initialIntentsUserSpace, - targetDataLoader); + targetDataLoader, + null); } @Override diff --git a/java/src/com/android/intentresolver/ChooserListAdapter.java b/java/src/com/android/intentresolver/ChooserListAdapter.java index 3af8a3a7..876ad5c3 100644 --- a/java/src/com/android/intentresolver/ChooserListAdapter.java +++ b/java/src/com/android/intentresolver/ChooserListAdapter.java @@ -30,7 +30,6 @@ import android.content.pm.PackageManager; import android.content.pm.ResolveInfo; import android.content.pm.ShortcutInfo; import android.graphics.drawable.Drawable; -import android.net.Uri; import android.os.AsyncTask; import android.os.Trace; import android.os.UserHandle; @@ -68,6 +67,17 @@ import java.util.concurrent.Executor; import java.util.stream.Collectors; public class ChooserListAdapter extends ResolverListAdapter { + + /** + * Delegate interface for injecting a chooser-specific operation to be performed before handling + * a package-change event. This allows the "driver" invoking the package-change to be generic, + * with no knowledge specific to the chooser implementation. + */ + public interface PackageChangeCallback { + /** Perform any steps necessary before processing the package-change event. */ + void beforeHandlingPackagesChanged(); + } + private static final String TAG = "ChooserListAdapter"; private static final boolean DEBUG = false; @@ -93,6 +103,9 @@ public class ChooserListAdapter extends ResolverListAdapter { private final Set mRequestedIcons = new HashSet<>(); + @Nullable + private final PackageChangeCallback mPackageChangeCallback; + // Reserve spots for incoming direct share targets by adding placeholders private final TargetInfo mPlaceHolderTargetInfo; private final TargetDataLoader mTargetDataLoader; @@ -152,7 +165,8 @@ public class ChooserListAdapter extends ResolverListAdapter { EventLog eventLog, int maxRankedTargets, UserHandle initialIntentsUserSpace, - TargetDataLoader targetDataLoader) { + TargetDataLoader targetDataLoader, + @Nullable PackageChangeCallback packageChangeCallback) { this( context, payloadIntents, @@ -169,6 +183,7 @@ public class ChooserListAdapter extends ResolverListAdapter { maxRankedTargets, initialIntentsUserSpace, targetDataLoader, + packageChangeCallback, AsyncTask.SERIAL_EXECUTOR, context.getMainExecutor()); } @@ -190,6 +205,7 @@ public class ChooserListAdapter extends ResolverListAdapter { int maxRankedTargets, UserHandle initialIntentsUserSpace, TargetDataLoader targetDataLoader, + @Nullable PackageChangeCallback packageChangeCallback, Executor bgExecutor, Executor mainExecutor) { // Don't send the initial intents through the shared ResolverActivity path, @@ -214,6 +230,7 @@ public class ChooserListAdapter extends ResolverListAdapter { mPlaceHolderTargetInfo = NotSelectableTargetInfo.newPlaceHolderTargetInfo(context); mTargetDataLoader = targetDataLoader; + mPackageChangeCallback = packageChangeCallback; createPlaceHolders(); mEventLog = eventLog; mShortcutSelectionLogic = new ShortcutSelectionLogic( @@ -286,6 +303,9 @@ public class ChooserListAdapter extends ResolverListAdapter { @Override public void handlePackagesChanged() { + if (mPackageChangeCallback != null) { + mPackageChangeCallback.beforeHandlingPackagesChanged(); + } if (DEBUG) { Log.d(TAG, "clearing queryTargets on package change"); } diff --git a/java/src/com/android/intentresolver/v2/ChooserActivity.java b/java/src/com/android/intentresolver/v2/ChooserActivity.java index 95eedf47..6c961bb8 100644 --- a/java/src/com/android/intentresolver/v2/ChooserActivity.java +++ b/java/src/com/android/intentresolver/v2/ChooserActivity.java @@ -594,25 +594,16 @@ public class ChooserActivity extends Hilt_ChooserActivity implements // Refresh pinned items mPinnedSharedPrefs = getPinnedSharedPrefs(this); if (listAdapter == null) { - handlePackageChangePerProfile(mChooserMultiProfilePagerAdapter.getActiveListAdapter()); + mChooserMultiProfilePagerAdapter.getActiveListAdapter().handlePackagesChanged(); if (mChooserMultiProfilePagerAdapter.getCount() > 1) { - handlePackageChangePerProfile( - mChooserMultiProfilePagerAdapter.getInactiveListAdapter()); + mChooserMultiProfilePagerAdapter.getInactiveListAdapter().handlePackagesChanged(); } } else { - handlePackageChangePerProfile(listAdapter); + listAdapter.handlePackagesChanged(); } updateProfileViewButton(); } - private void handlePackageChangePerProfile(ResolverListAdapter adapter) { - ProfileRecord record = getProfileRecord(adapter.getUserHandle()); - if (record != null && record.shortcutLoader != null) { - record.shortcutLoader.reset(); - } - adapter.handlePackagesChanged(); - } - @Override protected void onResume() { super.onResume(); @@ -1272,7 +1263,13 @@ public class ChooserActivity extends Hilt_ChooserActivity implements getEventLog(), maxTargetsPerRow, initialIntentsUserSpace, - targetDataLoader); + targetDataLoader, + () -> { + ProfileRecord record = getProfileRecord(userHandle); + if (record != null && record.shortcutLoader != null) { + record.shortcutLoader.reset(); + } + }); } @Override diff --git a/tests/activity/src/com/android/intentresolver/ChooserWrapperActivity.java b/tests/activity/src/com/android/intentresolver/ChooserWrapperActivity.java index 27d50adf..4ea0681d 100644 --- a/tests/activity/src/com/android/intentresolver/ChooserWrapperActivity.java +++ b/tests/activity/src/com/android/intentresolver/ChooserWrapperActivity.java @@ -92,7 +92,8 @@ public class ChooserWrapperActivity extends ChooserActivity implements IChooserW getEventLog(), maxTargetsPerRow, userHandle, - targetDataLoader); + targetDataLoader, + null); } @Override diff --git a/tests/activity/src/com/android/intentresolver/v2/ChooserWrapperActivity.java b/tests/activity/src/com/android/intentresolver/v2/ChooserWrapperActivity.java index a314ee97..3a401178 100644 --- a/tests/activity/src/com/android/intentresolver/v2/ChooserWrapperActivity.java +++ b/tests/activity/src/com/android/intentresolver/v2/ChooserWrapperActivity.java @@ -34,7 +34,6 @@ import android.os.UserHandle; import androidx.lifecycle.ViewModelProvider; import com.android.intentresolver.ChooserListAdapter; -import com.android.intentresolver.ChooserRequestParameters; import com.android.intentresolver.IChooserWrapper; import com.android.intentresolver.ResolverListController; import com.android.intentresolver.TestContentPreviewViewModel; @@ -107,7 +106,8 @@ public class ChooserWrapperActivity extends ChooserActivity implements IChooserW getEventLog(), maxTargetsPerRow, userHandle, - targetDataLoader); + targetDataLoader, + null); } @Override diff --git a/tests/unit/src/com/android/intentresolver/ChooserListAdapterDataTest.kt b/tests/unit/src/com/android/intentresolver/ChooserListAdapterDataTest.kt index 9864d0bd..98c5e008 100644 --- a/tests/unit/src/com/android/intentresolver/ChooserListAdapterDataTest.kt +++ b/tests/unit/src/com/android/intentresolver/ChooserListAdapterDataTest.kt @@ -44,7 +44,8 @@ class ChooserListAdapterDataTest { private val targetDataLoader = mock() private val backgroundExecutor = TestExecutor() private val immediateExecutor = TestExecutor(immediate = true) - private val referrerFillInIntent = Intent().putExtra(Intent.EXTRA_REFERRER, "org.referrer.package") + private val referrerFillInIntent = + Intent().putExtra(Intent.EXTRA_REFERRER, "org.referrer.package") @Test fun test_twoTargetsWithNonOverlappingInitialIntent_threeTargetsInResolverAdapter() { @@ -93,6 +94,7 @@ class ChooserListAdapterDataTest { /*maxRankedTargets=*/ 2, /*initialIntentsUserSpace=*/ userHandle, targetDataLoader, + null, backgroundExecutor, immediateExecutor, ) @@ -155,6 +157,7 @@ class ChooserListAdapterDataTest { /*maxRankedTargets=*/ 2, /*initialIntentsUserSpace=*/ userHandle, targetDataLoader, + null, backgroundExecutor, immediateExecutor, ) diff --git a/tests/unit/src/com/android/intentresolver/ChooserListAdapterTest.kt b/tests/unit/src/com/android/intentresolver/ChooserListAdapterTest.kt index a12c9ec1..cb043943 100644 --- a/tests/unit/src/com/android/intentresolver/ChooserListAdapterTest.kt +++ b/tests/unit/src/com/android/intentresolver/ChooserListAdapterTest.kt @@ -56,6 +56,7 @@ class ChooserListAdapterTest { private val targetLabel = "Target" private val mEventLog = mock() private val mTargetDataLoader = mock() + private val mPackageChangeCallback = mock() private val testSubject by lazy { ChooserListAdapter( @@ -73,7 +74,8 @@ class ChooserListAdapterTest { mEventLog, 0, null, - mTargetDataLoader + mTargetDataLoader, + mPackageChangeCallback ) } @@ -168,6 +170,12 @@ class ChooserListAdapterTest { assertThat(view.contentDescription).isEqualTo("$appLabel. Pinned") } + @Test + fun handlePackagesChanged_invokesCallback() { + testSubject.handlePackagesChanged() + verify(mPackageChangeCallback, times(1)).beforeHandlingPackagesChanged() + } + private fun createSelectableTargetInfo(isPinned: Boolean = false): TargetInfo { val shortcutInfo = createShortcutInfo("id-1", ComponentName("pkg", "Class"), 1).apply { -- cgit v1.2.3-59-g8ed1b From 15a4eef126161ee09961d3687c094f5a91ee2133 Mon Sep 17 00:00:00 2001 From: Joshua Trask Date: Tue, 5 Dec 2023 18:30:29 +0000 Subject: Pull package change handling into pager-adapter This *somewhat* cleans up a convoluted flow so that the "per-profile" concerns / references to "inactive profiles" get consolidated into MultiProfilePagerAdapter, where they'll be audited and generalized in the n-tab work. This has no side effects (beyond those noted for the parent change). These changes are as prototyped in ag/25335069 and described in go/chooser-ntab-refactoring. See below for a "by-snapshot" breakdown of the incremental changes composed in this CL. Snapshot 1: Move the main implementation from ResolverActivity to delegate through MultiProfilePagerAdapter, the component that owned most of the data responsibilities anyways. Note our `-activity` tests provide coverage for this basic flow (e.g., in manual experimentation, the tests would fail if I had the relocated method return the wrong value). Snapshot 2: Move ChooserActivity's customized package-change consideration (i.e., calling `notifyDataSetChanged()` prior to invoking the pager-adapter package-change handling steps) into `ChooserMultiProfilePagerAdapter`. This makes the "driver" code in the activities more generic in advance of moving more responsibilities to the page-adapter. Snapshot 3: Rebase on parent change, equivalent to snapshots 4 and 5 in ag/25335069. (Snapshot 4: update CL description after rebase) Snapshot 5: Move "per-profile" handling of refreshing all adapters into the MultiProfilePagerAdapter, which has a better model of the complete set of adapters that might need to be refreshed. (From ag/25335069 "snapshot 6") Bug: 310211468 Test: `IntentResolver-tests-{unit,activity,integration}`. See notes ^ Change-Id: I77981794345de23d52b4ca6b57ec974c60871dff --- .../android/intentresolver/v2/ChooserActivity.java | 11 +--- .../v2/ChooserMultiProfilePagerAdapter.java | 8 +++ .../v2/MultiProfilePagerAdapter.java | 60 ++++++++++++++++++++++ .../intentresolver/v2/ResolverActivity.java | 36 +++---------- 4 files changed, 75 insertions(+), 40 deletions(-) (limited to 'java/src') diff --git a/java/src/com/android/intentresolver/v2/ChooserActivity.java b/java/src/com/android/intentresolver/v2/ChooserActivity.java index 6c961bb8..b8b82d66 100644 --- a/java/src/com/android/intentresolver/v2/ChooserActivity.java +++ b/java/src/com/android/intentresolver/v2/ChooserActivity.java @@ -594,10 +594,7 @@ public class ChooserActivity extends Hilt_ChooserActivity implements // Refresh pinned items mPinnedSharedPrefs = getPinnedSharedPrefs(this); if (listAdapter == null) { - mChooserMultiProfilePagerAdapter.getActiveListAdapter().handlePackagesChanged(); - if (mChooserMultiProfilePagerAdapter.getCount() > 1) { - mChooserMultiProfilePagerAdapter.getInactiveListAdapter().handlePackagesChanged(); - } + mChooserMultiProfilePagerAdapter.refreshPackagesInAllTabs(); } else { listAdapter.handlePackagesChanged(); } @@ -1519,12 +1516,6 @@ public class ChooserActivity extends Hilt_ChooserActivity implements return PROFILE_PERSONAL; } - @Override // ResolverListCommunicator - public void onHandlePackagesChanged(ResolverListAdapter listAdapter) { - mChooserMultiProfilePagerAdapter.getActiveListAdapter().notifyDataSetChanged(); - super.onHandlePackagesChanged(listAdapter); - } - @Override protected void onListRebuilt(ResolverListAdapter listAdapter, boolean rebuildComplete) { setupScrollListener(); diff --git a/java/src/com/android/intentresolver/v2/ChooserMultiProfilePagerAdapter.java b/java/src/com/android/intentresolver/v2/ChooserMultiProfilePagerAdapter.java index 87b3b201..b9ee9622 100644 --- a/java/src/com/android/intentresolver/v2/ChooserMultiProfilePagerAdapter.java +++ b/java/src/com/android/intentresolver/v2/ChooserMultiProfilePagerAdapter.java @@ -153,6 +153,14 @@ public class ChooserMultiProfilePagerAdapter extends MultiProfilePagerAdapter< return rootView; } + @Override + public boolean onHandlePackagesChanged( + ChooserListAdapter listAdapter, boolean waitingToEnableWorkProfile) { + // TODO: why do we need to do the extra `notifyDataSetChanged()` in (only) the Chooser case? + getActiveListAdapter().notifyDataSetChanged(); + return super.onHandlePackagesChanged(listAdapter, waitingToEnableWorkProfile); + } + @Override protected final boolean rebuildTab(ChooserListAdapter listAdapter, boolean doPostProcessing) { if (doPostProcessing) { diff --git a/java/src/com/android/intentresolver/v2/MultiProfilePagerAdapter.java b/java/src/com/android/intentresolver/v2/MultiProfilePagerAdapter.java index a600d4ad..6d1870bf 100644 --- a/java/src/com/android/intentresolver/v2/MultiProfilePagerAdapter.java +++ b/java/src/com/android/intentresolver/v2/MultiProfilePagerAdapter.java @@ -353,6 +353,66 @@ public class MultiProfilePagerAdapter< return getListViewForIndex(1 - getCurrentPage()); } + private boolean anyAdapterHasItems() { + for (int i = 0; i < mItems.size(); ++i) { + ListAdapterT listAdapter = mListAdapterExtractor.apply(getAdapterForIndex(i)); + if (listAdapter.getCount() > 0) { + return true; + } + } + return false; + } + + public void refreshPackagesInAllTabs() { + // TODO: handle all inactive profiles; for now we can only have at most one. It's unclear if + // this legacy logic really requires the active tab to be rebuilt first, or if we could just + // iterate over the tabs in arbitrary order. + getActiveListAdapter().handlePackagesChanged(); + if (getCount() > 1) { + getInactiveListAdapter().handlePackagesChanged(); + } + } + + /** + * Notify that there has been a package change which could potentially modify the set of targets + * that should be shown in the specified {@code listAdapter}. This may result in + * "rebuilding" the target list for that adapter. + * + * @param listAdapter an adapter that may need to be updated after the package-change event. + * @param waitingToEnableWorkProfile whether we've turned on the work profile, but haven't yet + * seen an {@code ACTION_USER_UNLOCKED} broadcast. In this case we skip the rebuild of any + * work-profile adapter because we wouldn't expect meaningful results -- but another rebuild + * will be prompted when we eventually get the broadcast. + * + * @return whether we're able to proceed with a Sharesheet session after processing this + * package-change event. If false, we were able to rebuild the targets but determined that there + * aren't any we could present in the UI without the app looking broken, so we should just quit. + */ + public boolean onHandlePackagesChanged( + ListAdapterT listAdapter, boolean waitingToEnableWorkProfile) { + if (listAdapter == getActiveListAdapter()) { + if (listAdapter.getUserHandle().equals(mWorkProfileUserHandle) + && waitingToEnableWorkProfile) { + // We have just turned on the work profile and entered the passcode to start it, + // now we are waiting to receive the ACTION_USER_UNLOCKED broadcast. There is no + // point in reloading the list now, since the work profile user is still turning on. + return true; + } + + boolean listRebuilt = rebuildActiveTab(true); + if (listRebuilt) { + listAdapter.notifyDataSetChanged(); + } + + // TODO: shouldn't we check that the inactive tabs are built before declaring that we + // have to quit for lack of items? + return anyAdapterHasItems(); + } else { + clearInactiveProfileCache(); + return true; + } + } + /** * Rebuilds the tab that is currently visible to the user. *

Returns {@code true} if rebuild has completed. diff --git a/java/src/com/android/intentresolver/v2/ResolverActivity.java b/java/src/com/android/intentresolver/v2/ResolverActivity.java index a7f2047d..dec68649 100644 --- a/java/src/com/android/intentresolver/v2/ResolverActivity.java +++ b/java/src/com/android/intentresolver/v2/ResolverActivity.java @@ -945,29 +945,12 @@ public class ResolverActivity extends FragmentActivity implements } @Override // ResolverListCommunicator - public void onHandlePackagesChanged(ResolverListAdapter listAdapter) { - if (listAdapter == mMultiProfilePagerAdapter.getActiveListAdapter()) { - if (listAdapter.getUserHandle().equals( - requireAnnotatedUserHandles().workProfileUserHandle) - && mLogic.getWorkProfileAvailabilityManager().isWaitingToEnableWorkProfile()) { - // We have just turned on the work profile and entered the pass code to start it, - // now we are waiting to receive the ACTION_USER_UNLOCKED broadcast. There is no - // point in reloading the list now, since the work profile user is still - // turning on. - return; - } - boolean listRebuilt = mMultiProfilePagerAdapter.rebuildActiveTab(true); - if (listRebuilt) { - ResolverListAdapter activeListAdapter = - mMultiProfilePagerAdapter.getActiveListAdapter(); - activeListAdapter.notifyDataSetChanged(); - if (activeListAdapter.getCount() == 0 && !inactiveListAdapterHasItems()) { - // We no longer have any items... just finish the activity. - finish(); - } - } - } else { - mMultiProfilePagerAdapter.clearInactiveProfileCache(); + public final void onHandlePackagesChanged(ResolverListAdapter listAdapter) { + if (!mMultiProfilePagerAdapter.onHandlePackagesChanged( + listAdapter, + mLogic.getWorkProfileAvailabilityManager().isWaitingToEnableWorkProfile())) { + // We no longer have any items... just finish the activity. + finish(); } } @@ -2105,13 +2088,6 @@ public class ResolverActivity extends FragmentActivity implements mRetainInOnStop = retainInOnStop; } - private boolean inactiveListAdapterHasItems() { - if (!shouldShowTabs()) { - return false; - } - return mMultiProfilePagerAdapter.getInactiveListAdapter().getCount() > 0; - } - final class ItemClickListener implements AdapterView.OnItemClickListener, AdapterView.OnItemLongClickListener { @Override -- cgit v1.2.3-59-g8ed1b From 9c998ba53284fc1cc2c035c298d5c539c56080ac Mon Sep 17 00:00:00 2001 From: Mark Renouf Date: Tue, 12 Dec 2023 13:37:37 -0500 Subject: Extracts ActionTitle from ResolverActivity This has a small, well defined interface and is used in both Resolver and Chooser. Bug: 300157408 Test: atest com.android.intentresolver Change-Id: Ifc7518c91dc2c0b03760492e51ccd84fa7dea079 --- .../intentresolver/v2/ResolverActivity.java | 68 +---------------- .../android/intentresolver/v2/ui/ActionTitle.java | 89 ++++++++++++++++++++++ 2 files changed, 90 insertions(+), 67 deletions(-) create mode 100644 java/src/com/android/intentresolver/v2/ui/ActionTitle.java (limited to 'java/src') diff --git a/java/src/com/android/intentresolver/v2/ResolverActivity.java b/java/src/com/android/intentresolver/v2/ResolverActivity.java index 39a4752f..f3b596b2 100644 --- a/java/src/com/android/intentresolver/v2/ResolverActivity.java +++ b/java/src/com/android/intentresolver/v2/ResolverActivity.java @@ -67,7 +67,6 @@ import android.os.StrictMode; import android.os.Trace; import android.os.UserHandle; import android.os.UserManager; -import android.provider.MediaStore; import android.provider.Settings; import android.stats.devicepolicy.DevicePolicyEnums; import android.text.TextUtils; @@ -93,7 +92,6 @@ import android.widget.TabWidget; import android.widget.TextView; import android.widget.Toast; -import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.annotation.StringRes; import androidx.annotation.UiThread; @@ -120,6 +118,7 @@ import com.android.intentresolver.v2.emptystate.NoAppsAvailableEmptyStateProvide import com.android.intentresolver.v2.emptystate.NoCrossProfileEmptyStateProvider; import com.android.intentresolver.v2.emptystate.NoCrossProfileEmptyStateProvider.DevicePolicyBlockerEmptyState; import com.android.intentresolver.v2.emptystate.WorkProfilePausedEmptyStateProvider; +import com.android.intentresolver.v2.ui.ActionTitle; import com.android.intentresolver.widget.ResolverDrawerLayout; import com.android.internal.annotations.VisibleForTesting; import com.android.internal.content.PackageMonitor; @@ -132,12 +131,9 @@ import kotlin.Unit; import java.util.ArrayList; import java.util.Arrays; import java.util.Iterator; -import java.util.LinkedList; import java.util.List; import java.util.Objects; -import java.util.Queue; import java.util.Set; -import java.util.function.Consumer; /** * This is a copy of ResolverActivity to support IntentResolver's ChooserActivity. This code is @@ -233,68 +229,6 @@ public class ResolverActivity extends FragmentActivity implements protected final LatencyTracker mLatencyTracker = getLatencyTracker(); - private enum ActionTitle { - VIEW(Intent.ACTION_VIEW, - R.string.whichViewApplication, - R.string.whichViewApplicationNamed, - R.string.whichViewApplicationLabel), - EDIT(Intent.ACTION_EDIT, - R.string.whichEditApplication, - R.string.whichEditApplicationNamed, - R.string.whichEditApplicationLabel), - SEND(Intent.ACTION_SEND, - R.string.whichSendApplication, - R.string.whichSendApplicationNamed, - R.string.whichSendApplicationLabel), - SENDTO(Intent.ACTION_SENDTO, - R.string.whichSendToApplication, - R.string.whichSendToApplicationNamed, - R.string.whichSendToApplicationLabel), - SEND_MULTIPLE(Intent.ACTION_SEND_MULTIPLE, - R.string.whichSendApplication, - R.string.whichSendApplicationNamed, - R.string.whichSendApplicationLabel), - CAPTURE_IMAGE(MediaStore.ACTION_IMAGE_CAPTURE, - R.string.whichImageCaptureApplication, - R.string.whichImageCaptureApplicationNamed, - R.string.whichImageCaptureApplicationLabel), - DEFAULT(null, - R.string.whichApplication, - R.string.whichApplicationNamed, - R.string.whichApplicationLabel), - HOME(Intent.ACTION_MAIN, - R.string.whichHomeApplication, - R.string.whichHomeApplicationNamed, - R.string.whichHomeApplicationLabel); - - // titles for layout that deals with http(s) intents - public static final int BROWSABLE_TITLE_RES = R.string.whichOpenLinksWith; - public static final int BROWSABLE_HOST_TITLE_RES = R.string.whichOpenHostLinksWith; - public static final int BROWSABLE_HOST_APP_TITLE_RES = R.string.whichOpenHostLinksWithApp; - public static final int BROWSABLE_APP_TITLE_RES = R.string.whichOpenLinksWithApp; - - public final String action; - public final int titleRes; - public final int namedTitleRes; - public final @StringRes int labelRes; - - ActionTitle(String action, int titleRes, int namedTitleRes, @StringRes int labelRes) { - this.action = action; - this.titleRes = titleRes; - this.namedTitleRes = namedTitleRes; - this.labelRes = labelRes; - } - - public static ActionTitle forAction(String action) { - for (ActionTitle title : values()) { - if (title != HOME && action != null && action.equals(title.action)) { - return title; - } - } - return DEFAULT; - } - } - protected PackageMonitor createPackageMonitor(ResolverListAdapter listAdapter) { return new PackageMonitor() { @Override diff --git a/java/src/com/android/intentresolver/v2/ui/ActionTitle.java b/java/src/com/android/intentresolver/v2/ui/ActionTitle.java new file mode 100644 index 00000000..271c6f38 --- /dev/null +++ b/java/src/com/android/intentresolver/v2/ui/ActionTitle.java @@ -0,0 +1,89 @@ +/* + * Copyright (C) 2023 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.android.intentresolver.v2.ui; + +import android.content.Intent; +import android.provider.MediaStore; + +import androidx.annotation.StringRes; + +import com.android.intentresolver.R; +import com.android.intentresolver.v2.ResolverActivity; + +/** + * Provides a set of related resources for different use cases. + */ +public enum ActionTitle { + VIEW(Intent.ACTION_VIEW, + R.string.whichViewApplication, + R.string.whichViewApplicationNamed, + R.string.whichViewApplicationLabel), + EDIT(Intent.ACTION_EDIT, + R.string.whichEditApplication, + R.string.whichEditApplicationNamed, + R.string.whichEditApplicationLabel), + SEND(Intent.ACTION_SEND, + R.string.whichSendApplication, + R.string.whichSendApplicationNamed, + R.string.whichSendApplicationLabel), + SENDTO(Intent.ACTION_SENDTO, + R.string.whichSendToApplication, + R.string.whichSendToApplicationNamed, + R.string.whichSendToApplicationLabel), + SEND_MULTIPLE(Intent.ACTION_SEND_MULTIPLE, + R.string.whichSendApplication, + R.string.whichSendApplicationNamed, + R.string.whichSendApplicationLabel), + CAPTURE_IMAGE(MediaStore.ACTION_IMAGE_CAPTURE, + R.string.whichImageCaptureApplication, + R.string.whichImageCaptureApplicationNamed, + R.string.whichImageCaptureApplicationLabel), + DEFAULT(null, + R.string.whichApplication, + R.string.whichApplicationNamed, + R.string.whichApplicationLabel), + HOME(Intent.ACTION_MAIN, + R.string.whichHomeApplication, + R.string.whichHomeApplicationNamed, + R.string.whichHomeApplicationLabel); + + // titles for layout that deals with http(s) intents + public static final int BROWSABLE_TITLE_RES = R.string.whichOpenLinksWith; + public static final int BROWSABLE_HOST_TITLE_RES = R.string.whichOpenHostLinksWith; + public static final int BROWSABLE_HOST_APP_TITLE_RES = R.string.whichOpenHostLinksWithApp; + public static final int BROWSABLE_APP_TITLE_RES = R.string.whichOpenLinksWithApp; + + public final String action; + public final int titleRes; + public final int namedTitleRes; + public final @StringRes int labelRes; + + ActionTitle(String action, int titleRes, int namedTitleRes, @StringRes int labelRes) { + this.action = action; + this.titleRes = titleRes; + this.namedTitleRes = namedTitleRes; + this.labelRes = labelRes; + } + + public static ActionTitle forAction(String action) { + for (ActionTitle title : values()) { + if (title != HOME && action != null && action.equals(title.action)) { + return title; + } + } + return DEFAULT; + } +} -- cgit v1.2.3-59-g8ed1b From 1f1556997629de4575d9ddeb1c30a4510e6ff70c Mon Sep 17 00:00:00 2001 From: Mark Renouf Date: Tue, 12 Dec 2023 16:34:26 -0500 Subject: Extracts some DevicePolicyManager resources Moves some resources to a repository. These are resource strings which are customizable with a device policy manager. Bug: 300157408 Test: atest com.android.intentresolver Change-Id: I232ef93cf22fe6146ae1c6aa853e66714c961e3b --- .../intentresolver/v2/ResolverActivity.java | 54 ++++------------- .../v2/data/repository/DevicePolicyResources.kt | 68 ++++++++++++++++++++++ 2 files changed, 80 insertions(+), 42 deletions(-) create mode 100644 java/src/com/android/intentresolver/v2/data/repository/DevicePolicyResources.kt (limited to 'java/src') diff --git a/java/src/com/android/intentresolver/v2/ResolverActivity.java b/java/src/com/android/intentresolver/v2/ResolverActivity.java index f3b596b2..64dda6f2 100644 --- a/java/src/com/android/intentresolver/v2/ResolverActivity.java +++ b/java/src/com/android/intentresolver/v2/ResolverActivity.java @@ -20,11 +20,6 @@ import static android.Manifest.permission.INTERACT_ACROSS_PROFILES; import static android.app.admin.DevicePolicyResources.Strings.Core.RESOLVER_CANT_ACCESS_PERSONAL; import static android.app.admin.DevicePolicyResources.Strings.Core.RESOLVER_CANT_ACCESS_WORK; import static android.app.admin.DevicePolicyResources.Strings.Core.RESOLVER_CROSS_PROFILE_BLOCKED_TITLE; -import static android.app.admin.DevicePolicyResources.Strings.Core.RESOLVER_PERSONAL_TAB; -import static android.app.admin.DevicePolicyResources.Strings.Core.RESOLVER_PERSONAL_TAB_ACCESSIBILITY; -import static android.app.admin.DevicePolicyResources.Strings.Core.RESOLVER_WORK_PROFILE_NOT_SUPPORTED; -import static android.app.admin.DevicePolicyResources.Strings.Core.RESOLVER_WORK_TAB; -import static android.app.admin.DevicePolicyResources.Strings.Core.RESOLVER_WORK_TAB_ACCESSIBILITY; import static android.content.Intent.FLAG_ACTIVITY_NEW_TASK; import static android.content.PermissionChecker.PID_UNKNOWN; import static android.stats.devicepolicy.nano.DevicePolicyEnums.RESOLVER_EMPTY_STATE_NO_SHARING_TO_PERSONAL; @@ -114,6 +109,7 @@ import com.android.intentresolver.model.ResolverRankerServiceResolverComparator; import com.android.intentresolver.v2.MultiProfilePagerAdapter.MyUserIdProvider; import com.android.intentresolver.v2.MultiProfilePagerAdapter.OnSwitchOnWorkSelectedListener; import com.android.intentresolver.v2.MultiProfilePagerAdapter.Profile; +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; @@ -149,6 +145,8 @@ public class ResolverActivity extends FragmentActivity implements protected ActivityLogic mLogic; + private DevicePolicyResources mDevicePolicyResources; + public ResolverActivity() { mIsIntentPicker = getClass().equals(ResolverActivity.class); } @@ -265,6 +263,8 @@ public class ResolverActivity extends FragmentActivity implements // Skip initializing anything. return; } + mDevicePolicyResources = new DevicePolicyResources(getApplication().getResources(), + requireNonNull(getSystemService(DevicePolicyManager.class))); setLogic(new ResolverActivityLogic( TAG, () -> this, @@ -561,9 +561,9 @@ public class ResolverActivity extends FragmentActivity implements ResolveInfo ri = mMultiProfilePagerAdapter.getActiveListAdapter() .resolveInfoForPosition(which, hasIndexBeenFiltered); if (mLogic.getResolvingHome() && hasManagedProfile() && !supportsManagedProfiles(ri)) { + String launcherName = ri.activityInfo.loadLabel(getPackageManager()).toString(); Toast.makeText(this, - getWorkProfileNotSupportedMsg( - ri.activityInfo.loadLabel(getPackageManager()).toString()), + mDevicePolicyResources.getWorkProfileNotSupportedMessage(launcherName), Toast.LENGTH_LONG).show(); return; } @@ -1401,15 +1401,6 @@ public class ResolverActivity extends FragmentActivity implements mAlwaysButton.setEnabled(enabled); } - private String getWorkProfileNotSupportedMsg(String launcherName) { - return getSystemService(DevicePolicyManager.class).getResources().getString( - RESOLVER_WORK_PROFILE_NOT_SUPPORTED, - () -> getString( - R.string.activity_resolver_work_profiles_support, - launcherName), - launcherName); - } - @Override // ResolverListCommunicator public final void onPostListReady(ResolverListAdapter listAdapter, boolean doPostProcessing, boolean rebuildCompleted) { @@ -1811,8 +1802,9 @@ public class ResolverActivity extends FragmentActivity implements Button personalButton = (Button) getLayoutInflater().inflate( R.layout.resolver_profile_tab_button, tabHost.getTabWidget(), false); - personalButton.setText(getPersonalTabLabel()); - personalButton.setContentDescription(getPersonalTabAccessibilityLabel()); + personalButton.setText(mDevicePolicyResources.getPersonalTabLabel()); + personalButton.setContentDescription( + mDevicePolicyResources.getPersonalTabAccessibilityLabel()); TabHost.TabSpec tabSpec = tabHost.newTabSpec(TAB_TAG_PERSONAL) .setContent(com.android.internal.R.id.profile_pager) @@ -1821,8 +1813,8 @@ public class ResolverActivity extends FragmentActivity implements Button workButton = (Button) getLayoutInflater().inflate( R.layout.resolver_profile_tab_button, tabHost.getTabWidget(), false); - workButton.setText(getWorkTabLabel()); - workButton.setContentDescription(getWorkTabAccessibilityLabel()); + workButton.setText(mDevicePolicyResources.getWorkTabLabel()); + workButton.setContentDescription(mDevicePolicyResources.getWorkTabAccessibilityLabel()); tabSpec = tabHost.newTabSpec(TAB_TAG_WORK) .setContent(com.android.internal.R.id.profile_pager) @@ -1874,16 +1866,6 @@ public class ResolverActivity extends FragmentActivity implements }; } - private String getPersonalTabLabel() { - return getSystemService(DevicePolicyManager.class).getResources().getString( - RESOLVER_PERSONAL_TAB, () -> getString(R.string.resolver_personal_tab)); - } - - private String getWorkTabLabel() { - return getSystemService(DevicePolicyManager.class).getResources().getString( - RESOLVER_WORK_TAB, () -> getString(R.string.resolver_work_tab)); - } - private void maybeHideDivider() { if (!mIsIntentPicker) { return; @@ -1906,18 +1888,6 @@ public class ResolverActivity extends FragmentActivity implements } } - private String getPersonalTabAccessibilityLabel() { - return getSystemService(DevicePolicyManager.class).getResources().getString( - RESOLVER_PERSONAL_TAB_ACCESSIBILITY, - () -> getString(R.string.resolver_personal_tab_accessibility)); - } - - private String getWorkTabAccessibilityLabel() { - return getSystemService(DevicePolicyManager.class).getResources().getString( - RESOLVER_WORK_TAB_ACCESSIBILITY, - () -> getString(R.string.resolver_work_tab_accessibility)); - } - private static int getAttrColor(Context context, int attr) { TypedArray ta = context.obtainStyledAttributes(new int[]{attr}); int colorAccent = ta.getColor(0, 0); diff --git a/java/src/com/android/intentresolver/v2/data/repository/DevicePolicyResources.kt b/java/src/com/android/intentresolver/v2/data/repository/DevicePolicyResources.kt new file mode 100644 index 00000000..7debdf07 --- /dev/null +++ b/java/src/com/android/intentresolver/v2/data/repository/DevicePolicyResources.kt @@ -0,0 +1,68 @@ +/* + * Copyright (C) 2023 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.android.intentresolver.v2.data.repository + +import android.app.admin.DevicePolicyManager +import android.app.admin.DevicePolicyResources.Strings.Core.RESOLVER_PERSONAL_TAB +import android.app.admin.DevicePolicyResources.Strings.Core.RESOLVER_PERSONAL_TAB_ACCESSIBILITY +import android.app.admin.DevicePolicyResources.Strings.Core.RESOLVER_WORK_PROFILE_NOT_SUPPORTED +import android.app.admin.DevicePolicyResources.Strings.Core.RESOLVER_WORK_TAB +import android.app.admin.DevicePolicyResources.Strings.Core.RESOLVER_WORK_TAB_ACCESSIBILITY +import android.content.res.Resources +import com.android.intentresolver.R +import com.android.intentresolver.inject.ApplicationOwned +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class DevicePolicyResources @Inject constructor( + @ApplicationOwned private val resources: Resources, + devicePolicyManager: DevicePolicyManager +) { + private val policyResources = devicePolicyManager.resources + + val personalTabLabel by lazy { + requireNotNull(policyResources.getString(RESOLVER_PERSONAL_TAB) { + resources.getString(R.string.resolver_personal_tab) + }) + } + + val workTabLabel by lazy { + requireNotNull(policyResources.getString(RESOLVER_WORK_TAB) { + resources.getString(R.string.resolver_work_tab) + }) + } + + val personalTabAccessibilityLabel by lazy { + requireNotNull(policyResources.getString(RESOLVER_PERSONAL_TAB_ACCESSIBILITY) { + resources.getString(R.string.resolver_personal_tab_accessibility) + }) + } + + val workTabAccessibilityLabel by lazy { + requireNotNull(policyResources.getString(RESOLVER_WORK_TAB_ACCESSIBILITY) { + resources.getString(R.string.resolver_work_tab_accessibility) + }) + } + + fun getWorkProfileNotSupportedMessage(launcherName: String): String { + return requireNotNull(policyResources.getString(RESOLVER_WORK_PROFILE_NOT_SUPPORTED, { + resources.getString( + R.string.activity_resolver_work_profiles_support, + launcherName) + }, launcherName)) + } +} \ No newline at end of file -- cgit v1.2.3-59-g8ed1b From fe8ffdee714f073d729a43a3601bd56f711201b3 Mon Sep 17 00:00:00 2001 From: mrenouf Date: Wed, 13 Dec 2023 14:33:30 -0500 Subject: Fix crash in onRestoreInstanceState This fixes the startup sequence to initialize dependencies before restoring saved state (last selected tab). Bug: 316178537 Test: manual, open chooser, rotate the device Change-Id: I6e7d32e4625b91a6f2da40c74c1a80a7057710b1 --- .../intentresolver/v2/ResolverActivity.java | 23 +++++++++++----------- 1 file changed, 11 insertions(+), 12 deletions(-) (limited to 'java/src') diff --git a/java/src/com/android/intentresolver/v2/ResolverActivity.java b/java/src/com/android/intentresolver/v2/ResolverActivity.java index 64dda6f2..f65ae5a9 100644 --- a/java/src/com/android/intentresolver/v2/ResolverActivity.java +++ b/java/src/com/android/intentresolver/v2/ResolverActivity.java @@ -87,6 +87,7 @@ import android.widget.TabWidget; import android.widget.TextView; import android.widget.Toast; +import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.annotation.StringRes; import androidx.annotation.UiThread; @@ -276,6 +277,15 @@ public class ResolverActivity extends FragmentActivity implements protected final void onPostCreate(@Nullable Bundle savedInstanceState) { super.onPostCreate(savedInstanceState); mInit.forEach(Runnable::run); + + if (savedInstanceState != null) { + resetButtonBar(); + ViewPager viewPager = findViewById(com.android.internal.R.id.profile_pager); + if (viewPager != null) { + viewPager.setCurrentItem(savedInstanceState.getInt(LAST_SHOWN_TAB_KEY)); + } + mMultiProfilePagerAdapter.clearInactiveProfileCache(); + } } private void init() { @@ -1301,7 +1311,7 @@ public class ResolverActivity extends FragmentActivity implements } @Override - protected final void onSaveInstanceState(Bundle outState) { + protected final void onSaveInstanceState(@NonNull Bundle outState) { super.onSaveInstanceState(outState); ViewPager viewPager = findViewById(com.android.internal.R.id.profile_pager); if (viewPager != null) { @@ -1309,17 +1319,6 @@ public class ResolverActivity extends FragmentActivity implements } } - @Override - protected final void onRestoreInstanceState(Bundle savedInstanceState) { - super.onRestoreInstanceState(savedInstanceState); - resetButtonBar(); - ViewPager viewPager = findViewById(com.android.internal.R.id.profile_pager); - if (viewPager != null) { - viewPager.setCurrentItem(savedInstanceState.getInt(LAST_SHOWN_TAB_KEY)); - } - mMultiProfilePagerAdapter.clearInactiveProfileCache(); - } - private boolean hasManagedProfile() { UserManager userManager = (UserManager) getSystemService(Context.USER_SERVICE); if (userManager == null) { -- cgit v1.2.3-59-g8ed1b From 3007d9f481e92ed57ca9e3783719b3d84797ef2c Mon Sep 17 00:00:00 2001 From: Joshua Trask Date: Fri, 8 Dec 2023 20:25:14 +0000 Subject: Make "personal+work" conditions explicit Historically sometimes we assume that "having tabs" means we have these two particular tabs, or that the "inactive tab" is the work profile when the personal profile is "active" and vice versa. These assumptions won't hold in the future, so this CL switches such uses to refer explicitly to the personal/work configuration they were built for. There are no behavior changes included in this CL. These changes are as prototyped in ag/25335069 and described in go/chooser-ntab-refactoring, in particular the changes from "snapshot 10" to "snapshot 15" (skipping #14 which will depend on some other changes in the series). See below for a "by-snapshot" breakdown of the incremental changes composed in this CL. Snapshot 1: Switch "mini-resolver" to use explicit personal/work configurations. The legacy `ResolverActivityTest.testMiniResolver` covers the basic use case (e.g. failing if `shouldUseMiniResolver()` switches up which tab is considered "active" vs. "inactive"). Snapshot 2: Move remaining cross-profile autolaunch conditions over as guard clauses in `maybeAutolaunchIfCrossProfileSupported()`. Now that we have more explicit requirements about *which* profiles we're talking about, it's easier to refer to them all in one place -- the other partitioning was going to get a little clumsy. This change is minimally tested, e.g. having the cross-profile "maybe autolaunch" method always return true (->autolaunch) causes test failures in both IntentResolver-tests-activity and the legacy ResolverActivityTests. Snapshot 3: Specify that cross-profile autolaunch only applies in the specific "two-tab personal-and-work profiles" case. This doesn't change any behavior for now, but makes it easy to adjust the legacy logic from "active and inactive" tabs to "work and personal" tabs (in the next snapshot). As in the previous snapshot this is minimally covered in tests; in particular, inverting the "two-page configuration" condition from this CL causes `ResolverActivityTest` to fail. Snapshot 4: Implement cross-profile autolaunch explicitly in terms of "personal" and "work" tabs, so we don't have to refer to an "inactive tab." Snapshot 5: Fix a few places where `ResolverActivity` was relying on the `shouldShowTabs()` condition when it really explicitly meant to refer to (i.e. inline) the `hasWorkProfile()` check. Bug: 310211468 Test: `ResolverActivityTest` & IntentResolver activity tests. Notes ^ Change-Id: I95e383e2822917198425acf9ba8bfbea76fdf948 --- .../v2/MultiProfilePagerAdapter.java | 17 ++++ .../intentresolver/v2/ResolverActivity.java | 94 +++++++++++++++------- 2 files changed, 83 insertions(+), 28 deletions(-) (limited to 'java/src') diff --git a/java/src/com/android/intentresolver/v2/MultiProfilePagerAdapter.java b/java/src/com/android/intentresolver/v2/MultiProfilePagerAdapter.java index f785c11c..2d9be816 100644 --- a/java/src/com/android/intentresolver/v2/MultiProfilePagerAdapter.java +++ b/java/src/com/android/intentresolver/v2/MultiProfilePagerAdapter.java @@ -204,6 +204,14 @@ public class MultiProfilePagerAdapter< return mCurrentPage; } + public final @Profile int getActiveProfile() { + // TODO: here and elsewhere in this class, distinguish between a "profile ID" integer and + // its mapped "page index." When we support more than two profiles, this won't be a "stable + // mapping" -- some particular profile may not be represented by a "page," but the ones that + // are will be assigned contiguous page numbers that skip over the holes. + return getCurrentPage(); + } + @VisibleForTesting public UserHandle getCurrentUserHandle() { return getActiveListAdapter().getUserHandle(); @@ -329,6 +337,15 @@ public class MultiProfilePagerAdapter< return mListAdapterExtractor.apply(getAdapterForIndex(PROFILE_PERSONAL)); } + /** @return whether our tab data contains a page for the specified {@code profile} ID. */ + public final boolean hasPageForProfile(@Profile int profile) { + // TODO: here and elsewhere in this class, distinguish between a "profile ID" integer and + // its mapped "page index." When we support more than two profiles, this won't be a "stable + // mapping" -- some particular profile may not be represented by a "page," but the ones that + // are will be assigned contiguous page numbers that skip over the holes. + return hasAdapterForIndex(profile); + } + @Nullable public final ListAdapterT getWorkListAdapter() { if (!hasAdapterForIndex(PROFILE_WORK)) { diff --git a/java/src/com/android/intentresolver/v2/ResolverActivity.java b/java/src/com/android/intentresolver/v2/ResolverActivity.java index 3d08735e..2ba50ec3 100644 --- a/java/src/com/android/intentresolver/v2/ResolverActivity.java +++ b/java/src/com/android/intentresolver/v2/ResolverActivity.java @@ -308,7 +308,7 @@ public class ResolverActivity extends FragmentActivity implements // of the last used choice to highlight it in the list. We need to always // turn this off when running under voice interaction, since it results in // a more complicated UI that the current voice interaction flow is not able - // to handle. We also turn it off when the work tab is shown to simplify the UX. + // to handle. We also turn it off when multiple tabs are shown to simplify the UX. // We also turn it off when clonedProfile is present on the device, because we might have // different "last chosen" activities in the different profiles, and PackageManager doesn't // provide any more information to help us select between them. @@ -332,7 +332,7 @@ public class ResolverActivity extends FragmentActivity implements requireAnnotatedUserHandles().personalProfileUserHandle, false ); - if (shouldShowTabs()) { + if (hasWorkProfile()) { mWorkPackageMonitor = createPackageMonitor( mMultiProfilePagerAdapter.getWorkListAdapter()); mWorkPackageMonitor.register( @@ -1276,7 +1276,7 @@ public class ResolverActivity extends FragmentActivity implements getMainLooper(), requireAnnotatedUserHandles().personalProfileUserHandle, false); - if (shouldShowTabs()) { + if (hasWorkProfile()) { if (mWorkPackageMonitor == null) { mWorkPackageMonitor = createPackageMonitor( mMultiProfilePagerAdapter.getWorkListAdapter()); @@ -1291,7 +1291,7 @@ public class ResolverActivity extends FragmentActivity implements } WorkProfileAvailabilityManager workProfileAvailabilityManager = mLogic.getWorkProfileAvailabilityManager(); - if (shouldShowTabs() && workProfileAvailabilityManager.isWaitingToEnableWorkProfile()) { + if (hasWorkProfile() && workProfileAvailabilityManager.isWaitingToEnableWorkProfile()) { if (workProfileAvailabilityManager.isQuietModeEnabled()) { workProfileAvailabilityManager.markWorkProfileEnabledBroadcastReceived(); } @@ -1305,7 +1305,7 @@ public class ResolverActivity extends FragmentActivity implements super.onStart(); this.getWindow().addSystemFlags(SYSTEM_FLAG_HIDE_NON_SYSTEM_OVERLAY_WINDOWS); - if (shouldShowTabs()) { + if (hasWorkProfile()) { mLogic.getWorkProfileAvailabilityManager().registerWorkProfileStateReceiver(this); } } @@ -1510,6 +1510,7 @@ public class ResolverActivity extends FragmentActivity implements Trace.beginSection("configureContentView"); // We partially rebuild the inactive adapter to determine if we should auto launch // isTabLoaded will be true here if the empty state screen is shown instead of the list. + // To date, we really only care about "partially rebuilding" tabs for work and/or personal. boolean rebuildCompleted = mMultiProfilePagerAdapter.rebuildTabs(shouldShowTabs()); if (shouldUseMiniResolver()) { @@ -1540,12 +1541,25 @@ public class ResolverActivity extends FragmentActivity implements mLayoutId = R.layout.miniresolver; setContentView(mLayoutId); - DisplayResolveInfo sameProfileResolveInfo = - mMultiProfilePagerAdapter.getActiveListAdapter().getFirstDisplayResolveInfo(); + // TODO: try to dedupe and use the pager's `getActiveProfile()` instead of the activity + // `getCurrentProfile()` (or align them if they're not currently equivalent). If they truly + // need to be distinct here, then `getCurrentProfile()` should at *least* get a more + // specific name -- but note that checking `getCurrentProfile()` here, then following + // `getActiveProfile()` to find the "in/active adapter," is exactly the legacy behavior. boolean inWorkProfile = getCurrentProfile() == PROFILE_WORK; - final ResolverListAdapter inactiveAdapter = - mMultiProfilePagerAdapter.getInactiveListAdapter(); + ResolverListAdapter sameProfileAdapter = + (mMultiProfilePagerAdapter.getActiveProfile() == PROFILE_PERSONAL) + ? mMultiProfilePagerAdapter.getPersonalListAdapter() + : mMultiProfilePagerAdapter.getWorkListAdapter(); + + ResolverListAdapter inactiveAdapter = + (mMultiProfilePagerAdapter.getActiveProfile() == PROFILE_PERSONAL) + ? mMultiProfilePagerAdapter.getWorkListAdapter() + : mMultiProfilePagerAdapter.getPersonalListAdapter(); + + DisplayResolveInfo sameProfileResolveInfo = sameProfileAdapter.getFirstDisplayResolveInfo(); + final DisplayResolveInfo otherProfileResolveInfo = inactiveAdapter.getFirstDisplayResolveInfo(); @@ -1584,24 +1598,36 @@ public class ResolverActivity extends FragmentActivity implements }); } + private boolean isTwoPagePersonalAndWorkConfiguration() { + return (mMultiProfilePagerAdapter.getCount() == 2) + && mMultiProfilePagerAdapter.hasPageForProfile(PROFILE_PERSONAL) + && mMultiProfilePagerAdapter.hasPageForProfile(PROFILE_WORK); + } + /** * Mini resolver should be used when all of the following are true: * 1. This is the intent picker (ResolverActivity). - * 2. This profile only has web browser matches. - * 3. The other profile has a single non-browser match. + * 2. There are exactly two tabs, for the "personal" and "work" profiles. + * 3. This profile only has web browser matches. + * 4. The other profile has a single non-browser match. */ private boolean shouldUseMiniResolver() { if (!mIsIntentPicker) { return false; } - if (mMultiProfilePagerAdapter.getActiveListAdapter() == null - || mMultiProfilePagerAdapter.getInactiveListAdapter() == null) { + if (!isTwoPagePersonalAndWorkConfiguration()) { return false; } + ResolverListAdapter sameProfileAdapter = - mMultiProfilePagerAdapter.getActiveListAdapter(); + (mMultiProfilePagerAdapter.getActiveProfile() == PROFILE_PERSONAL) + ? mMultiProfilePagerAdapter.getPersonalListAdapter() + : mMultiProfilePagerAdapter.getWorkListAdapter(); + ResolverListAdapter otherProfileAdapter = - mMultiProfilePagerAdapter.getInactiveListAdapter(); + (mMultiProfilePagerAdapter.getActiveProfile() == PROFILE_PERSONAL) + ? mMultiProfilePagerAdapter.getWorkListAdapter() + : mMultiProfilePagerAdapter.getPersonalListAdapter(); if (sameProfileAdapter.getDisplayResolveInfoCount() == 0) { Log.d(TAG, "No targets in the current profile"); @@ -1661,10 +1687,7 @@ public class ResolverActivity extends FragmentActivity implements int numberOfProfiles = mMultiProfilePagerAdapter.getItemCount(); if (numberOfProfiles == 1 && maybeAutolaunchIfSingleTarget()) { return true; - } else if (numberOfProfiles == 2 - && mMultiProfilePagerAdapter.getActiveListAdapter().isTabLoaded() - && mMultiProfilePagerAdapter.getInactiveListAdapter().isTabLoaded() - && maybeAutolaunchIfCrossProfileSupported()) { + } else if (maybeAutolaunchIfCrossProfileSupported()) { // TODO(b/280988288): If the ChooserActivity is shown we should consider showing the // correct intent-picker UIs (e.g., mini-resolver) if it was launched without // ACTION_SEND. @@ -1695,33 +1718,48 @@ public class ResolverActivity extends FragmentActivity implements } /** - * When we have a personal and a work profile, we auto launch in the following scenario: + * When we have just a personal and a work profile, we auto launch in the following scenario: * - There is 1 resolved target on each profile * - That target is the same app on both profiles * - The target app has permission to communicate cross profiles * - The target app has declared it supports cross-profile communication via manifest metadata */ private boolean maybeAutolaunchIfCrossProfileSupported() { - ResolverListAdapter activeListAdapter = mMultiProfilePagerAdapter.getActiveListAdapter(); - int count = activeListAdapter.getUnfilteredCount(); - if (count != 1) { + if (!isTwoPagePersonalAndWorkConfiguration()) { return false; } + + ResolverListAdapter activeListAdapter = + (mMultiProfilePagerAdapter.getActiveProfile() == PROFILE_PERSONAL) + ? mMultiProfilePagerAdapter.getPersonalListAdapter() + : mMultiProfilePagerAdapter.getWorkListAdapter(); + ResolverListAdapter inactiveListAdapter = - mMultiProfilePagerAdapter.getInactiveListAdapter(); - if (inactiveListAdapter.getUnfilteredCount() != 1) { + (mMultiProfilePagerAdapter.getActiveProfile() == PROFILE_PERSONAL) + ? mMultiProfilePagerAdapter.getWorkListAdapter() + : mMultiProfilePagerAdapter.getPersonalListAdapter(); + + if (!activeListAdapter.isTabLoaded() || !inactiveListAdapter.isTabLoaded()) { return false; } - TargetInfo activeProfileTarget = activeListAdapter - .targetInfoForPosition(0, false); + + if ((activeListAdapter.getUnfilteredCount() != 1) + || (inactiveListAdapter.getUnfilteredCount() != 1)) { + return false; + } + + TargetInfo activeProfileTarget = activeListAdapter.targetInfoForPosition(0, false); TargetInfo inactiveProfileTarget = inactiveListAdapter.targetInfoForPosition(0, false); - if (!Objects.equals(activeProfileTarget.getResolvedComponentName(), + if (!Objects.equals( + activeProfileTarget.getResolvedComponentName(), inactiveProfileTarget.getResolvedComponentName())) { return false; } + if (!shouldAutoLaunchSingleChoice(activeProfileTarget)) { return false; } + String packageName = activeProfileTarget.getResolvedComponentName().getPackageName(); if (!canAppInteractCrossProfiles(packageName)) { return false; -- cgit v1.2.3-59-g8ed1b