From 49b65f54be53ec48d53a550e783759100e8812dc Mon Sep 17 00:00:00 2001 From: Andrey Epin Date: Mon, 12 Dec 2022 09:17:29 -0800 Subject: Add Chooser custom actions Add Chooser custom action support under a compile-time flag. Bug: 262278109 Test: manual testing of the basic functionality Test: manual custom actions testing with a test app Test: atest IntentResolverUnitTests (with the both flag values) Change-Id: Ib6f6b46aa4f693a544e0e52a6d1a3e63ba57b162 --- .../android/intentresolver/ChooserActivity.java | 47 +++++++++++++++++++++- .../intentresolver/ChooserContentPreviewUi.java | 27 +++++++++++-- .../intentresolver/ChooserRequestParameters.java | 23 ++++++++++- 3 files changed, 91 insertions(+), 6 deletions(-) (limited to 'java/src') diff --git a/java/src/com/android/intentresolver/ChooserActivity.java b/java/src/com/android/intentresolver/ChooserActivity.java index ceab62b2..55904fc1 100644 --- a/java/src/com/android/intentresolver/ChooserActivity.java +++ b/java/src/com/android/intentresolver/ChooserActivity.java @@ -32,6 +32,7 @@ import android.annotation.Nullable; import android.app.Activity; import android.app.ActivityManager; import android.app.ActivityOptions; +import android.app.PendingIntent; import android.app.prediction.AppPredictor; import android.app.prediction.AppTarget; import android.app.prediction.AppTargetEvent; @@ -70,6 +71,7 @@ import android.os.UserManager; import android.os.storage.StorageManager; import android.provider.DeviceConfig; import android.provider.Settings; +import android.service.chooser.ChooserAction; import android.service.chooser.ChooserTarget; import android.text.TextUtils; import android.util.Log; @@ -112,6 +114,8 @@ import com.android.internal.content.PackageMonitor; import com.android.internal.logging.nano.MetricsProto.MetricsEvent; import com.android.internal.util.FrameworkStatsLog; +import com.google.common.collect.ImmutableList; + import java.io.File; import java.io.IOException; import java.lang.annotation.Retention; @@ -158,6 +162,7 @@ public class ChooserActivity extends ResolverActivity implements private static final String CHIP_ICON_METADATA_KEY = "android.service.chooser.chip_icon"; private static final boolean DEBUG = true; + static final boolean ENABLE_CUSTOM_ACTIONS = false; public static final String LAUNCH_LOCATION_DIRECT_SHARE = "direct_share"; private static final String SHORTCUT_TARGET = "shortcut_target"; @@ -265,7 +270,7 @@ public class ChooserActivity extends ResolverActivity implements try { mChooserRequest = new ChooserRequestParameters( - getIntent(), getReferrer(), getNearbySharingComponent()); + getIntent(), getReferrer(), getNearbySharingComponent(), ENABLE_CUSTOM_ACTIONS); } catch (IllegalArgumentException e) { Log.e(TAG, "Caller provided invalid Chooser request parameters", e); finish(); @@ -732,6 +737,20 @@ public class ChooserActivity extends ResolverActivity implements public ActionRow.Action createNearbyButton() { return ChooserActivity.this.createNearbyAction(targetIntent); } + + @Override + public List createCustomActions() { + ImmutableList customActions = + mChooserRequest.getChooserActions(); + List actions = new ArrayList<>(customActions.size()); + for (ChooserAction customAction : customActions) { + ActionRow.Action action = createCustomAction(customAction); + if (action != null) { + actions.add(action); + } + } + return actions; + } }; ViewGroup layout = ChooserContentPreviewUi.displayContentPreview( @@ -740,7 +759,9 @@ public class ChooserActivity extends ResolverActivity implements getResources(), getLayoutInflater(), actionFactory, - R.layout.chooser_action_row, + ENABLE_CUSTOM_ACTIONS + ? R.layout.scrollable_chooser_action_row + : R.layout.chooser_action_row, parent, previewCoordinator, mEnterTransitionAnimationDelegate::markImagePreviewReady, @@ -927,6 +948,28 @@ public class ChooserActivity extends ResolverActivity implements ); } + @Nullable + private ActionRow.Action createCustomAction(ChooserAction action) { + Drawable icon = action.getIcon().loadDrawable(this); + if (icon == null && TextUtils.isEmpty(action.getLabel())) { + return null; + } + return new ActionRow.Action( + action.getLabel(), + icon, + () -> { + try { + action.getAction().send(); + } catch (PendingIntent.CanceledException e) { + Log.d(TAG, "Custom action, " + action.getLabel() + ", has been cancelled"); + } + // TODO: add reporting + setResult(RESULT_OK); + finish(); + } + ); + } + @Nullable private View getFirstVisibleImgPreviewView() { View firstImage = findViewById(com.android.internal.R.id.content_preview_image_1_large); diff --git a/java/src/com/android/intentresolver/ChooserContentPreviewUi.java b/java/src/com/android/intentresolver/ChooserContentPreviewUi.java index ff88e5e1..daded28b 100644 --- a/java/src/com/android/intentresolver/ChooserContentPreviewUi.java +++ b/java/src/com/android/intentresolver/ChooserContentPreviewUi.java @@ -102,6 +102,9 @@ public final class ChooserContentPreviewUi { /** Create an "Share to Nearby" action. */ @Nullable ActionRow.Action createNearbyButton(); + + /** Create custom actions */ + List createCustomActions(); } /** @@ -187,12 +190,15 @@ public final class ChooserContentPreviewUi { ImageMimeTypeClassifier imageClassifier) { ViewGroup layout = null; + List customActions = actionFactory.createCustomActions(); switch (previewType) { case CONTENT_PREVIEW_TEXT: layout = displayTextContentPreview( targetIntent, layoutInflater, - createTextPreviewActions(actionFactory), + createActions( + createTextPreviewActions(actionFactory), + customActions), parent, previewCoord, actionRowLayout); @@ -201,7 +207,9 @@ public final class ChooserContentPreviewUi { layout = displayImageContentPreview( targetIntent, layoutInflater, - createImagePreviewActions(actionFactory), + createActions( + createImagePreviewActions(actionFactory), + customActions), parent, previewCoord, onTransitionTargetReady, @@ -214,7 +222,9 @@ public final class ChooserContentPreviewUi { targetIntent, resources, layoutInflater, - createFilePreviewActions(actionFactory), + createActions( + createFilePreviewActions(actionFactory), + customActions), parent, previewCoord, contentResolver, @@ -227,6 +237,17 @@ public final class ChooserContentPreviewUi { return layout; } + private static List createActions( + List systemActions, List customActions) { + ArrayList actions = + new ArrayList<>(systemActions.size() + customActions.size()); + actions.addAll(systemActions); + if (ChooserActivity.ENABLE_CUSTOM_ACTIONS) { + actions.addAll(customActions); + } + return actions; + } + private static Cursor queryResolver(ContentResolver resolver, Uri uri) { return resolver.query(uri, null, null, null, null); } diff --git a/java/src/com/android/intentresolver/ChooserRequestParameters.java b/java/src/com/android/intentresolver/ChooserRequestParameters.java index 81481bf1..a7e543a5 100644 --- a/java/src/com/android/intentresolver/ChooserRequestParameters.java +++ b/java/src/com/android/intentresolver/ChooserRequestParameters.java @@ -26,6 +26,7 @@ import android.net.Uri; import android.os.Bundle; import android.os.Parcelable; import android.os.PatternMatcher; +import android.service.chooser.ChooserAction; import android.service.chooser.ChooserTarget; import android.text.TextUtils; import android.util.Log; @@ -70,6 +71,7 @@ public class ChooserRequestParameters { private final Intent mReferrerFillInIntent; private final ImmutableList mFilteredComponentNames; private final ImmutableList mCallerChooserTargets; + private final ImmutableList mChooserActions; private final boolean mRetainInOnStop; @Nullable @@ -96,7 +98,8 @@ public class ChooserRequestParameters { public ChooserRequestParameters( final Intent clientIntent, final Uri referrer, - @Nullable final ComponentName nearbySharingComponent) { + @Nullable final ComponentName nearbySharingComponent, + boolean extractCustomActions) { final Intent requestedTarget = parseTargetIntentExtra( clientIntent.getParcelableExtra(Intent.EXTRA_INTENT)); mTarget = intentWithModifiedLaunchFlags(requestedTarget); @@ -130,6 +133,10 @@ public class ChooserRequestParameters { mSharedText = mTarget.getStringExtra(Intent.EXTRA_TEXT); mTargetIntentFilter = getTargetIntentFilter(mTarget); + + mChooserActions = extractCustomActions + ? getChooserActions(clientIntent) + : ImmutableList.of(); } public Intent getTargetIntent() { @@ -171,6 +178,10 @@ public class ChooserRequestParameters { return mCallerChooserTargets; } + public ImmutableList getChooserActions() { + return mChooserActions; + } + /** * Whether the {@link ChooserActivity.EXTRA_PRIVATE_RETAIN_IN_ON_STOP} behavior was requested. */ @@ -300,6 +311,16 @@ public class ChooserRequestParameters { .collect(toImmutableList()); } + private static ImmutableList getChooserActions(Intent intent) { + return streamParcelableArrayExtra( + intent, + Intent.EXTRA_CHOOSER_CUSTOM_ACTIONS, + ChooserAction.class, + true, + true) + .collect(toImmutableList()); + } + private static Collector> toImmutableList() { return Collectors.collectingAndThen(Collectors.toList(), ImmutableList::copyOf); } -- cgit v1.2.3-59-g8ed1b From 8edfdb2a8ea1b1a92bd002ed7aa758b53113465d Mon Sep 17 00:00:00 2001 From: Nick Chameyev Date: Fri, 6 Jan 2023 11:29:32 +0000 Subject: [Chooser/ResolverActivity] Add option to show preview area even if there are no apps in the tab Add a protected method that could be overriden to change the default behavior that hides the content preview area when there are not items for the current user. This is needed for the partial screen sharing feature as we want to show the recents app selector no matter if we have apps available for the current profile or not. Recent app selector is displayed in content preview area. Test: atest com.android.intentresolver.UnbundledChooserActivityWorkProfileTest Bug: 233348916 Change-Id: I0fa8da2dd4a690f5be1741d0936f776a3374aee7 --- java/src/com/android/intentresolver/ChooserActivity.java | 14 +++++++++++++- java/src/com/android/intentresolver/ResolverActivity.java | 6 +++--- 2 files changed, 16 insertions(+), 4 deletions(-) (limited to 'java/src') diff --git a/java/src/com/android/intentresolver/ChooserActivity.java b/java/src/com/android/intentresolver/ChooserActivity.java index ceab62b2..a3de9d31 100644 --- a/java/src/com/android/intentresolver/ChooserActivity.java +++ b/java/src/com/android/intentresolver/ChooserActivity.java @@ -1946,11 +1946,23 @@ public class ChooserActivity extends ResolverActivity implements private boolean shouldShowStickyContentPreviewNoOrientationCheck() { return shouldShowTabs() - && mMultiProfilePagerAdapter.getListAdapterForUserHandle( + && (mMultiProfilePagerAdapter.getListAdapterForUserHandle( UserHandle.of(UserHandle.myUserId())).getCount() > 0 + || shouldShowContentPreviewWhenEmpty()) && shouldShowContentPreview(); } + /** + * 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 */ diff --git a/java/src/com/android/intentresolver/ResolverActivity.java b/java/src/com/android/intentresolver/ResolverActivity.java index 5573e18a..5f8f3da8 100644 --- a/java/src/com/android/intentresolver/ResolverActivity.java +++ b/java/src/com/android/intentresolver/ResolverActivity.java @@ -202,7 +202,7 @@ public class ResolverActivity extends FragmentActivity implements *

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

Possible values can be either {@link #PROFILE_PERSONAL} or {@link #PROFILE_WORK}. */ - static final String EXTRA_SELECTED_PROFILE = + protected static final String EXTRA_SELECTED_PROFILE = "com.android.internal.app.ResolverActivity.EXTRA_SELECTED_PROFILE"; /** @@ -217,8 +217,8 @@ public class ResolverActivity extends FragmentActivity implements static final String EXTRA_CALLING_USER = "com.android.internal.app.ResolverActivity.EXTRA_CALLING_USER"; - static final int PROFILE_PERSONAL = AbstractMultiProfilePagerAdapter.PROFILE_PERSONAL; - static final int PROFILE_WORK = AbstractMultiProfilePagerAdapter.PROFILE_WORK; + protected static final int PROFILE_PERSONAL = AbstractMultiProfilePagerAdapter.PROFILE_PERSONAL; + protected static final int PROFILE_WORK = AbstractMultiProfilePagerAdapter.PROFILE_WORK; private BroadcastReceiver mWorkProfileStateReceiver; private UserHandle mHeaderCreatorUser; -- cgit v1.2.3-59-g8ed1b From a9a6a1c4494529fe2a8e1672d7f6c56aca978743 Mon Sep 17 00:00:00 2001 From: Andrey Epin Date: Thu, 29 Dec 2022 20:47:57 -0800 Subject: EnterTransitionAnimationDelegate to track animation hold timeout Make EnterTransitionAnimationDelegate to track transition animation hold timeout instead of relying on image lading timeout in ChooserContentPreviewCoordinator. As after removing timeout tracking from the coordinator, not much functionality left in it, the following collateral changes were made: - ChooserContentPreviewUi$ContentPreviewCoordinator interface is replaced with a new ImageLoader interface that defines both suspend and callback methods for image loading by an URI; - ImagePreviewImageLoader implements the new ImageLoader interface; all image loading logic is moved from ChooserActivity and ChooserContentPreviewCoordinator is moved into it, ChooserContentPreviewCoordinator is removed. Flag: IntentResolver package entirely behind the CHOOSER_UNBUNDLED which is in teamfood Bug: 262280076 Test: manual basic functionality test for all preview types with and without work profile Test: atest IntentResolverUnitTests Change-Id: I310927e664e8de83d63472cc826665d36d994ed9 --- .../android/intentresolver/ChooserActivity.java | 33 ++---- .../ChooserContentPreviewCoordinator.java | 132 --------------------- .../intentresolver/ChooserContentPreviewUi.java | 127 ++++++++++---------- .../EnterTransitionAnimationDelegate.kt | 35 ++++-- java/src/com/android/intentresolver/ImageLoader.kt | 25 ++++ .../intentresolver/ImagePreviewImageLoader.kt | 45 +++++-- .../intentresolver/widget/ImagePreviewView.kt | 2 +- java/tests/Android.bp | 4 + .../intentresolver/ChooserWrapperActivity.java | 11 +- .../EnterTransitionAnimationDelegateTest.kt | 111 +++++++++++++++++ .../android/intentresolver/TestLifecycleOwner.kt | 33 ++++++ .../intentresolver/TestPreviewImageLoader.kt | 37 ++++++ 12 files changed, 346 insertions(+), 249 deletions(-) delete mode 100644 java/src/com/android/intentresolver/ChooserContentPreviewCoordinator.java create mode 100644 java/src/com/android/intentresolver/ImageLoader.kt create mode 100644 java/tests/src/com/android/intentresolver/EnterTransitionAnimationDelegateTest.kt create mode 100644 java/tests/src/com/android/intentresolver/TestLifecycleOwner.kt create mode 100644 java/tests/src/com/android/intentresolver/TestPreviewImageLoader.kt (limited to 'java/src') diff --git a/java/src/com/android/intentresolver/ChooserActivity.java b/java/src/com/android/intentresolver/ChooserActivity.java index ceab62b2..32cd45da 100644 --- a/java/src/com/android/intentresolver/ChooserActivity.java +++ b/java/src/com/android/intentresolver/ChooserActivity.java @@ -54,7 +54,6 @@ import android.content.pm.ShortcutInfo; import android.content.res.Configuration; import android.content.res.Resources; import android.database.Cursor; -import android.graphics.Bitmap; import android.graphics.Insets; import android.graphics.drawable.Drawable; import android.net.Uri; @@ -73,7 +72,6 @@ import android.provider.Settings; import android.service.chooser.ChooserTarget; import android.text.TextUtils; import android.util.Log; -import android.util.Size; import android.util.Slog; import android.util.SparseArray; import android.view.View; @@ -113,7 +111,6 @@ import com.android.internal.logging.nano.MetricsProto.MetricsEvent; import com.android.internal.util.FrameworkStatsLog; import java.io.File; -import java.io.IOException; import java.lang.annotation.Retention; import java.lang.annotation.RetentionPolicy; import java.text.Collator; @@ -241,7 +238,7 @@ public class ChooserActivity extends ResolverActivity implements private final ExecutorService mBackgroundThreadPoolExecutor = Executors.newFixedThreadPool(5); @Nullable - private ChooserContentPreviewCoordinator mPreviewCoordinator; + private ImageLoader mPreviewImageLoader; private int mScrollStatus = SCROLL_STATUS_IDLE; @@ -291,10 +288,7 @@ public class ChooserActivity extends ResolverActivity implements mChooserRequest.getTargetIntentFilter()), mChooserRequest.getTargetIntentFilter()); - mPreviewCoordinator = new ChooserContentPreviewCoordinator( - mBackgroundThreadPoolExecutor, - this, - () -> mEnterTransitionAnimationDelegate.markImagePreviewReady(false)); + mPreviewImageLoader = createPreviewImageLoader(); super.onCreate( savedInstanceState, @@ -707,9 +701,7 @@ public class ChooserActivity extends ResolverActivity implements * @param parent reference to the parent container where the view should be attached to * @return content preview view */ - protected ViewGroup createContentPreviewView( - ViewGroup parent, - ChooserContentPreviewUi.ContentPreviewCoordinator previewCoordinator) { + protected ViewGroup createContentPreviewView(ViewGroup parent, ImageLoader imageLoader) { Intent targetIntent = getTargetIntent(); int previewType = ChooserContentPreviewUi.findPreferredContentPreview( targetIntent, getContentResolver(), this::isImageType); @@ -742,7 +734,7 @@ public class ChooserActivity extends ResolverActivity implements actionFactory, R.layout.chooser_action_row, parent, - previewCoordinator, + imageLoader, mEnterTransitionAnimationDelegate::markImagePreviewReady, getContentResolver(), this::isImageType); @@ -1485,7 +1477,7 @@ public class ChooserActivity extends ResolverActivity implements @Override public View buildContentPreview(ViewGroup parent) { - return createContentPreviewView(parent, mPreviewCoordinator); + return createContentPreviewView(parent, mPreviewImageLoader); } @Override @@ -1600,17 +1592,8 @@ public class ChooserActivity extends ResolverActivity implements } @VisibleForTesting - protected Bitmap loadThumbnail(Uri uri, Size size) { - if (uri == null || size == null) { - return null; - } - - try { - return getContentResolver().loadThumbnail(uri, size, null); - } catch (IOException | NullPointerException | SecurityException ex) { - getChooserActivityLogger().logContentPreviewWarning(uri); - } - return null; + protected ImageLoader createPreviewImageLoader() { + return new ImagePreviewImageLoader(this, getLifecycle()); } private void handleScroll(View view, int x, int y, int oldx, int oldy) { @@ -1967,7 +1950,7 @@ public class ChooserActivity extends ResolverActivity implements ViewGroup contentPreviewContainer = findViewById(com.android.internal.R.id.content_preview_container); if (contentPreviewContainer.getChildCount() == 0) { ViewGroup contentPreviewView = - createContentPreviewView(contentPreviewContainer, mPreviewCoordinator); + createContentPreviewView(contentPreviewContainer, mPreviewImageLoader); contentPreviewContainer.addView(contentPreviewView); } } diff --git a/java/src/com/android/intentresolver/ChooserContentPreviewCoordinator.java b/java/src/com/android/intentresolver/ChooserContentPreviewCoordinator.java deleted file mode 100644 index 0b8dbe35..00000000 --- a/java/src/com/android/intentresolver/ChooserContentPreviewCoordinator.java +++ /dev/null @@ -1,132 +0,0 @@ -/* - * Copyright (C) 2008 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.android.intentresolver; - -import android.graphics.Bitmap; -import android.net.Uri; -import android.os.Handler; -import android.util.Size; - -import androidx.annotation.MainThread; -import androidx.annotation.Nullable; - -import com.google.common.util.concurrent.FutureCallback; -import com.google.common.util.concurrent.Futures; -import com.google.common.util.concurrent.ListenableFuture; -import com.google.common.util.concurrent.ListeningExecutorService; -import com.google.common.util.concurrent.MoreExecutors; - -import java.util.concurrent.ExecutorService; -import java.util.function.Consumer; - -/** - * Delegate to manage deferred resource loads for content preview assets, while - * implementing Chooser's application logic for determining timeout/success/failure conditions. - */ -public class ChooserContentPreviewCoordinator implements - ChooserContentPreviewUi.ContentPreviewCoordinator { - public ChooserContentPreviewCoordinator( - ExecutorService backgroundExecutor, - ChooserActivity chooserActivity, - Runnable onFailCallback) { - this.mBackgroundExecutor = MoreExecutors.listeningDecorator(backgroundExecutor); - this.mChooserActivity = chooserActivity; - this.mOnFailCallback = onFailCallback; - - this.mImageLoadTimeoutMillis = - chooserActivity.getResources().getInteger(R.integer.config_shortAnimTime); - } - - @Override - public void loadImage(final Uri imageUri, final Consumer callback) { - final int size = mChooserActivity.getResources().getDimensionPixelSize( - R.dimen.chooser_preview_image_max_dimen); - - // TODO: apparently this timeout is only used for not holding shared element transition - // animation for too long. If so, we already have a better place for it - // EnterTransitionAnimationDelegate. - mHandler.postDelayed(this::onWatchdogTimeout, mImageLoadTimeoutMillis); - - ListenableFuture bitmapFuture = mBackgroundExecutor.submit( - () -> mChooserActivity.loadThumbnail(imageUri, new Size(size, size))); - - Futures.addCallback( - bitmapFuture, - new FutureCallback() { - @Override - public void onSuccess(Bitmap loadedBitmap) { - try { - callback.accept(loadedBitmap); - onLoadCompleted(loadedBitmap); - } catch (Exception e) { /* unimportant */ } - } - - @Override - public void onFailure(Throwable t) { - callback.accept(null); - } - }, - mHandler::post); - } - - private final ChooserActivity mChooserActivity; - private final ListeningExecutorService mBackgroundExecutor; - private final Runnable mOnFailCallback; - private final int mImageLoadTimeoutMillis; - - // TODO: this uses a `Handler` because there doesn't seem to be a straightforward way to get a - // `ScheduledExecutorService` that posts to the UI thread unless we use Dagger. Eventually we'll - // use Dagger and can inject this as a `@UiThread ScheduledExecutorService`. - private final Handler mHandler = new Handler(); - - private boolean mAtLeastOneLoaded = false; - - @MainThread - private void onWatchdogTimeout() { - if (mChooserActivity.isFinishing()) { - return; - } - - // If at least one image loads within the timeout period, allow other loads to continue. - if (!mAtLeastOneLoaded) { - mOnFailCallback.run(); - } - } - - @MainThread - private void onLoadCompleted(@Nullable Bitmap loadedBitmap) { - if (mChooserActivity.isFinishing()) { - return; - } - - // TODO: the following logic can be described as "invoke the fail callback when the first - // image loading has failed". Historically, before we had switched from a single-threaded - // pool to a multi-threaded pool, we first loaded the transition element's image (the image - // preview is the only case when those callbacks matter) and aborting the animation on it's - // failure was reasonable. With the multi-thread pool, the first result may belong to any - // image and thus we can falsely abort the animation. - // Now, when we track the transition view state directly and after the timeout logic will - // be moved into ChooserActivity$EnterTransitionAnimationDelegate, we can just get rid of - // the fail callback and the following logic altogether. - mAtLeastOneLoaded |= loadedBitmap != null; - boolean wholeBatchFailed = !mAtLeastOneLoaded; - - if (wholeBatchFailed) { - mOnFailCallback.run(); - } - } -} diff --git a/java/src/com/android/intentresolver/ChooserContentPreviewUi.java b/java/src/com/android/intentresolver/ChooserContentPreviewUi.java index ff88e5e1..7dd771cb 100644 --- a/java/src/com/android/intentresolver/ChooserContentPreviewUi.java +++ b/java/src/com/android/intentresolver/ChooserContentPreviewUi.java @@ -71,21 +71,6 @@ import java.util.function.Consumer; public final class ChooserContentPreviewUi { private static final int IMAGE_FADE_IN_MILLIS = 150; - /** - * Delegate to handle background resource loads that are dependencies of content previews. - */ - public interface ContentPreviewCoordinator { - /** - * Request that an image be loaded in the background and set into a view. - * - * @param imageUri The {@link Uri} of the image to load. - * - * TODO: it looks like clients are probably capable of passing the view directly, but the - * deferred computation here is a closer match to the legacy model for now. - */ - void loadImage(Uri imageUri, Consumer callback); - } - /** * Delegate to build the default system action buttons to display in the preview layout, if/when * they're determined to be appropriate for the particular preview we display. @@ -181,7 +166,7 @@ public final class ChooserContentPreviewUi { ActionFactory actionFactory, @LayoutRes int actionRowLayout, ViewGroup parent, - ContentPreviewCoordinator previewCoord, + ImageLoader previewImageLoader, Consumer onTransitionTargetReady, ContentResolver contentResolver, ImageMimeTypeClassifier imageClassifier) { @@ -194,7 +179,7 @@ public final class ChooserContentPreviewUi { layoutInflater, createTextPreviewActions(actionFactory), parent, - previewCoord, + previewImageLoader, actionRowLayout); break; case CONTENT_PREVIEW_IMAGE: @@ -203,7 +188,7 @@ public final class ChooserContentPreviewUi { layoutInflater, createImagePreviewActions(actionFactory), parent, - previewCoord, + previewImageLoader, onTransitionTargetReady, contentResolver, imageClassifier, @@ -216,7 +201,7 @@ public final class ChooserContentPreviewUi { layoutInflater, createFilePreviewActions(actionFactory), parent, - previewCoord, + previewImageLoader, contentResolver, actionRowLayout); break; @@ -247,7 +232,7 @@ public final class ChooserContentPreviewUi { LayoutInflater layoutInflater, List actions, ViewGroup parent, - ContentPreviewCoordinator previewCoord, + ImageLoader previewImageLoader, @LayoutRes int actionRowLayout) { ViewGroup contentPreviewLayout = (ViewGroup) layoutInflater.inflate( R.layout.chooser_grid_preview_text, parent, false); @@ -292,7 +277,7 @@ public final class ChooserContentPreviewUi { if (previewThumbnail == null) { previewThumbnailView.setVisibility(View.GONE); } else { - previewCoord.loadImage( + previewImageLoader.loadImage( previewThumbnail, (bitmap) -> updateViewWithImage( contentPreviewLayout.findViewById( @@ -319,7 +304,7 @@ public final class ChooserContentPreviewUi { LayoutInflater layoutInflater, List actions, ViewGroup parent, - ContentPreviewCoordinator previewCoord, + ImageLoader imageLoader, Consumer onTransitionTargetReady, ContentResolver contentResolver, ImageMimeTypeClassifier imageClassifier, @@ -334,7 +319,6 @@ public final class ChooserContentPreviewUi { actionRow.setActions(actions); } - final ImagePreviewImageLoader imageLoader = new ImagePreviewImageLoader(previewCoord); final ArrayList imageUris = new ArrayList<>(); String action = targetIntent.getAction(); if (Intent.ACTION_SEND.equals(action)) { @@ -387,59 +371,74 @@ public final class ChooserContentPreviewUi { LayoutInflater layoutInflater, List actions, ViewGroup parent, - ContentPreviewCoordinator previewCoord, + ImageLoader imageLoader, ContentResolver contentResolver, @LayoutRes int actionRowLayout) { ViewGroup contentPreviewLayout = (ViewGroup) layoutInflater.inflate( R.layout.chooser_grid_preview_file, parent, false); + List uris = extractFileUris(targetIntent); + final int uriCount = uris.size(); + + if (uriCount == 0) { + contentPreviewLayout.setVisibility(View.GONE); + Log.i(TAG, + "Appears to be no uris available in EXTRA_STREAM, removing " + + "preview area"); + return contentPreviewLayout; + } + + if (uriCount == 1) { + loadFileUriIntoView(uris.get(0), contentPreviewLayout, imageLoader, contentResolver); + } else { + FileInfo fileInfo = extractFileInfo(uris.get(0), contentResolver); + int remUriCount = uriCount - 1; + Map arguments = new HashMap<>(); + arguments.put(PLURALS_COUNT, remUriCount); + arguments.put(PLURALS_FILE_NAME, fileInfo.name); + String fileName = + PluralsMessageFormatter.format(resources, arguments, R.string.file_count); + + TextView fileNameView = contentPreviewLayout.findViewById( + com.android.internal.R.id.content_preview_filename); + fileNameView.setText(fileName); + + View thumbnailView = contentPreviewLayout.findViewById( + com.android.internal.R.id.content_preview_file_thumbnail); + thumbnailView.setVisibility(View.GONE); + + ImageView fileIconView = contentPreviewLayout.findViewById( + com.android.internal.R.id.content_preview_file_icon); + fileIconView.setVisibility(View.VISIBLE); + fileIconView.setImageResource(R.drawable.ic_file_copy); + } + final ActionRow actionRow = inflateActionRow(contentPreviewLayout, actionRowLayout); if (actionRow != null) { actionRow.setActions(actions); } - String action = targetIntent.getAction(); - if (Intent.ACTION_SEND.equals(action)) { + return contentPreviewLayout; + } + + private static List extractFileUris(Intent targetIntent) { + List uris = new ArrayList<>(); + if (Intent.ACTION_SEND.equals(targetIntent.getAction())) { Uri uri = targetIntent.getParcelableExtra(Intent.EXTRA_STREAM); - loadFileUriIntoView(uri, contentPreviewLayout, previewCoord, contentResolver); + if (uri != null) { + uris.add(uri); + } } else { - List uris = targetIntent.getParcelableArrayListExtra(Intent.EXTRA_STREAM); - int uriCount = uris.size(); - - if (uriCount == 0) { - contentPreviewLayout.setVisibility(View.GONE); - Log.i(TAG, - "Appears to be no uris available in EXTRA_STREAM, removing " - + "preview area"); - return contentPreviewLayout; - } else if (uriCount == 1) { - loadFileUriIntoView( - uris.get(0), contentPreviewLayout, previewCoord, contentResolver); - } else { - FileInfo fileInfo = extractFileInfo(uris.get(0), contentResolver); - int remUriCount = uriCount - 1; - Map arguments = new HashMap<>(); - arguments.put(PLURALS_COUNT, remUriCount); - arguments.put(PLURALS_FILE_NAME, fileInfo.name); - String fileName = - PluralsMessageFormatter.format(resources, arguments, R.string.file_count); - - TextView fileNameView = contentPreviewLayout.findViewById( - com.android.internal.R.id.content_preview_filename); - fileNameView.setText(fileName); - - View thumbnailView = contentPreviewLayout.findViewById( - com.android.internal.R.id.content_preview_file_thumbnail); - thumbnailView.setVisibility(View.GONE); - - ImageView fileIconView = contentPreviewLayout.findViewById( - com.android.internal.R.id.content_preview_file_icon); - fileIconView.setVisibility(View.VISIBLE); - fileIconView.setImageResource(R.drawable.ic_file_copy); + List receivedUris = targetIntent.getParcelableArrayListExtra(Intent.EXTRA_STREAM); + if (receivedUris != null) { + for (Uri uri : receivedUris) { + if (uri != null) { + uris.add(uri); + } + } } } - - return contentPreviewLayout; + return uris; } private static List createFilePreviewActions(ActionFactory actionFactory) { @@ -473,7 +472,7 @@ public final class ChooserContentPreviewUi { private static void loadFileUriIntoView( final Uri uri, final View parent, - final ContentPreviewCoordinator previewCoord, + final ImageLoader imageLoader, final ContentResolver contentResolver) { FileInfo fileInfo = extractFileInfo(uri, contentResolver); @@ -482,7 +481,7 @@ public final class ChooserContentPreviewUi { fileNameView.setText(fileInfo.name); if (fileInfo.hasThumbnail) { - previewCoord.loadImage( + imageLoader.loadImage( uri, (bitmap) -> updateViewWithImage( parent.findViewById( diff --git a/java/src/com/android/intentresolver/EnterTransitionAnimationDelegate.kt b/java/src/com/android/intentresolver/EnterTransitionAnimationDelegate.kt index a0bf61b6..31aeea44 100644 --- a/java/src/com/android/intentresolver/EnterTransitionAnimationDelegate.kt +++ b/java/src/com/android/intentresolver/EnterTransitionAnimationDelegate.kt @@ -15,23 +15,30 @@ */ package com.android.intentresolver -import android.app.Activity import android.app.SharedElementCallback import android.view.View -import com.android.intentresolver.widget.ResolverDrawerLayout +import androidx.activity.ComponentActivity +import androidx.lifecycle.lifecycleScope +import com.android.internal.annotations.VisibleForTesting +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch import java.util.function.Supplier /** * A helper class to track app's readiness for the scene transition animation. * The app is ready when both the image is laid out and the drawer offset is calculated. */ -internal class EnterTransitionAnimationDelegate( - private val activity: Activity, - private val resolverDrawerLayoutSupplier: Supplier +@VisibleForTesting +class EnterTransitionAnimationDelegate( + private val activity: ComponentActivity, + private val transitionTargetSupplier: Supplier, ) : View.OnLayoutChangeListener { + private var removeSharedElements = false private var previewReady = false private var offsetCalculated = false + private var timeoutJob: Job? = null init { activity.setEnterSharedElementCallback( @@ -46,9 +53,23 @@ internal class EnterTransitionAnimationDelegate( }) } - fun postponeTransition() = activity.postponeEnterTransition() + fun postponeTransition() { + activity.postponeEnterTransition() + timeoutJob = activity.lifecycleScope.launch { + delay(activity.resources.getInteger(R.integer.config_shortAnimTime).toLong()) + onTimeout() + } + } + + private fun onTimeout() { + // We only mark the preview readiness and not the offset readiness + // (see [#markOffsetCalculated()]) as this is what legacy logic, effectively, did. We might + // want to review that aspect separately. + markImagePreviewReady(runTransitionAnimation = false) + } fun markImagePreviewReady(runTransitionAnimation: Boolean) { + timeoutJob?.cancel() if (!runTransitionAnimation) { removeSharedElements = true } @@ -77,7 +98,7 @@ internal class EnterTransitionAnimationDelegate( } private fun maybeStartListenForLayout() { - val drawer = resolverDrawerLayoutSupplier.get() + val drawer = transitionTargetSupplier.get() if (previewReady && offsetCalculated && drawer != null) { if (drawer.isInLayout) { startPostponedEnterTransition() diff --git a/java/src/com/android/intentresolver/ImageLoader.kt b/java/src/com/android/intentresolver/ImageLoader.kt new file mode 100644 index 00000000..13b1dd9c --- /dev/null +++ b/java/src/com/android/intentresolver/ImageLoader.kt @@ -0,0 +1,25 @@ +/* + * Copyright (C) 2022 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.intentresolver + +import android.graphics.Bitmap +import android.net.Uri +import java.util.function.Consumer + +interface ImageLoader : suspend (Uri) -> Bitmap? { + fun loadImage(uri: Uri, callback: Consumer) +} diff --git a/java/src/com/android/intentresolver/ImagePreviewImageLoader.kt b/java/src/com/android/intentresolver/ImagePreviewImageLoader.kt index e68eb66a..40081c87 100644 --- a/java/src/com/android/intentresolver/ImagePreviewImageLoader.kt +++ b/java/src/com/android/intentresolver/ImagePreviewImageLoader.kt @@ -16,23 +16,42 @@ package com.android.intentresolver +import android.content.Context import android.graphics.Bitmap import android.net.Uri -import kotlinx.coroutines.suspendCancellableCoroutine +import android.util.Size +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.coroutineScope +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import java.util.function.Consumer -// TODO: convert ChooserContentPreviewCoordinator to Kotlin and merge this class into it. -internal class ImagePreviewImageLoader( - private val previewCoordinator: ChooserContentPreviewUi.ContentPreviewCoordinator -) : suspend (Uri) -> Bitmap? { +internal class ImagePreviewImageLoader @JvmOverloads constructor( + private val context: Context, + private val lifecycle: Lifecycle, + private val dispatcher: CoroutineDispatcher = Dispatchers.IO +) : ImageLoader { - override suspend fun invoke(uri: Uri): Bitmap? = - suspendCancellableCoroutine { continuation -> - val callback = java.util.function.Consumer { bitmap -> - try { - continuation.resumeWith(Result.success(bitmap)) - } catch (ignored: Exception) { - } + override suspend fun invoke(uri: Uri): Bitmap? = loadImageAsync(uri) + + override fun loadImage(uri: Uri, callback: Consumer) { + lifecycle.coroutineScope.launch { + val image = loadImageAsync(uri) + if (isActive) { + callback.accept(image) } - previewCoordinator.loadImage(uri, callback) } + } + + private suspend fun loadImageAsync(uri: Uri): Bitmap? { + val size = context.resources.getDimensionPixelSize(R.dimen.chooser_preview_image_max_dimen) + return withContext(dispatcher) { + runCatching { + context.contentResolver.loadThumbnail(uri, Size(size, size), null) + }.getOrNull() + } + } } diff --git a/java/src/com/android/intentresolver/widget/ImagePreviewView.kt b/java/src/com/android/intentresolver/widget/ImagePreviewView.kt index a37ef954..c61c7c72 100644 --- a/java/src/com/android/intentresolver/widget/ImagePreviewView.kt +++ b/java/src/com/android/intentresolver/widget/ImagePreviewView.kt @@ -35,7 +35,7 @@ import kotlinx.coroutines.launch import java.util.function.Consumer import com.android.internal.R as IntR -typealias ImageLoader = suspend (Uri) -> Bitmap? +private typealias ImageLoader = suspend (Uri) -> Bitmap? private const val IMAGE_FADE_IN_MILLIS = 150L diff --git a/java/tests/Android.bp b/java/tests/Android.bp index 2913d128..a62c52e6 100644 --- a/java/tests/Android.bp +++ b/java/tests/Android.bp @@ -23,9 +23,13 @@ android_test { "androidx.test.ext.junit", "mockito-target-minus-junit4", "androidx.test.espresso.core", + "androidx.lifecycle_lifecycle-common-java8", + "androidx.lifecycle_lifecycle-extensions", + "androidx.lifecycle_lifecycle-runtime-ktx", "truth-prebuilt", "testables", "testng", + "kotlinx_coroutines_test", ], test_suites: ["general-tests"], sdk_version: "core_platform", diff --git a/java/tests/src/com/android/intentresolver/ChooserWrapperActivity.java b/java/tests/src/com/android/intentresolver/ChooserWrapperActivity.java index 97de97f5..6bc5e12a 100644 --- a/java/tests/src/com/android/intentresolver/ChooserWrapperActivity.java +++ b/java/tests/src/com/android/intentresolver/ChooserWrapperActivity.java @@ -30,10 +30,8 @@ import android.content.pm.PackageManager; import android.content.pm.ResolveInfo; import android.content.res.Resources; import android.database.Cursor; -import android.graphics.Bitmap; import android.net.Uri; import android.os.UserHandle; -import android.util.Size; import com.android.intentresolver.AbstractMultiProfilePagerAdapter.CrossProfileIntentsChecker; import com.android.intentresolver.AbstractMultiProfilePagerAdapter.MyUserIdProvider; @@ -208,11 +206,10 @@ public class ChooserWrapperActivity } @Override - protected Bitmap loadThumbnail(Uri uri, Size size) { - if (sOverrides.previewThumbnail != null) { - return sOverrides.previewThumbnail; - } - return super.loadThumbnail(uri, size); + protected ImageLoader createPreviewImageLoader() { + return new TestPreviewImageLoader( + super.createPreviewImageLoader(), + () -> sOverrides.previewThumbnail); } @Override diff --git a/java/tests/src/com/android/intentresolver/EnterTransitionAnimationDelegateTest.kt b/java/tests/src/com/android/intentresolver/EnterTransitionAnimationDelegateTest.kt new file mode 100644 index 00000000..ffe89400 --- /dev/null +++ b/java/tests/src/com/android/intentresolver/EnterTransitionAnimationDelegateTest.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 + +import android.content.res.Resources +import android.view.View +import android.view.Window +import androidx.activity.ComponentActivity +import androidx.lifecycle.Lifecycle +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.TestCoroutineScheduler +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.setMain +import org.junit.After +import org.junit.Before +import org.junit.Test +import org.mockito.Mockito.anyInt +import org.mockito.Mockito.never +import org.mockito.Mockito.times +import org.mockito.Mockito.verify + +private const val TIMEOUT_MS = 200 + +@OptIn(ExperimentalCoroutinesApi::class) +class EnterTransitionAnimationDelegateTest { + private val scheduler = TestCoroutineScheduler() + private val dispatcher = StandardTestDispatcher(scheduler) + private val lifecycleOwner = TestLifecycleOwner() + + private val transitionTargetView = mock { + // avoid the request-layout path in the delegate + whenever(isInLayout).thenReturn(true) + } + + private val windowMock = mock() + private val resourcesMock = mock { + whenever(getInteger(anyInt())).thenReturn(TIMEOUT_MS) + } + private val activity = mock { + whenever(lifecycle).thenReturn(lifecycleOwner.lifecycle) + whenever(resources).thenReturn(resourcesMock) + whenever(isActivityTransitionRunning).thenReturn(true) + whenever(window).thenReturn(windowMock) + } + + private val testSubject = EnterTransitionAnimationDelegate(activity) { + transitionTargetView + } + + @Before + fun setup() { + Dispatchers.setMain(dispatcher) + lifecycleOwner.state = Lifecycle.State.CREATED + } + + @After + fun cleanup() { + lifecycleOwner.state = Lifecycle.State.DESTROYED + Dispatchers.resetMain() + } + + @Test + fun test_postponeTransition_timeout() { + testSubject.postponeTransition() + testSubject.markOffsetCalculated() + + scheduler.advanceTimeBy(TIMEOUT_MS + 1L) + verify(activity, times(1)).startPostponedEnterTransition() + verify(windowMock, never()).setWindowAnimations(anyInt()) + } + + @Test + fun test_postponeTransition_animation_resumes_only_once() { + testSubject.postponeTransition() + testSubject.markOffsetCalculated() + testSubject.markImagePreviewReady(true) + testSubject.markOffsetCalculated() + testSubject.markImagePreviewReady(true) + + scheduler.advanceTimeBy(TIMEOUT_MS + 1L) + verify(activity, times(1)).startPostponedEnterTransition() + } + + @Test + fun test_postponeTransition_resume_animation_conditions() { + testSubject.postponeTransition() + verify(activity, never()).startPostponedEnterTransition() + + testSubject.markOffsetCalculated() + verify(activity, never()).startPostponedEnterTransition() + + testSubject.markImagePreviewReady(true) + verify(activity, times(1)).startPostponedEnterTransition() + } +} diff --git a/java/tests/src/com/android/intentresolver/TestLifecycleOwner.kt b/java/tests/src/com/android/intentresolver/TestLifecycleOwner.kt new file mode 100644 index 00000000..f47e343f --- /dev/null +++ b/java/tests/src/com/android/intentresolver/TestLifecycleOwner.kt @@ -0,0 +1,33 @@ +/* + * Copyright (C) 2023 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.intentresolver + +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleOwner +import androidx.lifecycle.LifecycleRegistry + +internal class TestLifecycleOwner : LifecycleOwner { + private val lifecycleRegistry = LifecycleRegistry.createUnsafe(this) + + override fun getLifecycle(): Lifecycle = lifecycleRegistry + + var state: Lifecycle.State + get() = lifecycle.currentState + set(value) { + lifecycleRegistry.currentState = value + } +} \ No newline at end of file diff --git a/java/tests/src/com/android/intentresolver/TestPreviewImageLoader.kt b/java/tests/src/com/android/intentresolver/TestPreviewImageLoader.kt new file mode 100644 index 00000000..fd617fdd --- /dev/null +++ b/java/tests/src/com/android/intentresolver/TestPreviewImageLoader.kt @@ -0,0 +1,37 @@ +/* + * Copyright (C) 2022 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.intentresolver + +import android.graphics.Bitmap +import android.net.Uri +import java.util.function.Consumer + +internal class TestPreviewImageLoader( + private val imageLoader: ImageLoader, + private val imageOverride: () -> Bitmap? +) : ImageLoader { + override fun loadImage(uri: Uri, callback: Consumer) { + val override = imageOverride() + if (override != null) { + callback.accept(override) + } else { + imageLoader.loadImage(uri, callback) + } + } + + override suspend fun invoke(uri: Uri): Bitmap? = imageOverride() ?: imageLoader(uri) +} -- cgit v1.2.3-59-g8ed1b From 929064ea70a78ef2fbc0be80b7aedf8fbd10deba Mon Sep 17 00:00:00 2001 From: Andrey Epin Date: Thu, 12 Jan 2023 09:47:41 -0800 Subject: Split ImagePreviewView into an interface and an implementation As a preparation step for an alternative image preview UI, introduce a generic interface for the image preview widget. Flag: IntentResolver package entirely behind the CHOOSER_UNBUNDLED which is in teamfood Bug: 262280076 Test: manual testing Change-Id: Ia7ddeb0c48790195e854af1d4b9a7376fa5e3421 --- java/res/layout/chooser_grid_preview_image.xml | 2 +- .../intentresolver/ChooserContentPreviewUi.java | 2 +- .../widget/ChooserImagePreviewView.kt | 175 +++++++++++++++++++++ .../intentresolver/widget/ImagePreviewView.kt | 147 +---------------- 4 files changed, 181 insertions(+), 145 deletions(-) create mode 100644 java/src/com/android/intentresolver/widget/ChooserImagePreviewView.kt (limited to 'java/src') diff --git a/java/res/layout/chooser_grid_preview_image.xml b/java/res/layout/chooser_grid_preview_image.xml index 5c324140..95cee0cb 100644 --- a/java/res/layout/chooser_grid_preview_image.xml +++ b/java/res/layout/chooser_grid_preview_image.xml @@ -25,7 +25,7 @@ android:orientation="vertical" android:background="?android:attr/colorBackground"> - ? = null + + override fun onFinishInflate() { + LayoutInflater.from(context).inflate(R.layout.image_preview_view, this, true) + mainImage = requireViewById(IntR.id.content_preview_image_1_large) + secondLargeImage = requireViewById(IntR.id.content_preview_image_2_large) + secondSmallImage = requireViewById(IntR.id.content_preview_image_2_small) + thirdImage = requireViewById(IntR.id.content_preview_image_3_small) + } + + /** + * Specifies a transition animation target name and a readiness callback. The callback will be + * invoked once when the view preparation is done i.e. either when an image is loaded into it + * and it is laid out (and it is ready to be draw) or image loading has failed. + * Should be called before [setImages]. + * @param name, transition name + * @param onViewReady, a callback that will be invoked with `true` if the view is ready to + * receive transition animation (the image was loaded successfully) and with `false` otherwise. + */ + override fun setSharedElementTransitionTarget(name: String, onViewReady: Consumer) { + mainImage.transitionName = name + onTransitionViewReadyCallback = onViewReady + } + + override fun setImages(uris: List, imageLoader: ImageLoader) { + loadImageJob?.cancel() + loadImageJob = coroutineScope.launch { + when (uris.size) { + 0 -> hideAllViews() + 1 -> showOneImage(uris, imageLoader) + 2 -> showTwoImages(uris, imageLoader) + else -> showThreeImages(uris, imageLoader) + } + } + } + + private fun hideAllViews() { + mainImage.isVisible = false + secondLargeImage.isVisible = false + secondSmallImage.isVisible = false + thirdImage.isVisible = false + invokeTransitionViewReadyCallback(runTransitionAnimation = false) + } + + private suspend fun showOneImage(uris: List, imageLoader: ImageLoader) { + secondLargeImage.isVisible = false + secondSmallImage.isVisible = false + thirdImage.isVisible = false + showImages(uris, imageLoader, mainImage) + } + + private suspend fun showTwoImages(uris: List, imageLoader: ImageLoader) { + secondSmallImage.isVisible = false + thirdImage.isVisible = false + showImages(uris, imageLoader, mainImage, secondLargeImage) + } + + private suspend fun showThreeImages(uris: List, imageLoader: ImageLoader) { + secondLargeImage.isVisible = false + showImages(uris, imageLoader, mainImage, secondSmallImage, thirdImage) + thirdImage.setExtraImageCount(uris.size - 3) + } + + private suspend fun showImages( + uris: List, imageLoader: ImageLoader, vararg views: RoundedRectImageView + ) = coroutineScope { + for (i in views.indices) { + launch { + loadImageIntoView(views[i], uris[i], imageLoader) + } + } + } + + private suspend fun loadImageIntoView( + view: RoundedRectImageView, uri: Uri, imageLoader: ImageLoader + ) { + val bitmap = runCatching { + imageLoader(uri) + }.getOrDefault(null) + if (bitmap == null) { + view.isVisible = false + if (view === mainImage) { + invokeTransitionViewReadyCallback(runTransitionAnimation = false) + } + } else { + view.isVisible = true + view.setImageBitmap(bitmap) + + view.alpha = 0f + ObjectAnimator.ofFloat(view, "alpha", 0.0f, 1.0f).apply { + interpolator = DecelerateInterpolator(1.0f) + duration = IMAGE_FADE_IN_MILLIS + start() + } + if (view === mainImage && onTransitionViewReadyCallback != null) { + setupPreDrawListener(mainImage) + } + } + } + + private fun setupPreDrawListener(view: View) { + view.viewTreeObserver.addOnPreDrawListener( + object : ViewTreeObserver.OnPreDrawListener { + override fun onPreDraw(): Boolean { + view.viewTreeObserver.removeOnPreDrawListener(this) + invokeTransitionViewReadyCallback(runTransitionAnimation = true) + return true + } + } + ) + } + + private fun invokeTransitionViewReadyCallback(runTransitionAnimation: Boolean) { + onTransitionViewReadyCallback?.accept(runTransitionAnimation) + onTransitionViewReadyCallback = null + } +} diff --git a/java/src/com/android/intentresolver/widget/ImagePreviewView.kt b/java/src/com/android/intentresolver/widget/ImagePreviewView.kt index c61c7c72..a5756054 100644 --- a/java/src/com/android/intentresolver/widget/ImagePreviewView.kt +++ b/java/src/com/android/intentresolver/widget/ImagePreviewView.kt @@ -16,58 +16,13 @@ package com.android.intentresolver.widget -import android.animation.ObjectAnimator -import android.content.Context import android.graphics.Bitmap import android.net.Uri -import android.util.AttributeSet -import android.view.LayoutInflater -import android.view.View -import android.view.ViewTreeObserver -import android.view.animation.DecelerateInterpolator -import android.widget.RelativeLayout -import androidx.core.view.isVisible -import com.android.intentresolver.R -import kotlinx.coroutines.Job -import kotlinx.coroutines.MainScope -import kotlinx.coroutines.coroutineScope -import kotlinx.coroutines.launch import java.util.function.Consumer -import com.android.internal.R as IntR -private typealias ImageLoader = suspend (Uri) -> Bitmap? +internal typealias ImageLoader = suspend (Uri) -> Bitmap? -private const val IMAGE_FADE_IN_MILLIS = 150L - -class ImagePreviewView : RelativeLayout { - - constructor(context: Context) : this(context, null) - constructor(context: Context, attrs: AttributeSet?) : this(context, attrs, 0) - - constructor( - context: Context, attrs: AttributeSet?, defStyleAttr: Int - ) : this(context, attrs, defStyleAttr, 0) - - constructor( - context: Context, attrs: AttributeSet?, defStyleAttr: Int, defStyleRes: Int - ) : super(context, attrs, defStyleAttr, defStyleRes) - - private val coroutineScope = MainScope() - private lateinit var mainImage: RoundedRectImageView - private lateinit var secondLargeImage: RoundedRectImageView - private lateinit var secondSmallImage: RoundedRectImageView - private lateinit var thirdImage: RoundedRectImageView - - private var loadImageJob: Job? = null - private var onTransitionViewReadyCallback: Consumer? = null - - override fun onFinishInflate() { - LayoutInflater.from(context).inflate(R.layout.image_preview_view, this, true) - mainImage = requireViewById(IntR.id.content_preview_image_1_large) - secondLargeImage = requireViewById(IntR.id.content_preview_image_2_large) - secondSmallImage = requireViewById(IntR.id.content_preview_image_2_small) - thirdImage = requireViewById(IntR.id.content_preview_image_3_small) - } +interface ImagePreviewView { /** * Specifies a transition animation target name and a readiness callback. The callback will be @@ -78,101 +33,7 @@ class ImagePreviewView : RelativeLayout { * @param onViewReady, a callback that will be invoked with `true` if the view is ready to * receive transition animation (the image was loaded successfully) and with `false` otherwise. */ - fun setSharedElementTransitionTarget(name: String, onViewReady: Consumer) { - mainImage.transitionName = name - onTransitionViewReadyCallback = onViewReady - } - - fun setImages(uris: List, imageLoader: ImageLoader) { - loadImageJob?.cancel() - loadImageJob = coroutineScope.launch { - when (uris.size) { - 0 -> hideAllViews() - 1 -> showOneImage(uris, imageLoader) - 2 -> showTwoImages(uris, imageLoader) - else -> showThreeImages(uris, imageLoader) - } - } - } - - private fun hideAllViews() { - mainImage.isVisible = false - secondLargeImage.isVisible = false - secondSmallImage.isVisible = false - thirdImage.isVisible = false - invokeTransitionViewReadyCallback(runTransitionAnimation = false) - } - - private suspend fun showOneImage(uris: List, imageLoader: ImageLoader) { - secondLargeImage.isVisible = false - secondSmallImage.isVisible = false - thirdImage.isVisible = false - showImages(uris, imageLoader, mainImage) - } - - private suspend fun showTwoImages(uris: List, imageLoader: ImageLoader) { - secondSmallImage.isVisible = false - thirdImage.isVisible = false - showImages(uris, imageLoader, mainImage, secondLargeImage) - } - - private suspend fun showThreeImages(uris: List, imageLoader: ImageLoader) { - secondLargeImage.isVisible = false - showImages(uris, imageLoader, mainImage, secondSmallImage, thirdImage) - thirdImage.setExtraImageCount(uris.size - 3) - } - - private suspend fun showImages( - uris: List, imageLoader: ImageLoader, vararg views: RoundedRectImageView - ) = coroutineScope { - for (i in views.indices) { - launch { - loadImageIntoView(views[i], uris[i], imageLoader) - } - } - } - - private suspend fun loadImageIntoView( - view: RoundedRectImageView, uri: Uri, imageLoader: ImageLoader - ) { - val bitmap = runCatching { - imageLoader(uri) - }.getOrDefault(null) - if (bitmap == null) { - view.isVisible = false - if (view === mainImage) { - invokeTransitionViewReadyCallback(runTransitionAnimation = false) - } - } else { - view.isVisible = true - view.setImageBitmap(bitmap) - - view.alpha = 0f - ObjectAnimator.ofFloat(view, "alpha", 0.0f, 1.0f).apply { - interpolator = DecelerateInterpolator(1.0f) - duration = IMAGE_FADE_IN_MILLIS - start() - } - if (view === mainImage && onTransitionViewReadyCallback != null) { - setupPreDrawListener(mainImage) - } - } - } - - private fun setupPreDrawListener(view: View) { - view.viewTreeObserver.addOnPreDrawListener( - object : ViewTreeObserver.OnPreDrawListener { - override fun onPreDraw(): Boolean { - view.viewTreeObserver.removeOnPreDrawListener(this) - invokeTransitionViewReadyCallback(runTransitionAnimation = true) - return true - } - } - ) - } + fun setSharedElementTransitionTarget(name: String, onViewReady: Consumer) - private fun invokeTransitionViewReadyCallback(runTransitionAnimation: Boolean) { - onTransitionViewReadyCallback?.accept(runTransitionAnimation) - onTransitionViewReadyCallback = null - } + fun setImages(uris: List, imageLoader: ImageLoader) } -- cgit v1.2.3-59-g8ed1b From 7c90c0a3e2f879866906866d868ee08164c6d42d Mon Sep 17 00:00:00 2001 From: Matt Casey Date: Sat, 14 Jan 2023 19:56:39 +0000 Subject: Add custom action and reselection logging params to IntentResolver Not actually writing real values yet, just updating for the new protos. Bug: 265504112 Test: Presubmits Change-Id: I9cdc15a0ea00cb210290780df0b61aea00f73566 --- .../android/intentresolver/ChooserActivityLogger.java | 16 ++++++++++++---- .../intentresolver/ChooserActivityLoggerTest.java | 4 +++- 2 files changed, 15 insertions(+), 5 deletions(-) (limited to 'java/src') diff --git a/java/src/com/android/intentresolver/ChooserActivityLogger.java b/java/src/com/android/intentresolver/ChooserActivityLogger.java index 9109bf93..1725a7bf 100644 --- a/java/src/com/android/intentresolver/ChooserActivityLogger.java +++ b/java/src/com/android/intentresolver/ChooserActivityLogger.java @@ -66,7 +66,9 @@ public class ChooserActivityLogger { int numAppProvidedAppTargets, boolean isWorkProfile, int previewType, - int intentType); + int intentType, + int numCustomActions, + boolean reselectionActionProvided); /** Overload to use for logging {@code FrameworkStatsLog.RANKING_SELECTED}. */ void write( @@ -126,7 +128,9 @@ public class ChooserActivityLogger { /* num_app_provided_app_targets = 6 */ appProvidedApp, /* is_workprofile = 7 */ isWorkprofile, /* previewType = 8 */ typeFromPreviewInt(previewType), - /* intentType = 9 */ typeFromIntentString(intent)); + /* intentType = 9 */ typeFromIntentString(intent), + /* num_provided_custom_actions = 10 */ 0, + /* reselection_action_provided = 11 */ false); } /** @@ -463,7 +467,9 @@ public class ChooserActivityLogger { int numAppProvidedAppTargets, boolean isWorkProfile, int previewType, - int intentType) { + int intentType, + int numCustomActions, + boolean reselectionActionProvided) { FrameworkStatsLog.write( frameworkEventId, /* event_id = 1 */ appEventId, @@ -474,7 +480,9 @@ public class ChooserActivityLogger { /* num_app_provided_app_targets */ numAppProvidedAppTargets, /* is_workprofile */ isWorkProfile, /* previewType = 8 */ previewType, - /* intentType = 9 */ intentType); + /* intentType = 9 */ intentType, + /* num_provided_custom_actions = 10 */ numCustomActions, + /* reselection_action_provided = 11 */ reselectionActionProvided); } @Override diff --git a/java/tests/src/com/android/intentresolver/ChooserActivityLoggerTest.java b/java/tests/src/com/android/intentresolver/ChooserActivityLoggerTest.java index 705a3228..c6a9b63f 100644 --- a/java/tests/src/com/android/intentresolver/ChooserActivityLoggerTest.java +++ b/java/tests/src/com/android/intentresolver/ChooserActivityLoggerTest.java @@ -141,7 +141,9 @@ public final class ChooserActivityLoggerTest { eq(appProvidedAppTargets), eq(workProfile), eq(FrameworkStatsLog.SHARESHEET_STARTED__PREVIEW_TYPE__CONTENT_PREVIEW_FILE), - eq(FrameworkStatsLog.SHARESHEET_STARTED__INTENT_TYPE__INTENT_ACTION_SENDTO)); + eq(FrameworkStatsLog.SHARESHEET_STARTED__INTENT_TYPE__INTENT_ACTION_SENDTO), + /* custom actions provided */ eq(0), + /* reselection action provided */ eq(false)); } @Test -- cgit v1.2.3-59-g8ed1b From afe103dfcbe593a79e2c5f9be5707011b23a6ace Mon Sep 17 00:00:00 2001 From: Andrey Epin Date: Thu, 1 Dec 2022 18:11:25 -0800 Subject: Convert ShortcutLoader to Kotlin A preparation step to update ShortcutLoader logic and base it on coroutines. Utializing Gerrit modification tracking specific. Patchsets: 1: Automatic conversion of ShortcutLoader from java to Kotlin; no other modifications. 2. Fix compile errors and unit tests. 3. Polish auto-converted Kotlin code plus somall refactoring: - some code snippets extracted into methods; - move shortcuts filtering by app status close tho the shortcut source. Test: namual testing Test: atest IntentResolverUnitTests Change-Id: Ic0c265b41faba774964cc17c2912c4282f0a3c3a --- .../android/intentresolver/ChooserActivity.java | 15 +- .../intentresolver/shortcuts/ShortcutLoader.java | 426 --------------------- .../intentresolver/shortcuts/ShortcutLoader.kt | 326 ++++++++++++++++ .../intentresolver/shortcuts/ShortcutLoaderTest.kt | 58 +-- 4 files changed, 365 insertions(+), 460 deletions(-) delete mode 100644 java/src/com/android/intentresolver/shortcuts/ShortcutLoader.java create mode 100644 java/src/com/android/intentresolver/shortcuts/ShortcutLoader.kt (limited to 'java/src') diff --git a/java/src/com/android/intentresolver/ChooserActivity.java b/java/src/com/android/intentresolver/ChooserActivity.java index 6a94d56c..dce8bde1 100644 --- a/java/src/com/android/intentresolver/ChooserActivity.java +++ b/java/src/com/android/intentresolver/ChooserActivity.java @@ -1871,21 +1871,20 @@ public class ChooserActivity extends ResolverActivity implements } @MainThread - private void onShortcutsLoaded( - UserHandle userHandle, ShortcutLoader.Result shortcutsResult) { + private void onShortcutsLoaded(UserHandle userHandle, ShortcutLoader.Result result) { if (DEBUG) { Log.d(TAG, "onShortcutsLoaded for user: " + userHandle); } - mDirectShareShortcutInfoCache.putAll(shortcutsResult.directShareShortcutInfoCache); - mDirectShareAppTargetCache.putAll(shortcutsResult.directShareAppTargetCache); + mDirectShareShortcutInfoCache.putAll(result.getDirectShareShortcutInfoCache()); + mDirectShareAppTargetCache.putAll(result.getDirectShareAppTargetCache()); ChooserListAdapter adapter = mChooserMultiProfilePagerAdapter.getListAdapterForUserHandle(userHandle); if (adapter != null) { - for (ShortcutLoader.ShortcutResultInfo resultInfo : shortcutsResult.shortcutsByApp) { + for (ShortcutLoader.ShortcutResultInfo resultInfo : result.getShortcutsByApp()) { adapter.addServiceResults( - resultInfo.appTarget, - resultInfo.shortcuts, - shortcutsResult.isFromAppPredictor + resultInfo.getAppTarget(), + resultInfo.getShortcuts(), + result.isFromAppPredictor() ? TARGET_TYPE_SHORTCUTS_FROM_PREDICTION_SERVICE : TARGET_TYPE_SHORTCUTS_FROM_SHORTCUT_MANAGER, mDirectShareShortcutInfoCache, diff --git a/java/src/com/android/intentresolver/shortcuts/ShortcutLoader.java b/java/src/com/android/intentresolver/shortcuts/ShortcutLoader.java deleted file mode 100644 index 1cfa2c8d..00000000 --- a/java/src/com/android/intentresolver/shortcuts/ShortcutLoader.java +++ /dev/null @@ -1,426 +0,0 @@ -/* - * Copyright (C) 2022 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.android.intentresolver.shortcuts; - -import android.app.ActivityManager; -import android.app.prediction.AppPredictor; -import android.app.prediction.AppTarget; -import android.content.ComponentName; -import android.content.Context; -import android.content.IntentFilter; -import android.content.pm.ApplicationInfo; -import android.content.pm.PackageManager; -import android.content.pm.PackageManager.ApplicationInfoFlags; -import android.content.pm.PackageManager.NameNotFoundException; -import android.content.pm.ShortcutInfo; -import android.content.pm.ShortcutManager; -import android.os.AsyncTask; -import android.os.UserHandle; -import android.os.UserManager; -import android.service.chooser.ChooserTarget; -import android.text.TextUtils; -import android.util.Log; - -import androidx.annotation.MainThread; -import androidx.annotation.Nullable; -import androidx.annotation.VisibleForTesting; -import androidx.annotation.WorkerThread; - -import com.android.intentresolver.chooser.DisplayResolveInfo; - -import java.util.ArrayList; -import java.util.HashMap; -import java.util.List; -import java.util.Map; -import java.util.concurrent.Executor; -import java.util.concurrent.atomic.AtomicReference; -import java.util.function.Consumer; - -/** - * Encapsulates shortcuts loading logic from either AppPredictor or ShortcutManager. - *

- * A ShortcutLoader instance can be viewed as a per-profile singleton hot stream of shortcut - * updates. The shortcut loading is triggered by the {@link #queryShortcuts(DisplayResolveInfo[])}, - * the processing will happen on the {@link #mBackgroundExecutor} and the result is delivered - * through the {@link #mCallback} on the {@link #mCallbackExecutor}, the main thread. - *

- *

- * The current version does not improve on the legacy in a way that it does not guarantee that - * each invocation of the {@link #queryShortcuts(DisplayResolveInfo[])} will be matched by an - * invocation of the callback (there are early terminations of the flow). Also, the fetched - * shortcuts would be matched against the last known input, i.e. two invocations of - * {@link #queryShortcuts(DisplayResolveInfo[])} may result in two callbacks where shortcuts are - * processed against the latest input. - *

- */ -public class ShortcutLoader { - private static final String TAG = "ChooserActivity"; - - private static final Request NO_REQUEST = new Request(new DisplayResolveInfo[0]); - - private final Context mContext; - @Nullable - private final AppPredictorProxy mAppPredictor; - private final UserHandle mUserHandle; - @Nullable - private final IntentFilter mTargetIntentFilter; - private final Executor mBackgroundExecutor; - private final Executor mCallbackExecutor; - private final boolean mIsPersonalProfile; - private final ShortcutToChooserTargetConverter mShortcutToChooserTargetConverter = - new ShortcutToChooserTargetConverter(); - private final UserManager mUserManager; - private final AtomicReference> mCallback = new AtomicReference<>(); - private final AtomicReference mActiveRequest = new AtomicReference<>(NO_REQUEST); - - @Nullable - private final AppPredictor.Callback mAppPredictorCallback; - - @MainThread - public ShortcutLoader( - Context context, - @Nullable AppPredictor appPredictor, - UserHandle userHandle, - @Nullable IntentFilter targetIntentFilter, - Consumer callback) { - this( - context, - appPredictor == null ? null : new AppPredictorProxy(appPredictor), - userHandle, - userHandle.equals(UserHandle.of(ActivityManager.getCurrentUser())), - targetIntentFilter, - AsyncTask.SERIAL_EXECUTOR, - context.getMainExecutor(), - callback); - } - - @VisibleForTesting - ShortcutLoader( - Context context, - @Nullable AppPredictorProxy appPredictor, - UserHandle userHandle, - boolean isPersonalProfile, - @Nullable IntentFilter targetIntentFilter, - Executor backgroundExecutor, - Executor callbackExecutor, - Consumer callback) { - mContext = context; - mAppPredictor = appPredictor; - mUserHandle = userHandle; - mTargetIntentFilter = targetIntentFilter; - mBackgroundExecutor = backgroundExecutor; - mCallbackExecutor = callbackExecutor; - mCallback.set(callback); - mIsPersonalProfile = isPersonalProfile; - mUserManager = (UserManager) mContext.getSystemService(Context.USER_SERVICE); - - if (mAppPredictor != null) { - mAppPredictorCallback = createAppPredictorCallback(); - mAppPredictor.registerPredictionUpdates(mCallbackExecutor, mAppPredictorCallback); - } else { - mAppPredictorCallback = null; - } - } - - /** - * Unsubscribe from app predictor if one was provided. - */ - @MainThread - public void destroy() { - if (mCallback.getAndSet(null) != null) { - if (mAppPredictor != null) { - mAppPredictor.unregisterPredictionUpdates(mAppPredictorCallback); - } - } - } - - private boolean isDestroyed() { - return mCallback.get() == null; - } - - /** - * Set new resolved targets. This will trigger shortcut loading. - * @param appTargets a collection of application targets a loaded set of shortcuts will be - * grouped against - */ - @MainThread - public void queryShortcuts(DisplayResolveInfo[] appTargets) { - if (isDestroyed()) { - return; - } - mActiveRequest.set(new Request(appTargets)); - mBackgroundExecutor.execute(this::loadShortcuts); - } - - @WorkerThread - private void loadShortcuts() { - // no need to query direct share for work profile when its locked or disabled - if (!shouldQueryDirectShareTargets()) { - return; - } - Log.d(TAG, "querying direct share targets"); - queryDirectShareTargets(false); - } - - @WorkerThread - private void queryDirectShareTargets(boolean skipAppPredictionService) { - if (isDestroyed()) { - return; - } - if (!skipAppPredictionService && mAppPredictor != null) { - mAppPredictor.requestPredictionUpdate(); - return; - } - // Default to just querying ShortcutManager if AppPredictor not present. - if (mTargetIntentFilter == null) { - return; - } - - Context selectedProfileContext = mContext.createContextAsUser(mUserHandle, 0 /* flags */); - ShortcutManager sm = (ShortcutManager) selectedProfileContext - .getSystemService(Context.SHORTCUT_SERVICE); - List shortcuts = - sm.getShareTargets(mTargetIntentFilter); - sendShareShortcutInfoList(shortcuts, false, null); - } - - private AppPredictor.Callback createAppPredictorCallback() { - return appPredictorTargets -> { - if (appPredictorTargets.isEmpty() && shouldQueryDirectShareTargets()) { - // APS may be disabled, so try querying targets ourselves. - queryDirectShareTargets(true); - return; - } - - final List shortcuts = new ArrayList<>(); - List shortcutResults = new ArrayList<>(); - for (AppTarget appTarget : appPredictorTargets) { - if (appTarget.getShortcutInfo() == null) { - continue; - } - shortcutResults.add(appTarget); - } - appPredictorTargets = shortcutResults; - for (AppTarget appTarget : appPredictorTargets) { - shortcuts.add(new ShortcutManager.ShareShortcutInfo( - appTarget.getShortcutInfo(), - new ComponentName(appTarget.getPackageName(), appTarget.getClassName()))); - } - sendShareShortcutInfoList(shortcuts, true, appPredictorTargets); - }; - } - - @WorkerThread - private void sendShareShortcutInfoList( - List shortcuts, - boolean isFromAppPredictor, - @Nullable List appPredictorTargets) { - if (appPredictorTargets != null && appPredictorTargets.size() != shortcuts.size()) { - throw new RuntimeException("resultList and appTargets must have the same size." - + " resultList.size()=" + shortcuts.size() - + " appTargets.size()=" + appPredictorTargets.size()); - } - Context selectedProfileContext = mContext.createContextAsUser(mUserHandle, 0 /* flags */); - for (int i = shortcuts.size() - 1; i >= 0; i--) { - final String packageName = shortcuts.get(i).getTargetComponent().getPackageName(); - if (!isPackageEnabled(selectedProfileContext, packageName)) { - shortcuts.remove(i); - if (appPredictorTargets != null) { - appPredictorTargets.remove(i); - } - } - } - - HashMap directShareAppTargetCache = new HashMap<>(); - HashMap directShareShortcutInfoCache = new HashMap<>(); - // Match ShareShortcutInfos with DisplayResolveInfos to be able to use the old code path - // for direct share targets. After ShareSheet is refactored we should use the - // ShareShortcutInfos directly. - final DisplayResolveInfo[] appTargets = mActiveRequest.get().appTargets; - List resultRecords = new ArrayList<>(); - for (DisplayResolveInfo displayResolveInfo : appTargets) { - List matchingShortcuts = - filterShortcutsByTargetComponentName( - shortcuts, displayResolveInfo.getResolvedComponentName()); - if (matchingShortcuts.isEmpty()) { - continue; - } - - List chooserTargets = mShortcutToChooserTargetConverter - .convertToChooserTarget( - matchingShortcuts, - shortcuts, - appPredictorTargets, - directShareAppTargetCache, - directShareShortcutInfoCache); - - ShortcutResultInfo resultRecord = - new ShortcutResultInfo(displayResolveInfo, chooserTargets); - resultRecords.add(resultRecord); - } - - postReport( - new Result( - isFromAppPredictor, - appTargets, - resultRecords.toArray(new ShortcutResultInfo[0]), - directShareAppTargetCache, - directShareShortcutInfoCache)); - } - - private void postReport(Result result) { - mCallbackExecutor.execute(() -> report(result)); - } - - @MainThread - private void report(Result result) { - Consumer callback = mCallback.get(); - if (callback != null) { - callback.accept(result); - } - } - - /** - * Returns {@code false} if {@code userHandle} is the work profile and it's either - * in quiet mode or not running. - */ - private boolean shouldQueryDirectShareTargets() { - return mIsPersonalProfile || isProfileActive(); - } - - @VisibleForTesting - protected boolean isProfileActive() { - return mUserManager.isUserRunning(mUserHandle) - && mUserManager.isUserUnlocked(mUserHandle) - && !mUserManager.isQuietModeEnabled(mUserHandle); - } - - private static boolean isPackageEnabled(Context context, String packageName) { - if (TextUtils.isEmpty(packageName)) { - return false; - } - ApplicationInfo appInfo; - try { - appInfo = context.getPackageManager().getApplicationInfo( - packageName, - ApplicationInfoFlags.of(PackageManager.GET_META_DATA)); - } catch (NameNotFoundException e) { - return false; - } - - return appInfo != null && appInfo.enabled - && (appInfo.flags & ApplicationInfo.FLAG_SUSPENDED) == 0; - } - - private static List filterShortcutsByTargetComponentName( - List allShortcuts, ComponentName requiredTarget) { - List matchingShortcuts = new ArrayList<>(); - for (ShortcutManager.ShareShortcutInfo shortcut : allShortcuts) { - if (requiredTarget.equals(shortcut.getTargetComponent())) { - matchingShortcuts.add(shortcut); - } - } - return matchingShortcuts; - } - - private static class Request { - public final DisplayResolveInfo[] appTargets; - - Request(DisplayResolveInfo[] targets) { - appTargets = targets; - } - } - - /** - * Resolved shortcuts with corresponding app targets. - */ - public static class Result { - public final boolean isFromAppPredictor; - /** - * Input app targets (see {@link ShortcutLoader#queryShortcuts(DisplayResolveInfo[])} the - * shortcuts were process against. - */ - public final DisplayResolveInfo[] appTargets; - /** - * Shortcuts grouped by app target. - */ - public final ShortcutResultInfo[] shortcutsByApp; - public final Map directShareAppTargetCache; - public final Map directShareShortcutInfoCache; - - @VisibleForTesting - public Result( - boolean isFromAppPredictor, - DisplayResolveInfo[] appTargets, - ShortcutResultInfo[] shortcutsByApp, - Map directShareAppTargetCache, - Map directShareShortcutInfoCache) { - this.isFromAppPredictor = isFromAppPredictor; - this.appTargets = appTargets; - this.shortcutsByApp = shortcutsByApp; - this.directShareAppTargetCache = directShareAppTargetCache; - this.directShareShortcutInfoCache = directShareShortcutInfoCache; - } - } - - /** - * Shortcuts grouped by app. - */ - public static class ShortcutResultInfo { - public final DisplayResolveInfo appTarget; - public final List shortcuts; - - public ShortcutResultInfo(DisplayResolveInfo appTarget, List shortcuts) { - this.appTarget = appTarget; - this.shortcuts = shortcuts; - } - } - - /** - * A wrapper around AppPredictor to facilitate unit-testing. - */ - @VisibleForTesting - public static class AppPredictorProxy { - private final AppPredictor mAppPredictor; - - AppPredictorProxy(AppPredictor appPredictor) { - mAppPredictor = appPredictor; - } - - /** - * {@link AppPredictor#registerPredictionUpdates} - */ - public void registerPredictionUpdates( - Executor callbackExecutor, AppPredictor.Callback callback) { - mAppPredictor.registerPredictionUpdates(callbackExecutor, callback); - } - - /** - * {@link AppPredictor#unregisterPredictionUpdates} - */ - public void unregisterPredictionUpdates(AppPredictor.Callback callback) { - mAppPredictor.unregisterPredictionUpdates(callback); - } - - /** - * {@link AppPredictor#requestPredictionUpdate} - */ - public void requestPredictionUpdate() { - mAppPredictor.requestPredictionUpdate(); - } - } -} diff --git a/java/src/com/android/intentresolver/shortcuts/ShortcutLoader.kt b/java/src/com/android/intentresolver/shortcuts/ShortcutLoader.kt new file mode 100644 index 00000000..6f7542f1 --- /dev/null +++ b/java/src/com/android/intentresolver/shortcuts/ShortcutLoader.kt @@ -0,0 +1,326 @@ +/* + * Copyright (C) 2022 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.android.intentresolver.shortcuts + +import android.app.ActivityManager +import android.app.prediction.AppPredictor +import android.app.prediction.AppTarget +import android.content.ComponentName +import android.content.Context +import android.content.IntentFilter +import android.content.pm.ApplicationInfo +import android.content.pm.PackageManager +import android.content.pm.ShortcutInfo +import android.content.pm.ShortcutManager +import android.content.pm.ShortcutManager.ShareShortcutInfo +import android.os.AsyncTask +import android.os.UserHandle +import android.os.UserManager +import android.service.chooser.ChooserTarget +import android.text.TextUtils +import android.util.Log +import androidx.annotation.MainThread +import androidx.annotation.OpenForTesting +import androidx.annotation.VisibleForTesting +import androidx.annotation.WorkerThread +import com.android.intentresolver.chooser.DisplayResolveInfo +import java.lang.RuntimeException +import java.util.ArrayList +import java.util.HashMap +import java.util.concurrent.Executor +import java.util.concurrent.atomic.AtomicReference +import java.util.function.Consumer + +/** + * Encapsulates shortcuts loading logic from either AppPredictor or ShortcutManager. + * + * + * A ShortcutLoader instance can be viewed as a per-profile singleton hot stream of shortcut + * updates. The shortcut loading is triggered by the [queryShortcuts], + * the processing will happen on the [backgroundExecutor] and the result is delivered + * through the [callback] on the [callbackExecutor], the main thread. + * + * + * The current version does not improve on the legacy in a way that it does not guarantee that + * each invocation of the [queryShortcuts] will be matched by an + * invocation of the callback (there are early terminations of the flow). Also, the fetched + * shortcuts would be matched against the last known input, i.e. two invocations of + * [queryShortcuts] may result in two callbacks where shortcuts are + * processed against the latest input. + * + */ +@OpenForTesting +open class ShortcutLoader @VisibleForTesting constructor( + private val context: Context, + private val appPredictor: AppPredictorProxy?, + private val userHandle: UserHandle, + private val isPersonalProfile: Boolean, + private val targetIntentFilter: IntentFilter?, + private val backgroundExecutor: Executor, + private val callbackExecutor: Executor, + private val callback: Consumer +) { + private val shortcutToChooserTargetConverter = ShortcutToChooserTargetConverter() + private val userManager = context.getSystemService(Context.USER_SERVICE) as UserManager + private val activeRequest = AtomicReference(NO_REQUEST) + private val appPredictorCallback = AppPredictor.Callback { onAppPredictorCallback(it) } + private var isDestroyed = false + + @MainThread + constructor( + context: Context, + appPredictor: AppPredictor?, + userHandle: UserHandle, + targetIntentFilter: IntentFilter?, + callback: Consumer + ) : this( + context, + appPredictor?.let { AppPredictorProxy(it) }, + userHandle, userHandle == UserHandle.of(ActivityManager.getCurrentUser()), + targetIntentFilter, + AsyncTask.SERIAL_EXECUTOR, + context.mainExecutor, + callback + ) + + init { + appPredictor?.registerPredictionUpdates(callbackExecutor, appPredictorCallback) + } + + /** + * Unsubscribe from app predictor if one was provided. + */ + @OpenForTesting + @MainThread + open fun destroy() { + isDestroyed = true + appPredictor?.unregisterPredictionUpdates(appPredictorCallback) + } + + /** + * Set new resolved targets. This will trigger shortcut loading. + * @param appTargets a collection of application targets a loaded set of shortcuts will be + * grouped against + */ + @OpenForTesting + @MainThread + open fun queryShortcuts(appTargets: Array) { + if (isDestroyed) return + activeRequest.set(Request(appTargets)) + backgroundExecutor.execute { loadShortcuts() } + } + + @WorkerThread + private fun loadShortcuts() { + // no need to query direct share for work profile when its locked or disabled + if (!shouldQueryDirectShareTargets()) return + Log.d(TAG, "querying direct share targets") + queryDirectShareTargets(false) + } + + @WorkerThread + private fun queryDirectShareTargets(skipAppPredictionService: Boolean) { + if (!skipAppPredictionService && appPredictor != null) { + appPredictor.requestPredictionUpdate() + return + } + // Default to just querying ShortcutManager if AppPredictor not present. + if (targetIntentFilter == null) return + val shortcuts = queryShortcutManager(targetIntentFilter) + sendShareShortcutInfoList(shortcuts, false, null) + } + + @WorkerThread + private fun queryShortcutManager(targetIntentFilter: IntentFilter): List { + val selectedProfileContext = context.createContextAsUser(userHandle, 0 /* flags */) + val sm = selectedProfileContext + .getSystemService(Context.SHORTCUT_SERVICE) as ShortcutManager? + val pm = context.createContextAsUser(userHandle, 0 /* flags */).packageManager + return sm?.getShareTargets(targetIntentFilter) + ?.filter { pm.isPackageEnabled(it.targetComponent.packageName) } + ?: emptyList() + } + + @WorkerThread + private fun onAppPredictorCallback(appPredictorTargets: List) { + if (appPredictorTargets.isEmpty() && shouldQueryDirectShareTargets()) { + // APS may be disabled, so try querying targets ourselves. + queryDirectShareTargets(true) + return + } + val pm = context.createContextAsUser(userHandle, 0).packageManager + val pair = appPredictorTargets.toShortcuts(pm) + sendShareShortcutInfoList(pair.shortcuts, true, pair.appTargets) + } + + @WorkerThread + private fun List.toShortcuts(pm: PackageManager): ShortcutsAppTargetsPair = + fold( + ShortcutsAppTargetsPair(ArrayList(size), ArrayList(size)) + ) { acc, appTarget -> + val shortcutInfo = appTarget.shortcutInfo + val packageName = appTarget.packageName + val className = appTarget.className + if (shortcutInfo != null && className != null && pm.isPackageEnabled(packageName)) { + (acc.shortcuts as ArrayList).add( + ShareShortcutInfo(shortcutInfo, ComponentName(packageName, className)) + ) + (acc.appTargets as ArrayList).add(appTarget) + } + acc + } + + @WorkerThread + private fun sendShareShortcutInfoList( + shortcuts: List, + isFromAppPredictor: Boolean, + appPredictorTargets: List? + ) { + if (appPredictorTargets != null && appPredictorTargets.size != shortcuts.size) { + throw RuntimeException( + "resultList and appTargets must have the same size." + + " resultList.size()=" + shortcuts.size + + " appTargets.size()=" + appPredictorTargets.size + ) + } + val directShareAppTargetCache = HashMap() + val directShareShortcutInfoCache = HashMap() + // Match ShareShortcutInfos with DisplayResolveInfos to be able to use the old code path + // for direct share targets. After ShareSheet is refactored we should use the + // ShareShortcutInfos directly. + val appTargets = activeRequest.get().appTargets + val resultRecords: MutableList = ArrayList() + for (displayResolveInfo in appTargets) { + val matchingShortcuts = shortcuts.filter { + it.targetComponent == displayResolveInfo.resolvedComponentName + } + if (matchingShortcuts.isEmpty()) continue + val chooserTargets = shortcutToChooserTargetConverter.convertToChooserTarget( + matchingShortcuts, + shortcuts, + appPredictorTargets, + directShareAppTargetCache, + directShareShortcutInfoCache + ) + val resultRecord = ShortcutResultInfo(displayResolveInfo, chooserTargets) + resultRecords.add(resultRecord) + } + postReport( + Result( + isFromAppPredictor, + appTargets, + resultRecords.toTypedArray(), + directShareAppTargetCache, + directShareShortcutInfoCache + ) + ) + } + + private fun postReport(result: Result) = callbackExecutor.execute { report(result) } + + @MainThread + private fun report(result: Result) { + if (isDestroyed) return + callback.accept(result) + } + + /** + * Returns `false` if `userHandle` is the work profile and it's either + * in quiet mode or not running. + */ + private fun shouldQueryDirectShareTargets(): Boolean = isPersonalProfile || isProfileActive + + @get:VisibleForTesting + protected val isProfileActive: Boolean + get() = userManager.isUserRunning(userHandle) + && userManager.isUserUnlocked(userHandle) + && !userManager.isQuietModeEnabled(userHandle) + + private class Request(val appTargets: Array) + + /** + * Resolved shortcuts with corresponding app targets. + */ + class Result( + val isFromAppPredictor: Boolean, + /** + * Input app targets (see [ShortcutLoader.queryShortcuts] the + * shortcuts were process against. + */ + val appTargets: Array, + /** + * Shortcuts grouped by app target. + */ + val shortcutsByApp: Array, + val directShareAppTargetCache: Map, + val directShareShortcutInfoCache: Map + ) + + /** + * Shortcuts grouped by app. + */ + class ShortcutResultInfo( + val appTarget: DisplayResolveInfo, + val shortcuts: List + ) + + private class ShortcutsAppTargetsPair( + val shortcuts: List, + val appTargets: List? + ) + + /** + * A wrapper around AppPredictor to facilitate unit-testing. + */ + @VisibleForTesting + open class AppPredictorProxy internal constructor(private val mAppPredictor: AppPredictor) { + /** + * [AppPredictor.registerPredictionUpdates] + */ + open fun registerPredictionUpdates( + callbackExecutor: Executor, callback: AppPredictor.Callback + ) = mAppPredictor.registerPredictionUpdates(callbackExecutor, callback) + + /** + * [AppPredictor.unregisterPredictionUpdates] + */ + open fun unregisterPredictionUpdates(callback: AppPredictor.Callback) = + mAppPredictor.unregisterPredictionUpdates(callback) + + /** + * [AppPredictor.requestPredictionUpdate] + */ + open fun requestPredictionUpdate() = mAppPredictor.requestPredictionUpdate() + } + + companion object { + private const val TAG = "ShortcutLoader" + private val NO_REQUEST = Request(arrayOf()) + + private fun PackageManager.isPackageEnabled(packageName: String): Boolean { + if (TextUtils.isEmpty(packageName)) { + return false + } + return runCatching { + val appInfo = getApplicationInfo( + packageName, + PackageManager.ApplicationInfoFlags.of(PackageManager.GET_META_DATA.toLong()) + ) + appInfo.enabled && (appInfo.flags and ApplicationInfo.FLAG_SUSPENDED) == 0 + }.getOrDefault(false) + } + } +} diff --git a/java/tests/src/com/android/intentresolver/shortcuts/ShortcutLoaderTest.kt b/java/tests/src/com/android/intentresolver/shortcuts/ShortcutLoaderTest.kt index 5756a0cd..0c817cb2 100644 --- a/java/tests/src/com/android/intentresolver/shortcuts/ShortcutLoaderTest.kt +++ b/java/tests/src/com/android/intentresolver/shortcuts/ShortcutLoaderTest.kt @@ -28,6 +28,8 @@ import android.os.UserHandle import android.os.UserManager import androidx.test.filters.SmallTest import com.android.intentresolver.any +import com.android.intentresolver.argumentCaptor +import com.android.intentresolver.capture import com.android.intentresolver.chooser.DisplayResolveInfo import com.android.intentresolver.createAppTarget import com.android.intentresolver.createShareShortcutInfo @@ -39,8 +41,8 @@ import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse import org.junit.Assert.assertTrue import org.junit.Test -import org.mockito.ArgumentCaptor import org.mockito.Mockito.anyInt +import org.mockito.Mockito.atLeastOnce import org.mockito.Mockito.never import org.mockito.Mockito.times import org.mockito.Mockito.verify @@ -56,9 +58,15 @@ class ShortcutLoaderTest { private val pm = mock { whenever(getApplicationInfo(any(), any())).thenReturn(appInfo) } + val userManager = mock { + whenever(isUserRunning(any())).thenReturn(true) + whenever(isUserUnlocked(any())).thenReturn(true) + whenever(isQuietModeEnabled(any())).thenReturn(false) + } private val context = mock { whenever(packageManager).thenReturn(pm) whenever(createContextAsUser(any(), anyInt())).thenReturn(this) + whenever(getSystemService(Context.USER_SERVICE)).thenReturn(userManager) } private val executor = ImmediateExecutor() private val intentFilter = mock() @@ -66,7 +74,7 @@ class ShortcutLoaderTest { private val callback = mock>() @Test - fun test_app_predictor_result() { + fun test_queryShortcuts_result_consistency_with_AppPredictor() { val componentName = ComponentName("pkg", "Class") val appTarget = mock { whenever(resolvedComponentName).thenReturn(componentName) @@ -85,24 +93,22 @@ class ShortcutLoaderTest { testSubject.queryShortcuts(appTargets) - verify(appPredictor, times(1)).requestPredictionUpdate() - val appPredictorCallbackCaptor = ArgumentCaptor.forClass(AppPredictor.Callback::class.java) - verify(appPredictor, times(1)) - .registerPredictionUpdates(any(), appPredictorCallbackCaptor.capture()) - val matchingShortcutInfo = createShortcutInfo("id-0", componentName, 1) val matchingAppTarget = createAppTarget(matchingShortcutInfo) val shortcuts = listOf( matchingAppTarget, - // mismatching shortcut + // 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.forClass(ShortcutLoader.Result::class.java) - verify(callback, times(1)).accept(resultCaptor.capture()) + val resultCaptor = argumentCaptor() + verify(callback, times(1)).accept(capture(resultCaptor)) val result = resultCaptor.value assertTrue("An app predictor result is expected", result.isFromAppPredictor) @@ -124,7 +130,7 @@ class ShortcutLoaderTest { } @Test - fun test_shortcut_manager_result() { + fun test_queryShortcuts_result_consistency_with_ShortcutManager() { val componentName = ComponentName("pkg", "Class") val appTarget = mock { whenever(resolvedComponentName).thenReturn(componentName) @@ -153,8 +159,8 @@ class ShortcutLoaderTest { testSubject.queryShortcuts(appTargets) - val resultCaptor = ArgumentCaptor.forClass(ShortcutLoader.Result::class.java) - verify(callback, times(1)).accept(resultCaptor.capture()) + val resultCaptor = argumentCaptor() + verify(callback, times(1)).accept(capture(resultCaptor)) val result = resultCaptor.value assertFalse("An ShortcutManager result is expected", result.isFromAppPredictor) @@ -175,7 +181,7 @@ class ShortcutLoaderTest { } @Test - fun test_fallback_to_shortcut_manager() { + fun test_queryShortcuts_falls_back_to_ShortcutManager_on_empty_reply() { val componentName = ComponentName("pkg", "Class") val appTarget = mock { whenever(resolvedComponentName).thenReturn(componentName) @@ -205,13 +211,13 @@ class ShortcutLoaderTest { testSubject.queryShortcuts(appTargets) verify(appPredictor, times(1)).requestPredictionUpdate() - val appPredictorCallbackCaptor = ArgumentCaptor.forClass(AppPredictor.Callback::class.java) + val appPredictorCallbackCaptor = argumentCaptor() verify(appPredictor, times(1)) - .registerPredictionUpdates(any(), appPredictorCallbackCaptor.capture()) + .registerPredictionUpdates(any(), capture(appPredictorCallbackCaptor)) appPredictorCallbackCaptor.value.onTargetsAvailable(emptyList()) - val resultCaptor = ArgumentCaptor.forClass(ShortcutLoader.Result::class.java) - verify(callback, times(1)).accept(resultCaptor.capture()) + val resultCaptor = argumentCaptor() + verify(callback, times(1)).accept(capture(resultCaptor)) val result = resultCaptor.value assertFalse("An ShortcutManager result is expected", result.isFromAppPredictor) @@ -232,32 +238,32 @@ class ShortcutLoaderTest { } @Test - fun test_do_not_call_services_for_not_running_work_profile() { + fun test_queryShortcuts_do_not_call_services_for_not_running_work_profile() { testDisabledWorkProfileDoNotCallSystem(isUserRunning = false) } @Test - fun test_do_not_call_services_for_locked_work_profile() { + fun test_queryShortcuts_do_not_call_services_for_locked_work_profile() { testDisabledWorkProfileDoNotCallSystem(isUserUnlocked = false) } @Test - fun test_do_not_call_services_if_quite_mode_is_enabled_for_work_profile() { + fun test_queryShortcuts_do_not_call_services_if_quite_mode_is_enabled_for_work_profile() { testDisabledWorkProfileDoNotCallSystem(isQuietModeEnabled = true) } @Test - fun test_call_services_for_not_running_main_profile() { + fun test_queryShortcuts_call_services_for_not_running_main_profile() { testAlwaysCallSystemForMainProfile(isUserRunning = false) } @Test - fun test_call_services_for_locked_main_profile() { + fun test_queryShortcuts_call_services_for_locked_main_profile() { testAlwaysCallSystemForMainProfile(isUserUnlocked = false) } @Test - fun test_call_services_if_quite_mode_is_enabled_for_main_profile() { + fun test_queryShortcuts_call_services_if_quite_mode_is_enabled_for_main_profile() { testAlwaysCallSystemForMainProfile(isQuietModeEnabled = true) } @@ -267,7 +273,7 @@ class ShortcutLoaderTest { isQuietModeEnabled: Boolean = false ) { val userHandle = UserHandle.of(10) - val userManager = mock { + with(userManager) { whenever(isUserRunning(userHandle)).thenReturn(isUserRunning) whenever(isUserUnlocked(userHandle)).thenReturn(isUserUnlocked) whenever(isQuietModeEnabled(userHandle)).thenReturn(isQuietModeEnabled) @@ -297,7 +303,7 @@ class ShortcutLoaderTest { isQuietModeEnabled: Boolean = false ) { val userHandle = UserHandle.of(10) - val userManager = mock { + with(userManager) { whenever(isUserRunning(userHandle)).thenReturn(isUserRunning) whenever(isUserUnlocked(userHandle)).thenReturn(isUserUnlocked) whenever(isQuietModeEnabled(userHandle)).thenReturn(isQuietModeEnabled) -- cgit v1.2.3-59-g8ed1b From baa428a9ba7a0a75414c6bc4504be83faa6e98da Mon Sep 17 00:00:00 2001 From: Andrey Epin Date: Mon, 23 Jan 2023 17:37:51 -0800 Subject: Add simple reselection action implementation Add a simple reselection action implementation under a compile-time flag with minimalistic UI -- a row above the action chips with a centerd text label. Bug: 262276456 Test: maual test that the feature is disabled on a regualr build Test: Rebuild the app with the flag enabled an use test applications to verify the functionality. Change-Id: I5d7c617524e7d20cc7f4bbdace118eacb9134c19 --- java/res/layout/chooser_grid_preview_file.xml | 9 ++++++ java/res/layout/chooser_grid_preview_image.xml | 9 ++++++ java/res/layout/chooser_grid_preview_text.xml | 9 ++++++ java/res/values/strings.xml | 7 +++++ java/res/values/styles.xml | 6 ++++ .../android/intentresolver/ChooserActivity.java | 32 +++++++++++++++++++++- .../intentresolver/ChooserContentPreviewUi.java | 15 ++++++++++ .../intentresolver/ChooserRequestParameters.java | 28 ++++++++++++++++++- 8 files changed, 113 insertions(+), 2 deletions(-) (limited to 'java/src') diff --git a/java/res/layout/chooser_grid_preview_file.xml b/java/res/layout/chooser_grid_preview_file.xml index e98c3273..94755114 100644 --- a/java/res/layout/chooser_grid_preview_file.xml +++ b/java/res/layout/chooser_grid_preview_file.xml @@ -68,6 +68,15 @@ android:singleLine="true"/> + + + + + + Use personal browser Use work browser + + + Select Files + + Select Images + + Select Text diff --git a/java/res/values/styles.xml b/java/res/values/styles.xml index cbbf406d..229512fa 100644 --- a/java/res/values/styles.xml +++ b/java/res/values/styles.xml @@ -46,4 +46,10 @@ @dimen/chooser_icon_size @dimen/chooser_badge_size + + diff --git a/java/src/com/android/intentresolver/ChooserActivity.java b/java/src/com/android/intentresolver/ChooserActivity.java index dce8bde1..5a1d2a39 100644 --- a/java/src/com/android/intentresolver/ChooserActivity.java +++ b/java/src/com/android/intentresolver/ChooserActivity.java @@ -160,6 +160,7 @@ public class ChooserActivity extends ResolverActivity implements private static final boolean DEBUG = true; static final boolean ENABLE_CUSTOM_ACTIONS = false; + static final boolean ENABLE_RESELECTION_ACTION = false; public static final String LAUNCH_LOCATION_DIRECT_SHARE = "direct_share"; private static final String SHORTCUT_TARGET = "shortcut_target"; @@ -267,7 +268,11 @@ public class ChooserActivity extends ResolverActivity implements try { mChooserRequest = new ChooserRequestParameters( - getIntent(), getReferrer(), getNearbySharingComponent(), ENABLE_CUSTOM_ACTIONS); + getIntent(), + getReferrer(), + getNearbySharingComponent(), + ENABLE_CUSTOM_ACTIONS, + ENABLE_RESELECTION_ACTION); } catch (IllegalArgumentException e) { Log.e(TAG, "Caller provided invalid Chooser request parameters", e); finish(); @@ -743,6 +748,18 @@ public class ChooserActivity extends ResolverActivity implements } return actions; } + + @Nullable + @Override + public Runnable getReselectionAction() { + if (!ENABLE_RESELECTION_ACTION) { + return null; + } + PendingIntent reselectionAction = mChooserRequest.getReselectionAction(); + return reselectionAction == null + ? null + : createReselectionRunnable(reselectionAction); + } }; ViewGroup layout = ChooserContentPreviewUi.displayContentPreview( @@ -962,6 +979,19 @@ public class ChooserActivity extends ResolverActivity implements ); } + private Runnable createReselectionRunnable(PendingIntent pendingIntent) { + return () -> { + try { + pendingIntent.send(); + } catch (PendingIntent.CanceledException e) { + Log.d(TAG, "Payload reselection action has been cancelled"); + } + // TODO: add reporting + setResult(RESULT_OK); + finish(); + }; + } + @Nullable private View getFirstVisibleImgPreviewView() { View firstImage = findViewById(com.android.internal.R.id.content_preview_image_1_large); diff --git a/java/src/com/android/intentresolver/ChooserContentPreviewUi.java b/java/src/com/android/intentresolver/ChooserContentPreviewUi.java index 27645da4..390c47c7 100644 --- a/java/src/com/android/intentresolver/ChooserContentPreviewUi.java +++ b/java/src/com/android/intentresolver/ChooserContentPreviewUi.java @@ -90,6 +90,12 @@ public final class ChooserContentPreviewUi { /** Create custom actions */ List createCustomActions(); + + /** + * Provides a re-selection action, if any. + */ + @Nullable + Runnable getReselectionAction(); } /** @@ -218,6 +224,15 @@ public final class ChooserContentPreviewUi { default: Log.e(TAG, "Unexpected content preview type: " + previewType); } + Runnable reselectionAction = actionFactory.getReselectionAction(); + if (reselectionAction != null && layout != null + && ChooserActivity.ENABLE_RESELECTION_ACTION) { + View reselectionView = layout.findViewById(R.id.reselection_action); + if (reselectionView != null) { + reselectionView.setVisibility(View.VISIBLE); + reselectionView.setOnClickListener(view -> reselectionAction.run()); + } + } return layout; } diff --git a/java/src/com/android/intentresolver/ChooserRequestParameters.java b/java/src/com/android/intentresolver/ChooserRequestParameters.java index a7e543a5..97bee82c 100644 --- a/java/src/com/android/intentresolver/ChooserRequestParameters.java +++ b/java/src/com/android/intentresolver/ChooserRequestParameters.java @@ -18,6 +18,7 @@ package com.android.intentresolver; import android.annotation.NonNull; import android.annotation.Nullable; +import android.app.PendingIntent; import android.content.ComponentName; import android.content.Intent; import android.content.IntentFilter; @@ -72,6 +73,7 @@ public class ChooserRequestParameters { private final ImmutableList mFilteredComponentNames; private final ImmutableList mCallerChooserTargets; private final ImmutableList mChooserActions; + private final PendingIntent mReselectionAction; private final boolean mRetainInOnStop; @Nullable @@ -99,7 +101,8 @@ public class ChooserRequestParameters { final Intent clientIntent, final Uri referrer, @Nullable final ComponentName nearbySharingComponent, - boolean extractCustomActions) { + boolean extractCustomActions, + boolean extractReslectionAction) { final Intent requestedTarget = parseTargetIntentExtra( clientIntent.getParcelableExtra(Intent.EXTRA_INTENT)); mTarget = intentWithModifiedLaunchFlags(requestedTarget); @@ -137,6 +140,9 @@ public class ChooserRequestParameters { mChooserActions = extractCustomActions ? getChooserActions(clientIntent) : ImmutableList.of(); + mReselectionAction = extractReslectionAction + ? getReselectionActionExtra(clientIntent) + : null; } public Intent getTargetIntent() { @@ -182,6 +188,11 @@ public class ChooserRequestParameters { return mChooserActions; } + @Nullable + public PendingIntent getReselectionAction() { + return mReselectionAction; + } + /** * Whether the {@link ChooserActivity.EXTRA_PRIVATE_RETAIN_IN_ON_STOP} behavior was requested. */ @@ -321,6 +332,21 @@ public class ChooserRequestParameters { .collect(toImmutableList()); } + @Nullable + private static PendingIntent getReselectionActionExtra(Intent intent) { + try { + return intent.getParcelableExtra( + Intent.EXTRA_CHOOSER_PAYLOAD_RESELECTION_ACTION, + PendingIntent.class); + } catch (Throwable t) { + Log.w( + TAG, + "Unable to retrieve Intent.EXTRA_CHOOSER_PAYLOAD_RESELECTION_ACTION argument", + t); + return null; + } + } + private static Collector> toImmutableList() { return Collectors.collectingAndThen(Collectors.toList(), ImmutableList::copyOf); } -- cgit v1.2.3-59-g8ed1b From 059dcd8ef365901b3d506da8343bd0e5653cc87e Mon Sep 17 00:00:00 2001 From: Andrey Epin Date: Thu, 12 Jan 2023 19:18:44 -0800 Subject: Generalize shared elements transition logic Update shared elements transition logic in a way that allows an ImagePreviewView implementation to specify multiple transition elements. Flag: IntentResolver package entirely behind the CHOOSER_UNBUNDLED which is in teamfood Bug: 262280076 Test: manual shcreenshot animation test Test atest IntentResolverUnitTests Change-Id: Ia7cf5634bb2d907c5cdb56a22f838447a158dd25 --- java/res/layout/image_preview_view.xml | 1 + .../android/intentresolver/ChooserActivity.java | 5 +-- .../intentresolver/ChooserContentPreviewUi.java | 44 ++++++++++--------- .../EnterTransitionAnimationDelegate.kt | 27 ++++++------ .../widget/ChooserImagePreviewView.kt | 49 ++++++++-------------- .../intentresolver/widget/ImagePreviewView.kt | 30 ++++++++----- .../intentresolver/widget/ViewExtensions.kt | 39 +++++++++++++++++ .../EnterTransitionAnimationDelegateTest.kt | 7 ++-- 8 files changed, 117 insertions(+), 85 deletions(-) create mode 100644 java/src/com/android/intentresolver/widget/ViewExtensions.kt (limited to 'java/src') diff --git a/java/res/layout/image_preview_view.xml b/java/res/layout/image_preview_view.xml index d2f94690..8730fc30 100644 --- a/java/res/layout/image_preview_view.xml +++ b/java/res/layout/image_preview_view.xml @@ -25,6 +25,7 @@ onTransitionTargetReady, + TransitionElementStatusCallback transitionElementStatusCallback, ContentResolver contentResolver, ImageMimeTypeClassifier imageClassifier) { ViewGroup layout = null; + if (previewType != CONTENT_PREVIEW_IMAGE) { + transitionElementStatusCallback.onAllTransitionElementsReady(); + } List customActions = actionFactory.createCustomActions(); switch (previewType) { case CONTENT_PREVIEW_TEXT: @@ -197,7 +201,7 @@ public final class ChooserContentPreviewUi { customActions), parent, previewImageLoader, - onTransitionTargetReady, + transitionElementStatusCallback, contentResolver, imageClassifier, actionRowLayout); @@ -326,7 +330,7 @@ public final class ChooserContentPreviewUi { List actions, ViewGroup parent, ImageLoader imageLoader, - Consumer onTransitionTargetReady, + TransitionElementStatusCallback transitionElementStatusCallback, ContentResolver contentResolver, ImageMimeTypeClassifier imageClassifier, @LayoutRes int actionRowLayout) { @@ -340,32 +344,26 @@ public final class ChooserContentPreviewUi { actionRow.setActions(actions); } - final ArrayList imageUris = new ArrayList<>(); String action = targetIntent.getAction(); - if (Intent.ACTION_SEND.equals(action)) { - // TODO: why don't we use image classifier in this case as well? - Uri uri = targetIntent.getParcelableExtra(Intent.EXTRA_STREAM); - imageUris.add(uri); - } else { - List uris = targetIntent.getParcelableArrayListExtra(Intent.EXTRA_STREAM); - for (Uri uri : uris) { - if (imageClassifier.isImageType(contentResolver.getType(uri))) { - imageUris.add(uri); - } - } - } + // TODO: why don't we use image classifier for single-element ACTION_SEND? + final List imageUris = Intent.ACTION_SEND.equals(action) + ? extractContentUris(targetIntent) + : extractContentUris(targetIntent) + .stream() + .filter(uri -> + imageClassifier.isImageType(contentResolver.getType(uri)) + ) + .collect(Collectors.toList()); if (imageUris.size() == 0) { Log.i(TAG, "Attempted to display image preview area with zero" + " available images detected in EXTRA_STREAM list"); ((View) imagePreview).setVisibility(View.GONE); - onTransitionTargetReady.accept(false); + transitionElementStatusCallback.onAllTransitionElementsReady(); return contentPreviewLayout; } - imagePreview.setSharedElementTransitionTarget( - ChooserActivity.FIRST_IMAGE_PREVIEW_TRANSITION_NAME, - onTransitionTargetReady); + imagePreview.setTransitionElementStatusCallback(transitionElementStatusCallback); imagePreview.setImages(imageUris, imageLoader); return contentPreviewLayout; @@ -398,7 +396,7 @@ public final class ChooserContentPreviewUi { ViewGroup contentPreviewLayout = (ViewGroup) layoutInflater.inflate( R.layout.chooser_grid_preview_file, parent, false); - List uris = extractFileUris(targetIntent); + List uris = extractContentUris(targetIntent); final int uriCount = uris.size(); if (uriCount == 0) { @@ -442,7 +440,7 @@ public final class ChooserContentPreviewUi { return contentPreviewLayout; } - private static List extractFileUris(Intent targetIntent) { + private static List extractContentUris(Intent targetIntent) { List uris = new ArrayList<>(); if (Intent.ACTION_SEND.equals(targetIntent.getAction())) { Uri uri = targetIntent.getParcelableExtra(Intent.EXTRA_STREAM); diff --git a/java/src/com/android/intentresolver/EnterTransitionAnimationDelegate.kt b/java/src/com/android/intentresolver/EnterTransitionAnimationDelegate.kt index 31aeea44..b1178aa5 100644 --- a/java/src/com/android/intentresolver/EnterTransitionAnimationDelegate.kt +++ b/java/src/com/android/intentresolver/EnterTransitionAnimationDelegate.kt @@ -19,6 +19,7 @@ import android.app.SharedElementCallback import android.view.View import androidx.activity.ComponentActivity import androidx.lifecycle.lifecycleScope +import com.android.intentresolver.widget.ImagePreviewView.TransitionElementStatusCallback import com.android.internal.annotations.VisibleForTesting import kotlinx.coroutines.Job import kotlinx.coroutines.delay @@ -29,13 +30,13 @@ import java.util.function.Supplier * A helper class to track app's readiness for the scene transition animation. * The app is ready when both the image is laid out and the drawer offset is calculated. */ -@VisibleForTesting +@VisibleForTesting(visibility = VisibleForTesting.Visibility.PACKAGE) class EnterTransitionAnimationDelegate( private val activity: ComponentActivity, private val transitionTargetSupplier: Supplier, -) : View.OnLayoutChangeListener { +) : View.OnLayoutChangeListener, TransitionElementStatusCallback { - private var removeSharedElements = false + private val transitionElements = HashSet() private var previewReady = false private var offsetCalculated = false private var timeoutJob: Job? = null @@ -65,14 +66,15 @@ class EnterTransitionAnimationDelegate( // We only mark the preview readiness and not the offset readiness // (see [#markOffsetCalculated()]) as this is what legacy logic, effectively, did. We might // want to review that aspect separately. - markImagePreviewReady(runTransitionAnimation = false) + onAllTransitionElementsReady() } - fun markImagePreviewReady(runTransitionAnimation: Boolean) { + override fun onTransitionElementReady(name: String) { + transitionElements.add(name) + } + + override fun onAllTransitionElementsReady() { timeoutJob?.cancel() - if (!runTransitionAnimation) { - removeSharedElements = true - } if (!previewReady) { previewReady = true maybeStartListenForLayout() @@ -90,11 +92,8 @@ class EnterTransitionAnimationDelegate( names: MutableList, sharedElements: MutableMap ) { - if (removeSharedElements) { - names.remove(ChooserActivity.FIRST_IMAGE_PREVIEW_TRANSITION_NAME) - sharedElements.remove(ChooserActivity.FIRST_IMAGE_PREVIEW_TRANSITION_NAME) - } - removeSharedElements = false + names.removeAll { !transitionElements.contains(it) } + sharedElements.entries.removeAll { !transitionElements.contains(it.key) } } private fun maybeStartListenForLayout() { @@ -119,7 +118,7 @@ class EnterTransitionAnimationDelegate( } private fun startPostponedEnterTransition() { - if (!removeSharedElements && activity.isActivityTransitionRunning) { + if (transitionElements.isNotEmpty() && activity.isActivityTransitionRunning) { // Disable the window animations as it interferes with the transition animation. activity.window.setWindowAnimations(0) } diff --git a/java/src/com/android/intentresolver/widget/ChooserImagePreviewView.kt b/java/src/com/android/intentresolver/widget/ChooserImagePreviewView.kt index dd1dd286..bf10bfaa 100644 --- a/java/src/com/android/intentresolver/widget/ChooserImagePreviewView.kt +++ b/java/src/com/android/intentresolver/widget/ChooserImagePreviewView.kt @@ -21,17 +21,15 @@ import android.content.Context import android.net.Uri import android.util.AttributeSet import android.view.LayoutInflater -import android.view.View -import android.view.ViewTreeObserver import android.view.animation.DecelerateInterpolator import android.widget.RelativeLayout import androidx.core.view.isVisible import com.android.intentresolver.R +import com.android.intentresolver.widget.ImagePreviewView.TransitionElementStatusCallback import kotlinx.coroutines.Job import kotlinx.coroutines.MainScope import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.launch -import java.util.function.Consumer import com.android.internal.R as IntR private const val IMAGE_FADE_IN_MILLIS = 150L @@ -56,7 +54,7 @@ class ChooserImagePreviewView : RelativeLayout, ImagePreviewView { private lateinit var thirdImage: RoundedRectImageView private var loadImageJob: Job? = null - private var onTransitionViewReadyCallback: Consumer? = null + private var transitionStatusElementCallback: TransitionElementStatusCallback? = null override fun onFinishInflate() { LayoutInflater.from(context).inflate(R.layout.image_preview_view, this, true) @@ -67,17 +65,12 @@ class ChooserImagePreviewView : RelativeLayout, ImagePreviewView { } /** - * Specifies a transition animation target name and a readiness callback. The callback will be - * invoked once when the view preparation is done i.e. either when an image is loaded into it - * and it is laid out (and it is ready to be draw) or image loading has failed. + * Specifies a transition animation target readiness callback. The callback will be + * invoked once when views preparation is done. * Should be called before [setImages]. - * @param name, transition name - * @param onViewReady, a callback that will be invoked with `true` if the view is ready to - * receive transition animation (the image was loaded successfully) and with `false` otherwise. */ - override fun setSharedElementTransitionTarget(name: String, onViewReady: Consumer) { - mainImage.transitionName = name - onTransitionViewReadyCallback = onViewReady + override fun setTransitionElementStatusCallback(callback: TransitionElementStatusCallback?) { + transitionStatusElementCallback = callback } override fun setImages(uris: List, imageLoader: ImageLoader) { @@ -97,7 +90,7 @@ class ChooserImagePreviewView : RelativeLayout, ImagePreviewView { secondLargeImage.isVisible = false secondSmallImage.isVisible = false thirdImage.isVisible = false - invokeTransitionViewReadyCallback(runTransitionAnimation = false) + invokeTransitionViewReadyCallback() } private suspend fun showOneImage(uris: List, imageLoader: ImageLoader) { @@ -138,7 +131,7 @@ class ChooserImagePreviewView : RelativeLayout, ImagePreviewView { if (bitmap == null) { view.isVisible = false if (view === mainImage) { - invokeTransitionViewReadyCallback(runTransitionAnimation = false) + invokeTransitionViewReadyCallback() } } else { view.isVisible = true @@ -150,26 +143,20 @@ class ChooserImagePreviewView : RelativeLayout, ImagePreviewView { duration = IMAGE_FADE_IN_MILLIS start() } - if (view === mainImage && onTransitionViewReadyCallback != null) { - setupPreDrawListener(mainImage) + if (view === mainImage && transitionStatusElementCallback != null) { + view.waitForPreDraw() + invokeTransitionViewReadyCallback() } } } - private fun setupPreDrawListener(view: View) { - view.viewTreeObserver.addOnPreDrawListener( - object : ViewTreeObserver.OnPreDrawListener { - override fun onPreDraw(): Boolean { - view.viewTreeObserver.removeOnPreDrawListener(this) - invokeTransitionViewReadyCallback(runTransitionAnimation = true) - return true - } + private fun invokeTransitionViewReadyCallback() { + transitionStatusElementCallback?.apply { + if (mainImage.isVisible && mainImage.drawable != null) { + mainImage.transitionName?.let { onTransitionElementReady(it) } } - ) - } - - private fun invokeTransitionViewReadyCallback(runTransitionAnimation: Boolean) { - onTransitionViewReadyCallback?.accept(runTransitionAnimation) - onTransitionViewReadyCallback = null + onAllTransitionElementsReady() + } + transitionStatusElementCallback = null } } diff --git a/java/src/com/android/intentresolver/widget/ImagePreviewView.kt b/java/src/com/android/intentresolver/widget/ImagePreviewView.kt index a5756054..a166ef27 100644 --- a/java/src/com/android/intentresolver/widget/ImagePreviewView.kt +++ b/java/src/com/android/intentresolver/widget/ImagePreviewView.kt @@ -18,22 +18,32 @@ package com.android.intentresolver.widget import android.graphics.Bitmap import android.net.Uri -import java.util.function.Consumer internal typealias ImageLoader = suspend (Uri) -> Bitmap? interface ImagePreviewView { + fun setTransitionElementStatusCallback(callback: TransitionElementStatusCallback?) + fun setImages(uris: List, imageLoader: ImageLoader) /** - * Specifies a transition animation target name and a readiness callback. The callback will be - * invoked once when the view preparation is done i.e. either when an image is loaded into it - * and it is laid out (and it is ready to be draw) or image loading has failed. - * Should be called before [setImages]. - * @param name, transition name - * @param onViewReady, a callback that will be invoked with `true` if the view is ready to - * receive transition animation (the image was loaded successfully) and with `false` otherwise. + * [ImagePreviewView] progressively prepares views for shared element transition and reports + * each successful preparation with [onTransitionElementReady] call followed by + * closing [onAllTransitionElementsReady] invocation. Thus the overall invocation pattern is + * zero or more [onTransitionElementReady] calls followed by the final + * [onAllTransitionElementsReady] call. */ - fun setSharedElementTransitionTarget(name: String, onViewReady: Consumer) + interface TransitionElementStatusCallback { + /** + * Invoked when a view for a shared transition animation element is ready i.e. the image + * is loaded and the view is laid out. + * @param name shared element name. + */ + fun onTransitionElementReady(name: String) - fun setImages(uris: List, imageLoader: ImageLoader) + /** + * Indicates that all supported transition elements have been reported with + * [onTransitionElementReady]. + */ + fun onAllTransitionElementsReady() + } } diff --git a/java/src/com/android/intentresolver/widget/ViewExtensions.kt b/java/src/com/android/intentresolver/widget/ViewExtensions.kt new file mode 100644 index 00000000..11b7c146 --- /dev/null +++ b/java/src/com/android/intentresolver/widget/ViewExtensions.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.widget + +import android.util.Log +import android.view.View +import androidx.core.view.OneShotPreDrawListener +import kotlinx.coroutines.suspendCancellableCoroutine +import java.util.concurrent.atomic.AtomicBoolean + +internal suspend fun View.waitForPreDraw(): Unit = suspendCancellableCoroutine { continuation -> + val isResumed = AtomicBoolean(false) + val callback = OneShotPreDrawListener.add( + this, + Runnable { + if (isResumed.compareAndSet(false, true)) { + continuation.resumeWith(Result.success(Unit)) + } else { + // it's not really expected but in some unknown corner-case let's not crash + Log.e("waitForPreDraw", "An attempt to resume a completed coroutine", Exception()) + } + } + ) + continuation.invokeOnCancellation { callback.removeListener() } +} diff --git a/java/tests/src/com/android/intentresolver/EnterTransitionAnimationDelegateTest.kt b/java/tests/src/com/android/intentresolver/EnterTransitionAnimationDelegateTest.kt index ffe89400..9ea9dfa7 100644 --- a/java/tests/src/com/android/intentresolver/EnterTransitionAnimationDelegateTest.kt +++ b/java/tests/src/com/android/intentresolver/EnterTransitionAnimationDelegateTest.kt @@ -39,6 +39,7 @@ private const val TIMEOUT_MS = 200 @OptIn(ExperimentalCoroutinesApi::class) class EnterTransitionAnimationDelegateTest { + private val elementName = "shared-element" private val scheduler = TestCoroutineScheduler() private val dispatcher = StandardTestDispatcher(scheduler) private val lifecycleOwner = TestLifecycleOwner() @@ -89,9 +90,9 @@ class EnterTransitionAnimationDelegateTest { fun test_postponeTransition_animation_resumes_only_once() { testSubject.postponeTransition() testSubject.markOffsetCalculated() - testSubject.markImagePreviewReady(true) + testSubject.onTransitionElementReady(elementName) testSubject.markOffsetCalculated() - testSubject.markImagePreviewReady(true) + testSubject.onTransitionElementReady(elementName) scheduler.advanceTimeBy(TIMEOUT_MS + 1L) verify(activity, times(1)).startPostponedEnterTransition() @@ -105,7 +106,7 @@ class EnterTransitionAnimationDelegateTest { testSubject.markOffsetCalculated() verify(activity, never()).startPostponedEnterTransition() - testSubject.markImagePreviewReady(true) + testSubject.onAllTransitionElementsReady() verify(activity, times(1)).startPostponedEnterTransition() } } -- cgit v1.2.3-59-g8ed1b From 03c8853c0902f5226af7fa36d7e2c7a45837efd1 Mon Sep 17 00:00:00 2001 From: Andrey Epin Date: Tue, 24 Jan 2023 20:55:16 -0800 Subject: Fix pinning dialog for Chooser direct targets Show target pinning dialog for direct share targets. Fix: 266647478 Test: manual testing Test: a new unit test Change-Id: I3ae25d7ce9c6d4c00aada2063eb3f285f4d3ea26 --- java/res/layout/chooser_dialog.xml | 1 + .../android/intentresolver/ChooserActivity.java | 6 +- .../UnbundledChooserActivityTest.java | 67 ++++++++++++++++++++++ 3 files changed, 71 insertions(+), 3 deletions(-) (limited to 'java/src') diff --git a/java/res/layout/chooser_dialog.xml b/java/res/layout/chooser_dialog.xml index e31712c7..19ead35a 100644 --- a/java/res/layout/chooser_dialog.xml +++ b/java/res/layout/chooser_dialog.xml @@ -18,6 +18,7 @@ resolvedComponentInfos = createResolvedComponentsForTest(2); + when( + ChooserActivityOverrideData + .getInstance() + .resolverListController + .getResolversForIntent( + Mockito.anyBoolean(), + Mockito.anyBoolean(), + Mockito.anyBoolean(), + Mockito.isA(List.class))) + .thenReturn(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)) + .queryShortcuts(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(); -- cgit v1.2.3-59-g8ed1b From 7073378c7c3d430871fbcc8821a0250bc79b5acc Mon Sep 17 00:00:00 2001 From: Matt Casey Date: Thu, 26 Jan 2023 15:20:50 +0000 Subject: Send a regular ResultReceiver instead of a subclass. With unbundled chooser, apps can't load the subclass when unparceling, but we don't actually need the subclass on that end. Parcel and unparcel on this end ahead of time to get the vanilla ResultRecevier representation to send. Test: atest CtsSharesheetDeviceTest (with unbundled flag on) Bug: 266716256 Change-Id: I498e98bbf680e98d58000af1a980d7ba7b4480a5 --- java/src/com/android/intentresolver/ChooserActivity.java | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) (limited to 'java/src') diff --git a/java/src/com/android/intentresolver/ChooserActivity.java b/java/src/com/android/intentresolver/ChooserActivity.java index f7f131a2..617a036d 100644 --- a/java/src/com/android/intentresolver/ChooserActivity.java +++ b/java/src/com/android/intentresolver/ChooserActivity.java @@ -61,6 +61,7 @@ import android.net.Uri; import android.os.Bundle; import android.os.Environment; import android.os.Handler; +import android.os.Parcel; import android.os.Parcelable; import android.os.PatternMatcher; import android.os.ResultReceiver; @@ -1180,7 +1181,7 @@ public class ChooserActivity extends ResolverActivity implements } mRefinementResultReceiver = new RefinementResultReceiver(this, target, null); fillIn.putExtra(Intent.EXTRA_RESULT_RECEIVER, - mRefinementResultReceiver); + mRefinementResultReceiver.copyForSending()); try { mChooserRequest.getRefinementIntentSender().sendIntent( this, 0, fillIn, null, null); @@ -2226,6 +2227,19 @@ public class ChooserActivity extends ResolverActivity implements mChooserActivity = null; mSelectedTarget = null; } + + /** + * Apps can't load this class directly, so we need a regular ResultReceiver copy for + * sending. Obtain this by parceling and unparceling (one weird trick). + */ + ResultReceiver copyForSending() { + Parcel parcel = Parcel.obtain(); + writeToParcel(parcel, 0); + parcel.setDataPosition(0); + ResultReceiver receiverForSending = ResultReceiver.CREATOR.createFromParcel(parcel); + parcel.recycle(); + return receiverForSending; + } } /** -- cgit v1.2.3-59-g8ed1b From 33bb96bf4da9ad037d1184c644fd9c26db569ded Mon Sep 17 00:00:00 2001 From: Andrey Epin Date: Tue, 20 Dec 2022 10:17:36 -0800 Subject: Feature flags support in Chooser A minimalistic replica of the SysUI feature flag infrastructure so the app could both reuse some of the existing flag infrastructure (local flag flipping) and have features controlled remotely (through DeviceConfig). Bug: 262578843 Test: Manual tests with some injected debug code and local flag flipping Change-Id: If4be0cfce17e98b978966ea5172f85c5d406f833 --- Android.bp | 24 +++++- .../flags/DebugFeatureFlagRepository.kt | 85 ++++++++++++++++++++++ .../flags/FeatureFlagRepositoryFactory.kt | 30 ++++++++ .../flags/FeatureFlagRepositoryFactory.kt | 24 ++++++ .../flags/ReleaseFeatureFlagRepository.kt | 31 ++++++++ .../intentresolver/flags/DeviceConfigProxy.kt | 28 +++++++ .../intentresolver/flags/FeatureFlagRepository.kt | 25 +++++++ java/src/com/android/intentresolver/flags/Flags.kt | 28 +++++++ 8 files changed, 274 insertions(+), 1 deletion(-) create mode 100644 java/src-debug/com/android/intentresolver/flags/DebugFeatureFlagRepository.kt create mode 100644 java/src-debug/com/android/intentresolver/flags/FeatureFlagRepositoryFactory.kt create mode 100644 java/src-release/com/android/intentresolver/flags/FeatureFlagRepositoryFactory.kt create mode 100644 java/src-release/com/android/intentresolver/flags/ReleaseFeatureFlagRepository.kt create mode 100644 java/src/com/android/intentresolver/flags/DeviceConfigProxy.kt create mode 100644 java/src/com/android/intentresolver/flags/FeatureFlagRepository.kt create mode 100644 java/src/com/android/intentresolver/flags/Flags.kt (limited to 'java/src') diff --git a/Android.bp b/Android.bp index 31d7d6d0..19d2a825 100644 --- a/Android.bp +++ b/Android.bp @@ -31,13 +31,34 @@ license { ], } +filegroup { + name: "ReleaseSources", + srcs: [ + "java/src-release/**/*.kt", + ], +} + +filegroup { + name: "DebugSources", + srcs: [ + "java/src-debug/**/*.kt", + ], +} + android_library { name: "IntentResolver-core", min_sdk_version: "current", srcs: [ "java/src/**/*.java", "java/src/**/*.kt", + ":ReleaseSources", ], + product_variables: { + debuggable: { + srcs: [":DebugSources"], + exclude_srcs: [":ReleaseSources"], + } + }, resource_dirs: [ "java/res", ], @@ -58,11 +79,12 @@ android_library { "kotlinx-coroutines-android", "//external/kotlinc:kotlin-annotations", "guava", + "SystemUIFlagsLib", ], plugins: ["java_api_finder"], lint: { - strict_updatability_linting: true, + strict_updatability_linting: false, }, } diff --git a/java/src-debug/com/android/intentresolver/flags/DebugFeatureFlagRepository.kt b/java/src-debug/com/android/intentresolver/flags/DebugFeatureFlagRepository.kt new file mode 100644 index 00000000..a85e0971 --- /dev/null +++ b/java/src-debug/com/android/intentresolver/flags/DebugFeatureFlagRepository.kt @@ -0,0 +1,85 @@ +/* + * 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 = SparseBooleanArray() + + override fun isEnabled(flag: UnreleasedFlag): Boolean = isFlagEnabled(flag) + + override fun isEnabled(flag: ReleasedFlag): Boolean = isFlagEnabled(flag) + + private fun isFlagEnabled(flag: BooleanFlag): Boolean { + synchronized(cache) { + val idx = cache.indexOfKey(flag.id) + if (idx >= 0) return cache.valueAt(idx) + } + val flagValue = readFlagValue(flag) + synchronized(cache) { + val idx = cache.indexOfKey(flag.id) + // the first read saved in the cache wins + if (idx >= 0) return cache.valueAt(idx) + cache.put(flag.id, flagValue) + } + return flagValue + } + + private fun readFlagValue(flag: BooleanFlag): Boolean { + val localOverride = runCatching { + flagManager.isEnabled(flag.id) + }.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_ID = 1 + + private val BooleanFlag.isTeamfoodFlag: Boolean + get() = id == TEAMFOOD_FLAG_ID + + private val FlagManager.isTeamfoodEnabled: Boolean + get() = runCatching { + isEnabled(TEAMFOOD_FLAG_ID) ?: 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 new file mode 100644 index 00000000..4ddb0447 --- /dev/null +++ b/java/src-debug/com/android/intentresolver/flags/FeatureFlagRepositoryFactory.kt @@ -0,0 +1,30 @@ +/* + * 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 new file mode 100644 index 00000000..6bf7579e --- /dev/null +++ b/java/src-release/com/android/intentresolver/flags/FeatureFlagRepositoryFactory.kt @@ -0,0 +1,24 @@ +/* + * 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 new file mode 100644 index 00000000..a513e46f --- /dev/null +++ b/java/src-release/com/android/intentresolver/flags/ReleaseFeatureFlagRepository.kt @@ -0,0 +1,31 @@ +/* + * 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.namespace, flag.name) ?: flag.default +} diff --git a/java/src/com/android/intentresolver/flags/DeviceConfigProxy.kt b/java/src/com/android/intentresolver/flags/DeviceConfigProxy.kt new file mode 100644 index 00000000..e23616f2 --- /dev/null +++ b/java/src/com/android/intentresolver/flags/DeviceConfigProxy.kt @@ -0,0 +1,28 @@ +/* + * 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.BooleanFlag + +internal class DeviceConfigProxy { + fun isEnabled(flag: BooleanFlag): Boolean? { + return runCatching { + DeviceConfig.getBoolean(flag.namespace, flag.name, flag.default) + }.getOrDefault(null) + } +} diff --git a/java/src/com/android/intentresolver/flags/FeatureFlagRepository.kt b/java/src/com/android/intentresolver/flags/FeatureFlagRepository.kt new file mode 100644 index 00000000..5b5d769c --- /dev/null +++ b/java/src/com/android/intentresolver/flags/FeatureFlagRepository.kt @@ -0,0 +1,25 @@ +/* + * 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 new file mode 100644 index 00000000..7c23f7de --- /dev/null +++ b/java/src/com/android/intentresolver/flags/Flags.kt @@ -0,0 +1,28 @@ +/* + * 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.UnreleasedFlag + +// keep in sync with com.android.systemui.flags.Flags to make the flags available in the +// flag flipper app (see go/sysui-flags) +object Flags { + @JvmField + val CHOOSER_CUSTOM_ACTIONS = UnreleasedFlag( + id = 1501, name = "chooser_custom_actions", namespace = "systemui", teamfood = false + ) +} -- cgit v1.2.3-59-g8ed1b From 6e245878978627b8741c219597c4a02609543969 Mon Sep 17 00:00:00 2001 From: Oli Thompson Date: Mon, 30 Jan 2023 10:03:07 +0000 Subject: Revert "Feature flags support in Chooser" This reverts commit 33bb96bf4da9ad037d1184c644fd9c26db569ded. Reason for revert: DroidMonitor-triggered revert due to breakage https://android-build.googleplex.com/builds/quarterdeck?branch=git_master&target=bramble-user&lkgb=9536619&lkbb=9537444&fkbb=9536627 , bug b/267135531 BUG: b/267135531 Change-Id: I73f5b6c66e27dd0e5a628034604e80523f2acf99 --- Android.bp | 24 +----- .../flags/DebugFeatureFlagRepository.kt | 85 ---------------------- .../flags/FeatureFlagRepositoryFactory.kt | 30 -------- .../flags/FeatureFlagRepositoryFactory.kt | 24 ------ .../flags/ReleaseFeatureFlagRepository.kt | 31 -------- .../intentresolver/flags/DeviceConfigProxy.kt | 28 ------- .../intentresolver/flags/FeatureFlagRepository.kt | 25 ------- java/src/com/android/intentresolver/flags/Flags.kt | 28 ------- 8 files changed, 1 insertion(+), 274 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 (limited to 'java/src') diff --git a/Android.bp b/Android.bp index 19d2a825..31d7d6d0 100644 --- a/Android.bp +++ b/Android.bp @@ -31,34 +31,13 @@ license { ], } -filegroup { - name: "ReleaseSources", - srcs: [ - "java/src-release/**/*.kt", - ], -} - -filegroup { - name: "DebugSources", - srcs: [ - "java/src-debug/**/*.kt", - ], -} - android_library { name: "IntentResolver-core", min_sdk_version: "current", srcs: [ "java/src/**/*.java", "java/src/**/*.kt", - ":ReleaseSources", ], - product_variables: { - debuggable: { - srcs: [":DebugSources"], - exclude_srcs: [":ReleaseSources"], - } - }, resource_dirs: [ "java/res", ], @@ -79,12 +58,11 @@ android_library { "kotlinx-coroutines-android", "//external/kotlinc:kotlin-annotations", "guava", - "SystemUIFlagsLib", ], plugins: ["java_api_finder"], lint: { - strict_updatability_linting: false, + strict_updatability_linting: true, }, } 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 a85e0971..00000000 --- a/java/src-debug/com/android/intentresolver/flags/DebugFeatureFlagRepository.kt +++ /dev/null @@ -1,85 +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 = SparseBooleanArray() - - override fun isEnabled(flag: UnreleasedFlag): Boolean = isFlagEnabled(flag) - - override fun isEnabled(flag: ReleasedFlag): Boolean = isFlagEnabled(flag) - - private fun isFlagEnabled(flag: BooleanFlag): Boolean { - synchronized(cache) { - val idx = cache.indexOfKey(flag.id) - if (idx >= 0) return cache.valueAt(idx) - } - val flagValue = readFlagValue(flag) - synchronized(cache) { - val idx = cache.indexOfKey(flag.id) - // the first read saved in the cache wins - if (idx >= 0) return cache.valueAt(idx) - cache.put(flag.id, flagValue) - } - return flagValue - } - - private fun readFlagValue(flag: BooleanFlag): Boolean { - val localOverride = runCatching { - flagManager.isEnabled(flag.id) - }.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_ID = 1 - - private val BooleanFlag.isTeamfoodFlag: Boolean - get() = id == TEAMFOOD_FLAG_ID - - private val FlagManager.isTeamfoodEnabled: Boolean - get() = runCatching { - isEnabled(TEAMFOOD_FLAG_ID) ?: 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 a513e46f..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.namespace, flag.name) ?: flag.default -} 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 e23616f2..00000000 --- a/java/src/com/android/intentresolver/flags/DeviceConfigProxy.kt +++ /dev/null @@ -1,28 +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.BooleanFlag - -internal class DeviceConfigProxy { - fun isEnabled(flag: BooleanFlag): Boolean? { - return runCatching { - DeviceConfig.getBoolean(flag.namespace, flag.name, flag.default) - }.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 7c23f7de..00000000 --- a/java/src/com/android/intentresolver/flags/Flags.kt +++ /dev/null @@ -1,28 +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.UnreleasedFlag - -// keep in sync with com.android.systemui.flags.Flags to make the flags available in the -// flag flipper app (see go/sysui-flags) -object Flags { - @JvmField - val CHOOSER_CUSTOM_ACTIONS = UnreleasedFlag( - id = 1501, name = "chooser_custom_actions", namespace = "systemui", teamfood = false - ) -} -- cgit v1.2.3-59-g8ed1b From e1be3b0b1a75a72cf4e04cda792cb209f2b450f4 Mon Sep 17 00:00:00 2001 From: Andrey Epin Date: Fri, 6 Jan 2023 16:49:07 -0800 Subject: Add reveal animation for Chooser's target icons Add alpha animation for Chooser's target icons. Bug: 262927266 Test: Manual test with artificial direct share target loading delay. Test: atest IntentResolverUnitTests Change-Id: Id13450d47cbe75feb3769fc131b4e90f484133a9 --- .../android/intentresolver/ChooserListAdapter.java | 2 +- .../android/intentresolver/ResolverListAdapter.java | 20 +++++++++++++++++++- 2 files changed, 20 insertions(+), 2 deletions(-) (limited to 'java/src') diff --git a/java/src/com/android/intentresolver/ChooserListAdapter.java b/java/src/com/android/intentresolver/ChooserListAdapter.java index 699190f9..49b883ae 100644 --- a/java/src/com/android/intentresolver/ChooserListAdapter.java +++ b/java/src/com/android/intentresolver/ChooserListAdapter.java @@ -264,7 +264,7 @@ public class ChooserListAdapter extends ResolverListAdapter { } holder.bindLabel(info.getDisplayLabel(), info.getExtendedInfo(), alwaysShowSubLabel()); - holder.bindIcon(info); + holder.bindIcon(info, /*animate =*/ true); if (info.isSelectableTargetInfo()) { // direct share targets should append the application name for a better readout DisplayResolveInfo rInfo = info.getDisplayResolveInfo(); diff --git a/java/src/com/android/intentresolver/ResolverListAdapter.java b/java/src/com/android/intentresolver/ResolverListAdapter.java index eecb914c..7a258a4c 100644 --- a/java/src/com/android/intentresolver/ResolverListAdapter.java +++ b/java/src/com/android/intentresolver/ResolverListAdapter.java @@ -18,6 +18,7 @@ package com.android.intentresolver; import static android.content.Context.ACTIVITY_SERVICE; +import android.animation.ObjectAnimator; import android.annotation.NonNull; import android.annotation.Nullable; import android.app.ActivityManager; @@ -42,6 +43,7 @@ import android.util.Log; import android.view.LayoutInflater; import android.view.View; import android.view.ViewGroup; +import android.view.animation.DecelerateInterpolator; import android.widget.AbsListView; import android.widget.BaseAdapter; import android.widget.ImageView; @@ -914,6 +916,7 @@ public class ResolverListAdapter extends BaseAdapter { */ @VisibleForTesting public static class ViewHolder { + private static final long IMAGE_FADE_IN_MILLIS = 150; public View itemView; public Drawable defaultItemViewBackground; @@ -952,7 +955,22 @@ public class ResolverListAdapter extends BaseAdapter { } public void bindIcon(TargetInfo info) { - icon.setImageDrawable(info.getDisplayIconHolder().getDisplayIcon()); + bindIcon(info, false); + } + + /** + * Bind view holder to a TargetInfo, run icon reveal animation, if required. + */ + public void bindIcon(TargetInfo info, boolean animate) { + Drawable displayIcon = info.getDisplayIconHolder().getDisplayIcon(); + boolean runAnimation = animate && (icon.getDrawable() == null) && (displayIcon != null); + icon.setImageDrawable(displayIcon); + if (runAnimation) { + ObjectAnimator animator = ObjectAnimator.ofFloat(icon, "alpha", 0.0f, 1.0f); + animator.setInterpolator(new DecelerateInterpolator(1.0f)); + animator.setDuration(IMAGE_FADE_IN_MILLIS); + animator.start(); + } if (info.isSuspended()) { icon.setColorFilter(getSuspendedColorMatrix()); } else { -- cgit v1.2.3-59-g8ed1b From 50a7e3ea4113a3f6fb4e21c36fc7da89862f36e1 Mon Sep 17 00:00:00 2001 From: Andrey Epin Date: Tue, 20 Dec 2022 10:17:36 -0800 Subject: Feature flags support in Chooser A minimalistic replica of the SysUI feature flag infrastructure so the app could both reuse some of the existing flag infrastructure (local flag flipping) and have features controlled remotely (through DeviceConfig). A re-introduction of reverted ag/20796272 with the fixed release build variant. Bug: 262578843 Test: Manual tests with some injected debug code and local flag flipping Test: Smoke tests both build variants Change-Id: Ie536172020bcb7e6cd96f44c228a6941004858f8 --- Android.bp | 24 ++++++- .../flags/DebugFeatureFlagRepository.kt | 81 ++++++++++++++++++++++ .../flags/FeatureFlagRepositoryFactory.kt | 30 ++++++++ .../flags/FeatureFlagRepositoryFactory.kt | 24 +++++++ .../flags/ReleaseFeatureFlagRepository.kt | 31 +++++++++ .../intentresolver/flags/DeviceConfigProxy.kt | 28 ++++++++ .../intentresolver/flags/FeatureFlagRepository.kt | 25 +++++++ java/src/com/android/intentresolver/flags/Flags.kt | 28 ++++++++ 8 files changed, 270 insertions(+), 1 deletion(-) create mode 100644 java/src-debug/com/android/intentresolver/flags/DebugFeatureFlagRepository.kt create mode 100644 java/src-debug/com/android/intentresolver/flags/FeatureFlagRepositoryFactory.kt create mode 100644 java/src-release/com/android/intentresolver/flags/FeatureFlagRepositoryFactory.kt create mode 100644 java/src-release/com/android/intentresolver/flags/ReleaseFeatureFlagRepository.kt create mode 100644 java/src/com/android/intentresolver/flags/DeviceConfigProxy.kt create mode 100644 java/src/com/android/intentresolver/flags/FeatureFlagRepository.kt create mode 100644 java/src/com/android/intentresolver/flags/Flags.kt (limited to 'java/src') diff --git a/Android.bp b/Android.bp index 31d7d6d0..19d2a825 100644 --- a/Android.bp +++ b/Android.bp @@ -31,13 +31,34 @@ license { ], } +filegroup { + name: "ReleaseSources", + srcs: [ + "java/src-release/**/*.kt", + ], +} + +filegroup { + name: "DebugSources", + srcs: [ + "java/src-debug/**/*.kt", + ], +} + android_library { name: "IntentResolver-core", min_sdk_version: "current", srcs: [ "java/src/**/*.java", "java/src/**/*.kt", + ":ReleaseSources", ], + product_variables: { + debuggable: { + srcs: [":DebugSources"], + exclude_srcs: [":ReleaseSources"], + } + }, resource_dirs: [ "java/res", ], @@ -58,11 +79,12 @@ android_library { "kotlinx-coroutines-android", "//external/kotlinc:kotlin-annotations", "guava", + "SystemUIFlagsLib", ], plugins: ["java_api_finder"], lint: { - strict_updatability_linting: true, + strict_updatability_linting: false, }, } diff --git a/java/src-debug/com/android/intentresolver/flags/DebugFeatureFlagRepository.kt b/java/src-debug/com/android/intentresolver/flags/DebugFeatureFlagRepository.kt new file mode 100644 index 00000000..5067c0ee --- /dev/null +++ b/java/src-debug/com/android/intentresolver/flags/DebugFeatureFlagRepository.kt @@ -0,0 +1,81 @@ +/* + * 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 new file mode 100644 index 00000000..4ddb0447 --- /dev/null +++ b/java/src-debug/com/android/intentresolver/flags/FeatureFlagRepositoryFactory.kt @@ -0,0 +1,30 @@ +/* + * 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 new file mode 100644 index 00000000..6bf7579e --- /dev/null +++ b/java/src-release/com/android/intentresolver/flags/FeatureFlagRepositoryFactory.kt @@ -0,0 +1,24 @@ +/* + * 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 new file mode 100644 index 00000000..f9fa2c6a --- /dev/null +++ b/java/src-release/com/android/intentresolver/flags/ReleaseFeatureFlagRepository.kt @@ -0,0 +1,31 @@ +/* + * 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/flags/DeviceConfigProxy.kt b/java/src/com/android/intentresolver/flags/DeviceConfigProxy.kt new file mode 100644 index 00000000..ac782471 --- /dev/null +++ b/java/src/com/android/intentresolver/flags/DeviceConfigProxy.kt @@ -0,0 +1,28 @@ +/* + * 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 { + DeviceConfig.getBoolean(flag.namespace, flag.name, flag.default) + }.getOrDefault(null) + } +} diff --git a/java/src/com/android/intentresolver/flags/FeatureFlagRepository.kt b/java/src/com/android/intentresolver/flags/FeatureFlagRepository.kt new file mode 100644 index 00000000..5b5d769c --- /dev/null +++ b/java/src/com/android/intentresolver/flags/FeatureFlagRepository.kt @@ -0,0 +1,25 @@ +/* + * 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 new file mode 100644 index 00000000..c9271a6a --- /dev/null +++ b/java/src/com/android/intentresolver/flags/Flags.kt @@ -0,0 +1,28 @@ +/* + * 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.UnreleasedFlag + +// keep in sync with com.android.systemui.flags.Flags to make the flags available in the +// flag flipper app (see go/sysui-flags) +object Flags { + @JvmField + val SHARESHEET_CUSTOM_ACTIONS = UnreleasedFlag( + id = 1501, name = "sharesheet_custom_actions", namespace = "systemui", teamfood = false + ) +} -- cgit v1.2.3-59-g8ed1b From 78f87a3886577b1937cbac96256596f1d72c5945 Mon Sep 17 00:00:00 2001 From: Andrey Epin Date: Fri, 27 Jan 2023 20:42:03 -0800 Subject: Use SystemUI feature flags Replace compile-time feature flags with SystemUI feture flags. Test: manual test Test: atest IntentResolverUnitTests Change-Id: I4e59d6c0773be2a4f968fc539a0eee2b5182b757 --- .../android/intentresolver/ChooserActivity.java | 25 ++++++++++++++-------- .../intentresolver/ChooserContentPreviewUi.java | 23 ++++++++++++++------ .../intentresolver/ChooserRequestParameters.java | 12 ++++++----- java/src/com/android/intentresolver/flags/Flags.kt | 16 +++++++++----- .../ChooserActivityOverrideData.java | 3 +++ .../intentresolver/ChooserWrapperActivity.java | 9 ++++++++ .../UnbundledChooserActivityTest.java | 19 +++++++++++++--- 7 files changed, 78 insertions(+), 29 deletions(-) (limited to 'java/src') diff --git a/java/src/com/android/intentresolver/ChooserActivity.java b/java/src/com/android/intentresolver/ChooserActivity.java index 7fa715c3..e741b06c 100644 --- a/java/src/com/android/intentresolver/ChooserActivity.java +++ b/java/src/com/android/intentresolver/ChooserActivity.java @@ -98,6 +98,9 @@ import com.android.intentresolver.NoCrossProfileEmptyStateProvider.DevicePolicyB import com.android.intentresolver.chooser.DisplayResolveInfo; import com.android.intentresolver.chooser.MultiDisplayResolveInfo; import com.android.intentresolver.chooser.TargetInfo; +import com.android.intentresolver.flags.FeatureFlagRepository; +import com.android.intentresolver.flags.FeatureFlagRepositoryFactory; +import com.android.intentresolver.flags.Flags; import com.android.intentresolver.grid.ChooserGridAdapter; import com.android.intentresolver.grid.DirectShareViewHolder; import com.android.intentresolver.model.AbstractResolverComparator; @@ -160,8 +163,6 @@ public class ChooserActivity extends ResolverActivity implements private static final String CHIP_ICON_METADATA_KEY = "android.service.chooser.chip_icon"; private static final boolean DEBUG = true; - static final boolean ENABLE_CUSTOM_ACTIONS = false; - static final boolean ENABLE_RESELECTION_ACTION = false; public static final String LAUNCH_LOCATION_DIRECT_SHARE = "direct_share"; private static final String SHORTCUT_TARGET = "shortcut_target"; @@ -218,6 +219,9 @@ public class ChooserActivity extends ResolverActivity implements @Nullable private ChooserRequestParameters mChooserRequest; + private FeatureFlagRepository mFeatureFlagRepository; + private ChooserContentPreviewUi mChooserContentPreviewUi; + private boolean mShouldDisplayLandscape; // statsd logger wrapper protected ChooserActivityLogger mChooserActivityLogger; @@ -267,19 +271,20 @@ public class ChooserActivity extends ResolverActivity implements getChooserActivityLogger().logSharesheetTriggered(); + mFeatureFlagRepository = createFeatureFlagRepository(); try { mChooserRequest = new ChooserRequestParameters( getIntent(), getReferrer(), getNearbySharingComponent(), - ENABLE_CUSTOM_ACTIONS, - ENABLE_RESELECTION_ACTION); + mFeatureFlagRepository); } catch (IllegalArgumentException e) { Log.e(TAG, "Caller provided invalid Chooser request parameters", e); finish(); super_onCreate(null); return; } + mChooserContentPreviewUi = new ChooserContentPreviewUi(mFeatureFlagRepository); setAdditionalTargets(mChooserRequest.getAdditionalTargets()); @@ -366,6 +371,10 @@ public class ChooserActivity extends ResolverActivity 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(); @@ -753,7 +762,8 @@ public class ChooserActivity extends ResolverActivity implements @Nullable @Override public Runnable getReselectionAction() { - if (!ENABLE_RESELECTION_ACTION) { + if (!mFeatureFlagRepository + .isEnabled(Flags.SHARESHEET_RESELECTION_ACTION)) { return null; } PendingIntent reselectionAction = mChooserRequest.getReselectionAction(); @@ -763,15 +773,12 @@ public class ChooserActivity extends ResolverActivity implements } }; - ViewGroup layout = ChooserContentPreviewUi.displayContentPreview( + ViewGroup layout = mChooserContentPreviewUi.displayContentPreview( previewType, targetIntent, getResources(), getLayoutInflater(), actionFactory, - ENABLE_CUSTOM_ACTIONS - ? R.layout.scrollable_chooser_action_row - : R.layout.chooser_action_row, parent, imageLoader, mEnterTransitionAnimationDelegate, diff --git a/java/src/com/android/intentresolver/ChooserContentPreviewUi.java b/java/src/com/android/intentresolver/ChooserContentPreviewUi.java index f3d00c43..26c08e36 100644 --- a/java/src/com/android/intentresolver/ChooserContentPreviewUi.java +++ b/java/src/com/android/intentresolver/ChooserContentPreviewUi.java @@ -45,6 +45,8 @@ import android.widget.TextView; import androidx.annotation.LayoutRes; import androidx.annotation.Nullable; +import com.android.intentresolver.flags.FeatureFlagRepository; +import com.android.intentresolver.flags.Flags; import com.android.intentresolver.widget.ActionRow; import com.android.intentresolver.widget.ImagePreviewView; import com.android.intentresolver.widget.ImagePreviewView.TransitionElementStatusCallback; @@ -129,6 +131,8 @@ public final class ChooserContentPreviewUi { private static final String PLURALS_COUNT = "count"; private static final String PLURALS_FILE_NAME = "file_name"; + private final FeatureFlagRepository mFeatureFlagRepository; + /** Determine the most appropriate type of preview to show for the provided {@link Intent}. */ @ContentPreviewType public static int findPreferredContentPreview( @@ -164,17 +168,21 @@ public final class ChooserContentPreviewUi { return CONTENT_PREVIEW_TEXT; } + public ChooserContentPreviewUi( + FeatureFlagRepository featureFlagRepository) { + mFeatureFlagRepository = featureFlagRepository; + } + /** * Display a content preview of the specified {@code previewType} to preview the content of the * specified {@code intent}. */ - public static ViewGroup displayContentPreview( + public ViewGroup displayContentPreview( @ContentPreviewType int previewType, Intent targetIntent, Resources resources, LayoutInflater layoutInflater, ActionFactory actionFactory, - @LayoutRes int actionRowLayout, ViewGroup parent, ImageLoader previewImageLoader, TransitionElementStatusCallback transitionElementStatusCallback, @@ -185,6 +193,9 @@ public final class ChooserContentPreviewUi { if (previewType != CONTENT_PREVIEW_IMAGE) { transitionElementStatusCallback.onAllTransitionElementsReady(); } + int actionRowLayout = mFeatureFlagRepository.isEnabled(Flags.SHARESHEET_CUSTOM_ACTIONS) + ? R.layout.scrollable_chooser_action_row + : R.layout.chooser_action_row; List customActions = actionFactory.createCustomActions(); switch (previewType) { case CONTENT_PREVIEW_TEXT: @@ -230,7 +241,7 @@ public final class ChooserContentPreviewUi { } Runnable reselectionAction = actionFactory.getReselectionAction(); if (reselectionAction != null && layout != null - && ChooserActivity.ENABLE_RESELECTION_ACTION) { + && mFeatureFlagRepository.isEnabled(Flags.SHARESHEET_RESELECTION_ACTION)) { View reselectionView = layout.findViewById(R.id.reselection_action); if (reselectionView != null) { reselectionView.setVisibility(View.VISIBLE); @@ -241,12 +252,12 @@ public final class ChooserContentPreviewUi { return layout; } - private static List createActions( + private List createActions( List systemActions, List customActions) { ArrayList actions = new ArrayList<>(systemActions.size() + customActions.size()); actions.addAll(systemActions); - if (ChooserActivity.ENABLE_CUSTOM_ACTIONS) { + if (mFeatureFlagRepository.isEnabled(Flags.SHARESHEET_CUSTOM_ACTIONS)) { actions.addAll(customActions); } return actions; @@ -594,6 +605,4 @@ public final class ChooserContentPreviewUi { this.hasThumbnail = hasThumbnail; } } - - private ChooserContentPreviewUi() {} } diff --git a/java/src/com/android/intentresolver/ChooserRequestParameters.java b/java/src/com/android/intentresolver/ChooserRequestParameters.java index 97bee82c..0d004b0d 100644 --- a/java/src/com/android/intentresolver/ChooserRequestParameters.java +++ b/java/src/com/android/intentresolver/ChooserRequestParameters.java @@ -33,6 +33,9 @@ import android.text.TextUtils; import android.util.Log; import android.util.Pair; +import com.android.intentresolver.flags.FeatureFlagRepository; +import com.android.intentresolver.flags.Flags; + import com.google.common.collect.ImmutableList; import java.net.URISyntaxException; @@ -101,8 +104,7 @@ public class ChooserRequestParameters { final Intent clientIntent, final Uri referrer, @Nullable final ComponentName nearbySharingComponent, - boolean extractCustomActions, - boolean extractReslectionAction) { + FeatureFlagRepository featureFlags) { final Intent requestedTarget = parseTargetIntentExtra( clientIntent.getParcelableExtra(Intent.EXTRA_INTENT)); mTarget = intentWithModifiedLaunchFlags(requestedTarget); @@ -137,10 +139,10 @@ public class ChooserRequestParameters { mTargetIntentFilter = getTargetIntentFilter(mTarget); - mChooserActions = extractCustomActions + mChooserActions = featureFlags.isEnabled(Flags.SHARESHEET_CUSTOM_ACTIONS) ? getChooserActions(clientIntent) : ImmutableList.of(); - mReselectionAction = extractReslectionAction + mReselectionAction = featureFlags.isEnabled(Flags.SHARESHEET_RESELECTION_ACTION) ? getReselectionActionExtra(clientIntent) : null; } @@ -194,7 +196,7 @@ public class ChooserRequestParameters { } /** - * Whether the {@link ChooserActivity.EXTRA_PRIVATE_RETAIN_IN_ON_STOP} behavior was requested. + * Whether the {@link ChooserActivity#EXTRA_PRIVATE_RETAIN_IN_ON_STOP} behavior was requested. */ public boolean shouldRetainInOnStop() { return mRetainInOnStop; diff --git a/java/src/com/android/intentresolver/flags/Flags.kt b/java/src/com/android/intentresolver/flags/Flags.kt index c9271a6a..6962b1c3 100644 --- a/java/src/com/android/intentresolver/flags/Flags.kt +++ b/java/src/com/android/intentresolver/flags/Flags.kt @@ -18,11 +18,17 @@ package com.android.intentresolver.flags import com.android.systemui.flags.UnreleasedFlag -// keep in sync with com.android.systemui.flags.Flags to make the flags available in the -// flag flipper app (see go/sysui-flags) +// 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). object Flags { + // TODO(b/266983432) Tracking Bug @JvmField - val SHARESHEET_CUSTOM_ACTIONS = UnreleasedFlag( - id = 1501, name = "sharesheet_custom_actions", namespace = "systemui", teamfood = false - ) + val SHARESHEET_CUSTOM_ACTIONS = unreleasedFlag(1501, "sharesheet_custom_actions") + + // TODO(b/266982749) Tracking Bug + @JvmField + val SHARESHEET_RESELECTION_ACTION = unreleasedFlag(1502, "sharesheet_reselection_action") + + private fun unreleasedFlag(id: Int, name: String, teamfood: Boolean = false) = + UnreleasedFlag(id, name, "systemui", teamfood) } diff --git a/java/tests/src/com/android/intentresolver/ChooserActivityOverrideData.java b/java/tests/src/com/android/intentresolver/ChooserActivityOverrideData.java index 5df0d4a2..857fa124 100644 --- a/java/tests/src/com/android/intentresolver/ChooserActivityOverrideData.java +++ b/java/tests/src/com/android/intentresolver/ChooserActivityOverrideData.java @@ -31,6 +31,7 @@ import com.android.intentresolver.AbstractMultiProfilePagerAdapter.CrossProfileI import com.android.intentresolver.AbstractMultiProfilePagerAdapter.MyUserIdProvider; import com.android.intentresolver.AbstractMultiProfilePagerAdapter.QuietModeManager; import com.android.intentresolver.chooser.TargetInfo; +import com.android.intentresolver.flags.FeatureFlagRepository; import com.android.intentresolver.shortcuts.ShortcutLoader; import java.util.function.Consumer; @@ -76,6 +77,7 @@ public class ChooserActivityOverrideData { public MyUserIdProvider mMyUserIdProvider; public CrossProfileIntentsChecker mCrossProfileIntentsChecker; public PackageManager packageManager; + public FeatureFlagRepository featureFlagRepository; public void reset() { onSafelyStartCallback = null; @@ -128,6 +130,7 @@ 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/ChooserWrapperActivity.java b/java/tests/src/com/android/intentresolver/ChooserWrapperActivity.java index 6bc5e12a..a47014e8 100644 --- a/java/tests/src/com/android/intentresolver/ChooserWrapperActivity.java +++ b/java/tests/src/com/android/intentresolver/ChooserWrapperActivity.java @@ -39,6 +39,7 @@ import com.android.intentresolver.AbstractMultiProfilePagerAdapter.QuietModeMana import com.android.intentresolver.chooser.DisplayResolveInfo; import com.android.intentresolver.chooser.NotSelectableTargetInfo; import com.android.intentresolver.chooser.TargetInfo; +import com.android.intentresolver.flags.FeatureFlagRepository; import com.android.intentresolver.grid.ChooserGridAdapter; import com.android.intentresolver.shortcuts.ShortcutLoader; import com.android.internal.logging.nano.MetricsProto.MetricsEvent; @@ -287,4 +288,12 @@ public class ChooserWrapperActivity 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/UnbundledChooserActivityTest.java b/java/tests/src/com/android/intentresolver/UnbundledChooserActivityTest.java index d7af8925..53c357d6 100644 --- a/java/tests/src/com/android/intentresolver/UnbundledChooserActivityTest.java +++ b/java/tests/src/com/android/intentresolver/UnbundledChooserActivityTest.java @@ -101,9 +101,13 @@ import androidx.test.rule.ActivityTestRule; import com.android.intentresolver.ResolverActivity.ResolvedComponentInfo; import com.android.intentresolver.chooser.DisplayResolveInfo; +import com.android.intentresolver.flags.FeatureFlagRepository; +import com.android.intentresolver.flags.Flags; 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.ReleasedFlag; +import com.android.systemui.flags.UnreleasedFlag; import org.hamcrest.Description; import org.hamcrest.Matcher; @@ -1673,9 +1677,18 @@ public class UnbundledChooserActivityTest { @Test public void testLaunchWithCustomAction() throws InterruptedException { - if (!ChooserActivity.ENABLE_CUSTOM_ACTIONS) { - return; - } + ChooserActivityOverrideData.getInstance().featureFlagRepository = + new FeatureFlagRepository() { + @Override + public boolean isEnabled(@NonNull UnreleasedFlag flag) { + return Flags.SHARESHEET_CUSTOM_ACTIONS.equals(flag) || flag.getDefault(); + } + + @Override + public boolean isEnabled(@NonNull ReleasedFlag flag) { + return false; + } + }; List resolvedComponentInfos = createResolvedComponentsForTest(2); when( ChooserActivityOverrideData -- cgit v1.2.3-59-g8ed1b From 53d426f78b1702ae471ae57fc6818674f196954c Mon Sep 17 00:00:00 2001 From: Andrey Epin Date: Tue, 17 Jan 2023 20:00:17 -0800 Subject: Simple scrollable image preview view A simple scrollale image preview view is created but not yet used. Bug: 262280076 Test: Add a debug code that replaces legacy image preview view with the new implementation. Verify basic functionality: small and large image count, screenshot transition animation. Change-Id: I5d8f00b8617abae66f76931b21872154f4726851 --- java/res/layout/image_preview_image_item.xml | 24 +++ .../widget/RecyclerViewExtensions.kt | 36 +++++ .../intentresolver/widget/ScrollableActionRow.kt | 15 -- .../widget/ScrollableImagePreviewView.kt | 178 +++++++++++++++++++++ 4 files changed, 238 insertions(+), 15 deletions(-) create mode 100644 java/res/layout/image_preview_image_item.xml create mode 100644 java/src/com/android/intentresolver/widget/RecyclerViewExtensions.kt create mode 100644 java/src/com/android/intentresolver/widget/ScrollableImagePreviewView.kt (limited to 'java/src') diff --git a/java/res/layout/image_preview_image_item.xml b/java/res/layout/image_preview_image_item.xml new file mode 100644 index 00000000..3895b6b4 --- /dev/null +++ b/java/res/layout/image_preview_image_item.xml @@ -0,0 +1,24 @@ + + + diff --git a/java/src/com/android/intentresolver/widget/RecyclerViewExtensions.kt b/java/src/com/android/intentresolver/widget/RecyclerViewExtensions.kt new file mode 100644 index 00000000..a7906001 --- /dev/null +++ b/java/src/com/android/intentresolver/widget/RecyclerViewExtensions.kt @@ -0,0 +1,36 @@ +/* + * Copyright (C) 2023 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.intentresolver.widget + +import android.view.View +import androidx.recyclerview.widget.RecyclerView + +internal val RecyclerView.areAllChildrenVisible: Boolean + get() { + val count = getChildCount() + if (count == 0) return true + val first = getChildAt(0) + val last = getChildAt(count - 1) + val itemCount = adapter?.itemCount ?: 0 + return getChildAdapterPosition(first) == 0 + && getChildAdapterPosition(last) == itemCount - 1 + && isFullyVisible(first) + && isFullyVisible(last) + } + +private fun RecyclerView.isFullyVisible(view: View): Boolean = + view.left >= paddingLeft && view.right <= width - paddingRight diff --git a/java/src/com/android/intentresolver/widget/ScrollableActionRow.kt b/java/src/com/android/intentresolver/widget/ScrollableActionRow.kt index a941b97a..81630545 100644 --- a/java/src/com/android/intentresolver/widget/ScrollableActionRow.kt +++ b/java/src/com/android/intentresolver/widget/ScrollableActionRow.kt @@ -50,21 +50,6 @@ class ScrollableActionRow : RecyclerView, ActionRow { ) } - private val areAllChildrenVisible: Boolean - get() { - val count = getChildCount() - if (count == 0) return true - val first = getChildAt(0) - val last = getChildAt(count - 1) - return getChildAdapterPosition(first) == 0 - && getChildAdapterPosition(last) == actionsAdapter.itemCount - 1 - && isFullyVisible(first) - && isFullyVisible(last) - } - - private fun isFullyVisible(view: View): Boolean = - view.left >= paddingLeft && view.right <= width - paddingRight - private class Adapter(private val context: Context) : RecyclerView.Adapter() { private val iconSize: Int = context.resources.getDimensionPixelSize(R.dimen.chooser_action_view_icon_size) diff --git a/java/src/com/android/intentresolver/widget/ScrollableImagePreviewView.kt b/java/src/com/android/intentresolver/widget/ScrollableImagePreviewView.kt new file mode 100644 index 00000000..467c404a --- /dev/null +++ b/java/src/com/android/intentresolver/widget/ScrollableImagePreviewView.kt @@ -0,0 +1,178 @@ +/* + * Copyright (C) 2023 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.intentresolver.widget + +import android.content.Context +import android.graphics.Rect +import android.net.Uri +import android.util.AttributeSet +import android.util.TypedValue +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.ImageView +import androidx.recyclerview.widget.LinearLayoutManager +import androidx.recyclerview.widget.RecyclerView +import com.android.intentresolver.R +import com.android.intentresolver.widget.ImagePreviewView.TransitionElementStatusCallback +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.MainScope +import kotlinx.coroutines.cancel +import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch +import kotlinx.coroutines.plus + +private const val TRANSITION_NAME = "screenshot_preview_image" + +class ScrollableImagePreviewView : RecyclerView, ImagePreviewView { + constructor(context: Context) : this(context, null) + constructor(context: Context, attrs: AttributeSet?) : this(context, attrs, 0) + constructor( + context: Context, attrs: AttributeSet?, defStyleAttr: Int + ) : super(context, attrs, defStyleAttr) { + layoutManager = LinearLayoutManager(context, LinearLayoutManager.HORIZONTAL, false) + adapter = Adapter(context) + val spacing = TypedValue.applyDimension( + TypedValue.COMPLEX_UNIT_DIP, 5f, context.resources.displayMetrics + ).toInt() + addItemDecoration(SpacingDecoration(spacing)) + } + + private val previewAdapter get() = adapter as Adapter + + override fun onLayout(changed: Boolean, l: Int, t: Int, r: Int, b: Int) { + super.onLayout(changed, l, t, r, b) + setOverScrollMode( + if (areAllChildrenVisible) View.OVER_SCROLL_NEVER else View.OVER_SCROLL_ALWAYS + ) + } + + override fun setTransitionElementStatusCallback(callback: TransitionElementStatusCallback?) { + previewAdapter.transitionStatusElementCallback = callback + } + + override fun setImages(uris: List, imageLoader: ImageLoader) { + previewAdapter.setImages(uris, imageLoader) + } + + private class Adapter(private val context: Context) : RecyclerView.Adapter() { + private val uris = ArrayList() + private var imageLoader: ImageLoader? = null + var transitionStatusElementCallback: TransitionElementStatusCallback? = null + + fun setImages(uris: List, imageLoader: ImageLoader) { + this.uris.clear() + this.uris.addAll(uris) + this.imageLoader = imageLoader + notifyDataSetChanged() + } + + override fun onCreateViewHolder(parent: ViewGroup, itemType: Int): ViewHolder { + return ViewHolder( + LayoutInflater.from(context) + .inflate(R.layout.image_preview_image_item, parent, false) + ) + } + + override fun getItemCount(): Int = uris.size + + override fun onBindViewHolder(vh: ViewHolder, position: Int) { + vh.bind( + uris[position], + imageLoader ?: error("ImageLoader is missing"), + if (position == 0 && transitionStatusElementCallback != null) { + this::onTransitionElementReady + } else { + null + } + ) + } + + override fun onViewRecycled(vh: ViewHolder) { + vh.unbind() + } + + override fun onFailedToRecycleView(vh: ViewHolder): Boolean { + vh.unbind() + return super.onFailedToRecycleView(vh) + } + + private fun onTransitionElementReady(name: String) { + transitionStatusElementCallback?.apply { + onTransitionElementReady(name) + onAllTransitionElementsReady() + } + transitionStatusElementCallback = null + } + } + + private class ViewHolder(view: View) : RecyclerView.ViewHolder(view) { + private val image = view.requireViewById(R.id.image) + private var scope: CoroutineScope? = null + + fun bind( + uri: Uri, + imageLoader: ImageLoader, + previewReadyCallback: ((String) -> Unit)? + ) { + image.setImageDrawable(null) + image.transitionName = if (previewReadyCallback != null) { + TRANSITION_NAME + } else { + null + } + resetScope().launch { + loadImage(uri, imageLoader, previewReadyCallback) + } + } + + private suspend fun loadImage( + uri: Uri, + imageLoader: ImageLoader, + previewReadyCallback: ((String) -> Unit)? + ) { + val bitmap = runCatching { + // it's expected for all loading/caching optimizations to be implemented by the + // loader + imageLoader(uri) + }.getOrNull() + image.setImageBitmap(bitmap) + previewReadyCallback?.let { callback -> + image.waitForPreDraw() + callback(TRANSITION_NAME) + } + } + + private fun resetScope(): CoroutineScope = + (MainScope() + Dispatchers.Main.immediate).also { + scope?.cancel() + scope = it + } + + fun unbind() { + scope?.cancel() + scope = null + } + } + + private class SpacingDecoration(private val margin: Int) : RecyclerView.ItemDecoration() { + override fun getItemOffsets(outRect: Rect, view: View, parent: RecyclerView, state: State) { + outRect.set(margin, 0, margin, 0) + } + } +} -- cgit v1.2.3-59-g8ed1b From ce9a21b2540f14478d326875f13b4156327f1fe4 Mon Sep 17 00:00:00 2001 From: Andrey Epin Date: Thu, 26 Jan 2023 18:13:17 -0800 Subject: A simple UI for Image + Text sharing Add a simple UI for Image + Text sharing under a feature flag. Bug: 262277421 Test: manual test Change-Id: Id7ee55c0d6e7bb21791e5f75a2acb78ce2eac79f --- java/res/layout/chooser_grid_preview_image.xml | 33 ++++++++++++---- .../intentresolver/ChooserContentPreviewUi.java | 23 ++++++++++- .../com/android/intentresolver/HttpUriMatcher.kt | 29 ++++++++++++++ java/src/com/android/intentresolver/flags/Flags.kt | 6 +++ .../UnbundledChooserActivityTest.java | 45 ++++++++++++++++++++++ 5 files changed, 128 insertions(+), 8 deletions(-) create mode 100644 java/src/com/android/intentresolver/HttpUriMatcher.kt (limited to 'java/src') diff --git a/java/res/layout/chooser_grid_preview_image.xml b/java/res/layout/chooser_grid_preview_image.xml index 23bc25d7..d43a26de 100644 --- a/java/res/layout/chooser_grid_preview_image.xml +++ b/java/res/layout/chooser_grid_preview_image.xml @@ -25,13 +25,33 @@ android:orientation="vertical" android:background="?android:attr/colorBackground"> - + android:orientation="horizontal" + android:gravity="center_horizontal" + android:layout_marginBottom="@dimen/chooser_view_spacing"> + + + + + - diff --git a/java/src/com/android/intentresolver/ChooserContentPreviewUi.java b/java/src/com/android/intentresolver/ChooserContentPreviewUi.java index 26c08e36..553a36c6 100644 --- a/java/src/com/android/intentresolver/ChooserContentPreviewUi.java +++ b/java/src/com/android/intentresolver/ChooserContentPreviewUi.java @@ -32,6 +32,7 @@ import android.provider.DocumentsContract; import android.provider.Downloads; import android.provider.OpenableColumns; import android.text.TextUtils; +import android.text.util.Linkify; import android.util.Log; import android.util.PluralsMessageFormatter; import android.view.LayoutInflater; @@ -350,7 +351,7 @@ public final class ChooserContentPreviewUi { return actions; } - private static ViewGroup displayImageContentPreview( + private ViewGroup displayImageContentPreview( Intent targetIntent, LayoutInflater layoutInflater, List actions, @@ -389,12 +390,32 @@ public final class ChooserContentPreviewUi { return contentPreviewLayout; } + setTextInImagePreviewVisibility( + contentPreviewLayout, + targetIntent.getCharSequenceExtra(Intent.EXTRA_TEXT)); imagePreview.setTransitionElementStatusCallback(transitionElementStatusCallback); imagePreview.setImages(imageUris, imageLoader); return contentPreviewLayout; } + private void setTextInImagePreviewVisibility( + ViewGroup contentPreview, CharSequence text) { + int visibility = mFeatureFlagRepository.isEnabled(Flags.SHARESHEET_IMAGE_AND_TEXT_PREVIEW) + && !TextUtils.isEmpty(text) + ? View.VISIBLE + : View.GONE; + + TextView textView = contentPreview + .requireViewById(com.android.internal.R.id.content_preview_text); + textView.setVisibility(visibility); + int linkMask = visibility == View.VISIBLE && HttpUriMatcher.isHttpUri(text.toString()) + ? Linkify.WEB_URLS + : 0; + textView.setAutoLinkMask(linkMask); + textView.setText(text); + } + private static List createImagePreviewActions( ActionFactory buttonFactory) { ArrayList actions = new ArrayList<>(2); diff --git a/java/src/com/android/intentresolver/HttpUriMatcher.kt b/java/src/com/android/intentresolver/HttpUriMatcher.kt new file mode 100644 index 00000000..0f59df2b --- /dev/null +++ b/java/src/com/android/intentresolver/HttpUriMatcher.kt @@ -0,0 +1,29 @@ +/* + * 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("HttpUriMatcher") +package com.android.intentresolver + +import com.android.internal.annotations.VisibleForTesting +import java.net.URI + +@VisibleForTesting(visibility = VisibleForTesting.Visibility.PACKAGE) +fun String.isHttpUri() = + kotlin.runCatching { + URI(this).scheme.takeIf { scheme -> + "http".compareTo(scheme, true) == 0 || "https".compareTo(scheme, true) == 0 + } + }.getOrNull() != null \ No newline at end of file diff --git a/java/src/com/android/intentresolver/flags/Flags.kt b/java/src/com/android/intentresolver/flags/Flags.kt index 6962b1c3..92ee5115 100644 --- a/java/src/com/android/intentresolver/flags/Flags.kt +++ b/java/src/com/android/intentresolver/flags/Flags.kt @@ -29,6 +29,12 @@ object Flags { @JvmField val SHARESHEET_RESELECTION_ACTION = unreleasedFlag(1502, "sharesheet_reselection_action") + // TODO(b/266983474) Tracking Bug + @JvmField + val SHARESHEET_IMAGE_AND_TEXT_PREVIEW = unreleasedFlag( + id = 1503, name = "sharesheet_image_text_preview" + ) + private fun unreleasedFlag(id: Int, name: String, teamfood: Boolean = false) = UnreleasedFlag(id, name, "systemui", teamfood) } diff --git a/java/tests/src/com/android/intentresolver/UnbundledChooserActivityTest.java b/java/tests/src/com/android/intentresolver/UnbundledChooserActivityTest.java index 53c357d6..f06812f1 100644 --- a/java/tests/src/com/android/intentresolver/UnbundledChooserActivityTest.java +++ b/java/tests/src/com/android/intentresolver/UnbundledChooserActivityTest.java @@ -971,6 +971,51 @@ public class UnbundledChooserActivityTest { .check(matches(isDisplayed())); } + @Test + public void testImageAndTextPreview() { + ChooserActivityOverrideData.getInstance().featureFlagRepository = + new FeatureFlagRepository() { + @Override + public boolean isEnabled(@NonNull UnreleasedFlag flag) { + return Flags.SHARESHEET_IMAGE_AND_TEXT_PREVIEW.equals(flag) + || flag.getDefault(); + } + + @Override + public boolean isEnabled(@NonNull ReleasedFlag flag) { + return false; + } + }; + final Uri uri = Uri.parse("android.resource://com.android.frameworks.coretests/" + + R.drawable.test320x240); + 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().previewThumbnail = createBitmap(); + ChooserActivityOverrideData.getInstance().isImageType = true; + + List resolvedComponentInfos = createResolvedComponentsForTest(2); + + when( + ChooserActivityOverrideData + .getInstance() + .resolverListController + .getResolversForIntent( + Mockito.anyBoolean(), + Mockito.anyBoolean(), + Mockito.anyBoolean(), + Mockito.isA(List.class))) + .thenReturn(resolvedComponentInfos); + mActivityRule.launchActivity(Intent.createChooser(sendIntent, null)); + waitForIdle(); + onView(withText(sharedText)) + .check(matches(isDisplayed())); + } + @Test public void testOnCreateLogging() { Intent sendIntent = createSendTextIntent(); -- cgit v1.2.3-59-g8ed1b From 3fd3c66efa00ed9da270e85753defa666bb9af62 Mon Sep 17 00:00:00 2001 From: Matt Casey Date: Thu, 2 Feb 2023 03:25:19 +0000 Subject: [intentresolver] Sharesheet feature flags -> teamfood Test: Build Bug: 262276456 Bug: 262587046 Bug: 262277421 Change-Id: I78181ef35056ae4f0ab86f294e43150ba115c7af --- java/src/com/android/intentresolver/flags/Flags.kt | 6 +++--- 1 file changed, 3 insertions(+), 3 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 92ee5115..db00a751 100644 --- a/java/src/com/android/intentresolver/flags/Flags.kt +++ b/java/src/com/android/intentresolver/flags/Flags.kt @@ -23,16 +23,16 @@ import com.android.systemui.flags.UnreleasedFlag object Flags { // TODO(b/266983432) Tracking Bug @JvmField - val SHARESHEET_CUSTOM_ACTIONS = unreleasedFlag(1501, "sharesheet_custom_actions") + val SHARESHEET_CUSTOM_ACTIONS = unreleasedFlag(1501, "sharesheet_custom_actions", teamfood = true) // TODO(b/266982749) Tracking Bug @JvmField - val SHARESHEET_RESELECTION_ACTION = unreleasedFlag(1502, "sharesheet_reselection_action") + val SHARESHEET_RESELECTION_ACTION = unreleasedFlag(1502, "sharesheet_reselection_action", teamfood = true) // TODO(b/266983474) Tracking Bug @JvmField val SHARESHEET_IMAGE_AND_TEXT_PREVIEW = unreleasedFlag( - id = 1503, name = "sharesheet_image_text_preview" + id = 1503, name = "sharesheet_image_text_preview", teamfood = true ) private fun unreleasedFlag(id: Int, name: String, teamfood: Boolean = false) = -- cgit v1.2.3-59-g8ed1b From 150f8b476d622b592ce9192aed2e106076f2f67e Mon Sep 17 00:00:00 2001 From: Andrey Epin Date: Tue, 31 Jan 2023 14:09:01 -0800 Subject: Enable scrollable image preview view Enable scrollable image preview view under a feature flag. Bug: 262280076 Test: manual test Test: atest IntentResolverUnitTests with the both flag values Change-Id: I86050f1e9193412d15fb35a162a0405cf8287401 --- java/res/layout/chooser_grid_preview_image.xml | 6 +- java/res/layout/chooser_image_preview_view.xml | 24 ++++ .../chooser_image_preview_view_internals.xml | 73 +++++++++++ java/res/layout/image_preview_view.xml | 73 ----------- java/res/layout/scrollable_image_preview_view.xml | 24 ++++ .../intentresolver/ChooserContentPreviewUi.java | 17 ++- java/src/com/android/intentresolver/flags/Flags.kt | 6 + .../widget/ChooserImagePreviewView.kt | 3 +- java/tests/Android.bp | 7 +- .../intentresolver/TestFeatureFlagRepository.kt | 30 +++++ .../UnbundledChooserActivityTest.java | 134 +++++++++++++-------- 11 files changed, 267 insertions(+), 130 deletions(-) create mode 100644 java/res/layout/chooser_image_preview_view.xml create mode 100644 java/res/layout/chooser_image_preview_view_internals.xml delete mode 100644 java/res/layout/image_preview_view.xml create mode 100644 java/res/layout/scrollable_image_preview_view.xml create mode 100644 java/tests/src/com/android/intentresolver/TestFeatureFlagRepository.kt (limited to 'java/src') diff --git a/java/res/layout/chooser_grid_preview_image.xml b/java/res/layout/chooser_grid_preview_image.xml index d43a26de..6af0af11 100644 --- a/java/res/layout/chooser_grid_preview_image.xml +++ b/java/res/layout/chooser_grid_preview_image.xml @@ -16,7 +16,6 @@ * limitations under the License. */ --> - - + + + diff --git a/java/res/layout/chooser_image_preview_view_internals.xml b/java/res/layout/chooser_image_preview_view_internals.xml new file mode 100644 index 00000000..8730fc30 --- /dev/null +++ b/java/res/layout/chooser_image_preview_view_internals.xml @@ -0,0 +1,73 @@ + + + + + + + + + + + + + diff --git a/java/res/layout/image_preview_view.xml b/java/res/layout/image_preview_view.xml deleted file mode 100644 index 8730fc30..00000000 --- a/java/res/layout/image_preview_view.xml +++ /dev/null @@ -1,73 +0,0 @@ - - - - - - - - - - - - - diff --git a/java/res/layout/scrollable_image_preview_view.xml b/java/res/layout/scrollable_image_preview_view.xml new file mode 100644 index 00000000..ff2f273f --- /dev/null +++ b/java/res/layout/scrollable_image_preview_view.xml @@ -0,0 +1,24 @@ + + + + diff --git a/java/src/com/android/intentresolver/ChooserContentPreviewUi.java b/java/src/com/android/intentresolver/ChooserContentPreviewUi.java index 553a36c6..9bef3553 100644 --- a/java/src/com/android/intentresolver/ChooserContentPreviewUi.java +++ b/java/src/com/android/intentresolver/ChooserContentPreviewUi.java @@ -363,8 +363,7 @@ public final class ChooserContentPreviewUi { @LayoutRes int actionRowLayout) { ViewGroup contentPreviewLayout = (ViewGroup) layoutInflater.inflate( R.layout.chooser_grid_preview_image, parent, false); - ImagePreviewView imagePreview = contentPreviewLayout.findViewById( - com.android.internal.R.id.content_preview_image_area); + ImagePreviewView imagePreview = inflateImagePreviewView(contentPreviewLayout); final ActionRow actionRow = inflateActionRow(contentPreviewLayout, actionRowLayout); if (actionRow != null) { @@ -431,6 +430,20 @@ public final class ChooserContentPreviewUi { return actions; } + private ImagePreviewView inflateImagePreviewView(ViewGroup previewLayout) { + ViewStub stub = previewLayout.findViewById(R.id.image_preview_stub); + if (stub != null) { + int layoutId = + mFeatureFlagRepository.isEnabled(Flags.SHARESHEET_SCROLLABLE_IMAGE_PREVIEW) + ? R.layout.scrollable_image_preview_view + : R.layout.chooser_image_preview_view; + stub.setLayoutResource(layoutId); + stub.inflate(); + } + return previewLayout.findViewById( + com.android.internal.R.id.content_preview_image_area); + } + private static ViewGroup displayFileContentPreview( Intent targetIntent, Resources resources, diff --git a/java/src/com/android/intentresolver/flags/Flags.kt b/java/src/com/android/intentresolver/flags/Flags.kt index 92ee5115..027eb56c 100644 --- a/java/src/com/android/intentresolver/flags/Flags.kt +++ b/java/src/com/android/intentresolver/flags/Flags.kt @@ -35,6 +35,12 @@ object Flags { id = 1503, name = "sharesheet_image_text_preview" ) + // TODO(b/267355521) Tracking Bug + @JvmField + val SHARESHEET_SCROLLABLE_IMAGE_PREVIEW = unreleasedFlag( + 1504, "sharesheet_scrollable_image_preview" + ) + private fun unreleasedFlag(id: Int, name: String, teamfood: Boolean = false) = UnreleasedFlag(id, name, "systemui", teamfood) } diff --git a/java/src/com/android/intentresolver/widget/ChooserImagePreviewView.kt b/java/src/com/android/intentresolver/widget/ChooserImagePreviewView.kt index bf10bfaa..ca94a95d 100644 --- a/java/src/com/android/intentresolver/widget/ChooserImagePreviewView.kt +++ b/java/src/com/android/intentresolver/widget/ChooserImagePreviewView.kt @@ -57,7 +57,8 @@ class ChooserImagePreviewView : RelativeLayout, ImagePreviewView { private var transitionStatusElementCallback: TransitionElementStatusCallback? = null override fun onFinishInflate() { - LayoutInflater.from(context).inflate(R.layout.image_preview_view, this, true) + LayoutInflater.from(context) + .inflate(R.layout.chooser_image_preview_view_internals, this, true) mainImage = requireViewById(IntR.id.content_preview_image_1_large) secondLargeImage = requireViewById(IntR.id.content_preview_image_2_large) secondSmallImage = requireViewById(IntR.id.content_preview_image_2_small) diff --git a/java/tests/Android.bp b/java/tests/Android.bp index a62c52e6..4e835ec8 100644 --- a/java/tests/Android.bp +++ b/java/tests/Android.bp @@ -21,11 +21,12 @@ android_test { "IntentResolver-core", "androidx.test.rules", "androidx.test.ext.junit", + "androidx.test.espresso.contrib", "mockito-target-minus-junit4", "androidx.test.espresso.core", - "androidx.lifecycle_lifecycle-common-java8", - "androidx.lifecycle_lifecycle-extensions", - "androidx.lifecycle_lifecycle-runtime-ktx", + "androidx.lifecycle_lifecycle-common-java8", + "androidx.lifecycle_lifecycle-extensions", + "androidx.lifecycle_lifecycle-runtime-ktx", "truth-prebuilt", "testables", "testng", diff --git a/java/tests/src/com/android/intentresolver/TestFeatureFlagRepository.kt b/java/tests/src/com/android/intentresolver/TestFeatureFlagRepository.kt new file mode 100644 index 00000000..abc24efb --- /dev/null +++ b/java/tests/src/com/android/intentresolver/TestFeatureFlagRepository.kt @@ -0,0 +1,30 @@ +/* + * 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.ReleasedFlag +import com.android.systemui.flags.UnreleasedFlag + +internal class TestFeatureFlagRepository( + private val overrides: Map +) : FeatureFlagRepository { + override fun isEnabled(flag: UnreleasedFlag): Boolean = + overrides.getOrDefault(flag, flag.default) + + override fun isEnabled(flag: ReleasedFlag): Boolean = flag.default +} diff --git a/java/tests/src/com/android/intentresolver/UnbundledChooserActivityTest.java b/java/tests/src/com/android/intentresolver/UnbundledChooserActivityTest.java index 2d8a74b9..249dca62 100644 --- a/java/tests/src/com/android/intentresolver/UnbundledChooserActivityTest.java +++ b/java/tests/src/com/android/intentresolver/UnbundledChooserActivityTest.java @@ -90,24 +90,23 @@ import android.util.HashedStringCache; import android.util.Pair; import android.util.SparseArray; import android.view.View; +import android.view.ViewGroup; import androidx.annotation.CallSuper; import androidx.annotation.NonNull; 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.platform.app.InstrumentationRegistry; import androidx.test.rule.ActivityTestRule; import com.android.intentresolver.ResolverActivity.ResolvedComponentInfo; import com.android.intentresolver.chooser.DisplayResolveInfo; -import com.android.intentresolver.flags.FeatureFlagRepository; import com.android.intentresolver.flags.Flags; 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.ReleasedFlag; -import com.android.systemui.flags.UnreleasedFlag; import org.hamcrest.Description; import org.hamcrest.Matcher; @@ -124,6 +123,7 @@ 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; @@ -859,7 +859,7 @@ public class UnbundledChooserActivityTest { @Test - public void oneVisibleImagePreview() throws InterruptedException { + public void oneVisibleImagePreview() { Uri uri = Uri.parse("android.resource://com.android.frameworks.coretests/" + R.drawable.test320x240); @@ -884,18 +884,31 @@ public class UnbundledChooserActivityTest { .thenReturn(resolvedComponentInfos); mActivityRule.launchActivity(Intent.createChooser(sendIntent, null)); waitForIdle(); - onView(withId(com.android.internal.R.id.content_preview_image_1_large)) - .check(matches(isDisplayed())); - onView(withId(com.android.internal.R.id.content_preview_image_2_large)) - .check(matches(not(isDisplayed()))); - onView(withId(com.android.internal.R.id.content_preview_image_2_small)) - .check(matches(not(isDisplayed()))); - onView(withId(com.android.internal.R.id.content_preview_image_3_small)) - .check(matches(not(isDisplayed()))); + onView(withId(com.android.internal.R.id.content_preview_image_area)) + .check((view, exception) -> { + if (exception != null) { + throw exception; + } + ViewGroup parent = (ViewGroup) view; + ArrayList visibleViews = new ArrayList<>(); + for (int i = 0, count = parent.getChildCount(); i < count; i++) { + View child = parent.getChildAt(i); + if (child.getVisibility() == View.VISIBLE) { + visibleViews.add(child); + } + } + assertThat(visibleViews.size(), is(1)); + assertThat( + "image preview view is fully visible", + isDisplayed().matches(visibleViews.get(0))); + }); } @Test - public void twoVisibleImagePreview() throws InterruptedException { + public void twoVisibleImagePreview() { + ChooserActivityOverrideData.getInstance().featureFlagRepository = + new TestFeatureFlagRepository( + Collections.singletonMap(Flags.SHARESHEET_SCROLLABLE_IMAGE_PREVIEW, false)); Uri uri = Uri.parse("android.resource://com.android.frameworks.coretests/" + R.drawable.test320x240); @@ -932,7 +945,11 @@ public class UnbundledChooserActivityTest { } @Test - public void threeOrMoreVisibleImagePreview() throws InterruptedException { + public void threeOrMoreVisibleImagePreview() { + ChooserActivityOverrideData.getInstance().featureFlagRepository = + new TestFeatureFlagRepository( + Collections.singletonMap( + Flags.SHARESHEET_SCROLLABLE_IMAGE_PREVIEW, false)); Uri uri = Uri.parse("android.resource://com.android.frameworks.coretests/" + R.drawable.test320x240); @@ -972,20 +989,60 @@ public class UnbundledChooserActivityTest { } @Test - public void testImageAndTextPreview() { + public void testManyVisibleImagePreview_ScrollableImagePreview() { ChooserActivityOverrideData.getInstance().featureFlagRepository = - new FeatureFlagRepository() { - @Override - public boolean isEnabled(@NonNull UnreleasedFlag flag) { - return Flags.SHARESHEET_IMAGE_AND_TEXT_PREVIEW.equals(flag) - || flag.getDefault(); - } + new TestFeatureFlagRepository( + Collections.singletonMap( + Flags.SHARESHEET_SCROLLABLE_IMAGE_PREVIEW, true)); + Uri uri = Uri.parse("android.resource://com.android.frameworks.coretests/" + + R.drawable.test320x240); + + 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().previewThumbnail = createBitmap(); + ChooserActivityOverrideData.getInstance().isImageType = true; + + List resolvedComponentInfos = createResolvedComponentsForTest(2); - @Override - public boolean isEnabled(@NonNull ReleasedFlag flag) { - return false; + when( + ChooserActivityOverrideData + .getInstance() + .resolverListController + .getResolversForIntent( + Mockito.anyBoolean(), + Mockito.anyBoolean(), + Mockito.anyBoolean(), + Mockito.isA(List.class))) + .thenReturn(resolvedComponentInfos); + mActivityRule.launchActivity(Intent.createChooser(sendIntent, null)); + waitForIdle(); + onView(withId(com.android.internal.R.id.content_preview_image_area)) + .perform(RecyclerViewActions.scrollToLastPosition()) + .check((view, exception) -> { + if (exception != null) { + throw exception; } - }; + RecyclerView recyclerView = (RecyclerView) view; + assertThat(recyclerView.getAdapter().getItemCount(), is(uris.size())); + }); + } + + @Test + public void testImageAndTextPreview() { + ChooserActivityOverrideData.getInstance().featureFlagRepository = + new TestFeatureFlagRepository( + Collections.singletonMap(Flags.SHARESHEET_IMAGE_AND_TEXT_PREVIEW, true)); final Uri uri = Uri.parse("android.resource://com.android.frameworks.coretests/" + R.drawable.test320x240); final String sharedText = "text-" + System.currentTimeMillis(); @@ -1723,17 +1780,8 @@ public class UnbundledChooserActivityTest { @Test public void testLaunchWithCustomAction() throws InterruptedException { ChooserActivityOverrideData.getInstance().featureFlagRepository = - new FeatureFlagRepository() { - @Override - public boolean isEnabled(@NonNull UnreleasedFlag flag) { - return Flags.SHARESHEET_CUSTOM_ACTIONS.equals(flag) || flag.getDefault(); - } - - @Override - public boolean isEnabled(@NonNull ReleasedFlag flag) { - return false; - } - }; + new TestFeatureFlagRepository( + Collections.singletonMap(Flags.SHARESHEET_CUSTOM_ACTIONS, true)); List resolvedComponentInfos = createResolvedComponentsForTest(2); when( ChooserActivityOverrideData @@ -1787,18 +1835,8 @@ public class UnbundledChooserActivityTest { @Test public void testLaunchWithPayloadReselection() throws InterruptedException { ChooserActivityOverrideData.getInstance().featureFlagRepository = - new FeatureFlagRepository() { - @Override - public boolean isEnabled(@NonNull UnreleasedFlag flag) { - return Flags.SHARESHEET_RESELECTION_ACTION.equals(flag) - || flag.getDefault(); - } - - @Override - public boolean isEnabled(@NonNull ReleasedFlag flag) { - return false; - } - }; + new TestFeatureFlagRepository( + Collections.singletonMap(Flags.SHARESHEET_RESELECTION_ACTION, true)); List resolvedComponentInfos = createResolvedComponentsForTest(2); when( ChooserActivityOverrideData -- cgit v1.2.3-59-g8ed1b From 651ea96e4e069309cf3d1c5f518388e1410d5172 Mon Sep 17 00:00:00 2001 From: Andrey Epin Date: Thu, 2 Feb 2023 11:37:21 -0800 Subject: Add MultiDisplayResolveInfo#cloneFilledIn() implementation. Fix: 267652812 Test: manual verification of the fix Change-Id: I1355c64a6226579d4af05c2b259866f782b4e9fe --- .../intentresolver/chooser/DisplayResolveInfo.java | 4 ++++ .../chooser/MultiDisplayResolveInfo.java | 19 ++++++++++++++++--- .../android/intentresolver/chooser/TargetInfoTest.kt | 5 +++++ 3 files changed, 25 insertions(+), 3 deletions(-) (limited to 'java/src') diff --git a/java/src/com/android/intentresolver/chooser/DisplayResolveInfo.java b/java/src/com/android/intentresolver/chooser/DisplayResolveInfo.java index db5ae0b4..1b729c0e 100644 --- a/java/src/com/android/intentresolver/chooser/DisplayResolveInfo.java +++ b/java/src/com/android/intentresolver/chooser/DisplayResolveInfo.java @@ -169,6 +169,10 @@ public class DisplayResolveInfo implements TargetInfo { @Override public TargetInfo cloneFilledIn(Intent fillInIntent, int flags) { + return cloneFilledInInternal(fillInIntent, flags); + } + + protected final DisplayResolveInfo cloneFilledInInternal(Intent fillInIntent, int flags) { return new DisplayResolveInfo(this, fillInIntent, flags, mPresentationGetter); } diff --git a/java/src/com/android/intentresolver/chooser/MultiDisplayResolveInfo.java b/java/src/com/android/intentresolver/chooser/MultiDisplayResolveInfo.java index 29f00a35..e4cec887 100644 --- a/java/src/com/android/intentresolver/chooser/MultiDisplayResolveInfo.java +++ b/java/src/com/android/intentresolver/chooser/MultiDisplayResolveInfo.java @@ -17,6 +17,7 @@ package com.android.intentresolver.chooser; import android.app.Activity; +import android.content.Intent; import android.os.Bundle; import android.os.UserHandle; @@ -30,7 +31,7 @@ import java.util.List; */ public class MultiDisplayResolveInfo extends DisplayResolveInfo { - ArrayList mTargetInfos = new ArrayList<>(); + final ArrayList mTargetInfos; // Index of selected target private int mSelected = -1; @@ -66,8 +67,9 @@ public class MultiDisplayResolveInfo extends DisplayResolveInfo { /** * List of all {@link DisplayResolveInfo}s included in this target. - * TODO: provide as a generic {@code List} once {@link ChooserActivity} - * stops requiring the signature to match that of the other "lists" it builds up. + * TODO: provide as a generic {@code List} once + * {@link com.android.intentresolver.ChooserActivity} stops requiring the signature to match + * that of the other "lists" it builds up. */ @Override public ArrayList getAllDisplayTargets() { @@ -92,6 +94,17 @@ public class MultiDisplayResolveInfo extends DisplayResolveInfo { return mSelected >= 0; } + @Override + public TargetInfo cloneFilledIn(Intent fillInIntent, int flags) { + ArrayList targetInfos = new ArrayList<>(mTargetInfos.size()); + for (int i = 0, size = mTargetInfos.size(); i < size; i++) { + targetInfos.add(mTargetInfos.get(i).cloneFilledInInternal(fillInIntent, flags)); + } + MultiDisplayResolveInfo clone = new MultiDisplayResolveInfo(targetInfos); + clone.mSelected = mSelected; + return clone; + } + @Override public boolean start(Activity activity, Bundle options) { return mTargetInfos.get(mSelected).start(activity, options); diff --git a/java/tests/src/com/android/intentresolver/chooser/TargetInfoTest.kt b/java/tests/src/com/android/intentresolver/chooser/TargetInfoTest.kt index 7c2b07a9..69948cc9 100644 --- a/java/tests/src/com/android/intentresolver/chooser/TargetInfoTest.kt +++ b/java/tests/src/com/android/intentresolver/chooser/TargetInfoTest.kt @@ -186,6 +186,11 @@ class TargetInfoTest { assertThat(multiTargetInfo.hasSelected()).isTrue() assertThat(multiTargetInfo.getSelectedTarget()).isEqualTo(secondTargetInfo) + val multiTargetInfoClone = multiTargetInfo.cloneFilledIn(Intent(), 0) + assertThat(multiTargetInfoClone).isInstanceOf(MultiDisplayResolveInfo::class.java) + assertThat((multiTargetInfoClone as MultiDisplayResolveInfo).hasSelected()) + .isEqualTo(multiTargetInfo.hasSelected()) + // TODO: consider exercising activity-start behavior. // TODO: consider exercising DisplayResolveInfo base class behavior. } -- cgit v1.2.3-59-g8ed1b From 89697b484befb174482c92f5072f01836678d46c Mon Sep 17 00:00:00 2001 From: Andrey Epin Date: Fri, 3 Feb 2023 08:56:17 -0800 Subject: Add optional text exclusion from media + text share Add an option to the user to exclude text from being shared from a media + text sharing case. The text is ecluded only if the primary send inten is used i.e. it is possible for senders to specify alterntive intents that can also be used. Bug: 262277421 Test: manual testing of: (1) regualr image sharing, (2) image and text/web-link sharing, (3) using of an alternative intent, (4) sharing to a shortcut. All of those for one and two (+work) profiles. Test: integration tests Change-Id: Iebb33b951853c6588157fa92d3826b6a6e096591 --- java/res/layout/chooser_grid_preview_image.xml | 8 + java/res/values/strings.xml | 9 ++ .../android/intentresolver/ChooserActivity.java | 30 ++++ .../intentresolver/ChooserContentPreviewUi.java | 50 +++++-- .../intentresolver/chooser/DisplayResolveInfo.java | 5 + .../chooser/MultiDisplayResolveInfo.java | 5 + .../chooser/NotSelectableTargetInfo.java | 7 + .../chooser/SelectableTargetInfo.java | 6 + .../android/intentresolver/chooser/TargetInfo.java | 6 + .../intentresolver/ResolverDataProvider.java | 27 ++++ .../UnbundledChooserActivityTest.java | 165 +++++++++++++++++++++ 11 files changed, 309 insertions(+), 9 deletions(-) (limited to 'java/src') diff --git a/java/res/layout/chooser_grid_preview_image.xml b/java/res/layout/chooser_grid_preview_image.xml index 6af0af11..80c12e6c 100644 --- a/java/res/layout/chooser_grid_preview_image.xml +++ b/java/res/layout/chooser_grid_preview_image.xml @@ -24,6 +24,14 @@ android:orientation="vertical" android:background="?android:attr/colorBackground"> + + Select Images Select Text + + + Exclude text + + Include text + + Exclude link + + Include link diff --git a/java/src/com/android/intentresolver/ChooserActivity.java b/java/src/com/android/intentresolver/ChooserActivity.java index e741b06c..3a7d4e68 100644 --- a/java/src/com/android/intentresolver/ChooserActivity.java +++ b/java/src/com/android/intentresolver/ChooserActivity.java @@ -262,6 +262,8 @@ public class ChooserActivity extends ResolverActivity implements private final SparseArray mProfileRecords = new SparseArray<>(); + private boolean mExcludeSharedText = false; + public ChooserActivity() {} @Override @@ -771,6 +773,11 @@ public class ChooserActivity extends ResolverActivity implements ? null : createReselectionRunnable(reselectionAction); } + + @Override + public Consumer getExcludeSharedTextAction() { + return (isExcluded) -> mExcludeSharedText = isExcluded; + } }; ViewGroup layout = mChooserContentPreviewUi.displayContentPreview( @@ -1196,6 +1203,7 @@ public class ChooserActivity extends ResolverActivity implements } } updateModelAndChooserCounts(target); + maybeRemoveSharedText(target); return super.onTargetSelected(target, alwaysCheck); } @@ -1384,6 +1392,27 @@ public class ChooserActivity extends ResolverActivity implements mIsSuccessfullySelected = true; } + private void maybeRemoveSharedText(@androidx.annotation.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()) { @@ -1451,6 +1480,7 @@ public class ChooserActivity extends ResolverActivity implements + " cannot match refined source intent " + matchingIntent); } else { TargetInfo clonedTarget = selectedTarget.cloneFilledIn(matchingIntent, 0); + maybeRemoveSharedText(clonedTarget); if (super.onTargetSelected(clonedTarget, false)) { updateModelAndChooserCounts(clonedTarget); finish(); diff --git a/java/src/com/android/intentresolver/ChooserContentPreviewUi.java b/java/src/com/android/intentresolver/ChooserContentPreviewUi.java index 9bef3553..91abd9d0 100644 --- a/java/src/com/android/intentresolver/ChooserContentPreviewUi.java +++ b/java/src/com/android/intentresolver/ChooserContentPreviewUi.java @@ -40,6 +40,7 @@ import android.view.View; import android.view.ViewGroup; import android.view.ViewStub; import android.view.animation.DecelerateInterpolator; +import android.widget.CheckBox; import android.widget.ImageView; import android.widget.TextView; @@ -59,6 +60,7 @@ import java.util.ArrayList; import java.util.HashMap; import java.util.List; import java.util.Map; +import java.util.function.Consumer; import java.util.stream.Collectors; /** @@ -100,6 +102,17 @@ public final class ChooserContentPreviewUi { */ @Nullable Runnable getReselectionAction(); + + /** + *

+ * 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. + *

+ */ + Consumer getExcludeSharedTextAction(); } /** @@ -222,7 +235,8 @@ public final class ChooserContentPreviewUi { transitionElementStatusCallback, contentResolver, imageClassifier, - actionRowLayout); + actionRowLayout, + actionFactory); break; case CONTENT_PREVIEW_FILE: layout = displayFileContentPreview( @@ -360,7 +374,8 @@ public final class ChooserContentPreviewUi { TransitionElementStatusCallback transitionElementStatusCallback, ContentResolver contentResolver, ImageMimeTypeClassifier imageClassifier, - @LayoutRes int actionRowLayout) { + @LayoutRes int actionRowLayout, + ActionFactory actionFactory) { ViewGroup contentPreviewLayout = (ViewGroup) layoutInflater.inflate( R.layout.chooser_grid_preview_image, parent, false); ImagePreviewView imagePreview = inflateImagePreviewView(contentPreviewLayout); @@ -391,7 +406,8 @@ public final class ChooserContentPreviewUi { setTextInImagePreviewVisibility( contentPreviewLayout, - targetIntent.getCharSequenceExtra(Intent.EXTRA_TEXT)); + targetIntent.getCharSequenceExtra(Intent.EXTRA_TEXT), + actionFactory); imagePreview.setTransitionElementStatusCallback(transitionElementStatusCallback); imagePreview.setImages(imageUris, imageLoader); @@ -399,20 +415,36 @@ public final class ChooserContentPreviewUi { } private void setTextInImagePreviewVisibility( - ViewGroup contentPreview, CharSequence text) { + ViewGroup contentPreview, CharSequence text, ActionFactory actionFactory) { int visibility = mFeatureFlagRepository.isEnabled(Flags.SHARESHEET_IMAGE_AND_TEXT_PREVIEW) && !TextUtils.isEmpty(text) ? View.VISIBLE : View.GONE; - TextView textView = contentPreview + final TextView textView = contentPreview .requireViewById(com.android.internal.R.id.content_preview_text); + CheckBox actionView = contentPreview + .requireViewById(R.id.include_text_action); textView.setVisibility(visibility); - int linkMask = visibility == View.VISIBLE && HttpUriMatcher.isHttpUri(text.toString()) - ? Linkify.WEB_URLS - : 0; - textView.setAutoLinkMask(linkMask); + boolean isLink = visibility == View.VISIBLE && HttpUriMatcher.isHttpUri(text.toString()); + textView.setAutoLinkMask(isLink ? Linkify.WEB_URLS : 0); textView.setText(text); + + if (visibility == View.VISIBLE) { + final int[] actionLabels = isLink + ? new int[] { R.string.include_link, R.string.exclude_link } + : new int[] { R.string.include_text, R.string.exclude_text }; + final Consumer shareTextAction = actionFactory.getExcludeSharedTextAction(); + actionView.setChecked(true); + actionView.setText(actionLabels[1]); + shareTextAction.accept(false); + actionView.setOnCheckedChangeListener((view, isChecked) -> { + view.setText(actionLabels[isChecked ? 1 : 0]); + textView.setVisibility(isChecked ? View.VISIBLE : View.GONE); + shareTextAction.accept(!isChecked); + }); + } + actionView.setVisibility(visibility); } private static List createImagePreviewActions( diff --git a/java/src/com/android/intentresolver/chooser/DisplayResolveInfo.java b/java/src/com/android/intentresolver/chooser/DisplayResolveInfo.java index 1b729c0e..4bbf59d8 100644 --- a/java/src/com/android/intentresolver/chooser/DisplayResolveInfo.java +++ b/java/src/com/android/intentresolver/chooser/DisplayResolveInfo.java @@ -224,6 +224,11 @@ public class DisplayResolveInfo implements TargetInfo { return false; } + @Override + public Intent getTargetIntent() { + return mResolvedIntent; + } + public boolean isSuspended() { return mIsSuspended; } diff --git a/java/src/com/android/intentresolver/chooser/MultiDisplayResolveInfo.java b/java/src/com/android/intentresolver/chooser/MultiDisplayResolveInfo.java index e4cec887..0d79e5d5 100644 --- a/java/src/com/android/intentresolver/chooser/MultiDisplayResolveInfo.java +++ b/java/src/com/android/intentresolver/chooser/MultiDisplayResolveInfo.java @@ -119,4 +119,9 @@ public class MultiDisplayResolveInfo extends DisplayResolveInfo { public boolean startAsUser(Activity activity, Bundle options, UserHandle user) { return mTargetInfos.get(mSelected).startAsUser(activity, options, user); } + + @Override + public Intent getTargetIntent() { + return mTargetInfos.get(mSelected).getTargetIntent(); + } } diff --git a/java/src/com/android/intentresolver/chooser/NotSelectableTargetInfo.java b/java/src/com/android/intentresolver/chooser/NotSelectableTargetInfo.java index d6333374..9a2c971f 100644 --- a/java/src/com/android/intentresolver/chooser/NotSelectableTargetInfo.java +++ b/java/src/com/android/intentresolver/chooser/NotSelectableTargetInfo.java @@ -16,6 +16,7 @@ package com.android.intentresolver.chooser; +import android.annotation.Nullable; import android.app.Activity; import android.content.ComponentName; import android.content.Context; @@ -101,6 +102,12 @@ public abstract class NotSelectableTargetInfo extends ChooserTargetInfo { return false; } + @Nullable + @Override + public Intent getTargetIntent() { + return null; + } + public boolean startAsUser(Activity activity, Bundle options, UserHandle user) { return false; } diff --git a/java/src/com/android/intentresolver/chooser/SelectableTargetInfo.java b/java/src/com/android/intentresolver/chooser/SelectableTargetInfo.java index 3ab50175..ca778233 100644 --- a/java/src/com/android/intentresolver/chooser/SelectableTargetInfo.java +++ b/java/src/com/android/intentresolver/chooser/SelectableTargetInfo.java @@ -346,6 +346,12 @@ public final class SelectableTargetInfo extends ChooserTargetInfo { return mActivityStarter.startAsUser(activity, options, user); } + @Nullable + @Override + public Intent getTargetIntent() { + return mBaseIntentToSend; + } + @Override public ResolveInfo getResolveInfo() { return mResolveInfo; diff --git a/java/src/com/android/intentresolver/chooser/TargetInfo.java b/java/src/com/android/intentresolver/chooser/TargetInfo.java index 72dd1b0b..7dcf66b2 100644 --- a/java/src/com/android/intentresolver/chooser/TargetInfo.java +++ b/java/src/com/android/intentresolver/chooser/TargetInfo.java @@ -87,6 +87,12 @@ public interface TargetInfo { */ Intent getResolvedIntent(); + /** + * Get the target intent, the one that will be used with one of the start methods. + * @return the intent with target will be launced with. + */ + @Nullable Intent getTargetIntent(); + /** * Get the resolved component name that represents this target. Note that this may not * be the component that will be directly launched by calling one of the start diff --git a/java/tests/src/com/android/intentresolver/ResolverDataProvider.java b/java/tests/src/com/android/intentresolver/ResolverDataProvider.java index fb928e09..6807bfd6 100644 --- a/java/tests/src/com/android/intentresolver/ResolverDataProvider.java +++ b/java/tests/src/com/android/intentresolver/ResolverDataProvider.java @@ -41,6 +41,14 @@ public class ResolverDataProvider { createResolverIntent(i), createResolveInfo(i, UserHandle.USER_CURRENT)); } + static ResolverActivity.ResolvedComponentInfo createResolvedComponentInfo( + ComponentName componentName, Intent intent) { + return new ResolverActivity.ResolvedComponentInfo( + componentName, + intent, + createResolveInfo(componentName, UserHandle.USER_CURRENT)); + } + static ResolverActivity.ResolvedComponentInfo createResolvedComponentInfoWithOtherId(int i) { return new ResolverActivity.ResolvedComponentInfo(createComponentName(i), createResolverIntent(i), createResolveInfo(i, USER_SOMEONE_ELSE)); @@ -64,6 +72,13 @@ public class ResolverDataProvider { return resolveInfo; } + public static ResolveInfo createResolveInfo(ComponentName componentName, int userId) { + final ResolveInfo resolveInfo = new ResolveInfo(); + resolveInfo.activityInfo = createActivityInfo(componentName); + resolveInfo.targetUserId = userId; + return resolveInfo; + } + static ActivityInfo createActivityInfo(int i) { ActivityInfo ai = new ActivityInfo(); ai.name = "activity_name" + i; @@ -75,6 +90,18 @@ public class ResolverDataProvider { return ai; } + static ActivityInfo createActivityInfo(ComponentName componentName) { + ActivityInfo ai = new ActivityInfo(); + ai.name = componentName.getClassName(); + ai.packageName = componentName.getPackageName(); + ai.enabled = true; + ai.exported = true; + ai.permission = null; + ai.applicationInfo = createApplicationInfo(); + ai.applicationInfo.packageName = componentName.getPackageName(); + return ai; + } + static ApplicationInfo createApplicationInfo() { ApplicationInfo ai = new ApplicationInfo(); ai.name = "app_name"; diff --git a/java/tests/src/com/android/intentresolver/UnbundledChooserActivityTest.java b/java/tests/src/com/android/intentresolver/UnbundledChooserActivityTest.java index 249dca62..c90f0b63 100644 --- a/java/tests/src/com/android/intentresolver/UnbundledChooserActivityTest.java +++ b/java/tests/src/com/android/intentresolver/UnbundledChooserActivityTest.java @@ -128,6 +128,7 @@ import java.util.HashMap; import java.util.List; import java.util.Map; import java.util.concurrent.CountDownLatch; +import java.util.concurrent.atomic.AtomicReference; import java.util.function.Consumer; import java.util.function.Function; @@ -755,6 +756,170 @@ public class UnbundledChooserActivityTest { assertThat(chosen[0], is(toChoose)); } + @Test + public void testImagePlusTextSharing_ExcludeText() { + ChooserActivityOverrideData.getInstance().featureFlagRepository = + new TestFeatureFlagRepository( + Collections.singletonMap(Flags.SHARESHEET_IMAGE_AND_TEXT_PREVIEW, true)); + Intent sendIntent = createSendImageIntent( + Uri.parse("android.resource://com.android.frameworks.coretests/" + + R.drawable.test320x240)); + ChooserActivityOverrideData.getInstance().previewThumbnail = createBitmap(); + ChooserActivityOverrideData.getInstance().isImageType = true; + sendIntent.putExtra(Intent.EXTRA_TEXT, "https://google.com/search?q=google"); + + List resolvedComponentInfos = Arrays.asList( + ResolverDataProvider.createResolvedComponentInfo( + new ComponentName("org.imageviewer", "ImageTarget"), + sendIntent), + ResolverDataProvider.createResolvedComponentInfo( + new ComponentName("org.textviewer", "UriTarget"), + new Intent("VIEW_TEXT")) + ); + + when( + ChooserActivityOverrideData + .getInstance() + .resolverListController + .getResolversForIntent( + Mockito.anyBoolean(), + Mockito.anyBoolean(), + Mockito.anyBoolean(), + Mockito.isA(List.class))) + .thenReturn(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().onSafelyStartCallback = 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 + public void testImagePlusTextSharing_RemoveAndAddBackText() { + ChooserActivityOverrideData.getInstance().featureFlagRepository = + new TestFeatureFlagRepository( + Collections.singletonMap(Flags.SHARESHEET_IMAGE_AND_TEXT_PREVIEW, true)); + Intent sendIntent = createSendImageIntent( + Uri.parse("android.resource://com.android.frameworks.coretests/" + + R.drawable.test320x240)); + ChooserActivityOverrideData.getInstance().previewThumbnail = createBitmap(); + ChooserActivityOverrideData.getInstance().isImageType = true; + 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), + ResolverDataProvider.createResolvedComponentInfo( + new ComponentName("org.textviewer", "UriTarget"), + new Intent("VIEW_TEXT")) + ); + + when( + ChooserActivityOverrideData + .getInstance() + .resolverListController + .getResolversForIntent( + Mockito.anyBoolean(), + Mockito.anyBoolean(), + Mockito.anyBoolean(), + Mockito.isA(List.class))) + .thenReturn(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.include_text_action)) + .perform(click()); + waitForIdle(); + + AtomicReference launchedIntentRef = new AtomicReference<>(); + ChooserActivityOverrideData.getInstance().onSafelyStartCallback = 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 + public void testImagePlusTextSharing_TextExclusionDoesNotAffectAlternativeIntent() { + ChooserActivityOverrideData.getInstance().featureFlagRepository = + new TestFeatureFlagRepository( + Collections.singletonMap(Flags.SHARESHEET_IMAGE_AND_TEXT_PREVIEW, true)); + Intent sendIntent = createSendImageIntent( + Uri.parse("android.resource://com.android.frameworks.coretests/" + + R.drawable.test320x240)); + ChooserActivityOverrideData.getInstance().previewThumbnail = createBitmap(); + ChooserActivityOverrideData.getInstance().isImageType = true; + 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), + ResolverDataProvider.createResolvedComponentInfo( + new ComponentName("org.textviewer", "UriTarget"), + alternativeIntent) + ); + + when( + ChooserActivityOverrideData + .getInstance() + .resolverListController + .getResolversForIntent( + Mockito.anyBoolean(), + Mockito.anyBoolean(), + Mockito.anyBoolean(), + Mockito.isA(List.class))) + .thenReturn(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().onSafelyStartCallback = 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 public void copyTextToClipboard() throws Exception { Intent sendIntent = createSendTextIntent(); -- cgit v1.2.3-59-g8ed1b From b4d44a2f5f9a6b07f56f2312d583921a9eff0b2b Mon Sep 17 00:00:00 2001 From: Andrey Epin Date: Mon, 6 Feb 2023 15:21:53 -0800 Subject: Explicitly tint chooser action icons Fix: 267509997 Test: manual test Change-Id: I2f0f559f4aff0ce85d083cc60544b99a1d809e57 --- java/src/com/android/intentresolver/widget/ScrollableActionRow.kt | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) (limited to 'java/src') diff --git a/java/src/com/android/intentresolver/widget/ScrollableActionRow.kt b/java/src/com/android/intentresolver/widget/ScrollableActionRow.kt index 81630545..f2a8b9e8 100644 --- a/java/src/com/android/intentresolver/widget/ScrollableActionRow.kt +++ b/java/src/com/android/intentresolver/widget/ScrollableActionRow.kt @@ -88,11 +88,12 @@ class ScrollableActionRow : RecyclerView, ActionRow { ) : RecyclerView.ViewHolder(view) { fun bind(action: ActionRow.Action) { - if (action.icon != null) { - action.icon.setBounds(0, 0, iconSize, iconSize) + action.icon?.let { icon -> + icon.setBounds(0, 0, iconSize, iconSize) // some drawables (edit) does not gets tinted when set to the top of the text // with TextView#setCompoundDrawableRelative - view.setCompoundDrawablesRelative(null, action.icon, null, null) + tintIcon(icon, view) + view.setCompoundDrawablesRelative(null, icon, null, null) } view.text = action.label ?: "" view.setOnClickListener { -- cgit v1.2.3-59-g8ed1b From f9976aef1792f73b7873552598133021b872b9c0 Mon Sep 17 00:00:00 2001 From: Joshua Trask Date: Tue, 31 Jan 2023 15:24:49 -0500 Subject: Introduce ImmutableTargetInfo. This is the (belated) next step for go/chooser-targetinfo-cleanup. The new class isn't yet integrated anywhere (but it's covered by thorough unit tests). In the next step(s) of this cleanup, we'll replace the implementations of the per-target-type factory methods (like `SelectableTargetInfo.newSelectableTargetInfo()`) with calls against the new `ImmutableTargetInfo.Builder` API. Existing type-specific tests in TargetInfoTest.kt cover the behavior of those factory methods. Finally, we can merge all the APIs to a single `TargetInfo` class, with the concrete implementation taken from `ImmutableTargetInfo`, and all the (static) factory methods pulled in to preserve the categorization that had previously been established via the subclass design. (In practice some pieces may shift along the way, e.g. API method additions that will have to be propagated up to the base & out to the new immutable type. The general plan won't be affected by these kinds of minor disruptions.) Test: new unit test (component isn't yet integrated anywhere) Bug: 202167050 Change-Id: Iaa8b260efd3d01db5ce58068adcaf43082a64c90 --- .../chooser/ImmutableTargetInfo.java | 596 +++++++++++++++++++++ .../chooser/ImmutableTargetInfoTest.kt | 496 +++++++++++++++++ 2 files changed, 1092 insertions(+) create mode 100644 java/src/com/android/intentresolver/chooser/ImmutableTargetInfo.java create mode 100644 java/tests/src/com/android/intentresolver/chooser/ImmutableTargetInfoTest.kt (limited to 'java/src') diff --git a/java/src/com/android/intentresolver/chooser/ImmutableTargetInfo.java b/java/src/com/android/intentresolver/chooser/ImmutableTargetInfo.java new file mode 100644 index 00000000..315cea4d --- /dev/null +++ b/java/src/com/android/intentresolver/chooser/ImmutableTargetInfo.java @@ -0,0 +1,596 @@ +/* + * 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.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.pm.ResolveInfo; +import android.content.pm.ShortcutInfo; +import android.os.Bundle; +import android.os.UserHandle; +import android.util.HashedStringCache; +import android.util.Log; + +import androidx.annotation.VisibleForTesting; + +import com.android.intentresolver.ResolverActivity; + +import com.google.common.collect.ImmutableList; + +import java.util.ArrayList; +import java.util.List; + +/** + * An implementation of {@link TargetInfo} with immutable data. Any modifications must be made by + * creating a new instance (e.g., via {@link ImmutableTargetInfo#toBuilder()}). + */ +public final class ImmutableTargetInfo implements TargetInfo { + private static final String TAG = "TargetInfo"; + + /** Delegate interface to implement {@link TargetInfo#getHashedTargetIdForMetrics()}. */ + public interface TargetHashProvider { + /** Request a hash for the specified {@code target}. */ + HashedStringCache.HashResult getHashedTargetIdForMetrics( + TargetInfo target, Context context); + } + + /** Delegate interface to request that the target be launched by a particular API. */ + public interface TargetActivityStarter { + /** + * Request that the delegate use the {@link Activity#startActivity()} API to launch the + * specified {@code target}. + * + * @return true if the target was launched successfully. + */ + boolean start(TargetInfo target, Activity activity, Bundle options); + + + /** + * Request that the delegate use the {@link Activity#startAsCaller()} 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 + * specified {@code target}. + * + * @return true if the target was launched successfully. + */ + boolean startAsUser(TargetInfo target, Activity activity, Bundle options, UserHandle user); + } + + enum LegacyTargetType { + NOT_LEGACY_TARGET, + EMPTY_TARGET_INFO, + PLACEHOLDER_TARGET_INFO, + SELECTABLE_TARGET_INFO, + DISPLAY_RESOLVE_INFO, + MULTI_DISPLAY_RESOLVE_INFO + }; + + /** Builder API to construct {@code ImmutableTargetInfo} instances. */ + public static class Builder { + @Nullable + private ComponentName mResolvedComponentName; + + @Nullable + private ComponentName mChooserTargetComponentName; + + @Nullable + private ShortcutInfo mDirectShareShortcutInfo; + + @Nullable + private AppTarget mDirectShareAppTarget; + + @Nullable + private DisplayResolveInfo mDisplayResolveInfo; + + @Nullable + private TargetHashProvider mHashProvider; + + @Nullable + private Intent mReferrerFillInIntent; + + private Intent mResolvedIntent; + private Intent mTargetIntent; + private TargetActivityStarter mActivityStarter; + private ResolveInfo mResolveInfo; + private CharSequence mDisplayLabel; + private CharSequence mExtendedInfo; + private IconHolder mDisplayIconHolder; + private List mSourceIntents; + private List mAllDisplayTargets; + private boolean mIsSuspended; + private boolean mIsPinned; + private float mModifiedScore = -0.1f; + private LegacyTargetType mLegacyType = LegacyTargetType.NOT_LEGACY_TARGET; + + /** + * Configure an {@link Intent} to be built in to the output target as the resolution for the + * requested target data. + */ + public Builder setResolvedIntent(Intent resolvedIntent) { + mResolvedIntent = resolvedIntent; + return this; + } + + /** + * Configure an {@link Intent} to be built in to the output as the "target intent." + */ + public Builder setTargetIntent(Intent targetIntent) { + mTargetIntent = targetIntent; + return this; + } + + /** + * Configure a fill-in intent provided by the referrer to be used in populating the launch + * intent if the output target is ever selected. + * + * @see android.content.Intent#fillIn(Intent, int) + */ + public Builder setReferrerFillInIntent(@Nullable Intent referrerFillInIntent) { + mReferrerFillInIntent = referrerFillInIntent; + return this; + } + + /** + * Configure a {@link ComponentName} to be built in to the output target, as the real + * component we were able to resolve on this device given the available target data. + */ + public Builder setResolvedComponentName(@Nullable ComponentName resolvedComponentName) { + mResolvedComponentName = resolvedComponentName; + return this; + } + + /** + * Configure a {@link ComponentName} to be built in to the output target, as the component + * supposedly associated with a {@link ChooserTarget} from which the builder data is being + * derived. + */ + public Builder setChooserTargetComponentName(@Nullable ComponentName componentName) { + mChooserTargetComponentName = componentName; + return this; + } + + /** Configure the {@link TargetActivityStarter} to be built in to the output target. */ + public Builder setActivityStarter(TargetActivityStarter activityStarter) { + mActivityStarter = activityStarter; + return this; + } + + /** Configure the {@link ResolveInfo} to be built in to the output target. */ + public Builder setResolveInfo(ResolveInfo resolveInfo) { + mResolveInfo = resolveInfo; + return this; + } + + /** Configure the display label to be built in to the output target. */ + public Builder setDisplayLabel(CharSequence displayLabel) { + mDisplayLabel = displayLabel; + return this; + } + + /** Configure the extended info to be built in to the output target. */ + public Builder setExtendedInfo(CharSequence extendedInfo) { + mExtendedInfo = extendedInfo; + return this; + } + + /** Configure the {@link IconHolder} to be built in to the output target. */ + public Builder setDisplayIconHolder(IconHolder displayIconHolder) { + mDisplayIconHolder = displayIconHolder; + return this; + } + + /** Configure the list of source intents to be built in to the output target. */ + public Builder setAllSourceIntents(List sourceIntents) { + mSourceIntents = sourceIntents; + return this; + } + + /** Configure the list of display targets to be built in to the output target. */ + public Builder setAllDisplayTargets(List targets) { + mAllDisplayTargets = targets; + return this; + } + + /** Configure the is-suspended status to be built in to the output target. */ + public Builder setIsSuspended(boolean isSuspended) { + mIsSuspended = isSuspended; + return this; + } + + /** Configure the is-pinned status to be built in to the output target. */ + public Builder setIsPinned(boolean isPinned) { + mIsPinned = isPinned; + return this; + } + + /** Configure the modified score to be built in to the output target. */ + public Builder setModifiedScore(float modifiedScore) { + mModifiedScore = modifiedScore; + return this; + } + + /** Configure the {@link ShortcutInfo} to be built in to the output target. */ + public Builder setDirectShareShortcutInfo(@Nullable ShortcutInfo shortcutInfo) { + mDirectShareShortcutInfo = shortcutInfo; + return this; + } + + /** Configure the {@link AppTarget} to be built in to the output target. */ + public Builder setDirectShareAppTarget(@Nullable AppTarget appTarget) { + mDirectShareAppTarget = appTarget; + return this; + } + + /** Configure the {@link DisplayResolveInfo} to be built in to the output target. */ + public Builder setDisplayResolveInfo(@Nullable DisplayResolveInfo displayResolveInfo) { + mDisplayResolveInfo = displayResolveInfo; + return this; + } + + /** Configure the {@link TargetHashProvider} to be built in to the output target. */ + public Builder setHashProvider(@Nullable TargetHashProvider hashProvider) { + mHashProvider = hashProvider; + return this; + } + + Builder setLegacyType(LegacyTargetType legacyType) { + mLegacyType = legacyType; + return this; + } + + /** + * Construct an {@code ImmutableTargetInfo} with the current builder data, where the + * provided intent is used to fill in missing values from the resolved intent before the + * target is (potentially) ever launched. + * + * @see android.content.Intent#fillIn(Intent, int) + */ + public ImmutableTargetInfo buildWithFillInIntent( + @Nullable Intent fillInIntent, int fillInFlags) { + Intent baseIntentToSend = mResolvedIntent; + if (baseIntentToSend == null) { + Log.w(TAG, "No base intent to send"); + } else { + baseIntentToSend = new Intent(baseIntentToSend); + if (fillInIntent != null) { + baseIntentToSend.fillIn(fillInIntent, fillInFlags); + } + if (mReferrerFillInIntent != null) { + baseIntentToSend.fillIn(mReferrerFillInIntent, 0); + } + } + + return new ImmutableTargetInfo( + baseIntentToSend, + mResolvedIntent, + mTargetIntent, + mReferrerFillInIntent, + mResolvedComponentName, + mChooserTargetComponentName, + mActivityStarter, + mResolveInfo, + mDisplayLabel, + mExtendedInfo, + mDisplayIconHolder, + mSourceIntents, + mAllDisplayTargets, + mIsSuspended, + mIsPinned, + mModifiedScore, + mDirectShareShortcutInfo, + mDirectShareAppTarget, + mDisplayResolveInfo, + mHashProvider, + mLegacyType); + } + + /** Construct an {@code ImmutableTargetInfo} with the current builder data. */ + public ImmutableTargetInfo build() { + return buildWithFillInIntent(null, 0); + } + } + + @Nullable + private final Intent mReferrerFillInIntent; + + @Nullable + private final ComponentName mResolvedComponentName; + + @Nullable + private final ComponentName mChooserTargetComponentName; + + @Nullable + private final ShortcutInfo mDirectShareShortcutInfo; + + @Nullable + private final AppTarget mDirectShareAppTarget; + + @Nullable + private final DisplayResolveInfo mDisplayResolveInfo; + + @Nullable + private final TargetHashProvider mHashProvider; + + private final Intent mBaseIntentToSend; + private final Intent mResolvedIntent; + private final Intent mTargetIntent; + private final TargetActivityStarter mActivityStarter; + private final ResolveInfo mResolveInfo; + private final CharSequence mDisplayLabel; + private final CharSequence mExtendedInfo; + private final IconHolder mDisplayIconHolder; + private final ImmutableList mSourceIntents; + private final ImmutableList mAllDisplayTargets; + private final boolean mIsSuspended; + private final boolean mIsPinned; + private final float mModifiedScore; + private final LegacyTargetType mLegacyType; + + /** Construct a {@link Builder}. */ + public static Builder newBuilder() { + return new Builder(); + } + + /** Construct a {@link Builder} pre-initialized to match this target. */ + public Builder toBuilder() { + return newBuilder() + .setResolvedIntent(getResolvedIntent()) + .setTargetIntent(getTargetIntent()) + .setReferrerFillInIntent(getReferrerFillInIntent()) + .setResolvedComponentName(getResolvedComponentName()) + .setChooserTargetComponentName(getChooserTargetComponentName()) + .setActivityStarter(mActivityStarter) + .setResolveInfo(getResolveInfo()) + .setDisplayLabel(getDisplayLabel()) + .setExtendedInfo(getExtendedInfo()) + .setDisplayIconHolder(getDisplayIconHolder()) + .setAllSourceIntents(getAllSourceIntents()) + .setAllDisplayTargets(getAllDisplayTargets()) + .setIsSuspended(isSuspended()) + .setIsPinned(isPinned()) + .setModifiedScore(getModifiedScore()) + .setDirectShareShortcutInfo(getDirectShareShortcutInfo()) + .setDirectShareAppTarget(getDirectShareAppTarget()) + .setDisplayResolveInfo(getDisplayResolveInfo()) + .setHashProvider(getHashProvider()) + .setLegacyType(mLegacyType); + } + + @VisibleForTesting + Intent getBaseIntentToSend() { + return mBaseIntentToSend; + } + + @Override + public ImmutableTargetInfo cloneFilledIn(Intent fillInIntent, int flags) { + return toBuilder().buildWithFillInIntent(fillInIntent, flags); + } + + @Override + public Intent getResolvedIntent() { + return mResolvedIntent; + } + + @Override + public Intent getTargetIntent() { + return mTargetIntent; + } + + @Nullable + public Intent getReferrerFillInIntent() { + return mReferrerFillInIntent; + } + + @Override + @Nullable + public ComponentName getResolvedComponentName() { + return mResolvedComponentName; + } + + @Override + @Nullable + public ComponentName getChooserTargetComponentName() { + return mChooserTargetComponentName; + } + + @Override + public boolean start(Activity activity, Bundle options) { + return mActivityStarter.start(this, activity, options); + } + + @Override + public boolean startAsCaller(ResolverActivity activity, Bundle options, int userId) { + return mActivityStarter.startAsCaller(this, activity, options, userId); + } + + @Override + public boolean startAsUser(Activity activity, Bundle options, UserHandle user) { + return mActivityStarter.startAsUser(this, activity, options, user); + } + + @Override + public ResolveInfo getResolveInfo() { + return mResolveInfo; + } + + @Override + public CharSequence getDisplayLabel() { + return mDisplayLabel; + } + + @Override + public CharSequence getExtendedInfo() { + return mExtendedInfo; + } + + @Override + public IconHolder getDisplayIconHolder() { + return mDisplayIconHolder; + } + + @Override + public List getAllSourceIntents() { + return mSourceIntents; + } + + @Override + public ArrayList getAllDisplayTargets() { + ArrayList targets = new ArrayList<>(); + targets.addAll(mAllDisplayTargets); + return targets; + } + + @Override + public boolean isSuspended() { + return mIsSuspended; + } + + @Override + public boolean isPinned() { + return mIsPinned; + } + + @Override + public float getModifiedScore() { + return mModifiedScore; + } + + @Override + @Nullable + public ShortcutInfo getDirectShareShortcutInfo() { + return mDirectShareShortcutInfo; + } + + @Override + @Nullable + public AppTarget getDirectShareAppTarget() { + return mDirectShareAppTarget; + } + + @Override + @Nullable + public DisplayResolveInfo getDisplayResolveInfo() { + return mDisplayResolveInfo; + } + + @Override + public HashedStringCache.HashResult getHashedTargetIdForMetrics(Context context) { + return (mHashProvider == null) + ? null : mHashProvider.getHashedTargetIdForMetrics(this, context); + } + + @VisibleForTesting + @Nullable + TargetHashProvider getHashProvider() { + return mHashProvider; + } + + @Override + public boolean isEmptyTargetInfo() { + return mLegacyType == LegacyTargetType.EMPTY_TARGET_INFO; + } + + @Override + public boolean isPlaceHolderTargetInfo() { + return mLegacyType == LegacyTargetType.PLACEHOLDER_TARGET_INFO; + } + + @Override + public boolean isNotSelectableTargetInfo() { + return isEmptyTargetInfo() || isPlaceHolderTargetInfo(); + } + + @Override + public boolean isSelectableTargetInfo() { + return mLegacyType == LegacyTargetType.SELECTABLE_TARGET_INFO; + } + + @Override + public boolean isChooserTargetInfo() { + return isNotSelectableTargetInfo() || isSelectableTargetInfo(); + } + + @Override + public boolean isMultiDisplayResolveInfo() { + return mLegacyType == LegacyTargetType.MULTI_DISPLAY_RESOLVE_INFO; + } + + @Override + public boolean isDisplayResolveInfo() { + return (mLegacyType == LegacyTargetType.DISPLAY_RESOLVE_INFO) + || isMultiDisplayResolveInfo(); + } + + private ImmutableTargetInfo( + Intent baseIntentToSend, + Intent resolvedIntent, + Intent targetIntent, + @Nullable Intent referrerFillInIntent, + @Nullable ComponentName resolvedComponentName, + @Nullable ComponentName chooserTargetComponentName, + TargetActivityStarter activityStarter, + ResolveInfo resolveInfo, + CharSequence displayLabel, + CharSequence extendedInfo, + IconHolder iconHolder, + @Nullable List sourceIntents, + @Nullable List allDisplayTargets, + boolean isSuspended, + boolean isPinned, + float modifiedScore, + @Nullable ShortcutInfo directShareShortcutInfo, + @Nullable AppTarget directShareAppTarget, + @Nullable DisplayResolveInfo displayResolveInfo, + @Nullable TargetHashProvider hashProvider, + LegacyTargetType legacyType) { + mBaseIntentToSend = baseIntentToSend; + mResolvedIntent = resolvedIntent; + mTargetIntent = targetIntent; + mReferrerFillInIntent = referrerFillInIntent; + mResolvedComponentName = resolvedComponentName; + mChooserTargetComponentName = chooserTargetComponentName; + mActivityStarter = activityStarter; + mResolveInfo = resolveInfo; + mDisplayLabel = displayLabel; + mExtendedInfo = extendedInfo; + mDisplayIconHolder = iconHolder; + mSourceIntents = immutableCopyOrEmpty(sourceIntents); + mAllDisplayTargets = immutableCopyOrEmpty(allDisplayTargets); + mIsSuspended = isSuspended; + mIsPinned = isPinned; + mModifiedScore = modifiedScore; + mDirectShareShortcutInfo = directShareShortcutInfo; + mDirectShareAppTarget = directShareAppTarget; + mDisplayResolveInfo = displayResolveInfo; + mHashProvider = hashProvider; + mLegacyType = legacyType; + } + + private static ImmutableList immutableCopyOrEmpty(@Nullable List source) { + return (source == null) ? ImmutableList.of() : ImmutableList.copyOf(source); + } +} diff --git a/java/tests/src/com/android/intentresolver/chooser/ImmutableTargetInfoTest.kt b/java/tests/src/com/android/intentresolver/chooser/ImmutableTargetInfoTest.kt new file mode 100644 index 00000000..4d825f6b --- /dev/null +++ b/java/tests/src/com/android/intentresolver/chooser/ImmutableTargetInfoTest.kt @@ -0,0 +1,496 @@ +/* + * Copyright (C) 2023 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + *3 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.intentresolver.chooser + +import android.app.Activity +import android.app.prediction.AppTarget +import android.app.prediction.AppTargetId +import android.content.ComponentName +import android.content.Intent +import android.content.pm.ResolveInfo +import android.os.Bundle +import android.os.UserHandle +import com.android.intentresolver.createShortcutInfo +import com.android.intentresolver.mock +import com.android.intentresolver.ResolverActivity +import com.android.intentresolver.ResolverDataProvider +import com.google.common.truth.Truth.assertThat +import org.junit.Test + +class ImmutableTargetInfoTest { + private val resolvedIntent = Intent("resolved") + private val targetIntent = Intent("target") + private val referrerFillInIntent = Intent("referrer_fillin") + private val resolvedComponentName = ComponentName("resolved", "component") + private val chooserTargetComponentName = ComponentName("chooser", "target") + private val resolveInfo = ResolverDataProvider.createResolveInfo(1, 0) + private val displayLabel: CharSequence = "Display Label" + private val extendedInfo: CharSequence = "Extended Info" + private val displayIconHolder: TargetInfo.IconHolder = mock() + private val sourceIntent1 = Intent("source1") + private val sourceIntent2 = Intent("source2") + private val displayTarget1 = DisplayResolveInfo.newDisplayResolveInfo( + Intent("display1"), + ResolverDataProvider.createResolveInfo(2, 0), + "display1 label", + "display1 extended info", + Intent("display1_resolved"), + /* resolveInfoPresentationGetter= */ null) + private val displayTarget2 = DisplayResolveInfo.newDisplayResolveInfo( + Intent("display2"), + ResolverDataProvider.createResolveInfo(3, 0), + "display2 label", + "display2 extended info", + Intent("display2_resolved"), + /* resolveInfoPresentationGetter= */ null) + private val directShareShortcutInfo = createShortcutInfo( + "shortcutid", ResolverDataProvider.createComponentName(4), 4) + private val directShareAppTarget = AppTarget( + AppTargetId("apptargetid"), + "test.directshare", + "target", + UserHandle.CURRENT) + private val displayResolveInfo = DisplayResolveInfo.newDisplayResolveInfo( + Intent("displayresolve"), + ResolverDataProvider.createResolveInfo(5, 0), + "displayresolve label", + "displayresolve extended info", + Intent("display_resolved"), + /* resolveInfoPresentationGetter= */ null) + private val hashProvider: ImmutableTargetInfo.TargetHashProvider = mock() + + @Test + fun testBasicProperties() { // Fields that are reflected back w/o logic. + // TODO: we could consider passing copies of all the values into the builder so that we can + // verify that they're not mutated (e.g. no extras added to the intents). For now that + // should be obvious from the implementation. + val info = ImmutableTargetInfo.newBuilder() + .setResolvedIntent(resolvedIntent) + .setTargetIntent(targetIntent) + .setReferrerFillInIntent(referrerFillInIntent) + .setResolvedComponentName(resolvedComponentName) + .setChooserTargetComponentName(chooserTargetComponentName) + .setResolveInfo(resolveInfo) + .setDisplayLabel(displayLabel) + .setExtendedInfo(extendedInfo) + .setDisplayIconHolder(displayIconHolder) + .setAllSourceIntents(listOf(sourceIntent1, sourceIntent2)) + .setAllDisplayTargets(listOf(displayTarget1, displayTarget2)) + .setIsSuspended(true) + .setIsPinned(true) + .setModifiedScore(42.0f) + .setDirectShareShortcutInfo(directShareShortcutInfo) + .setDirectShareAppTarget(directShareAppTarget) + .setDisplayResolveInfo(displayResolveInfo) + .setHashProvider(hashProvider) + .build() + + assertThat(info.resolvedIntent).isEqualTo(resolvedIntent) + assertThat(info.targetIntent).isEqualTo(targetIntent) + assertThat(info.referrerFillInIntent).isEqualTo(referrerFillInIntent) + assertThat(info.resolvedComponentName).isEqualTo(resolvedComponentName) + assertThat(info.chooserTargetComponentName).isEqualTo(chooserTargetComponentName) + assertThat(info.resolveInfo).isEqualTo(resolveInfo) + assertThat(info.displayLabel).isEqualTo(displayLabel) + assertThat(info.extendedInfo).isEqualTo(extendedInfo) + assertThat(info.displayIconHolder).isEqualTo(displayIconHolder) + assertThat(info.allSourceIntents).containsExactly(sourceIntent1, sourceIntent2) + assertThat(info.allDisplayTargets).containsExactly(displayTarget1, displayTarget2) + assertThat(info.isSuspended).isTrue() + assertThat(info.isPinned).isTrue() + assertThat(info.modifiedScore).isEqualTo(42.0f) + assertThat(info.directShareShortcutInfo).isEqualTo(directShareShortcutInfo) + assertThat(info.directShareAppTarget).isEqualTo(directShareAppTarget) + assertThat(info.displayResolveInfo).isEqualTo(displayResolveInfo) + assertThat(info.isEmptyTargetInfo).isFalse() + assertThat(info.isPlaceHolderTargetInfo).isFalse() + assertThat(info.isNotSelectableTargetInfo).isFalse() + assertThat(info.isSelectableTargetInfo).isFalse() + assertThat(info.isChooserTargetInfo).isFalse() + assertThat(info.isMultiDisplayResolveInfo).isFalse() + assertThat(info.isDisplayResolveInfo).isFalse() + assertThat(info.hashProvider).isEqualTo(hashProvider) + } + + @Test + fun testToBuilderPreservesBasicProperties() { + // Note this is set up exactly as in `testBasicProperties`, but the assertions will be made + // against a *copy* of the object instead. + val infoToCopyFrom = ImmutableTargetInfo.newBuilder() + .setResolvedIntent(resolvedIntent) + .setTargetIntent(targetIntent) + .setReferrerFillInIntent(referrerFillInIntent) + .setResolvedComponentName(resolvedComponentName) + .setChooserTargetComponentName(chooserTargetComponentName) + .setResolveInfo(resolveInfo) + .setDisplayLabel(displayLabel) + .setExtendedInfo(extendedInfo) + .setDisplayIconHolder(displayIconHolder) + .setAllSourceIntents(listOf(sourceIntent1, sourceIntent2)) + .setAllDisplayTargets(listOf(displayTarget1, displayTarget2)) + .setIsSuspended(true) + .setIsPinned(true) + .setModifiedScore(42.0f) + .setDirectShareShortcutInfo(directShareShortcutInfo) + .setDirectShareAppTarget(directShareAppTarget) + .setDisplayResolveInfo(displayResolveInfo) + .setHashProvider(hashProvider) + .build() + + val info = infoToCopyFrom.toBuilder().build() + + assertThat(info.resolvedIntent).isEqualTo(resolvedIntent) + assertThat(info.targetIntent).isEqualTo(targetIntent) + assertThat(info.referrerFillInIntent).isEqualTo(referrerFillInIntent) + assertThat(info.resolvedComponentName).isEqualTo(resolvedComponentName) + assertThat(info.chooserTargetComponentName).isEqualTo(chooserTargetComponentName) + assertThat(info.resolveInfo).isEqualTo(resolveInfo) + assertThat(info.displayLabel).isEqualTo(displayLabel) + assertThat(info.extendedInfo).isEqualTo(extendedInfo) + assertThat(info.displayIconHolder).isEqualTo(displayIconHolder) + assertThat(info.allSourceIntents).containsExactly(sourceIntent1, sourceIntent2) + assertThat(info.allDisplayTargets).containsExactly(displayTarget1, displayTarget2) + assertThat(info.isSuspended).isTrue() + assertThat(info.isPinned).isTrue() + assertThat(info.modifiedScore).isEqualTo(42.0f) + assertThat(info.directShareShortcutInfo).isEqualTo(directShareShortcutInfo) + assertThat(info.directShareAppTarget).isEqualTo(directShareAppTarget) + assertThat(info.displayResolveInfo).isEqualTo(displayResolveInfo) + assertThat(info.isEmptyTargetInfo).isFalse() + assertThat(info.isPlaceHolderTargetInfo).isFalse() + assertThat(info.isNotSelectableTargetInfo).isFalse() + assertThat(info.isSelectableTargetInfo).isFalse() + assertThat(info.isChooserTargetInfo).isFalse() + assertThat(info.isMultiDisplayResolveInfo).isFalse() + assertThat(info.isDisplayResolveInfo).isFalse() + assertThat(info.hashProvider).isEqualTo(hashProvider) + } + + @Test + fun testBaseIntentToSend_defaultsToResolvedIntent() { + val info = ImmutableTargetInfo.newBuilder().setResolvedIntent(resolvedIntent).build() + assertThat(info.baseIntentToSend.filterEquals(resolvedIntent)).isTrue() + } + + @Test + fun testBaseIntentToSend_fillsInFromReferrerIntent() { + val originalIntent = Intent() + originalIntent.setPackage("original") + + val referrerFillInIntent = Intent("REFERRER_FILL_IN") + referrerFillInIntent.setPackage("referrer") + + val info = ImmutableTargetInfo.newBuilder() + .setResolvedIntent(originalIntent) + .setReferrerFillInIntent(referrerFillInIntent) + .build() + + assertThat(info.baseIntentToSend.getPackage()).isEqualTo("original") // Only fill if empty. + assertThat(info.baseIntentToSend.action).isEqualTo("REFERRER_FILL_IN") + } + + @Test + fun testBaseIntentToSend_fillsInFromCloneRequestIntent() { + val originalIntent = Intent() + originalIntent.setPackage("original") + + val cloneFillInIntent = Intent("CLONE_FILL_IN") + cloneFillInIntent.setPackage("clone") + + val originalInfo = ImmutableTargetInfo.newBuilder() + .setResolvedIntent(originalIntent) + .build() + val info = originalInfo.cloneFilledIn(cloneFillInIntent, 0) + + assertThat(info.baseIntentToSend.getPackage()).isEqualTo("original") // Only fill if empty. + assertThat(info.baseIntentToSend.action).isEqualTo("CLONE_FILL_IN") + } + + @Test + fun testBaseIntentToSend_twoFillInSourcesFavorsCloneRequest() { + val originalIntent = Intent() + originalIntent.setPackage("original") + + val referrerFillInIntent = Intent("REFERRER_FILL_IN") + referrerFillInIntent.setPackage("referrer_pkg") + referrerFillInIntent.setType("test/referrer") + + val infoWithReferrerFillIn = ImmutableTargetInfo.newBuilder() + .setResolvedIntent(originalIntent) + .setReferrerFillInIntent(referrerFillInIntent) + .build() + + val cloneFillInIntent = Intent("CLONE_FILL_IN") + cloneFillInIntent.setPackage("clone") + + val info = infoWithReferrerFillIn.cloneFilledIn(cloneFillInIntent, 0) + + assertThat(info.baseIntentToSend.getPackage()).isEqualTo("original") // Set all along. + assertThat(info.baseIntentToSend.action).isEqualTo("CLONE_FILL_IN") // Clone wins. + assertThat(info.baseIntentToSend.type).isEqualTo("test/referrer") // Left for referrer. + } + + @Test + fun testBaseIntentToSend_doubleCloningPreservesReferrerFillInButNotOriginalCloneFillIn() { + val originalIntent = Intent() + val referrerFillInIntent = Intent("REFERRER_FILL_IN") + val cloneFillInIntent1 = Intent() + cloneFillInIntent1.setPackage("clone1") + val cloneFillInIntent2 = Intent() + cloneFillInIntent2.setType("test/clone2") + + val originalInfo = ImmutableTargetInfo.newBuilder() + .setResolvedIntent(originalIntent) + .setReferrerFillInIntent(referrerFillInIntent) + .build() + + val clone1 = originalInfo.cloneFilledIn(cloneFillInIntent1, 0) + val clone2 = clone1.cloneFilledIn(cloneFillInIntent2, 0) // Clone-of-clone. + + // Both clones get the same values filled in from the referrer intent. + assertThat(clone1.baseIntentToSend.action).isEqualTo("REFERRER_FILL_IN") + assertThat(clone2.baseIntentToSend.action).isEqualTo("REFERRER_FILL_IN") + // Each clone has the respective value that was set in the fill-in request. + assertThat(clone1.baseIntentToSend.getPackage()).isEqualTo("clone1") + assertThat(clone2.baseIntentToSend.type).isEqualTo("test/clone2") + // The clones don't have the data from each other's fill-in requests, even though the intent + // field is empty (thus able to be populated by filling-in). + assertThat(clone1.baseIntentToSend.type).isNull() + assertThat(clone2.baseIntentToSend.getPackage()).isNull() + } + + @Test + fun testLegacySubclassRelationships_empty() { + val info = ImmutableTargetInfo.newBuilder() + .setLegacyType(ImmutableTargetInfo.LegacyTargetType.EMPTY_TARGET_INFO) + .build() + + assertThat(info.isEmptyTargetInfo).isTrue() + assertThat(info.isPlaceHolderTargetInfo).isFalse() + assertThat(info.isNotSelectableTargetInfo).isTrue() + assertThat(info.isSelectableTargetInfo).isFalse() + assertThat(info.isChooserTargetInfo).isTrue() + assertThat(info.isMultiDisplayResolveInfo).isFalse() + assertThat(info.isDisplayResolveInfo).isFalse() + } + + @Test + fun testLegacySubclassRelationships_placeholder() { + val info = ImmutableTargetInfo.newBuilder() + .setLegacyType(ImmutableTargetInfo.LegacyTargetType.PLACEHOLDER_TARGET_INFO) + .build() + + assertThat(info.isEmptyTargetInfo).isFalse() + assertThat(info.isPlaceHolderTargetInfo).isTrue() + assertThat(info.isNotSelectableTargetInfo).isTrue() + assertThat(info.isSelectableTargetInfo).isFalse() + assertThat(info.isChooserTargetInfo).isTrue() + assertThat(info.isMultiDisplayResolveInfo).isFalse() + assertThat(info.isDisplayResolveInfo).isFalse() + } + + @Test + fun testLegacySubclassRelationships_selectable() { + val info = ImmutableTargetInfo.newBuilder() + .setLegacyType(ImmutableTargetInfo.LegacyTargetType.SELECTABLE_TARGET_INFO) + .build() + + assertThat(info.isEmptyTargetInfo).isFalse() + assertThat(info.isPlaceHolderTargetInfo).isFalse() + assertThat(info.isNotSelectableTargetInfo).isFalse() + assertThat(info.isSelectableTargetInfo).isTrue() + assertThat(info.isChooserTargetInfo).isTrue() + assertThat(info.isMultiDisplayResolveInfo).isFalse() + assertThat(info.isDisplayResolveInfo).isFalse() + } + + @Test + fun testLegacySubclassRelationships_displayResolveInfo() { + val info = ImmutableTargetInfo.newBuilder() + .setLegacyType(ImmutableTargetInfo.LegacyTargetType.DISPLAY_RESOLVE_INFO) + .build() + + assertThat(info.isEmptyTargetInfo).isFalse() + assertThat(info.isPlaceHolderTargetInfo).isFalse() + assertThat(info.isNotSelectableTargetInfo).isFalse() + assertThat(info.isSelectableTargetInfo).isFalse() + assertThat(info.isChooserTargetInfo).isFalse() + assertThat(info.isMultiDisplayResolveInfo).isFalse() + assertThat(info.isDisplayResolveInfo).isTrue() + } + + @Test + fun testLegacySubclassRelationships_multiDisplayResolveInfo() { + val info = ImmutableTargetInfo.newBuilder() + .setLegacyType(ImmutableTargetInfo.LegacyTargetType.MULTI_DISPLAY_RESOLVE_INFO) + .build() + + assertThat(info.isEmptyTargetInfo).isFalse() + assertThat(info.isPlaceHolderTargetInfo).isFalse() + assertThat(info.isNotSelectableTargetInfo).isFalse() + assertThat(info.isSelectableTargetInfo).isFalse() + assertThat(info.isChooserTargetInfo).isFalse() + assertThat(info.isMultiDisplayResolveInfo).isTrue() + assertThat(info.isDisplayResolveInfo).isTrue() + } + + @Test + fun testActivityStarter_correctNumberOfInvocations_start() { + val activityStarter = object : TestActivityStarter() { + override fun startAsCaller( + target: TargetInfo, activity: Activity, options: Bundle, userId: Int): Boolean { + throw RuntimeException("Wrong API used: startAsCaller") + } + + override fun startAsUser( + target: TargetInfo, activity: Activity, options: Bundle, user: UserHandle + ): Boolean { + throw RuntimeException("Wrong API used: startAsUser") + } + } + + val info = ImmutableTargetInfo.newBuilder().setActivityStarter(activityStarter).build() + val activity: Activity = mock() + val options = Bundle() + options.putInt("TEST_KEY", 1) + + info.start(activity, options) + + assertThat(activityStarter.totalInvocations).isEqualTo(1) + assertThat(activityStarter.lastInvocationTargetInfo).isEqualTo(info) + assertThat(activityStarter.lastInvocationActivity).isEqualTo(activity) + assertThat(activityStarter.lastInvocationOptions).isEqualTo(options) + assertThat(activityStarter.lastInvocationUserId).isNull() + assertThat(activityStarter.lastInvocationAsCaller).isFalse() + } + + @Test + fun testActivityStarter_correctNumberOfInvocations_startAsCaller() { + val activityStarter = object : TestActivityStarter() { + override fun start(target: TargetInfo, activity: Activity, options: Bundle): Boolean { + throw RuntimeException("Wrong API used: start") + } + + override fun startAsUser( + target: TargetInfo, activity: Activity, options: Bundle, user: UserHandle + ): Boolean { + throw RuntimeException("Wrong API used: startAsUser") + } + } + + val info = ImmutableTargetInfo.newBuilder().setActivityStarter(activityStarter).build() + val activity: ResolverActivity = mock() + val options = Bundle() + options.putInt("TEST_KEY", 1) + + info.startAsCaller(activity, options, 42) + + assertThat(activityStarter.totalInvocations).isEqualTo(1) + assertThat(activityStarter.lastInvocationTargetInfo).isEqualTo(info) + assertThat(activityStarter.lastInvocationActivity).isEqualTo(activity) + assertThat(activityStarter.lastInvocationOptions).isEqualTo(options) + assertThat(activityStarter.lastInvocationUserId).isEqualTo(42) + assertThat(activityStarter.lastInvocationAsCaller).isTrue() + } + + @Test + fun testActivityStarter_correctNumberOfInvocations_startAsUser() { + val activityStarter = object : TestActivityStarter() { + override fun start(target: TargetInfo, activity: Activity, options: Bundle): Boolean { + throw RuntimeException("Wrong API used: start") + } + + override fun startAsCaller( + target: TargetInfo, activity: Activity, options: Bundle, userId: Int): Boolean { + throw RuntimeException("Wrong API used: startAsCaller") + } + } + + val info = ImmutableTargetInfo.newBuilder().setActivityStarter(activityStarter).build() + val activity: Activity = mock() + val options = Bundle() + options.putInt("TEST_KEY", 1) + + info.startAsUser(activity, options, UserHandle.of(42)) + + assertThat(activityStarter.totalInvocations).isEqualTo(1) + assertThat(activityStarter.lastInvocationTargetInfo).isEqualTo(info) + assertThat(activityStarter.lastInvocationActivity).isEqualTo(activity) + assertThat(activityStarter.lastInvocationOptions).isEqualTo(options) + assertThat(activityStarter.lastInvocationUserId).isEqualTo(42) + assertThat(activityStarter.lastInvocationAsCaller).isFalse() + } + + @Test + fun testActivityStarter_invokedWithRespectiveTargetInfoAfterCopy() { + val activityStarter = TestActivityStarter() + val info1 = ImmutableTargetInfo.newBuilder().setActivityStarter(activityStarter).build() + val info2 = info1.toBuilder().build() + + info1.start(mock(), Bundle()) + assertThat(activityStarter.lastInvocationTargetInfo).isEqualTo(info1) + info2.start(mock(), Bundle()) + assertThat(activityStarter.lastInvocationTargetInfo).isEqualTo(info2) + info2.startAsCaller(mock(), Bundle(), 42) + assertThat(activityStarter.lastInvocationTargetInfo).isEqualTo(info2) + info2.startAsUser(mock(), Bundle(), UserHandle.of(42)) + assertThat(activityStarter.lastInvocationTargetInfo).isEqualTo(info2) + + assertThat(activityStarter.totalInvocations).isEqualTo(4) // Instance is still shared. + } +} + +private open class TestActivityStarter : ImmutableTargetInfo.TargetActivityStarter { + var totalInvocations = 0 + var lastInvocationTargetInfo: TargetInfo? = null + var lastInvocationActivity: Activity? = null + var lastInvocationOptions: Bundle? = null + var lastInvocationUserId: Integer? = null + var lastInvocationAsCaller = false + + override fun start(target: TargetInfo, activity: Activity, options: Bundle): Boolean { + ++totalInvocations + lastInvocationTargetInfo = target + lastInvocationActivity = activity + lastInvocationOptions = options + lastInvocationUserId = null + lastInvocationAsCaller = false + return true + } + + override fun startAsCaller( + target: TargetInfo, activity: Activity, options: Bundle, userId: Int): Boolean { + ++totalInvocations + lastInvocationTargetInfo = target + lastInvocationActivity = activity + lastInvocationOptions = options + lastInvocationUserId = Integer(userId) + lastInvocationAsCaller = true + return true + } + + override fun startAsUser( + target: TargetInfo, activity: Activity, options: Bundle, user: UserHandle): Boolean { + ++totalInvocations + lastInvocationTargetInfo = target + lastInvocationActivity = activity + lastInvocationOptions = options + lastInvocationUserId = Integer(user.identifier) + lastInvocationAsCaller = false + return true + } +} -- cgit v1.2.3-59-g8ed1b From f23a43b87875dc5e484a3935d06337447e76d97f Mon Sep 17 00:00:00 2001 From: Andrey Epin Date: Tue, 7 Feb 2023 08:45:34 -0800 Subject: DO NOT MERGE - scrollable image preview to teamfood Bug: 262280076 Test: presubmits Change-Id: I8d7cfced5ff01f25f9f6a899889956c94a3eb0d0 --- java/src/com/android/intentresolver/flags/Flags.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) (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 9c206265..59b5ea74 100644 --- a/java/src/com/android/intentresolver/flags/Flags.kt +++ b/java/src/com/android/intentresolver/flags/Flags.kt @@ -38,7 +38,7 @@ object Flags { // TODO(b/267355521) Tracking Bug @JvmField val SHARESHEET_SCROLLABLE_IMAGE_PREVIEW = unreleasedFlag( - 1504, "sharesheet_scrollable_image_preview" + 1504, "sharesheet_scrollable_image_preview", teamfood = true ) private fun unreleasedFlag(id: Int, name: String, teamfood: Boolean = false) = -- cgit v1.2.3-59-g8ed1b From ad6c99c6d25b7710bcf650d1a0bb1f7c8996f9ad Mon Sep 17 00:00:00 2001 From: Joshua Trask Date: Mon, 30 Jan 2023 09:35:49 -0500 Subject: Introduce `AnnotatedUserHandles` class. This component is a container for precomputed `UserHandle` values that should be consistent wherever they're referenced throughout a chooser/resolver session. This includes some low-hanging integrations in `ChooserActivity` and `ResolverActivity` that seemed unobjectionable and suitable for "pure" refactoring -- i.e. the same handles are ultimately evaluated from the same expressions, and I don't immediately plan to change the legacy logic. Once this is checked in, we can proceed to looking at some of the more complex/refactorable applications of `UserHandle` and eventually integrate this component more thoroughly. First follow-up priority is test coverage; existing coverage validates our typical behavior as observed in the activities, but it would be great if we could validate our understanding with thorough unit tests directly against the `AnnotatedUserHandles` API. Test: `atest IntentResolverUnitTests` Change-Id: I36116d8c7156b7d30e777dd3c609c7e883ffc042 --- .../intentresolver/AnnotatedUserHandles.java | 113 +++++++++++++++++++++ .../android/intentresolver/ChooserActivity.java | 2 +- .../android/intentresolver/ResolverActivity.java | 51 ++++------ 3 files changed, 136 insertions(+), 30 deletions(-) create mode 100644 java/src/com/android/intentresolver/AnnotatedUserHandles.java (limited to 'java/src') diff --git a/java/src/com/android/intentresolver/AnnotatedUserHandles.java b/java/src/com/android/intentresolver/AnnotatedUserHandles.java new file mode 100644 index 00000000..b4365b84 --- /dev/null +++ b/java/src/com/android/intentresolver/AnnotatedUserHandles.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; + +import android.annotation.Nullable; +import android.app.Activity; +import android.app.ActivityManager; +import android.os.UserHandle; +import android.os.UserManager; + +/** + * Helper class to precompute the (immutable) designations of various user handles in the system + * that may contribute to the current Sharesheet session. + */ +public final class AnnotatedUserHandles { + /** The user id of the app that started the share activity. */ + public final int userIdOfCallingApp; + + /** + * The {@link UserHandle} that launched Sharesheet. + * TODO: I believe this would always be the handle corresponding to {@code userIdOfCallingApp} + * except possibly if the caller used {@link Activity#startActivityAsUser()} to launch + * Sharesheet as a different user than they themselves were running as. Verify and document. + */ + public final UserHandle userHandleSharesheetLaunchedAs; + + /** + * The {@link UserHandle} that owns the "personal tab" in a tabbed share UI (or the *only* 'tab' + * in a non-tabbed UI). + * + * This is never a work or clone user, but may either be the root user (0) or a "secondary" + * multi-user profile (i.e., one that's not root, work, nor clone). This is a "secondary" + * profile only when that user is the active "foreground" user. + * + * In the current implementation, we can assert that this is the root user (0) any time we + * display a tabbed UI (i.e., any time `workProfileUserHandle` is non-null), or any time that we + * have a clone profile. This note is only provided for informational purposes; clients should + * avoid making any reliances on that assumption. + */ + public final UserHandle personalProfileUserHandle; + + /** + * The {@link UserHandle} that owns the "work tab" in a tabbed share UI. This is (an arbitrary) + * one of the "managed" profiles associated with {@link personalProfileUserHandle}. + */ + @Nullable + public final UserHandle workProfileUserHandle; + + /** + * The {@link UserHandle} of the clone profile belonging to {@link personalProfileUserHandle}. + */ + @Nullable + public final UserHandle cloneProfileUserHandle; + + /** + * The "tab owner" user handle (i.e., either {@link personalProfileUserHandle} or + * {@link workProfileUserHandle}) that either matches or owns the profile of the + * {@link userHandleSharesheetLaunchedAs}. + * + * In the current implementation, we can assert that this is the same as + * `userHandleSharesheetLaunchedAs` except when the latter is the clone profile; then this is + * the "personal" profile owning that clone profile (which we currently know must belong to + * user 0, but clients should avoid making any reliances on that assumption). + */ + public final UserHandle tabOwnerUserHandleForLaunch; + + public AnnotatedUserHandles(Activity forShareActivity) { + userIdOfCallingApp = forShareActivity.getLaunchedFromUid(); + if ((userIdOfCallingApp < 0) || UserHandle.isIsolated(userIdOfCallingApp)) { + throw new SecurityException("Can't start a resolver from uid " + userIdOfCallingApp); + } + + // TODO: integrate logic for `ResolverActivity.EXTRA_CALLING_USER`. + userHandleSharesheetLaunchedAs = UserHandle.of(UserHandle.myUserId()); + + personalProfileUserHandle = UserHandle.of(ActivityManager.getCurrentUser()); + + UserManager userManager = forShareActivity.getSystemService(UserManager.class); + workProfileUserHandle = getWorkProfileForUser(userManager, personalProfileUserHandle); + cloneProfileUserHandle = getCloneProfileForUser(userManager, personalProfileUserHandle); + + tabOwnerUserHandleForLaunch = (userHandleSharesheetLaunchedAs == workProfileUserHandle) + ? workProfileUserHandle : personalProfileUserHandle; + } + + @Nullable + private static UserHandle getWorkProfileForUser( + UserManager userManager, UserHandle profileOwnerUserHandle) { + return userManager.getProfiles(profileOwnerUserHandle.getIdentifier()).stream() + .filter(info -> info.isManagedProfile()).findFirst() + .map(info -> info.getUserHandle()).orElse(null); + } + + @Nullable + private static UserHandle getCloneProfileForUser( + UserManager userManager, UserHandle profileOwnerUserHandle) { + return null; // Not yet supported in framework. + } +} diff --git a/java/src/com/android/intentresolver/ChooserActivity.java b/java/src/com/android/intentresolver/ChooserActivity.java index 3a7d4e68..a355bef8 100644 --- a/java/src/com/android/intentresolver/ChooserActivity.java +++ b/java/src/com/android/intentresolver/ChooserActivity.java @@ -1694,7 +1694,7 @@ public class ChooserActivity extends ResolverActivity implements mPm, getTargetIntent(), getReferrerPackageName(), - mLaunchedFromUid, + getAnnotatedUserHandles().userIdOfCallingApp, userHandle, resolverComparator); } diff --git a/java/src/com/android/intentresolver/ResolverActivity.java b/java/src/com/android/intentresolver/ResolverActivity.java index 5f8f3da8..d431d57b 100644 --- a/java/src/com/android/intentresolver/ResolverActivity.java +++ b/java/src/com/android/intentresolver/ResolverActivity.java @@ -163,7 +163,6 @@ public class ResolverActivity extends FragmentActivity implements protected boolean mSupportsAlwaysUseOption; protected ResolverDrawerLayout mResolverDrawerLayout; protected PackageManager mPm; - protected int mLaunchedFromUid; private static final String TAG = "ResolverActivity"; private static final boolean DEBUG = false; @@ -223,9 +222,15 @@ public class ResolverActivity extends FragmentActivity implements private BroadcastReceiver mWorkProfileStateReceiver; private UserHandle mHeaderCreatorUser; - private Supplier mLazyWorkProfileUserHandle = () -> { - final UserHandle result = fetchWorkProfileUserProfile(); - mLazyWorkProfileUserHandle = () -> result; + // 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 = new AnnotatedUserHandles(this); + mLazyAnnotatedUserHandles = () -> result; return result; }; @@ -395,12 +400,9 @@ public class ResolverActivity extends FragmentActivity implements // from managed profile to owner or other way around. setProfileSwitchMessage(intent.getContentUserHint()); - mLaunchedFromUid = getLaunchedFromUid(); - if (mLaunchedFromUid < 0 || UserHandle.isIsolated(mLaunchedFromUid)) { - // Gulp! - finish(); - return; - } + // 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(); mPm = getPackageManager(); @@ -699,28 +701,18 @@ public class ResolverActivity extends FragmentActivity implements return (UserHandle.myUserId() == UserHandle.USER_SYSTEM ? PROFILE_PERSONAL : PROFILE_WORK); } - protected UserHandle getPersonalProfileUserHandle() { - return UserHandle.of(ActivityManager.getCurrentUser()); + protected final AnnotatedUserHandles getAnnotatedUserHandles() { + return mLazyAnnotatedUserHandles.get(); } - @Nullable - protected UserHandle getWorkProfileUserHandle() { - return mLazyWorkProfileUserHandle.get(); + protected final UserHandle getPersonalProfileUserHandle() { + return getAnnotatedUserHandles().personalProfileUserHandle; } + // TODO: have tests override `getAnnotatedUserHandles()`, and make this method `final`. @Nullable - private UserHandle fetchWorkProfileUserProfile() { - UserManager userManager = getSystemService(UserManager.class); - if (userManager == null) { - return null; - } - UserHandle result = null; - for (final UserInfo userInfo : userManager.getProfiles(ActivityManager.getCurrentUser())) { - if (userInfo.isManagedProfile()) { - result = userInfo.getUserHandle(); - } - } - return result; + protected UserHandle getWorkProfileUserHandle() { + return getAnnotatedUserHandles().workProfileUserHandle; } private boolean hasWorkProfile() { @@ -1494,7 +1486,8 @@ public class ResolverActivity extends FragmentActivity implements maybeLogCrossProfileTargetLaunch(cti, user); } } catch (RuntimeException e) { - Slog.wtf(TAG, "Unable to launch as uid " + mLaunchedFromUid + Slog.wtf(TAG, + "Unable to launch as uid " + getAnnotatedUserHandles().userIdOfCallingApp + " package " + getLaunchedFromPackage() + ", while running in " + ActivityThread.currentProcessName(), e); } @@ -1560,7 +1553,7 @@ public class ResolverActivity extends FragmentActivity implements mPm, getTargetIntent(), getReferrerPackageName(), - mLaunchedFromUid, + getAnnotatedUserHandles().userIdOfCallingApp, userHandle); } -- cgit v1.2.3-59-g8ed1b From 7a2f3afc9111ba0c3a7176e9bdb2a55ddb9d3772 Mon Sep 17 00:00:00 2001 From: Andrey Epin Date: Tue, 7 Feb 2023 13:00:50 -0800 Subject: Fix Chooser teamfood flag logic Explisitly check for flag presense. Test: manual testing Change-Id: I16aa1dc536548754d7be8ce0c6dba9f5737963c0 --- java/src/com/android/intentresolver/flags/DeviceConfigProxy.kt | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) (limited to 'java/src') diff --git a/java/src/com/android/intentresolver/flags/DeviceConfigProxy.kt b/java/src/com/android/intentresolver/flags/DeviceConfigProxy.kt index ac782471..d1494fe7 100644 --- a/java/src/com/android/intentresolver/flags/DeviceConfigProxy.kt +++ b/java/src/com/android/intentresolver/flags/DeviceConfigProxy.kt @@ -22,7 +22,12 @@ import com.android.systemui.flags.ParcelableFlag internal class DeviceConfigProxy { fun isEnabled(flag: ParcelableFlag): Boolean? { return runCatching { - DeviceConfig.getBoolean(flag.namespace, flag.name, flag.default) + val hasProperty = DeviceConfig.getProperty(flag.namespace, flag.name) != null + if (hasProperty) { + DeviceConfig.getBoolean(flag.namespace, flag.name, flag.default) + } else { + null + } }.getOrDefault(null) } } -- cgit v1.2.3-59-g8ed1b From 6d9518a2706d65e7e749e95b290d4bfc41312a55 Mon Sep 17 00:00:00 2001 From: Andrey Epin Date: Tue, 7 Feb 2023 17:05:45 -0800 Subject: Fix image + text layout; add transition animation Fix image preview elements paddings, an artifact of a logical merge conflict. Add transition animation. Bug: 262277421 Test: manual testing Change-Id: I1c87369a4aecb9ba576f37553da80eb53d3b7a1a --- java/res/layout/chooser_grid_preview_image.xml | 5 +---- java/res/layout/chooser_image_preview_view.xml | 2 ++ java/res/layout/scrollable_image_preview_view.xml | 2 ++ java/src/com/android/intentresolver/ChooserContentPreviewUi.java | 2 ++ 4 files changed, 7 insertions(+), 4 deletions(-) (limited to 'java/src') diff --git a/java/res/layout/chooser_grid_preview_image.xml b/java/res/layout/chooser_grid_preview_image.xml index 80c12e6c..614d9b5e 100644 --- a/java/res/layout/chooser_grid_preview_image.xml +++ b/java/res/layout/chooser_grid_preview_image.xml @@ -43,10 +43,7 @@ android:id="@+id/image_preview_stub" android:inflatedId="@androidprv:id/content_preview_image_area" android:layout_width="wrap_content" - android:layout_height="wrap_content" - android:paddingStart="@dimen/chooser_edge_margin_normal" - android:paddingEnd="@dimen/chooser_edge_margin_normal" - android:background="?android:attr/colorBackground" /> + android:layout_height="wrap_content" /> diff --git a/java/res/layout/scrollable_image_preview_view.xml b/java/res/layout/scrollable_image_preview_view.xml index ff2f273f..c6c310e6 100644 --- a/java/res/layout/scrollable_image_preview_view.xml +++ b/java/res/layout/scrollable_image_preview_view.xml @@ -20,5 +20,7 @@ android:layout_width="wrap_content" android:layout_height="wrap_content" android:layout_gravity="center_horizontal" + android:paddingStart="@dimen/chooser_edge_margin_normal" + android:paddingEnd="@dimen/chooser_edge_margin_normal" android:paddingBottom="@dimen/chooser_view_spacing" android:background="?android:attr/colorBackground" /> diff --git a/java/src/com/android/intentresolver/ChooserContentPreviewUi.java b/java/src/com/android/intentresolver/ChooserContentPreviewUi.java index 91abd9d0..1acb4d57 100644 --- a/java/src/com/android/intentresolver/ChooserContentPreviewUi.java +++ b/java/src/com/android/intentresolver/ChooserContentPreviewUi.java @@ -33,6 +33,7 @@ import android.provider.Downloads; import android.provider.OpenableColumns; import android.text.TextUtils; import android.text.util.Linkify; +import android.transition.TransitionManager; import android.util.Log; import android.util.PluralsMessageFormatter; import android.view.LayoutInflater; @@ -440,6 +441,7 @@ public final class ChooserContentPreviewUi { shareTextAction.accept(false); actionView.setOnCheckedChangeListener((view, isChecked) -> { view.setText(actionLabels[isChecked ? 1 : 0]); + TransitionManager.beginDelayedTransition((ViewGroup) textView.getParent()); textView.setVisibility(isChecked ? View.VISIBLE : View.GONE); shareTextAction.accept(!isChecked); }); -- cgit v1.2.3-59-g8ed1b From 691cfdb494fd6d4e194b1e9dc07470ab64564bfe Mon Sep 17 00:00:00 2001 From: Joshua Trask Date: Wed, 8 Feb 2023 22:25:29 +0000 Subject: Convert NotSelectableTargetInfo subtree to factories Clients of the `NotSelectableTargetInfo` factory methods won't notice any API change, but those methods will now return instances of `ImmutableTargetInfo`, built to match the same behavior of the legacy subtype (note no clients have any remaining `instanceof` checks in this part of the inheritance graph). The class now just hosts the static factory methods and some private static helpers, and no longer inherits from `ChooserTargetInfo` (or any other base). The two concrete `NotSelectableTargetInfo` subclasses are removed altogether. This change is a near-pure refactoring; the only other theoretical difference is that these `NotSelectableTargetInfo` targets will now get a plausible implementation of `cloneFilledIn()` instead of returning null, but in practice that method will never actually be invoked on these particular `TargetInfo` types. We're also *slightly* more eager in building `IconHolder` instances, but in practice we would always do that at nearly the same time anyways (and there's no particular concern about the overhead). There are also two changes to tests: 1. The device config permission change seemed to be necessary in order to run `TargetInfoTest` in isolation, even though those same tests could run as part of `IntentResolverUnitTests` already. I'm not sure why the discrepancy, but the fix seems reasonable. 2. I needed to do a better job of setting up the placeholder targets' icon for tests since the new `ImmutableTargetInfo.Builder` API doesn't offer a mechanism to override `hasDisplayIcon()` directly. With the better support in place, I made some extra assertions. This is the first (low-hanging-fruit) conversion of this type, and in future CLs we should proceed through the rest of the hierarchy before eventually flattening all the APIs into a single class. Test: `atest TargetInfoTest` Bug: 202167050 Change-Id: Ibabfee6ef2349de0db0be97f1d2f894d0672cbfd --- .../chooser/NotSelectableTargetInfo.java | 147 +++++++-------------- .../intentresolver/chooser/TargetInfoTest.kt | 24 +++- 2 files changed, 66 insertions(+), 105 deletions(-) (limited to 'java/src') diff --git a/java/src/com/android/intentresolver/chooser/NotSelectableTargetInfo.java b/java/src/com/android/intentresolver/chooser/NotSelectableTargetInfo.java index 9a2c971f..c63ebc8c 100644 --- a/java/src/com/android/intentresolver/chooser/NotSelectableTargetInfo.java +++ b/java/src/com/android/intentresolver/chooser/NotSelectableTargetInfo.java @@ -18,33 +18,28 @@ package com.android.intentresolver.chooser; import android.annotation.Nullable; import android.app.Activity; -import android.content.ComponentName; import android.content.Context; -import android.content.Intent; -import android.content.pm.ResolveInfo; import android.graphics.drawable.AnimatedVectorDrawable; import android.graphics.drawable.Drawable; import android.os.Bundle; import android.os.UserHandle; import com.android.intentresolver.R; -import com.android.intentresolver.ResolverActivity; -import java.util.List; +import java.util.function.Supplier; /** * Distinguish between targets that selectable by the user, vs those that are * placeholders for the system while information is loading in an async manner. */ -public abstract class NotSelectableTargetInfo extends ChooserTargetInfo { +public final class NotSelectableTargetInfo { /** Create a non-selectable {@link TargetInfo} with no content. */ public static TargetInfo newEmptyTargetInfo() { - return new NotSelectableTargetInfo() { - @Override - public boolean isEmptyTargetInfo() { - return true; - } - }; + return ImmutableTargetInfo.newBuilder() + .setLegacyType(ImmutableTargetInfo.LegacyTargetType.EMPTY_TARGET_INFO) + .setDisplayIconHolder(makeReadOnlyIconHolder(() -> null)) + .setActivityStarter(makeNoOpActivityStarter()) + .build(); } /** @@ -52,108 +47,56 @@ public abstract class NotSelectableTargetInfo extends ChooserTargetInfo { * unless/until it can be replaced by the result of a pending asynchronous load. */ public static TargetInfo newPlaceHolderTargetInfo(Context context) { - return new NotSelectableTargetInfo() { - @Override - public boolean isPlaceHolderTargetInfo() { - return true; - } - - @Override - public IconHolder getDisplayIconHolder() { - return new IconHolder() { - @Override - public Drawable getDisplayIcon() { - AnimatedVectorDrawable avd = (AnimatedVectorDrawable) - context.getDrawable( - R.drawable.chooser_direct_share_icon_placeholder); - avd.start(); // Start animation after generation. - return avd; - } - - @Override - public void setDisplayIcon(Drawable icon) {} - }; - } - - @Override - public boolean hasDisplayIcon() { - return true; - } - }; - } - - public final boolean isNotSelectableTargetInfo() { - return true; - } - - public Intent getResolvedIntent() { - return null; - } - - public ComponentName getResolvedComponentName() { - return null; - } - - public boolean start(Activity activity, Bundle options) { - return false; - } - - public boolean startAsCaller(ResolverActivity activity, Bundle options, int userId) { - return false; - } - - @Nullable - @Override - public Intent getTargetIntent() { - return null; + return ImmutableTargetInfo.newBuilder() + .setLegacyType(ImmutableTargetInfo.LegacyTargetType.PLACEHOLDER_TARGET_INFO) + .setDisplayIconHolder( + makeReadOnlyIconHolder(() -> makeStartedPlaceholderDrawable(context))) + .setActivityStarter(makeNoOpActivityStarter()) + .build(); } - public boolean startAsUser(Activity activity, Bundle options, UserHandle user) { - return false; + private static Drawable makeStartedPlaceholderDrawable(Context context) { + AnimatedVectorDrawable avd = (AnimatedVectorDrawable) context.getDrawable( + R.drawable.chooser_direct_share_icon_placeholder); + avd.start(); // Start animation after generation. + return avd; } - public ResolveInfo getResolveInfo() { - return null; - } - - public CharSequence getDisplayLabel() { - return null; - } - - public CharSequence getExtendedInfo() { - return null; - } - - public TargetInfo cloneFilledIn(Intent fillInIntent, int flags) { - return null; - } - - public List getAllSourceIntents() { - return null; - } - - public float getModifiedScore() { - return -0.1f; - } + private static ImmutableTargetInfo.IconHolder makeReadOnlyIconHolder( + Supplier iconProvider) { + return new ImmutableTargetInfo.IconHolder() { + @Override + @Nullable + public Drawable getDisplayIcon() { + return iconProvider.get(); + } - public boolean isSuspended() { - return false; + @Override + public void setDisplayIcon(Drawable icon) {} + }; } - public boolean isPinned() { - return false; - } + private static ImmutableTargetInfo.TargetActivityStarter makeNoOpActivityStarter() { + return new ImmutableTargetInfo.TargetActivityStarter() { + @Override + public boolean start(TargetInfo target, Activity activity, Bundle options) { + return false; + } - @Override - public IconHolder getDisplayIconHolder() { - return new IconHolder() { @Override - public Drawable getDisplayIcon() { - return null; + public boolean startAsCaller( + TargetInfo target, Activity activity, Bundle options, int userId) { + return false; } @Override - public void setDisplayIcon(Drawable icon) {} + public boolean startAsUser( + TargetInfo target, Activity activity, Bundle options, UserHandle user) { + return false; + } }; } + + // TODO: merge all the APIs up to a single `TargetInfo` class. + private NotSelectableTargetInfo() {} } diff --git a/java/tests/src/com/android/intentresolver/chooser/TargetInfoTest.kt b/java/tests/src/com/android/intentresolver/chooser/TargetInfoTest.kt index 69948cc9..e9dbe00e 100644 --- a/java/tests/src/com/android/intentresolver/chooser/TargetInfoTest.kt +++ b/java/tests/src/com/android/intentresolver/chooser/TargetInfoTest.kt @@ -22,18 +22,30 @@ import android.content.ComponentName import android.content.Intent import android.content.pm.ActivityInfo import android.content.pm.ResolveInfo +import android.graphics.drawable.AnimatedVectorDrawable import android.os.UserHandle +import android.test.UiThreadTest import androidx.test.platform.app.InstrumentationRegistry import com.android.intentresolver.createChooserTarget import com.android.intentresolver.createShortcutInfo import com.android.intentresolver.mock import com.android.intentresolver.ResolverDataProvider import com.google.common.truth.Truth.assertThat +import org.junit.Before import org.junit.Test class TargetInfoTest { private val context = InstrumentationRegistry.getInstrumentation().getContext() + @Before + fun setup() { + // SelectableTargetInfo reads DeviceConfig and needs a permission for that. + InstrumentationRegistry + .getInstrumentation() + .getUiAutomation() + .adoptShellPermissionIdentity("android.permission.READ_DEVICE_CONFIG") + } + @Test fun testNewEmptyTargetInfo() { val info = NotSelectableTargetInfo.newEmptyTargetInfo() @@ -43,13 +55,19 @@ class TargetInfoTest { assertThat(info.getDisplayIconHolder().getDisplayIcon()).isNull() } + @UiThreadTest // AnimatedVectorDrawable needs to start from a thread with a Looper. @Test fun testNewPlaceholderTargetInfo() { val info = NotSelectableTargetInfo.newPlaceHolderTargetInfo(context) - assertThat(info.isPlaceHolderTargetInfo()).isTrue() - assertThat(info.isChooserTargetInfo()).isTrue() // From legacy inheritance model. + assertThat(info.isPlaceHolderTargetInfo).isTrue() + assertThat(info.isChooserTargetInfo).isTrue() // From legacy inheritance model. assertThat(info.hasDisplayIcon()).isTrue() - // TODO: test infrastructure isn't set up to assert anything about the icon itself. + assertThat(info.displayIconHolder.displayIcon) + .isInstanceOf(AnimatedVectorDrawable::class.java) + // TODO: assert that the animation is pre-started/running (IIUC this requires synchronizing + // with some "render thread" per the `AnimatedVectorDrawable` docs). I believe this is + // possible using `AnimatorTestRule` but I couldn't find any sample usage in Kotlin nor get + // it working myself. } @Test -- cgit v1.2.3-59-g8ed1b From 031a84bc72c2f8d5483fefc24539bb5ee08d5fdc Mon Sep 17 00:00:00 2001 From: Joshua Trask Date: Thu, 9 Feb 2023 18:26:37 +0000 Subject: Remove plain `startActivity` plumbing APIs These were unused, and the pruned-down API surface better indicates that we *always* launch activities to an explicit *user* (and *maybe* with additional specification about the caller identity -- something to do with the "safe forwarding mode" feature I'm still trying to get my mind aroud). While I was in here, I changed the signature of the one 'activity starter' method that arbitrarily took a `ResolverActivity` instead of the more generic `Activity` used in the others. We've been commenting about the opportunity to make this fix for a while now, and there's no time like the present. Test: `atest IntentResolverUnitTests` Bug: 202167050 Change-Id: I33be96b68e6feba8b7fdb0295431373aadd2c2bf --- .../intentresolver/chooser/DisplayResolveInfo.java | 15 +++--- .../chooser/ImmutableTargetInfo.java | 18 +------- .../chooser/MultiDisplayResolveInfo.java | 9 +--- .../chooser/NotSelectableTargetInfo.java | 5 -- .../chooser/SelectableTargetInfo.java | 8 +--- .../android/intentresolver/chooser/TargetInfo.java | 17 ++----- .../chooser/ImmutableTargetInfoTest.kt | 54 +--------------------- 7 files changed, 15 insertions(+), 111 deletions(-) (limited to 'java/src') diff --git a/java/src/com/android/intentresolver/chooser/DisplayResolveInfo.java b/java/src/com/android/intentresolver/chooser/DisplayResolveInfo.java index 4bbf59d8..0bbd6901 100644 --- a/java/src/com/android/intentresolver/chooser/DisplayResolveInfo.java +++ b/java/src/com/android/intentresolver/chooser/DisplayResolveInfo.java @@ -27,7 +27,6 @@ import android.content.pm.ResolveInfo; import android.os.Bundle; import android.os.UserHandle; -import com.android.intentresolver.ResolverActivity; import com.android.intentresolver.TargetPresentationGetter; import java.util.ArrayList; @@ -205,13 +204,7 @@ public class DisplayResolveInfo implements TargetInfo { } @Override - public boolean start(Activity activity, Bundle options) { - activity.startActivity(mResolvedIntent, options); - return true; - } - - @Override - public boolean startAsCaller(ResolverActivity activity, Bundle options, int userId) { + public boolean startAsCaller(Activity activity, Bundle options, int userId) { TargetInfo.prepareIntentForCrossProfileLaunch(mResolvedIntent, userId); activity.startActivityAsCaller(mResolvedIntent, options, false, userId); return true; @@ -220,6 +213,12 @@ public class DisplayResolveInfo implements TargetInfo { @Override public boolean startAsUser(Activity activity, Bundle options, UserHandle user) { TargetInfo.prepareIntentForCrossProfileLaunch(mResolvedIntent, user.getIdentifier()); + // TODO: is this equivalent to `startActivityAsCaller` with `ignoreTargetSecurity=true`? If + // so, we can consolidate on the one API method to show that this flag is the only + // distinction between `startAsCaller` and `startAsUser`. We can even bake that flag into + // the `TargetActivityStarter` upfront since it just reflects our "safe forwarding mode" -- + // which is constant for the duration of our lifecycle, leaving clients no other + // responsibilities in this logic. activity.startActivityAsUser(mResolvedIntent, options, user); return false; } diff --git a/java/src/com/android/intentresolver/chooser/ImmutableTargetInfo.java b/java/src/com/android/intentresolver/chooser/ImmutableTargetInfo.java index 315cea4d..38991c78 100644 --- a/java/src/com/android/intentresolver/chooser/ImmutableTargetInfo.java +++ b/java/src/com/android/intentresolver/chooser/ImmutableTargetInfo.java @@ -31,8 +31,6 @@ import android.util.Log; import androidx.annotation.VisibleForTesting; -import com.android.intentresolver.ResolverActivity; - import com.google.common.collect.ImmutableList; import java.util.ArrayList; @@ -54,15 +52,6 @@ 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#startActivity()} API to launch the - * specified {@code target}. - * - * @return true if the target was launched successfully. - */ - boolean start(TargetInfo target, Activity activity, Bundle options); - - /** * Request that the delegate use the {@link Activity#startAsCaller()} API to launch the * specified {@code target}. @@ -418,12 +407,7 @@ public final class ImmutableTargetInfo implements TargetInfo { } @Override - public boolean start(Activity activity, Bundle options) { - return mActivityStarter.start(this, activity, options); - } - - @Override - public boolean startAsCaller(ResolverActivity activity, Bundle options, int userId) { + public boolean startAsCaller(Activity activity, Bundle options, int userId) { return mActivityStarter.startAsCaller(this, activity, options, userId); } diff --git a/java/src/com/android/intentresolver/chooser/MultiDisplayResolveInfo.java b/java/src/com/android/intentresolver/chooser/MultiDisplayResolveInfo.java index 0d79e5d5..0938c55e 100644 --- a/java/src/com/android/intentresolver/chooser/MultiDisplayResolveInfo.java +++ b/java/src/com/android/intentresolver/chooser/MultiDisplayResolveInfo.java @@ -21,8 +21,6 @@ import android.content.Intent; import android.os.Bundle; import android.os.UserHandle; -import com.android.intentresolver.ResolverActivity; - import java.util.ArrayList; import java.util.List; @@ -106,12 +104,7 @@ public class MultiDisplayResolveInfo extends DisplayResolveInfo { } @Override - public boolean start(Activity activity, Bundle options) { - return mTargetInfos.get(mSelected).start(activity, options); - } - - @Override - public boolean startAsCaller(ResolverActivity activity, Bundle options, int userId) { + public boolean startAsCaller(Activity activity, Bundle options, int userId) { return mTargetInfos.get(mSelected).startAsCaller(activity, options, userId); } diff --git a/java/src/com/android/intentresolver/chooser/NotSelectableTargetInfo.java b/java/src/com/android/intentresolver/chooser/NotSelectableTargetInfo.java index c63ebc8c..6444e13b 100644 --- a/java/src/com/android/intentresolver/chooser/NotSelectableTargetInfo.java +++ b/java/src/com/android/intentresolver/chooser/NotSelectableTargetInfo.java @@ -78,11 +78,6 @@ public final class NotSelectableTargetInfo { private static ImmutableTargetInfo.TargetActivityStarter makeNoOpActivityStarter() { return new ImmutableTargetInfo.TargetActivityStarter() { - @Override - public boolean start(TargetInfo target, Activity activity, Bundle options) { - return false; - } - @Override public boolean startAsCaller( TargetInfo target, Activity activity, Bundle options, int userId) { diff --git a/java/src/com/android/intentresolver/chooser/SelectableTargetInfo.java b/java/src/com/android/intentresolver/chooser/SelectableTargetInfo.java index ca778233..df27c2b0 100644 --- a/java/src/com/android/intentresolver/chooser/SelectableTargetInfo.java +++ b/java/src/com/android/intentresolver/chooser/SelectableTargetInfo.java @@ -33,7 +33,6 @@ import android.text.SpannableStringBuilder; import android.util.HashedStringCache; import android.util.Log; -import com.android.intentresolver.ResolverActivity; import com.android.internal.config.sysui.SystemUiDeviceConfigFlags; import java.util.ArrayList; @@ -332,12 +331,7 @@ public final class SelectableTargetInfo extends ChooserTargetInfo { } @Override - public boolean start(Activity activity, Bundle options) { - return mActivityStarter.start(activity, options); - } - - @Override - public boolean startAsCaller(ResolverActivity activity, Bundle options, int userId) { + public boolean startAsCaller(Activity activity, Bundle options, int userId) { return mActivityStarter.startAsCaller(activity, options, userId); } diff --git a/java/src/com/android/intentresolver/chooser/TargetInfo.java b/java/src/com/android/intentresolver/chooser/TargetInfo.java index 7dcf66b2..69f58a7b 100644 --- a/java/src/com/android/intentresolver/chooser/TargetInfo.java +++ b/java/src/com/android/intentresolver/chooser/TargetInfo.java @@ -32,8 +32,6 @@ import android.service.chooser.ChooserTarget; import android.text.TextUtils; import android.util.HashedStringCache; -import com.android.intentresolver.ResolverActivity; - import java.util.ArrayList; import java.util.List; import java.util.Objects; @@ -124,24 +122,15 @@ public interface TargetInfo { } /** - * Start the activity referenced by this target. - * - * @param activity calling Activity performing the launch - * @param options ActivityOptions bundle - * @return true if the start completed successfully - */ - boolean start(Activity activity, Bundle options); - - /** - * Start the activity referenced by this target as if the ResolverActivity's caller - * was performing the start operation. + * Start the activity referenced by this target as if the Activity's caller was performing the + * start operation. * * @param activity calling Activity (actually) performing the launch * @param options ActivityOptions bundle * @param userId userId to start as or {@link UserHandle#USER_NULL} for activity's caller * @return true if the start completed successfully */ - boolean startAsCaller(ResolverActivity activity, Bundle options, int userId); + boolean startAsCaller(Activity activity, Bundle options, int userId); /** * Start the activity referenced by this target as a given user. diff --git a/java/tests/src/com/android/intentresolver/chooser/ImmutableTargetInfoTest.kt b/java/tests/src/com/android/intentresolver/chooser/ImmutableTargetInfoTest.kt index 4d825f6b..4989a3f1 100644 --- a/java/tests/src/com/android/intentresolver/chooser/ImmutableTargetInfoTest.kt +++ b/java/tests/src/com/android/intentresolver/chooser/ImmutableTargetInfoTest.kt @@ -348,43 +348,9 @@ class ImmutableTargetInfoTest { assertThat(info.isDisplayResolveInfo).isTrue() } - @Test - fun testActivityStarter_correctNumberOfInvocations_start() { - val activityStarter = object : TestActivityStarter() { - override fun startAsCaller( - target: TargetInfo, activity: Activity, options: Bundle, userId: Int): Boolean { - throw RuntimeException("Wrong API used: startAsCaller") - } - - override fun startAsUser( - target: TargetInfo, activity: Activity, options: Bundle, user: UserHandle - ): Boolean { - throw RuntimeException("Wrong API used: startAsUser") - } - } - - val info = ImmutableTargetInfo.newBuilder().setActivityStarter(activityStarter).build() - val activity: Activity = mock() - val options = Bundle() - options.putInt("TEST_KEY", 1) - - info.start(activity, options) - - assertThat(activityStarter.totalInvocations).isEqualTo(1) - assertThat(activityStarter.lastInvocationTargetInfo).isEqualTo(info) - assertThat(activityStarter.lastInvocationActivity).isEqualTo(activity) - assertThat(activityStarter.lastInvocationOptions).isEqualTo(options) - assertThat(activityStarter.lastInvocationUserId).isNull() - assertThat(activityStarter.lastInvocationAsCaller).isFalse() - } - @Test fun testActivityStarter_correctNumberOfInvocations_startAsCaller() { val activityStarter = object : TestActivityStarter() { - override fun start(target: TargetInfo, activity: Activity, options: Bundle): Boolean { - throw RuntimeException("Wrong API used: start") - } - override fun startAsUser( target: TargetInfo, activity: Activity, options: Bundle, user: UserHandle ): Boolean { @@ -410,10 +376,6 @@ class ImmutableTargetInfoTest { @Test fun testActivityStarter_correctNumberOfInvocations_startAsUser() { val activityStarter = object : TestActivityStarter() { - override fun start(target: TargetInfo, activity: Activity, options: Bundle): Boolean { - throw RuntimeException("Wrong API used: start") - } - override fun startAsCaller( target: TargetInfo, activity: Activity, options: Bundle, userId: Int): Boolean { throw RuntimeException("Wrong API used: startAsCaller") @@ -441,16 +403,14 @@ class ImmutableTargetInfoTest { val info1 = ImmutableTargetInfo.newBuilder().setActivityStarter(activityStarter).build() val info2 = info1.toBuilder().build() - info1.start(mock(), Bundle()) + info1.startAsCaller(mock(), Bundle(), 42) assertThat(activityStarter.lastInvocationTargetInfo).isEqualTo(info1) - info2.start(mock(), Bundle()) - assertThat(activityStarter.lastInvocationTargetInfo).isEqualTo(info2) info2.startAsCaller(mock(), Bundle(), 42) assertThat(activityStarter.lastInvocationTargetInfo).isEqualTo(info2) info2.startAsUser(mock(), Bundle(), UserHandle.of(42)) assertThat(activityStarter.lastInvocationTargetInfo).isEqualTo(info2) - assertThat(activityStarter.totalInvocations).isEqualTo(4) // Instance is still shared. + assertThat(activityStarter.totalInvocations).isEqualTo(3) // Instance is still shared. } } @@ -462,16 +422,6 @@ private open class TestActivityStarter : ImmutableTargetInfo.TargetActivityStart var lastInvocationUserId: Integer? = null var lastInvocationAsCaller = false - override fun start(target: TargetInfo, activity: Activity, options: Bundle): Boolean { - ++totalInvocations - lastInvocationTargetInfo = target - lastInvocationActivity = activity - lastInvocationOptions = options - lastInvocationUserId = null - lastInvocationAsCaller = false - return true - } - override fun startAsCaller( target: TargetInfo, activity: Activity, options: Bundle, userId: Int): Boolean { ++totalInvocations -- cgit v1.2.3-59-g8ed1b From b3240df7c525de985765e0cbbb094a0f7c83e440 Mon Sep 17 00:00:00 2001 From: Joshua Trask Date: Fri, 10 Feb 2023 16:25:01 +0000 Subject: Clarify ResolverActivity inheritance contract. This is a simple mechanical refactoring and (given everything still builds) it can't possibly have any side effects. The procedure for generating this change was as follows: 1. Add `final` to all `ResolverActivity` methods. 2. Build, and remove `final` from any of those methods that now break compilation. 3. Sort the `final` (and `static`) methods to the bottom of the source file / non-final to the top. 4. Build `IntentResolverUnitTests` and remove `final` from any `ResolverActivity` methods that broke test-only compilation. Replace with a comment `// @NonFinalForTesting` on these methods (referencing an annotation that isn't available in Android, but still worth noting. There are other patterns we can use to formalize our design requirements, but these lightweight comments are more appropriate while the API is in flux). 5. Sort these "non-final for testing" methods above the `final` methods but below any that are overridden by real clients (since they're still *really* just internal implementation details). 6. Build and test. (7. Go back through and remove `final` from any private methods, as requested by the linter. I'm not sure I agre3 with this rule in a language where these methods will be "open" by default if their visibility is ever changed, but c'est la vie...) This groups together the `ResolverActivity` "override surface," which roughly outlines the interface of a hypothetical delegate interface that could be injected as part of `ChooserActivity` configuration in order to avoid overriding any `ResolverActivity` methods. (That's left out of scope in this CL, because -- even though we *could* effect such a change via purely-mechanical transformations -- I suspect we'll want to apply *some* design discretion along the way.) Ultimately this will be an important step in decoupling our components from the `Activity` API (with benefits for code clarity, hypothetical "embedded Sharesheet" and "build-your-own chooser" APIs, etc). Test: `atest IntentResolverUnitTests` Bug: 202167050 Change-Id: Iffe340e1d6e3e3224fb6bd78005c349384716162 --- .../android/intentresolver/ResolverActivity.java | 1667 ++++++++++---------- 1 file changed, 839 insertions(+), 828 deletions(-) (limited to 'java/src') diff --git a/java/src/com/android/intentresolver/ResolverActivity.java b/java/src/com/android/intentresolver/ResolverActivity.java index d431d57b..ff436ed8 100644 --- a/java/src/com/android/intentresolver/ResolverActivity.java +++ b/java/src/com/android/intentresolver/ResolverActivity.java @@ -239,22 +239,6 @@ public class ResolverActivity extends FragmentActivity implements protected final LatencyTracker mLatencyTracker = getLatencyTracker(); - 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; - } - private enum ActionTitle { VIEW(Intent.ACTION_VIEW, com.android.internal.R.string.whichViewApplication, @@ -338,27 +322,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); - 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 void super_onCreate(Bundle savedInstanceState) { - super.onCreate(savedInstanceState); - } - @Override protected void onCreate(Bundle savedInstanceState) { // Use a specialized prompt when we're handling the 'Home' app startActivity() @@ -492,48 +455,6 @@ public class ResolverActivity extends FragmentActivity implements return resolverMultiProfilePagerAdapter; } - @VisibleForTesting - protected MyUserIdProvider createMyUserIdProvider() { - return new MyUserIdProvider(); - } - - @VisibleForTesting - protected CrossProfileIntentsChecker createCrossProfileIntentsChecker() { - return new CrossProfileIntentsChecker(getContentResolver()); - } - - @VisibleForTesting - protected QuietModeManager createQuietModeManager() { - UserManager userManager = getSystemService(UserManager.class); - return new QuietModeManager() { - - private boolean mIsWaitingToEnableWorkProfile = false; - - @Override - public boolean isQuietModeEnabled(UserHandle workProfileUserHandle) { - return userManager.isQuietModeEnabled(workProfileUserHandle); - } - - @Override - public void requestQuietModeEnabled(boolean enabled, UserHandle workProfileUserHandle) { - AsyncTask.THREAD_POOL_EXECUTOR.execute(() -> { - userManager.requestQuietModeEnabled(enabled, workProfileUserHandle); - }); - mIsWaitingToEnableWorkProfile = true; - } - - @Override - public void markWorkProfileEnabledBroadcastReceived() { - mIsWaitingToEnableWorkProfile = false; - } - - @Override - public boolean isWaitingToEnableWorkProfile() { - return mIsWaitingToEnableWorkProfile; - } - }; - } - protected EmptyStateProvider createBlockerEmptyStateProvider() { final boolean shouldShowNoCrossProfileIntentsEmptyState = getUser().equals(getIntentUser()); @@ -551,7 +472,8 @@ public class ResolverActivity extends FragmentActivity implements /* defaultSubtitleResource= */ R.string.resolver_cant_access_personal_apps_explanation, /* devicePolicyEventId= */ RESOLVER_EMPTY_STATE_NO_SHARING_TO_PERSONAL, - /* devicePolicyEventCategory= */ ResolverActivity.METRICS_CATEGORY_RESOLVER); + /* devicePolicyEventCategory= */ + ResolverActivity.METRICS_CATEGORY_RESOLVER); final AbstractMultiProfilePagerAdapter.EmptyState noPersonalToWorkEmptyState = new DevicePolicyBlockerEmptyState(/* context= */ this, @@ -561,182 +483,18 @@ public class ResolverActivity extends FragmentActivity implements /* defaultSubtitleResource= */ R.string.resolver_cant_access_work_apps_explanation, /* devicePolicyEventId= */ RESOLVER_EMPTY_STATE_NO_SHARING_TO_WORK, - /* devicePolicyEventCategory= */ ResolverActivity.METRICS_CATEGORY_RESOLVER); + /* devicePolicyEventCategory= */ + ResolverActivity.METRICS_CATEGORY_RESOLVER); return new NoCrossProfileEmptyStateProvider(getPersonalProfileUserHandle(), noWorkToPersonalEmptyState, noPersonalToWorkEmptyState, createCrossProfileIntentsChecker(), createMyUserIdProvider()); } - protected EmptyStateProvider createEmptyStateProvider( - @Nullable UserHandle workProfileUserHandle) { - final EmptyStateProvider blockerEmptyStateProvider = createBlockerEmptyStateProvider(); - - final EmptyStateProvider workProfileOffEmptyStateProvider = - new WorkProfilePausedEmptyStateProvider(this, workProfileUserHandle, - mQuietModeManager, - /* onSwitchOnWorkSelectedListener= */ - () -> { if (mOnSwitchOnWorkSelectedListener != null) { - mOnSwitchOnWorkSelectedListener.onSwitchOnWorkSelected(); - }}, - getMetricsCategory()); - - final EmptyStateProvider noAppsEmptyStateProvider = new NoAppsAvailableEmptyStateProvider( - this, - workProfileUserHandle, - getPersonalProfileUserHandle(), - getMetricsCategory(), - createMyUserIdProvider() - ); - - // Return composite provider, the order matters (the higher, the more priority) - return new CompositeEmptyStateProvider( - blockerEmptyStateProvider, - workProfileOffEmptyStateProvider, - noAppsEmptyStateProvider - ); - } - - private ResolverMultiProfilePagerAdapter createResolverMultiProfilePagerAdapterForOneProfile( - Intent[] initialIntents, - List rList, boolean filterLastUsed) { - ResolverListAdapter adapter = createResolverListAdapter( - /* context */ this, - /* payloadIntents */ mIntents, - initialIntents, - rList, - filterLastUsed, - /* userHandle */ UserHandle.of(UserHandle.myUserId())); - QuietModeManager quietModeManager = createQuietModeManager(); - return new ResolverMultiProfilePagerAdapter( - /* context */ this, - adapter, - createEmptyStateProvider(/* workProfileUserHandle= */ null), - quietModeManager, - /* workProfileUserHandle= */ null); - } - - private UserHandle getIntentUser() { - return getIntent().hasExtra(EXTRA_CALLING_USER) - ? getIntent().getParcelableExtra(EXTRA_CALLING_USER) - : getUser(); - } - - private ResolverMultiProfilePagerAdapter createResolverMultiProfilePagerAdapterForTwoProfiles( - Intent[] initialIntents, - List rList, - boolean filterLastUsed) { - // In the edge case when we have 0 apps in the current profile and >1 apps in the other, - // the intent resolver is started in the other profile. Since this is the only case when - // this happens, we check for it here and set the current profile's tab. - int selectedProfile = getCurrentProfile(); - UserHandle intentUser = getIntentUser(); - if (!getUser().equals(intentUser)) { - if (getPersonalProfileUserHandle().equals(intentUser)) { - selectedProfile = PROFILE_PERSONAL; - } else if (getWorkProfileUserHandle().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, - rList, - (filterLastUsed && UserHandle.myUserId() - == getPersonalProfileUserHandle().getIdentifier()), - /* userHandle */ getPersonalProfileUserHandle()); - UserHandle workProfileUserHandle = getWorkProfileUserHandle(); - ResolverListAdapter workAdapter = createResolverListAdapter( - /* context */ this, - /* payloadIntents */ mIntents, - selectedProfile == PROFILE_WORK ? initialIntents : null, - rList, - (filterLastUsed && UserHandle.myUserId() - == workProfileUserHandle.getIdentifier()), - /* userHandle */ workProfileUserHandle); - QuietModeManager quietModeManager = createQuietModeManager(); - return new ResolverMultiProfilePagerAdapter( - /* context */ this, - personalAdapter, - workAdapter, - createEmptyStateProvider(getWorkProfileUserHandle()), - quietModeManager, - selectedProfile, - getWorkProfileUserHandle()); - } - protected int appliedThemeResId() { return R.style.Theme_DeviceDefault_Resolver; } - /** - * 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} - */ - 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 @Profile int getCurrentProfile() { - return (UserHandle.myUserId() == UserHandle.USER_SYSTEM ? 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`. - @Nullable - protected UserHandle getWorkProfileUserHandle() { - return getAnnotatedUserHandles().workProfileUserHandle; - } - - private boolean hasWorkProfile() { - return getWorkProfileUserHandle() != null; - } - - protected boolean shouldShowTabs() { - return hasWorkProfile(); - } - - protected 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(); - } - /** * Numerous layouts are supported, each with optional ViewGroups. * Make sure the inset gets added to the correct View, using @@ -801,219 +559,13 @@ public class ResolverActivity extends FragmentActivity implements } } - 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)); + public int getLayoutResource() { + return R.layout.resolver_list; } - @Override // ResolverListCommunicator - public void sendVoiceChoicesIfNeeded() { - if (!isVoiceInteraction()) { - // Clearly not needed. - return; - } - - int count = mMultiProfilePagerAdapter.getActiveListAdapter().getCount(); - final Option[] options = new Option[count]; - for (int i = 0, N = options.length; i < N; 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); - } - - Option optionForChooserTarget(TargetInfo target, int index) { - return new Option(target.getDisplayLabel(), index); - } - - protected final void setAdditionalTargets(Intent[] intents) { - if (intents != null) { - for (Intent intent : intents) { - mIntents.add(intent); - } - } - } - - public Intent getTargetIntent() { - return mIntents.isEmpty() ? null : mIntents.get(0); - } - - protected String getReferrerPackageName() { - final Uri referrer = getReferrer(); - if (referrer != null && "android-app".equals(referrer.getScheme())) { - return referrer.getHost(); - } - return null; - } - - public int getLayoutResource() { - return R.layout.resolver_list; - } - - @Override // ResolverListCommunicator - public 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(com.android.internal.R.string.forward_intent_to_owner)); - } - - private String getForwardToWorkMsg() { - return getSystemService(DevicePolicyManager.class).getResources().getString( - FORWARD_INTENT_TO_WORK, - () -> getString(com.android.internal.R.string.forward_intent_to_work)); - } - - /** - * Turn on launch mode that is safe to use when forwarding intents received from - * applications and running in system processes. This mode uses Activity.startActivityAsCaller - * instead of the normal Activity.startActivity for launching the activity selected - * by the user. - * - *

This mode is set to true by default if the activity is initialized through - * {@link #onCreate(android.os.Bundle)}. If a subclass calls one of the other onCreate - * methods, it is set to false by default. You must set it before calling one of the - * more detailed onCreate methods, so that it will be set correctly in the case where - * there is only one intent to resolve and it is thus started immediately.

- */ - public void setSafeForwardingMode(boolean safeForwarding) { - mSafeForwardingMode = safeForwarding; - } - - protected 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, mMultiProfilePagerAdapter - .getActiveListAdapter().getFilteredItem().getDisplayLabel()) - : getString(title.titleRes); - } - } - - void dismiss() { - if (!isFinishing()) { - finish(); - } - } - - @Override - protected void onRestart() { - super.onRestart(); - if (!mRegistered) { - mPersonalPackageMonitor.register(this, getMainLooper(), - getPersonalProfileUserHandle(), false); - if (shouldShowTabs()) { - if (mWorkPackageMonitor == null) { - mWorkPackageMonitor = createPackageMonitor( - mMultiProfilePagerAdapter.getWorkListAdapter()); - } - mWorkPackageMonitor.register(this, getMainLooper(), - getWorkProfileUserHandle(), false); - } - mRegistered = true; - } - if (shouldShowTabs() && mQuietModeManager.isWaitingToEnableWorkProfile()) { - if (mQuietModeManager.isQuietModeEnabled(getWorkProfileUserHandle())) { - mQuietModeManager.markWorkProfileEnabledBroadcastReceived(); - } - } - mMultiProfilePagerAdapter.getActiveListAdapter().handlePackagesChanged(); - updateProfileViewButton(); - } - - @Override - protected void onStart() { - super.onStart(); - - this.getWindow().addSystemFlags(SYSTEM_FLAG_HIDE_NON_SYSTEM_OVERLAY_WINDOWS); - if (shouldShowTabs()) { - mWorkProfileStateReceiver = createWorkProfileStateReceiver(); - registerWorkProfileStateReceiver(); - - mWorkProfileHasBeenEnabled = isWorkProfileEnabled(); - } - } - - private boolean isWorkProfileEnabled() { - UserHandle workUserHandle = getWorkProfileUserHandle(); - UserManager userManager = getSystemService(UserManager.class); - - return !userManager.isQuietModeEnabled(workUserHandle) - && userManager.isUserUnlocked(workUserHandle); - } - - private void registerWorkProfileStateReceiver() { - IntentFilter filter = new IntentFilter(); - filter.addAction(Intent.ACTION_USER_UNLOCKED); - filter.addAction(Intent.ACTION_MANAGED_PROFILE_AVAILABLE); - filter.addAction(Intent.ACTION_MANAGED_PROFILE_UNAVAILABLE); - registerReceiverAsUser(mWorkProfileStateReceiver, UserHandle.ALL, filter, null, null); - } - - @Override - protected void onStop() { - super.onStop(); + @Override + protected void onStop() { + super.onStop(); final Window window = this.getWindow(); final WindowManager.LayoutParams attrs = window.getAttributes(); @@ -1059,156 +611,53 @@ public class ResolverActivity extends FragmentActivity implements } } - @Override - protected 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()); - } + 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); } - @Override - protected 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)); + public void startSelected(int which, boolean always, boolean hasIndexBeenFiltered) { + if (isFinishing()) { + return; } - mMultiProfilePagerAdapter.clearInactiveProfileCache(); - } - - private boolean hasManagedProfile() { - UserManager userManager = (UserManager) getSystemService(Context.USER_SERVICE); - if (userManager == null) { - return false; + 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; } - try { - List profiles = userManager.getProfiles(getUserId()); - for (UserInfo userInfo : profiles) { - if (userInfo != null && userInfo.isManagedProfile()) { - return true; - } + 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); } - } catch (SecurityException e) { - return false; + MetricsLogger.action(this, + mMultiProfilePagerAdapter.getActiveListAdapter().hasFilteredItem() + ? MetricsProto.MetricsEvent.ACTION_HIDE_APP_DISAMBIG_APP_FEATURED + : MetricsProto.MetricsEvent.ACTION_HIDE_APP_DISAMBIG_NONE_FEATURED); + finish(); } - 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; - } - 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) - == android.content.pm.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); - } - - 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(); - } - } - - private String getWorkProfileNotSupportedMsg(String launcherName) { - return getSystemService(DevicePolicyManager.class).getResources().getString( - RESOLVER_WORK_PROFILE_NOT_SUPPORTED, - () -> getString( - com.android.internal.R.string.activity_resolver_work_profiles_support, - launcherName), - launcherName); } /** @@ -1219,33 +668,6 @@ public class ResolverActivity extends FragmentActivity implements return defIntent; } - @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); - } - } - protected void onListRebuilt(ResolverListAdapter listAdapter, boolean rebuildCompleted) { final ItemClickListener listener = new ItemClickListener(); setupAdapterListView((ListView) mMultiProfilePagerAdapter.getActiveAdapterView(), listener); @@ -1407,29 +829,783 @@ public class ResolverActivity extends FragmentActivity implements } } - if (target != null) { - safelyStartActivity(target); + 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(); + } + + @VisibleForTesting + protected ResolverListController createListController(UserHandle userHandle) { + return new ResolverListController( + this, + mPm, + getTargetIntent(), + getReferrerPackageName(), + getAnnotatedUserHandles().userIdOfCallingApp, + 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(getWorkProfileUserHandle()) + && mQuietModeManager.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()); + } + + // @NonFinalForTesting + @VisibleForTesting + protected QuietModeManager createQuietModeManager() { + UserManager userManager = getSystemService(UserManager.class); + return new QuietModeManager() { + + private boolean mIsWaitingToEnableWorkProfile = false; + + @Override + public boolean isQuietModeEnabled(UserHandle workProfileUserHandle) { + return userManager.isQuietModeEnabled(workProfileUserHandle); + } + + @Override + public void requestQuietModeEnabled(boolean enabled, UserHandle workProfileUserHandle) { + AsyncTask.THREAD_POOL_EXECUTOR.execute(() -> { + userManager.requestQuietModeEnabled(enabled, workProfileUserHandle); + }); + mIsWaitingToEnableWorkProfile = true; + } + + @Override + public void markWorkProfileEnabledBroadcastReceived() { + mIsWaitingToEnableWorkProfile = false; + } + + @Override + public boolean isWaitingToEnableWorkProfile() { + return mIsWaitingToEnableWorkProfile; + } + }; + } + + // TODO: have tests override `getAnnotatedUserHandles()`, and make this method `final`. + // @NonFinalForTesting + @Nullable + protected UserHandle getWorkProfileUserHandle() { + return getAnnotatedUserHandles().workProfileUserHandle; + } + + // @NonFinalForTesting + @VisibleForTesting + public void safelyStartActivity(TargetInfo cti) { + // We're dispatching intents that might be coming from legacy apps, so + // don't kill ourselves. + StrictMode.disableDeathOnFileUriExposure(); + try { + UserHandle currentUserHandle = mMultiProfilePagerAdapter.getCurrentUserHandle(); + safelyStartActivityInternal(cti, currentUserHandle, null); + } finally { + StrictMode.enableDeathOnFileUriExposure(); + } + } + + // @NonFinalForTesting + @VisibleForTesting + protected ResolverListAdapter createResolverListAdapter(Context context, + List payloadIntents, Intent[] initialIntents, List rList, + boolean filterLastUsed, UserHandle userHandle) { + Intent startIntent = getIntent(); + boolean isAudioCaptureDevice = + startIntent.getBooleanExtra(EXTRA_IS_AUDIO_CAPTURE_DEVICE, false); + return new ResolverListAdapter( + context, + payloadIntents, + initialIntents, + rList, + filterLastUsed, + createListController(userHandle), + userHandle, + getTargetIntent(), + this, + 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, + mQuietModeManager, + /* onSwitchOnWorkSelectedListener= */ + () -> { + if (mOnSwitchOnWorkSelectedListener != null) { + mOnSwitchOnWorkSelectedListener.onSwitchOnWorkSelected(); + } + }, + getMetricsCategory()); + + final EmptyStateProvider noAppsEmptyStateProvider = new NoAppsAvailableEmptyStateProvider( + this, + workProfileUserHandle, + getPersonalProfileUserHandle(), + getMetricsCategory(), + createMyUserIdProvider() + ); + + // 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); + 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 rList, + boolean filterLastUsed) { + ResolverListAdapter adapter = createResolverListAdapter( + /* context */ this, + /* payloadIntents */ mIntents, + initialIntents, + rList, + filterLastUsed, + /* userHandle */ UserHandle.of(UserHandle.myUserId())); + QuietModeManager quietModeManager = createQuietModeManager(); + return new ResolverMultiProfilePagerAdapter( + /* context */ this, + adapter, + createEmptyStateProvider(/* workProfileUserHandle= */ null), + quietModeManager, + /* workProfileUserHandle= */ null); + } + + private UserHandle getIntentUser() { + return getIntent().hasExtra(EXTRA_CALLING_USER) + ? getIntent().getParcelableExtra(EXTRA_CALLING_USER) + : getUser(); + } + + private ResolverMultiProfilePagerAdapter createResolverMultiProfilePagerAdapterForTwoProfiles( + Intent[] initialIntents, + List rList, + boolean filterLastUsed) { + // In the edge case when we have 0 apps in the current profile and >1 apps in the other, + // the intent resolver is started in the other profile. Since this is the only case when + // this happens, we check for it here and set the current profile's tab. + int selectedProfile = getCurrentProfile(); + UserHandle intentUser = getIntentUser(); + if (!getUser().equals(intentUser)) { + if (getPersonalProfileUserHandle().equals(intentUser)) { + selectedProfile = PROFILE_PERSONAL; + } else if (getWorkProfileUserHandle().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, + rList, + (filterLastUsed && UserHandle.myUserId() + == getPersonalProfileUserHandle().getIdentifier()), + /* userHandle */ getPersonalProfileUserHandle()); + UserHandle workProfileUserHandle = getWorkProfileUserHandle(); + ResolverListAdapter workAdapter = createResolverListAdapter( + /* context */ this, + /* payloadIntents */ mIntents, + selectedProfile == PROFILE_WORK ? initialIntents : null, + rList, + (filterLastUsed && UserHandle.myUserId() + == workProfileUserHandle.getIdentifier()), + /* userHandle */ workProfileUserHandle); + QuietModeManager quietModeManager = createQuietModeManager(); + return new ResolverMultiProfilePagerAdapter( + /* context */ this, + personalAdapter, + workAdapter, + createEmptyStateProvider(getWorkProfileUserHandle()), + quietModeManager, + selectedProfile, + getWorkProfileUserHandle()); + } + + /** + * 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() { + return (UserHandle.myUserId() == UserHandle.USER_SYSTEM ? PROFILE_PERSONAL : PROFILE_WORK); + } + + protected final AnnotatedUserHandles getAnnotatedUserHandles() { + return mLazyAnnotatedUserHandles.get(); + } + + protected final UserHandle getPersonalProfileUserHandle() { + return getAnnotatedUserHandles().personalProfileUserHandle; + } + + private boolean hasWorkProfile() { + return getWorkProfileUserHandle() != null; + } + + 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(getPersonalProfileUserHandle())) + .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(target.getDisplayLabel(), index); + } + + protected final void setAdditionalTargets(Intent[] intents) { + if (intents != null) { + for (Intent intent : intents) { + mIntents.add(intent); + } + } + } + + public final Intent getTargetIntent() { + return mIntents.isEmpty() ? null : mIntents.get(0); + } + + protected final String getReferrerPackageName() { + final Uri referrer = getReferrer(); + if (referrer != null && "android-app".equals(referrer.getScheme())) { + return referrer.getHost(); + } + return null; + } + + @Override // ResolverListCommunicator + public final void updateProfileViewButton() { + if (mProfileView == null) { + return; + } + + final DisplayResolveInfo dri = + mMultiProfilePagerAdapter.getActiveListAdapter().getOtherProfile(); + if (dri != null && !shouldShowTabs()) { + mProfileView.setVisibility(View.VISIBLE); + View text = mProfileView.findViewById(com.android.internal.R.id.profile_button); + if (!(text instanceof TextView)) { + text = mProfileView.findViewById(com.android.internal.R.id.text1); + } + ((TextView) text).setText(dri.getDisplayLabel()); + } else { + mProfileView.setVisibility(View.GONE); + } + } + + private void setProfileSwitchMessage(int contentUserHint) { + if ((contentUserHint != UserHandle.USER_CURRENT) + && (contentUserHint != UserHandle.myUserId())) { + UserManager userManager = (UserManager) getSystemService(Context.USER_SERVICE); + UserInfo originUserInfo = userManager.getUserInfo(contentUserHint); + boolean originIsManaged = originUserInfo != null ? originUserInfo.isManagedProfile() + : false; + boolean targetIsManaged = userManager.isManagedProfile(); + if (originIsManaged && !targetIsManaged) { + mProfileSwitchMessage = getForwardToPersonalMsg(); + } else if (!originIsManaged && targetIsManaged) { + mProfileSwitchMessage = getForwardToWorkMsg(); + } + } + } + + private String getForwardToPersonalMsg() { + return getSystemService(DevicePolicyManager.class).getResources().getString( + FORWARD_INTENT_TO_PERSONAL, + () -> getString(com.android.internal.R.string.forward_intent_to_owner)); + } + + private String getForwardToWorkMsg() { + return getSystemService(DevicePolicyManager.class).getResources().getString( + FORWARD_INTENT_TO_WORK, + () -> getString(com.android.internal.R.string.forward_intent_to_work)); + } + + /** + * Turn on launch mode that is safe to use when forwarding intents received from + * applications and running in system processes. This mode uses Activity.startActivityAsCaller + * instead of the normal Activity.startActivity for launching the activity selected + * by the user. + * + *

This mode is set to true by default if the activity is initialized through + * {@link #onCreate(android.os.Bundle)}. If a subclass calls one of the other onCreate + * methods, it is set to false by default. You must set it before calling one of the + * more detailed onCreate methods, so that it will be set correctly in the case where + * there is only one intent to resolve and it is thus started immediately.

+ */ + public final void setSafeForwardingMode(boolean safeForwarding) { + mSafeForwardingMode = safeForwarding; + } + + 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, mMultiProfilePagerAdapter + .getActiveListAdapter().getFilteredItem().getDisplayLabel()) + : getString(title.titleRes); + } + } + + final void dismiss() { + if (!isFinishing()) { + finish(); + } + } + + @Override + protected final void onRestart() { + super.onRestart(); + if (!mRegistered) { + mPersonalPackageMonitor.register(this, getMainLooper(), + getPersonalProfileUserHandle(), false); + if (shouldShowTabs()) { + if (mWorkPackageMonitor == null) { + mWorkPackageMonitor = createPackageMonitor( + mMultiProfilePagerAdapter.getWorkListAdapter()); + } + mWorkPackageMonitor.register(this, getMainLooper(), + getWorkProfileUserHandle(), false); + } + mRegistered = true; + } + if (shouldShowTabs() && mQuietModeManager.isWaitingToEnableWorkProfile()) { + if (mQuietModeManager.isQuietModeEnabled(getWorkProfileUserHandle())) { + mQuietModeManager.markWorkProfileEnabledBroadcastReceived(); + } + } + mMultiProfilePagerAdapter.getActiveListAdapter().handlePackagesChanged(); + updateProfileViewButton(); + } + + @Override + protected final void onStart() { + super.onStart(); + + this.getWindow().addSystemFlags(SYSTEM_FLAG_HIDE_NON_SYSTEM_OVERLAY_WINDOWS); + if (shouldShowTabs()) { + mWorkProfileStateReceiver = createWorkProfileStateReceiver(); + registerWorkProfileStateReceiver(); + + mWorkProfileHasBeenEnabled = isWorkProfileEnabled(); + } + } + + private boolean isWorkProfileEnabled() { + UserHandle workUserHandle = getWorkProfileUserHandle(); + UserManager userManager = getSystemService(UserManager.class); + + return !userManager.isQuietModeEnabled(workUserHandle) + && userManager.isUserUnlocked(workUserHandle); + } + + private void registerWorkProfileStateReceiver() { + IntentFilter filter = new IntentFilter(); + filter.addAction(Intent.ACTION_USER_UNLOCKED); + filter.addAction(Intent.ACTION_MANAGED_PROFILE_AVAILABLE); + filter.addAction(Intent.ACTION_MANAGED_PROFILE_UNAVAILABLE); + registerReceiverAsUser(mWorkProfileStateReceiver, UserHandle.ALL, filter, null, null); + } + + @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; + } + 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; - // Rely on the ActivityManager to pop up a dialog regarding app suspension - // and return false - if (target.isSuspended()) { - return false; + boolean hasRecordPermission = + mPm.checkPermission(android.Manifest.permission.RECORD_AUDIO, + activityInfo.packageName) + == android.content.pm.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); + } - return true; + private String getWorkProfileNotSupportedMsg(String launcherName) { + return getSystemService(DevicePolicyManager.class).getResources().getString( + RESOLVER_WORK_PROFILE_NOT_SUPPORTED, + () -> getString( + com.android.internal.R.string.activity_resolver_work_profiles_support, + launcherName), + launcherName); } - @VisibleForTesting - public void safelyStartActivity(TargetInfo cti) { - // We're dispatching intents that might be coming from legacy apps, so - // don't kill ourselves. - StrictMode.disableDeathOnFileUriExposure(); - try { - UserHandle currentUserHandle = mMultiProfilePagerAdapter.getCurrentUserHandle(); - safelyStartActivityInternal(cti, currentUserHandle, null); - } finally { - StrictMode.enableDeathOnFileUriExposure(); + @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); } } @@ -1439,11 +1615,11 @@ public class ResolverActivity extends FragmentActivity implements * @param user User to launch this activity as. */ @VisibleForTesting - public void safelyStartActivityAsUser(TargetInfo cti, UserHandle user) { + public final void safelyStartActivityAsUser(TargetInfo cti, UserHandle user) { safelyStartActivityAsUser(cti, user, null); } - protected void safelyStartActivityAsUser( + 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. @@ -1493,70 +1669,13 @@ public class ResolverActivity extends FragmentActivity implements } } - private void maybeLogCrossProfileTargetLaunch(TargetInfo cti, UserHandle currentUserHandle) { - if (!hasWorkProfile() || currentUserHandle.equals(getUser())) { - return; - } - DevicePolicyEventLogger - .createEvent(DevicePolicyEnums.RESOLVER_CROSS_PROFILE_TARGET_OPENED) - .setBoolean(currentUserHandle.equals(getPersonalProfileUserHandle())) - .setStrings(getMetricsCategory(), - cti.isInDirectShareMetricsCategory() ? "direct_share" : "other_target") - .write(); - } - - - public void onActivityStarted(TargetInfo cti) { - // Do nothing - } - - @Override // ResolverListCommunicator - public boolean shouldGetActivityMetadata() { - return false; - } - - public boolean shouldAutoLaunchSingleChoice(TargetInfo target) { - return !target.isSuspended(); - } - - void showTargetDetails(ResolveInfo ri) { + 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()); } - @VisibleForTesting - protected ResolverListAdapter createResolverListAdapter(Context context, - List payloadIntents, Intent[] initialIntents, List rList, - boolean filterLastUsed, UserHandle userHandle) { - Intent startIntent = getIntent(); - boolean isAudioCaptureDevice = - startIntent.getBooleanExtra(EXTRA_IS_AUDIO_CAPTURE_DEVICE, false); - return new ResolverListAdapter( - context, - payloadIntents, - initialIntents, - rList, - filterLastUsed, - createListController(userHandle), - userHandle, - getTargetIntent(), - this, - isAudioCaptureDevice); - } - - @VisibleForTesting - protected ResolverListController createListController(UserHandle userHandle) { - return new ResolverListController( - this, - mPm, - getTargetIntent(), - getReferrerPackageName(), - getAnnotatedUserHandles().userIdOfCallingApp, - userHandle); - } - /** * Sets up the content view. * @return true if the activity is finishing and creation should halt. @@ -1691,16 +1810,6 @@ public class ResolverActivity extends FragmentActivity implements return true; } - /** - * 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); - } - /** * Finishing procedures to be performed after the list has been rebuilt. * @param rebuildCompleted @@ -1958,8 +2067,6 @@ public class ResolverActivity extends FragmentActivity implements RESOLVER_WORK_TAB, () -> getString(R.string.resolver_work_tab)); } - void onHorizontalSwipeStateChanged(int state) {} - private void maybeHideDivider() { if (!mIsIntentPicker) { return; @@ -1971,12 +2078,6 @@ public class ResolverActivity extends FragmentActivity implements divider.setVisibility(View.GONE); } - /** - * Callback called when user changes the profile tab. - *

This method is intended to be overridden by subclasses. - */ - protected void onProfileTabSelected() { } - private void resetCheckedItem() { if (!mIsIntentPicker) { return; @@ -2023,20 +2124,17 @@ public class ResolverActivity extends FragmentActivity implements } /** - * Add a label to signify that the user can pick a different app. - * @param adapter The adapter used to provide data to item views. + * 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. */ - 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); + 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); } } @@ -2084,61 +2182,6 @@ public class ResolverActivity extends FragmentActivity implements mHeaderCreatorUser = listAdapter.getUserHandle(); } - 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(); - } - - /** - * 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 resetAlwaysOrOnceButtonBar() { // Disable both buttons initially setAlwaysButtonEnabled(false, ListView.INVALID_POSITION, false); @@ -2164,7 +2207,7 @@ public class ResolverActivity extends FragmentActivity implements } @Override // ResolverListCommunicator - public boolean useLayoutWithDefault() { + 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 currentUserAdapterHasFilteredItem; @@ -2183,7 +2226,7 @@ public class ResolverActivity extends FragmentActivity implements * 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 void setRetainInOnStop(boolean retainInOnStop) { + protected final void setRetainInOnStop(boolean retainInOnStop) { mRetainInOnStop = retainInOnStop; } @@ -2191,43 +2234,13 @@ public class ResolverActivity extends FragmentActivity implements * Check a simple match for the component of two ResolveInfos. */ @Override // ResolverListCommunicator - public boolean resolveInfoMatch(ResolveInfo lhs, ResolveInfo rhs) { + 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); } - protected String getMetricsCategory() { - return METRICS_CATEGORY_RESOLVER; - } - - @Override // ResolverListCommunicator - public void onHandlePackagesChanged(ResolverListAdapter listAdapter) { - if (listAdapter == mMultiProfilePagerAdapter.getActiveListAdapter()) { - if (listAdapter.getUserHandle().equals(getWorkProfileUserHandle()) - && mQuietModeManager.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(); - } - } - private boolean inactiveListAdapterHasItems() { if (!shouldShowTabs()) { return false; @@ -2329,7 +2342,7 @@ public class ResolverActivity extends FragmentActivity implements } } - class ItemClickListener implements AdapterView.OnItemClickListener, + final class ItemClickListener implements AdapterView.OnItemClickListener, AdapterView.OnItemLongClickListener { @Override public void onItemClick(AdapterView parent, View view, int position, long id) { @@ -2390,7 +2403,7 @@ public class ResolverActivity extends FragmentActivity implements && match <= IntentFilter.MATCH_CATEGORY_PATH; } - static class PickTargetOptionRequest extends PickOptionRequest { + static final class PickTargetOptionRequest extends PickOptionRequest { public PickTargetOptionRequest(@Nullable Prompt prompt, Option[] options, @Nullable Bundle extras) { super(prompt, options, extras); @@ -2426,6 +2439,4 @@ public class ResolverActivity extends FragmentActivity implements } } } - - protected void maybeLogProfileChange() {} -} +} \ No newline at end of file -- cgit v1.2.3-59-g8ed1b From 8bbaf3def25fb233400d70aa50454f0581021cbd Mon Sep 17 00:00:00 2001 From: Matt Casey Date: Sun, 5 Feb 2023 04:03:56 +0000 Subject: [intentresolver] Reselection -> modify share Doesn't change the UI or logging in this change, just API and code naming. Bug: 267870268 Test: atest UnbundledChooserActivityTest Change-Id: I2b8a68ed1e3fe1e6d4bdb1a89f155afdf377159a Merged-In: I2b8a68ed1e3fe1e6d4bdb1a89f155afdf377159a (cherry picked from commit 6333997c2c04f2e71c8fefad56a651e88bd25922) --- java/src/com/android/intentresolver/ChooserActivity.java | 4 ++-- .../android/intentresolver/ChooserContentPreviewUi.java | 16 ++++++++-------- .../android/intentresolver/ChooserRequestParameters.java | 16 ++++++++-------- .../intentresolver/UnbundledChooserActivityTest.java | 10 +++++----- 4 files changed, 23 insertions(+), 23 deletions(-) (limited to 'java/src') diff --git a/java/src/com/android/intentresolver/ChooserActivity.java b/java/src/com/android/intentresolver/ChooserActivity.java index a355bef8..34390770 100644 --- a/java/src/com/android/intentresolver/ChooserActivity.java +++ b/java/src/com/android/intentresolver/ChooserActivity.java @@ -763,12 +763,12 @@ public class ChooserActivity extends ResolverActivity implements @Nullable @Override - public Runnable getReselectionAction() { + public Runnable getModifyShareAction() { if (!mFeatureFlagRepository .isEnabled(Flags.SHARESHEET_RESELECTION_ACTION)) { return null; } - PendingIntent reselectionAction = mChooserRequest.getReselectionAction(); + PendingIntent reselectionAction = mChooserRequest.getModifyShareAction(); return reselectionAction == null ? null : createReselectionRunnable(reselectionAction); diff --git a/java/src/com/android/intentresolver/ChooserContentPreviewUi.java b/java/src/com/android/intentresolver/ChooserContentPreviewUi.java index 1acb4d57..7d627e07 100644 --- a/java/src/com/android/intentresolver/ChooserContentPreviewUi.java +++ b/java/src/com/android/intentresolver/ChooserContentPreviewUi.java @@ -99,10 +99,10 @@ public final class ChooserContentPreviewUi { List createCustomActions(); /** - * Provides a re-selection action, if any. + * Provides a share modification action, if any. */ @Nullable - Runnable getReselectionAction(); + Runnable getModifyShareAction(); /** *

@@ -255,13 +255,13 @@ public final class ChooserContentPreviewUi { default: Log.e(TAG, "Unexpected content preview type: " + previewType); } - Runnable reselectionAction = actionFactory.getReselectionAction(); - if (reselectionAction != null && layout != null + Runnable modifyShareAction = actionFactory.getModifyShareAction(); + if (modifyShareAction != null && layout != null && mFeatureFlagRepository.isEnabled(Flags.SHARESHEET_RESELECTION_ACTION)) { - View reselectionView = layout.findViewById(R.id.reselection_action); - if (reselectionView != null) { - reselectionView.setVisibility(View.VISIBLE); - reselectionView.setOnClickListener(view -> reselectionAction.run()); + View modifyShareView = layout.findViewById(R.id.reselection_action); + if (modifyShareView != null) { + modifyShareView.setVisibility(View.VISIBLE); + modifyShareView.setOnClickListener(view -> modifyShareAction.run()); } } diff --git a/java/src/com/android/intentresolver/ChooserRequestParameters.java b/java/src/com/android/intentresolver/ChooserRequestParameters.java index 0d004b0d..2b67b273 100644 --- a/java/src/com/android/intentresolver/ChooserRequestParameters.java +++ b/java/src/com/android/intentresolver/ChooserRequestParameters.java @@ -76,7 +76,7 @@ public class ChooserRequestParameters { private final ImmutableList mFilteredComponentNames; private final ImmutableList mCallerChooserTargets; private final ImmutableList mChooserActions; - private final PendingIntent mReselectionAction; + private final PendingIntent mModifyShareAction; private final boolean mRetainInOnStop; @Nullable @@ -142,8 +142,8 @@ public class ChooserRequestParameters { mChooserActions = featureFlags.isEnabled(Flags.SHARESHEET_CUSTOM_ACTIONS) ? getChooserActions(clientIntent) : ImmutableList.of(); - mReselectionAction = featureFlags.isEnabled(Flags.SHARESHEET_RESELECTION_ACTION) - ? getReselectionActionExtra(clientIntent) + mModifyShareAction = featureFlags.isEnabled(Flags.SHARESHEET_RESELECTION_ACTION) + ? getModifyShareAction(clientIntent) : null; } @@ -191,8 +191,8 @@ public class ChooserRequestParameters { } @Nullable - public PendingIntent getReselectionAction() { - return mReselectionAction; + public PendingIntent getModifyShareAction() { + return mModifyShareAction; } /** @@ -335,15 +335,15 @@ public class ChooserRequestParameters { } @Nullable - private static PendingIntent getReselectionActionExtra(Intent intent) { + private static PendingIntent getModifyShareAction(Intent intent) { try { return intent.getParcelableExtra( - Intent.EXTRA_CHOOSER_PAYLOAD_RESELECTION_ACTION, + Intent.EXTRA_CHOOSER_MODIFY_SHARE_ACTION, PendingIntent.class); } catch (Throwable t) { Log.w( TAG, - "Unable to retrieve Intent.EXTRA_CHOOSER_PAYLOAD_RESELECTION_ACTION argument", + "Unable to retrieve Intent.EXTRA_CHOOSER_MODIFY_SHARE_ACTION argument", t); return null; } diff --git a/java/tests/src/com/android/intentresolver/UnbundledChooserActivityTest.java b/java/tests/src/com/android/intentresolver/UnbundledChooserActivityTest.java index c90f0b63..17fd5bd9 100644 --- a/java/tests/src/com/android/intentresolver/UnbundledChooserActivityTest.java +++ b/java/tests/src/com/android/intentresolver/UnbundledChooserActivityTest.java @@ -1998,7 +1998,7 @@ public class UnbundledChooserActivityTest { } @Test - public void testLaunchWithPayloadReselection() throws InterruptedException { + public void testLaunchWithShareModification() throws InterruptedException { ChooserActivityOverrideData.getInstance().featureFlagRepository = new TestFeatureFlagRepository( Collections.singletonMap(Flags.SHARESHEET_RESELECTION_ACTION, true)); @@ -2015,14 +2015,14 @@ public class UnbundledChooserActivityTest { .thenReturn(resolvedComponentInfos); Context testContext = InstrumentationRegistry.getInstrumentation().getContext(); - final String reselectionAction = "test-broadcast-receiver-action"; + final String modifyShareAction = "test-broadcast-receiver-action"; Intent chooserIntent = Intent.createChooser(createSendTextIntent(), null); chooserIntent.putExtra( - Intent.EXTRA_CHOOSER_PAYLOAD_RESELECTION_ACTION, + Intent.EXTRA_CHOOSER_MODIFY_SHARE_ACTION, PendingIntent.getBroadcast( testContext, 123, - new Intent(reselectionAction), + new Intent(modifyShareAction), PendingIntent.FLAG_IMMUTABLE | PendingIntent.FLAG_ONE_SHOT)); // Start activity mActivityRule.launchActivity(chooserIntent); @@ -2035,7 +2035,7 @@ public class UnbundledChooserActivityTest { broadcastInvoked.countDown(); } }; - testContext.registerReceiver(testReceiver, new IntentFilter(reselectionAction)); + testContext.registerReceiver(testReceiver, new IntentFilter(modifyShareAction)); try { onView(withText(R.string.select_text)).perform(click()); -- cgit v1.2.3-59-g8ed1b From 9a733ee98ed889b80c6182fa95fa123c8bbdb7b7 Mon Sep 17 00:00:00 2001 From: Joshua Trask Date: Mon, 13 Feb 2023 19:10:50 +0000 Subject: Extract ChooserActionFactory. This was a sizable chunk of code dedicated to one logical set of responsibilities, so it's nice to separate. Test: `atest IntentResolverUnitTests` Bug: 202167050 Change-Id: I3b033e975afeee66e33da38d0dc0eeba768d0ed4 Merged-In: I3b033e975afeee66e33da38d0dc0eeba768d0ed4 (cherry picked from commit 6f3ea1e9310afb2a56c1491148802e1b6154a094) --- .../intentresolver/ChooserActionFactory.java | 477 +++++++++++++++++++++ .../android/intentresolver/ChooserActivity.java | 403 ++--------------- .../ChooserIntegratedDeviceComponents.java | 77 ++++ .../intentresolver/ChooserRequestParameters.java | 20 +- .../intentresolver/ChooserWrapperActivity.java | 19 +- 5 files changed, 626 insertions(+), 370 deletions(-) create mode 100644 java/src/com/android/intentresolver/ChooserActionFactory.java create mode 100644 java/src/com/android/intentresolver/ChooserIntegratedDeviceComponents.java (limited to 'java/src') diff --git a/java/src/com/android/intentresolver/ChooserActionFactory.java b/java/src/com/android/intentresolver/ChooserActionFactory.java new file mode 100644 index 00000000..1fe55890 --- /dev/null +++ b/java/src/com/android/intentresolver/ChooserActionFactory.java @@ -0,0 +1,477 @@ +/* + * Copyright (C) 2023 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.intentresolver; + +import android.annotation.Nullable; +import android.app.Activity; +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.PackageManager.NameNotFoundException; +import android.content.pm.ResolveInfo; +import android.content.res.Resources; +import android.graphics.drawable.Drawable; +import android.net.Uri; +import android.os.Bundle; +import android.service.chooser.ChooserAction; +import android.text.TextUtils; +import android.util.Log; +import android.view.View; + +import com.android.intentresolver.chooser.DisplayResolveInfo; +import com.android.intentresolver.chooser.TargetInfo; +import com.android.intentresolver.flags.FeatureFlagRepository; +import com.android.intentresolver.flags.Flags; +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.concurrent.Callable; +import java.util.function.Consumer; +import java.util.stream.Collectors; + +/** + * 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; + + 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; + private final String mCopyButtonLabel; + private final Drawable mCopyButtonDrawable; + private final Runnable mOnCopyButtonClicked; + private final TargetInfo mEditSharingTarget; + private final Runnable mOnEditButtonClicked; + private final TargetInfo mNearbySharingTarget; + private final Runnable mOnNearbyButtonClicked; + private final ImmutableList mCustomActions; + private final PendingIntent mReselectionIntent; + private final Consumer mExcludeSharedTextAction; + private final Consumer mFinishCallback; + + /** + * @param context + * @param chooserRequest data about the invocation of the current Sharesheet session. + * @param featureFlagRepository feature flags that may control the eligibility of some actions. + * @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. + * @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, + FeatureFlagRepository featureFlagRepository, + ChooserIntegratedDeviceComponents integratedDeviceComponents, + ChooserActivityLogger logger, + Consumer onUpdateSharedTextIsExcluded, + Callable firstVisibleImageQuery, + ActionActivityStarter activityStarter, + Consumer finishCallback) { + this( + context, + context.getString(com.android.internal.R.string.copy), + context.getDrawable(com.android.internal.R.drawable.ic_menu_copy_material), + makeOnCopyRunnable( + context, + chooserRequest.getTargetIntent(), + chooserRequest.getReferrerPackageName(), + finishCallback, + logger), + getEditSharingTarget( + context, + chooserRequest.getTargetIntent(), + integratedDeviceComponents), + makeOnEditRunnable( + getEditSharingTarget( + context, + chooserRequest.getTargetIntent(), + integratedDeviceComponents), + firstVisibleImageQuery, + activityStarter, + logger), + getNearbySharingTarget( + context, + chooserRequest.getTargetIntent(), + integratedDeviceComponents), + makeOnNearbyShareRunnable( + getNearbySharingTarget( + context, + chooserRequest.getTargetIntent(), + integratedDeviceComponents), + activityStarter, + finishCallback, + logger), + chooserRequest.getChooserActions(), + (featureFlagRepository.isEnabled(Flags.SHARESHEET_RESELECTION_ACTION) + ? chooserRequest.getModifyShareAction() : null), + onUpdateSharedTextIsExcluded, + finishCallback); + } + + @VisibleForTesting + ChooserActionFactory( + Context context, + String copyButtonLabel, + Drawable copyButtonDrawable, + Runnable onCopyButtonClicked, + TargetInfo editSharingTarget, + Runnable onEditButtonClicked, + TargetInfo nearbySharingTarget, + Runnable onNearbyButtonClicked, + List customActions, + @Nullable PendingIntent reselectionIntent, + Consumer onUpdateSharedTextIsExcluded, + Consumer finishCallback) { + mContext = context; + mCopyButtonLabel = copyButtonLabel; + mCopyButtonDrawable = copyButtonDrawable; + mOnCopyButtonClicked = onCopyButtonClicked; + mEditSharingTarget = editSharingTarget; + mOnEditButtonClicked = onEditButtonClicked; + mNearbySharingTarget = nearbySharingTarget; + mOnNearbyButtonClicked = onNearbyButtonClicked; + mCustomActions = ImmutableList.copyOf(customActions); + mReselectionIntent = reselectionIntent; + mExcludeSharedTextAction = onUpdateSharedTextIsExcluded; + mFinishCallback = finishCallback; + } + + /** Create an action that copies the share content to the clipboard. */ + @Override + public ActionRow.Action createCopyButton() { + return new ActionRow.Action( + com.android.internal.R.id.chooser_copy_button, + mCopyButtonLabel, + mCopyButtonDrawable, + mOnCopyButtonClicked); + } + + /** Create an action that opens the share content in a system-default editor. */ + @Override + @Nullable + public ActionRow.Action createEditButton() { + if (mEditSharingTarget == null) { + return null; + } + + return new ActionRow.Action( + com.android.internal.R.id.chooser_edit_button, + mEditSharingTarget.getDisplayLabel(), + mEditSharingTarget.getDisplayIconHolder().getDisplayIcon(), + mOnEditButtonClicked); + } + + /** Create a "Share to Nearby" action. */ + @Override + @Nullable + public ActionRow.Action createNearbyButton() { + if (mNearbySharingTarget == null) { + return null; + } + + return new ActionRow.Action( + com.android.internal.R.id.chooser_nearby_button, + mNearbySharingTarget.getDisplayLabel(), + mNearbySharingTarget.getDisplayIconHolder().getDisplayIcon(), + mOnNearbyButtonClicked); + } + + /** Create custom actions */ + @Override + public List createCustomActions() { + return mCustomActions.stream() + .map(target -> createCustomAction(mContext, target, mFinishCallback)) + .filter(action -> action != null) + .collect(Collectors.toList()); + } + + /** + * Provides a share modification action, if any. + */ + @Override + @Nullable + public Runnable getModifyShareAction() { + return (mReselectionIntent == null) ? null : createReselectionRunnable(mReselectionIntent); + } + + private Runnable createReselectionRunnable(PendingIntent pendingIntent) { + return () -> { + try { + pendingIntent.send(); + } catch (PendingIntent.CanceledException e) { + Log.d(TAG, "Payload reselection action has been cancelled"); + } + // TODO: add reporting + mFinishCallback.accept(Activity.RESULT_OK); + }; + } + + /** + *

+ * 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; + } + + private static Runnable makeOnCopyRunnable( + Context context, + Intent targetIntent, + String referrerPackageName, + Consumer finishCallback, + ChooserActivityLogger logger) { + return () -> { + if (targetIntent == null) { + finishCallback.accept(null); + return; + } + + final String action = targetIntent.getAction(); + + ClipData clipData = null; + if (Intent.ACTION_SEND.equals(action)) { + String extraText = targetIntent.getStringExtra(Intent.EXTRA_TEXT); + Uri extraStream = targetIntent.getParcelableExtra(Intent.EXTRA_STREAM); + + if (extraText != null) { + clipData = ClipData.newPlainText(null, extraText); + } else if (extraStream != null) { + clipData = ClipData.newUri(context.getContentResolver(), null, extraStream); + } else { + Log.w(TAG, "No data available to copy to clipboard"); + return; + } + } else if (Intent.ACTION_SEND_MULTIPLE.equals(action)) { + final ArrayList streams = targetIntent.getParcelableArrayListExtra( + Intent.EXTRA_STREAM); + clipData = ClipData.newUri(context.getContentResolver(), null, streams.get(0)); + for (int i = 1; i < streams.size(); i++) { + clipData.addItem( + context.getContentResolver(), + new ClipData.Item(streams.get(i))); + } + } else { + // expected to only be visible with ACTION_SEND or ACTION_SEND_MULTIPLE + // so warn about unexpected action + Log.w(TAG, "Action (" + action + ") not supported for copying to clipboard"); + return; + } + + ClipboardManager clipboardManager = (ClipboardManager) context.getSystemService( + Context.CLIPBOARD_SERVICE); + clipboardManager.setPrimaryClipAsPackage(clipData, referrerPackageName); + + logger.logActionSelected(ChooserActivityLogger.SELECTION_TYPE_COPY); + finishCallback.accept(Activity.RESULT_OK); + }; + } + + private static TargetInfo getEditSharingTarget( + Context context, + Intent originalIntent, + ChooserIntegratedDeviceComponents integratedComponents) { + final ComponentName editorComponent = integratedComponents.getEditSharingComponent(); + + final Intent resolveIntent = new Intent(originalIntent); + // Retain only URI permission grant flags if present. Other flags may prevent the scene + // transition animation from running (i.e FLAG_ACTIVITY_NO_ANIMATION, + // FLAG_ACTIVITY_NEW_TASK, FLAG_ACTIVITY_NEW_DOCUMENT) but also not needed. + resolveIntent.setFlags(originalIntent.getFlags() & URI_PERMISSION_INTENT_FLAGS); + resolveIntent.setComponent(editorComponent); + resolveIntent.setAction(Intent.ACTION_EDIT); + 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 (" + editorComponent + ") not available"); + return null; + } + + final DisplayResolveInfo dri = DisplayResolveInfo.newDisplayResolveInfo( + originalIntent, + ri, + context.getString(com.android.internal.R.string.screenshot_edit), + "", + resolveIntent, + null); + dri.getDisplayIconHolder().setDisplayIcon( + context.getDrawable(com.android.internal.R.drawable.ic_screenshot_edit)); + return dri; + } + + private static Runnable makeOnEditRunnable( + TargetInfo editSharingTarget, + Callable firstVisibleImageQuery, + ActionActivityStarter activityStarter, + ChooserActivityLogger logger) { + return () -> { + // Log share completion via edit. + logger.logActionSelected(ChooserActivityLogger.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); + } + }; + } + + private static TargetInfo getNearbySharingTarget( + Context context, + Intent originalIntent, + ChooserIntegratedDeviceComponents integratedComponents) { + final ComponentName cn = integratedComponents.getNearbySharingComponent(); + if (cn == null) return null; + + final Intent resolveIntent = new Intent(originalIntent); + resolveIntent.setComponent(cn); + final ResolveInfo ri = context.getPackageManager().resolveActivity( + resolveIntent, PackageManager.GET_META_DATA); + if (ri == null || ri.activityInfo == null) { + Log.e(TAG, "Device-specified nearby sharing component (" + cn + + ") not available"); + return null; + } + + // Allow the nearby sharing component to provide a more appropriate icon and label + // for the chip. + CharSequence name = null; + Drawable icon = null; + final Bundle metaData = ri.activityInfo.metaData; + if (metaData != null) { + try { + final Resources pkgRes = context.getPackageManager().getResourcesForActivity(cn); + final int nameResId = metaData.getInt(CHIP_LABEL_METADATA_KEY); + name = pkgRes.getString(nameResId); + final int resId = metaData.getInt(CHIP_ICON_METADATA_KEY); + icon = pkgRes.getDrawable(resId); + } catch (NameNotFoundException | Resources.NotFoundException ex) { /* ignore */ } + } + if (TextUtils.isEmpty(name)) { + name = ri.loadLabel(context.getPackageManager()); + } + if (icon == null) { + icon = ri.loadIcon(context.getPackageManager()); + } + + final DisplayResolveInfo dri = DisplayResolveInfo.newDisplayResolveInfo( + originalIntent, ri, name, "", resolveIntent, null); + dri.getDisplayIconHolder().setDisplayIcon(icon); + return dri; + } + + private static Runnable makeOnNearbyShareRunnable( + TargetInfo nearbyShareTarget, + ActionActivityStarter activityStarter, + Consumer finishCallback, + ChooserActivityLogger logger) { + return () -> { + logger.logActionSelected(ChooserActivityLogger.SELECTION_TYPE_NEARBY); + // Action bar is user-independent; always start as primary. + activityStarter.safelyStartActivityAsPersonalProfileUser(nearbyShareTarget); + }; + } + + @Nullable + private static ActionRow.Action createCustomAction( + Context context, ChooserAction action, Consumer finishCallback) { + 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(); + } catch (PendingIntent.CanceledException e) { + Log.d(TAG, "Custom action, " + action.getLabel() + ", has been cancelled"); + } + // TODO: add reporting + finishCallback.accept(Activity.RESULT_OK); + } + ); + } +} diff --git a/java/src/com/android/intentresolver/ChooserActivity.java b/java/src/com/android/intentresolver/ChooserActivity.java index 34390770..a2f2bbde 100644 --- a/java/src/com/android/intentresolver/ChooserActivity.java +++ b/java/src/com/android/intentresolver/ChooserActivity.java @@ -32,13 +32,10 @@ import android.annotation.Nullable; import android.app.Activity; import android.app.ActivityManager; import android.app.ActivityOptions; -import android.app.PendingIntent; import android.app.prediction.AppPredictor; import android.app.prediction.AppTarget; import android.app.prediction.AppTargetEvent; import android.app.prediction.AppTargetId; -import android.content.ClipData; -import android.content.ClipboardManager; import android.content.ComponentName; import android.content.ContentResolver; import android.content.Context; @@ -49,31 +46,24 @@ import android.content.IntentSender.SendIntentException; import android.content.SharedPreferences; import android.content.pm.ActivityInfo; import android.content.pm.PackageManager; -import android.content.pm.PackageManager.NameNotFoundException; import android.content.pm.ResolveInfo; import android.content.pm.ShortcutInfo; import android.content.res.Configuration; -import android.content.res.Resources; import android.database.Cursor; import android.graphics.Insets; -import android.graphics.drawable.Drawable; import android.net.Uri; import android.os.Bundle; import android.os.Environment; import android.os.Handler; import android.os.Parcel; import android.os.Parcelable; -import android.os.PatternMatcher; import android.os.ResultReceiver; import android.os.SystemClock; import android.os.UserHandle; import android.os.UserManager; import android.os.storage.StorageManager; import android.provider.DeviceConfig; -import android.provider.Settings; -import android.service.chooser.ChooserAction; import android.service.chooser.ChooserTarget; -import android.text.TextUtils; import android.util.Log; import android.util.Slog; import android.util.SparseArray; @@ -100,7 +90,6 @@ import com.android.intentresolver.chooser.MultiDisplayResolveInfo; import com.android.intentresolver.chooser.TargetInfo; import com.android.intentresolver.flags.FeatureFlagRepository; import com.android.intentresolver.flags.FeatureFlagRepositoryFactory; -import com.android.intentresolver.flags.Flags; import com.android.intentresolver.grid.ChooserGridAdapter; import com.android.intentresolver.grid.DirectShareViewHolder; import com.android.intentresolver.model.AbstractResolverComparator; @@ -108,7 +97,6 @@ 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.ActionRow; import com.android.intentresolver.widget.ResolverDrawerLayout; import com.android.internal.annotations.VisibleForTesting; import com.android.internal.config.sysui.SystemUiDeviceConfigFlags; @@ -116,8 +104,6 @@ import com.android.internal.content.PackageMonitor; import com.android.internal.logging.nano.MetricsProto.MetricsEvent; import com.android.internal.util.FrameworkStatsLog; -import com.google.common.collect.ImmutableList; - import java.io.File; import java.lang.annotation.Retention; import java.lang.annotation.RetentionPolicy; @@ -210,6 +196,8 @@ public class ChooserActivity extends ResolverActivity implements | Intent.FLAG_GRANT_PERSISTABLE_URI_PERMISSION | Intent.FLAG_GRANT_PREFIX_URI_PERMISSION; + 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 @@ -220,6 +208,7 @@ public class ChooserActivity extends ResolverActivity implements private ChooserRequestParameters mChooserRequest; private FeatureFlagRepository mFeatureFlagRepository; + private ChooserActionFactory mChooserActionFactory; private ChooserContentPreviewUi mChooserContentPreviewUi; private boolean mShouldDisplayLandscape; @@ -274,11 +263,14 @@ public class ChooserActivity extends ResolverActivity implements getChooserActivityLogger().logSharesheetTriggered(); mFeatureFlagRepository = createFeatureFlagRepository(); + mIntegratedDeviceComponents = getIntegratedDeviceComponents(); + try { mChooserRequest = new ChooserRequestParameters( getIntent(), + getReferrerPackageName(), getReferrer(), - getNearbySharingComponent(), + mIntegratedDeviceComponents, mFeatureFlagRepository); } catch (IllegalArgumentException e) { Log.e(TAG, "Caller provided invalid Chooser request parameters", e); @@ -286,6 +278,39 @@ public class ChooserActivity extends ResolverActivity implements super_onCreate(null); return; } + + mChooserActionFactory = new ChooserActionFactory( + this, + mChooserRequest, + mFeatureFlagRepository, + mIntegratedDeviceComponents, + getChooserActivityLogger(), + (isExcluded) -> mExcludeSharedText = isExcluded, + this::getFirstVisibleImgPreviewView, + new ChooserActionFactory.ActionActivityStarter() { + @Override + public void safelyStartActivityAsPersonalProfileUser(TargetInfo targetInfo) { + safelyStartActivityAsUser(targetInfo, getPersonalProfileUserHandle()); + finish(); + } + + @Override + public void safelyStartActivityAsPersonalProfileUserWithSharedElementTransition( + TargetInfo targetInfo, View sharedElement, String sharedElementName) { + ActivityOptions options = ActivityOptions.makeSceneTransitionAnimation( + ChooserActivity.this, sharedElement, sharedElementName); + safelyStartActivityAsUser( + targetInfo, getPersonalProfileUserHandle(), options.toBundle()); + startFinishAnimation(); + } + }, + (status) -> { + if (status != null) { + setResult(status); + } + finish(); + }); + mChooserContentPreviewUi = new ChooserContentPreviewUi(mFeatureFlagRepository); setAdditionalTargets(mChooserRequest.getAdditionalTargets()); @@ -368,6 +393,11 @@ public class ChooserActivity extends ResolverActivity implements mEnterTransitionAnimationDelegate.postponeTransition(); } + @VisibleForTesting + protected ChooserIntegratedDeviceComponents getIntegratedDeviceComponents() { + return ChooserIntegratedDeviceComponents.get(this); + } + @Override protected int appliedThemeResId() { return R.style.Theme_DeviceDefault_Chooser; @@ -607,51 +637,6 @@ public class ChooserActivity extends ResolverActivity implements updateProfileViewButton(); } - private void onCopyButtonClicked() { - Intent targetIntent = getTargetIntent(); - if (targetIntent == null) { - finish(); - } else { - final String action = targetIntent.getAction(); - - ClipData clipData = null; - if (Intent.ACTION_SEND.equals(action)) { - String extraText = targetIntent.getStringExtra(Intent.EXTRA_TEXT); - Uri extraStream = targetIntent.getParcelableExtra(Intent.EXTRA_STREAM); - - if (extraText != null) { - clipData = ClipData.newPlainText(null, extraText); - } else if (extraStream != null) { - clipData = ClipData.newUri(getContentResolver(), null, extraStream); - } else { - Log.w(TAG, "No data available to copy to clipboard"); - return; - } - } else if (Intent.ACTION_SEND_MULTIPLE.equals(action)) { - final ArrayList streams = targetIntent.getParcelableArrayListExtra( - Intent.EXTRA_STREAM); - clipData = ClipData.newUri(getContentResolver(), null, streams.get(0)); - for (int i = 1; i < streams.size(); i++) { - clipData.addItem(getContentResolver(), new ClipData.Item(streams.get(i))); - } - } else { - // expected to only be visible with ACTION_SEND or ACTION_SEND_MULTIPLE - // so warn about unexpected action - Log.w(TAG, "Action (" + action + ") not supported for copying to clipboard"); - return; - } - - ClipboardManager clipboardManager = (ClipboardManager) getSystemService( - Context.CLIPBOARD_SERVICE); - clipboardManager.setPrimaryClipAsPackage(clipData, getReferrerPackageName()); - - getChooserActivityLogger().logActionSelected(ChooserActivityLogger.SELECTION_TYPE_COPY); - - setResult(RESULT_OK); - finish(); - } - } - @Override protected void onResume() { super.onResume(); @@ -728,64 +713,12 @@ public class ChooserActivity extends ResolverActivity implements int previewType = ChooserContentPreviewUi.findPreferredContentPreview( targetIntent, getContentResolver(), this::isImageType); - ChooserContentPreviewUi.ActionFactory actionFactory = - new ChooserContentPreviewUi.ActionFactory() { - @Override - public ActionRow.Action createCopyButton() { - return ChooserActivity.this.createCopyAction(); - } - - @Nullable - @Override - public ActionRow.Action createEditButton() { - return ChooserActivity.this.createEditAction(targetIntent); - } - - @Nullable - @Override - public ActionRow.Action createNearbyButton() { - return ChooserActivity.this.createNearbyAction(targetIntent); - } - - @Override - public List createCustomActions() { - ImmutableList customActions = - mChooserRequest.getChooserActions(); - List actions = new ArrayList<>(customActions.size()); - for (ChooserAction customAction : customActions) { - ActionRow.Action action = createCustomAction(customAction); - if (action != null) { - actions.add(action); - } - } - return actions; - } - - @Nullable - @Override - public Runnable getModifyShareAction() { - if (!mFeatureFlagRepository - .isEnabled(Flags.SHARESHEET_RESELECTION_ACTION)) { - return null; - } - PendingIntent reselectionAction = mChooserRequest.getModifyShareAction(); - return reselectionAction == null - ? null - : createReselectionRunnable(reselectionAction); - } - - @Override - public Consumer getExcludeSharedTextAction() { - return (isExcluded) -> mExcludeSharedText = isExcluded; - } - }; - ViewGroup layout = mChooserContentPreviewUi.displayContentPreview( previewType, targetIntent, getResources(), getLayoutInflater(), - actionFactory, + mChooserActionFactory, parent, imageLoader, mEnterTransitionAnimationDelegate, @@ -799,211 +732,6 @@ public class ChooserActivity extends ResolverActivity implements return layout; } - @VisibleForTesting - protected ComponentName getNearbySharingComponent() { - String nearbyComponent = Settings.Secure.getString( - getContentResolver(), - Settings.Secure.NEARBY_SHARING_COMPONENT); - if (TextUtils.isEmpty(nearbyComponent)) { - nearbyComponent = getString(R.string.config_defaultNearbySharingComponent); - } - if (TextUtils.isEmpty(nearbyComponent)) { - return null; - } - return ComponentName.unflattenFromString(nearbyComponent); - } - - @VisibleForTesting - protected @Nullable ComponentName getEditSharingComponent() { - String editorPackage = getApplicationContext().getString(R.string.config_systemImageEditor); - if (editorPackage == null || TextUtils.isEmpty(editorPackage)) { - return null; - } - return ComponentName.unflattenFromString(editorPackage); - } - - @VisibleForTesting - protected TargetInfo getEditSharingTarget(Intent originalIntent) { - final ComponentName cn = getEditSharingComponent(); - - final Intent resolveIntent = new Intent(originalIntent); - // Retain only URI permission grant flags if present. Other flags may prevent the scene - // transition animation from running (i.e FLAG_ACTIVITY_NO_ANIMATION, - // FLAG_ACTIVITY_NEW_TASK, FLAG_ACTIVITY_NEW_DOCUMENT) but also not needed. - resolveIntent.setFlags(originalIntent.getFlags() & URI_PERMISSION_INTENT_FLAGS); - resolveIntent.setComponent(cn); - resolveIntent.setAction(Intent.ACTION_EDIT); - 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 = getContentResolver().getType(uri); - resolveIntent.setDataAndType(uri, mimeType); - } - } - } else { - Log.e(TAG, originalAction + " is not supported."); - return null; - } - final ResolveInfo ri = getPackageManager().resolveActivity( - resolveIntent, PackageManager.GET_META_DATA); - if (ri == null || ri.activityInfo == null) { - Log.e(TAG, "Device-specified image edit component (" + cn - + ") not available"); - return null; - } - - final DisplayResolveInfo dri = DisplayResolveInfo.newDisplayResolveInfo( - originalIntent, - ri, - getString(com.android.internal.R.string.screenshot_edit), - "", - resolveIntent, - null); - dri.getDisplayIconHolder().setDisplayIcon( - getDrawable(com.android.internal.R.drawable.ic_screenshot_edit)); - return dri; - } - - @VisibleForTesting - protected TargetInfo getNearbySharingTarget(Intent originalIntent) { - final ComponentName cn = getNearbySharingComponent(); - if (cn == null) return null; - - final Intent resolveIntent = new Intent(originalIntent); - resolveIntent.setComponent(cn); - final ResolveInfo ri = getPackageManager().resolveActivity( - resolveIntent, PackageManager.GET_META_DATA); - if (ri == null || ri.activityInfo == null) { - Log.e(TAG, "Device-specified nearby sharing component (" + cn - + ") not available"); - return null; - } - - // Allow the nearby sharing component to provide a more appropriate icon and label - // for the chip. - CharSequence name = null; - Drawable icon = null; - final Bundle metaData = ri.activityInfo.metaData; - if (metaData != null) { - try { - final Resources pkgRes = getPackageManager().getResourcesForActivity(cn); - final int nameResId = metaData.getInt(CHIP_LABEL_METADATA_KEY); - name = pkgRes.getString(nameResId); - final int resId = metaData.getInt(CHIP_ICON_METADATA_KEY); - icon = pkgRes.getDrawable(resId); - } catch (Resources.NotFoundException ex) { - } catch (NameNotFoundException ex) { - } - } - if (TextUtils.isEmpty(name)) { - name = ri.loadLabel(getPackageManager()); - } - if (icon == null) { - icon = ri.loadIcon(getPackageManager()); - } - - final DisplayResolveInfo dri = DisplayResolveInfo.newDisplayResolveInfo( - originalIntent, ri, name, "", resolveIntent, null); - dri.getDisplayIconHolder().setDisplayIcon(icon); - return dri; - } - - private ActionRow.Action createCopyAction() { - return new ActionRow.Action( - com.android.internal.R.id.chooser_copy_button, - getString(com.android.internal.R.string.copy), - getDrawable(com.android.internal.R.drawable.ic_menu_copy_material), - this::onCopyButtonClicked); - } - - @Nullable - private ActionRow.Action createNearbyAction(Intent originalIntent) { - final TargetInfo ti = getNearbySharingTarget(originalIntent); - if (ti == null) { - return null; - } - - return new ActionRow.Action( - com.android.internal.R.id.chooser_nearby_button, - ti.getDisplayLabel(), - ti.getDisplayIconHolder().getDisplayIcon(), - () -> { - getChooserActivityLogger().logActionSelected( - ChooserActivityLogger.SELECTION_TYPE_NEARBY); - // Action bar is user-independent, always start as primary - safelyStartActivityAsUser(ti, getPersonalProfileUserHandle()); - finish(); - }); - } - - @Nullable - private ActionRow.Action createEditAction(Intent originalIntent) { - final TargetInfo ti = getEditSharingTarget(originalIntent); - if (ti == null) { - return null; - } - - return new ActionRow.Action( - com.android.internal.R.id.chooser_edit_button, - ti.getDisplayLabel(), - ti.getDisplayIconHolder().getDisplayIcon(), - () -> { - // Log share completion via edit - getChooserActivityLogger().logActionSelected( - ChooserActivityLogger.SELECTION_TYPE_EDIT); - View firstImgView = getFirstVisibleImgPreviewView(); - // Action bar is user-independent, always start as primary - if (firstImgView == null) { - safelyStartActivityAsUser(ti, getPersonalProfileUserHandle()); - finish(); - } else { - ActivityOptions options = ActivityOptions.makeSceneTransitionAnimation( - this, firstImgView, IMAGE_EDITOR_SHARED_ELEMENT); - safelyStartActivityAsUser( - ti, getPersonalProfileUserHandle(), options.toBundle()); - startFinishAnimation(); - } - } - ); - } - - @Nullable - private ActionRow.Action createCustomAction(ChooserAction action) { - Drawable icon = action.getIcon().loadDrawable(this); - if (icon == null && TextUtils.isEmpty(action.getLabel())) { - return null; - } - return new ActionRow.Action( - action.getLabel(), - icon, - () -> { - try { - action.getAction().send(); - } catch (PendingIntent.CanceledException e) { - Log.d(TAG, "Custom action, " + action.getLabel() + ", has been cancelled"); - } - // TODO: add reporting - setResult(RESULT_OK); - finish(); - } - ); - } - - private Runnable createReselectionRunnable(PendingIntent pendingIntent) { - return () -> { - try { - pendingIntent.send(); - } catch (PendingIntent.CanceledException e) { - Log.d(TAG, "Payload reselection action has been cancelled"); - } - // TODO: add reporting - setResult(RESULT_OK); - finish(); - }; - } - @Nullable private View getFirstVisibleImgPreviewView() { View firstImage = findViewById(com.android.internal.R.id.content_preview_image_1_large); @@ -1315,45 +1043,6 @@ public class ChooserActivity extends ResolverActivity implements } } - private IntentFilter getTargetIntentFilter() { - return getTargetIntentFilter(getTargetIntent()); - } - - private IntentFilter getTargetIntentFilter(final Intent intent) { - try { - String dataString = intent.getDataString(); - if (intent.getType() == null) { - if (!TextUtils.isEmpty(dataString)) { - return new IntentFilter(intent.getAction(), dataString); - } - Log.e(TAG, "Failed to get target intent filter: intent data and type are null"); - return null; - } - IntentFilter intentFilter = new IntentFilter(intent.getAction(), intent.getType()); - List contentUris = new ArrayList<>(); - if (Intent.ACTION_SEND.equals(intent.getAction())) { - Uri uri = (Uri) intent.getParcelableExtra(Intent.EXTRA_STREAM); - if (uri != null) { - contentUris.add(uri); - } - } else { - List uris = intent.getParcelableArrayListExtra(Intent.EXTRA_STREAM); - if (uris != null) { - contentUris.addAll(uris); - } - } - for (Uri uri : contentUris) { - intentFilter.addDataScheme(uri.getScheme()); - intentFilter.addDataAuthority(uri.getAuthority(), null); - intentFilter.addDataPath(uri.getPath(), PatternMatcher.PATTERN_LITERAL); - } - return intentFilter; - } catch (Exception e) { - Log.e(TAG, "Failed to get target intent filter", e); - return null; - } - } - private void logDirectShareTargetReceived(UserHandle forUser) { ProfileRecord profileRecord = getProfileRecord(forUser); if (profileRecord == null) { diff --git a/java/src/com/android/intentresolver/ChooserIntegratedDeviceComponents.java b/java/src/com/android/intentresolver/ChooserIntegratedDeviceComponents.java new file mode 100644 index 00000000..9b124c20 --- /dev/null +++ b/java/src/com/android/intentresolver/ChooserIntegratedDeviceComponents.java @@ -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; + +import android.annotation.Nullable; +import android.content.ComponentName; +import android.content.Context; +import android.provider.Settings; +import android.text.TextUtils; + +import com.android.internal.annotations.VisibleForTesting; + +/** + * Helper to look up the components available on this device to handle assorted built-in actions + * like "Edit" that may be displayed for certain content/preview types. The components are queried + * when this record is instantiated, and are then immutable for a given instance. + * + * Because this describes the app's external execution environment, test methods may prefer to + * provide explicit values to override the default lookup logic. + */ +public final class ChooserIntegratedDeviceComponents { + @Nullable + private final ComponentName mEditSharingComponent; + + @Nullable + private final ComponentName mNearbySharingComponent; + + /** Look up the integrated components available on this device. */ + public static ChooserIntegratedDeviceComponents get(Context context) { + return new ChooserIntegratedDeviceComponents( + getEditSharingComponent(context), + getNearbySharingComponent(context)); + } + + @VisibleForTesting + ChooserIntegratedDeviceComponents( + ComponentName editSharingComponent, ComponentName nearbySharingComponent) { + mEditSharingComponent = editSharingComponent; + mNearbySharingComponent = nearbySharingComponent; + } + + public ComponentName getEditSharingComponent() { + return mEditSharingComponent; + } + + public ComponentName getNearbySharingComponent() { + return mNearbySharingComponent; + } + + private static ComponentName getEditSharingComponent(Context context) { + String editorComponent = context.getApplicationContext().getString( + R.string.config_systemImageEditor); + return TextUtils.isEmpty(editorComponent) + ? null : ComponentName.unflattenFromString(editorComponent); + } + + private static ComponentName getNearbySharingComponent(Context context) { + String nearbyComponent = Settings.Secure.getString( + context.getContentResolver(), Settings.Secure.NEARBY_SHARING_COMPONENT); + return TextUtils.isEmpty(nearbyComponent) + ? null : ComponentName.unflattenFromString(nearbyComponent); + } +} diff --git a/java/src/com/android/intentresolver/ChooserRequestParameters.java b/java/src/com/android/intentresolver/ChooserRequestParameters.java index 2b67b273..83a0e2e1 100644 --- a/java/src/com/android/intentresolver/ChooserRequestParameters.java +++ b/java/src/com/android/intentresolver/ChooserRequestParameters.java @@ -71,6 +71,8 @@ public class ChooserRequestParameters { Intent.FLAG_ACTIVITY_NEW_DOCUMENT | Intent.FLAG_ACTIVITY_MULTIPLE_TASK; private final Intent mTarget; + private final ChooserIntegratedDeviceComponents mIntegratedDeviceComponents; + private final String mReferrerPackageName; private final Pair mTitleSpec; private final Intent mReferrerFillInIntent; private final ImmutableList mFilteredComponentNames; @@ -102,13 +104,18 @@ public class ChooserRequestParameters { public ChooserRequestParameters( final Intent clientIntent, + String referrerPackageName, final Uri referrer, - @Nullable final ComponentName nearbySharingComponent, + ChooserIntegratedDeviceComponents integratedDeviceComponents, FeatureFlagRepository featureFlags) { final Intent requestedTarget = parseTargetIntentExtra( clientIntent.getParcelableExtra(Intent.EXTRA_INTENT)); mTarget = intentWithModifiedLaunchFlags(requestedTarget); + mIntegratedDeviceComponents = integratedDeviceComponents; + + mReferrerPackageName = referrerPackageName; + mAdditionalTargets = intentsWithModifiedLaunchFlagsFromExtraIfPresent( clientIntent, Intent.EXTRA_ALTERNATE_INTENTS); @@ -128,7 +135,8 @@ public class ChooserRequestParameters { mRefinementIntentSender = clientIntent.getParcelableExtra( Intent.EXTRA_CHOOSER_REFINEMENT_INTENT_SENDER); - mFilteredComponentNames = getFilteredComponentNames(clientIntent, nearbySharingComponent); + mFilteredComponentNames = getFilteredComponentNames( + clientIntent, mIntegratedDeviceComponents.getNearbySharingComponent()); mCallerChooserTargets = parseCallerTargetsFromClientIntent(clientIntent); @@ -165,6 +173,10 @@ public class ChooserRequestParameters { return getTargetIntent().getType(); } + public String getReferrerPackageName() { + return mReferrerPackageName; + } + @Nullable public CharSequence getTitle() { return mTitleSpec.first; @@ -245,6 +257,10 @@ public class ChooserRequestParameters { return mTargetIntentFilter; } + public ChooserIntegratedDeviceComponents getIntegratedDeviceComponents() { + return mIntegratedDeviceComponents; + } + private static boolean isSendAction(@Nullable String action) { return (Intent.ACTION_SEND.equals(action) || Intent.ACTION_SEND_MULTIPLE.equals(action)); } diff --git a/java/tests/src/com/android/intentresolver/ChooserWrapperActivity.java b/java/tests/src/com/android/intentresolver/ChooserWrapperActivity.java index a47014e8..17084e1c 100644 --- a/java/tests/src/com/android/intentresolver/ChooserWrapperActivity.java +++ b/java/tests/src/com/android/intentresolver/ChooserWrapperActivity.java @@ -37,7 +37,6 @@ import com.android.intentresolver.AbstractMultiProfilePagerAdapter.CrossProfileI import com.android.intentresolver.AbstractMultiProfilePagerAdapter.MyUserIdProvider; import com.android.intentresolver.AbstractMultiProfilePagerAdapter.QuietModeManager; import com.android.intentresolver.chooser.DisplayResolveInfo; -import com.android.intentresolver.chooser.NotSelectableTargetInfo; import com.android.intentresolver.chooser.TargetInfo; import com.android.intentresolver.flags.FeatureFlagRepository; import com.android.intentresolver.grid.ChooserGridAdapter; @@ -120,15 +119,13 @@ public class ChooserWrapperActivity } @Override - protected ComponentName getNearbySharingComponent() { - // an arbitrary pre-installed activity that handles this type of intent - return ComponentName.unflattenFromString("com.google.android.apps.messaging/" - + "com.google.android.apps.messaging.ui.conversationlist.ShareIntentActivity"); - } - - @Override - protected TargetInfo getNearbySharingTarget(Intent originalIntent) { - return NotSelectableTargetInfo.newEmptyTargetInfo(); + 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 @@ -172,7 +169,7 @@ public class ChooserWrapperActivity } @Override - public void safelyStartActivity(com.android.intentresolver.chooser.TargetInfo cti) { + public void safelyStartActivity(TargetInfo cti) { if (sOverrides.onSafelyStartCallback != null && sOverrides.onSafelyStartCallback.apply(cti)) { return; -- cgit v1.2.3-59-g8ed1b From f1576aeb415af53da75ca7dbc9413b04c43dee54 Mon Sep 17 00:00:00 2001 From: Joshua Trask Date: Mon, 13 Feb 2023 23:38:29 +0000 Subject: Extract a component to handle refinement. This is in advance of any possible bug-fixes related to b/262805893 (which may probably be accompanied by additional unit tests..) Test: `atest IntentResolverUnitTests` Bug: 202167050 Change-Id: I4c8d20522236559ff99b6e11a7c1a3a0fcbbd17d Merged-In: I4c8d20522236559ff99b6e11a7c1a3a0fcbbd17d (cherry picked from commit c07d3f064db9cf715e36e9b6d4c1cd516e2258ce) --- .../android/intentresolver/ChooserActivity.java | 172 ++--------------- .../intentresolver/ChooserRefinementManager.java | 215 +++++++++++++++++++++ 2 files changed, 236 insertions(+), 151 deletions(-) create mode 100644 java/src/com/android/intentresolver/ChooserRefinementManager.java (limited to 'java/src') diff --git a/java/src/com/android/intentresolver/ChooserActivity.java b/java/src/com/android/intentresolver/ChooserActivity.java index a2f2bbde..65c72fda 100644 --- a/java/src/com/android/intentresolver/ChooserActivity.java +++ b/java/src/com/android/intentresolver/ChooserActivity.java @@ -42,7 +42,6 @@ import android.content.Context; import android.content.Intent; import android.content.IntentFilter; import android.content.IntentSender; -import android.content.IntentSender.SendIntentException; import android.content.SharedPreferences; import android.content.pm.ActivityInfo; import android.content.pm.PackageManager; @@ -54,10 +53,6 @@ import android.graphics.Insets; import android.net.Uri; import android.os.Bundle; import android.os.Environment; -import android.os.Handler; -import android.os.Parcel; -import android.os.Parcelable; -import android.os.ResultReceiver; import android.os.SystemClock; import android.os.UserHandle; import android.os.UserManager; @@ -207,6 +202,8 @@ public class ChooserActivity extends ResolverActivity implements @Nullable private ChooserRequestParameters mChooserRequest; + private ChooserRefinementManager mRefinementManager; + private FeatureFlagRepository mFeatureFlagRepository; private ChooserActionFactory mChooserActionFactory; private ChooserContentPreviewUi mChooserContentPreviewUi; @@ -215,9 +212,6 @@ public class ChooserActivity extends ResolverActivity implements // statsd logger wrapper protected ChooserActivityLogger mChooserActivityLogger; - @Nullable - private RefinementResultReceiver mRefinementResultReceiver; - private long mChooserShownTime; protected boolean mIsSuccessfullySelected; @@ -311,6 +305,20 @@ public class ChooserActivity extends ResolverActivity implements finish(); }); + mRefinementManager = new ChooserRefinementManager( + this, + mChooserRequest.getRefinementIntentSender(), + (validatedRefinedTarget) -> { + maybeRemoveSharedText(validatedRefinedTarget); + if (super.onTargetSelected(validatedRefinedTarget, false)) { + finish(); + } + }, + () -> { + mRefinementManager.destroy(); + finish(); + }); + mChooserContentPreviewUi = new ChooserContentPreviewUi(mFeatureFlagRepository); setAdditionalTargets(mChooserRequest.getAdditionalTargets()); @@ -777,9 +785,9 @@ public class ChooserActivity extends ResolverActivity implements mLatencyTracker.onActionCancel(ACTION_LOAD_SHARE_SHEET); } - if (mRefinementResultReceiver != null) { - mRefinementResultReceiver.destroy(); - mRefinementResultReceiver = null; + if (mRefinementManager != null) { // TODO: null-checked in case of early-destroy, or skip? + mRefinementManager.destroy(); + mRefinementManager = null; } mBackgroundThreadPoolExecutor.shutdownNow(); @@ -903,32 +911,8 @@ public class ChooserActivity extends ResolverActivity implements @Override protected boolean onTargetSelected(TargetInfo target, boolean alwaysCheck) { - if (mChooserRequest.getRefinementIntentSender() != null) { - final Intent fillIn = new Intent(); - final List sourceIntents = target.getAllSourceIntents(); - if (!sourceIntents.isEmpty()) { - fillIn.putExtra(Intent.EXTRA_INTENT, sourceIntents.get(0)); - if (sourceIntents.size() > 1) { - final Intent[] alts = new Intent[sourceIntents.size() - 1]; - for (int i = 1, N = sourceIntents.size(); i < N; i++) { - alts[i - 1] = sourceIntents.get(i); - } - fillIn.putExtra(Intent.EXTRA_ALTERNATE_INTENTS, alts); - } - if (mRefinementResultReceiver != null) { - mRefinementResultReceiver.destroy(); - } - mRefinementResultReceiver = new RefinementResultReceiver(this, target, null); - fillIn.putExtra(Intent.EXTRA_RESULT_RECEIVER, - mRefinementResultReceiver.copyForSending()); - try { - mChooserRequest.getRefinementIntentSender().sendIntent( - this, 0, fillIn, null, null); - return false; - } catch (SendIntentException e) { - Log.e(TAG, "Refinement IntentSender failed to send", e); - } - } + if (mRefinementManager.maybeHandleSelection(target)) { + return false; } updateModelAndChooserCounts(target); maybeRemoveSharedText(target); @@ -1157,47 +1141,6 @@ public class ChooserActivity extends ResolverActivity implements return (record == null) ? null : record.appPredictor; } - void onRefinementResult(TargetInfo selectedTarget, Intent matchingIntent) { - if (mRefinementResultReceiver != null) { - mRefinementResultReceiver.destroy(); - mRefinementResultReceiver = null; - } - if (selectedTarget == null) { - Log.e(TAG, "Refinement result intent did not match any known targets; canceling"); - } else if (!checkTargetSourceIntent(selectedTarget, matchingIntent)) { - Log.e(TAG, "onRefinementResult: Selected target " + selectedTarget - + " cannot match refined source intent " + matchingIntent); - } else { - TargetInfo clonedTarget = selectedTarget.cloneFilledIn(matchingIntent, 0); - maybeRemoveSharedText(clonedTarget); - if (super.onTargetSelected(clonedTarget, false)) { - updateModelAndChooserCounts(clonedTarget); - finish(); - return; - } - } - onRefinementCanceled(); - } - - void onRefinementCanceled() { - if (mRefinementResultReceiver != null) { - mRefinementResultReceiver.destroy(); - mRefinementResultReceiver = null; - } - finish(); - } - - boolean checkTargetSourceIntent(TargetInfo target, Intent matchingIntent) { - final List targetIntents = target.getAllSourceIntents(); - for (int i = 0, N = targetIntents.size(); i < N; i++) { - final Intent targetIntent = targetIntents.get(i); - if (targetIntent.filterEquals(matchingIntent)) { - return true; - } - } - return false; - } - /** * Sort intents alphabetically based on display label. */ @@ -1892,79 +1835,6 @@ public class ChooserActivity extends ResolverActivity implements } } - static class ChooserTargetRankingInfo { - public final List scores; - public final UserHandle userHandle; - - ChooserTargetRankingInfo(List chooserTargetScores, - UserHandle userHandle) { - this.scores = chooserTargetScores; - this.userHandle = userHandle; - } - } - - static class RefinementResultReceiver extends ResultReceiver { - private ChooserActivity mChooserActivity; - private TargetInfo mSelectedTarget; - - public RefinementResultReceiver(ChooserActivity host, TargetInfo target, - Handler handler) { - super(handler); - mChooserActivity = host; - mSelectedTarget = target; - } - - @Override - protected void onReceiveResult(int resultCode, Bundle resultData) { - if (mChooserActivity == null) { - Log.e(TAG, "Destroyed RefinementResultReceiver received a result"); - return; - } - if (resultData == null) { - Log.e(TAG, "RefinementResultReceiver received null resultData"); - return; - } - - switch (resultCode) { - case RESULT_CANCELED: - mChooserActivity.onRefinementCanceled(); - break; - case RESULT_OK: - Parcelable intentParcelable = resultData.getParcelable(Intent.EXTRA_INTENT); - if (intentParcelable instanceof Intent) { - mChooserActivity.onRefinementResult(mSelectedTarget, - (Intent) intentParcelable); - } else { - Log.e(TAG, "RefinementResultReceiver received RESULT_OK but no Intent" - + " in resultData with key Intent.EXTRA_INTENT"); - } - break; - default: - Log.w(TAG, "Unknown result code " + resultCode - + " sent to RefinementResultReceiver"); - break; - } - } - - public void destroy() { - mChooserActivity = null; - mSelectedTarget = null; - } - - /** - * Apps can't load this class directly, so we need a regular ResultReceiver copy for - * sending. Obtain this by parceling and unparceling (one weird trick). - */ - ResultReceiver copyForSending() { - Parcel parcel = Parcel.obtain(); - writeToParcel(parcel, 0); - parcel.setDataPosition(0); - ResultReceiver receiverForSending = ResultReceiver.CREATOR.createFromParcel(parcel); - parcel.recycle(); - return receiverForSending; - } - } - /** * Used in combination with the scene transition when launching the image editor */ diff --git a/java/src/com/android/intentresolver/ChooserRefinementManager.java b/java/src/com/android/intentresolver/ChooserRefinementManager.java new file mode 100644 index 00000000..5997bfed --- /dev/null +++ b/java/src/com/android/intentresolver/ChooserRefinementManager.java @@ -0,0 +1,215 @@ +/* + * Copyright (C) 2023 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.intentresolver; + +import android.annotation.Nullable; +import android.app.Activity; +import android.content.Context; +import android.content.Intent; +import android.content.IntentSender; +import android.content.IntentSender.SendIntentException; +import android.os.Bundle; +import android.os.Parcel; +import android.os.Parcelable; +import android.os.ResultReceiver; +import android.util.Log; + +import com.android.intentresolver.chooser.TargetInfo; + +import java.util.List; +import java.util.function.Consumer; + +/** + * Helper class to manage Sharesheet's "refinement" flow, where callers supply a "refinement + * activity" that will be invoked when a target is selected, allowing the calling app to add + * additional extras and other refinements (subject to {@link Intent#filterEquals()}), e.g., to + * convert the format of the payload, or lazy-download some data that was deferred in the original + * call). + * + * TODO(b/262805893): this currently requires the result to be a refinement of the best + * match for the user's selected target among the initially-provided source intents (according to + * their originally-provided priority order). In order to support alternate formats/actions, we + * should instead require it to refine any of the source intents -- presumably, the first + * in priority order that matches according to {@link Intent#filterEquals()}. + */ +public final class ChooserRefinementManager { + private static final String TAG = "ChooserRefinement"; + + @Nullable + private final IntentSender mRefinementIntentSender; + + private final Context mContext; + private final Consumer mOnSelectionRefined; + private final Runnable mOnRefinementCancelled; + + @Nullable + private RefinementResultReceiver mRefinementResultReceiver; + + public ChooserRefinementManager( + Context context, + @Nullable IntentSender refinementIntentSender, + Consumer onSelectionRefined, + Runnable onRefinementCancelled) { + mContext = context; + mRefinementIntentSender = refinementIntentSender; + mOnSelectionRefined = onSelectionRefined; + mOnRefinementCancelled = onRefinementCancelled; + } + + /** + * Delegate the user's {@code selectedTarget} to the refinement flow, if possible. + * @return true if the selection should wait for a now-started refinement flow, or false if it + * can proceed by the default (non-refinement) logic. + */ + public boolean maybeHandleSelection(TargetInfo selectedTarget) { + if (mRefinementIntentSender == null) { + return false; + } + if (selectedTarget.getAllSourceIntents().isEmpty()) { + return false; + } + + destroy(); // Terminate any prior sessions. + mRefinementResultReceiver = new RefinementResultReceiver( + refinedIntent -> { + destroy(); + TargetInfo refinedTarget = getValidRefinedTarget(selectedTarget, refinedIntent); + if (refinedTarget != null) { + mOnSelectionRefined.accept(refinedTarget); + } else { + mOnRefinementCancelled.run(); + } + }, + mOnRefinementCancelled); + + Intent refinementRequest = makeRefinementRequest(mRefinementResultReceiver, selectedTarget); + try { + mRefinementIntentSender.sendIntent(mContext, 0, refinementRequest, null, null); + return true; + } catch (SendIntentException e) { + Log.e(TAG, "Refinement IntentSender failed to send", e); + } + return false; + } + + /** Clean up any ongoing refinement session. */ + public void destroy() { + if (mRefinementResultReceiver != null) { + mRefinementResultReceiver.destroy(); + mRefinementResultReceiver = null; + } + } + + private static Intent makeRefinementRequest( + RefinementResultReceiver resultReceiver, TargetInfo originalTarget) { + final Intent fillIn = new Intent(); + final List sourceIntents = originalTarget.getAllSourceIntents(); + fillIn.putExtra(Intent.EXTRA_INTENT, sourceIntents.get(0)); + if (sourceIntents.size() > 1) { + fillIn.putExtra( + Intent.EXTRA_ALTERNATE_INTENTS, + sourceIntents.subList(1, sourceIntents.size()).toArray()); + } + fillIn.putExtra(Intent.EXTRA_RESULT_RECEIVER, resultReceiver.copyForSending()); + return fillIn; + } + + private static class RefinementResultReceiver extends ResultReceiver { + private final Consumer mOnSelectionRefined; + private final Runnable mOnRefinementCancelled; + + private boolean mDestroyed; + + RefinementResultReceiver( + Consumer onSelectionRefined, + Runnable onRefinementCancelled) { + super(/* handler=*/ null); + mOnSelectionRefined = onSelectionRefined; + mOnRefinementCancelled = onRefinementCancelled; + } + + public void destroy() { + mDestroyed = true; + } + + @Override + protected void onReceiveResult(int resultCode, Bundle resultData) { + if (mDestroyed) { + Log.e(TAG, "Destroyed RefinementResultReceiver received a result"); + return; + } + if (resultData == null) { + Log.e(TAG, "RefinementResultReceiver received null resultData"); + // TODO: treat as cancellation? + return; + } + + switch (resultCode) { + case Activity.RESULT_CANCELED: + mOnRefinementCancelled.run(); + break; + case Activity.RESULT_OK: + Parcelable intentParcelable = resultData.getParcelable(Intent.EXTRA_INTENT); + if (intentParcelable instanceof Intent) { + mOnSelectionRefined.accept((Intent) intentParcelable); + } else { + Log.e(TAG, "No valid Intent.EXTRA_INTENT in 'OK' refinement result data"); + } + break; + default: + Log.w(TAG, "Received unknown refinement result " + resultCode); + break; + } + } + + /** + * Apps can't load this class directly, so we need a regular ResultReceiver copy for + * sending. Obtain this by parceling and unparceling (one weird trick). + */ + ResultReceiver copyForSending() { + Parcel parcel = Parcel.obtain(); + writeToParcel(parcel, 0); + parcel.setDataPosition(0); + ResultReceiver receiverForSending = ResultReceiver.CREATOR.createFromParcel(parcel); + parcel.recycle(); + return receiverForSending; + } + } + + private static TargetInfo getValidRefinedTarget( + TargetInfo originalTarget, Intent proposedRefinement) { + if (originalTarget == null) { + // TODO: this legacy log message doesn't seem to describe the real condition we just + // checked; probably this method should never be invoked with a null target. + Log.e(TAG, "Refinement result intent did not match any known targets; canceling"); + return null; + } + if (!checkProposalRefinesSourceIntent(originalTarget, proposedRefinement)) { + Log.e(TAG, "Refinement " + proposedRefinement + " has no match in " + originalTarget); + return null; + } + return originalTarget.cloneFilledIn(proposedRefinement, 0); // TODO: select the right base. + } + + // TODO: return the actual match, to use as the base that we fill in? Or, if that's handled by + // `TargetInfo.cloneFilledIn()`, just let it be nullable (it already is?) and don't bother doing + // this pre-check. + private static boolean checkProposalRefinesSourceIntent( + TargetInfo originalTarget, Intent proposedMatch) { + return originalTarget.getAllSourceIntents().stream().anyMatch(proposedMatch::filterEquals); + } +} -- cgit v1.2.3-59-g8ed1b From 622b5d33df631a7bd6fc5482a9a80931eece0c2d Mon Sep 17 00:00:00 2001 From: Matt Casey Date: Wed, 15 Feb 2023 21:01:10 +0000 Subject: Handle refinement responses on the main thread. Bug: 269170766 Test: atest CtsSharesheetDeviceTest Change-Id: I539c1f4a00c4572913fb3c0f10f22d9c20eb6ca9 --- .../src/com/android/intentresolver/ChooserRefinementManager.java | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) (limited to 'java/src') diff --git a/java/src/com/android/intentresolver/ChooserRefinementManager.java b/java/src/com/android/intentresolver/ChooserRefinementManager.java index 5997bfed..98c6bddc 100644 --- a/java/src/com/android/intentresolver/ChooserRefinementManager.java +++ b/java/src/com/android/intentresolver/ChooserRefinementManager.java @@ -23,6 +23,7 @@ import android.content.Intent; import android.content.IntentSender; import android.content.IntentSender.SendIntentException; import android.os.Bundle; +import android.os.Handler; import android.os.Parcel; import android.os.Parcelable; import android.os.ResultReceiver; @@ -94,7 +95,8 @@ public final class ChooserRefinementManager { mOnRefinementCancelled.run(); } }, - mOnRefinementCancelled); + mOnRefinementCancelled, + mContext.getMainThreadHandler()); Intent refinementRequest = makeRefinementRequest(mRefinementResultReceiver, selectedTarget); try { @@ -136,8 +138,9 @@ public final class ChooserRefinementManager { RefinementResultReceiver( Consumer onSelectionRefined, - Runnable onRefinementCancelled) { - super(/* handler=*/ null); + Runnable onRefinementCancelled, + Handler handler) { + super(handler); mOnSelectionRefined = onSelectionRefined; mOnRefinementCancelled = onRefinementCancelled; } -- cgit v1.2.3-59-g8ed1b From 8397111c0234fa1ea639c85677065482a4c34727 Mon Sep 17 00:00:00 2001 From: Andrey Epin Date: Mon, 6 Feb 2023 21:31:44 -0800 Subject: Run Chooser integration tests for the new features Add a new test parameter, feature flag set, and run Chooser tests for two set values: all flags are off and all flags are on. This ensures that our integration tests cover the new features. A new test rule is added to ignore tests that were designed for a specific flag values when running in a set that does not have those values set. Bug: 258838272 Test: the modified test itself Change-Id: If646ef123a383e801fda55d601e10b186c6c5c1f (cherry picked from commit 880417ed82485d63c87737d38a270a3367d27594) Merged-In: If646ef123a383e801fda55d601e10b186c6c5c1f --- java/src/com/android/intentresolver/flags/Flags.kt | 17 ++- .../com/android/intentresolver/FeatureFlagRule.kt | 56 ++++++++++ .../android/intentresolver/RequireFeatureFlags.kt | 23 +++++ .../intentresolver/TestFeatureFlagRepository.kt | 9 +- .../UnbundledChooserActivityTest.java | 114 +++++++++++++-------- 5 files changed, 171 insertions(+), 48 deletions(-) create mode 100644 java/tests/src/com/android/intentresolver/FeatureFlagRule.kt create mode 100644 java/tests/src/com/android/intentresolver/RequireFeatureFlags.kt (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 59b5ea74..f4dbeddb 100644 --- a/java/src/com/android/intentresolver/flags/Flags.kt +++ b/java/src/com/android/intentresolver/flags/Flags.kt @@ -21,24 +21,33 @@ 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). object Flags { + const val SHARESHEET_CUSTOM_ACTIONS_NAME = "sharesheet_custom_actions" + const val SHARESHEET_RESELECTION_ACTION_NAME = "sharesheet_reselection_action" + const val SHARESHEET_IMAGE_AND_TEXT_PREVIEW_NAME = "sharesheet_image_text_preview" + const val SHARESHEET_SCROLLABLE_IMAGE_PREVIEW_NAME = "sharesheet_scrollable_image_preview" + // TODO(b/266983432) Tracking Bug @JvmField - val SHARESHEET_CUSTOM_ACTIONS = unreleasedFlag(1501, "sharesheet_custom_actions", teamfood = true) + val SHARESHEET_CUSTOM_ACTIONS = unreleasedFlag( + 1501, SHARESHEET_CUSTOM_ACTIONS_NAME, teamfood = true + ) // TODO(b/266982749) Tracking Bug @JvmField - val SHARESHEET_RESELECTION_ACTION = unreleasedFlag(1502, "sharesheet_reselection_action", teamfood = true) + val SHARESHEET_RESELECTION_ACTION = unreleasedFlag( + 1502, SHARESHEET_RESELECTION_ACTION_NAME, teamfood = true + ) // TODO(b/266983474) Tracking Bug @JvmField val SHARESHEET_IMAGE_AND_TEXT_PREVIEW = unreleasedFlag( - id = 1503, name = "sharesheet_image_text_preview", teamfood = true + id = 1503, name = SHARESHEET_IMAGE_AND_TEXT_PREVIEW_NAME, teamfood = true ) // TODO(b/267355521) Tracking Bug @JvmField val SHARESHEET_SCROLLABLE_IMAGE_PREVIEW = unreleasedFlag( - 1504, "sharesheet_scrollable_image_preview", teamfood = true + 1504, SHARESHEET_SCROLLABLE_IMAGE_PREVIEW_NAME, teamfood = true ) private fun unreleasedFlag(id: Int, name: String, teamfood: Boolean = false) = diff --git a/java/tests/src/com/android/intentresolver/FeatureFlagRule.kt b/java/tests/src/com/android/intentresolver/FeatureFlagRule.kt new file mode 100644 index 00000000..3fa01bcc --- /dev/null +++ b/java/tests/src/com/android/intentresolver/FeatureFlagRule.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 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 new file mode 100644 index 00000000..1ddf7462 --- /dev/null +++ b/java/tests/src/com/android/intentresolver/RequireFeatureFlags.kt @@ -0,0 +1,23 @@ +/* + * 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 index abc24efb..5a159d24 100644 --- a/java/tests/src/com/android/intentresolver/TestFeatureFlagRepository.kt +++ b/java/tests/src/com/android/intentresolver/TestFeatureFlagRepository.kt @@ -17,14 +17,15 @@ 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 internal class TestFeatureFlagRepository( - private val overrides: Map + private val overrides: Map ) : FeatureFlagRepository { - override fun isEnabled(flag: UnreleasedFlag): Boolean = - overrides.getOrDefault(flag, flag.default) + override fun isEnabled(flag: UnreleasedFlag): Boolean = getValue(flag) + override fun isEnabled(flag: ReleasedFlag): Boolean = getValue(flag) - override fun isEnabled(flag: ReleasedFlag): Boolean = flag.default + 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 17fd5bd9..82a635dd 100644 --- a/java/tests/src/com/android/intentresolver/UnbundledChooserActivityTest.java +++ b/java/tests/src/com/android/intentresolver/UnbundledChooserActivityTest.java @@ -107,6 +107,7 @@ import com.android.intentresolver.flags.Flags; 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; @@ -115,6 +116,8 @@ 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; @@ -123,7 +126,6 @@ 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; @@ -162,14 +164,40 @@ public class UnbundledChooserActivityTest { return mock; }; + private static final List ALL_FLAGS = + Arrays.asList( + Flags.SHARESHEET_CUSTOM_ACTIONS, + Flags.SHARESHEET_RESELECTION_ACTION, + Flags.SHARESHEET_IMAGE_AND_TEXT_PREVIEW, + Flags.SHARESHEET_SCROLLABLE_IMAGE_PREVIEW); + + private static final Map ALL_FLAGS_OFF = + createAllFlagsOverride(false); + private static final Map ALL_FLAGS_ON = + createAllFlagsOverride(true); + @Parameterized.Parameters public static Collection packageManagers() { return Arrays.asList(new Object[][] { - {0, "Default PackageManager", DEFAULT_PM}, - {1, "No App Prediction Service", NO_APP_PREDICTION_SERVICE_PM} + // Default PackageManager and all flags off + { DEFAULT_PM, ALL_FLAGS_OFF }, + // Default PackageManager and all flags on + { DEFAULT_PM, ALL_FLAGS_ON }, + // No App Prediction Service and all flags off + { NO_APP_PREDICTION_SERVICE_PM, ALL_FLAGS_OFF }, + // No App Prediction Service and all flags on + { NO_APP_PREDICTION_SERVICE_PM, ALL_FLAGS_ON } }); } + private static Map 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. * -------- @@ -189,6 +217,8 @@ public class UnbundledChooserActivityTest { .adoptShellPermissionIdentity(); cleanOverrideData(); + ChooserActivityOverrideData.getInstance().featureFlagRepository = + new TestFeatureFlagRepository(mFlags); } /** @@ -221,11 +251,13 @@ public class UnbundledChooserActivityTest { * -------- */ + @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. - @Rule - public ActivityTestRule mActivityRule = + private ActivityTestRule mActivityRule = new ActivityTestRule<>(ChooserActivity.class, false, false) { @Override public ChooserActivity launchActivity(Intent clientIntent) { @@ -252,16 +284,20 @@ public class UnbundledChooserActivityTest { 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 Function mPackageManagerOverride; - private int mTestNum; + + private final Function mPackageManagerOverride; + private final Map mFlags; public UnbundledChooserActivityTest( - int testNum, - String testName, - Function packageManagerOverride) { + Function packageManagerOverride, + Map flags) { mPackageManagerOverride = packageManagerOverride; - mTestNum = testNum; + mFlags = flags; + + mRule = RuleChain + .outerRule(new FeatureFlagRule(flags)) + .around(mActivityRule); } private void setDeviceConfigProperty( @@ -757,10 +793,10 @@ public class UnbundledChooserActivityTest { } @Test + @RequireFeatureFlags( + flags = { Flags.SHARESHEET_IMAGE_AND_TEXT_PREVIEW_NAME }, + values = { true }) public void testImagePlusTextSharing_ExcludeText() { - ChooserActivityOverrideData.getInstance().featureFlagRepository = - new TestFeatureFlagRepository( - Collections.singletonMap(Flags.SHARESHEET_IMAGE_AND_TEXT_PREVIEW, true)); Intent sendIntent = createSendImageIntent( Uri.parse("android.resource://com.android.frameworks.coretests/" + R.drawable.test320x240)); @@ -809,10 +845,10 @@ public class UnbundledChooserActivityTest { } @Test + @RequireFeatureFlags( + flags = { Flags.SHARESHEET_IMAGE_AND_TEXT_PREVIEW_NAME }, + values = { true }) public void testImagePlusTextSharing_RemoveAndAddBackText() { - ChooserActivityOverrideData.getInstance().featureFlagRepository = - new TestFeatureFlagRepository( - Collections.singletonMap(Flags.SHARESHEET_IMAGE_AND_TEXT_PREVIEW, true)); Intent sendIntent = createSendImageIntent( Uri.parse("android.resource://com.android.frameworks.coretests/" + R.drawable.test320x240)); @@ -865,10 +901,10 @@ public class UnbundledChooserActivityTest { } @Test + @RequireFeatureFlags( + flags = { Flags.SHARESHEET_IMAGE_AND_TEXT_PREVIEW_NAME }, + values = { true }) public void testImagePlusTextSharing_TextExclusionDoesNotAffectAlternativeIntent() { - ChooserActivityOverrideData.getInstance().featureFlagRepository = - new TestFeatureFlagRepository( - Collections.singletonMap(Flags.SHARESHEET_IMAGE_AND_TEXT_PREVIEW, true)); Intent sendIntent = createSendImageIntent( Uri.parse("android.resource://com.android.frameworks.coretests/" + R.drawable.test320x240)); @@ -1070,10 +1106,10 @@ public class UnbundledChooserActivityTest { } @Test + @RequireFeatureFlags( + flags = { Flags.SHARESHEET_SCROLLABLE_IMAGE_PREVIEW_NAME }, + values = { false }) public void twoVisibleImagePreview() { - ChooserActivityOverrideData.getInstance().featureFlagRepository = - new TestFeatureFlagRepository( - Collections.singletonMap(Flags.SHARESHEET_SCROLLABLE_IMAGE_PREVIEW, false)); Uri uri = Uri.parse("android.resource://com.android.frameworks.coretests/" + R.drawable.test320x240); @@ -1110,11 +1146,10 @@ public class UnbundledChooserActivityTest { } @Test + @RequireFeatureFlags( + flags = { Flags.SHARESHEET_SCROLLABLE_IMAGE_PREVIEW_NAME }, + values = { false }) public void threeOrMoreVisibleImagePreview() { - ChooserActivityOverrideData.getInstance().featureFlagRepository = - new TestFeatureFlagRepository( - Collections.singletonMap( - Flags.SHARESHEET_SCROLLABLE_IMAGE_PREVIEW, false)); Uri uri = Uri.parse("android.resource://com.android.frameworks.coretests/" + R.drawable.test320x240); @@ -1154,11 +1189,10 @@ public class UnbundledChooserActivityTest { } @Test + @RequireFeatureFlags( + flags = { Flags.SHARESHEET_SCROLLABLE_IMAGE_PREVIEW_NAME }, + values = { true }) public void testManyVisibleImagePreview_ScrollableImagePreview() { - ChooserActivityOverrideData.getInstance().featureFlagRepository = - new TestFeatureFlagRepository( - Collections.singletonMap( - Flags.SHARESHEET_SCROLLABLE_IMAGE_PREVIEW, true)); Uri uri = Uri.parse("android.resource://com.android.frameworks.coretests/" + R.drawable.test320x240); @@ -1204,10 +1238,10 @@ public class UnbundledChooserActivityTest { } @Test + @RequireFeatureFlags( + flags = { Flags.SHARESHEET_IMAGE_AND_TEXT_PREVIEW_NAME }, + values = { true }) public void testImageAndTextPreview() { - ChooserActivityOverrideData.getInstance().featureFlagRepository = - new TestFeatureFlagRepository( - Collections.singletonMap(Flags.SHARESHEET_IMAGE_AND_TEXT_PREVIEW, true)); final Uri uri = Uri.parse("android.resource://com.android.frameworks.coretests/" + R.drawable.test320x240); final String sharedText = "text-" + System.currentTimeMillis(); @@ -1943,10 +1977,10 @@ public class UnbundledChooserActivityTest { } @Test + @RequireFeatureFlags( + flags = { Flags.SHARESHEET_CUSTOM_ACTIONS_NAME }, + values = { true }) public void testLaunchWithCustomAction() throws InterruptedException { - ChooserActivityOverrideData.getInstance().featureFlagRepository = - new TestFeatureFlagRepository( - Collections.singletonMap(Flags.SHARESHEET_CUSTOM_ACTIONS, true)); List resolvedComponentInfos = createResolvedComponentsForTest(2); when( ChooserActivityOverrideData @@ -1998,10 +2032,10 @@ public class UnbundledChooserActivityTest { } @Test + @RequireFeatureFlags( + flags = { Flags.SHARESHEET_RESELECTION_ACTION_NAME }, + values = { true }) public void testLaunchWithShareModification() throws InterruptedException { - ChooserActivityOverrideData.getInstance().featureFlagRepository = - new TestFeatureFlagRepository( - Collections.singletonMap(Flags.SHARESHEET_RESELECTION_ACTION, true)); List resolvedComponentInfos = createResolvedComponentsForTest(2); when( ChooserActivityOverrideData -- cgit v1.2.3-59-g8ed1b From 216c8a8cbc8928c874c7f9a31b6a1dba52f102c4 Mon Sep 17 00:00:00 2001 From: Matt Casey Date: Fri, 17 Feb 2023 21:21:27 +0000 Subject: Update variable naming for updated API Bug: 265504112 Test: Builds Change-Id: Ie1071c88dfb0f8dcbc01d800d396c62abbe92936 --- java/src/com/android/intentresolver/ChooserActivityLogger.java | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) (limited to 'java/src') diff --git a/java/src/com/android/intentresolver/ChooserActivityLogger.java b/java/src/com/android/intentresolver/ChooserActivityLogger.java index 1725a7bf..f90de4c2 100644 --- a/java/src/com/android/intentresolver/ChooserActivityLogger.java +++ b/java/src/com/android/intentresolver/ChooserActivityLogger.java @@ -68,7 +68,7 @@ public class ChooserActivityLogger { int previewType, int intentType, int numCustomActions, - boolean reselectionActionProvided); + boolean modifyShareActionProvided); /** Overload to use for logging {@code FrameworkStatsLog.RANKING_SELECTED}. */ void write( @@ -130,7 +130,7 @@ public class ChooserActivityLogger { /* previewType = 8 */ typeFromPreviewInt(previewType), /* intentType = 9 */ typeFromIntentString(intent), /* num_provided_custom_actions = 10 */ 0, - /* reselection_action_provided = 11 */ false); + /* modify_share_action_provided = 11 */ false); } /** @@ -469,7 +469,7 @@ public class ChooserActivityLogger { int previewType, int intentType, int numCustomActions, - boolean reselectionActionProvided) { + boolean modifyShareActionProvided) { FrameworkStatsLog.write( frameworkEventId, /* event_id = 1 */ appEventId, @@ -482,7 +482,7 @@ public class ChooserActivityLogger { /* previewType = 8 */ previewType, /* intentType = 9 */ intentType, /* num_provided_custom_actions = 10 */ numCustomActions, - /* reselection_action_provided = 11 */ reselectionActionProvided); + /* modify_share_action_provided = 11 */ modifyShareActionProvided); } @Override -- cgit v1.2.3-59-g8ed1b From 3d00192dba2b153068538583ab569b56ffbc0eaa Mon Sep 17 00:00:00 2001 From: Mark Renouf Date: Wed, 15 Feb 2023 14:59:28 -0500 Subject: Prevent sharesheet from previewing unowned URIs This is a high priority security fix. Bug: 261036568 Test: manually via supplied tool (see bug) Change-Id: I7e05506dc260d10984b8e56a8e657b50177ff04d Merged-In: I7e05506dc260d10984b8e56a8e657b50177ff04d --- .../intentresolver/ChooserContentPreviewUi.java | 27 +++++++++++++++++++--- 1 file changed, 24 insertions(+), 3 deletions(-) (limited to 'java/src') diff --git a/java/src/com/android/intentresolver/ChooserContentPreviewUi.java b/java/src/com/android/intentresolver/ChooserContentPreviewUi.java index 7d627e07..aa147853 100644 --- a/java/src/com/android/intentresolver/ChooserContentPreviewUi.java +++ b/java/src/com/android/intentresolver/ChooserContentPreviewUi.java @@ -16,6 +16,7 @@ package com.android.intentresolver; +import static android.content.ContentProvider.getUserIdFromUri; import static java.lang.annotation.RetentionPolicy.SOURCE; import android.animation.ObjectAnimator; @@ -28,6 +29,7 @@ import android.content.res.Resources; import android.database.Cursor; import android.graphics.Bitmap; import android.net.Uri; +import android.os.UserHandle; import android.provider.DocumentsContract; import android.provider.Downloads; import android.provider.OpenableColumns; @@ -341,7 +343,7 @@ public final class ChooserContentPreviewUi { ImageView previewThumbnailView = contentPreviewLayout.findViewById( com.android.internal.R.id.content_preview_thumbnail); - if (previewThumbnail == null) { + if (!validForContentPreview(previewThumbnail)) { previewThumbnailView.setVisibility(View.GONE); } else { previewImageLoader.loadImage( @@ -538,14 +540,14 @@ public final class ChooserContentPreviewUi { List uris = new ArrayList<>(); if (Intent.ACTION_SEND.equals(targetIntent.getAction())) { Uri uri = targetIntent.getParcelableExtra(Intent.EXTRA_STREAM); - if (uri != null) { + if (validForContentPreview(uri)) { uris.add(uri); } } else { List receivedUris = targetIntent.getParcelableArrayListExtra(Intent.EXTRA_STREAM); if (receivedUris != null) { for (Uri uri : receivedUris) { - if (uri != null) { + if (validForContentPreview(uri)) { uris.add(uri); } } @@ -554,6 +556,25 @@ public final class ChooserContentPreviewUi { return uris; } + /** + * Indicate if the incoming content URI should be allowed. + * + * @param uri the uri to test + * @return true if the URI is allowed for content preview + */ + private static boolean validForContentPreview(Uri uri) throws SecurityException { + if (uri == null) { + return false; + } + int userId = getUserIdFromUri(uri, UserHandle.USER_CURRENT); + if (userId != UserHandle.USER_CURRENT && userId != UserHandle.myUserId()) { + Log.e(TAG, "dropped invalid content URI belonging to user " + userId); + return false; + } + return true; + } + + private static List createFilePreviewActions(ActionFactory actionFactory) { List actions = new ArrayList<>(1); //TODO(b/120417119): -- cgit v1.2.3-59-g8ed1b From c1fdc6cb1540098cb9feed1147141c9a28df7043 Mon Sep 17 00:00:00 2001 From: Matt Casey Date: Tue, 21 Feb 2023 16:43:04 +0000 Subject: Add logging for modify share and custom action clicks Just a regular UiEvent log for modify share. Include position information for custom actions (*just* the position within the custom action set, ignoring other system actions that may be within the display container). Bug: 265504112 Test: atest ChooserActivityLoggerTest Test: atest ChooserActivityFactoryTest Change-Id: If64db5c1afccf6571d23395624d6ffbbef677188 --- .../intentresolver/ChooserActionFactory.java | 57 +++++--- .../intentresolver/ChooserActivityLogger.java | 27 +++- .../ChooserIntegratedDeviceComponents.java | 2 +- .../intentresolver/ChooserActionFactoryTest.kt | 154 +++++++++++++++++++++ .../intentresolver/ChooserActivityLoggerTest.java | 11 ++ 5 files changed, 233 insertions(+), 18 deletions(-) create mode 100644 java/tests/src/com/android/intentresolver/ChooserActionFactoryTest.kt (limited to 'java/src') diff --git a/java/src/com/android/intentresolver/ChooserActionFactory.java b/java/src/com/android/intentresolver/ChooserActionFactory.java index 1fe55890..566b2546 100644 --- a/java/src/com/android/intentresolver/ChooserActionFactory.java +++ b/java/src/com/android/intentresolver/ChooserActionFactory.java @@ -49,7 +49,6 @@ import java.util.ArrayList; import java.util.List; import java.util.concurrent.Callable; import java.util.function.Consumer; -import java.util.stream.Collectors; /** * Implementation of {@link ChooserContentPreviewUi.ActionFactory} specialized to the application @@ -96,9 +95,10 @@ public final class ChooserActionFactory implements ChooserContentPreviewUi.Actio private final TargetInfo mNearbySharingTarget; private final Runnable mOnNearbyButtonClicked; private final ImmutableList mCustomActions; - private final PendingIntent mReselectionIntent; + private final Runnable mOnModifyShareClicked; private final Consumer mExcludeSharedTextAction; private final Consumer mFinishCallback; + private final ChooserActivityLogger mLogger; /** * @param context @@ -160,8 +160,13 @@ public final class ChooserActionFactory implements ChooserContentPreviewUi.Actio logger), chooserRequest.getChooserActions(), (featureFlagRepository.isEnabled(Flags.SHARESHEET_RESELECTION_ACTION) - ? chooserRequest.getModifyShareAction() : null), + ? createModifyShareRunnable( + chooserRequest.getModifyShareAction(), + finishCallback, + logger) + : null), onUpdateSharedTextIsExcluded, + logger, finishCallback); } @@ -176,8 +181,9 @@ public final class ChooserActionFactory implements ChooserContentPreviewUi.Actio TargetInfo nearbySharingTarget, Runnable onNearbyButtonClicked, List customActions, - @Nullable PendingIntent reselectionIntent, + @Nullable Runnable onModifyShareClicked, Consumer onUpdateSharedTextIsExcluded, + ChooserActivityLogger logger, Consumer finishCallback) { mContext = context; mCopyButtonLabel = copyButtonLabel; @@ -188,8 +194,9 @@ public final class ChooserActionFactory implements ChooserContentPreviewUi.Actio mNearbySharingTarget = nearbySharingTarget; mOnNearbyButtonClicked = onNearbyButtonClicked; mCustomActions = ImmutableList.copyOf(customActions); - mReselectionIntent = reselectionIntent; + mOnModifyShareClicked = onModifyShareClicked; mExcludeSharedTextAction = onUpdateSharedTextIsExcluded; + mLogger = logger; mFinishCallback = finishCallback; } @@ -236,10 +243,15 @@ public final class ChooserActionFactory implements ChooserContentPreviewUi.Actio /** Create custom actions */ @Override public List createCustomActions() { - return mCustomActions.stream() - .map(target -> createCustomAction(mContext, target, mFinishCallback)) - .filter(action -> action != null) - .collect(Collectors.toList()); + List actions = new ArrayList<>(); + for (int i = 0; i < mCustomActions.size(); i++) { + ActionRow.Action actionRow = createCustomAction( + mContext, mCustomActions.get(i), mFinishCallback, i, mLogger); + if (actionRow != null) { + actions.add(actionRow); + } + } + return actions; } /** @@ -248,18 +260,25 @@ public final class ChooserActionFactory implements ChooserContentPreviewUi.Actio @Override @Nullable public Runnable getModifyShareAction() { - return (mReselectionIntent == null) ? null : createReselectionRunnable(mReselectionIntent); + return mOnModifyShareClicked; } - private Runnable createReselectionRunnable(PendingIntent pendingIntent) { + private static Runnable createModifyShareRunnable( + PendingIntent pendingIntent, + Consumer finishCallback, + ChooserActivityLogger logger) { + if (pendingIntent == null) { + return null; + } + return () -> { try { pendingIntent.send(); } catch (PendingIntent.CanceledException e) { Log.d(TAG, "Payload reselection action has been cancelled"); } - // TODO: add reporting - mFinishCallback.accept(Activity.RESULT_OK); + logger.logActionSelected(ChooserActivityLogger.SELECTION_TYPE_MODIFY_SHARE); + finishCallback.accept(Activity.RESULT_OK); }; } @@ -402,7 +421,9 @@ public final class ChooserActionFactory implements ChooserContentPreviewUi.Actio Intent originalIntent, ChooserIntegratedDeviceComponents integratedComponents) { final ComponentName cn = integratedComponents.getNearbySharingComponent(); - if (cn == null) return null; + if (cn == null) { + return null; + } final Intent resolveIntent = new Intent(originalIntent); resolveIntent.setComponent(cn); @@ -455,7 +476,11 @@ public final class ChooserActionFactory implements ChooserContentPreviewUi.Actio @Nullable private static ActionRow.Action createCustomAction( - Context context, ChooserAction action, Consumer finishCallback) { + Context context, + ChooserAction action, + Consumer finishCallback, + int position, + ChooserActivityLogger logger) { Drawable icon = action.getIcon().loadDrawable(context); if (icon == null && TextUtils.isEmpty(action.getLabel())) { return null; @@ -469,7 +494,7 @@ public final class ChooserActionFactory implements ChooserContentPreviewUi.Actio } catch (PendingIntent.CanceledException e) { Log.d(TAG, "Custom action, " + action.getLabel() + ", has been cancelled"); } - // TODO: add reporting + logger.logCustomActionSelected(position); finishCallback.accept(Activity.RESULT_OK); } ); diff --git a/java/src/com/android/intentresolver/ChooserActivityLogger.java b/java/src/com/android/intentresolver/ChooserActivityLogger.java index 1725a7bf..f298955b 100644 --- a/java/src/com/android/intentresolver/ChooserActivityLogger.java +++ b/java/src/com/android/intentresolver/ChooserActivityLogger.java @@ -48,6 +48,8 @@ public class ChooserActivityLogger { 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 @@ -133,6 +135,21 @@ public class ChooserActivityLogger { /* reselection_action_provided = 11 */ false); } + /** + * 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 @@ -332,7 +349,11 @@ public class ChooserActivityLogger { @UiEvent(doc = "User selected the nearby target.") SHARESHEET_NEARBY_TARGET_SELECTED(626), @UiEvent(doc = "User selected the edit target.") - SHARESHEET_EDIT_TARGET_SELECTED(669); + 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) { @@ -356,6 +377,10 @@ public class ChooserActivityLogger { 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; } diff --git a/java/src/com/android/intentresolver/ChooserIntegratedDeviceComponents.java b/java/src/com/android/intentresolver/ChooserIntegratedDeviceComponents.java index 9b124c20..14255ca0 100644 --- a/java/src/com/android/intentresolver/ChooserIntegratedDeviceComponents.java +++ b/java/src/com/android/intentresolver/ChooserIntegratedDeviceComponents.java @@ -32,7 +32,7 @@ import com.android.internal.annotations.VisibleForTesting; * Because this describes the app's external execution environment, test methods may prefer to * provide explicit values to override the default lookup logic. */ -public final class ChooserIntegratedDeviceComponents { +public class ChooserIntegratedDeviceComponents { @Nullable private final ComponentName mEditSharingComponent; diff --git a/java/tests/src/com/android/intentresolver/ChooserActionFactoryTest.kt b/java/tests/src/com/android/intentresolver/ChooserActionFactoryTest.kt new file mode 100644 index 00000000..af134fcd --- /dev/null +++ b/java/tests/src/com/android/intentresolver/ChooserActionFactoryTest.kt @@ -0,0 +1,154 @@ +/* + * Copyright (C) 2023 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.intentresolver + +import android.app.Activity +import android.app.PendingIntent +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.content.IntentFilter +import android.content.res.Resources +import android.graphics.drawable.Icon +import android.service.chooser.ChooserAction +import android.view.View +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry +import com.android.intentresolver.flags.FeatureFlagRepository +import com.android.intentresolver.flags.Flags +import com.google.common.collect.ImmutableList +import com.google.common.truth.Truth.assertThat +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.Mockito +import java.util.concurrent.Callable +import java.util.concurrent.CountDownLatch +import java.util.concurrent.TimeUnit +import java.util.function.Consumer + +@RunWith(AndroidJUnit4::class) +class ChooserActionFactoryTest { + private val context = InstrumentationRegistry.getInstrumentation().getContext() + + private val logger = mock() + private val flags = mock() + private val actionLabel = "Action label" + private val testAction = "com.android.intentresolver.testaction" + private val countdown = CountDownLatch(1) + private val testReceiver: BroadcastReceiver = object : BroadcastReceiver() { + override fun onReceive(context: Context, intent: Intent) { + // Just doing at most a single countdown per test. + countdown.countDown() + } + } + private object resultConsumer : Consumer { + var latestReturn = Integer.MIN_VALUE + + override fun accept(resultCode: Int) { + latestReturn = resultCode + } + + } + + @Before + fun setup() { + whenever(flags.isEnabled(Flags.SHARESHEET_RESELECTION_ACTION)).thenReturn(true) + context.registerReceiver(testReceiver, IntentFilter(testAction)) + } + + @After + fun teardown() { + context.unregisterReceiver(testReceiver) + } + + @Test + fun testCreateCustomActions() { + val factory = createFactory() + + val customActions = factory.createCustomActions() + + assertThat(customActions.size).isEqualTo(1) + assertThat(customActions[0].label).isEqualTo(actionLabel) + + // click it + customActions[0].onClicked.run() + + Mockito.verify(logger).logCustomActionSelected(eq(0)) + assertEquals(Activity.RESULT_OK, resultConsumer.latestReturn) + // Verify the pendingintent has been called + countdown.await(500, TimeUnit.MILLISECONDS) + } + + @Test + fun testNoModifyShareAction() { + val factory = createFactory(includeModifyShare = false) + + assertThat(factory.modifyShareAction).isNull() + } + + @Test + fun testNoModifyShareAction_flagDisabled() { + whenever(flags.isEnabled(Flags.SHARESHEET_RESELECTION_ACTION)).thenReturn(false) + val factory = createFactory(includeModifyShare = true) + + assertThat(factory.modifyShareAction).isNull() + } + + @Test + fun testModifyShareAction() { + val factory = createFactory(includeModifyShare = true) + + factory.modifyShareAction!!.run() + + Mockito.verify(logger).logActionSelected( + eq(ChooserActivityLogger.SELECTION_TYPE_MODIFY_SHARE)) + assertEquals(Activity.RESULT_OK, resultConsumer.latestReturn) + // Verify the pendingintent has been called + countdown.await(500, TimeUnit.MILLISECONDS) + } + + private fun createFactory(includeModifyShare: Boolean = false): ChooserActionFactory { + val testPendingIntent = PendingIntent.getActivity(context, 0, Intent(testAction),0) + val targetIntent = Intent() + val action = ChooserAction.Builder( + Icon.createWithResource("", Resources.ID_NULL), + actionLabel, + testPendingIntent + ).build() + val chooserRequest = mock() + whenever(chooserRequest.targetIntent).thenReturn(targetIntent) + whenever(chooserRequest.chooserActions).thenReturn(ImmutableList.of(action)) + + if (includeModifyShare) { + whenever(chooserRequest.modifyShareAction).thenReturn(testPendingIntent) + } + + return ChooserActionFactory( + context, + chooserRequest, + flags, + mock(), + logger, + Consumer{}, + Callable{null}, + mock(), + resultConsumer) + } +} \ No newline at end of file diff --git a/java/tests/src/com/android/intentresolver/ChooserActivityLoggerTest.java b/java/tests/src/com/android/intentresolver/ChooserActivityLoggerTest.java index c6a9b63f..d8868fc1 100644 --- a/java/tests/src/com/android/intentresolver/ChooserActivityLoggerTest.java +++ b/java/tests/src/com/android/intentresolver/ChooserActivityLoggerTest.java @@ -204,6 +204,17 @@ public final class ChooserActivityLoggerTest { 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; -- cgit v1.2.3-59-g8ed1b From 9c1f0f80ee06bef63ab8a681aae0c1fed60e4d96 Mon Sep 17 00:00:00 2001 From: Joshua Trask Date: Fri, 17 Feb 2023 15:59:54 +0000 Subject: Extract component for work-profile availability. The new `WorkProfileAvailabilityManager` component consolidates responsibilities that were previously associated with the `QuietModeManager` interface and resolver's work-profile-state `BroadcastReceiver`. See code review comments for further discussion of CL diffs. Test: `atest IntentResolverUnitTests` Bug: 202167050 Change-Id: I5fd0385e161959a5fbcdf9c27dbc757d5faa2a06 --- .../AbstractMultiProfilePagerAdapter.java | 42 +----- .../android/intentresolver/ChooserActivity.java | 4 +- .../ChooserMultiProfilePagerAdapter.java | 12 +- .../GenericMultiProfilePagerAdapter.java | 4 +- .../android/intentresolver/ResolverActivity.java | 134 ++++------------- .../ResolverMultiProfilePagerAdapter.java | 12 +- .../WorkProfileAvailabilityManager.java | 166 +++++++++++++++++++++ .../WorkProfilePausedEmptyStateProvider.java | 13 +- .../ChooserActivityOverrideData.java | 18 ++- .../intentresolver/ChooserWrapperActivity.java | 9 +- .../intentresolver/ResolverWrapperActivity.java | 26 ++-- 11 files changed, 250 insertions(+), 190 deletions(-) create mode 100644 java/src/com/android/intentresolver/WorkProfileAvailabilityManager.java (limited to 'java/src') diff --git a/java/src/com/android/intentresolver/AbstractMultiProfilePagerAdapter.java b/java/src/com/android/intentresolver/AbstractMultiProfilePagerAdapter.java index 17dbb8f2..e3f1b233 100644 --- a/java/src/com/android/intentresolver/AbstractMultiProfilePagerAdapter.java +++ b/java/src/com/android/intentresolver/AbstractMultiProfilePagerAdapter.java @@ -40,6 +40,7 @@ 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 @@ -61,22 +62,20 @@ public abstract class AbstractMultiProfilePagerAdapter extends PagerAdapter { private Set mLoadedPages; private final EmptyStateProvider mEmptyStateProvider; private final UserHandle mWorkProfileUserHandle; - private final QuietModeManager mQuietModeManager; + private final Supplier mWorkProfileQuietModeChecker; // True when work is quiet. - AbstractMultiProfilePagerAdapter(Context context, int currentPage, + AbstractMultiProfilePagerAdapter( + Context context, + int currentPage, EmptyStateProvider emptyStateProvider, - QuietModeManager quietModeManager, + Supplier workProfileQuietModeChecker, UserHandle workProfileUserHandle) { mContext = Objects.requireNonNull(context); mCurrentPage = currentPage; mLoadedPages = new HashSet<>(); mWorkProfileUserHandle = workProfileUserHandle; mEmptyStateProvider = emptyStateProvider; - mQuietModeManager = quietModeManager; - } - - private boolean isQuietModeEnabled(UserHandle workProfileUserHandle) { - return mQuietModeManager.isQuietModeEnabled(workProfileUserHandle); + mWorkProfileQuietModeChecker = workProfileQuietModeChecker; } void setOnProfileSelectedListener(OnProfileSelectedListener listener) { @@ -433,7 +432,7 @@ public abstract class AbstractMultiProfilePagerAdapter extends PagerAdapter { int count = listAdapter.getUnfilteredCount(); return (count == 0 && listAdapter.getPlaceholderCount() == 0) || (listAdapter.getUserHandle().equals(mWorkProfileUserHandle) - && isQuietModeEnabled(mWorkProfileUserHandle)); + && mWorkProfileQuietModeChecker.get()); } protected static class ProfileDescriptor { @@ -573,29 +572,4 @@ public abstract class AbstractMultiProfilePagerAdapter extends PagerAdapter { */ void onSwitchOnWorkSelected(); } - - /** - * Describes an injector to be used for cross profile functionality. Overridable for testing. - */ - public interface QuietModeManager { - /** - * Returns whether the given profile is in quiet mode or not. - */ - boolean isQuietModeEnabled(UserHandle workProfileUserHandle); - - /** - * Enables or disables quiet mode for a managed profile. - */ - void requestQuietModeEnabled(boolean enabled, UserHandle workProfileUserHandle); - - /** - * Should be called when the work profile enabled broadcast received - */ - void markWorkProfileEnabledBroadcastReceived(); - - /** - * Returns true if enabling of work profile is in progress - */ - boolean isWaitingToEnableWorkProfile(); - } } diff --git a/java/src/com/android/intentresolver/ChooserActivity.java b/java/src/com/android/intentresolver/ChooserActivity.java index a2f2bbde..6b5418e6 100644 --- a/java/src/com/android/intentresolver/ChooserActivity.java +++ b/java/src/com/android/intentresolver/ChooserActivity.java @@ -535,7 +535,7 @@ public class ChooserActivity extends ResolverActivity implements /* context */ this, adapter, createEmptyStateProvider(/* workProfileUserHandle= */ null), - mQuietModeManager, + /* workProfileQuietModeChecker= */ () -> false, /* workProfileUserHandle= */ null, mMaxTargetsPerRow); } @@ -564,7 +564,7 @@ public class ChooserActivity extends ResolverActivity implements personalAdapter, workAdapter, createEmptyStateProvider(/* workProfileUserHandle= */ getWorkProfileUserHandle()), - mQuietModeManager, + () -> mWorkProfileAvailability.isQuietModeEnabled(), selectedProfile, getWorkProfileUserHandle(), mMaxTargetsPerRow); diff --git a/java/src/com/android/intentresolver/ChooserMultiProfilePagerAdapter.java b/java/src/com/android/intentresolver/ChooserMultiProfilePagerAdapter.java index 39d1fab0..3e2ea473 100644 --- a/java/src/com/android/intentresolver/ChooserMultiProfilePagerAdapter.java +++ b/java/src/com/android/intentresolver/ChooserMultiProfilePagerAdapter.java @@ -48,7 +48,7 @@ public class ChooserMultiProfilePagerAdapter extends GenericMultiProfilePagerAda Context context, ChooserGridAdapter adapter, EmptyStateProvider emptyStateProvider, - QuietModeManager quietModeManager, + Supplier workProfileQuietModeChecker, UserHandle workProfileUserHandle, int maxTargetsPerRow) { this( @@ -56,7 +56,7 @@ public class ChooserMultiProfilePagerAdapter extends GenericMultiProfilePagerAda new ChooserProfileAdapterBinder(maxTargetsPerRow), ImmutableList.of(adapter), emptyStateProvider, - quietModeManager, + workProfileQuietModeChecker, /* defaultProfile= */ 0, workProfileUserHandle, new BottomPaddingOverrideSupplier(context)); @@ -67,7 +67,7 @@ public class ChooserMultiProfilePagerAdapter extends GenericMultiProfilePagerAda ChooserGridAdapter personalAdapter, ChooserGridAdapter workAdapter, EmptyStateProvider emptyStateProvider, - QuietModeManager quietModeManager, + Supplier workProfileQuietModeChecker, @Profile int defaultProfile, UserHandle workProfileUserHandle, int maxTargetsPerRow) { @@ -76,7 +76,7 @@ public class ChooserMultiProfilePagerAdapter extends GenericMultiProfilePagerAda new ChooserProfileAdapterBinder(maxTargetsPerRow), ImmutableList.of(personalAdapter, workAdapter), emptyStateProvider, - quietModeManager, + workProfileQuietModeChecker, defaultProfile, workProfileUserHandle, new BottomPaddingOverrideSupplier(context)); @@ -87,7 +87,7 @@ public class ChooserMultiProfilePagerAdapter extends GenericMultiProfilePagerAda ChooserProfileAdapterBinder adapterBinder, ImmutableList gridAdapters, EmptyStateProvider emptyStateProvider, - QuietModeManager quietModeManager, + Supplier workProfileQuietModeChecker, @Profile int defaultProfile, UserHandle workProfileUserHandle, BottomPaddingOverrideSupplier bottomPaddingOverrideSupplier) { @@ -97,7 +97,7 @@ public class ChooserMultiProfilePagerAdapter extends GenericMultiProfilePagerAda adapterBinder, gridAdapters, emptyStateProvider, - quietModeManager, + workProfileQuietModeChecker, defaultProfile, workProfileUserHandle, () -> makeProfileView(context), diff --git a/java/src/com/android/intentresolver/GenericMultiProfilePagerAdapter.java b/java/src/com/android/intentresolver/GenericMultiProfilePagerAdapter.java index 9bbdf7c7..7613f35f 100644 --- a/java/src/com/android/intentresolver/GenericMultiProfilePagerAdapter.java +++ b/java/src/com/android/intentresolver/GenericMultiProfilePagerAdapter.java @@ -81,7 +81,7 @@ class GenericMultiProfilePagerAdapter< AdapterBinder adapterBinder, ImmutableList adapters, EmptyStateProvider emptyStateProvider, - QuietModeManager quietModeManager, + Supplier workProfileQuietModeChecker, @Profile int defaultProfile, UserHandle workProfileUserHandle, Supplier pageViewInflater, @@ -90,7 +90,7 @@ class GenericMultiProfilePagerAdapter< context, /* currentPage= */ defaultProfile, emptyStateProvider, - quietModeManager, + workProfileQuietModeChecker, workProfileUserHandle); mListAdapterExtractor = listAdapterExtractor; diff --git a/java/src/com/android/intentresolver/ResolverActivity.java b/java/src/com/android/intentresolver/ResolverActivity.java index ff436ed8..faccbc36 100644 --- a/java/src/com/android/intentresolver/ResolverActivity.java +++ b/java/src/com/android/intentresolver/ResolverActivity.java @@ -44,7 +44,6 @@ import android.app.VoiceInteractor.PickOptionRequest.Option; import android.app.VoiceInteractor.Prompt; import android.app.admin.DevicePolicyEventLogger; import android.app.admin.DevicePolicyManager; -import android.content.BroadcastReceiver; import android.content.ComponentName; import android.content.Context; import android.content.Intent; @@ -61,7 +60,6 @@ import android.content.res.TypedArray; import android.graphics.Insets; import android.graphics.drawable.Drawable; import android.net.Uri; -import android.os.AsyncTask; import android.os.Build; import android.os.Bundle; import android.os.PatternMatcher; @@ -105,7 +103,6 @@ import com.android.intentresolver.AbstractMultiProfilePagerAdapter.EmptyStatePro import com.android.intentresolver.AbstractMultiProfilePagerAdapter.MyUserIdProvider; import com.android.intentresolver.AbstractMultiProfilePagerAdapter.OnSwitchOnWorkSelectedListener; import com.android.intentresolver.AbstractMultiProfilePagerAdapter.Profile; -import com.android.intentresolver.AbstractMultiProfilePagerAdapter.QuietModeManager; import com.android.intentresolver.NoCrossProfileEmptyStateProvider.DevicePolicyBlockerEmptyState; import com.android.intentresolver.chooser.DisplayResolveInfo; import com.android.intentresolver.chooser.TargetInfo; @@ -191,7 +188,7 @@ public class ResolverActivity extends FragmentActivity implements @VisibleForTesting protected AbstractMultiProfilePagerAdapter mMultiProfilePagerAdapter; - protected QuietModeManager mQuietModeManager; + protected WorkProfileAvailabilityManager mWorkProfileAvailability; // Intent extra for connected audio devices public static final String EXTRA_IS_AUDIO_CAPTURE_DEVICE = "is_audio_capture_device"; @@ -219,7 +216,6 @@ public class ResolverActivity extends FragmentActivity implements protected static final int PROFILE_PERSONAL = AbstractMultiProfilePagerAdapter.PROFILE_PERSONAL; protected static final int PROFILE_WORK = AbstractMultiProfilePagerAdapter.PROFILE_WORK; - private BroadcastReceiver mWorkProfileStateReceiver; private UserHandle mHeaderCreatorUser; // User handle annotations are lazy-initialized to ensure that they're computed exactly once @@ -357,8 +353,6 @@ public class ResolverActivity extends FragmentActivity implements setTheme(appliedThemeResId()); super.onCreate(savedInstanceState); - mQuietModeManager = createQuietModeManager(); - // Determine whether we should show that intent is forwarded // from managed profile to owner or other way around. setProfileSwitchMessage(intent.getContentUserHint()); @@ -367,6 +361,8 @@ public class ResolverActivity extends FragmentActivity implements // associated TODO comment to explain why this is structured as a lazy computation.) AnnotatedUserHandles unusedReferenceToHandles = mLazyAnnotatedUserHandles.get(); + mWorkProfileAvailability = createWorkProfileAvailabilityManager(); + mPm = getPackageManager(); mReferrerPackage = getReferrerPackageName(); @@ -593,10 +589,8 @@ public class ResolverActivity extends FragmentActivity implements finish(); } } - if (mWorkPackageMonitor != null) { - unregisterReceiver(mWorkProfileStateReceiver); - mWorkPackageMonitor = null; - } + // TODO: should we clean up the work-profile manager before we potentially finish() above? + mWorkProfileAvailability.unregisterWorkProfileStateReceiver(this); } @Override @@ -950,7 +944,7 @@ public class ResolverActivity extends FragmentActivity implements public void onHandlePackagesChanged(ResolverListAdapter listAdapter) { if (listAdapter == mMultiProfilePagerAdapter.getActiveListAdapter()) { if (listAdapter.getUserHandle().equals(getWorkProfileUserHandle()) - && mQuietModeManager.isWaitingToEnableWorkProfile()) { + && 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 @@ -988,35 +982,19 @@ public class ResolverActivity extends FragmentActivity implements // @NonFinalForTesting @VisibleForTesting - protected QuietModeManager createQuietModeManager() { - UserManager userManager = getSystemService(UserManager.class); - return new QuietModeManager() { - - private boolean mIsWaitingToEnableWorkProfile = false; - - @Override - public boolean isQuietModeEnabled(UserHandle workProfileUserHandle) { - return userManager.isQuietModeEnabled(workProfileUserHandle); - } - - @Override - public void requestQuietModeEnabled(boolean enabled, UserHandle workProfileUserHandle) { - AsyncTask.THREAD_POOL_EXECUTOR.execute(() -> { - userManager.requestQuietModeEnabled(enabled, workProfileUserHandle); + protected WorkProfileAvailabilityManager createWorkProfileAvailabilityManager() { + final UserHandle workUser = getWorkProfileUserHandle(); + + return new WorkProfileAvailabilityManager( + getSystemService(UserManager.class), + workUser, + () -> { + if (mMultiProfilePagerAdapter.getCurrentUserHandle().equals(workUser)) { + mMultiProfilePagerAdapter.rebuildActiveTab(true); + } else { + mMultiProfilePagerAdapter.clearInactiveProfileCache(); + } }); - mIsWaitingToEnableWorkProfile = true; - } - - @Override - public void markWorkProfileEnabledBroadcastReceived() { - mIsWaitingToEnableWorkProfile = false; - } - - @Override - public boolean isWaitingToEnableWorkProfile() { - return mIsWaitingToEnableWorkProfile; - } - }; } // TODO: have tests override `getAnnotatedUserHandles()`, and make this method `final`. @@ -1083,7 +1061,7 @@ public class ResolverActivity extends FragmentActivity implements final EmptyStateProvider workProfileOffEmptyStateProvider = new WorkProfilePausedEmptyStateProvider(this, workProfileUserHandle, - mQuietModeManager, + mWorkProfileAvailability, /* onSwitchOnWorkSelectedListener= */ () -> { if (mOnSwitchOnWorkSelectedListener != null) { @@ -1141,12 +1119,11 @@ public class ResolverActivity extends FragmentActivity implements rList, filterLastUsed, /* userHandle */ UserHandle.of(UserHandle.myUserId())); - QuietModeManager quietModeManager = createQuietModeManager(); return new ResolverMultiProfilePagerAdapter( /* context */ this, adapter, createEmptyStateProvider(/* workProfileUserHandle= */ null), - quietModeManager, + /* workProfileQuietModeChecker= */ () -> false, /* workProfileUserHandle= */ null); } @@ -1197,13 +1174,12 @@ public class ResolverActivity extends FragmentActivity implements (filterLastUsed && UserHandle.myUserId() == workProfileUserHandle.getIdentifier()), /* userHandle */ workProfileUserHandle); - QuietModeManager quietModeManager = createQuietModeManager(); return new ResolverMultiProfilePagerAdapter( /* context */ this, personalAdapter, workAdapter, createEmptyStateProvider(getWorkProfileUserHandle()), - quietModeManager, + () -> mWorkProfileAvailability.isQuietModeEnabled(), selectedProfile, getWorkProfileUserHandle()); } @@ -1441,9 +1417,9 @@ public class ResolverActivity extends FragmentActivity implements } mRegistered = true; } - if (shouldShowTabs() && mQuietModeManager.isWaitingToEnableWorkProfile()) { - if (mQuietModeManager.isQuietModeEnabled(getWorkProfileUserHandle())) { - mQuietModeManager.markWorkProfileEnabledBroadcastReceived(); + if (shouldShowTabs() && mWorkProfileAvailability.isWaitingToEnableWorkProfile()) { + if (mWorkProfileAvailability.isQuietModeEnabled()) { + mWorkProfileAvailability.markWorkProfileEnabledBroadcastReceived(); } } mMultiProfilePagerAdapter.getActiveListAdapter().handlePackagesChanged(); @@ -1456,29 +1432,10 @@ public class ResolverActivity extends FragmentActivity implements this.getWindow().addSystemFlags(SYSTEM_FLAG_HIDE_NON_SYSTEM_OVERLAY_WINDOWS); if (shouldShowTabs()) { - mWorkProfileStateReceiver = createWorkProfileStateReceiver(); - registerWorkProfileStateReceiver(); - - mWorkProfileHasBeenEnabled = isWorkProfileEnabled(); + mWorkProfileAvailability.registerWorkProfileStateReceiver(this); } } - private boolean isWorkProfileEnabled() { - UserHandle workUserHandle = getWorkProfileUserHandle(); - UserManager userManager = getSystemService(UserManager.class); - - return !userManager.isQuietModeEnabled(workUserHandle) - && userManager.isUserUnlocked(workUserHandle); - } - - private void registerWorkProfileStateReceiver() { - IntentFilter filter = new IntentFilter(); - filter.addAction(Intent.ACTION_USER_UNLOCKED); - filter.addAction(Intent.ACTION_MANAGED_PROFILE_AVAILABLE); - filter.addAction(Intent.ACTION_MANAGED_PROFILE_UNAVAILABLE); - registerReceiverAsUser(mWorkProfileStateReceiver, UserHandle.ALL, filter, null, null); - } - @Override protected final void onSaveInstanceState(Bundle outState) { super.onSaveInstanceState(outState); @@ -2248,45 +2205,6 @@ public class ResolverActivity extends FragmentActivity implements return mMultiProfilePagerAdapter.getInactiveListAdapter().getCount() > 0; } - private BroadcastReceiver createWorkProfileStateReceiver() { - return new BroadcastReceiver() { - @Override - public void onReceive(Context context, Intent intent) { - String action = intent.getAction(); - if (!TextUtils.equals(action, Intent.ACTION_USER_UNLOCKED) - && !TextUtils.equals(action, Intent.ACTION_MANAGED_PROFILE_UNAVAILABLE) - && !TextUtils.equals(action, Intent.ACTION_MANAGED_PROFILE_AVAILABLE)) { - return; - } - - int userId = intent.getIntExtra(Intent.EXTRA_USER_HANDLE, -1); - - if (userId != getWorkProfileUserHandle().getIdentifier()) { - return; - } - - if (isWorkProfileEnabled()) { - if (mWorkProfileHasBeenEnabled) { - return; - } - - mWorkProfileHasBeenEnabled = true; - mQuietModeManager.markWorkProfileEnabledBroadcastReceived(); - } else { - // Must be an UNAVAILABLE broadcast, so we watch for the next availability - mWorkProfileHasBeenEnabled = false; - } - - if (mMultiProfilePagerAdapter.getCurrentUserHandle() - .equals(getWorkProfileUserHandle())) { - mMultiProfilePagerAdapter.rebuildActiveTab(true); - } else { - mMultiProfilePagerAdapter.clearInactiveProfileCache(); - } - } - }; - } - public static final class ResolvedComponentInfo { public final ComponentName name; private final List mIntents = new ArrayList<>(); @@ -2439,4 +2357,4 @@ public class ResolverActivity extends FragmentActivity implements } } } -} \ No newline at end of file +} diff --git a/java/src/com/android/intentresolver/ResolverMultiProfilePagerAdapter.java b/java/src/com/android/intentresolver/ResolverMultiProfilePagerAdapter.java index 65de9409..48e3b62d 100644 --- a/java/src/com/android/intentresolver/ResolverMultiProfilePagerAdapter.java +++ b/java/src/com/android/intentresolver/ResolverMultiProfilePagerAdapter.java @@ -43,13 +43,13 @@ public class ResolverMultiProfilePagerAdapter extends Context context, ResolverListAdapter adapter, EmptyStateProvider emptyStateProvider, - QuietModeManager quietModeManager, + Supplier workProfileQuietModeChecker, UserHandle workProfileUserHandle) { this( context, ImmutableList.of(adapter), emptyStateProvider, - quietModeManager, + workProfileQuietModeChecker, /* defaultProfile= */ 0, workProfileUserHandle, new BottomPaddingOverrideSupplier()); @@ -59,14 +59,14 @@ public class ResolverMultiProfilePagerAdapter extends ResolverListAdapter personalAdapter, ResolverListAdapter workAdapter, EmptyStateProvider emptyStateProvider, - QuietModeManager quietModeManager, + Supplier workProfileQuietModeChecker, @Profile int defaultProfile, UserHandle workProfileUserHandle) { this( context, ImmutableList.of(personalAdapter, workAdapter), emptyStateProvider, - quietModeManager, + workProfileQuietModeChecker, defaultProfile, workProfileUserHandle, new BottomPaddingOverrideSupplier()); @@ -76,7 +76,7 @@ public class ResolverMultiProfilePagerAdapter extends Context context, ImmutableList listAdapters, EmptyStateProvider emptyStateProvider, - QuietModeManager quietModeManager, + Supplier workProfileQuietModeChecker, @Profile int defaultProfile, UserHandle workProfileUserHandle, BottomPaddingOverrideSupplier bottomPaddingOverrideSupplier) { @@ -86,7 +86,7 @@ public class ResolverMultiProfilePagerAdapter extends (listView, bindAdapter) -> listView.setAdapter(bindAdapter), listAdapters, emptyStateProvider, - quietModeManager, + workProfileQuietModeChecker, defaultProfile, workProfileUserHandle, () -> (ViewGroup) LayoutInflater.from(context).inflate( diff --git a/java/src/com/android/intentresolver/WorkProfileAvailabilityManager.java b/java/src/com/android/intentresolver/WorkProfileAvailabilityManager.java new file mode 100644 index 00000000..6e51520b --- /dev/null +++ b/java/src/com/android/intentresolver/WorkProfileAvailabilityManager.java @@ -0,0 +1,166 @@ +/* + * 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.BroadcastReceiver; +import android.content.Context; +import android.content.Intent; +import android.content.IntentFilter; +import android.os.AsyncTask; +import android.os.UserHandle; +import android.os.UserManager; +import android.text.TextUtils; + +import androidx.annotation.VisibleForTesting; + +/** Monitor for runtime conditions that may disable work profile display. */ +public class WorkProfileAvailabilityManager { + private final UserManager mUserManager; + private final UserHandle mWorkProfileUserHandle; + private final Runnable mOnWorkProfileStateUpdated; + + private BroadcastReceiver mWorkProfileStateReceiver; + + private boolean mIsWaitingToEnableWorkProfile; + private boolean mWorkProfileHasBeenEnabled; + + public WorkProfileAvailabilityManager( + UserManager userManager, + UserHandle workProfileUserHandle, + Runnable onWorkProfileStateUpdated) { + mUserManager = userManager; + mWorkProfileUserHandle = workProfileUserHandle; + mWorkProfileHasBeenEnabled = isWorkProfileEnabled(); + mOnWorkProfileStateUpdated = onWorkProfileStateUpdated; + } + + /** + * Register a {@link BroadcastReceiver}, if we haven't already, to be notified about work + * profile availability changes. + * + * TODO: this takes the context for testing, because we don't have a context on hand when we + * set up this component's default "override" in {@link ChooserActivityOverrideData#reset()}. + * The use of these overrides in our testing design is questionable and can hopefully be + * improved someday; then this context should be injected in our constructor & held as `final`. + * + * TODO: consider injecting an optional `Lifecycle` so that this component can automatically + * manage its own registration/unregistration. (This would be optional because registration of + * the receiver is conditional on having `shouldShowTabs()` in our session.) + */ + public void registerWorkProfileStateReceiver(Context context) { + if (mWorkProfileStateReceiver != null) { + return; + } + mWorkProfileStateReceiver = createWorkProfileStateReceiver(); + + IntentFilter filter = new IntentFilter(); + filter.addAction(Intent.ACTION_USER_UNLOCKED); + filter.addAction(Intent.ACTION_MANAGED_PROFILE_AVAILABLE); + filter.addAction(Intent.ACTION_MANAGED_PROFILE_UNAVAILABLE); + context.registerReceiverAsUser( + mWorkProfileStateReceiver, UserHandle.ALL, filter, null, null); + } + + /** + * Unregister any {@link BroadcastReceiver} currently waiting for a work-enabled broadcast. + * + * TODO: this takes the context for testing, because we don't have a context on hand when we + * set up this component's default "override" in {@link ChooserActivityOverrideData#reset()}. + * The use of these overrides in our testing design is questionable and can hopefully be + * improved someday; then this context should be injected in our constructor & held as `final`. + */ + public void unregisterWorkProfileStateReceiver(Context context) { + if (mWorkProfileStateReceiver == null) { + return; + } + context.unregisterReceiver(mWorkProfileStateReceiver); + mWorkProfileStateReceiver = null; + } + + public boolean isQuietModeEnabled() { + return mUserManager.isQuietModeEnabled(mWorkProfileUserHandle); + } + + // TODO: why do clients only care about the result of `isQuietModeEnabled()`, even though + // internally (in `isWorkProfileEnabled()`) we also check this 'unlocked' condition? + @VisibleForTesting + public boolean isWorkProfileUserUnlocked() { + return mUserManager.isUserUnlocked(mWorkProfileUserHandle); + } + + /** + * Request that quiet mode be enabled (or disabled) for the work profile. + * TODO: this is only used to disable quiet mode; should that be hard-coded? + */ + public void requestQuietModeEnabled(boolean enabled) { + AsyncTask.THREAD_POOL_EXECUTOR.execute( + () -> mUserManager.requestQuietModeEnabled(enabled, mWorkProfileUserHandle)); + mIsWaitingToEnableWorkProfile = true; + } + + /** + * Stop waiting for a work-enabled broadcast. + * TODO: this seems strangely low-level to include as part of the public API. Maybe some + * responsibilities need to be pulled over from the client? + */ + public void markWorkProfileEnabledBroadcastReceived() { + mIsWaitingToEnableWorkProfile = false; + } + + public boolean isWaitingToEnableWorkProfile() { + return mIsWaitingToEnableWorkProfile; + } + + private boolean isWorkProfileEnabled() { + return (mWorkProfileUserHandle != null) + && !isQuietModeEnabled() + && isWorkProfileUserUnlocked(); + } + + private BroadcastReceiver createWorkProfileStateReceiver() { + return new BroadcastReceiver() { + @Override + public void onReceive(Context context, Intent intent) { + String action = intent.getAction(); + if (!TextUtils.equals(action, Intent.ACTION_USER_UNLOCKED) + && !TextUtils.equals(action, Intent.ACTION_MANAGED_PROFILE_UNAVAILABLE) + && !TextUtils.equals(action, Intent.ACTION_MANAGED_PROFILE_AVAILABLE)) { + return; + } + + if (intent.getIntExtra(Intent.EXTRA_USER_HANDLE, -1) + != mWorkProfileUserHandle.getIdentifier()) { + return; + } + + if (isWorkProfileEnabled()) { + if (mWorkProfileHasBeenEnabled) { + return; + } + mWorkProfileHasBeenEnabled = true; + mIsWaitingToEnableWorkProfile = false; + } else { + // Must be an UNAVAILABLE broadcast, so we watch for the next availability. + // TODO: confirm the above reasoning (& handling of "UNAVAILABLE" in general). + mWorkProfileHasBeenEnabled = false; + } + + mOnWorkProfileStateUpdated.run(); + } + }; + } +} diff --git a/java/src/com/android/intentresolver/WorkProfilePausedEmptyStateProvider.java b/java/src/com/android/intentresolver/WorkProfilePausedEmptyStateProvider.java index b7c89907..0333039b 100644 --- a/java/src/com/android/intentresolver/WorkProfilePausedEmptyStateProvider.java +++ b/java/src/com/android/intentresolver/WorkProfilePausedEmptyStateProvider.java @@ -26,11 +26,10 @@ import android.content.Context; import android.os.UserHandle; import android.stats.devicepolicy.nano.DevicePolicyEnums; -import com.android.internal.R; import com.android.intentresolver.AbstractMultiProfilePagerAdapter.EmptyState; import com.android.intentresolver.AbstractMultiProfilePagerAdapter.EmptyStateProvider; import com.android.intentresolver.AbstractMultiProfilePagerAdapter.OnSwitchOnWorkSelectedListener; -import com.android.intentresolver.AbstractMultiProfilePagerAdapter.QuietModeManager; +import com.android.internal.R; /** * Chooser/ResolverActivity empty state provider that returns empty state which is shown when @@ -39,19 +38,19 @@ import com.android.intentresolver.AbstractMultiProfilePagerAdapter.QuietModeMana public class WorkProfilePausedEmptyStateProvider implements EmptyStateProvider { private final UserHandle mWorkProfileUserHandle; - private final QuietModeManager mQuietModeManager; + 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 QuietModeManager quietModeManager, + @NonNull WorkProfileAvailabilityManager workProfileAvailability, @Nullable OnSwitchOnWorkSelectedListener onSwitchOnWorkSelectedListener, @NonNull String metricsCategory) { mContext = context; mWorkProfileUserHandle = workProfileUserHandle; - mQuietModeManager = quietModeManager; + mWorkProfileAvailability = workProfileAvailability; mMetricsCategory = metricsCategory; mOnSwitchOnWorkSelectedListener = onSwitchOnWorkSelectedListener; } @@ -60,7 +59,7 @@ public class WorkProfilePausedEmptyStateProvider implements EmptyStateProvider { @Override public EmptyState getEmptyState(ResolverListAdapter resolverListAdapter) { if (!resolverListAdapter.getUserHandle().equals(mWorkProfileUserHandle) - || !mQuietModeManager.isQuietModeEnabled(mWorkProfileUserHandle) + || !mWorkProfileAvailability.isQuietModeEnabled() || resolverListAdapter.getCount() == 0) { return null; } @@ -74,7 +73,7 @@ public class WorkProfilePausedEmptyStateProvider implements EmptyStateProvider { if (mOnSwitchOnWorkSelectedListener != null) { mOnSwitchOnWorkSelectedListener.onSwitchOnWorkSelected(); } - mQuietModeManager.requestQuietModeEnabled(false, mWorkProfileUserHandle); + mWorkProfileAvailability.requestQuietModeEnabled(false); }, mMetricsCategory); } diff --git a/java/tests/src/com/android/intentresolver/ChooserActivityOverrideData.java b/java/tests/src/com/android/intentresolver/ChooserActivityOverrideData.java index 857fa124..0d5b58dc 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.AbstractMultiProfilePagerAdapter.MyUserIdProvider; -import com.android.intentresolver.AbstractMultiProfilePagerAdapter.QuietModeManager; import com.android.intentresolver.chooser.TargetInfo; import com.android.intentresolver.flags.FeatureFlagRepository; import com.android.intentresolver.shortcuts.ShortcutLoader; @@ -73,7 +72,7 @@ public class ChooserActivityOverrideData { public boolean hasCrossProfileIntents; public boolean isQuietModeEnabled; public Integer myUserId; - public QuietModeManager mQuietModeManager; + public WorkProfileAvailabilityManager mWorkProfileAvailability; public MyUserIdProvider mMyUserIdProvider; public CrossProfileIntentsChecker mCrossProfileIntentsChecker; public PackageManager packageManager; @@ -97,22 +96,25 @@ public class ChooserActivityOverrideData { isQuietModeEnabled = false; myUserId = null; packageManager = null; - mQuietModeManager = new QuietModeManager() { + mWorkProfileAvailability = new WorkProfileAvailabilityManager(null, null, null) { @Override - public boolean isQuietModeEnabled(UserHandle workProfileUserHandle) { + public boolean isQuietModeEnabled() { return isQuietModeEnabled; } @Override - public void requestQuietModeEnabled(boolean enabled, - UserHandle workProfileUserHandle) { - isQuietModeEnabled = enabled; + public boolean isWorkProfileUserUnlocked() { + return true; } @Override - public void markWorkProfileEnabledBroadcastReceived() { + public void requestQuietModeEnabled(boolean enabled) { + isQuietModeEnabled = enabled; } + @Override + public void markWorkProfileEnabledBroadcastReceived() {} + @Override public boolean isWaitingToEnableWorkProfile() { return false; diff --git a/java/tests/src/com/android/intentresolver/ChooserWrapperActivity.java b/java/tests/src/com/android/intentresolver/ChooserWrapperActivity.java index 17084e1c..c1c34604 100644 --- a/java/tests/src/com/android/intentresolver/ChooserWrapperActivity.java +++ b/java/tests/src/com/android/intentresolver/ChooserWrapperActivity.java @@ -35,7 +35,6 @@ import android.os.UserHandle; import com.android.intentresolver.AbstractMultiProfilePagerAdapter.CrossProfileIntentsChecker; import com.android.intentresolver.AbstractMultiProfilePagerAdapter.MyUserIdProvider; -import com.android.intentresolver.AbstractMultiProfilePagerAdapter.QuietModeManager; import com.android.intentresolver.chooser.DisplayResolveInfo; import com.android.intentresolver.chooser.TargetInfo; import com.android.intentresolver.flags.FeatureFlagRepository; @@ -161,11 +160,11 @@ public class ChooserWrapperActivity } @Override - protected QuietModeManager createQuietModeManager() { - if (sOverrides.mQuietModeManager != null) { - return sOverrides.mQuietModeManager; + protected WorkProfileAvailabilityManager createWorkProfileAvailabilityManager() { + if (sOverrides.mWorkProfileAvailability != null) { + return sOverrides.mWorkProfileAvailability; } - return super.createQuietModeManager(); + return super.createWorkProfileAvailabilityManager(); } @Override diff --git a/java/tests/src/com/android/intentresolver/ResolverWrapperActivity.java b/java/tests/src/com/android/intentresolver/ResolverWrapperActivity.java index 239bffe0..ade8cc77 100644 --- a/java/tests/src/com/android/intentresolver/ResolverWrapperActivity.java +++ b/java/tests/src/com/android/intentresolver/ResolverWrapperActivity.java @@ -31,7 +31,6 @@ import android.os.UserHandle; import com.android.intentresolver.AbstractMultiProfilePagerAdapter.CrossProfileIntentsChecker; import com.android.intentresolver.AbstractMultiProfilePagerAdapter.MyUserIdProvider; -import com.android.intentresolver.AbstractMultiProfilePagerAdapter.QuietModeManager; import com.android.intentresolver.chooser.TargetInfo; import java.util.List; @@ -88,11 +87,11 @@ public class ResolverWrapperActivity extends ResolverActivity { } @Override - protected QuietModeManager createQuietModeManager() { - if (sOverrides.mQuietModeManager != null) { - return sOverrides.mQuietModeManager; + protected WorkProfileAvailabilityManager createWorkProfileAvailabilityManager() { + if (sOverrides.mWorkProfileAvailability != null) { + return sOverrides.mWorkProfileAvailability; } - return super.createQuietModeManager(); + return super.createWorkProfileAvailabilityManager(); } ResolverWrapperAdapter getAdapter() { @@ -175,7 +174,7 @@ public class ResolverWrapperActivity extends ResolverActivity { public Integer myUserId; public boolean hasCrossProfileIntents; public boolean isQuietModeEnabled; - public QuietModeManager mQuietModeManager; + public WorkProfileAvailabilityManager mWorkProfileAvailability; public MyUserIdProvider mMyUserIdProvider; public CrossProfileIntentsChecker mCrossProfileIntentsChecker; @@ -190,22 +189,25 @@ public class ResolverWrapperActivity extends ResolverActivity { hasCrossProfileIntents = true; isQuietModeEnabled = false; - mQuietModeManager = new QuietModeManager() { + mWorkProfileAvailability = new WorkProfileAvailabilityManager(null, null, null) { @Override - public boolean isQuietModeEnabled(UserHandle workProfileUserHandle) { + public boolean isQuietModeEnabled() { return isQuietModeEnabled; } @Override - public void requestQuietModeEnabled(boolean enabled, - UserHandle workProfileUserHandle) { - isQuietModeEnabled = enabled; + public boolean isWorkProfileUserUnlocked() { + return true; } @Override - public void markWorkProfileEnabledBroadcastReceived() { + public void requestQuietModeEnabled(boolean enabled) { + isQuietModeEnabled = enabled; } + @Override + public void markWorkProfileEnabledBroadcastReceived() {} + @Override public boolean isWaitingToEnableWorkProfile() { return false; -- cgit v1.2.3-59-g8ed1b From 248b655453b87149990b9e9138139bcf54291a90 Mon Sep 17 00:00:00 2001 From: Joshua Trask Date: Wed, 22 Feb 2023 18:23:44 +0000 Subject: Remove UserHandle ivar from ResolverListController. ResolverListController instances are 1:1 with the ResolverListAdapter instances that hold them, and the adapters already carry their associated UserHandles, so this was just one more "source of truth" to potentially get out of sync as we're trying to clean up management of these handles throughout the project. (And by exposing the handle in a now-removed getter, it also added a redundant mechanism for clients to access this value, making it harder to track down all the usages in our application.) I also took this opportunity to factor out a lot of boilerplate in our tests to use a helper. That's not generally considered a good practice for testing, but in this case the helper already existed and just wasn't used in some cases -- where we instead duplicated verbose boilerplate that represents less about the conditions we'd actually expect to see in practice. Long-term we should clean up a *lot* of details in our test design, so I think for now it's OK to just use a more terse version of the design we already have. Eventually I'd like to break up ResolverListController into a few components with narrower focus, so removing any unnecessary responsibilities is a great start. Test: `atest IntentResolverUnitTests` Bug: 202167050 Change-Id: Iade5b8d1f4e31d5439c234fbb1d82169ec01a386 --- .../android/intentresolver/ChooserActivity.java | 15 +- .../android/intentresolver/ResolverActivity.java | 19 +- .../intentresolver/ResolverListAdapter.java | 12 +- .../intentresolver/ResolverListController.java | 23 +- .../ChooserActivityOverrideData.java | 8 +- .../intentresolver/ChooserWrapperActivity.java | 6 +- .../intentresolver/ResolverActivityTest.java | 60 +-- .../intentresolver/ResolverWrapperActivity.java | 2 - .../UnbundledChooserActivityTest.java | 522 +++------------------ .../UnbundledChooserActivityWorkProfileTest.java | 20 +- 10 files changed, 138 insertions(+), 549 deletions(-) (limited to 'java/src') diff --git a/java/src/com/android/intentresolver/ChooserActivity.java b/java/src/com/android/intentresolver/ChooserActivity.java index 72336e84..da3694c4 100644 --- a/java/src/com/android/intentresolver/ChooserActivity.java +++ b/java/src/com/android/intentresolver/ChooserActivity.java @@ -1165,14 +1165,19 @@ public class ChooserActivity extends ResolverActivity implements } public class ChooserListController extends ResolverListController { - public ChooserListController(Context context, + public ChooserListController( + Context context, PackageManager pm, Intent targetIntent, String referrerPackageName, int launchedFromUid, - UserHandle userId, AbstractResolverComparator resolverComparator) { - super(context, pm, targetIntent, referrerPackageName, launchedFromUid, userId, + super( + context, + pm, + targetIntent, + referrerPackageName, + launchedFromUid, resolverComparator); } @@ -1308,8 +1313,9 @@ public class ChooserActivity extends ResolverActivity implements maxTargetsPerRow); } + @Override @VisibleForTesting - protected ResolverListController createListController(UserHandle userHandle) { + protected ChooserListController createListController(UserHandle userHandle) { AppPredictor appPredictor = getAppPredictor(userHandle); AbstractResolverComparator resolverComparator; if (appPredictor != null) { @@ -1327,7 +1333,6 @@ public class ChooserActivity extends ResolverActivity implements getTargetIntent(), getReferrerPackageName(), getAnnotatedUserHandles().userIdOfCallingApp, - userHandle, resolverComparator); } diff --git a/java/src/com/android/intentresolver/ResolverActivity.java b/java/src/com/android/intentresolver/ResolverActivity.java index faccbc36..08f42404 100644 --- a/java/src/com/android/intentresolver/ResolverActivity.java +++ b/java/src/com/android/intentresolver/ResolverActivity.java @@ -849,15 +849,25 @@ public class ResolverActivity extends FragmentActivity implements return !target.isSuspended(); } + // TODO: this method takes an unused `UserHandle` because the override in `ChooserActivity` uses + // that data to set up other components as dependencies of the controller. In reality, these + // methods don't require polymorphism, because they're only invoked from within their respective + // concrete class; `ResolverActivity` will never call this method expecting to get a + // `ChooserListController` (subclass) result, because `ResolverActivity` only invokes this + // method as part of handling `createMultiProfilePagerAdapter()`, which is itself overridden in + // `ChooserActivity`. A future refactoring could better express the coupling between the adapter + // and controller types; in the meantime, structuring as an override (with matching signatures) + // shows that these methods are *structurally* related, and helps to prevent any regressions in + // the future if resolver *were* to make any (non-overridden) calls to a version that used a + // different signature (and thus didn't return the subclass type). @VisibleForTesting - protected ResolverListController createListController(UserHandle userHandle) { + protected ResolverListController createListController(UserHandle unused) { return new ResolverListController( this, mPm, getTargetIntent(), getReferrerPackageName(), - getAnnotatedUserHandles().userIdOfCallingApp, - userHandle); + getAnnotatedUserHandles().userIdOfCallingApp); } /** @@ -1719,8 +1729,7 @@ public class ResolverActivity extends FragmentActivity implements findViewById(com.android.internal.R.id.button_open).setOnClickListener(v -> { Intent intent = otherProfileResolveInfo.getResolvedIntent(); - safelyStartActivityAsUser(otherProfileResolveInfo, - inactiveAdapter.mResolverListController.getUserHandle()); + safelyStartActivityAsUser(otherProfileResolveInfo, inactiveAdapter.getUserHandle()); finish(); }); } diff --git a/java/src/com/android/intentresolver/ResolverListAdapter.java b/java/src/com/android/intentresolver/ResolverListAdapter.java index 7a258a4c..c8a9d5dc 100644 --- a/java/src/com/android/intentresolver/ResolverListAdapter.java +++ b/java/src/com/android/intentresolver/ResolverListAdapter.java @@ -289,11 +289,7 @@ public class ResolverListAdapter extends BaseAdapter { mBaseResolveList); return currentResolveList; } else { - return mResolverListController.getResolversForIntent( - /* shouldGetResolvedFilter= */ true, - mResolverListCommunicator.shouldGetActivityMetadata(), - mResolverListCommunicator.shouldGetOnlyDefaultActivities(), - mIntents); + return getResolversForUser(mUserHandle); } } @@ -804,10 +800,12 @@ public class ResolverListAdapter extends BaseAdapter { } protected List getResolversForUser(UserHandle userHandle) { - return mResolverListController.getResolversForIntentAsUser(true, + return mResolverListController.getResolversForIntentAsUser( + /* shouldGetResolvedFilter= */ true, mResolverListCommunicator.shouldGetActivityMetadata(), mResolverListCommunicator.shouldGetOnlyDefaultActivities(), - mIntents, userHandle); + mIntents, + userHandle); } protected List getIntents() { diff --git a/java/src/com/android/intentresolver/ResolverListController.java b/java/src/com/android/intentresolver/ResolverListController.java index bfffe0d8..6eb027ea 100644 --- a/java/src/com/android/intentresolver/ResolverListController.java +++ b/java/src/com/android/intentresolver/ResolverListController.java @@ -58,7 +58,6 @@ public class ResolverListController { private static final String TAG = "ResolverListController"; private static final boolean DEBUG = false; - private final UserHandle mUserHandle; private AbstractResolverComparator mResolverComparator; private boolean isComputed = false; @@ -68,9 +67,8 @@ public class ResolverListController { PackageManager pm, Intent targetIntent, String referrerPackage, - int launchedFromUid, - UserHandle userHandle) { - this(context, pm, targetIntent, referrerPackage, launchedFromUid, userHandle, + int launchedFromUid) { + this(context, pm, targetIntent, referrerPackage, launchedFromUid, new ResolverRankerServiceResolverComparator( context, targetIntent, referrerPackage, null, null)); } @@ -81,14 +79,12 @@ public class ResolverListController { Intent targetIntent, String referrerPackage, int launchedFromUid, - UserHandle userHandle, AbstractResolverComparator resolverComparator) { mContext = context; mpm = pm; mLaunchedFromUid = launchedFromUid; mTargetIntent = targetIntent; mReferrerPackage = referrerPackage; - mUserHandle = userHandle; mResolverComparator = resolverComparator; } @@ -108,16 +104,6 @@ public class ResolverListController { filter, match, intent.getComponent()); } - @VisibleForTesting - public List getResolversForIntent( - boolean shouldGetResolvedFilter, - boolean shouldGetActivityMetadata, - boolean shouldGetOnlyDefaultActivities, - List intents) { - return getResolversForIntentAsUser(shouldGetResolvedFilter, shouldGetActivityMetadata, - shouldGetOnlyDefaultActivities, intents, mUserHandle); - } - public List getResolversForIntentAsUser( boolean shouldGetResolvedFilter, boolean shouldGetActivityMetadata, @@ -159,11 +145,6 @@ public class ResolverListController { return resolvedComponents; } - @VisibleForTesting - public UserHandle getUserHandle() { - return mUserHandle; - } - @VisibleForTesting public void addResolveListDedupe(List into, Intent intent, diff --git a/java/tests/src/com/android/intentresolver/ChooserActivityOverrideData.java b/java/tests/src/com/android/intentresolver/ChooserActivityOverrideData.java index 0d5b58dc..f0c459e5 100644 --- a/java/tests/src/com/android/intentresolver/ChooserActivityOverrideData.java +++ b/java/tests/src/com/android/intentresolver/ChooserActivityOverrideData.java @@ -58,8 +58,8 @@ public class ChooserActivityOverrideData { public Function onSafelyStartCallback; public Function2, ShortcutLoader> shortcutLoaderFactory = (userHandle, callback) -> null; - public ResolverListController resolverListController; - public ResolverListController workResolverListController; + public ChooserActivity.ChooserListController resolverListController; + public ChooserActivity.ChooserListController workResolverListController; public Boolean isVoiceInteraction; public boolean isImageType; public Cursor resolverCursor; @@ -86,8 +86,8 @@ public class ChooserActivityOverrideData { isImageType = false; resolverCursor = null; resolverForceException = false; - resolverListController = mock(ResolverListController.class); - workResolverListController = mock(ResolverListController.class); + resolverListController = mock(ChooserActivity.ChooserListController.class); + workResolverListController = mock(ChooserActivity.ChooserListController.class); chooserActivityLogger = mock(ChooserActivityLogger.class); alternateProfileSetting = 0; resources = null; diff --git a/java/tests/src/com/android/intentresolver/ChooserWrapperActivity.java b/java/tests/src/com/android/intentresolver/ChooserWrapperActivity.java index c1c34604..d4ae666b 100644 --- a/java/tests/src/com/android/intentresolver/ChooserWrapperActivity.java +++ b/java/tests/src/com/android/intentresolver/ChooserWrapperActivity.java @@ -16,8 +16,6 @@ package com.android.intentresolver; -import static org.mockito.Mockito.when; - import android.annotation.Nullable; import android.app.prediction.AppPredictor; import android.app.usage.UsageStatsManager; @@ -177,12 +175,10 @@ public class ChooserWrapperActivity } @Override - protected ResolverListController createListController(UserHandle userHandle) { + protected ChooserListController createListController(UserHandle userHandle) { if (userHandle == UserHandle.SYSTEM) { - when(sOverrides.resolverListController.getUserHandle()).thenReturn(UserHandle.SYSTEM); return sOverrides.resolverListController; } - when(sOverrides.workResolverListController.getUserHandle()).thenReturn(userHandle); return sOverrides.workResolverListController; } diff --git a/java/tests/src/com/android/intentresolver/ResolverActivityTest.java b/java/tests/src/com/android/intentresolver/ResolverActivityTest.java index 62c16ff5..7950b16b 100644 --- a/java/tests/src/com/android/intentresolver/ResolverActivityTest.java +++ b/java/tests/src/com/android/intentresolver/ResolverActivityTest.java @@ -101,10 +101,7 @@ public class ResolverActivityTest { Intent sendIntent = createSendImageIntent(); List resolvedComponentInfos = createResolvedComponentsForTest(2); - when(sOverrides.resolverListController.getResolversForIntent(Mockito.anyBoolean(), - Mockito.anyBoolean(), - Mockito.anyBoolean(), - Mockito.isA(List.class))).thenReturn(resolvedComponentInfos); + setupResolverControllers(resolvedComponentInfos); final ResolverWrapperActivity activity = mActivityRule.launchActivity(sendIntent); Espresso.registerIdlingResources(activity.getAdapter().getLabelIdlingResource()); @@ -133,10 +130,7 @@ public class ResolverActivityTest { Intent sendIntent = createSendImageIntent(); List resolvedComponentInfos = createResolvedComponentsForTest(2); - when(sOverrides.resolverListController.getResolversForIntent(Mockito.anyBoolean(), - Mockito.anyBoolean(), - Mockito.anyBoolean(), - Mockito.isA(List.class))).thenReturn(resolvedComponentInfos); + setupResolverControllers(resolvedComponentInfos); waitForIdle(); final ResolverWrapperActivity activity = mActivityRule.launchActivity(sendIntent); @@ -178,10 +172,7 @@ public class ResolverActivityTest { Intent sendIntent = createSendImageIntent(); List resolvedComponentInfos = createResolvedComponentsForTest(2); - when(sOverrides.resolverListController.getResolversForIntent(Mockito.anyBoolean(), - Mockito.anyBoolean(), - Mockito.anyBoolean(), - Mockito.isA(List.class))).thenReturn(resolvedComponentInfos); + setupResolverControllers(resolvedComponentInfos); waitForIdle(); final ResolverWrapperActivity activity = mActivityRule.launchActivity(sendIntent); @@ -211,10 +202,7 @@ public class ResolverActivityTest { List resolvedComponentInfos = createResolvedComponentsForTest(2); ResolveInfo toChoose = resolvedComponentInfos.get(0).getResolveInfoAt(0); - when(sOverrides.resolverListController.getResolversForIntent(Mockito.anyBoolean(), - Mockito.anyBoolean(), - Mockito.anyBoolean(), - Mockito.isA(List.class))).thenReturn(resolvedComponentInfos); + setupResolverControllers(resolvedComponentInfos); when(sOverrides.resolverListController.getLastChosen()) .thenReturn(resolvedComponentInfos.get(0).getResolveInfoAt(0)); @@ -277,10 +265,7 @@ public class ResolverActivityTest { createResolvedComponentsForTestWithOtherProfile(3); ResolveInfo toChoose = resolvedComponentInfos.get(1).getResolveInfoAt(0); - when(sOverrides.resolverListController.getResolversForIntent(Mockito.anyBoolean(), - Mockito.anyBoolean(), - Mockito.anyBoolean(), - Mockito.isA(List.class))).thenReturn(resolvedComponentInfos); + setupResolverControllers(resolvedComponentInfos); final ResolverWrapperActivity activity = mActivityRule.launchActivity(sendIntent); Espresso.registerIdlingResources(activity.getAdapter().getLabelIdlingResource()); @@ -319,10 +304,7 @@ public class ResolverActivityTest { createResolvedComponentsForTestWithOtherProfile(3); ResolveInfo toChoose = resolvedComponentInfos.get(1).getResolveInfoAt(0); - when(sOverrides.resolverListController.getResolversForIntent(Mockito.anyBoolean(), - Mockito.anyBoolean(), - Mockito.anyBoolean(), - Mockito.isA(List.class))).thenReturn(resolvedComponentInfos); + setupResolverControllers(resolvedComponentInfos); when(sOverrides.resolverListController.getLastChosen()) .thenReturn(resolvedComponentInfos.get(1).getResolveInfoAt(0)); @@ -761,10 +743,7 @@ public class ResolverActivityTest { List resolvedComponentInfos = createResolvedComponentsForTest(2); - when(sOverrides.resolverListController.getResolversForIntent(Mockito.anyBoolean(), - Mockito.anyBoolean(), - Mockito.anyBoolean(), - Mockito.isA(List.class))).thenReturn(resolvedComponentInfos); + setupResolverControllers(resolvedComponentInfos); when(sOverrides.resolverListController.getLastChosen()) .thenReturn(resolvedComponentInfos.get(1).getResolveInfoAt(0)); @@ -836,23 +815,34 @@ public class ResolverActivityTest { ResolverWrapperActivity.sOverrides.workProfileUserHandle = UserHandle.of(10); } + private void setupResolverControllers( + List personalResolvedComponentInfos) { + setupResolverControllers(personalResolvedComponentInfos, new ArrayList<>()); + } + private void setupResolverControllers( List personalResolvedComponentInfos, List workResolvedComponentInfos) { - when(sOverrides.resolverListController.getResolversForIntent(Mockito.anyBoolean(), + when(sOverrides.resolverListController.getResolversForIntentAsUser( Mockito.anyBoolean(), Mockito.anyBoolean(), - Mockito.isA(List.class))) - .thenReturn(new ArrayList<>(personalResolvedComponentInfos)); - when(sOverrides.workResolverListController.getResolversForIntent(Mockito.anyBoolean(), Mockito.anyBoolean(), + Mockito.isA(List.class), + eq(UserHandle.SYSTEM))) + .thenReturn(new ArrayList<>(personalResolvedComponentInfos)); + when(sOverrides.workResolverListController.getResolversForIntentAsUser( Mockito.anyBoolean(), - Mockito.isA(List.class))).thenReturn(workResolvedComponentInfos); - when(sOverrides.workResolverListController.getResolversForIntentAsUser(Mockito.anyBoolean(), Mockito.anyBoolean(), Mockito.anyBoolean(), Mockito.isA(List.class), eq(UserHandle.SYSTEM))) - .thenReturn(new ArrayList<>(personalResolvedComponentInfos)); + .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/ResolverWrapperActivity.java b/java/tests/src/com/android/intentresolver/ResolverWrapperActivity.java index ade8cc77..d67b73af 100644 --- a/java/tests/src/com/android/intentresolver/ResolverWrapperActivity.java +++ b/java/tests/src/com/android/intentresolver/ResolverWrapperActivity.java @@ -129,10 +129,8 @@ public class ResolverWrapperActivity extends ResolverActivity { @Override protected ResolverListController createListController(UserHandle userHandle) { if (userHandle == UserHandle.SYSTEM) { - when(sOverrides.resolverListController.getUserHandle()).thenReturn(UserHandle.SYSTEM); return sOverrides.resolverListController; } - when(sOverrides.workResolverListController.getUserHandle()).thenReturn(userHandle); return sOverrides.workResolverListController; } diff --git a/java/tests/src/com/android/intentresolver/UnbundledChooserActivityTest.java b/java/tests/src/com/android/intentresolver/UnbundledChooserActivityTest.java index 82a635dd..b0c0f360 100644 --- a/java/tests/src/com/android/intentresolver/UnbundledChooserActivityTest.java +++ b/java/tests/src/com/android/intentresolver/UnbundledChooserActivityTest.java @@ -332,16 +332,7 @@ public class UnbundledChooserActivityTest { Intent viewIntent = createViewTextIntent(); List resolvedComponentInfos = createResolvedComponentsForTest(2); - when( - ChooserActivityOverrideData - .getInstance() - .resolverListController - .getResolversForIntent( - Mockito.anyBoolean(), - Mockito.anyBoolean(), - Mockito.anyBoolean(), - Mockito.isA(List.class))) - .thenReturn(resolvedComponentInfos); + setupResolverControllers(resolvedComponentInfos); final IChooserWrapper activity = (IChooserWrapper) mActivityRule.launchActivity( Intent.createChooser(viewIntent, "chooser test")); @@ -356,16 +347,7 @@ public class UnbundledChooserActivityTest { Intent sendIntent = createSendTextIntent(); List resolvedComponentInfos = createResolvedComponentsForTest(2); - when( - ChooserActivityOverrideData - .getInstance() - .resolverListController - .getResolversForIntent( - Mockito.anyBoolean(), - Mockito.anyBoolean(), - Mockito.anyBoolean(), - Mockito.isA(List.class))) - .thenReturn(resolvedComponentInfos); + setupResolverControllers(resolvedComponentInfos); mActivityRule.launchActivity(Intent.createChooser(sendIntent, "chooser test")); waitForIdle(); onView(withId(android.R.id.title)) @@ -377,16 +359,7 @@ public class UnbundledChooserActivityTest { Intent sendIntent = createSendTextIntent(); List resolvedComponentInfos = createResolvedComponentsForTest(2); - when( - ChooserActivityOverrideData - .getInstance() - .resolverListController - .getResolversForIntent( - Mockito.anyBoolean(), - Mockito.anyBoolean(), - Mockito.anyBoolean(), - Mockito.isA(List.class))) - .thenReturn(resolvedComponentInfos); + setupResolverControllers(resolvedComponentInfos); mActivityRule.launchActivity(Intent.createChooser(sendIntent, null)); waitForIdle(); onView(withId(android.R.id.title)) @@ -398,16 +371,7 @@ public class UnbundledChooserActivityTest { Intent sendIntent = createSendTextIntentWithPreview(null, null); List resolvedComponentInfos = createResolvedComponentsForTest(2); - when( - ChooserActivityOverrideData - .getInstance() - .resolverListController - .getResolversForIntent( - Mockito.anyBoolean(), - Mockito.anyBoolean(), - Mockito.anyBoolean(), - Mockito.isA(List.class))) - .thenReturn(resolvedComponentInfos); + setupResolverControllers(resolvedComponentInfos); mActivityRule.launchActivity(Intent.createChooser(sendIntent, null)); waitForIdle(); onView(withId(com.android.internal.R.id.content_preview_title)) @@ -422,16 +386,7 @@ public class UnbundledChooserActivityTest { Intent sendIntent = createSendTextIntentWithPreview(previewTitle, null); List resolvedComponentInfos = createResolvedComponentsForTest(2); - when( - ChooserActivityOverrideData - .getInstance() - .resolverListController - .getResolversForIntent( - Mockito.anyBoolean(), - Mockito.anyBoolean(), - Mockito.anyBoolean(), - Mockito.isA(List.class))) - .thenReturn(resolvedComponentInfos); + setupResolverControllers(resolvedComponentInfos); mActivityRule.launchActivity(Intent.createChooser(sendIntent, null)); waitForIdle(); onView(withId(com.android.internal.R.id.content_preview_title)) @@ -449,16 +404,7 @@ public class UnbundledChooserActivityTest { Uri.parse("tel:(+49)12345789")); List resolvedComponentInfos = createResolvedComponentsForTest(2); - when( - ChooserActivityOverrideData - .getInstance() - .resolverListController - .getResolversForIntent( - Mockito.anyBoolean(), - Mockito.anyBoolean(), - Mockito.anyBoolean(), - Mockito.isA(List.class))) - .thenReturn(resolvedComponentInfos); + setupResolverControllers(resolvedComponentInfos); mActivityRule.launchActivity(Intent.createChooser(sendIntent, null)); waitForIdle(); onView(withId(com.android.internal.R.id.content_preview_title)) @@ -476,16 +422,7 @@ public class UnbundledChooserActivityTest { ChooserActivityOverrideData.getInstance().previewThumbnail = createBitmap(); List resolvedComponentInfos = createResolvedComponentsForTest(2); - when( - ChooserActivityOverrideData - .getInstance() - .resolverListController - .getResolversForIntent( - Mockito.anyBoolean(), - Mockito.anyBoolean(), - Mockito.anyBoolean(), - Mockito.isA(List.class))) - .thenReturn(resolvedComponentInfos); + setupResolverControllers(resolvedComponentInfos); mActivityRule.launchActivity(Intent.createChooser(sendIntent, null)); waitForIdle(); onView(withId(com.android.internal.R.id.content_preview_title)) @@ -499,16 +436,7 @@ public class UnbundledChooserActivityTest { Intent sendIntent = createSendTextIntent(); List resolvedComponentInfos = createResolvedComponentsForTest(2); - when( - ChooserActivityOverrideData - .getInstance() - .resolverListController - .getResolversForIntent( - Mockito.anyBoolean(), - Mockito.anyBoolean(), - Mockito.anyBoolean(), - Mockito.isA(List.class))) - .thenReturn(resolvedComponentInfos); + setupResolverControllers(resolvedComponentInfos); final IChooserWrapper activity = (IChooserWrapper) mActivityRule.launchActivity(Intent.createChooser(sendIntent, null)); @@ -554,16 +482,7 @@ public class UnbundledChooserActivityTest { } resolvedComponentInfos.addAll(infosToStack); - when( - ChooserActivityOverrideData - .getInstance() - .resolverListController - .getResolversForIntent( - Mockito.anyBoolean(), - Mockito.anyBoolean(), - Mockito.anyBoolean(), - Mockito.isA(List.class))) - .thenReturn(resolvedComponentInfos); + setupResolverControllers(resolvedComponentInfos); final IChooserWrapper activity = (IChooserWrapper) mActivityRule.launchActivity(Intent.createChooser(sendIntent, null)); @@ -595,16 +514,7 @@ public class UnbundledChooserActivityTest { Intent sendIntent = createSendTextIntent(); List resolvedComponentInfos = createResolvedComponentsForTest(2); - when( - ChooserActivityOverrideData - .getInstance() - .resolverListController - .getResolversForIntent( - Mockito.anyBoolean(), - Mockito.anyBoolean(), - Mockito.anyBoolean(), - Mockito.isA(List.class))) - .thenReturn(resolvedComponentInfos); + setupResolverControllers(resolvedComponentInfos); final IChooserWrapper activity = (IChooserWrapper) mActivityRule.launchActivity(Intent.createChooser(sendIntent, null)); @@ -630,16 +540,7 @@ public class UnbundledChooserActivityTest { @Ignore // b/148158199 @Test public void noResultsFromPackageManager() { - when( - ChooserActivityOverrideData - .getInstance() - .resolverListController - .getResolversForIntent( - Mockito.anyBoolean(), - Mockito.anyBoolean(), - Mockito.anyBoolean(), - Mockito.isA(List.class))) - .thenReturn(null); + setupResolverControllers(null); Intent sendIntent = createSendTextIntent(); final ChooserActivity activity = mActivityRule.launchActivity(Intent.createChooser(sendIntent, null)); @@ -666,16 +567,7 @@ public class UnbundledChooserActivityTest { }; List resolvedComponentInfos = createResolvedComponentsForTest(1); - when( - ChooserActivityOverrideData - .getInstance() - .resolverListController - .getResolversForIntent( - Mockito.anyBoolean(), - Mockito.anyBoolean(), - Mockito.anyBoolean(), - Mockito.isA(List.class))) - .thenReturn(resolvedComponentInfos); + setupResolverControllers(resolvedComponentInfos); Intent sendIntent = createSendTextIntent(); final ChooserActivity activity = @@ -727,11 +619,7 @@ public class UnbundledChooserActivityTest { createResolvedComponentsForTestWithOtherProfile(3); ResolveInfo toChoose = resolvedComponentInfos.get(1).getResolveInfoAt(0); - when(ChooserActivityOverrideData.getInstance().resolverListController.getResolversForIntent( - Mockito.anyBoolean(), - Mockito.anyBoolean(), - Mockito.anyBoolean(), - Mockito.isA(List.class))).thenReturn(resolvedComponentInfos); + setupResolverControllers(resolvedComponentInfos); when(ChooserActivityOverrideData.getInstance().resolverListController.getLastChosen()) .thenReturn(resolvedComponentInfos.get(0).getResolveInfoAt(0)); @@ -764,11 +652,7 @@ public class UnbundledChooserActivityTest { createResolvedComponentsForTestWithOtherProfile(3); ResolveInfo toChoose = resolvedComponentInfos.get(1).getResolveInfoAt(0); - when(ChooserActivityOverrideData.getInstance().resolverListController.getResolversForIntent( - Mockito.anyBoolean(), - Mockito.anyBoolean(), - Mockito.anyBoolean(), - Mockito.isA(List.class))).thenReturn(resolvedComponentInfos); + setupResolverControllers(resolvedComponentInfos); final IChooserWrapper activity = (IChooserWrapper) mActivityRule.launchActivity(Intent.createChooser(sendIntent, null)); @@ -813,16 +697,7 @@ public class UnbundledChooserActivityTest { new Intent("VIEW_TEXT")) ); - when( - ChooserActivityOverrideData - .getInstance() - .resolverListController - .getResolversForIntent( - Mockito.anyBoolean(), - Mockito.anyBoolean(), - Mockito.anyBoolean(), - Mockito.isA(List.class))) - .thenReturn(resolvedComponentInfos); + setupResolverControllers(resolvedComponentInfos); mActivityRule.launchActivity(Intent.createChooser(sendIntent, null)); waitForIdle(); @@ -866,16 +741,7 @@ public class UnbundledChooserActivityTest { new Intent("VIEW_TEXT")) ); - when( - ChooserActivityOverrideData - .getInstance() - .resolverListController - .getResolversForIntent( - Mockito.anyBoolean(), - Mockito.anyBoolean(), - Mockito.anyBoolean(), - Mockito.isA(List.class))) - .thenReturn(resolvedComponentInfos); + setupResolverControllers(resolvedComponentInfos); mActivityRule.launchActivity(Intent.createChooser(sendIntent, null)); waitForIdle(); @@ -925,16 +791,7 @@ public class UnbundledChooserActivityTest { alternativeIntent) ); - when( - ChooserActivityOverrideData - .getInstance() - .resolverListController - .getResolversForIntent( - Mockito.anyBoolean(), - Mockito.anyBoolean(), - Mockito.anyBoolean(), - Mockito.isA(List.class))) - .thenReturn(resolvedComponentInfos); + setupResolverControllers(resolvedComponentInfos); mActivityRule.launchActivity(Intent.createChooser(sendIntent, null)); waitForIdle(); @@ -961,11 +818,7 @@ public class UnbundledChooserActivityTest { Intent sendIntent = createSendTextIntent(); List resolvedComponentInfos = createResolvedComponentsForTest(2); - when(ChooserActivityOverrideData.getInstance().resolverListController.getResolversForIntent( - Mockito.anyBoolean(), - Mockito.anyBoolean(), - Mockito.anyBoolean(), - Mockito.isA(List.class))).thenReturn(resolvedComponentInfos); + setupResolverControllers(resolvedComponentInfos); final ChooserActivity activity = mActivityRule.launchActivity(Intent.createChooser(sendIntent, null)); @@ -989,11 +842,7 @@ public class UnbundledChooserActivityTest { Intent sendIntent = createSendTextIntent(); List resolvedComponentInfos = createResolvedComponentsForTest(2); - when(ChooserActivityOverrideData.getInstance().resolverListController.getResolversForIntent( - Mockito.anyBoolean(), - Mockito.anyBoolean(), - Mockito.anyBoolean(), - Mockito.isA(List.class))).thenReturn(resolvedComponentInfos); + setupResolverControllers(resolvedComponentInfos); final IChooserWrapper activity = (IChooserWrapper) mActivityRule.launchActivity(Intent.createChooser(sendIntent, null)); @@ -1012,11 +861,7 @@ public class UnbundledChooserActivityTest { Intent sendIntent = createSendTextIntent(); List resolvedComponentInfos = createResolvedComponentsForTest(2); - when(ChooserActivityOverrideData.getInstance().resolverListController.getResolversForIntent( - Mockito.anyBoolean(), - Mockito.anyBoolean(), - Mockito.anyBoolean(), - Mockito.isA(List.class))).thenReturn(resolvedComponentInfos); + setupResolverControllers(resolvedComponentInfos); final IChooserWrapper activity = (IChooserWrapper) mActivityRule.launchActivity(Intent.createChooser(sendIntent, null)); @@ -1042,11 +887,7 @@ public class UnbundledChooserActivityTest { List resolvedComponentInfos = createResolvedComponentsForTest(2); - when(ChooserActivityOverrideData.getInstance().resolverListController.getResolversForIntent( - Mockito.anyBoolean(), - Mockito.anyBoolean(), - Mockito.anyBoolean(), - Mockito.isA(List.class))).thenReturn(resolvedComponentInfos); + setupResolverControllers(resolvedComponentInfos); final IChooserWrapper activity = (IChooserWrapper) mActivityRule.launchActivity(Intent.createChooser(sendIntent, null)); @@ -1073,16 +914,7 @@ public class UnbundledChooserActivityTest { List resolvedComponentInfos = createResolvedComponentsForTest(2); - when( - ChooserActivityOverrideData - .getInstance() - .resolverListController - .getResolversForIntent( - Mockito.anyBoolean(), - Mockito.anyBoolean(), - Mockito.anyBoolean(), - Mockito.isA(List.class))) - .thenReturn(resolvedComponentInfos); + setupResolverControllers(resolvedComponentInfos); mActivityRule.launchActivity(Intent.createChooser(sendIntent, null)); waitForIdle(); onView(withId(com.android.internal.R.id.content_preview_image_area)) @@ -1123,16 +955,7 @@ public class UnbundledChooserActivityTest { List resolvedComponentInfos = createResolvedComponentsForTest(2); - when( - ChooserActivityOverrideData - .getInstance() - .resolverListController - .getResolversForIntent( - Mockito.anyBoolean(), - Mockito.anyBoolean(), - Mockito.anyBoolean(), - Mockito.isA(List.class))) - .thenReturn(resolvedComponentInfos); + setupResolverControllers(resolvedComponentInfos); mActivityRule.launchActivity(Intent.createChooser(sendIntent, null)); waitForIdle(); onView(withId(com.android.internal.R.id.content_preview_image_1_large)) @@ -1166,16 +989,7 @@ public class UnbundledChooserActivityTest { List resolvedComponentInfos = createResolvedComponentsForTest(2); - when( - ChooserActivityOverrideData - .getInstance() - .resolverListController - .getResolversForIntent( - Mockito.anyBoolean(), - Mockito.anyBoolean(), - Mockito.anyBoolean(), - Mockito.isA(List.class))) - .thenReturn(resolvedComponentInfos); + setupResolverControllers(resolvedComponentInfos); mActivityRule.launchActivity(Intent.createChooser(sendIntent, null)); waitForIdle(); onView(withId(com.android.internal.R.id.content_preview_image_1_large)) @@ -1214,16 +1028,7 @@ public class UnbundledChooserActivityTest { List resolvedComponentInfos = createResolvedComponentsForTest(2); - when( - ChooserActivityOverrideData - .getInstance() - .resolverListController - .getResolversForIntent( - Mockito.anyBoolean(), - Mockito.anyBoolean(), - Mockito.anyBoolean(), - Mockito.isA(List.class))) - .thenReturn(resolvedComponentInfos); + setupResolverControllers(resolvedComponentInfos); mActivityRule.launchActivity(Intent.createChooser(sendIntent, null)); waitForIdle(); onView(withId(com.android.internal.R.id.content_preview_image_area)) @@ -1256,16 +1061,7 @@ public class UnbundledChooserActivityTest { List resolvedComponentInfos = createResolvedComponentsForTest(2); - when( - ChooserActivityOverrideData - .getInstance() - .resolverListController - .getResolversForIntent( - Mockito.anyBoolean(), - Mockito.anyBoolean(), - Mockito.anyBoolean(), - Mockito.isA(List.class))) - .thenReturn(resolvedComponentInfos); + setupResolverControllers(resolvedComponentInfos); mActivityRule.launchActivity(Intent.createChooser(sendIntent, null)); waitForIdle(); onView(withText(sharedText)) @@ -1319,11 +1115,7 @@ public class UnbundledChooserActivityTest { List resolvedComponentInfos = createResolvedComponentsForTest(2); - when(ChooserActivityOverrideData.getInstance().resolverListController.getResolversForIntent( - Mockito.anyBoolean(), - Mockito.anyBoolean(), - Mockito.anyBoolean(), - Mockito.isA(List.class))).thenReturn(resolvedComponentInfos); + setupResolverControllers(resolvedComponentInfos); final IChooserWrapper activity = (IChooserWrapper) mActivityRule.launchActivity(Intent.createChooser(sendIntent, null)); @@ -1348,16 +1140,7 @@ public class UnbundledChooserActivityTest { List resolvedComponentInfos = createResolvedComponentsForTest(2); - when( - ChooserActivityOverrideData - .getInstance() - .resolverListController - .getResolversForIntent( - Mockito.anyBoolean(), - Mockito.anyBoolean(), - Mockito.anyBoolean(), - Mockito.isA(List.class))) - .thenReturn(resolvedComponentInfos); + setupResolverControllers(resolvedComponentInfos); final IChooserWrapper activity = (IChooserWrapper) mActivityRule.launchActivity(Intent.createChooser(sendIntent, null)); @@ -1377,16 +1160,7 @@ public class UnbundledChooserActivityTest { List resolvedComponentInfos = createResolvedComponentsForTest(2); - when( - ChooserActivityOverrideData - .getInstance() - .resolverListController - .getResolversForIntent( - Mockito.anyBoolean(), - Mockito.anyBoolean(), - Mockito.anyBoolean(), - Mockito.isA(List.class))) - .thenReturn(resolvedComponentInfos); + setupResolverControllers(resolvedComponentInfos); mActivityRule.launchActivity(Intent.createChooser(sendIntent, null)); waitForIdle(); onView(withId(com.android.internal.R.id.content_preview_filename)) @@ -1411,16 +1185,7 @@ public class UnbundledChooserActivityTest { List resolvedComponentInfos = createResolvedComponentsForTest(2); - when( - ChooserActivityOverrideData - .getInstance() - .resolverListController - .getResolversForIntent( - Mockito.anyBoolean(), - Mockito.anyBoolean(), - Mockito.anyBoolean(), - Mockito.isA(List.class))) - .thenReturn(resolvedComponentInfos); + setupResolverControllers(resolvedComponentInfos); mActivityRule.launchActivity(Intent.createChooser(sendIntent, null)); waitForIdle(); onView(withId(com.android.internal.R.id.content_preview_filename)) @@ -1441,16 +1206,7 @@ public class UnbundledChooserActivityTest { Intent sendIntent = createSendUriIntentWithPreview(uris); List resolvedComponentInfos = createResolvedComponentsForTest(2); - when( - ChooserActivityOverrideData - .getInstance() - .resolverListController - .getResolversForIntent( - Mockito.anyBoolean(), - Mockito.anyBoolean(), - Mockito.anyBoolean(), - Mockito.isA(List.class))) - .thenReturn(resolvedComponentInfos); + setupResolverControllers(resolvedComponentInfos); ChooserActivityOverrideData.getInstance().resolverForceException = true; @@ -1475,16 +1231,7 @@ public class UnbundledChooserActivityTest { Intent sendIntent = createSendUriIntentWithPreview(uris); List resolvedComponentInfos = createResolvedComponentsForTest(2); - when( - ChooserActivityOverrideData - .getInstance() - .resolverListController - .getResolversForIntent( - Mockito.anyBoolean(), - Mockito.anyBoolean(), - Mockito.anyBoolean(), - Mockito.isA(List.class))) - .thenReturn(resolvedComponentInfos); + setupResolverControllers(resolvedComponentInfos); Cursor cursor = mock(Cursor.class); when(cursor.getCount()).thenReturn(1); @@ -1511,16 +1258,8 @@ public class UnbundledChooserActivityTest { Intent sendIntent = createSendTextIntent(); List resolvedComponentInfos = createResolvedComponentsForTest(2); - when( - ChooserActivityOverrideData - .getInstance() - .resolverListController - .getResolversForIntent( - Mockito.anyBoolean(), - Mockito.anyBoolean(), - Mockito.anyBoolean(), - Mockito.isA(List.class))) - .thenReturn(resolvedComponentInfos); + setupResolverControllers(resolvedComponentInfos); + when( ChooserActivityOverrideData .getInstance() @@ -1553,16 +1292,7 @@ public class UnbundledChooserActivityTest { Intent sendIntent = createSendTextIntent(); // We need app targets for direct targets to get displayed List resolvedComponentInfos = createResolvedComponentsForTest(2); - when( - ChooserActivityOverrideData - .getInstance() - .resolverListController - .getResolversForIntent( - Mockito.anyBoolean(), - Mockito.anyBoolean(), - Mockito.anyBoolean(), - Mockito.isA(List.class))) - .thenReturn(resolvedComponentInfos); + setupResolverControllers(resolvedComponentInfos); // create test shortcut loader factory, remember loaders and their callbacks SparseArray>> shortcutLoaders = @@ -1643,16 +1373,7 @@ public class UnbundledChooserActivityTest { Intent sendIntent = createSendTextIntent(); // We need app targets for direct targets to get displayed List resolvedComponentInfos = createResolvedComponentsForTest(2); - when( - ChooserActivityOverrideData - .getInstance() - .resolverListController - .getResolversForIntent( - Mockito.anyBoolean(), - Mockito.anyBoolean(), - Mockito.anyBoolean(), - Mockito.isA(List.class))) - .thenReturn(resolvedComponentInfos); + setupResolverControllers(resolvedComponentInfos); // create test shortcut loader factory, remember loaders and their callbacks SparseArray>> shortcutLoaders = @@ -1737,16 +1458,7 @@ public class UnbundledChooserActivityTest { Intent sendIntent = createSendTextIntent(); // We need app targets for direct targets to get displayed List resolvedComponentInfos = createResolvedComponentsForTest(2); - when( - ChooserActivityOverrideData - .getInstance() - .resolverListController - .getResolversForIntent( - Mockito.anyBoolean(), - Mockito.anyBoolean(), - Mockito.anyBoolean(), - Mockito.isA(List.class))) - .thenReturn(resolvedComponentInfos); + setupResolverControllers(resolvedComponentInfos); // create test shortcut loader factory, remember loaders and their callbacks SparseArray>> shortcutLoaders = @@ -1821,16 +1533,7 @@ public class UnbundledChooserActivityTest { Intent sendIntent = createSendTextIntent(); // We need app targets for direct targets to get displayed List resolvedComponentInfos = createResolvedComponentsForTest(2); - when( - ChooserActivityOverrideData - .getInstance() - .resolverListController - .getResolversForIntent( - Mockito.anyBoolean(), - Mockito.anyBoolean(), - Mockito.anyBoolean(), - Mockito.isA(List.class))) - .thenReturn(resolvedComponentInfos); + setupResolverControllers(resolvedComponentInfos); // create test shortcut loader factory, remember loaders and their callbacks SparseArray>> shortcutLoaders = @@ -1909,16 +1612,7 @@ public class UnbundledChooserActivityTest { // We need app targets for direct targets to get displayed List resolvedComponentInfos = createResolvedComponentsForTest(2); - when( - ChooserActivityOverrideData - .getInstance() - .resolverListController - .getResolversForIntent( - Mockito.anyBoolean(), - Mockito.anyBoolean(), - Mockito.anyBoolean(), - Mockito.isA(List.class))) - .thenReturn(resolvedComponentInfos); + setupResolverControllers(resolvedComponentInfos); // set caller-provided target Intent chooserIntent = Intent.createChooser(createSendTextIntent(), null); @@ -1982,16 +1676,7 @@ public class UnbundledChooserActivityTest { values = { true }) public void testLaunchWithCustomAction() throws InterruptedException { List resolvedComponentInfos = createResolvedComponentsForTest(2); - when( - ChooserActivityOverrideData - .getInstance() - .resolverListController - .getResolversForIntent( - Mockito.anyBoolean(), - Mockito.anyBoolean(), - Mockito.anyBoolean(), - Mockito.isA(List.class))) - .thenReturn(resolvedComponentInfos); + setupResolverControllers(resolvedComponentInfos); Context testContext = InstrumentationRegistry.getInstrumentation().getContext(); final String customActionLabel = "Custom Action"; @@ -2037,16 +1722,7 @@ public class UnbundledChooserActivityTest { values = { true }) public void testLaunchWithShareModification() throws InterruptedException { List resolvedComponentInfos = createResolvedComponentsForTest(2); - when( - ChooserActivityOverrideData - .getInstance() - .resolverListController - .getResolversForIntent( - Mockito.anyBoolean(), - Mockito.anyBoolean(), - Mockito.anyBoolean(), - Mockito.isA(List.class))) - .thenReturn(resolvedComponentInfos); + setupResolverControllers(resolvedComponentInfos); Context testContext = InstrumentationRegistry.getInstrumentation().getContext(); final String modifyShareAction = "test-broadcast-receiver-action"; @@ -2130,16 +1806,7 @@ public class UnbundledChooserActivityTest { Intent sendIntent = createSendTextIntent(); // We need app targets for direct targets to get displayed List resolvedComponentInfos = createResolvedComponentsForTest(15); - when( - ChooserActivityOverrideData - .getInstance() - .resolverListController - .getResolversForIntent( - Mockito.anyBoolean(), - Mockito.anyBoolean(), - Mockito.anyBoolean(), - Mockito.isA(List.class))) - .thenReturn(resolvedComponentInfos); + setupResolverControllers(resolvedComponentInfos); // Create direct share target List serviceTargets = createDirectShareTargets(1, @@ -2419,16 +2086,7 @@ public class UnbundledChooserActivityTest { Intent sendIntent = createSendTextIntent(); List resolvedComponentInfos = createResolvedComponentsForTest(2); - when( - ChooserActivityOverrideData - .getInstance() - .resolverListController - .getResolversForIntent( - Mockito.anyBoolean(), - Mockito.anyBoolean(), - Mockito.anyBoolean(), - Mockito.isA(List.class))) - .thenReturn(resolvedComponentInfos); + setupResolverControllers(resolvedComponentInfos); final IChooserWrapper activity = (IChooserWrapper) mActivityRule.launchActivity(Intent.createChooser(sendIntent, null)); @@ -2460,16 +2118,7 @@ public class UnbundledChooserActivityTest { Intent sendIntent = createSendTextIntent(); // We need app targets for direct targets to get displayed List resolvedComponentInfos = createResolvedComponentsForTest(2); - when( - ChooserActivityOverrideData - .getInstance() - .resolverListController - .getResolversForIntent( - Mockito.anyBoolean(), - Mockito.anyBoolean(), - Mockito.anyBoolean(), - Mockito.isA(List.class))) - .thenReturn(resolvedComponentInfos); + setupResolverControllers(resolvedComponentInfos); // create test shortcut loader factory, remember loaders and their callbacks SparseArray>> shortcutLoaders = @@ -2550,16 +2199,7 @@ public class UnbundledChooserActivityTest { Intent sendIntent = createSendTextIntent(); // We need app targets for direct targets to get displayed List resolvedComponentInfos = createResolvedComponentsForTest(2); - when( - ChooserActivityOverrideData - .getInstance() - .resolverListController - .getResolversForIntent( - Mockito.anyBoolean(), - Mockito.anyBoolean(), - Mockito.anyBoolean(), - Mockito.isA(List.class))) - .thenReturn(resolvedComponentInfos); + setupResolverControllers(resolvedComponentInfos); // create test shortcut loader factory, remember loaders and their callbacks SparseArray>> shortcutLoaders = @@ -2616,16 +2256,7 @@ public class UnbundledChooserActivityTest { Intent sendIntent = createSendTextIntent(); // We need app targets for direct targets to get displayed List resolvedComponentInfos = createResolvedComponentsForTest(2); - when( - ChooserActivityOverrideData - .getInstance() - .resolverListController - .getResolversForIntent( - Mockito.anyBoolean(), - Mockito.anyBoolean(), - Mockito.anyBoolean(), - Mockito.isA(List.class))) - .thenReturn(resolvedComponentInfos); + setupResolverControllers(resolvedComponentInfos); // Start activity final IChooserWrapper activity = (IChooserWrapper) @@ -2649,16 +2280,7 @@ public class UnbundledChooserActivityTest { Intent sendIntent = createSendTextIntent(); List resolvedComponentInfos = createResolvedComponentsForTest(2); - when( - ChooserActivityOverrideData - .getInstance() - .resolverListController - .getResolversForIntent( - Mockito.anyBoolean(), - Mockito.anyBoolean(), - Mockito.anyBoolean(), - Mockito.isA(List.class))) - .thenReturn(resolvedComponentInfos); + setupResolverControllers(resolvedComponentInfos); final IChooserWrapper activity = (IChooserWrapper) mActivityRule.launchActivity(Intent.createChooser(sendIntent, null)); @@ -2720,16 +2342,7 @@ public class UnbundledChooserActivityTest { public void testOneInitialIntent_noAutolaunch() { List personalResolvedComponentInfos = createResolvedComponentsForTest(1); - when( - ChooserActivityOverrideData - .getInstance() - .resolverListController - .getResolversForIntent( - Mockito.anyBoolean(), - Mockito.anyBoolean(), - Mockito.anyBoolean(), - Mockito.isA(List.class))) - .thenReturn(new ArrayList<>(personalResolvedComponentInfos)); + setupResolverControllers(personalResolvedComponentInfos); Intent chooserIntent = createChooserIntent(createSendTextIntent(), new Intent[] {new Intent("action.fake")}); ResolveInfo[] chosen = new ResolveInfo[1]; @@ -2855,12 +2468,7 @@ public class UnbundledChooserActivityTest { // Create 4 ranked app targets. List personalResolvedComponentInfos = createResolvedComponentsForTest(4); - when(ChooserActivityOverrideData.getInstance().resolverListController.getResolversForIntent( - Mockito.anyBoolean(), - Mockito.anyBoolean(), - Mockito.anyBoolean(), - Mockito.isA(List.class))) - .thenReturn(new ArrayList<>(personalResolvedComponentInfos)); + setupResolverControllers(personalResolvedComponentInfos); // Create caller target which is duplicate with one of app targets Intent chooserIntent = createChooserIntent(createSendTextIntent(), new Intent[] {new Intent("action.fake")}); @@ -3126,6 +2734,11 @@ public class UnbundledChooserActivityTest { ChooserActivityOverrideData.getInstance().workProfileUserHandle = UserHandle.of(10); } + private void setupResolverControllers( + List personalResolvedComponentInfos) { + setupResolverControllers(personalResolvedComponentInfos, new ArrayList<>()); + } + private void setupResolverControllers( List personalResolvedComponentInfos, List workResolvedComponentInfos) { @@ -3133,22 +2746,24 @@ public class UnbundledChooserActivityTest { ChooserActivityOverrideData .getInstance() .resolverListController - .getResolversForIntent( + .getResolversForIntentAsUser( Mockito.anyBoolean(), Mockito.anyBoolean(), Mockito.anyBoolean(), - Mockito.isA(List.class))) + Mockito.isA(List.class), + eq(UserHandle.SYSTEM))) .thenReturn(new ArrayList<>(personalResolvedComponentInfos)); when( ChooserActivityOverrideData .getInstance() .workResolverListController - .getResolversForIntent( + .getResolversForIntentAsUser( Mockito.anyBoolean(), Mockito.anyBoolean(), Mockito.anyBoolean(), - Mockito.isA(List.class))) - .thenReturn(new ArrayList<>(workResolvedComponentInfos)); + Mockito.isA(List.class), + eq(UserHandle.SYSTEM))) + .thenReturn(new ArrayList<>(personalResolvedComponentInfos)); when( ChooserActivityOverrideData .getInstance() @@ -3158,8 +2773,8 @@ public class UnbundledChooserActivityTest { Mockito.anyBoolean(), Mockito.anyBoolean(), Mockito.isA(List.class), - eq(UserHandle.SYSTEM))) - .thenReturn(new ArrayList<>(personalResolvedComponentInfos)); + eq(UserHandle.of(10)))) + .thenReturn(new ArrayList<>(workResolvedComponentInfos)); } private static GridRecyclerSpanCountMatcher withGridColumnCount(int columnCount) { @@ -3198,16 +2813,7 @@ public class UnbundledChooserActivityTest { private void givenAppTargets(int appCount) { List resolvedComponentInfos = createResolvedComponentsForTest(appCount); - when( - ChooserActivityOverrideData - .getInstance() - .resolverListController - .getResolversForIntent( - Mockito.anyBoolean(), - Mockito.anyBoolean(), - Mockito.anyBoolean(), - Mockito.isA(List.class))) - .thenReturn(resolvedComponentInfos); + setupResolverControllers(resolvedComponentInfos); } private void updateMaxTargetsPerRowResource(int targetsPerRow) { diff --git a/java/tests/src/com/android/intentresolver/UnbundledChooserActivityWorkProfileTest.java b/java/tests/src/com/android/intentresolver/UnbundledChooserActivityWorkProfileTest.java index f1febed2..904f1148 100644 --- a/java/tests/src/com/android/intentresolver/UnbundledChooserActivityWorkProfileTest.java +++ b/java/tests/src/com/android/intentresolver/UnbundledChooserActivityWorkProfileTest.java @@ -274,21 +274,27 @@ public class UnbundledChooserActivityWorkProfileTest { private void setupResolverControllers( List personalResolvedComponentInfos, List workResolvedComponentInfos) { - when(sOverrides.resolverListController.getResolversForIntent(Mockito.anyBoolean(), + when(sOverrides.resolverListController.getResolversForIntentAsUser( Mockito.anyBoolean(), Mockito.anyBoolean(), - Mockito.isA(List.class))) - .thenReturn(new ArrayList<>(personalResolvedComponentInfos)); - when(sOverrides.workResolverListController.getResolversForIntent(Mockito.anyBoolean(), Mockito.anyBoolean(), + Mockito.isA(List.class), + eq(UserHandle.SYSTEM))) + .thenReturn(new ArrayList<>(personalResolvedComponentInfos)); + when(sOverrides.workResolverListController.getResolversForIntentAsUser( Mockito.anyBoolean(), - Mockito.isA(List.class))).thenReturn(workResolvedComponentInfos); - when(sOverrides.workResolverListController.getResolversForIntentAsUser(Mockito.anyBoolean(), Mockito.anyBoolean(), Mockito.anyBoolean(), Mockito.isA(List.class), eq(UserHandle.SYSTEM))) - .thenReturn(new ArrayList<>(personalResolvedComponentInfos)); + .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() { -- cgit v1.2.3-59-g8ed1b From 84dda853d3ad5769c928ed95f77bcb694fb0d622 Mon Sep 17 00:00:00 2001 From: Matt Casey Date: Wed, 22 Feb 2023 21:20:25 +0000 Subject: Log number of custom actions and presence of modify share action Also remove an unused logging parameter. Clarify nullability of custom actions list (should migrate this to kotlin sometime). Bug: 265504112 Test: atest ChooserActivityLoggerTest Change-Id: If3f224ef16755a9a3a2c625d63e2fc35888fa37a --- .../src/com/android/intentresolver/ChooserActivity.java | 6 +++--- .../android/intentresolver/ChooserActivityLogger.java | 17 ++++++++++++----- .../intentresolver/ChooserRequestParameters.java | 4 +++- .../intentresolver/ChooserActivityLoggerTest.java | 12 +++++++----- 4 files changed, 25 insertions(+), 14 deletions(-) (limited to 'java/src') diff --git a/java/src/com/android/intentresolver/ChooserActivity.java b/java/src/com/android/intentresolver/ChooserActivity.java index 72336e84..5f46ec4f 100644 --- a/java/src/com/android/intentresolver/ChooserActivity.java +++ b/java/src/com/android/intentresolver/ChooserActivity.java @@ -97,7 +97,6 @@ import com.android.internal.annotations.VisibleForTesting; import com.android.internal.config.sysui.SystemUiDeviceConfigFlags; import com.android.internal.content.PackageMonitor; import com.android.internal.logging.nano.MetricsProto.MetricsEvent; -import com.android.internal.util.FrameworkStatsLog; import java.io.File; import java.lang.annotation.Retention; @@ -386,7 +385,6 @@ public class ChooserActivity extends ResolverActivity implements } getChooserActivityLogger().logShareStarted( - FrameworkStatsLog.SHARESHEET_STARTED, getReferrerPackageName(), mChooserRequest.getTargetType(), mChooserRequest.getCallerChooserTargets().size(), @@ -395,7 +393,9 @@ public class ChooserActivity extends ResolverActivity implements isWorkProfile(), ChooserContentPreviewUi.findPreferredContentPreview( getTargetIntent(), getContentResolver(), this::isImageType), - mChooserRequest.getTargetAction() + mChooserRequest.getTargetAction(), + mChooserRequest.getChooserActions().size(), + mChooserRequest.getModifyShareAction() != null ); mEnterTransitionAnimationDelegate.postponeTransition(); diff --git a/java/src/com/android/intentresolver/ChooserActivityLogger.java b/java/src/com/android/intentresolver/ChooserActivityLogger.java index 331b6c07..f7ab595b 100644 --- a/java/src/com/android/intentresolver/ChooserActivityLogger.java +++ b/java/src/com/android/intentresolver/ChooserActivityLogger.java @@ -118,9 +118,16 @@ public class ChooserActivityLogger { } /** Logs a UiEventReported event for the system sharesheet completing initial start-up. */ - public void logShareStarted(int eventId, String packageName, String mimeType, - int appProvidedDirect, int appProvidedApp, boolean isWorkprofile, int previewType, - String intent) { + 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, @@ -131,8 +138,8 @@ public class ChooserActivityLogger { /* is_workprofile = 7 */ isWorkprofile, /* previewType = 8 */ typeFromPreviewInt(previewType), /* intentType = 9 */ typeFromIntentString(intent), - /* num_provided_custom_actions = 10 */ 0, - /* modify_share_action_provided = 11 */ false); + /* num_provided_custom_actions = 10 */ customActionCount, + /* modify_share_action_provided = 11 */ modifyShareActionProvided); } /** diff --git a/java/src/com/android/intentresolver/ChooserRequestParameters.java b/java/src/com/android/intentresolver/ChooserRequestParameters.java index 83a0e2e1..3d99e475 100644 --- a/java/src/com/android/intentresolver/ChooserRequestParameters.java +++ b/java/src/com/android/intentresolver/ChooserRequestParameters.java @@ -77,7 +77,7 @@ public class ChooserRequestParameters { private final Intent mReferrerFillInIntent; private final ImmutableList mFilteredComponentNames; private final ImmutableList mCallerChooserTargets; - private final ImmutableList mChooserActions; + private final @NonNull ImmutableList mChooserActions; private final PendingIntent mModifyShareAction; private final boolean mRetainInOnStop; @@ -198,6 +198,7 @@ public class ChooserRequestParameters { return mCallerChooserTargets; } + @NonNull public ImmutableList getChooserActions() { return mChooserActions; } @@ -340,6 +341,7 @@ public class ChooserRequestParameters { .collect(toImmutableList()); } + @NonNull private static ImmutableList getChooserActions(Intent intent) { return streamParcelableArrayExtra( intent, diff --git a/java/tests/src/com/android/intentresolver/ChooserActivityLoggerTest.java b/java/tests/src/com/android/intentresolver/ChooserActivityLoggerTest.java index d8868fc1..7d1b2488 100644 --- a/java/tests/src/com/android/intentresolver/ChooserActivityLoggerTest.java +++ b/java/tests/src/com/android/intentresolver/ChooserActivityLoggerTest.java @@ -112,7 +112,6 @@ public final class ChooserActivityLoggerTest { @Test public void testLogShareStarted() { - final int eventId = -1; // Passed-in eventId is unused. TODO: remove from method signature. final String packageName = "com.test.foo"; final String mimeType = "text/plain"; final int appProvidedDirectTargets = 123; @@ -120,16 +119,19 @@ public final class ChooserActivityLoggerTest { final boolean workProfile = true; final int previewType = ChooserContentPreviewUi.CONTENT_PREVIEW_FILE; final String intentAction = Intent.ACTION_SENDTO; + final int numCustomActions = 3; + final boolean modifyShareProvided = true; mChooserLogger.logShareStarted( - eventId, packageName, mimeType, appProvidedDirectTargets, appProvidedAppTargets, workProfile, previewType, - intentAction); + intentAction, + numCustomActions, + modifyShareProvided); verify(mFrameworkLog).write( eq(FrameworkStatsLog.SHARESHEET_STARTED), @@ -142,8 +144,8 @@ public final class ChooserActivityLoggerTest { eq(workProfile), eq(FrameworkStatsLog.SHARESHEET_STARTED__PREVIEW_TYPE__CONTENT_PREVIEW_FILE), eq(FrameworkStatsLog.SHARESHEET_STARTED__INTENT_TYPE__INTENT_ACTION_SENDTO), - /* custom actions provided */ eq(0), - /* reselection action provided */ eq(false)); + /* custom actions provided */ eq(numCustomActions), + /* reselection action provided */ eq(modifyShareProvided)); } @Test -- cgit v1.2.3-59-g8ed1b From 4e32c3eedd19c6725b860934bb89304f81cde158 Mon Sep 17 00:00:00 2001 From: Joshua Trask Date: Wed, 22 Feb 2023 23:47:28 +0000 Subject: Extract ResolvedComponentInfo to top-level. This was previously an inner class of `ResolverActivity` but that seems inappropriate since it's a common component used throughout our code base (including "Chooser" configurations). Test: `atest IntentResolverUnitTests` Bug: 202167050 Change-Id: I9b001d02c87faa376032682796ccb2bc9c902401 --- .../android/intentresolver/ChooserListAdapter.java | 1 - .../NoAppsAvailableEmptyStateProvider.java | 6 +- .../intentresolver/ResolvedComponentInfo.java | 105 +++++++++++++++++++++ .../android/intentresolver/ResolverActivity.java | 55 ----------- .../intentresolver/ResolverListAdapter.java | 1 - .../intentresolver/ResolverListController.java | 54 +++++------ .../model/AbstractResolverComparator.java | 2 +- .../AppPredictionServiceResolverComparator.java | 2 +- .../ResolverRankerServiceResolverComparator.java | 2 +- .../intentresolver/ResolverActivityTest.java | 1 - .../intentresolver/ResolverDataProvider.java | 29 +++--- .../UnbundledChooserActivityTest.java | 1 - .../UnbundledChooserActivityWorkProfileTest.java | 1 - .../model/AbstractResolverComparatorTest.java | 12 +-- 14 files changed, 159 insertions(+), 113 deletions(-) create mode 100644 java/src/com/android/intentresolver/ResolvedComponentInfo.java (limited to 'java/src') diff --git a/java/src/com/android/intentresolver/ChooserListAdapter.java b/java/src/com/android/intentresolver/ChooserListAdapter.java index 49b883ae..f0651360 100644 --- a/java/src/com/android/intentresolver/ChooserListAdapter.java +++ b/java/src/com/android/intentresolver/ChooserListAdapter.java @@ -49,7 +49,6 @@ import android.widget.TextView; import androidx.annotation.WorkerThread; -import com.android.intentresolver.ResolverActivity.ResolvedComponentInfo; import com.android.intentresolver.chooser.DisplayResolveInfo; import com.android.intentresolver.chooser.MultiDisplayResolveInfo; import com.android.intentresolver.chooser.NotSelectableTargetInfo; diff --git a/java/src/com/android/intentresolver/NoAppsAvailableEmptyStateProvider.java b/java/src/com/android/intentresolver/NoAppsAvailableEmptyStateProvider.java index 5bf994d6..c1373f4b 100644 --- a/java/src/com/android/intentresolver/NoAppsAvailableEmptyStateProvider.java +++ b/java/src/com/android/intentresolver/NoAppsAvailableEmptyStateProvider.java @@ -101,9 +101,9 @@ public class NoAppsAvailableEmptyStateProvider implements EmptyStateProvider { if (mWorkProfileUserHandle == null) { return false; } - List resolversForIntent = + List resolversForIntent = adapter.getResolversForUser(UserHandle.of(mMyUserIdProvider.getMyUserId())); - for (ResolverActivity.ResolvedComponentInfo info : resolversForIntent) { + for (ResolvedComponentInfo info : resolversForIntent) { ResolveInfo resolveInfo = info.getResolveInfoAt(0); if (resolveInfo.targetUserId != UserHandle.USER_CURRENT) { return true; @@ -151,4 +151,4 @@ public class NoAppsAvailableEmptyStateProvider implements EmptyStateProvider { .write(); } } -} \ No newline at end of file +} diff --git a/java/src/com/android/intentresolver/ResolvedComponentInfo.java b/java/src/com/android/intentresolver/ResolvedComponentInfo.java new file mode 100644 index 00000000..ecb72cbf --- /dev/null +++ b/java/src/com/android/intentresolver/ResolvedComponentInfo.java @@ -0,0 +1,105 @@ +/* + * 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.Intent; +import android.content.pm.ResolveInfo; + +import java.util.ArrayList; +import java.util.List; + +/** + * Record type to store all resolutions that are deduped to a single target component, along with + * other metadata about the component (which applies to all of the resolutions in the record). + * This record is assembled when we're first processing resolutions, and then later it's used to + * derive the {@link TargetInfo} record(s) that specify how the resolutions will be presented as + * targets in the UI. + */ +public final class ResolvedComponentInfo { + public final ComponentName name; + private final List mIntents = new ArrayList<>(); + private final List mResolveInfos = new ArrayList<>(); + private boolean mPinned; + + /** + * @param name the name of the component that owns all the resolutions added to this record. + * @param intent an initial {@link Intent} to add to this record + * @param info the {@link ResolveInfo} associated with the given {@code intent}. + */ + public ResolvedComponentInfo(ComponentName name, Intent intent, ResolveInfo info) { + this.name = name; + add(intent, info); + } + + /** + * Add an {@link Intent} and associated {@link ResolveInfo} as resolutions for this component. + */ + public void add(Intent intent, ResolveInfo info) { + mIntents.add(intent); + mResolveInfos.add(info); + } + + /** @return the number of {@link Intent}/{@link ResolveInfo} pairs added to this record. */ + public int getCount() { + return mIntents.size(); + } + + /** @return the {@link Intent} at the specified {@code index}, if any, or else null. */ + public Intent getIntentAt(int index) { + return (index >= 0) ? mIntents.get(index) : null; + } + + /** @return the {@link ResolveInfo} at the specified {@code index}, if any, or else null. */ + public ResolveInfo getResolveInfoAt(int index) { + return (index >= 0) ? mResolveInfos.get(index) : null; + } + + /** + * @return the index of the provided {@link Intent} among those that have been added to this + * {@link ResolvedComponentInfo}, or -1 if it has't been added. + */ + public int findIntent(Intent intent) { + return mIntents.indexOf(intent); + } + + /** + * @return the index of the provided {@link ResolveInfo} among those that have been added to + * this {@link ResolvedComponentInfo}, or -1 if it has't been added. + */ + public int findResolveInfo(ResolveInfo info) { + return mResolveInfos.indexOf(info); + } + + /** + * @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. + */ + public boolean isPinned() { + return mPinned; + } + + /** + * Set whether this component will be considered pinned in future calls to {@link #isPinned()}. + * TODO: consolidate sources of pinning data and/or document how this differs from other places + * we make a "pinning" determination. + */ + public void setPinned(boolean pinned) { + mPinned = pinned; + } +} diff --git a/java/src/com/android/intentresolver/ResolverActivity.java b/java/src/com/android/intentresolver/ResolverActivity.java index 08f42404..7a0c0f1a 100644 --- a/java/src/com/android/intentresolver/ResolverActivity.java +++ b/java/src/com/android/intentresolver/ResolverActivity.java @@ -2214,61 +2214,6 @@ public class ResolverActivity extends FragmentActivity implements return mMultiProfilePagerAdapter.getInactiveListAdapter().getCount() > 0; } - public static final class ResolvedComponentInfo { - public final ComponentName name; - private final List mIntents = new ArrayList<>(); - private final List mResolveInfos = new ArrayList<>(); - private boolean mPinned; - - public ResolvedComponentInfo(ComponentName name, Intent intent, ResolveInfo info) { - this.name = name; - add(intent, info); - } - - public void add(Intent intent, ResolveInfo info) { - mIntents.add(intent); - mResolveInfos.add(info); - } - - public int getCount() { - return mIntents.size(); - } - - public Intent getIntentAt(int index) { - return index >= 0 ? mIntents.get(index) : null; - } - - public ResolveInfo getResolveInfoAt(int index) { - return index >= 0 ? mResolveInfos.get(index) : null; - } - - public int findIntent(Intent intent) { - for (int i = 0, N = mIntents.size(); i < N; i++) { - if (intent.equals(mIntents.get(i))) { - return i; - } - } - return -1; - } - - public int findResolveInfo(ResolveInfo info) { - for (int i = 0, N = mResolveInfos.size(); i < N; i++) { - if (info.equals(mResolveInfos.get(i))) { - return i; - } - } - return -1; - } - - public boolean isPinned() { - return mPinned; - } - - public void setPinned(boolean pinned) { - mPinned = pinned; - } - } - final class ItemClickListener implements AdapterView.OnItemClickListener, AdapterView.OnItemLongClickListener { @Override diff --git a/java/src/com/android/intentresolver/ResolverListAdapter.java b/java/src/com/android/intentresolver/ResolverListAdapter.java index c8a9d5dc..eac275cc 100644 --- a/java/src/com/android/intentresolver/ResolverListAdapter.java +++ b/java/src/com/android/intentresolver/ResolverListAdapter.java @@ -49,7 +49,6 @@ import android.widget.BaseAdapter; import android.widget.ImageView; import android.widget.TextView; -import com.android.intentresolver.ResolverActivity.ResolvedComponentInfo; import com.android.intentresolver.chooser.DisplayResolveInfo; import com.android.intentresolver.chooser.TargetInfo; import com.android.internal.annotations.VisibleForTesting; diff --git a/java/src/com/android/intentresolver/ResolverListController.java b/java/src/com/android/intentresolver/ResolverListController.java index 6eb027ea..b4544c43 100644 --- a/java/src/com/android/intentresolver/ResolverListController.java +++ b/java/src/com/android/intentresolver/ResolverListController.java @@ -104,7 +104,11 @@ public class ResolverListController { filter, match, intent.getComponent()); } - public List getResolversForIntentAsUser( + /** + * Get data about all the ways the user with the specified handle can resolve any of the + * provided {@code intents}. + */ + public List getResolversForIntentAsUser( boolean shouldGetResolvedFilter, boolean shouldGetActivityMetadata, boolean shouldGetOnlyDefaultActivities, @@ -118,11 +122,9 @@ public class ResolverListController { return getResolversForIntentAsUserInternal(intents, userHandle, baseFlags); } - private List getResolversForIntentAsUserInternal( - List intents, - UserHandle userHandle, - int baseFlags) { - List resolvedComponents = null; + private List getResolversForIntentAsUserInternal( + List intents, UserHandle userHandle, int baseFlags) { + List resolvedComponents = null; for (int i = 0, N = intents.size(); i < N; i++) { Intent intent = intents.get(i); int flags = baseFlags; @@ -146,9 +148,8 @@ public class ResolverListController { } @VisibleForTesting - public void addResolveListDedupe(List into, - Intent intent, - List from) { + public void addResolveListDedupe( + List into, Intent intent, List from) { final int fromCount = from.size(); final int intoCount = into.size(); for (int i = 0; i < fromCount; i++) { @@ -156,7 +157,7 @@ public class ResolverListController { boolean found = false; // Only loop to the end of into as it was before we started; no dupes in from. for (int j = 0; j < intoCount; j++) { - final ResolverActivity.ResolvedComponentInfo rci = into.get(j); + final ResolvedComponentInfo rci = into.get(j); if (isSameResolvedComponent(newInfo, rci)) { found = true; rci.add(intent, newInfo); @@ -166,8 +167,7 @@ public class ResolverListController { if (!found) { final ComponentName name = new ComponentName( newInfo.activityInfo.packageName, newInfo.activityInfo.name); - final ResolverActivity.ResolvedComponentInfo rci = - new ResolverActivity.ResolvedComponentInfo(name, intent, newInfo); + final ResolvedComponentInfo rci = new ResolvedComponentInfo(name, intent, newInfo); rci.setPinned(isComponentPinned(name)); into.add(rci); } @@ -187,10 +187,9 @@ public class ResolverListController { // To preserve the inputList, optionally will return the original list if any modification has // been made. @VisibleForTesting - public ArrayList filterIneligibleActivities( - List inputList, - boolean returnCopyOfOriginalListIfModified) { - ArrayList listToReturn = null; + public ArrayList filterIneligibleActivities( + List inputList, boolean returnCopyOfOriginalListIfModified) { + ArrayList listToReturn = null; for (int i = inputList.size()-1; i >= 0; i--) { ActivityInfo ai = inputList.get(i) .getResolveInfoAt(0).activityInfo; @@ -216,13 +215,12 @@ public class ResolverListController { // To preserve the inputList, optionally will return the original list if any modification has // been made. @VisibleForTesting - public ArrayList filterLowPriority( - List inputList, - boolean returnCopyOfOriginalListIfModified) { - ArrayList listToReturn = null; + public ArrayList filterLowPriority( + List inputList, boolean returnCopyOfOriginalListIfModified) { + ArrayList listToReturn = null; // Only display the first matches that are either of equal // priority or have asked to be default options. - ResolverActivity.ResolvedComponentInfo rci0 = inputList.get(0); + ResolvedComponentInfo rci0 = inputList.get(0); ResolveInfo r0 = rci0.getResolveInfoAt(0); int N = inputList.size(); for (int i = 1; i < N; i++) { @@ -247,8 +245,7 @@ public class ResolverListController { return listToReturn; } - private void compute(List inputList) - throws InterruptedException { + private void compute(List inputList) throws InterruptedException { if (mResolverComparator == null) { Log.d(TAG, "Comparator has already been destroyed; skipped."); return; @@ -262,7 +259,7 @@ public class ResolverListController { @VisibleForTesting @WorkerThread - public void sort(List inputList) { + public void sort(List inputList) { try { long beforeRank = System.currentTimeMillis(); if (!isComputed) { @@ -281,7 +278,7 @@ public class ResolverListController { @VisibleForTesting @WorkerThread - public void topK(List inputList, int k) { + public void topK(List inputList, int k) { if (inputList == null || inputList.isEmpty() || k <= 0) { return; } @@ -298,7 +295,7 @@ public class ResolverListController { } // Top of this heap has lowest rank. - PriorityQueue minHeap = new PriorityQueue<>(k, + PriorityQueue minHeap = new PriorityQueue<>(k, (o1, o2) -> -mResolverComparator.compare(o1, o2)); final int size = inputList.size(); // Use this pointer to keep track of the position of next element @@ -306,7 +303,7 @@ public class ResolverListController { int pointer = size - 1; minHeap.addAll(inputList.subList(size - k, size)); for (int i = size - k - 1; i >= 0; --i) { - ResolverActivity.ResolvedComponentInfo ci = inputList.get(i); + ResolvedComponentInfo ci = inputList.get(i); if (-mResolverComparator.compare(ci, minHeap.peek()) > 0) { // When ranked higher than top of heap, remove top of heap, // update input list with it, add this new element to heap. @@ -335,8 +332,7 @@ public class ResolverListController { } } - private static boolean isSameResolvedComponent(ResolveInfo a, - ResolverActivity.ResolvedComponentInfo b) { + private static boolean isSameResolvedComponent(ResolveInfo a, ResolvedComponentInfo b) { final ActivityInfo ai = a.activityInfo; return ai.packageName.equals(b.name.getPackageName()) && ai.name.equals(b.name.getClassName()); diff --git a/java/src/com/android/intentresolver/model/AbstractResolverComparator.java b/java/src/com/android/intentresolver/model/AbstractResolverComparator.java index 271c6f98..ea767568 100644 --- a/java/src/com/android/intentresolver/model/AbstractResolverComparator.java +++ b/java/src/com/android/intentresolver/model/AbstractResolverComparator.java @@ -30,8 +30,8 @@ import android.os.UserHandle; import android.util.Log; import com.android.intentresolver.ChooserActivityLogger; +import com.android.intentresolver.ResolvedComponentInfo; import com.android.intentresolver.ResolverActivity; -import com.android.intentresolver.ResolverActivity.ResolvedComponentInfo; import java.text.Collator; import java.util.ArrayList; diff --git a/java/src/com/android/intentresolver/model/AppPredictionServiceResolverComparator.java b/java/src/com/android/intentresolver/model/AppPredictionServiceResolverComparator.java index c6bb2b85..c986ef15 100644 --- a/java/src/com/android/intentresolver/model/AppPredictionServiceResolverComparator.java +++ b/java/src/com/android/intentresolver/model/AppPredictionServiceResolverComparator.java @@ -32,7 +32,7 @@ import android.os.UserHandle; import android.util.Log; import com.android.intentresolver.ChooserActivityLogger; -import com.android.intentresolver.ResolverActivity.ResolvedComponentInfo; +import com.android.intentresolver.ResolvedComponentInfo; import java.util.ArrayList; import java.util.Comparator; diff --git a/java/src/com/android/intentresolver/model/ResolverRankerServiceResolverComparator.java b/java/src/com/android/intentresolver/model/ResolverRankerServiceResolverComparator.java index 4382f109..0431078c 100644 --- a/java/src/com/android/intentresolver/model/ResolverRankerServiceResolverComparator.java +++ b/java/src/com/android/intentresolver/model/ResolverRankerServiceResolverComparator.java @@ -38,7 +38,7 @@ import android.service.resolver.ResolverTarget; import android.util.Log; import com.android.intentresolver.ChooserActivityLogger; -import com.android.intentresolver.ResolverActivity.ResolvedComponentInfo; +import com.android.intentresolver.ResolvedComponentInfo; import com.android.internal.logging.MetricsLogger; import com.android.internal.logging.nano.MetricsProto.MetricsEvent; diff --git a/java/tests/src/com/android/intentresolver/ResolverActivityTest.java b/java/tests/src/com/android/intentresolver/ResolverActivityTest.java index 7950b16b..ae1b99f8 100644 --- a/java/tests/src/com/android/intentresolver/ResolverActivityTest.java +++ b/java/tests/src/com/android/intentresolver/ResolverActivityTest.java @@ -54,7 +54,6 @@ import androidx.test.espresso.NoMatchingViewException; import androidx.test.rule.ActivityTestRule; import androidx.test.runner.AndroidJUnit4; -import com.android.intentresolver.ResolverActivity.ResolvedComponentInfo; import com.android.intentresolver.widget.ResolverDrawerLayout; import com.android.internal.R; diff --git a/java/tests/src/com/android/intentresolver/ResolverDataProvider.java b/java/tests/src/com/android/intentresolver/ResolverDataProvider.java index 6807bfd6..b6b32b5a 100644 --- a/java/tests/src/com/android/intentresolver/ResolverDataProvider.java +++ b/java/tests/src/com/android/intentresolver/ResolverDataProvider.java @@ -36,28 +36,33 @@ public class ResolverDataProvider { static private int USER_SOMEONE_ELSE = 10; - static ResolverActivity.ResolvedComponentInfo createResolvedComponentInfo(int i) { - return new ResolverActivity.ResolvedComponentInfo(createComponentName(i), - createResolverIntent(i), createResolveInfo(i, UserHandle.USER_CURRENT)); + static ResolvedComponentInfo createResolvedComponentInfo(int i) { + return new ResolvedComponentInfo( + createComponentName(i), + createResolverIntent(i), + createResolveInfo(i, UserHandle.USER_CURRENT)); } - static ResolverActivity.ResolvedComponentInfo createResolvedComponentInfo( + static ResolvedComponentInfo createResolvedComponentInfo( ComponentName componentName, Intent intent) { - return new ResolverActivity.ResolvedComponentInfo( + return new ResolvedComponentInfo( componentName, intent, createResolveInfo(componentName, UserHandle.USER_CURRENT)); } - static ResolverActivity.ResolvedComponentInfo createResolvedComponentInfoWithOtherId(int i) { - return new ResolverActivity.ResolvedComponentInfo(createComponentName(i), - createResolverIntent(i), createResolveInfo(i, USER_SOMEONE_ELSE)); + static ResolvedComponentInfo createResolvedComponentInfoWithOtherId(int i) { + return new ResolvedComponentInfo( + createComponentName(i), + createResolverIntent(i), + createResolveInfo(i, USER_SOMEONE_ELSE)); } - static ResolverActivity.ResolvedComponentInfo createResolvedComponentInfoWithOtherId(int i, - int userId) { - return new ResolverActivity.ResolvedComponentInfo(createComponentName(i), - createResolverIntent(i), createResolveInfo(i, userId)); + static ResolvedComponentInfo createResolvedComponentInfoWithOtherId(int i, int userId) { + return new ResolvedComponentInfo( + createComponentName(i), + createResolverIntent(i), + createResolveInfo(i, userId)); } public static ComponentName createComponentName(int i) { diff --git a/java/tests/src/com/android/intentresolver/UnbundledChooserActivityTest.java b/java/tests/src/com/android/intentresolver/UnbundledChooserActivityTest.java index b0c0f360..9ffd02d4 100644 --- a/java/tests/src/com/android/intentresolver/UnbundledChooserActivityTest.java +++ b/java/tests/src/com/android/intentresolver/UnbundledChooserActivityTest.java @@ -101,7 +101,6 @@ import androidx.test.espresso.matcher.BoundedDiagnosingMatcher; import androidx.test.platform.app.InstrumentationRegistry; import androidx.test.rule.ActivityTestRule; -import com.android.intentresolver.ResolverActivity.ResolvedComponentInfo; import com.android.intentresolver.chooser.DisplayResolveInfo; import com.android.intentresolver.flags.Flags; import com.android.intentresolver.shortcuts.ShortcutLoader; diff --git a/java/tests/src/com/android/intentresolver/UnbundledChooserActivityWorkProfileTest.java b/java/tests/src/com/android/intentresolver/UnbundledChooserActivityWorkProfileTest.java index 904f1148..87dc1b9d 100644 --- a/java/tests/src/com/android/intentresolver/UnbundledChooserActivityWorkProfileTest.java +++ b/java/tests/src/com/android/intentresolver/UnbundledChooserActivityWorkProfileTest.java @@ -48,7 +48,6 @@ import androidx.test.InstrumentationRegistry; import androidx.test.espresso.NoMatchingViewException; import androidx.test.rule.ActivityTestRule; -import com.android.intentresolver.ResolverActivity.ResolvedComponentInfo; import com.android.intentresolver.UnbundledChooserActivityWorkProfileTest.TestCase.Tab; import com.android.internal.R; diff --git a/java/tests/src/com/android/intentresolver/model/AbstractResolverComparatorTest.java b/java/tests/src/com/android/intentresolver/model/AbstractResolverComparatorTest.java index 448718cd..006f3b2d 100644 --- a/java/tests/src/com/android/intentresolver/model/AbstractResolverComparatorTest.java +++ b/java/tests/src/com/android/intentresolver/model/AbstractResolverComparatorTest.java @@ -27,7 +27,7 @@ import android.os.Message; import androidx.test.InstrumentationRegistry; -import com.android.intentresolver.ResolverActivity; +import com.android.intentresolver.ResolvedComponentInfo; import org.junit.Test; @@ -37,12 +37,12 @@ public class AbstractResolverComparatorTest { @Test public void testPinned() { - ResolverActivity.ResolvedComponentInfo r1 = new ResolverActivity.ResolvedComponentInfo( + ResolvedComponentInfo r1 = new ResolvedComponentInfo( new ComponentName("package", "class"), new Intent(), new ResolveInfo() ); r1.setPinned(true); - ResolverActivity.ResolvedComponentInfo r2 = new ResolverActivity.ResolvedComponentInfo( + ResolvedComponentInfo r2 = new ResolvedComponentInfo( new ComponentName("zackage", "zlass"), new Intent(), new ResolveInfo() ); @@ -60,14 +60,14 @@ public class AbstractResolverComparatorTest { pmInfo1.activityInfo = new ActivityInfo(); pmInfo1.activityInfo.packageName = "aaa"; - ResolverActivity.ResolvedComponentInfo r1 = new ResolverActivity.ResolvedComponentInfo( + ResolvedComponentInfo r1 = new ResolvedComponentInfo( new ComponentName("package", "class"), new Intent(), pmInfo1); r1.setPinned(true); ResolveInfo pmInfo2 = new ResolveInfo(); pmInfo2.activityInfo = new ActivityInfo(); pmInfo2.activityInfo.packageName = "zzz"; - ResolverActivity.ResolvedComponentInfo r2 = new ResolverActivity.ResolvedComponentInfo( + ResolvedComponentInfo r2 = new ResolvedComponentInfo( new ComponentName("zackage", "zlass"), new Intent(), pmInfo2); r2.setPinned(true); @@ -91,7 +91,7 @@ public class AbstractResolverComparatorTest { } @Override - void doCompute(List targets) {} + void doCompute(List targets) {} @Override public float getScore(ComponentName name) { -- cgit v1.2.3-59-g8ed1b From c0662f6b855dd661dc12ee50a50031c88aac2997 Mon Sep 17 00:00:00 2001 From: Andrey Epin Date: Wed, 22 Feb 2023 22:19:57 -0800 Subject: Fix alternate intents in the intent refinement call. Add test to validate that getParcelableArrayExtra() works with the Intent that is sent. Bug: 270552026 Test: atest ChooserRefinementManagerTest Change-Id: Id1a6b374c36b11334278eeee35dcec4bec240101 --- .../intentresolver/ChooserRefinementManager.java | 7 ++- .../intentresolver/ChooserRefinementManagerTest.kt | 61 ++++++++++++++++++++++ 2 files changed, 66 insertions(+), 2 deletions(-) create mode 100644 java/tests/src/com/android/intentresolver/ChooserRefinementManagerTest.kt (limited to 'java/src') diff --git a/java/src/com/android/intentresolver/ChooserRefinementManager.java b/java/src/com/android/intentresolver/ChooserRefinementManager.java index 98c6bddc..5b5c1d32 100644 --- a/java/src/com/android/intentresolver/ChooserRefinementManager.java +++ b/java/src/com/android/intentresolver/ChooserRefinementManager.java @@ -121,10 +121,13 @@ public final class ChooserRefinementManager { final Intent fillIn = new Intent(); final List sourceIntents = originalTarget.getAllSourceIntents(); fillIn.putExtra(Intent.EXTRA_INTENT, sourceIntents.get(0)); - if (sourceIntents.size() > 1) { + final int sourceIntentCount = sourceIntents.size(); + if (sourceIntentCount > 1) { fillIn.putExtra( Intent.EXTRA_ALTERNATE_INTENTS, - sourceIntents.subList(1, sourceIntents.size()).toArray()); + sourceIntents + .subList(1, sourceIntentCount) + .toArray(new Intent[sourceIntentCount - 1])); } fillIn.putExtra(Intent.EXTRA_RESULT_RECEIVER, resultReceiver.copyForSending()); return fillIn; diff --git a/java/tests/src/com/android/intentresolver/ChooserRefinementManagerTest.kt b/java/tests/src/com/android/intentresolver/ChooserRefinementManagerTest.kt new file mode 100644 index 00000000..50c37c7f --- /dev/null +++ b/java/tests/src/com/android/intentresolver/ChooserRefinementManagerTest.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 + +import android.content.Context +import android.content.Intent +import android.content.IntentSender +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.android.intentresolver.chooser.TargetInfo +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.ArgumentCaptor +import org.mockito.Mockito +import java.util.function.Consumer +import org.junit.Assert.assertEquals + +@RunWith(AndroidJUnit4::class) +class ChooserRefinementManagerTest { + @Test + fun testMaybeHandleSelection() { + val intentSender = mock() + val refinementManager = ChooserRefinementManager( + mock(), + intentSender, + Consumer{}, + Runnable{}) + + val intents = listOf(Intent(Intent.ACTION_VIEW), Intent(Intent.ACTION_EDIT)) + val targetInfo = mock{ + whenever(allSourceIntents).thenReturn(intents) + } + + refinementManager.maybeHandleSelection(targetInfo) + + val intentCaptor = ArgumentCaptor.forClass(Intent::class.java) + Mockito.verify(intentSender).sendIntent( + any(), eq(0), intentCaptor.capture(), eq(null), eq(null)) + + val intent = intentCaptor.value + assertEquals(intents[0], intent.getParcelableExtra(Intent.EXTRA_INTENT, Intent::class.java)) + + val alternates = + intent.getParcelableArrayExtra(Intent.EXTRA_ALTERNATE_INTENTS, Intent::class.java) + assertEquals(1, alternates?.size) + assertEquals(intents[1], alternates?.get(0)) + } +} -- cgit v1.2.3-59-g8ed1b From 475e8f782ceafd82760d70f958c6916ede1e9a6a Mon Sep 17 00:00:00 2001 From: Matt Casey Date: Mon, 27 Feb 2023 15:18:22 +0000 Subject: Check for nearby component in config Line was accidentally removed in ag/21444756 Slight refactor to test the class as well. Bug: 270276629 Test: atest ChooserIntegratedDeviceComponentsTest Change-Id: I8bcbb5820ea366fb475b5124e612a60f6f1d1501 --- .../android/intentresolver/ChooserActivity.java | 2 +- .../ChooserIntegratedDeviceComponents.java | 14 +++-- .../com/android/intentresolver/SecureSettings.kt | 29 +++++++++ .../ChooserIntegratedDeviceComponentsTest.kt | 71 ++++++++++++++++++++++ 4 files changed, 111 insertions(+), 5 deletions(-) create mode 100644 java/src/com/android/intentresolver/SecureSettings.kt create mode 100644 java/tests/src/com/android/intentresolver/ChooserIntegratedDeviceComponentsTest.kt (limited to 'java/src') diff --git a/java/src/com/android/intentresolver/ChooserActivity.java b/java/src/com/android/intentresolver/ChooserActivity.java index 72336e84..e796d694 100644 --- a/java/src/com/android/intentresolver/ChooserActivity.java +++ b/java/src/com/android/intentresolver/ChooserActivity.java @@ -403,7 +403,7 @@ public class ChooserActivity extends ResolverActivity implements @VisibleForTesting protected ChooserIntegratedDeviceComponents getIntegratedDeviceComponents() { - return ChooserIntegratedDeviceComponents.get(this); + return ChooserIntegratedDeviceComponents.get(this, new SecureSettings()); } @Override diff --git a/java/src/com/android/intentresolver/ChooserIntegratedDeviceComponents.java b/java/src/com/android/intentresolver/ChooserIntegratedDeviceComponents.java index 14255ca0..5fbf03a0 100644 --- a/java/src/com/android/intentresolver/ChooserIntegratedDeviceComponents.java +++ b/java/src/com/android/intentresolver/ChooserIntegratedDeviceComponents.java @@ -40,10 +40,12 @@ public class ChooserIntegratedDeviceComponents { private final ComponentName mNearbySharingComponent; /** Look up the integrated components available on this device. */ - public static ChooserIntegratedDeviceComponents get(Context context) { + public static ChooserIntegratedDeviceComponents get( + Context context, + SecureSettings secureSettings) { return new ChooserIntegratedDeviceComponents( getEditSharingComponent(context), - getNearbySharingComponent(context)); + getNearbySharingComponent(context, secureSettings)); } @VisibleForTesting @@ -68,9 +70,13 @@ public class ChooserIntegratedDeviceComponents { ? null : ComponentName.unflattenFromString(editorComponent); } - private static ComponentName getNearbySharingComponent(Context context) { - String nearbyComponent = Settings.Secure.getString( + private static ComponentName getNearbySharingComponent(Context context, + SecureSettings secureSettings) { + String nearbyComponent = secureSettings.getString( context.getContentResolver(), Settings.Secure.NEARBY_SHARING_COMPONENT); + if (TextUtils.isEmpty(nearbyComponent)) { + nearbyComponent = context.getString(R.string.config_defaultNearbySharingComponent); + } return TextUtils.isEmpty(nearbyComponent) ? null : ComponentName.unflattenFromString(nearbyComponent); } diff --git a/java/src/com/android/intentresolver/SecureSettings.kt b/java/src/com/android/intentresolver/SecureSettings.kt new file mode 100644 index 00000000..a4853fd8 --- /dev/null +++ b/java/src/com/android/intentresolver/SecureSettings.kt @@ -0,0 +1,29 @@ +/* + * Copyright (C) 2023 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.intentresolver + +import android.content.ContentResolver +import android.provider.Settings + +/** + * A proxy class for secure settings, for easier testing. + */ +open class SecureSettings { + open fun getString(resolver: ContentResolver, name: String): String? { + return Settings.Secure.getString(resolver, name) + } +} diff --git a/java/tests/src/com/android/intentresolver/ChooserIntegratedDeviceComponentsTest.kt b/java/tests/src/com/android/intentresolver/ChooserIntegratedDeviceComponentsTest.kt new file mode 100644 index 00000000..9a5dabdb --- /dev/null +++ b/java/tests/src/com/android/intentresolver/ChooserIntegratedDeviceComponentsTest.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 + +import android.content.ComponentName +import android.provider.Settings +import android.testing.TestableContext +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry +import com.google.common.truth.Truth.assertThat +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class ChooserIntegratedDeviceComponentsTest { + private val secureSettings = mock() + private val testableContext = + TestableContext(InstrumentationRegistry.getInstrumentation().getContext()) + + @Test + fun testEditorAndNearby() { + val resources = testableContext.getOrCreateTestableResources() + + resources.addOverride(R.string.config_systemImageEditor, "") + resources.addOverride(R.string.config_defaultNearbySharingComponent, "") + + var components = ChooserIntegratedDeviceComponents.get(testableContext, secureSettings) + + assertThat(components.editSharingComponent).isNull() + assertThat(components.nearbySharingComponent).isNull() + + val editor = ComponentName.unflattenFromString("com.android/com.android.Editor") + val nearby = ComponentName.unflattenFromString("com.android/com.android.nearby") + + resources.addOverride(R.string.config_systemImageEditor, editor?.flattenToString()) + resources.addOverride( + R.string.config_defaultNearbySharingComponent, nearby?.flattenToString()) + + components = ChooserIntegratedDeviceComponents.get(testableContext, secureSettings) + + assertThat(components.editSharingComponent).isEqualTo(editor) + assertThat(components.nearbySharingComponent).isEqualTo(nearby) + + val anotherNearby = + ComponentName.unflattenFromString("com.android/com.android.another_nearby") + whenever( + secureSettings.getString( + any(), + eq(Settings.Secure.NEARBY_SHARING_COMPONENT) + ) + ).thenReturn(anotherNearby?.flattenToString()) + + components = ChooserIntegratedDeviceComponents.get(testableContext, secureSettings) + + assertThat(components.nearbySharingComponent).isEqualTo(anotherNearby) + } +} -- cgit v1.2.3-59-g8ed1b From 218a9903375ccd7cafd9c29ed84daf0d07ca4c2d Mon Sep 17 00:00:00 2001 From: Joshua Trask Date: Thu, 23 Feb 2023 22:39:10 +0000 Subject: Allow refinement of any matching source intent. We believe it's merely a bug that the legacy implementation always merged the "refinement" into the "primary match" (among those that matched the selected target -- so it's sort of arbitrary that this didn't even necessarily have to be the "primary intent" of the chooser request). Thus we believe the loosened restrictions keep with the spirit of the original guardrails on refinement -- but this seems to be a necessary "fix" before the refinement flow could fit its billing as a mechanism to let users select among alternate formats. I believe this won't currently work for `SelectableTargetInfo` (i.e. app share/shortcut targets) which for some reason doesn't seem to keep track of its "alternates" -- I'll look into that outside of the scope of this current CL. Patchset #5 changes: * Make sure that component name is set for DisplayResolveInfo after a refinement intent is provided; * Update MultiDisplayResolveInfo intent refinement logic -- it is now delegated to a selected target. Bug: 262805893 Test: `atest IntentResolverUnitTests` Change-Id: Ie56b14b9a7beaa6bde8fe476d1ff140280abc51a --- .../intentresolver/ChooserRefinementManager.java | 33 +--- .../intentresolver/chooser/DisplayResolveInfo.java | 42 +++-- .../chooser/ImmutableTargetInfo.java | 139 ++++++++++----- .../chooser/MultiDisplayResolveInfo.java | 27 ++- .../chooser/SelectableTargetInfo.java | 56 +++--- .../android/intentresolver/chooser/TargetInfo.java | 19 ++- .../chooser/ImmutableTargetInfoTest.kt | 117 +++++++++---- .../intentresolver/chooser/TargetInfoTest.kt | 189 ++++++++++++++++++++- 8 files changed, 462 insertions(+), 160 deletions(-) (limited to 'java/src') diff --git a/java/src/com/android/intentresolver/ChooserRefinementManager.java b/java/src/com/android/intentresolver/ChooserRefinementManager.java index 98c6bddc..90447c5e 100644 --- a/java/src/com/android/intentresolver/ChooserRefinementManager.java +++ b/java/src/com/android/intentresolver/ChooserRefinementManager.java @@ -40,12 +40,6 @@ import java.util.function.Consumer; * additional extras and other refinements (subject to {@link Intent#filterEquals()}), e.g., to * convert the format of the payload, or lazy-download some data that was deferred in the original * call). - * - * TODO(b/262805893): this currently requires the result to be a refinement of the best - * match for the user's selected target among the initially-provided source intents (according to - * their originally-provided priority order). In order to support alternate formats/actions, we - * should instead require it to refine any of the source intents -- presumably, the first - * in priority order that matches according to {@link Intent#filterEquals()}. */ public final class ChooserRefinementManager { private static final String TAG = "ChooserRefinement"; @@ -88,10 +82,12 @@ public final class ChooserRefinementManager { mRefinementResultReceiver = new RefinementResultReceiver( refinedIntent -> { destroy(); - TargetInfo refinedTarget = getValidRefinedTarget(selectedTarget, refinedIntent); + TargetInfo refinedTarget = + selectedTarget.tryToCloneWithAppliedRefinement(refinedIntent); if (refinedTarget != null) { mOnSelectionRefined.accept(refinedTarget); } else { + Log.e(TAG, "Failed to apply refinement to any matching source intent"); mOnRefinementCancelled.run(); } }, @@ -192,27 +188,4 @@ public final class ChooserRefinementManager { return receiverForSending; } } - - private static TargetInfo getValidRefinedTarget( - TargetInfo originalTarget, Intent proposedRefinement) { - if (originalTarget == null) { - // TODO: this legacy log message doesn't seem to describe the real condition we just - // checked; probably this method should never be invoked with a null target. - Log.e(TAG, "Refinement result intent did not match any known targets; canceling"); - return null; - } - if (!checkProposalRefinesSourceIntent(originalTarget, proposedRefinement)) { - Log.e(TAG, "Refinement " + proposedRefinement + " has no match in " + originalTarget); - return null; - } - return originalTarget.cloneFilledIn(proposedRefinement, 0); // TODO: select the right base. - } - - // TODO: return the actual match, to use as the base that we fill in? Or, if that's handled by - // `TargetInfo.cloneFilledIn()`, just let it be nullable (it already is?) and don't bother doing - // this pre-check. - private static boolean checkProposalRefinesSourceIntent( - TargetInfo originalTarget, Intent proposedMatch) { - return originalTarget.getAllSourceIntents().stream().anyMatch(proposedMatch::filterEquals); - } } diff --git a/java/src/com/android/intentresolver/chooser/DisplayResolveInfo.java b/java/src/com/android/intentresolver/chooser/DisplayResolveInfo.java index 0bbd6901..29be6dc6 100644 --- a/java/src/com/android/intentresolver/chooser/DisplayResolveInfo.java +++ b/java/src/com/android/intentresolver/chooser/DisplayResolveInfo.java @@ -96,25 +96,22 @@ public class DisplayResolveInfo implements TargetInfo { final ActivityInfo ai = mResolveInfo.activityInfo; mIsSuspended = (ai.applicationInfo.flags & ApplicationInfo.FLAG_SUSPENDED) != 0; - final Intent intent = new Intent(resolvedIntent); - intent.addFlags(Intent.FLAG_ACTIVITY_FORWARD_RESULT - | Intent.FLAG_ACTIVITY_PREVIOUS_IS_TOP); - intent.setComponent(new ComponentName(ai.applicationInfo.packageName, ai.name)); - mResolvedIntent = intent; + mResolvedIntent = createResolvedIntent(resolvedIntent, ai); } private DisplayResolveInfo( DisplayResolveInfo other, - Intent fillInIntent, - int flags, + @Nullable Intent baseIntentToSend, TargetPresentationGetter presentationGetter) { mSourceIntents.addAll(other.getAllSourceIntents()); mResolveInfo = other.mResolveInfo; mIsSuspended = other.mIsSuspended; mDisplayLabel = other.mDisplayLabel; mExtendedInfo = other.mExtendedInfo; - mResolvedIntent = new Intent(other.mResolvedIntent); - mResolvedIntent.fillIn(fillInIntent, flags); + + mResolvedIntent = createResolvedIntent( + baseIntentToSend == null ? other.mResolvedIntent : baseIntentToSend, + mResolveInfo.activityInfo); mPresentationGetter = presentationGetter; mDisplayIconHolder.setDisplayIcon(other.mDisplayIconHolder.getDisplayIcon()); @@ -132,6 +129,14 @@ public class DisplayResolveInfo implements TargetInfo { mDisplayIconHolder.setDisplayIcon(other.mDisplayIconHolder.getDisplayIcon()); } + private static Intent createResolvedIntent(Intent resolvedIntent, ActivityInfo ai) { + final Intent result = new Intent(resolvedIntent); + result.addFlags(Intent.FLAG_ACTIVITY_FORWARD_RESULT + | Intent.FLAG_ACTIVITY_PREVIOUS_IS_TOP); + result.setComponent(new ComponentName(ai.applicationInfo.packageName, ai.name)); + return result; + } + @Override public final boolean isDisplayResolveInfo() { return true; @@ -167,12 +172,21 @@ public class DisplayResolveInfo implements TargetInfo { } @Override - public TargetInfo cloneFilledIn(Intent fillInIntent, int flags) { - return cloneFilledInInternal(fillInIntent, flags); - } + @Nullable + public DisplayResolveInfo tryToCloneWithAppliedRefinement(Intent proposedRefinement) { + Intent matchingBase = + getAllSourceIntents() + .stream() + .filter(i -> i.filterEquals(proposedRefinement)) + .findFirst() + .orElse(null); + if (matchingBase == null) { + return null; + } - protected final DisplayResolveInfo cloneFilledInInternal(Intent fillInIntent, int flags) { - return new DisplayResolveInfo(this, fillInIntent, flags, mPresentationGetter); + Intent merged = new Intent(matchingBase); + merged.fillIn(proposedRefinement, 0); + return new DisplayResolveInfo(this, merged, mPresentationGetter); } @Override diff --git a/java/src/com/android/intentresolver/chooser/ImmutableTargetInfo.java b/java/src/com/android/intentresolver/chooser/ImmutableTargetInfo.java index 38991c78..2d9683e1 100644 --- a/java/src/com/android/intentresolver/chooser/ImmutableTargetInfo.java +++ b/java/src/com/android/intentresolver/chooser/ImmutableTargetInfo.java @@ -16,6 +16,7 @@ package com.android.intentresolver.chooser; +import android.annotation.NonNull; import android.annotation.Nullable; import android.app.Activity; import android.app.prediction.AppTarget; @@ -27,7 +28,6 @@ import android.content.pm.ShortcutInfo; import android.os.Bundle; import android.os.UserHandle; import android.util.HashedStringCache; -import android.util.Log; import androidx.annotation.VisibleForTesting; @@ -83,6 +83,15 @@ public final class ImmutableTargetInfo implements TargetInfo { @Nullable private ComponentName mResolvedComponentName; + @Nullable + private Intent mResolvedIntent; + + @Nullable + private Intent mBaseIntentToSend; + + @Nullable + private Intent mTargetIntent; + @Nullable private ComponentName mChooserTargetComponentName; @@ -101,20 +110,29 @@ public final class ImmutableTargetInfo implements TargetInfo { @Nullable private Intent mReferrerFillInIntent; - private Intent mResolvedIntent; - private Intent mTargetIntent; + @Nullable private TargetActivityStarter mActivityStarter; + + @Nullable private ResolveInfo mResolveInfo; + + @Nullable private CharSequence mDisplayLabel; + + @Nullable private CharSequence mExtendedInfo; + + @Nullable private IconHolder mDisplayIconHolder; - private List mSourceIntents; - private List mAllDisplayTargets; + private boolean mIsSuspended; private boolean mIsPinned; private float mModifiedScore = -0.1f; private LegacyTargetType mLegacyType = LegacyTargetType.NOT_LEGACY_TARGET; + private ImmutableList mAlternateSourceIntents = ImmutableList.of(); + private ImmutableList mAllDisplayTargets = ImmutableList.of(); + /** * Configure an {@link Intent} to be built in to the output target as the resolution for the * requested target data. @@ -124,6 +142,17 @@ public final class ImmutableTargetInfo implements TargetInfo { return this; } + /** + * 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 + * expanded, the builder should probably be responsible for enforcing the refinement check. + */ + private Builder setBaseIntentToSend(Intent baseIntent) { + mBaseIntentToSend = baseIntent; + return this; + } + /** * Configure an {@link Intent} to be built in to the output as the "target intent." */ @@ -192,15 +221,33 @@ public final class ImmutableTargetInfo implements TargetInfo { return this; } - /** Configure the list of source intents to be built in to the output target. */ + /** Configure the list of alternate source intents we could resolve for this target. */ + public Builder setAlternateSourceIntents(List sourceIntents) { + mAlternateSourceIntents = immutableCopyOrEmpty(sourceIntents); + return this; + } + + /** + * 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 + * fields on the builder if there are no corresponding elements in the list). + */ public Builder setAllSourceIntents(List sourceIntents) { - mSourceIntents = sourceIntents; + if ((sourceIntents == null) || sourceIntents.isEmpty()) { + setResolvedIntent(null); + setAlternateSourceIntents(null); + return this; + } + + setResolvedIntent(sourceIntents.get(0)); + setAlternateSourceIntents(sourceIntents.subList(1, sourceIntents.size())); return this; } /** Configure the list of display targets to be built in to the output target. */ public Builder setAllDisplayTargets(List targets) { - mAllDisplayTargets = targets; + mAllDisplayTargets = immutableCopyOrEmpty(targets); return this; } @@ -246,28 +293,27 @@ public final class ImmutableTargetInfo implements TargetInfo { return this; } - Builder setLegacyType(LegacyTargetType legacyType) { + Builder setLegacyType(@NonNull LegacyTargetType legacyType) { mLegacyType = legacyType; return this; } - /** - * Construct an {@code ImmutableTargetInfo} with the current builder data, where the - * provided intent is used to fill in missing values from the resolved intent before the - * target is (potentially) ever launched. - * - * @see android.content.Intent#fillIn(Intent, int) - */ - public ImmutableTargetInfo buildWithFillInIntent( - @Nullable Intent fillInIntent, int fillInFlags) { - Intent baseIntentToSend = mResolvedIntent; - if (baseIntentToSend == null) { - Log.w(TAG, "No base intent to send"); - } else { + /** Construct an {@code ImmutableTargetInfo} with the current builder data. */ + public ImmutableTargetInfo build() { + List sourceIntents = new ArrayList<>(); + if (mResolvedIntent != null) { + sourceIntents.add(mResolvedIntent); + } + if (mAlternateSourceIntents != null) { + sourceIntents.addAll(mAlternateSourceIntents); + } + + Intent baseIntentToSend = mBaseIntentToSend; + if ((baseIntentToSend == null) && !sourceIntents.isEmpty()) { + baseIntentToSend = sourceIntents.get(0); + } + if (baseIntentToSend != null) { baseIntentToSend = new Intent(baseIntentToSend); - if (fillInIntent != null) { - baseIntentToSend.fillIn(fillInIntent, fillInFlags); - } if (mReferrerFillInIntent != null) { baseIntentToSend.fillIn(mReferrerFillInIntent, 0); } @@ -275,7 +321,7 @@ public final class ImmutableTargetInfo implements TargetInfo { return new ImmutableTargetInfo( baseIntentToSend, - mResolvedIntent, + ImmutableList.copyOf(sourceIntents), mTargetIntent, mReferrerFillInIntent, mResolvedComponentName, @@ -285,7 +331,6 @@ public final class ImmutableTargetInfo implements TargetInfo { mDisplayLabel, mExtendedInfo, mDisplayIconHolder, - mSourceIntents, mAllDisplayTargets, mIsSuspended, mIsPinned, @@ -296,11 +341,6 @@ public final class ImmutableTargetInfo implements TargetInfo { mHashProvider, mLegacyType); } - - /** Construct an {@code ImmutableTargetInfo} with the current builder data. */ - public ImmutableTargetInfo build() { - return buildWithFillInIntent(null, 0); - } } @Nullable @@ -325,14 +365,13 @@ public final class ImmutableTargetInfo implements TargetInfo { private final TargetHashProvider mHashProvider; private final Intent mBaseIntentToSend; - private final Intent mResolvedIntent; + private final ImmutableList mSourceIntents; private final Intent mTargetIntent; private final TargetActivityStarter mActivityStarter; private final ResolveInfo mResolveInfo; private final CharSequence mDisplayLabel; private final CharSequence mExtendedInfo; private final IconHolder mDisplayIconHolder; - private final ImmutableList mSourceIntents; private final ImmutableList mAllDisplayTargets; private final boolean mIsSuspended; private final boolean mIsPinned; @@ -347,6 +386,7 @@ public final class ImmutableTargetInfo implements TargetInfo { /** Construct a {@link Builder} pre-initialized to match this target. */ public Builder toBuilder() { return newBuilder() + .setBaseIntentToSend(getBaseIntentToSend()) .setResolvedIntent(getResolvedIntent()) .setTargetIntent(getTargetIntent()) .setReferrerFillInIntent(getReferrerFillInIntent()) @@ -375,13 +415,26 @@ public final class ImmutableTargetInfo implements TargetInfo { } @Override - public ImmutableTargetInfo cloneFilledIn(Intent fillInIntent, int flags) { - return toBuilder().buildWithFillInIntent(fillInIntent, flags); + @Nullable + public ImmutableTargetInfo tryToCloneWithAppliedRefinement(Intent proposedRefinement) { + Intent matchingBase = + getAllSourceIntents() + .stream() + .filter(i -> i.filterEquals(proposedRefinement)) + .findFirst() + .orElse(null); + if (matchingBase == null) { + return null; + } + + Intent merged = new Intent(matchingBase); + merged.fillIn(proposedRefinement, 0); + return toBuilder().setBaseIntentToSend(merged).build(); } @Override public Intent getResolvedIntent() { - return mResolvedIntent; + return (mSourceIntents.isEmpty() ? null : mSourceIntents.get(0)); } @Override @@ -408,11 +461,13 @@ public final class ImmutableTargetInfo implements TargetInfo { @Override public boolean startAsCaller(Activity activity, Bundle options, int userId) { + // TODO: make sure that the component name is set in all cases return mActivityStarter.startAsCaller(this, activity, options, userId); } @Override public boolean startAsUser(Activity activity, Bundle options, UserHandle user) { + // TODO: make sure that the component name is set in all cases return mActivityStarter.startAsUser(this, activity, options, user); } @@ -531,7 +586,7 @@ public final class ImmutableTargetInfo implements TargetInfo { private ImmutableTargetInfo( Intent baseIntentToSend, - Intent resolvedIntent, + ImmutableList sourceIntents, Intent targetIntent, @Nullable Intent referrerFillInIntent, @Nullable ComponentName resolvedComponentName, @@ -541,8 +596,7 @@ public final class ImmutableTargetInfo implements TargetInfo { CharSequence displayLabel, CharSequence extendedInfo, IconHolder iconHolder, - @Nullable List sourceIntents, - @Nullable List allDisplayTargets, + ImmutableList allDisplayTargets, boolean isSuspended, boolean isPinned, float modifiedScore, @@ -552,7 +606,7 @@ public final class ImmutableTargetInfo implements TargetInfo { @Nullable TargetHashProvider hashProvider, LegacyTargetType legacyType) { mBaseIntentToSend = baseIntentToSend; - mResolvedIntent = resolvedIntent; + mSourceIntents = sourceIntents; mTargetIntent = targetIntent; mReferrerFillInIntent = referrerFillInIntent; mResolvedComponentName = resolvedComponentName; @@ -562,8 +616,7 @@ public final class ImmutableTargetInfo implements TargetInfo { mDisplayLabel = displayLabel; mExtendedInfo = extendedInfo; mDisplayIconHolder = iconHolder; - mSourceIntents = immutableCopyOrEmpty(sourceIntents); - mAllDisplayTargets = immutableCopyOrEmpty(allDisplayTargets); + mAllDisplayTargets = allDisplayTargets; mIsSuspended = isSuspended; mIsPinned = isPinned; mModifiedScore = modifiedScore; diff --git a/java/src/com/android/intentresolver/chooser/MultiDisplayResolveInfo.java b/java/src/com/android/intentresolver/chooser/MultiDisplayResolveInfo.java index 0938c55e..b97e6b45 100644 --- a/java/src/com/android/intentresolver/chooser/MultiDisplayResolveInfo.java +++ b/java/src/com/android/intentresolver/chooser/MultiDisplayResolveInfo.java @@ -21,7 +21,10 @@ import android.content.Intent; import android.os.Bundle; import android.os.UserHandle; +import androidx.annotation.Nullable; + import java.util.ArrayList; +import java.util.Collections; import java.util.List; /** @@ -93,10 +96,19 @@ public class MultiDisplayResolveInfo extends DisplayResolveInfo { } @Override - public TargetInfo cloneFilledIn(Intent fillInIntent, int flags) { - ArrayList targetInfos = new ArrayList<>(mTargetInfos.size()); - for (int i = 0, size = mTargetInfos.size(); i < size; i++) { - targetInfos.add(mTargetInfos.get(i).cloneFilledInInternal(fillInIntent, flags)); + @Nullable + public MultiDisplayResolveInfo tryToCloneWithAppliedRefinement(Intent proposedRefinement) { + final int size = mTargetInfos.size(); + ArrayList targetInfos = new ArrayList<>(size); + for (int i = 0; i < size; i++) { + DisplayResolveInfo target = mTargetInfos.get(i); + DisplayResolveInfo targetClone = (i == mSelected) + ? target.tryToCloneWithAppliedRefinement(proposedRefinement) + : new DisplayResolveInfo(target); + if (targetClone == null) { + return null; + } + targetInfos.add(targetClone); } MultiDisplayResolveInfo clone = new MultiDisplayResolveInfo(targetInfos); clone.mSelected = mSelected; @@ -117,4 +129,11 @@ public class MultiDisplayResolveInfo extends DisplayResolveInfo { public Intent getTargetIntent() { return mTargetInfos.get(mSelected).getTargetIntent(); } + + @Override + public List getAllSourceIntents() { + return hasSelected() + ? mTargetInfos.get(mSelected).getAllSourceIntents() + : Collections.emptyList(); + } } diff --git a/java/src/com/android/intentresolver/chooser/SelectableTargetInfo.java b/java/src/com/android/intentresolver/chooser/SelectableTargetInfo.java index df27c2b0..1fbe2da7 100644 --- a/java/src/com/android/intentresolver/chooser/SelectableTargetInfo.java +++ b/java/src/com/android/intentresolver/chooser/SelectableTargetInfo.java @@ -78,7 +78,6 @@ public final class SelectableTargetInfo extends ChooserTargetInfo { private final CharSequence mChooserTargetUnsanitizedTitle; private final Icon mChooserTargetIcon; private final Bundle mChooserTargetIntentExtras; - private final int mFillInFlags; private final boolean mIsPinned; private final float mModifiedScore; private final boolean mIsSuspended; @@ -90,12 +89,6 @@ public final class SelectableTargetInfo extends ChooserTargetInfo { private final TargetHashProvider mHashProvider; private final TargetActivityStarter mActivityStarter; - /** - * A refinement intent from the caller, if any (see - * {@link Intent#EXTRA_CHOOSER_REFINEMENT_INTENT_SENDER}) - */ - private final Intent mFillInIntent; - /** * An intent containing referrer URI (see {@link Activity#getReferrer()} (possibly {@code null}) * in its extended data under the key {@link Intent#EXTRA_REFERRER}. @@ -159,6 +152,7 @@ public final class SelectableTargetInfo extends ChooserTargetInfo { sourceInfo, backupResolveInfo, resolvedIntent, + null, chooserTargetComponentName, chooserTargetUnsanitizedTitle, chooserTargetIcon, @@ -166,15 +160,14 @@ public final class SelectableTargetInfo extends ChooserTargetInfo { modifiedScore, shortcutInfo, appTarget, - referrerFillInIntent, - /* fillInIntent = */ null, - /* fillInFlags = */ 0); + referrerFillInIntent); } private SelectableTargetInfo( @Nullable DisplayResolveInfo sourceInfo, @Nullable ResolveInfo backupResolveInfo, Intent resolvedIntent, + @Nullable Intent baseIntentToSend, ComponentName chooserTargetComponentName, CharSequence chooserTargetUnsanitizedTitle, Icon chooserTargetIcon, @@ -182,9 +175,7 @@ public final class SelectableTargetInfo extends ChooserTargetInfo { float modifiedScore, @Nullable ShortcutInfo shortcutInfo, @Nullable AppTarget appTarget, - Intent referrerFillInIntent, - @Nullable Intent fillInIntent, - int fillInFlags) { + Intent referrerFillInIntent) { mSourceInfo = sourceInfo; mBackupResolveInfo = backupResolveInfo; mResolvedIntent = resolvedIntent; @@ -192,8 +183,6 @@ public final class SelectableTargetInfo extends ChooserTargetInfo { mShortcutInfo = shortcutInfo; mAppTarget = appTarget; mReferrerFillInIntent = referrerFillInIntent; - mFillInIntent = fillInIntent; - mFillInFlags = fillInFlags; mChooserTargetComponentName = chooserTargetComponentName; mChooserTargetUnsanitizedTitle = chooserTargetUnsanitizedTitle; mChooserTargetIcon = chooserTargetIcon; @@ -209,9 +198,8 @@ public final class SelectableTargetInfo extends ChooserTargetInfo { mAllSourceIntents = getAllSourceIntents(sourceInfo); mBaseIntentToSend = getBaseIntentToSend( + baseIntentToSend, mResolvedIntent, - mFillInIntent, - mFillInFlags, mReferrerFillInIntent); mHashProvider = context -> { @@ -262,11 +250,12 @@ public final class SelectableTargetInfo extends ChooserTargetInfo { }; } - private SelectableTargetInfo(SelectableTargetInfo other, Intent fillInIntent, int flags) { + private SelectableTargetInfo(SelectableTargetInfo other, Intent baseIntentToSend) { this( other.mSourceInfo, other.mBackupResolveInfo, other.mResolvedIntent, + baseIntentToSend, other.mChooserTargetComponentName, other.mChooserTargetUnsanitizedTitle, other.mChooserTargetIcon, @@ -274,14 +263,25 @@ public final class SelectableTargetInfo extends ChooserTargetInfo { other.mModifiedScore, other.mShortcutInfo, other.mAppTarget, - other.mReferrerFillInIntent, - fillInIntent, - flags); + other.mReferrerFillInIntent); } @Override - public TargetInfo cloneFilledIn(Intent fillInIntent, int flags) { - return new SelectableTargetInfo(this, fillInIntent, flags); + @Nullable + public TargetInfo tryToCloneWithAppliedRefinement(Intent proposedRefinement) { + Intent matchingBase = + getAllSourceIntents() + .stream() + .filter(i -> i.filterEquals(proposedRefinement)) + .findFirst() + .orElse(null); + if (matchingBase == null) { + return null; + } + + Intent merged = new Intent(matchingBase); + merged.fillIn(proposedRefinement, 0); + return new SelectableTargetInfo(this, merged); } @Override @@ -418,18 +418,14 @@ public final class SelectableTargetInfo extends ChooserTargetInfo { @Nullable private static Intent getBaseIntentToSend( - @Nullable Intent resolvedIntent, - Intent fillInIntent, - int fillInFlags, + @Nullable Intent providedBase, + @Nullable Intent fallbackBase, Intent referrerFillInIntent) { - Intent result = resolvedIntent; + Intent result = (providedBase != null) ? providedBase : fallbackBase; if (result == null) { Log.e(TAG, "ChooserTargetInfo: no base intent available to send"); } else { result = new Intent(result); - if (fillInIntent != null) { - result.fillIn(fillInIntent, fillInFlags); - } result.fillIn(referrerFillInIntent, 0); } return result; diff --git a/java/src/com/android/intentresolver/chooser/TargetInfo.java b/java/src/com/android/intentresolver/chooser/TargetInfo.java index 69f58a7b..2f48704c 100644 --- a/java/src/com/android/intentresolver/chooser/TargetInfo.java +++ b/java/src/com/android/intentresolver/chooser/TargetInfo.java @@ -182,10 +182,25 @@ public interface TargetInfo { default boolean hasDisplayIcon() { return getDisplayIconHolder().getDisplayIcon() != null; } + /** - * Clone this target with the given fill-in information. + * 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 + * 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 + * merging the refinement into the best-matching source intent, if possible. If there is no + * suitable match for the proposed refinement, or if merging fails for any other reason, this + * returns null. + * + * @see android.content.Intent#fillIn(Intent, int) */ - TargetInfo cloneFilledIn(Intent fillInIntent, int flags); + @Nullable + TargetInfo tryToCloneWithAppliedRefinement(Intent proposedRefinement); /** * @return the list of supported source intents deduped against this single target diff --git a/java/tests/src/com/android/intentresolver/chooser/ImmutableTargetInfoTest.kt b/java/tests/src/com/android/intentresolver/chooser/ImmutableTargetInfoTest.kt index 4989a3f1..e9c755d3 100644 --- a/java/tests/src/com/android/intentresolver/chooser/ImmutableTargetInfoTest.kt +++ b/java/tests/src/com/android/intentresolver/chooser/ImmutableTargetInfoTest.kt @@ -88,7 +88,7 @@ class ImmutableTargetInfoTest { .setDisplayLabel(displayLabel) .setExtendedInfo(extendedInfo) .setDisplayIconHolder(displayIconHolder) - .setAllSourceIntents(listOf(sourceIntent1, sourceIntent2)) + .setAlternateSourceIntents(listOf(sourceIntent1, sourceIntent2)) .setAllDisplayTargets(listOf(displayTarget1, displayTarget2)) .setIsSuspended(true) .setIsPinned(true) @@ -108,7 +108,8 @@ class ImmutableTargetInfoTest { assertThat(info.displayLabel).isEqualTo(displayLabel) assertThat(info.extendedInfo).isEqualTo(extendedInfo) assertThat(info.displayIconHolder).isEqualTo(displayIconHolder) - assertThat(info.allSourceIntents).containsExactly(sourceIntent1, sourceIntent2) + assertThat(info.allSourceIntents).containsExactly( + resolvedIntent, sourceIntent1, sourceIntent2) assertThat(info.allDisplayTargets).containsExactly(displayTarget1, displayTarget2) assertThat(info.isSuspended).isTrue() assertThat(info.isPinned).isTrue() @@ -140,7 +141,7 @@ class ImmutableTargetInfoTest { .setDisplayLabel(displayLabel) .setExtendedInfo(extendedInfo) .setDisplayIconHolder(displayIconHolder) - .setAllSourceIntents(listOf(sourceIntent1, sourceIntent2)) + .setAlternateSourceIntents(listOf(sourceIntent1, sourceIntent2)) .setAllDisplayTargets(listOf(displayTarget1, displayTarget2)) .setIsSuspended(true) .setIsPinned(true) @@ -162,7 +163,8 @@ class ImmutableTargetInfoTest { assertThat(info.displayLabel).isEqualTo(displayLabel) assertThat(info.extendedInfo).isEqualTo(extendedInfo) assertThat(info.displayIconHolder).isEqualTo(displayIconHolder) - assertThat(info.allSourceIntents).containsExactly(sourceIntent1, sourceIntent2) + assertThat(info.allSourceIntents).containsExactly( + resolvedIntent, sourceIntent1, sourceIntent2) assertThat(info.allDisplayTargets).containsExactly(displayTarget1, displayTarget2) assertThat(info.isSuspended).isTrue() assertThat(info.isPinned).isTrue() @@ -204,25 +206,25 @@ class ImmutableTargetInfoTest { } @Test - fun testBaseIntentToSend_fillsInFromCloneRequestIntent() { + fun testBaseIntentToSend_fillsInFromRefinementIntent() { val originalIntent = Intent() - originalIntent.setPackage("original") + originalIntent.putExtra("ORIGINAL", true) - val cloneFillInIntent = Intent("CLONE_FILL_IN") - cloneFillInIntent.setPackage("clone") + val refinementIntent = Intent() + refinementIntent.putExtra("REFINEMENT", true) val originalInfo = ImmutableTargetInfo.newBuilder() .setResolvedIntent(originalIntent) .build() - val info = originalInfo.cloneFilledIn(cloneFillInIntent, 0) + val info = originalInfo.tryToCloneWithAppliedRefinement(refinementIntent) - assertThat(info.baseIntentToSend.getPackage()).isEqualTo("original") // Only fill if empty. - assertThat(info.baseIntentToSend.action).isEqualTo("CLONE_FILL_IN") + assertThat(info.baseIntentToSend.getBooleanExtra("ORIGINAL", false)).isTrue() + assertThat(info.baseIntentToSend.getBooleanExtra("REFINEMENT", false)).isTrue() } @Test - fun testBaseIntentToSend_twoFillInSourcesFavorsCloneRequest() { - val originalIntent = Intent() + fun testBaseIntentToSend_twoFillInSourcesFavorsRefinementRequest() { + val originalIntent = Intent("REFINE_ME") originalIntent.setPackage("original") val referrerFillInIntent = Intent("REFERRER_FILL_IN") @@ -234,43 +236,92 @@ class ImmutableTargetInfoTest { .setReferrerFillInIntent(referrerFillInIntent) .build() - val cloneFillInIntent = Intent("CLONE_FILL_IN") - cloneFillInIntent.setPackage("clone") + val refinementIntent = Intent("REFINE_ME") + refinementIntent.setPackage("original") // Has to match for refinement. - val info = infoWithReferrerFillIn.cloneFilledIn(cloneFillInIntent, 0) + val info = infoWithReferrerFillIn.tryToCloneWithAppliedRefinement(refinementIntent) assertThat(info.baseIntentToSend.getPackage()).isEqualTo("original") // Set all along. - assertThat(info.baseIntentToSend.action).isEqualTo("CLONE_FILL_IN") // Clone wins. + assertThat(info.baseIntentToSend.action).isEqualTo("REFINE_ME") // Refinement wins. assertThat(info.baseIntentToSend.type).isEqualTo("test/referrer") // Left for referrer. } @Test - fun testBaseIntentToSend_doubleCloningPreservesReferrerFillInButNotOriginalCloneFillIn() { - val originalIntent = Intent() + fun testBaseIntentToSend_doubleRefinementPreservesReferrerFillInButNotOriginalRefinement() { + val originalIntent = Intent("REFINE_ME") val referrerFillInIntent = Intent("REFERRER_FILL_IN") - val cloneFillInIntent1 = Intent() - cloneFillInIntent1.setPackage("clone1") - val cloneFillInIntent2 = Intent() - cloneFillInIntent2.setType("test/clone2") + referrerFillInIntent.putExtra("TEST", "REFERRER") + val refinementIntent1 = Intent("REFINE_ME") + refinementIntent1.putExtra("TEST1", "1") + val refinementIntent2 = Intent("REFINE_ME") + refinementIntent2.putExtra("TEST2", "2") val originalInfo = ImmutableTargetInfo.newBuilder() .setResolvedIntent(originalIntent) .setReferrerFillInIntent(referrerFillInIntent) .build() - val clone1 = originalInfo.cloneFilledIn(cloneFillInIntent1, 0) - val clone2 = clone1.cloneFilledIn(cloneFillInIntent2, 0) // Clone-of-clone. + val refined1 = originalInfo.tryToCloneWithAppliedRefinement(refinementIntent1) + val refined2 = refined1.tryToCloneWithAppliedRefinement(refinementIntent2) // Cloned clone. // Both clones get the same values filled in from the referrer intent. - assertThat(clone1.baseIntentToSend.action).isEqualTo("REFERRER_FILL_IN") - assertThat(clone2.baseIntentToSend.action).isEqualTo("REFERRER_FILL_IN") - // Each clone has the respective value that was set in the fill-in request. - assertThat(clone1.baseIntentToSend.getPackage()).isEqualTo("clone1") - assertThat(clone2.baseIntentToSend.type).isEqualTo("test/clone2") - // The clones don't have the data from each other's fill-in requests, even though the intent + assertThat(refined1.baseIntentToSend.getStringExtra("TEST")).isEqualTo("REFERRER") + assertThat(refined2.baseIntentToSend.getStringExtra("TEST")).isEqualTo("REFERRER") + // Each clone has the respective value that was set in their own refinement request. + assertThat(refined1.baseIntentToSend.getStringExtra("TEST1")).isEqualTo("1") + assertThat(refined2.baseIntentToSend.getStringExtra("TEST2")).isEqualTo("2") + // The clones don't have the data from each other's refinements, even though the intent // field is empty (thus able to be populated by filling-in). - assertThat(clone1.baseIntentToSend.type).isNull() - assertThat(clone2.baseIntentToSend.getPackage()).isNull() + assertThat(refined1.baseIntentToSend.getStringExtra("TEST2")).isNull() + assertThat(refined2.baseIntentToSend.getStringExtra("TEST1")).isNull() + } + + @Test + fun testBaseIntentToSend_refinementToAlternateSourceIntent() { + val originalIntent = Intent("DONT_REFINE_ME") + originalIntent.putExtra("originalIntent", true) + val mismatchedAlternate = Intent("DOESNT_MATCH") + mismatchedAlternate.putExtra("mismatchedAlternate", true) + val targetAlternate = Intent("REFINE_ME") + targetAlternate.putExtra("targetAlternate", true) + val extraMatch = Intent("REFINE_ME") + extraMatch.putExtra("extraMatch", true) + + val originalInfo = ImmutableTargetInfo.newBuilder() + .setResolvedIntent(originalIntent) + .setAllSourceIntents(listOf( + originalIntent, mismatchedAlternate, targetAlternate, extraMatch)) + .build() + + val refinement = Intent("REFINE_ME") // First match is `targetAlternate` + refinement.putExtra("refinement", true) + + val refinedResult = originalInfo.tryToCloneWithAppliedRefinement(refinement) + assertThat(refinedResult.baseIntentToSend.getBooleanExtra("refinement", false)).isTrue() + assertThat(refinedResult.baseIntentToSend.getBooleanExtra("targetAlternate", false)) + .isTrue() + // None of the other source intents got merged in (not even the later one that matched): + assertThat(refinedResult.baseIntentToSend.getBooleanExtra("originalIntent", false)) + .isFalse() + assertThat(refinedResult.baseIntentToSend.getBooleanExtra("mismatchedAlternate", false)) + .isFalse() + assertThat(refinedResult.baseIntentToSend.getBooleanExtra("extraMatch", false)).isFalse() + } + + @Test + fun testBaseIntentToSend_noSourceIntentMatchingProposedRefinement() { + val originalIntent = Intent("DONT_REFINE_ME") + originalIntent.putExtra("originalIntent", true) + val mismatchedAlternate = Intent("DOESNT_MATCH") + mismatchedAlternate.putExtra("mismatchedAlternate", true) + + val originalInfo = ImmutableTargetInfo.newBuilder() + .setResolvedIntent(originalIntent) + .setAllSourceIntents(listOf(originalIntent, mismatchedAlternate)) + .build() + + val refinement = Intent("PROPOSED_REFINEMENT") + assertThat(originalInfo.tryToCloneWithAppliedRefinement(refinement)).isNull() } @Test diff --git a/java/tests/src/com/android/intentresolver/chooser/TargetInfoTest.kt b/java/tests/src/com/android/intentresolver/chooser/TargetInfoTest.kt index e9dbe00e..dddbcccb 100644 --- a/java/tests/src/com/android/intentresolver/chooser/TargetInfoTest.kt +++ b/java/tests/src/com/android/intentresolver/chooser/TargetInfoTest.kt @@ -26,13 +26,19 @@ import android.graphics.drawable.AnimatedVectorDrawable import android.os.UserHandle import android.test.UiThreadTest import androidx.test.platform.app.InstrumentationRegistry +import com.android.intentresolver.ResolverDataProvider import com.android.intentresolver.createChooserTarget import com.android.intentresolver.createShortcutInfo import com.android.intentresolver.mock -import com.android.intentresolver.ResolverDataProvider +import com.android.intentresolver.whenever import com.google.common.truth.Truth.assertThat import org.junit.Before import org.junit.Test +import org.mockito.Mockito.any +import org.mockito.Mockito.never +import org.mockito.Mockito.spy +import org.mockito.Mockito.times +import org.mockito.Mockito.verify class TargetInfoTest { private val context = InstrumentationRegistry.getInstrumentation().getContext() @@ -142,6 +148,42 @@ class TargetInfoTest { assertThat(targetInfo.resolvedComponentName).isEqualTo(ComponentName(pkgName, className)) } + @Test + fun testSelectableTargetInfo_noSourceIntentMatchingProposedRefinement() { + val resolvedIntent = Intent("DONT_REFINE_ME") + resolvedIntent.putExtra("resolvedIntent", true) + + val baseDisplayInfo = DisplayResolveInfo.newDisplayResolveInfo( + resolvedIntent, + ResolverDataProvider.createResolveInfo(1, 0), + "label", + "extended info", + resolvedIntent, + /* resolveInfoPresentationGetter= */ null) + val chooserTarget = createChooserTarget( + "title", 0.3f, ResolverDataProvider.createComponentName(2), "test_shortcut_id") + val shortcutInfo = createShortcutInfo("id", ResolverDataProvider.createComponentName(3), 3) + val appTarget = AppTarget( + AppTargetId("id"), + chooserTarget.componentName.packageName, + chooserTarget.componentName.className, + UserHandle.CURRENT) + + val targetInfo = SelectableTargetInfo.newSelectableTargetInfo( + baseDisplayInfo, + mock(), + resolvedIntent, + chooserTarget, + 0.1f, + shortcutInfo, + appTarget, + mock(), + ) + + val refinement = Intent("PROPOSED_REFINEMENT") + assertThat(targetInfo.tryToCloneWithAppliedRefinement(refinement)).isNull() + } + @Test fun testNewDisplayResolveInfo() { val intent = Intent(Intent.ACTION_SEND) @@ -162,6 +204,64 @@ class TargetInfoTest { assertThat(targetInfo.isChooserTargetInfo()).isFalse() } + @Test + fun test_DisplayResolveInfo_refinementToAlternateSourceIntent() { + val originalIntent = Intent("DONT_REFINE_ME") + originalIntent.putExtra("originalIntent", true) + val mismatchedAlternate = Intent("DOESNT_MATCH") + mismatchedAlternate.putExtra("mismatchedAlternate", true) + val targetAlternate = Intent("REFINE_ME") + targetAlternate.putExtra("targetAlternate", true) + val extraMatch = Intent("REFINE_ME") + extraMatch.putExtra("extraMatch", true) + + val originalInfo = DisplayResolveInfo.newDisplayResolveInfo( + originalIntent, + ResolverDataProvider.createResolveInfo(3, 0), + "label", + "extended info", + originalIntent, + /* resolveInfoPresentationGetter= */ null) + originalInfo.addAlternateSourceIntent(mismatchedAlternate) + originalInfo.addAlternateSourceIntent(targetAlternate) + originalInfo.addAlternateSourceIntent(extraMatch) + + val refinement = Intent("REFINE_ME") // First match is `targetAlternate` + refinement.putExtra("refinement", true) + + val refinedResult = originalInfo.tryToCloneWithAppliedRefinement(refinement) + // Note `DisplayResolveInfo` targets merge refinements directly into their `resolvedIntent`. + assertThat(refinedResult.resolvedIntent.getBooleanExtra("refinement", false)).isTrue() + assertThat(refinedResult.resolvedIntent.getBooleanExtra("targetAlternate", false)) + .isTrue() + // None of the other source intents got merged in (not even the later one that matched): + assertThat(refinedResult.resolvedIntent.getBooleanExtra("originalIntent", false)) + .isFalse() + assertThat(refinedResult.resolvedIntent.getBooleanExtra("mismatchedAlternate", false)) + .isFalse() + assertThat(refinedResult.resolvedIntent.getBooleanExtra("extraMatch", false)).isFalse() + } + + @Test + fun testDisplayResolveInfo_noSourceIntentMatchingProposedRefinement() { + val originalIntent = Intent("DONT_REFINE_ME") + originalIntent.putExtra("originalIntent", true) + val mismatchedAlternate = Intent("DOESNT_MATCH") + mismatchedAlternate.putExtra("mismatchedAlternate", true) + + val originalInfo = DisplayResolveInfo.newDisplayResolveInfo( + originalIntent, + ResolverDataProvider.createResolveInfo(3, 0), + "label", + "extended info", + originalIntent, + /* resolveInfoPresentationGetter= */ null) + originalInfo.addAlternateSourceIntent(mismatchedAlternate) + + val refinement = Intent("PROPOSED_REFINEMENT") + assertThat(originalInfo.tryToCloneWithAppliedRefinement(refinement)).isNull() + } + @Test fun testNewMultiDisplayResolveInfo() { val intent = Intent(Intent.ACTION_SEND) @@ -204,12 +304,93 @@ class TargetInfoTest { assertThat(multiTargetInfo.hasSelected()).isTrue() assertThat(multiTargetInfo.getSelectedTarget()).isEqualTo(secondTargetInfo) - val multiTargetInfoClone = multiTargetInfo.cloneFilledIn(Intent(), 0) - assertThat(multiTargetInfoClone).isInstanceOf(MultiDisplayResolveInfo::class.java) - assertThat((multiTargetInfoClone as MultiDisplayResolveInfo).hasSelected()) + val refined = multiTargetInfo.tryToCloneWithAppliedRefinement(intent) + assertThat(refined).isInstanceOf(MultiDisplayResolveInfo::class.java) + assertThat((refined as MultiDisplayResolveInfo).hasSelected()) .isEqualTo(multiTargetInfo.hasSelected()) // TODO: consider exercising activity-start behavior. // TODO: consider exercising DisplayResolveInfo base class behavior. } + + @Test + fun testNewMultiDisplayResolveInfo_getAllSourceIntents_fromSelectedTarget() { + val sendImage = Intent("SEND").apply { type = "image/png" } + val sendUri = Intent("SEND").apply { type = "text/uri" } + + val resolveInfo = ResolverDataProvider.createResolveInfo(1, 0) + + val imageOnlyTarget = DisplayResolveInfo.newDisplayResolveInfo( + sendImage, + resolveInfo, + "Send Image", + "Sends only images", + sendImage, + /* resolveInfoPresentationGetter= */ null) + + val textOnlyTarget = DisplayResolveInfo.newDisplayResolveInfo( + sendUri, + resolveInfo, + "Send Text", + "Sends only text", + sendUri, + /* resolveInfoPresentationGetter= */ null) + + val imageOrTextTarget = DisplayResolveInfo.newDisplayResolveInfo( + sendImage, + resolveInfo, + "Send Image or Text", + "Sends images or text", + sendImage, + /* resolveInfoPresentationGetter= */ null + ).apply { + addAlternateSourceIntent(sendUri) + } + + val multiTargetInfo = MultiDisplayResolveInfo.newMultiDisplayResolveInfo( + listOf(imageOnlyTarget, textOnlyTarget, imageOrTextTarget) + ) + + multiTargetInfo.setSelected(0) + assertThat(multiTargetInfo.selectedTarget).isEqualTo(imageOnlyTarget) + assertThat(multiTargetInfo.allSourceIntents).isEqualTo(imageOnlyTarget.allSourceIntents) + + multiTargetInfo.setSelected(1) + assertThat(multiTargetInfo.selectedTarget).isEqualTo(textOnlyTarget) + assertThat(multiTargetInfo.allSourceIntents).isEqualTo(textOnlyTarget.allSourceIntents) + + multiTargetInfo.setSelected(2) + assertThat(multiTargetInfo.selectedTarget).isEqualTo(imageOrTextTarget) + assertThat(multiTargetInfo.allSourceIntents).isEqualTo(imageOrTextTarget.allSourceIntents) + } + + @Test + fun testNewMultiDisplayResolveInfo_tryToCloneWithAppliedRefinement_delegatedToSelectedTarget() { + val refined = Intent("SEND") + val sendImage = Intent("SEND") + val targetOne = spy( + DisplayResolveInfo.newDisplayResolveInfo( + sendImage, + ResolverDataProvider.createResolveInfo(1, 0), + "Target One", + "Target One", + sendImage, + /* resolveInfoPresentationGetter= */ null + ) + ) + val targetTwo = mock { + whenever(tryToCloneWithAppliedRefinement(any())).thenReturn(this) + } + + val multiTargetInfo = MultiDisplayResolveInfo.newMultiDisplayResolveInfo( + listOf(targetOne, targetTwo) + ) + + multiTargetInfo.setSelected(1) + assertThat(multiTargetInfo.selectedTarget).isEqualTo(targetTwo) + + multiTargetInfo.tryToCloneWithAppliedRefinement(refined) + verify(targetTwo, times(1)).tryToCloneWithAppliedRefinement(refined) + verify(targetOne, never()).tryToCloneWithAppliedRefinement(any()) + } } -- cgit v1.2.3-59-g8ed1b From 05ba3a265af1b5763899d543f66d033fbd3cbeb7 Mon Sep 17 00:00:00 2001 From: Andrey Epin Date: Wed, 1 Mar 2023 21:28:38 -0800 Subject: Add animation to custom actions Animate custom actions in the same way as targets selection i.e. slide-out to the left / slide-in from the right. Bug: 271366532 Test: manual testing Change-Id: I2fbff047b8a06cbdd890b56db45ba1efce3e3f95 --- java/res/anim/slide_in_right.xml | 22 ++++++++++++++++++++++ java/res/anim/slide_out_left.xml | 20 ++++++++++++++++++++ .../intentresolver/ChooserActionFactory.java | 14 +++++++++++++- 3 files changed, 55 insertions(+), 1 deletion(-) create mode 100644 java/res/anim/slide_in_right.xml create mode 100644 java/res/anim/slide_out_left.xml (limited to 'java/src') diff --git a/java/res/anim/slide_in_right.xml b/java/res/anim/slide_in_right.xml new file mode 100644 index 00000000..3d3cd919 --- /dev/null +++ b/java/res/anim/slide_in_right.xml @@ -0,0 +1,22 @@ + + + + + + \ No newline at end of file diff --git a/java/res/anim/slide_out_left.xml b/java/res/anim/slide_out_left.xml new file mode 100644 index 00000000..b3471518 --- /dev/null +++ b/java/res/anim/slide_out_left.xml @@ -0,0 +1,20 @@ + + + + + \ 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 566b2546..14d59720 100644 --- a/java/src/com/android/intentresolver/ChooserActionFactory.java +++ b/java/src/com/android/intentresolver/ChooserActionFactory.java @@ -18,6 +18,7 @@ package com.android.intentresolver; import android.annotation.Nullable; import android.app.Activity; +import android.app.ActivityOptions; import android.app.PendingIntent; import android.content.ClipData; import android.content.ClipboardManager; @@ -490,7 +491,18 @@ public final class ChooserActionFactory implements ChooserContentPreviewUi.Actio icon, () -> { try { - action.getAction().send(); + 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"); } -- cgit v1.2.3-59-g8ed1b From 1d9b80bad8984a6b2c4d6277e162de07ded7bd41 Mon Sep 17 00:00:00 2001 From: Andrey Epin Date: Fri, 17 Feb 2023 15:25:15 -0800 Subject: Add image caching to ImagePreviewImageLoader ScrollableImagePreviewView being a RecyclerView may reattache its children multiple times and rely on the ImageLoader to implement any image retrival optimizations. Fix: 269797062 Test: manual test, unit tests Change-Id: I256f4a78a677e939f717fee5dd82492ec572bc65 --- .../chooser_image_preview_view_internals.xml | 12 +-- java/res/layout/image_preview_image_item.xml | 4 +- java/res/values/dimens.xml | 2 + .../android/intentresolver/ChooserActivity.java | 11 ++- .../intentresolver/ChooserContentPreviewUi.java | 2 + java/src/com/android/intentresolver/ImageLoader.kt | 1 + .../intentresolver/ImagePreviewImageLoader.kt | 46 ++++++++-- .../intentresolver/ImagePreviewImageLoaderTest.kt | 101 +++++++++++++++++++++ .../android/intentresolver/MockitoKotlinHelpers.kt | 3 + .../intentresolver/TestPreviewImageLoader.kt | 1 + 10 files changed, 166 insertions(+), 17 deletions(-) create mode 100644 java/tests/src/com/android/intentresolver/ImagePreviewImageLoaderTest.kt (limited to 'java/src') diff --git a/java/res/layout/chooser_image_preview_view_internals.xml b/java/res/layout/chooser_image_preview_view_internals.xml index 8730fc30..2b93edf8 100644 --- a/java/res/layout/chooser_image_preview_view_internals.xml +++ b/java/res/layout/chooser_image_preview_view_internals.xml @@ -26,8 +26,8 @@ diff --git a/java/res/values/dimens.xml b/java/res/values/dimens.xml index 93cb4637..87eec7fb 100644 --- a/java/res/values/dimens.xml +++ b/java/res/values/dimens.xml @@ -25,6 +25,8 @@ 24dp 20sp 1dp + 120dp + 104dp 200dp -1px 4dp diff --git a/java/src/com/android/intentresolver/ChooserActivity.java b/java/src/com/android/intentresolver/ChooserActivity.java index 32b10f23..910eb885 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.chooser.MultiDisplayResolveInfo; import com.android.intentresolver.chooser.TargetInfo; import com.android.intentresolver.flags.FeatureFlagRepository; import com.android.intentresolver.flags.FeatureFlagRepositoryFactory; +import com.android.intentresolver.flags.Flags; import com.android.intentresolver.grid.ChooserGridAdapter; import com.android.intentresolver.grid.DirectShareViewHolder; import com.android.intentresolver.model.AbstractResolverComparator; @@ -1338,7 +1339,15 @@ public class ChooserActivity extends ResolverActivity implements @VisibleForTesting protected ImageLoader createPreviewImageLoader() { - return new ImagePreviewImageLoader(this, getLifecycle()); + final int cacheSize; + if (mFeatureFlagRepository.isEnabled(Flags.SHARESHEET_SCROLLABLE_IMAGE_PREVIEW)) { + float chooserWidth = getResources().getDimension(R.dimen.chooser_width); + float imageWidth = getResources().getDimension(R.dimen.chooser_preview_image_width); + cacheSize = (int) (Math.ceil(chooserWidth / imageWidth) + 2); + } else { + cacheSize = 3; + } + return new ImagePreviewImageLoader(this, getLifecycle(), cacheSize); } private void handleScroll(View view, int x, int y, int oldx, int oldy) { diff --git a/java/src/com/android/intentresolver/ChooserContentPreviewUi.java b/java/src/com/android/intentresolver/ChooserContentPreviewUi.java index aa147853..60ea0122 100644 --- a/java/src/com/android/intentresolver/ChooserContentPreviewUi.java +++ b/java/src/com/android/intentresolver/ChooserContentPreviewUi.java @@ -17,6 +17,7 @@ package com.android.intentresolver; import static android.content.ContentProvider.getUserIdFromUri; + import static java.lang.annotation.RetentionPolicy.SOURCE; import android.animation.ObjectAnimator; @@ -413,6 +414,7 @@ public final class ChooserContentPreviewUi { actionFactory); imagePreview.setTransitionElementStatusCallback(transitionElementStatusCallback); imagePreview.setImages(imageUris, imageLoader); + imageLoader.prePopulate(imageUris); return contentPreviewLayout; } diff --git a/java/src/com/android/intentresolver/ImageLoader.kt b/java/src/com/android/intentresolver/ImageLoader.kt index 13b1dd9c..0ed8b122 100644 --- a/java/src/com/android/intentresolver/ImageLoader.kt +++ b/java/src/com/android/intentresolver/ImageLoader.kt @@ -22,4 +22,5 @@ import java.util.function.Consumer interface ImageLoader : suspend (Uri) -> Bitmap? { fun loadImage(uri: Uri, callback: Consumer) + fun prePopulate(uris: List) } diff --git a/java/src/com/android/intentresolver/ImagePreviewImageLoader.kt b/java/src/com/android/intentresolver/ImagePreviewImageLoader.kt index 40081c87..7b6651a2 100644 --- a/java/src/com/android/intentresolver/ImagePreviewImageLoader.kt +++ b/java/src/com/android/intentresolver/ImagePreviewImageLoader.kt @@ -20,21 +20,34 @@ import android.content.Context import android.graphics.Bitmap import android.net.Uri 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 kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.isActive import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext import java.util.function.Consumer -internal class ImagePreviewImageLoader @JvmOverloads constructor( +@VisibleForTesting +class ImagePreviewImageLoader @JvmOverloads constructor( private val context: Context, private val lifecycle: Lifecycle, + cacheSize: Int, private val dispatcher: CoroutineDispatcher = Dispatchers.IO ) : ImageLoader { + private val thumbnailSize: Size = + context.resources.getDimensionPixelSize(R.dimen.chooser_preview_image_max_dimen).let { + Size(it, it) + } + + @GuardedBy("self") + private val cache = LruCache>(cacheSize) + override suspend fun invoke(uri: Uri): Bitmap? = loadImageAsync(uri) override fun loadImage(uri: Uri, callback: Consumer) { @@ -46,12 +59,29 @@ internal class ImagePreviewImageLoader @JvmOverloads constructor( } } - private suspend fun loadImageAsync(uri: Uri): Bitmap? { - val size = context.resources.getDimensionPixelSize(R.dimen.chooser_preview_image_max_dimen) - return withContext(dispatcher) { - runCatching { - context.contentResolver.loadThumbnail(uri, Size(size, size), null) - }.getOrNull() + override fun prePopulate(uris: List) { + uris.asSequence().take(cache.maxSize()).forEach { uri -> + lifecycle.coroutineScope.launch { + loadImageAsync(uri) + } } } + + private suspend fun loadImageAsync(uri: Uri): Bitmap? { + return synchronized(cache) { + cache.get(uri) ?: CompletableDeferred().also { result -> + cache.put(uri, result) + lifecycle.coroutineScope.launch(dispatcher) { + result.loadBitmap(uri) + } + } + }.await() + } + + private fun CompletableDeferred.loadBitmap(uri: Uri) { + val bitmap = runCatching { + context.contentResolver.loadThumbnail(uri, thumbnailSize, null) + }.getOrNull() + complete(bitmap) + } } diff --git a/java/tests/src/com/android/intentresolver/ImagePreviewImageLoaderTest.kt b/java/tests/src/com/android/intentresolver/ImagePreviewImageLoaderTest.kt new file mode 100644 index 00000000..f327e19e --- /dev/null +++ b/java/tests/src/com/android/intentresolver/ImagePreviewImageLoaderTest.kt @@ -0,0 +1,101 @@ +/* + * Copyright (C) 2023 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.intentresolver + +import android.content.ContentResolver +import android.content.Context +import android.content.res.Resources +import android.net.Uri +import android.util.Size +import androidx.lifecycle.Lifecycle +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.TestCoroutineScheduler +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import org.junit.After +import org.junit.Before +import org.junit.Test +import org.mockito.Mockito.never +import org.mockito.Mockito.times +import org.mockito.Mockito.verify + +@OptIn(ExperimentalCoroutinesApi::class) +class ImagePreviewImageLoaderTest { + private val imageSize = Size(300, 300) + private val uriOne = Uri.parse("content://org.package.app/image-1.png") + private val uriTwo = Uri.parse("content://org.package.app/image-2.png") + private val contentResolver = mock() + private val resources = mock { + whenever(getDimensionPixelSize(R.dimen.chooser_preview_image_max_dimen)) + .thenReturn(imageSize.width) + } + private val context = mock { + whenever(this.resources).thenReturn(this@ImagePreviewImageLoaderTest.resources) + whenever(this.contentResolver).thenReturn(this@ImagePreviewImageLoaderTest.contentResolver) + } + private val scheduler = TestCoroutineScheduler() + private val lifecycleOwner = TestLifecycleOwner() + private val dispatcher = UnconfinedTestDispatcher(scheduler) + private val testSubject = ImagePreviewImageLoader( + context, lifecycleOwner.lifecycle, 1, dispatcher + ) + + @Before + fun setup() { + Dispatchers.setMain(dispatcher) + lifecycleOwner.state = Lifecycle.State.CREATED + } + + @After + fun cleanup() { + lifecycleOwner.state = Lifecycle.State.DESTROYED + Dispatchers.resetMain() + } + + @Test + fun test_prePopulate() = runTest { + testSubject.prePopulate(listOf(uriOne, uriTwo)) + + verify(contentResolver, times(1)).loadThumbnail(uriOne, imageSize, null) + verify(contentResolver, never()).loadThumbnail(uriTwo, imageSize, null) + + testSubject(uriOne) + verify(contentResolver, times(1)).loadThumbnail(uriOne, imageSize, null) + } + + @Test + fun test_invoke_return_cached_image() = runTest { + testSubject(uriOne) + testSubject(uriOne) + + verify(contentResolver, times(1)).loadThumbnail(any(), any(), anyOrNull()) + } + + @Test + fun test_invoke_old_records_evicted_from_the_cache() = runTest { + testSubject(uriOne) + testSubject(uriTwo) + testSubject(uriTwo) + testSubject(uriOne) + + verify(contentResolver, times(2)).loadThumbnail(uriOne, imageSize, null) + verify(contentResolver, times(1)).loadThumbnail(uriTwo, imageSize, null) + } +} diff --git a/java/tests/src/com/android/intentresolver/MockitoKotlinHelpers.kt b/java/tests/src/com/android/intentresolver/MockitoKotlinHelpers.kt index 159c6d6a..aaa7a282 100644 --- a/java/tests/src/com/android/intentresolver/MockitoKotlinHelpers.kt +++ b/java/tests/src/com/android/intentresolver/MockitoKotlinHelpers.kt @@ -26,6 +26,7 @@ package com.android.intentresolver import org.mockito.ArgumentCaptor import org.mockito.ArgumentMatcher +import org.mockito.ArgumentMatchers import org.mockito.Mockito import org.mockito.stubbing.OngoingStubbing @@ -144,3 +145,5 @@ inline fun withArgCaptor(block: KotlinArgumentCaptor.() -> */ inline fun captureMany(block: KotlinArgumentCaptor.() -> Unit): List = kotlinArgumentCaptor().apply{ block() }.allValues + +inline fun anyOrNull() = ArgumentMatchers.argThat(ArgumentMatcher { true }) diff --git a/java/tests/src/com/android/intentresolver/TestPreviewImageLoader.kt b/java/tests/src/com/android/intentresolver/TestPreviewImageLoader.kt index fd617fdd..cfe041dd 100644 --- a/java/tests/src/com/android/intentresolver/TestPreviewImageLoader.kt +++ b/java/tests/src/com/android/intentresolver/TestPreviewImageLoader.kt @@ -34,4 +34,5 @@ internal class TestPreviewImageLoader( } override suspend fun invoke(uri: Uri): Bitmap? = imageOverride() ?: imageLoader(uri) + override fun prePopulate(uris: List) = Unit } -- cgit v1.2.3-59-g8ed1b From 21ab7fb0b67fe3b5c16fc26b5cf6d016bfc0e248 Mon Sep 17 00:00:00 2001 From: Andrey Epin Date: Mon, 6 Mar 2023 12:01:27 -0800 Subject: Split ChooserContentPreviewUi into multiple components A prep step for the new preview UI: extract each preview logic from ChooserContentPreviewUi into individual components leaving the former as a facade for the latter. More specifically: * move ChooserContnetPreviewUi into a new package; * make #displayTextContentPreview() to be the core of the TextContentPreviewUI (with related methods); * make #displayFileContentPreview() to be the core of the FileContentPreviewUi (with related methods); * make #displayImageContentPreview() to the core of the ImageContentPreviewUi (with the related methods); * for all aforementioned new components, pass component specific dependencies as constructor arguments, capture the common component contract in the base abstract class, ContentPreviewUi, along with the static utility methods. Bug: 271613784 Test: Manual testing of * Text sharing with and without preview; * File sharing with and without preivew, single file and multiple fiels; * Image sharing, single and multiple files, image + text sharing; * Custom actions with text, image and files sharing; * Reselection action with text, image and files sharing. Test: screenshot transition animation Test: atest IntentResolverUnitTests Change-Id: I392de610b3d3e044e23c83d29fd11061fbc7192d --- .../intentresolver/ChooserActionFactory.java | 1 + .../android/intentresolver/ChooserActivity.java | 111 ++-- .../intentresolver/ChooserActivityLogger.java | 7 +- .../intentresolver/ChooserContentPreviewUi.java | 699 --------------------- .../com/android/intentresolver/HttpUriMatcher.kt | 29 - .../android/intentresolver/ResolverActivity.java | 2 +- .../contentpreview/ChooserContentPreviewUi.java | 310 +++++++++ .../contentpreview/ContentPreviewType.java | 35 ++ .../contentpreview/ContentPreviewUi.java | 130 ++++ .../contentpreview/FileContentPreviewUi.java | 236 +++++++ .../contentpreview/ImageContentPreviewUi.java | 179 ++++++ .../intentresolver/contentpreview/IsHttpUri.kt | 27 + .../contentpreview/NoContextPreviewUi.kt | 33 + .../contentpreview/TextContentPreviewUi.java | 138 ++++ .../intentresolver/ChooserActivityLoggerTest.java | 5 +- .../intentresolver/TestFeatureFlagRepository.kt | 2 +- .../contentpreview/ChooserContentPreviewUiTest.kt | 203 ++++++ 17 files changed, 1352 insertions(+), 795 deletions(-) delete mode 100644 java/src/com/android/intentresolver/ChooserContentPreviewUi.java delete mode 100644 java/src/com/android/intentresolver/HttpUriMatcher.kt create mode 100644 java/src/com/android/intentresolver/contentpreview/ChooserContentPreviewUi.java create mode 100644 java/src/com/android/intentresolver/contentpreview/ContentPreviewType.java create mode 100644 java/src/com/android/intentresolver/contentpreview/ContentPreviewUi.java create mode 100644 java/src/com/android/intentresolver/contentpreview/FileContentPreviewUi.java create mode 100644 java/src/com/android/intentresolver/contentpreview/ImageContentPreviewUi.java create mode 100644 java/src/com/android/intentresolver/contentpreview/IsHttpUri.kt create mode 100644 java/src/com/android/intentresolver/contentpreview/NoContextPreviewUi.kt create mode 100644 java/src/com/android/intentresolver/contentpreview/TextContentPreviewUi.java create mode 100644 java/tests/src/com/android/intentresolver/contentpreview/ChooserContentPreviewUiTest.kt (limited to 'java/src') diff --git a/java/src/com/android/intentresolver/ChooserActionFactory.java b/java/src/com/android/intentresolver/ChooserActionFactory.java index 14d59720..947155f3 100644 --- a/java/src/com/android/intentresolver/ChooserActionFactory.java +++ b/java/src/com/android/intentresolver/ChooserActionFactory.java @@ -39,6 +39,7 @@ import android.view.View; import com.android.intentresolver.chooser.DisplayResolveInfo; import com.android.intentresolver.chooser.TargetInfo; +import com.android.intentresolver.contentpreview.ChooserContentPreviewUi; import com.android.intentresolver.flags.FeatureFlagRepository; import com.android.intentresolver.flags.Flags; import com.android.intentresolver.widget.ActionRow; diff --git a/java/src/com/android/intentresolver/ChooserActivity.java b/java/src/com/android/intentresolver/ChooserActivity.java index 3a7c892e..ae5be26d 100644 --- a/java/src/com/android/intentresolver/ChooserActivity.java +++ b/java/src/com/android/intentresolver/ChooserActivity.java @@ -83,6 +83,7 @@ import com.android.intentresolver.NoCrossProfileEmptyStateProvider.DevicePolicyB import com.android.intentresolver.chooser.DisplayResolveInfo; import com.android.intentresolver.chooser.MultiDisplayResolveInfo; import com.android.intentresolver.chooser.TargetInfo; +import com.android.intentresolver.contentpreview.ChooserContentPreviewUi; import com.android.intentresolver.flags.FeatureFlagRepository; import com.android.intentresolver.flags.FeatureFlagRepositoryFactory; import com.android.intentresolver.flags.Flags; @@ -205,7 +206,6 @@ public class ChooserActivity extends ResolverActivity implements private ChooserRefinementManager mRefinementManager; private FeatureFlagRepository mFeatureFlagRepository; - private ChooserActionFactory mChooserActionFactory; private ChooserContentPreviewUi mChooserContentPreviewUi; private boolean mShouldDisplayLandscape; @@ -231,9 +231,6 @@ public class ChooserActivity extends ResolverActivity implements private final ExecutorService mBackgroundThreadPoolExecutor = Executors.newFixedThreadPool(5); - @Nullable - private ImageLoader mPreviewImageLoader; - private int mScrollStatus = SCROLL_STATUS_IDLE; @VisibleForTesting @@ -273,38 +270,6 @@ public class ChooserActivity extends ResolverActivity implements return; } - mChooserActionFactory = new ChooserActionFactory( - this, - mChooserRequest, - mFeatureFlagRepository, - mIntegratedDeviceComponents, - getChooserActivityLogger(), - (isExcluded) -> mExcludeSharedText = isExcluded, - this::getFirstVisibleImgPreviewView, - new ChooserActionFactory.ActionActivityStarter() { - @Override - public void safelyStartActivityAsPersonalProfileUser(TargetInfo targetInfo) { - safelyStartActivityAsUser(targetInfo, getPersonalProfileUserHandle()); - finish(); - } - - @Override - public void safelyStartActivityAsPersonalProfileUserWithSharedElementTransition( - TargetInfo targetInfo, View sharedElement, String sharedElementName) { - ActivityOptions options = ActivityOptions.makeSceneTransitionAnimation( - ChooserActivity.this, sharedElement, sharedElementName); - safelyStartActivityAsUser( - targetInfo, getPersonalProfileUserHandle(), options.toBundle()); - startFinishAnimation(); - } - }, - (status) -> { - if (status != null) { - setResult(status); - } - finish(); - }); - mRefinementManager = new ChooserRefinementManager( this, mChooserRequest.getRefinementIntentSender(), @@ -319,7 +284,14 @@ public class ChooserActivity extends ResolverActivity implements finish(); }); - mChooserContentPreviewUi = new ChooserContentPreviewUi(mFeatureFlagRepository); + mChooserContentPreviewUi = new ChooserContentPreviewUi( + mChooserRequest.getTargetIntent(), + getContentResolver(), + this::isImageType, + createPreviewImageLoader(), + createChooserActionFactory(), + mEnterTransitionAnimationDelegate, + mFeatureFlagRepository); setAdditionalTargets(mChooserRequest.getAdditionalTargets()); @@ -339,8 +311,6 @@ public class ChooserActivity extends ResolverActivity implements mChooserRequest.getTargetIntentFilter()), mChooserRequest.getTargetIntentFilter()); - mPreviewImageLoader = createPreviewImageLoader(); - super.onCreate( savedInstanceState, mChooserRequest.getTargetIntent(), @@ -392,8 +362,7 @@ public class ChooserActivity extends ResolverActivity implements (mChooserRequest.getInitialIntents() == null) ? 0 : mChooserRequest.getInitialIntents().length, isWorkProfile(), - ChooserContentPreviewUi.findPreferredContentPreview( - getTargetIntent(), getContentResolver(), this::isImageType), + mChooserContentPreviewUi.getPreferredContentPreview(), mChooserRequest.getTargetAction(), mChooserRequest.getChooserActions().size(), mChooserRequest.getModifyShareAction() != null @@ -594,8 +563,7 @@ public class ChooserActivity extends ResolverActivity implements || mChooserMultiProfilePagerAdapter .getCurrentRootAdapter().getSystemRowCount() != 0) { getChooserActivityLogger().logActionShareWithPreview( - ChooserContentPreviewUi.findPreferredContentPreview( - getTargetIntent(), getContentResolver(), this::isImageType)); + mChooserContentPreviewUi.getPreferredContentPreview()); } return postRebuildListInternal(rebuildCompleted); } @@ -717,22 +685,11 @@ public class ChooserActivity extends ResolverActivity implements * @param parent reference to the parent container where the view should be attached to * @return content preview view */ - protected ViewGroup createContentPreviewView(ViewGroup parent, ImageLoader imageLoader) { - Intent targetIntent = getTargetIntent(); - int previewType = ChooserContentPreviewUi.findPreferredContentPreview( - targetIntent, getContentResolver(), this::isImageType); - + protected ViewGroup createContentPreviewView(ViewGroup parent) { ViewGroup layout = mChooserContentPreviewUi.displayContentPreview( - previewType, - targetIntent, getResources(), getLayoutInflater(), - mChooserActionFactory, - parent, - imageLoader, - mEnterTransitionAnimationDelegate, - getContentResolver(), - this::isImageType); + parent); if (layout != null) { adjustPreviewWidth(getResources().getConfiguration().orientation, layout); @@ -1223,7 +1180,7 @@ public class ChooserActivity extends ResolverActivity implements @Override public View buildContentPreview(ViewGroup parent) { - return createContentPreviewView(parent, mPreviewImageLoader); + return createContentPreviewView(parent); } @Override @@ -1350,6 +1307,40 @@ public class ChooserActivity extends ResolverActivity implements return new ImagePreviewImageLoader(this, getLifecycle(), cacheSize); } + private ChooserActionFactory createChooserActionFactory() { + return new ChooserActionFactory( + this, + mChooserRequest, + mFeatureFlagRepository, + mIntegratedDeviceComponents, + getChooserActivityLogger(), + (isExcluded) -> mExcludeSharedText = isExcluded, + this::getFirstVisibleImgPreviewView, + new ChooserActionFactory.ActionActivityStarter() { + @Override + public void safelyStartActivityAsPersonalProfileUser(TargetInfo targetInfo) { + safelyStartActivityAsUser(targetInfo, getPersonalProfileUserHandle()); + finish(); + } + + @Override + public void safelyStartActivityAsPersonalProfileUserWithSharedElementTransition( + TargetInfo targetInfo, View sharedElement, String sharedElementName) { + ActivityOptions options = ActivityOptions.makeSceneTransitionAnimation( + ChooserActivity.this, sharedElement, sharedElementName); + safelyStartActivityAsUser( + targetInfo, getPersonalProfileUserHandle(), options.toBundle()); + startFinishAnimation(); + } + }, + (status) -> { + if (status != null) { + setResult(status); + } + finish(); + }); + } + private void handleScroll(View view, int x, int y, int oldx, int oldy) { if (mChooserMultiProfilePagerAdapter.getCurrentRootAdapter() != null) { mChooserMultiProfilePagerAdapter.getCurrentRootAdapter().handleScroll(view, y, oldy); @@ -1712,10 +1703,10 @@ public class ChooserActivity extends ResolverActivity implements // 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); + ViewGroup contentPreviewContainer = + findViewById(com.android.internal.R.id.content_preview_container); if (contentPreviewContainer.getChildCount() == 0) { - ViewGroup contentPreviewView = - createContentPreviewView(contentPreviewContainer, mPreviewImageLoader); + ViewGroup contentPreviewView = createContentPreviewView(contentPreviewContainer); contentPreviewContainer.addView(contentPreviewView); } } diff --git a/java/src/com/android/intentresolver/ChooserActivityLogger.java b/java/src/com/android/intentresolver/ChooserActivityLogger.java index f7ab595b..1f606f26 100644 --- a/java/src/com/android/intentresolver/ChooserActivityLogger.java +++ b/java/src/com/android/intentresolver/ChooserActivityLogger.java @@ -24,6 +24,7 @@ import android.provider.MediaStore; import android.util.HashedStringCache; import android.util.Log; +import com.android.intentresolver.contentpreview.ContentPreviewType; import com.android.internal.annotations.VisibleForTesting; import com.android.internal.logging.InstanceId; import com.android.internal.logging.InstanceIdSequence; @@ -432,11 +433,11 @@ public class ChooserActivityLogger { */ private static int typeFromPreviewInt(int previewType) { switch(previewType) { - case ChooserContentPreviewUi.CONTENT_PREVIEW_IMAGE: + case ContentPreviewType.CONTENT_PREVIEW_IMAGE: return FrameworkStatsLog.SHARESHEET_STARTED__PREVIEW_TYPE__CONTENT_PREVIEW_IMAGE; - case ChooserContentPreviewUi.CONTENT_PREVIEW_FILE: + case ContentPreviewType.CONTENT_PREVIEW_FILE: return FrameworkStatsLog.SHARESHEET_STARTED__PREVIEW_TYPE__CONTENT_PREVIEW_FILE; - case ChooserContentPreviewUi.CONTENT_PREVIEW_TEXT: + case ContentPreviewType.CONTENT_PREVIEW_TEXT: default: return FrameworkStatsLog .SHARESHEET_STARTED__PREVIEW_TYPE__CONTENT_PREVIEW_TYPE_UNKNOWN; diff --git a/java/src/com/android/intentresolver/ChooserContentPreviewUi.java b/java/src/com/android/intentresolver/ChooserContentPreviewUi.java deleted file mode 100644 index 60ea0122..00000000 --- a/java/src/com/android/intentresolver/ChooserContentPreviewUi.java +++ /dev/null @@ -1,699 +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.content.ContentProvider.getUserIdFromUri; - -import static java.lang.annotation.RetentionPolicy.SOURCE; - -import android.animation.ObjectAnimator; -import android.animation.ValueAnimator; -import android.annotation.IntDef; -import android.content.ClipData; -import android.content.ContentResolver; -import android.content.Intent; -import android.content.res.Resources; -import android.database.Cursor; -import android.graphics.Bitmap; -import android.net.Uri; -import android.os.UserHandle; -import android.provider.DocumentsContract; -import android.provider.Downloads; -import android.provider.OpenableColumns; -import android.text.TextUtils; -import android.text.util.Linkify; -import android.transition.TransitionManager; -import android.util.Log; -import android.util.PluralsMessageFormatter; -import android.view.LayoutInflater; -import android.view.View; -import android.view.ViewGroup; -import android.view.ViewStub; -import android.view.animation.DecelerateInterpolator; -import android.widget.CheckBox; -import android.widget.ImageView; -import android.widget.TextView; - -import androidx.annotation.LayoutRes; -import androidx.annotation.Nullable; - -import com.android.intentresolver.flags.FeatureFlagRepository; -import com.android.intentresolver.flags.Flags; -import com.android.intentresolver.widget.ActionRow; -import com.android.intentresolver.widget.ImagePreviewView; -import com.android.intentresolver.widget.ImagePreviewView.TransitionElementStatusCallback; -import com.android.intentresolver.widget.RoundedRectImageView; -import com.android.internal.annotations.VisibleForTesting; - -import java.lang.annotation.Retention; -import java.util.ArrayList; -import java.util.HashMap; -import java.util.List; -import java.util.Map; -import java.util.function.Consumer; -import java.util.stream.Collectors; - -/** - * Collection of helpers for building the content preview UI displayed in {@link ChooserActivity}. - * - * TODO: this "namespace" was pulled out of {@link ChooserActivity} as a bucket of static methods - * to show that they're one-shot procedures with no dependencies back to {@link ChooserActivity} - * state other than the delegates that are explicitly provided. There may be more appropriate - * abstractions (e.g., maybe this can be a "widget" added directly to the view hierarchy to show the - * appropriate preview), or it may at least be safe (and more convenient) to adopt a more "object - * oriented" design where the static specifiers are removed and some of the dependencies are cached - * as ivars when this "class" is initialized. - */ -public final class ChooserContentPreviewUi { - private static final int IMAGE_FADE_IN_MILLIS = 150; - - /** - * Delegate to build the default system action buttons to display in the preview layout, if/when - * they're determined to be appropriate for the particular preview we display. - * TODO: clarify why action buttons are part of preview logic. - */ - public interface ActionFactory { - /** Create an action that copies the share content to the clipboard. */ - ActionRow.Action createCopyButton(); - - /** Create an action that opens the share content in a system-default editor. */ - @Nullable - ActionRow.Action createEditButton(); - - /** Create an "Share to Nearby" action. */ - @Nullable - ActionRow.Action createNearbyButton(); - - /** Create custom actions */ - List createCustomActions(); - - /** - * Provides a share modification action, if any. - */ - @Nullable - Runnable getModifyShareAction(); - - /** - *

- * 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. - *

- */ - Consumer getExcludeSharedTextAction(); - } - - /** - * Testing shim to specify whether a given mime type is considered to be an "image." - * - * TODO: move away from {@link ChooserActivityOverrideData} as a model to configure our tests, - * then migrate {@link ChooserActivity#isImageType(String)} into this class. - */ - public interface ImageMimeTypeClassifier { - /** @return whether the specified {@code mimeType} is classified as an "image" type. */ - boolean isImageType(String mimeType); - } - - @Retention(SOURCE) - @IntDef({CONTENT_PREVIEW_FILE, CONTENT_PREVIEW_IMAGE, CONTENT_PREVIEW_TEXT}) - private @interface ContentPreviewType { - } - - // Starting at 1 since 0 is considered "undefined" for some of the database transformations - // of tron logs. - @VisibleForTesting - public static final int CONTENT_PREVIEW_IMAGE = 1; - @VisibleForTesting - public static final int CONTENT_PREVIEW_FILE = 2; - @VisibleForTesting - public static final int CONTENT_PREVIEW_TEXT = 3; - - private static final String TAG = "ChooserPreview"; - - private static final String PLURALS_COUNT = "count"; - private static final String PLURALS_FILE_NAME = "file_name"; - - private final FeatureFlagRepository mFeatureFlagRepository; - - /** Determine the most appropriate type of preview to show for the provided {@link Intent}. */ - @ContentPreviewType - public static int findPreferredContentPreview( - Intent targetIntent, - ContentResolver resolver, - ImageMimeTypeClassifier imageClassifier) { - /* In {@link android.content.Intent#getType}, the app may specify a very general mime type - * that broadly covers all data being shared, such as {@literal *}/* when sending an image - * and text. We therefore should inspect each item for the preferred type, in order: IMAGE, - * FILE, TEXT. */ - String action = targetIntent.getAction(); - if (Intent.ACTION_SEND.equals(action)) { - Uri uri = targetIntent.getParcelableExtra(Intent.EXTRA_STREAM); - return findPreferredContentPreview(uri, resolver, imageClassifier); - } else if (Intent.ACTION_SEND_MULTIPLE.equals(action)) { - List uris = targetIntent.getParcelableArrayListExtra(Intent.EXTRA_STREAM); - if (uris == null || uris.isEmpty()) { - return CONTENT_PREVIEW_TEXT; - } - - for (Uri uri : uris) { - // Defaulting to file preview when there are mixed image/file types is - // preferable, as it shows the user the correct number of items being shared - int uriPreviewType = findPreferredContentPreview(uri, resolver, imageClassifier); - if (uriPreviewType == CONTENT_PREVIEW_FILE) { - return CONTENT_PREVIEW_FILE; - } - } - - return CONTENT_PREVIEW_IMAGE; - } - - return CONTENT_PREVIEW_TEXT; - } - - public ChooserContentPreviewUi( - FeatureFlagRepository featureFlagRepository) { - mFeatureFlagRepository = featureFlagRepository; - } - - /** - * Display a content preview of the specified {@code previewType} to preview the content of the - * specified {@code intent}. - */ - public ViewGroup displayContentPreview( - @ContentPreviewType int previewType, - Intent targetIntent, - Resources resources, - LayoutInflater layoutInflater, - ActionFactory actionFactory, - ViewGroup parent, - ImageLoader previewImageLoader, - TransitionElementStatusCallback transitionElementStatusCallback, - ContentResolver contentResolver, - ImageMimeTypeClassifier imageClassifier) { - ViewGroup layout = null; - - if (previewType != CONTENT_PREVIEW_IMAGE) { - transitionElementStatusCallback.onAllTransitionElementsReady(); - } - int actionRowLayout = mFeatureFlagRepository.isEnabled(Flags.SHARESHEET_CUSTOM_ACTIONS) - ? R.layout.scrollable_chooser_action_row - : R.layout.chooser_action_row; - List customActions = actionFactory.createCustomActions(); - switch (previewType) { - case CONTENT_PREVIEW_TEXT: - layout = displayTextContentPreview( - targetIntent, - layoutInflater, - createActions( - createTextPreviewActions(actionFactory), - customActions), - parent, - previewImageLoader, - actionRowLayout); - break; - case CONTENT_PREVIEW_IMAGE: - layout = displayImageContentPreview( - targetIntent, - layoutInflater, - createActions( - createImagePreviewActions(actionFactory), - customActions), - parent, - previewImageLoader, - transitionElementStatusCallback, - contentResolver, - imageClassifier, - actionRowLayout, - actionFactory); - break; - case CONTENT_PREVIEW_FILE: - layout = displayFileContentPreview( - targetIntent, - resources, - layoutInflater, - createActions( - createFilePreviewActions(actionFactory), - customActions), - parent, - previewImageLoader, - contentResolver, - actionRowLayout); - break; - default: - Log.e(TAG, "Unexpected content preview type: " + previewType); - } - Runnable modifyShareAction = actionFactory.getModifyShareAction(); - if (modifyShareAction != null && layout != null - && mFeatureFlagRepository.isEnabled(Flags.SHARESHEET_RESELECTION_ACTION)) { - View modifyShareView = layout.findViewById(R.id.reselection_action); - if (modifyShareView != null) { - modifyShareView.setVisibility(View.VISIBLE); - modifyShareView.setOnClickListener(view -> modifyShareAction.run()); - } - } - - return layout; - } - - private List createActions( - List systemActions, List customActions) { - ArrayList actions = - new ArrayList<>(systemActions.size() + customActions.size()); - actions.addAll(systemActions); - if (mFeatureFlagRepository.isEnabled(Flags.SHARESHEET_CUSTOM_ACTIONS)) { - actions.addAll(customActions); - } - return actions; - } - - private static Cursor queryResolver(ContentResolver resolver, Uri uri) { - return resolver.query(uri, null, null, null, null); - } - - @ContentPreviewType - private static int findPreferredContentPreview( - Uri uri, ContentResolver resolver, ImageMimeTypeClassifier imageClassifier) { - if (uri == null) { - return CONTENT_PREVIEW_TEXT; - } - - String mimeType = resolver.getType(uri); - return imageClassifier.isImageType(mimeType) ? CONTENT_PREVIEW_IMAGE : CONTENT_PREVIEW_FILE; - } - - private static ViewGroup displayTextContentPreview( - Intent targetIntent, - LayoutInflater layoutInflater, - List actions, - ViewGroup parent, - ImageLoader previewImageLoader, - @LayoutRes int actionRowLayout) { - ViewGroup contentPreviewLayout = (ViewGroup) layoutInflater.inflate( - R.layout.chooser_grid_preview_text, parent, false); - - final ActionRow actionRow = inflateActionRow(contentPreviewLayout, actionRowLayout); - if (actionRow != null) { - actionRow.setActions(actions); - } - - CharSequence sharingText = targetIntent.getCharSequenceExtra(Intent.EXTRA_TEXT); - if (sharingText == null) { - contentPreviewLayout - .findViewById(com.android.internal.R.id.content_preview_text_layout) - .setVisibility(View.GONE); - } else { - TextView textView = contentPreviewLayout.findViewById( - com.android.internal.R.id.content_preview_text); - textView.setText(sharingText); - } - - String previewTitle = targetIntent.getStringExtra(Intent.EXTRA_TITLE); - if (TextUtils.isEmpty(previewTitle)) { - contentPreviewLayout - .findViewById(com.android.internal.R.id.content_preview_title_layout) - .setVisibility(View.GONE); - } else { - TextView previewTitleView = contentPreviewLayout.findViewById( - com.android.internal.R.id.content_preview_title); - previewTitleView.setText(previewTitle); - - ClipData previewData = targetIntent.getClipData(); - Uri previewThumbnail = null; - if (previewData != null) { - if (previewData.getItemCount() > 0) { - ClipData.Item previewDataItem = previewData.getItemAt(0); - previewThumbnail = previewDataItem.getUri(); - } - } - - ImageView previewThumbnailView = contentPreviewLayout.findViewById( - com.android.internal.R.id.content_preview_thumbnail); - if (!validForContentPreview(previewThumbnail)) { - previewThumbnailView.setVisibility(View.GONE); - } else { - previewImageLoader.loadImage( - previewThumbnail, - (bitmap) -> updateViewWithImage( - contentPreviewLayout.findViewById( - com.android.internal.R.id.content_preview_thumbnail), - bitmap)); - } - } - - return contentPreviewLayout; - } - - private static List createTextPreviewActions(ActionFactory actionFactory) { - ArrayList actions = new ArrayList<>(2); - actions.add(actionFactory.createCopyButton()); - ActionRow.Action nearbyAction = actionFactory.createNearbyButton(); - if (nearbyAction != null) { - actions.add(nearbyAction); - } - return actions; - } - - private ViewGroup displayImageContentPreview( - Intent targetIntent, - LayoutInflater layoutInflater, - List actions, - ViewGroup parent, - ImageLoader imageLoader, - TransitionElementStatusCallback transitionElementStatusCallback, - ContentResolver contentResolver, - ImageMimeTypeClassifier imageClassifier, - @LayoutRes int actionRowLayout, - ActionFactory actionFactory) { - ViewGroup contentPreviewLayout = (ViewGroup) layoutInflater.inflate( - R.layout.chooser_grid_preview_image, parent, false); - ImagePreviewView imagePreview = inflateImagePreviewView(contentPreviewLayout); - - final ActionRow actionRow = inflateActionRow(contentPreviewLayout, actionRowLayout); - if (actionRow != null) { - actionRow.setActions(actions); - } - - String action = targetIntent.getAction(); - // TODO: why don't we use image classifier for single-element ACTION_SEND? - final List imageUris = Intent.ACTION_SEND.equals(action) - ? extractContentUris(targetIntent) - : extractContentUris(targetIntent) - .stream() - .filter(uri -> - imageClassifier.isImageType(contentResolver.getType(uri)) - ) - .collect(Collectors.toList()); - - if (imageUris.size() == 0) { - Log.i(TAG, "Attempted to display image preview area with zero" - + " available images detected in EXTRA_STREAM list"); - ((View) imagePreview).setVisibility(View.GONE); - transitionElementStatusCallback.onAllTransitionElementsReady(); - return contentPreviewLayout; - } - - setTextInImagePreviewVisibility( - contentPreviewLayout, - targetIntent.getCharSequenceExtra(Intent.EXTRA_TEXT), - actionFactory); - imagePreview.setTransitionElementStatusCallback(transitionElementStatusCallback); - imagePreview.setImages(imageUris, imageLoader); - imageLoader.prePopulate(imageUris); - - return contentPreviewLayout; - } - - private void setTextInImagePreviewVisibility( - ViewGroup contentPreview, CharSequence text, ActionFactory actionFactory) { - int visibility = mFeatureFlagRepository.isEnabled(Flags.SHARESHEET_IMAGE_AND_TEXT_PREVIEW) - && !TextUtils.isEmpty(text) - ? View.VISIBLE - : View.GONE; - - final TextView textView = contentPreview - .requireViewById(com.android.internal.R.id.content_preview_text); - CheckBox actionView = contentPreview - .requireViewById(R.id.include_text_action); - textView.setVisibility(visibility); - boolean isLink = visibility == View.VISIBLE && HttpUriMatcher.isHttpUri(text.toString()); - textView.setAutoLinkMask(isLink ? Linkify.WEB_URLS : 0); - textView.setText(text); - - if (visibility == View.VISIBLE) { - final int[] actionLabels = isLink - ? new int[] { R.string.include_link, R.string.exclude_link } - : new int[] { R.string.include_text, R.string.exclude_text }; - final Consumer shareTextAction = actionFactory.getExcludeSharedTextAction(); - actionView.setChecked(true); - actionView.setText(actionLabels[1]); - shareTextAction.accept(false); - actionView.setOnCheckedChangeListener((view, isChecked) -> { - view.setText(actionLabels[isChecked ? 1 : 0]); - TransitionManager.beginDelayedTransition((ViewGroup) textView.getParent()); - textView.setVisibility(isChecked ? View.VISIBLE : View.GONE); - shareTextAction.accept(!isChecked); - }); - } - actionView.setVisibility(visibility); - } - - private static List createImagePreviewActions( - ActionFactory buttonFactory) { - ArrayList actions = new ArrayList<>(2); - //TODO: add copy action; - ActionRow.Action action = buttonFactory.createNearbyButton(); - if (action != null) { - actions.add(action); - } - action = buttonFactory.createEditButton(); - if (action != null) { - actions.add(action); - } - return actions; - } - - private ImagePreviewView inflateImagePreviewView(ViewGroup previewLayout) { - ViewStub stub = previewLayout.findViewById(R.id.image_preview_stub); - if (stub != null) { - int layoutId = - mFeatureFlagRepository.isEnabled(Flags.SHARESHEET_SCROLLABLE_IMAGE_PREVIEW) - ? R.layout.scrollable_image_preview_view - : R.layout.chooser_image_preview_view; - stub.setLayoutResource(layoutId); - stub.inflate(); - } - return previewLayout.findViewById( - com.android.internal.R.id.content_preview_image_area); - } - - private static ViewGroup displayFileContentPreview( - Intent targetIntent, - Resources resources, - LayoutInflater layoutInflater, - List actions, - ViewGroup parent, - ImageLoader imageLoader, - ContentResolver contentResolver, - @LayoutRes int actionRowLayout) { - ViewGroup contentPreviewLayout = (ViewGroup) layoutInflater.inflate( - R.layout.chooser_grid_preview_file, parent, false); - - List uris = extractContentUris(targetIntent); - final int uriCount = uris.size(); - - if (uriCount == 0) { - contentPreviewLayout.setVisibility(View.GONE); - Log.i(TAG, - "Appears to be no uris available in EXTRA_STREAM, removing " - + "preview area"); - return contentPreviewLayout; - } - - if (uriCount == 1) { - loadFileUriIntoView(uris.get(0), contentPreviewLayout, imageLoader, contentResolver); - } else { - FileInfo fileInfo = extractFileInfo(uris.get(0), contentResolver); - int remUriCount = uriCount - 1; - Map arguments = new HashMap<>(); - arguments.put(PLURALS_COUNT, remUriCount); - arguments.put(PLURALS_FILE_NAME, fileInfo.name); - String fileName = - PluralsMessageFormatter.format(resources, arguments, R.string.file_count); - - TextView fileNameView = contentPreviewLayout.findViewById( - com.android.internal.R.id.content_preview_filename); - fileNameView.setText(fileName); - - View thumbnailView = contentPreviewLayout.findViewById( - com.android.internal.R.id.content_preview_file_thumbnail); - thumbnailView.setVisibility(View.GONE); - - ImageView fileIconView = contentPreviewLayout.findViewById( - com.android.internal.R.id.content_preview_file_icon); - fileIconView.setVisibility(View.VISIBLE); - fileIconView.setImageResource(R.drawable.ic_file_copy); - } - - final ActionRow actionRow = inflateActionRow(contentPreviewLayout, actionRowLayout); - if (actionRow != null) { - actionRow.setActions(actions); - } - - return contentPreviewLayout; - } - - private static List extractContentUris(Intent targetIntent) { - List uris = new ArrayList<>(); - if (Intent.ACTION_SEND.equals(targetIntent.getAction())) { - Uri uri = targetIntent.getParcelableExtra(Intent.EXTRA_STREAM); - if (validForContentPreview(uri)) { - uris.add(uri); - } - } else { - List receivedUris = targetIntent.getParcelableArrayListExtra(Intent.EXTRA_STREAM); - if (receivedUris != null) { - for (Uri uri : receivedUris) { - if (validForContentPreview(uri)) { - uris.add(uri); - } - } - } - } - return uris; - } - - /** - * Indicate if the incoming content URI should be allowed. - * - * @param uri the uri to test - * @return true if the URI is allowed for content preview - */ - private static boolean validForContentPreview(Uri uri) throws SecurityException { - if (uri == null) { - return false; - } - int userId = getUserIdFromUri(uri, UserHandle.USER_CURRENT); - if (userId != UserHandle.USER_CURRENT && userId != UserHandle.myUserId()) { - Log.e(TAG, "dropped invalid content URI belonging to user " + userId); - return false; - } - return true; - } - - - private static List createFilePreviewActions(ActionFactory actionFactory) { - List actions = new ArrayList<>(1); - //TODO(b/120417119): - // add action buttonFactory.createCopyButton() - ActionRow.Action action = actionFactory.createNearbyButton(); - if (action != null) { - actions.add(action); - } - return actions; - } - - private static ActionRow inflateActionRow(ViewGroup parent, @LayoutRes int actionRowLayout) { - final ViewStub stub = parent.findViewById(com.android.intentresolver.R.id.action_row_stub); - if (stub != null) { - stub.setLayoutResource(actionRowLayout); - stub.inflate(); - } - return parent.findViewById(com.android.internal.R.id.chooser_action_row); - } - - private static 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"); - } - - private static void loadFileUriIntoView( - final Uri uri, - final View parent, - final ImageLoader imageLoader, - final ContentResolver contentResolver) { - FileInfo fileInfo = extractFileInfo(uri, contentResolver); - - TextView fileNameView = parent.findViewById( - com.android.internal.R.id.content_preview_filename); - fileNameView.setText(fileInfo.name); - - if (fileInfo.hasThumbnail) { - imageLoader.loadImage( - uri, - (bitmap) -> updateViewWithImage( - parent.findViewById( - com.android.internal.R.id.content_preview_file_thumbnail), - bitmap)); - } else { - View thumbnailView = parent.findViewById( - com.android.internal.R.id.content_preview_file_thumbnail); - thumbnailView.setVisibility(View.GONE); - - ImageView fileIconView = parent.findViewById( - com.android.internal.R.id.content_preview_file_icon); - fileIconView.setVisibility(View.VISIBLE); - fileIconView.setImageResource(R.drawable.chooser_file_generic); - } - } - - private static void updateViewWithImage(RoundedRectImageView imageView, Bitmap image) { - if (image == null) { - imageView.setVisibility(View.GONE); - return; - } - imageView.setVisibility(View.VISIBLE); - imageView.setAlpha(0.0f); - imageView.setImageBitmap(image); - - ValueAnimator fadeAnim = ObjectAnimator.ofFloat(imageView, "alpha", 0.0f, 1.0f); - fadeAnim.setInterpolator(new DecelerateInterpolator(1.0f)); - fadeAnim.setDuration(IMAGE_FADE_IN_MILLIS); - fadeAnim.start(); - } - - private static FileInfo extractFileInfo(Uri uri, ContentResolver resolver) { - String fileName = null; - boolean hasThumbnail = false; - - try (Cursor cursor = queryResolver(resolver, uri)) { - if (cursor != null && cursor.getCount() > 0) { - int nameIndex = cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME); - int titleIndex = cursor.getColumnIndex(Downloads.Impl.COLUMN_TITLE); - int flagsIndex = cursor.getColumnIndex(DocumentsContract.Document.COLUMN_FLAGS); - - cursor.moveToFirst(); - if (nameIndex != -1) { - fileName = cursor.getString(nameIndex); - } else if (titleIndex != -1) { - fileName = cursor.getString(titleIndex); - } - - if (flagsIndex != -1) { - hasThumbnail = (cursor.getInt(flagsIndex) - & DocumentsContract.Document.FLAG_SUPPORTS_THUMBNAIL) != 0; - } - } - } catch (SecurityException | NullPointerException e) { - logContentPreviewWarning(uri); - } - - if (TextUtils.isEmpty(fileName)) { - fileName = uri.getPath(); - int index = fileName.lastIndexOf('/'); - if (index != -1) { - fileName = fileName.substring(index + 1); - } - } - - return new FileInfo(fileName, hasThumbnail); - } - - private static class FileInfo { - public final String name; - public final boolean hasThumbnail; - - FileInfo(String name, boolean hasThumbnail) { - this.name = name; - this.hasThumbnail = hasThumbnail; - } - } -} diff --git a/java/src/com/android/intentresolver/HttpUriMatcher.kt b/java/src/com/android/intentresolver/HttpUriMatcher.kt deleted file mode 100644 index 0f59df2b..00000000 --- a/java/src/com/android/intentresolver/HttpUriMatcher.kt +++ /dev/null @@ -1,29 +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. - */ - -@file:JvmName("HttpUriMatcher") -package com.android.intentresolver - -import com.android.internal.annotations.VisibleForTesting -import java.net.URI - -@VisibleForTesting(visibility = VisibleForTesting.Visibility.PACKAGE) -fun String.isHttpUri() = - kotlin.runCatching { - URI(this).scheme.takeIf { scheme -> - "http".compareTo(scheme, true) == 0 || "https".compareTo(scheme, true) == 0 - } - }.getOrNull() != null \ No newline at end of file diff --git a/java/src/com/android/intentresolver/ResolverActivity.java b/java/src/com/android/intentresolver/ResolverActivity.java index 7a0c0f1a..d224299e 100644 --- a/java/src/com/android/intentresolver/ResolverActivity.java +++ b/java/src/com/android/intentresolver/ResolverActivity.java @@ -1581,7 +1581,7 @@ public class ResolverActivity extends FragmentActivity implements * @param cti TargetInfo to be launched. * @param user User to launch this activity as. */ - @VisibleForTesting + @VisibleForTesting(visibility = VisibleForTesting.Visibility.PROTECTED) public final void safelyStartActivityAsUser(TargetInfo cti, UserHandle user) { safelyStartActivityAsUser(cti, user, null); } diff --git a/java/src/com/android/intentresolver/contentpreview/ChooserContentPreviewUi.java b/java/src/com/android/intentresolver/contentpreview/ChooserContentPreviewUi.java new file mode 100644 index 00000000..205be444 --- /dev/null +++ b/java/src/com/android/intentresolver/contentpreview/ChooserContentPreviewUi.java @@ -0,0 +1,310 @@ +/* + * 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.contentpreview; + +import static com.android.intentresolver.contentpreview.ContentPreviewType.CONTENT_PREVIEW_FILE; +import static com.android.intentresolver.contentpreview.ContentPreviewType.CONTENT_PREVIEW_IMAGE; +import static com.android.intentresolver.contentpreview.ContentPreviewType.CONTENT_PREVIEW_TEXT; + +import android.content.ClipData; +import android.content.ClipDescription; +import android.content.ContentInterface; +import android.content.Intent; +import android.content.res.Resources; +import android.net.Uri; +import android.os.RemoteException; +import android.view.LayoutInflater; +import android.view.ViewGroup; + +import androidx.annotation.Nullable; + +import com.android.intentresolver.ImageLoader; +import com.android.intentresolver.flags.FeatureFlagRepository; +import com.android.intentresolver.widget.ActionRow; +import com.android.intentresolver.widget.ImagePreviewView; +import com.android.intentresolver.widget.ImagePreviewView.TransitionElementStatusCallback; + +import java.util.ArrayList; +import java.util.List; +import java.util.function.Consumer; +import java.util.stream.Collectors; + +/** + * Collection of helpers for building the content preview UI displayed in + * {@link com.android.intentresolver.ChooserActivity}. + * + * A content preview façade. + */ +public final class ChooserContentPreviewUi { + /** + * Delegate to build the default system action buttons to display in the preview layout, if/when + * they're determined to be appropriate for the particular preview we display. + * TODO: clarify why action buttons are part of preview logic. + */ + public interface ActionFactory { + /** Create an action that copies the share content to the clipboard. */ + ActionRow.Action createCopyButton(); + + /** Create an action that opens the share content in a system-default editor. */ + @Nullable + ActionRow.Action createEditButton(); + + /** Create an "Share to Nearby" action. */ + @Nullable + ActionRow.Action createNearbyButton(); + + /** Create custom actions */ + List createCustomActions(); + + /** + * Provides a share modification action, if any. + */ + @Nullable + Runnable getModifyShareAction(); + + /** + *

+ * 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. + *

+ */ + Consumer getExcludeSharedTextAction(); + } + + /** + * Testing shim to specify whether a given mime type is considered to be an "image." + * + * TODO: move away from {@link ChooserActivityOverrideData} as a model to configure our tests, + * then migrate {@link com.android.intentresolver.ChooserActivity#isImageType(String)} into this + * class. + */ + public interface ImageMimeTypeClassifier { + /** @return whether the specified {@code mimeType} is classified as an "image" type. */ + boolean isImageType(String mimeType); + } + + private final ContentPreviewUi mContentPreviewUi; + + public ChooserContentPreviewUi( + Intent targetIntent, + ContentInterface contentResolver, + ImageMimeTypeClassifier imageClassifier, + ImageLoader imageLoader, + ActionFactory actionFactory, + TransitionElementStatusCallback transitionElementStatusCallback, + FeatureFlagRepository featureFlagRepository) { + + mContentPreviewUi = createContentPreview( + targetIntent, + contentResolver, + imageClassifier, + imageLoader, + actionFactory, + transitionElementStatusCallback, + featureFlagRepository); + if (mContentPreviewUi.getType() != CONTENT_PREVIEW_IMAGE) { + transitionElementStatusCallback.onAllTransitionElementsReady(); + } + } + + private ContentPreviewUi createContentPreview( + Intent targetIntent, + ContentInterface contentResolver, + ImageMimeTypeClassifier imageClassifier, + ImageLoader imageLoader, + ActionFactory actionFactory, + TransitionElementStatusCallback transitionElementStatusCallback, + FeatureFlagRepository featureFlagRepository) { + int type = findPreferredContentPreview(targetIntent, contentResolver, imageClassifier); + switch (type) { + case CONTENT_PREVIEW_TEXT: + return createTextPreview( + targetIntent, actionFactory, imageLoader, featureFlagRepository); + + case CONTENT_PREVIEW_FILE: + return new FileContentPreviewUi( + extractContentUris(targetIntent), + actionFactory, + imageLoader, + contentResolver, + featureFlagRepository); + + case CONTENT_PREVIEW_IMAGE: + return createImagePreview( + targetIntent, + actionFactory, + contentResolver, + imageClassifier, + imageLoader, + transitionElementStatusCallback, + featureFlagRepository); + } + + return new NoContextPreviewUi(type); + } + + public int getPreferredContentPreview() { + return mContentPreviewUi.getType(); + } + + /** + * Display a content preview of the specified {@code previewType} to preview the content of the + * specified {@code intent}. + */ + public ViewGroup displayContentPreview( + Resources resources, LayoutInflater layoutInflater, ViewGroup parent) { + + return mContentPreviewUi.display(resources, layoutInflater, parent); + } + + /** Determine the most appropriate type of preview to show for the provided {@link Intent}. */ + @ContentPreviewType + private static int findPreferredContentPreview( + Intent targetIntent, + ContentInterface resolver, + ImageMimeTypeClassifier imageClassifier) { + /* In {@link android.content.Intent#getType}, the app may specify a very general mime type + * that broadly covers all data being shared, such as {@literal *}/* when sending an image + * and text. We therefore should inspect each item for the preferred type, in order: IMAGE, + * FILE, TEXT. */ + final String action = targetIntent.getAction(); + final String type = targetIntent.getType(); + final boolean isSend = Intent.ACTION_SEND.equals(action); + final boolean isSendMultiple = Intent.ACTION_SEND_MULTIPLE.equals(action); + + if (!(isSend || isSendMultiple) + || (type != null && ClipDescription.compareMimeTypes(type, "text/*"))) { + return CONTENT_PREVIEW_TEXT; + } + + if (isSend) { + Uri uri = targetIntent.getParcelableExtra(Intent.EXTRA_STREAM); + return findPreferredContentPreview(uri, resolver, imageClassifier); + } + + List uris = targetIntent.getParcelableArrayListExtra(Intent.EXTRA_STREAM); + if (uris == null || uris.isEmpty()) { + return CONTENT_PREVIEW_TEXT; + } + + for (Uri uri : uris) { + // Defaulting to file preview when there are mixed image/file types is + // preferable, as it shows the user the correct number of items being shared + int uriPreviewType = findPreferredContentPreview(uri, resolver, imageClassifier); + if (uriPreviewType == CONTENT_PREVIEW_FILE) { + return CONTENT_PREVIEW_FILE; + } + } + + return CONTENT_PREVIEW_IMAGE; + } + + @ContentPreviewType + private static int findPreferredContentPreview( + Uri uri, ContentInterface resolver, ImageMimeTypeClassifier imageClassifier) { + if (uri == null) { + return CONTENT_PREVIEW_TEXT; + } + + String mimeType = null; + try { + mimeType = resolver.getType(uri); + } catch (RemoteException ignored) { + } + return imageClassifier.isImageType(mimeType) ? CONTENT_PREVIEW_IMAGE : CONTENT_PREVIEW_FILE; + } + + private static TextContentPreviewUi createTextPreview( + Intent targetIntent, + ChooserContentPreviewUi.ActionFactory actionFactory, + ImageLoader imageLoader, + FeatureFlagRepository featureFlagRepository) { + CharSequence sharingText = targetIntent.getCharSequenceExtra(Intent.EXTRA_TEXT); + String previewTitle = targetIntent.getStringExtra(Intent.EXTRA_TITLE); + ClipData previewData = targetIntent.getClipData(); + Uri previewThumbnail = null; + if (previewData != null) { + if (previewData.getItemCount() > 0) { + ClipData.Item previewDataItem = previewData.getItemAt(0); + previewThumbnail = previewDataItem.getUri(); + } + } + return new TextContentPreviewUi( + sharingText, + previewTitle, + previewThumbnail, + actionFactory, + imageLoader, + featureFlagRepository); + } + + static ImageContentPreviewUi createImagePreview( + Intent targetIntent, + ChooserContentPreviewUi.ActionFactory actionFactory, + ContentInterface contentResolver, + ChooserContentPreviewUi.ImageMimeTypeClassifier imageClassifier, + ImageLoader imageLoader, + ImagePreviewView.TransitionElementStatusCallback transitionElementStatusCallback, + FeatureFlagRepository featureFlagRepository) { + CharSequence text = targetIntent.getCharSequenceExtra(Intent.EXTRA_TEXT); + String action = targetIntent.getAction(); + // TODO: why don't we use image classifier for single-element ACTION_SEND? + final List imageUris = Intent.ACTION_SEND.equals(action) + ? extractContentUris(targetIntent) + : extractContentUris(targetIntent) + .stream() + .filter(uri -> { + String type = null; + try { + type = contentResolver.getType(uri); + } catch (RemoteException ignored) { + } + return imageClassifier.isImageType(type); + }) + .collect(Collectors.toList()); + return new ImageContentPreviewUi( + imageUris, + text, + actionFactory, + imageLoader, + transitionElementStatusCallback, + featureFlagRepository); + } + + private static List extractContentUris(Intent targetIntent) { + List uris = new ArrayList<>(); + if (Intent.ACTION_SEND.equals(targetIntent.getAction())) { + Uri uri = targetIntent.getParcelableExtra(Intent.EXTRA_STREAM); + if (ContentPreviewUi.validForContentPreview(uri)) { + uris.add(uri); + } + } else { + List receivedUris = targetIntent.getParcelableArrayListExtra(Intent.EXTRA_STREAM); + if (receivedUris != null) { + for (Uri uri : receivedUris) { + if (ContentPreviewUi.validForContentPreview(uri)) { + uris.add(uri); + } + } + } + } + return uris; + } +} diff --git a/java/src/com/android/intentresolver/contentpreview/ContentPreviewType.java b/java/src/com/android/intentresolver/contentpreview/ContentPreviewType.java new file mode 100644 index 00000000..ebab147d --- /dev/null +++ b/java/src/com/android/intentresolver/contentpreview/ContentPreviewType.java @@ -0,0 +1,35 @@ +/* + * 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 static java.lang.annotation.RetentionPolicy.SOURCE; + +import android.annotation.IntDef; + +import java.lang.annotation.Retention; + +@Retention(SOURCE) +@IntDef({ContentPreviewType.CONTENT_PREVIEW_FILE, + ContentPreviewType.CONTENT_PREVIEW_IMAGE, + ContentPreviewType.CONTENT_PREVIEW_TEXT}) +public @interface ContentPreviewType { + // Starting at 1 since 0 is considered "undefined" for some of the database transformations + // of tron logs. + int CONTENT_PREVIEW_IMAGE = 1; + int CONTENT_PREVIEW_FILE = 2; + int CONTENT_PREVIEW_TEXT = 3; +} diff --git a/java/src/com/android/intentresolver/contentpreview/ContentPreviewUi.java b/java/src/com/android/intentresolver/contentpreview/ContentPreviewUi.java new file mode 100644 index 00000000..39856e66 --- /dev/null +++ b/java/src/com/android/intentresolver/contentpreview/ContentPreviewUi.java @@ -0,0 +1,130 @@ +/* + * 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 static android.content.ContentProvider.getUserIdFromUri; + +import android.animation.ObjectAnimator; +import android.animation.ValueAnimator; +import android.content.res.Resources; +import android.graphics.Bitmap; +import android.net.Uri; +import android.os.UserHandle; +import android.util.Log; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.view.ViewStub; +import android.view.animation.DecelerateInterpolator; + +import androidx.annotation.LayoutRes; + +import com.android.intentresolver.R; +import com.android.intentresolver.flags.FeatureFlagRepository; +import com.android.intentresolver.flags.Flags; +import com.android.intentresolver.widget.ActionRow; +import com.android.intentresolver.widget.RoundedRectImageView; + +import java.util.ArrayList; +import java.util.List; + +abstract class ContentPreviewUi { + private static final int IMAGE_FADE_IN_MILLIS = 150; + static final String TAG = "ChooserPreview"; + + @ContentPreviewType + public abstract int getType(); + + public abstract ViewGroup display( + Resources resources, LayoutInflater layoutInflater, ViewGroup parent); + + protected static int getActionRowLayout(FeatureFlagRepository featureFlagRepository) { + return featureFlagRepository.isEnabled(Flags.SHARESHEET_CUSTOM_ACTIONS) + ? R.layout.scrollable_chooser_action_row + : R.layout.chooser_action_row; + } + + protected static ActionRow inflateActionRow(ViewGroup parent, @LayoutRes int actionRowLayout) { + final ViewStub stub = parent.findViewById(com.android.intentresolver.R.id.action_row_stub); + if (stub != null) { + stub.setLayoutResource(actionRowLayout); + stub.inflate(); + } + return parent.findViewById(com.android.internal.R.id.chooser_action_row); + } + + protected static List createActions( + List systemActions, + List customActions, + FeatureFlagRepository featureFlagRepository) { + ArrayList actions = + new ArrayList<>(systemActions.size() + customActions.size()); + actions.addAll(systemActions); + if (featureFlagRepository.isEnabled(Flags.SHARESHEET_CUSTOM_ACTIONS)) { + actions.addAll(customActions); + } + return actions; + } + + /** + * Indicate if the incoming content URI should be allowed. + * + * @param uri the uri to test + * @return true if the URI is allowed for content preview + */ + protected static boolean validForContentPreview(Uri uri) throws SecurityException { + if (uri == null) { + return false; + } + int userId = getUserIdFromUri(uri, UserHandle.USER_CURRENT); + if (userId != UserHandle.USER_CURRENT && userId != UserHandle.myUserId()) { + Log.e(ContentPreviewUi.TAG, "dropped invalid content URI belonging to user " + userId); + return false; + } + return true; + } + + protected static void updateViewWithImage(RoundedRectImageView imageView, Bitmap image) { + if (image == null) { + imageView.setVisibility(View.GONE); + return; + } + imageView.setVisibility(View.VISIBLE); + imageView.setAlpha(0.0f); + imageView.setImageBitmap(image); + + ValueAnimator fadeAnim = ObjectAnimator.ofFloat(imageView, "alpha", 0.0f, 1.0f); + fadeAnim.setInterpolator(new DecelerateInterpolator(1.0f)); + fadeAnim.setDuration(IMAGE_FADE_IN_MILLIS); + fadeAnim.start(); + } + + protected static void displayPayloadReselectionAction( + ViewGroup layout, + ChooserContentPreviewUi.ActionFactory actionFactory, + FeatureFlagRepository featureFlagRepository) { + Runnable modifyShareAction = actionFactory.getModifyShareAction(); + if (modifyShareAction != null && layout != null + && featureFlagRepository.isEnabled(Flags.SHARESHEET_RESELECTION_ACTION)) { + View modifyShareView = layout.findViewById(R.id.reselection_action); + if (modifyShareView != null) { + modifyShareView.setVisibility(View.VISIBLE); + modifyShareView.setOnClickListener(view -> modifyShareAction.run()); + } + } + } +} diff --git a/java/src/com/android/intentresolver/contentpreview/FileContentPreviewUi.java b/java/src/com/android/intentresolver/contentpreview/FileContentPreviewUi.java new file mode 100644 index 00000000..7cd71475 --- /dev/null +++ b/java/src/com/android/intentresolver/contentpreview/FileContentPreviewUi.java @@ -0,0 +1,236 @@ +/* + * Copyright (C) 2023 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.intentresolver.contentpreview; + +import android.content.ContentInterface; +import android.content.res.Resources; +import android.database.Cursor; +import android.net.Uri; +import android.os.RemoteException; +import android.provider.DocumentsContract; +import android.provider.Downloads; +import android.provider.OpenableColumns; +import android.text.TextUtils; +import android.util.Log; +import android.util.PluralsMessageFormatter; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.ImageView; +import android.widget.TextView; + +import androidx.annotation.LayoutRes; + +import com.android.intentresolver.ImageLoader; +import com.android.intentresolver.R; +import com.android.intentresolver.flags.FeatureFlagRepository; +import com.android.intentresolver.widget.ActionRow; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +class FileContentPreviewUi extends ContentPreviewUi { + private static final String PLURALS_COUNT = "count"; + private static final String PLURALS_FILE_NAME = "file_name"; + + private final List mUris; + private final ChooserContentPreviewUi.ActionFactory mActionFactory; + private final ImageLoader mImageLoader; + private final ContentInterface mContentResolver; + private final FeatureFlagRepository mFeatureFlagRepository; + + FileContentPreviewUi(List uris, + ChooserContentPreviewUi.ActionFactory actionFactory, + ImageLoader imageLoader, + ContentInterface contentResolver, + FeatureFlagRepository featureFlagRepository) { + mUris = uris; + mActionFactory = actionFactory; + mImageLoader = imageLoader; + mContentResolver = contentResolver; + mFeatureFlagRepository = featureFlagRepository; + } + + @Override + public int getType() { + return ContentPreviewType.CONTENT_PREVIEW_FILE; + } + + @Override + public ViewGroup display(Resources resources, LayoutInflater layoutInflater, ViewGroup parent) { + ViewGroup layout = displayInternal(resources, layoutInflater, parent); + displayPayloadReselectionAction(layout, mActionFactory, mFeatureFlagRepository); + return layout; + } + + private ViewGroup displayInternal( + Resources resources, LayoutInflater layoutInflater, ViewGroup parent) { + @LayoutRes int actionRowLayout = getActionRowLayout(mFeatureFlagRepository); + ViewGroup contentPreviewLayout = (ViewGroup) layoutInflater.inflate( + R.layout.chooser_grid_preview_file, parent, false); + + final int uriCount = mUris.size(); + + if (uriCount == 0) { + contentPreviewLayout.setVisibility(View.GONE); + Log.i(TAG, "Appears to be no uris available in EXTRA_STREAM," + + " removing preview area"); + return contentPreviewLayout; + } + + if (uriCount == 1) { + loadFileUriIntoView(mUris.get(0), contentPreviewLayout, mImageLoader, mContentResolver); + } else { + FileInfo fileInfo = extractFileInfo(mUris.get(0), mContentResolver); + int remUriCount = uriCount - 1; + Map arguments = new HashMap<>(); + arguments.put(PLURALS_COUNT, remUriCount); + arguments.put(PLURALS_FILE_NAME, fileInfo.name); + String fileName = + PluralsMessageFormatter.format(resources, arguments, R.string.file_count); + + TextView fileNameView = contentPreviewLayout.findViewById( + com.android.internal.R.id.content_preview_filename); + fileNameView.setText(fileName); + + View thumbnailView = contentPreviewLayout.findViewById( + com.android.internal.R.id.content_preview_file_thumbnail); + thumbnailView.setVisibility(View.GONE); + + ImageView fileIconView = contentPreviewLayout.findViewById( + com.android.internal.R.id.content_preview_file_icon); + fileIconView.setVisibility(View.VISIBLE); + fileIconView.setImageResource(R.drawable.ic_file_copy); + } + + final ActionRow actionRow = inflateActionRow(contentPreviewLayout, actionRowLayout); + if (actionRow != null) { + actionRow.setActions( + createActions( + createFilePreviewActions(), + mActionFactory.createCustomActions(), + mFeatureFlagRepository)); + } + + return contentPreviewLayout; + } + + private List createFilePreviewActions() { + List actions = new ArrayList<>(1); + //TODO(b/120417119): + // add action buttonFactory.createCopyButton() + ActionRow.Action action = mActionFactory.createNearbyButton(); + if (action != null) { + actions.add(action); + } + return actions; + } + + private static void loadFileUriIntoView( + final Uri uri, + final View parent, + final ImageLoader imageLoader, + final ContentInterface contentResolver) { + FileInfo fileInfo = extractFileInfo(uri, contentResolver); + + TextView fileNameView = parent.findViewById( + com.android.internal.R.id.content_preview_filename); + fileNameView.setText(fileInfo.name); + + if (fileInfo.hasThumbnail) { + imageLoader.loadImage( + uri, + (bitmap) -> updateViewWithImage( + parent.findViewById( + com.android.internal.R.id.content_preview_file_thumbnail), + bitmap)); + } else { + View thumbnailView = parent.findViewById( + com.android.internal.R.id.content_preview_file_thumbnail); + thumbnailView.setVisibility(View.GONE); + + ImageView fileIconView = parent.findViewById( + com.android.internal.R.id.content_preview_file_icon); + fileIconView.setVisibility(View.VISIBLE); + fileIconView.setImageResource(R.drawable.chooser_file_generic); + } + } + + private static FileInfo extractFileInfo(Uri uri, ContentInterface resolver) { + String fileName = null; + boolean hasThumbnail = false; + + try (Cursor cursor = queryResolver(resolver, uri)) { + if (cursor != null && cursor.getCount() > 0) { + int nameIndex = cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME); + int titleIndex = cursor.getColumnIndex(Downloads.Impl.COLUMN_TITLE); + int flagsIndex = cursor.getColumnIndex(DocumentsContract.Document.COLUMN_FLAGS); + + cursor.moveToFirst(); + if (nameIndex != -1) { + fileName = cursor.getString(nameIndex); + } else if (titleIndex != -1) { + fileName = cursor.getString(titleIndex); + } + + if (flagsIndex != -1) { + hasThumbnail = (cursor.getInt(flagsIndex) + & DocumentsContract.Document.FLAG_SUPPORTS_THUMBNAIL) != 0; + } + } + } catch (SecurityException | NullPointerException e) { + // 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"); + } + + if (TextUtils.isEmpty(fileName)) { + fileName = uri.getPath(); + fileName = fileName == null ? "" : fileName; + int index = fileName.lastIndexOf('/'); + if (index != -1) { + fileName = fileName.substring(index + 1); + } + } + + return new FileInfo(fileName, hasThumbnail); + } + + private static Cursor queryResolver(ContentInterface resolver, Uri uri) { + try { + return resolver.query(uri, null, null, null); + } catch (RemoteException e) { + return null; + } + } + + private static class FileInfo { + public final String name; + public final boolean hasThumbnail; + + FileInfo(String name, boolean hasThumbnail) { + this.name = name; + this.hasThumbnail = hasThumbnail; + } + } +} diff --git a/java/src/com/android/intentresolver/contentpreview/ImageContentPreviewUi.java b/java/src/com/android/intentresolver/contentpreview/ImageContentPreviewUi.java new file mode 100644 index 00000000..db26ab1b --- /dev/null +++ b/java/src/com/android/intentresolver/contentpreview/ImageContentPreviewUi.java @@ -0,0 +1,179 @@ +/* + * 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 static com.android.intentresolver.contentpreview.ContentPreviewType.CONTENT_PREVIEW_IMAGE; + +import android.content.res.Resources; +import android.net.Uri; +import android.text.TextUtils; +import android.text.util.Linkify; +import android.transition.TransitionManager; +import android.util.Log; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.view.ViewStub; +import android.widget.CheckBox; +import android.widget.TextView; + +import androidx.annotation.LayoutRes; +import androidx.annotation.Nullable; + +import com.android.intentresolver.ImageLoader; +import com.android.intentresolver.R; +import com.android.intentresolver.flags.FeatureFlagRepository; +import com.android.intentresolver.flags.Flags; +import com.android.intentresolver.widget.ActionRow; +import com.android.intentresolver.widget.ImagePreviewView; + +import java.util.ArrayList; +import java.util.List; +import java.util.function.Consumer; + +class ImageContentPreviewUi extends ContentPreviewUi { + private final List mImageUris; + @Nullable + private final CharSequence mText; + private final ChooserContentPreviewUi.ActionFactory mActionFactory; + private final ImageLoader mImageLoader; + private final ImagePreviewView.TransitionElementStatusCallback mTransitionElementStatusCallback; + private final FeatureFlagRepository mFeatureFlagRepository; + + ImageContentPreviewUi( + List imageUris, + @Nullable CharSequence text, + ChooserContentPreviewUi.ActionFactory actionFactory, + ImageLoader imageLoader, + ImagePreviewView.TransitionElementStatusCallback transitionElementStatusCallback, + FeatureFlagRepository featureFlagRepository) { + mImageUris = imageUris; + mText = text; + mActionFactory = actionFactory; + mImageLoader = imageLoader; + mTransitionElementStatusCallback = transitionElementStatusCallback; + mFeatureFlagRepository = featureFlagRepository; + + mImageLoader.prePopulate(mImageUris); + } + + @Override + public int getType() { + return CONTENT_PREVIEW_IMAGE; + } + + @Override + public ViewGroup display(Resources resources, LayoutInflater layoutInflater, ViewGroup parent) { + ViewGroup layout = displayInternal(layoutInflater, parent); + displayPayloadReselectionAction(layout, mActionFactory, mFeatureFlagRepository); + return layout; + } + + private ViewGroup displayInternal(LayoutInflater layoutInflater, ViewGroup parent) { + @LayoutRes int actionRowLayout = getActionRowLayout(mFeatureFlagRepository); + ViewGroup contentPreviewLayout = (ViewGroup) layoutInflater.inflate( + R.layout.chooser_grid_preview_image, parent, false); + ImagePreviewView imagePreview = inflateImagePreviewView(contentPreviewLayout); + + final ActionRow actionRow = inflateActionRow(contentPreviewLayout, actionRowLayout); + if (actionRow != null) { + actionRow.setActions( + createActions( + createImagePreviewActions(), + mActionFactory.createCustomActions(), + mFeatureFlagRepository)); + } + + if (mImageUris.size() == 0) { + Log.i( + TAG, + "Attempted to display image preview area with zero" + + " available images detected in EXTRA_STREAM list"); + ((View) imagePreview).setVisibility(View.GONE); + mTransitionElementStatusCallback.onAllTransitionElementsReady(); + return contentPreviewLayout; + } + + setTextInImagePreviewVisibility(contentPreviewLayout, mActionFactory); + imagePreview.setTransitionElementStatusCallback(mTransitionElementStatusCallback); + imagePreview.setImages(mImageUris, mImageLoader); + + return contentPreviewLayout; + } + + private List createImagePreviewActions() { + ArrayList actions = new ArrayList<>(2); + //TODO: add copy action; + ActionRow.Action action = mActionFactory.createNearbyButton(); + if (action != null) { + actions.add(action); + } + action = mActionFactory.createEditButton(); + if (action != null) { + actions.add(action); + } + return actions; + } + + private ImagePreviewView inflateImagePreviewView(ViewGroup previewLayout) { + ViewStub stub = previewLayout.findViewById(R.id.image_preview_stub); + if (stub != null) { + int layoutId = + mFeatureFlagRepository.isEnabled(Flags.SHARESHEET_SCROLLABLE_IMAGE_PREVIEW) + ? R.layout.scrollable_image_preview_view + : R.layout.chooser_image_preview_view; + stub.setLayoutResource(layoutId); + stub.inflate(); + } + return previewLayout.findViewById( + com.android.internal.R.id.content_preview_image_area); + } + + private void setTextInImagePreviewVisibility( + ViewGroup contentPreview, ChooserContentPreviewUi.ActionFactory actionFactory) { + int visibility = mFeatureFlagRepository.isEnabled(Flags.SHARESHEET_IMAGE_AND_TEXT_PREVIEW) + && !TextUtils.isEmpty(mText) + ? View.VISIBLE + : View.GONE; + + final TextView textView = contentPreview + .requireViewById(com.android.internal.R.id.content_preview_text); + CheckBox actionView = contentPreview + .requireViewById(R.id.include_text_action); + textView.setVisibility(visibility); + boolean isLink = visibility == View.VISIBLE && HttpUriMatcher.isHttpUri(mText.toString()); + textView.setAutoLinkMask(isLink ? Linkify.WEB_URLS : 0); + textView.setText(mText); + + if (visibility == View.VISIBLE) { + final int[] actionLabels = isLink + ? new int[] { R.string.include_link, R.string.exclude_link } + : new int[] { R.string.include_text, R.string.exclude_text }; + final Consumer shareTextAction = actionFactory.getExcludeSharedTextAction(); + actionView.setChecked(true); + actionView.setText(actionLabels[1]); + shareTextAction.accept(false); + actionView.setOnCheckedChangeListener((view, isChecked) -> { + view.setText(actionLabels[isChecked ? 1 : 0]); + TransitionManager.beginDelayedTransition((ViewGroup) textView.getParent()); + textView.setVisibility(isChecked ? View.VISIBLE : View.GONE); + shareTextAction.accept(!isChecked); + }); + } + actionView.setVisibility(visibility); + } +} diff --git a/java/src/com/android/intentresolver/contentpreview/IsHttpUri.kt b/java/src/com/android/intentresolver/contentpreview/IsHttpUri.kt new file mode 100644 index 00000000..80232537 --- /dev/null +++ b/java/src/com/android/intentresolver/contentpreview/IsHttpUri.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. + */ + +@file:JvmName("HttpUriMatcher") +package com.android.intentresolver.contentpreview + +import java.net.URI + +internal fun String.isHttpUri() = + kotlin.runCatching { + URI(this).scheme.takeIf { scheme -> + "http".compareTo(scheme, true) == 0 || "https".compareTo(scheme, true) == 0 + } + }.getOrNull() != null diff --git a/java/src/com/android/intentresolver/contentpreview/NoContextPreviewUi.kt b/java/src/com/android/intentresolver/contentpreview/NoContextPreviewUi.kt new file mode 100644 index 00000000..90016932 --- /dev/null +++ b/java/src/com/android/intentresolver/contentpreview/NoContextPreviewUi.kt @@ -0,0 +1,33 @@ +/* + * Copyright (C) 2023 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.intentresolver.contentpreview + +import android.content.res.Resources +import android.util.Log +import android.view.LayoutInflater +import android.view.ViewGroup + +internal class NoContextPreviewUi(private val type: Int) : ContentPreviewUi() { + override fun getType(): Int = type + + override fun display( + resources: Resources?, layoutInflater: LayoutInflater?, parent: ViewGroup? + ): ViewGroup? { + Log.e(TAG, "Unexpected content preview type: $type") + return null + } +} diff --git a/java/src/com/android/intentresolver/contentpreview/TextContentPreviewUi.java b/java/src/com/android/intentresolver/contentpreview/TextContentPreviewUi.java new file mode 100644 index 00000000..7901e4cb --- /dev/null +++ b/java/src/com/android/intentresolver/contentpreview/TextContentPreviewUi.java @@ -0,0 +1,138 @@ +/* + * Copyright (C) 2023 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.intentresolver.contentpreview; + +import android.content.res.Resources; +import android.net.Uri; +import android.text.TextUtils; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.ImageView; +import android.widget.TextView; + +import androidx.annotation.LayoutRes; +import androidx.annotation.Nullable; + +import com.android.intentresolver.ImageLoader; +import com.android.intentresolver.R; +import com.android.intentresolver.flags.FeatureFlagRepository; +import com.android.intentresolver.widget.ActionRow; + +import java.util.ArrayList; +import java.util.List; + +class TextContentPreviewUi extends ContentPreviewUi { + @Nullable + private final CharSequence mSharingText; + @Nullable + private final CharSequence mPreviewTitle; + @Nullable + private final Uri mPreviewThumbnail; + private final ImageLoader mImageLoader; + private final ChooserContentPreviewUi.ActionFactory mActionFactory; + private final FeatureFlagRepository mFeatureFlagRepository; + + TextContentPreviewUi( + @Nullable CharSequence sharingText, + @Nullable CharSequence previewTitle, + @Nullable Uri previewThumbnail, + ChooserContentPreviewUi.ActionFactory actionFactory, + ImageLoader imageLoader, + FeatureFlagRepository featureFlagRepository) { + mSharingText = sharingText; + mPreviewTitle = previewTitle; + mPreviewThumbnail = previewThumbnail; + mImageLoader = imageLoader; + mActionFactory = actionFactory; + mFeatureFlagRepository = featureFlagRepository; + } + + @Override + public int getType() { + return ContentPreviewType.CONTENT_PREVIEW_TEXT; + } + + @Override + public ViewGroup display(Resources resources, LayoutInflater layoutInflater, ViewGroup parent) { + ViewGroup layout = displayInternal(layoutInflater, parent); + displayPayloadReselectionAction(layout, mActionFactory, mFeatureFlagRepository); + return layout; + } + + private ViewGroup displayInternal( + LayoutInflater layoutInflater, + ViewGroup parent) { + @LayoutRes int actionRowLayout = getActionRowLayout(mFeatureFlagRepository); + ViewGroup contentPreviewLayout = (ViewGroup) layoutInflater.inflate( + R.layout.chooser_grid_preview_text, parent, false); + + final ActionRow actionRow = inflateActionRow(contentPreviewLayout, actionRowLayout); + if (actionRow != null) { + actionRow.setActions( + createActions( + createTextPreviewActions(), + mActionFactory.createCustomActions(), + mFeatureFlagRepository)); + } + + if (mSharingText == null) { + contentPreviewLayout + .findViewById(com.android.internal.R.id.content_preview_text_layout) + .setVisibility(View.GONE); + } else { + TextView textView = contentPreviewLayout.findViewById( + com.android.internal.R.id.content_preview_text); + textView.setText(mSharingText); + } + + if (TextUtils.isEmpty(mPreviewTitle)) { + contentPreviewLayout + .findViewById(com.android.internal.R.id.content_preview_title_layout) + .setVisibility(View.GONE); + } else { + TextView previewTitleView = contentPreviewLayout.findViewById( + com.android.internal.R.id.content_preview_title); + previewTitleView.setText(mPreviewTitle); + + ImageView previewThumbnailView = contentPreviewLayout.findViewById( + com.android.internal.R.id.content_preview_thumbnail); + if (!validForContentPreview(mPreviewThumbnail)) { + previewThumbnailView.setVisibility(View.GONE); + } else { + mImageLoader.loadImage( + mPreviewThumbnail, + (bitmap) -> updateViewWithImage( + contentPreviewLayout.findViewById( + com.android.internal.R.id.content_preview_thumbnail), + bitmap)); + } + } + + return contentPreviewLayout; + } + + private List createTextPreviewActions() { + ArrayList actions = new ArrayList<>(2); + actions.add(mActionFactory.createCopyButton()); + ActionRow.Action nearbyAction = mActionFactory.createNearbyButton(); + if (nearbyAction != null) { + actions.add(nearbyAction); + } + return actions; + } +} diff --git a/java/tests/src/com/android/intentresolver/ChooserActivityLoggerTest.java b/java/tests/src/com/android/intentresolver/ChooserActivityLoggerTest.java index 7d1b2488..aa42c24c 100644 --- a/java/tests/src/com/android/intentresolver/ChooserActivityLoggerTest.java +++ b/java/tests/src/com/android/intentresolver/ChooserActivityLoggerTest.java @@ -36,6 +36,7 @@ import com.android.intentresolver.ChooserActivityLogger.FrameworkStatsLogger; import com.android.intentresolver.ChooserActivityLogger.SharesheetStandardEvent; import com.android.intentresolver.ChooserActivityLogger.SharesheetStartedEvent; import com.android.intentresolver.ChooserActivityLogger.SharesheetTargetSelectedEvent; +import com.android.intentresolver.contentpreview.ContentPreviewType; import com.android.internal.logging.InstanceId; import com.android.internal.logging.MetricsLogger; import com.android.internal.logging.UiEventLogger; @@ -117,7 +118,7 @@ public final class ChooserActivityLoggerTest { final int appProvidedDirectTargets = 123; final int appProvidedAppTargets = 456; final boolean workProfile = true; - final int previewType = ChooserContentPreviewUi.CONTENT_PREVIEW_FILE; + final int previewType = ContentPreviewType.CONTENT_PREVIEW_FILE; final String intentAction = Intent.ACTION_SENDTO; final int numCustomActions = 3; final boolean modifyShareProvided = true; @@ -233,7 +234,7 @@ public final class ChooserActivityLoggerTest { @Test public void testLogActionShareWithPreview() { - final int previewType = ChooserContentPreviewUi.CONTENT_PREVIEW_TEXT; + final int previewType = ContentPreviewType.CONTENT_PREVIEW_TEXT; mChooserLogger.logActionShareWithPreview(previewType); diff --git a/java/tests/src/com/android/intentresolver/TestFeatureFlagRepository.kt b/java/tests/src/com/android/intentresolver/TestFeatureFlagRepository.kt index 5a159d24..b9047712 100644 --- a/java/tests/src/com/android/intentresolver/TestFeatureFlagRepository.kt +++ b/java/tests/src/com/android/intentresolver/TestFeatureFlagRepository.kt @@ -21,7 +21,7 @@ import com.android.systemui.flags.BooleanFlag import com.android.systemui.flags.ReleasedFlag import com.android.systemui.flags.UnreleasedFlag -internal class TestFeatureFlagRepository( +class TestFeatureFlagRepository( private val overrides: Map ) : FeatureFlagRepository { override fun isEnabled(flag: UnreleasedFlag): Boolean = getValue(flag) diff --git a/java/tests/src/com/android/intentresolver/contentpreview/ChooserContentPreviewUiTest.kt b/java/tests/src/com/android/intentresolver/contentpreview/ChooserContentPreviewUiTest.kt new file mode 100644 index 00000000..d870a8c2 --- /dev/null +++ b/java/tests/src/com/android/intentresolver/contentpreview/ChooserContentPreviewUiTest.kt @@ -0,0 +1,203 @@ +/* + * Copyright (C) 2023 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.intentresolver.contentpreview + +import android.content.ClipDescription +import android.content.ContentInterface +import android.content.Intent +import android.graphics.Bitmap +import android.net.Uri +import com.android.intentresolver.ImageLoader +import com.android.intentresolver.TestFeatureFlagRepository +import com.android.intentresolver.contentpreview.ChooserContentPreviewUi.ActionFactory +import com.android.intentresolver.flags.Flags +import com.android.intentresolver.mock +import com.android.intentresolver.whenever +import com.android.intentresolver.widget.ActionRow +import com.android.intentresolver.widget.ImagePreviewView +import com.google.common.truth.Truth.assertThat +import org.junit.Test +import org.mockito.Mockito.never +import org.mockito.Mockito.times +import org.mockito.Mockito.verify +import java.util.function.Consumer + +private const val PROVIDER_NAME = "org.pkg.app" +class ChooserContentPreviewUiTest { + private val contentResolver = mock() + private val imageClassifier = ChooserContentPreviewUi.ImageMimeTypeClassifier { mimeType -> + mimeType != null && ClipDescription.compareMimeTypes(mimeType, "image/*") + } + private val imageLoader = object : ImageLoader { + override fun loadImage(uri: Uri, callback: Consumer) { + callback.accept(null) + } + override fun prePopulate(uris: List) = Unit + override suspend fun invoke(uri: Uri): Bitmap? = null + } + private val actionFactory = object : ActionFactory { + override fun createCopyButton() = ActionRow.Action(label = "Copy", icon = null) {} + override fun createEditButton(): ActionRow.Action? = null + override fun createNearbyButton(): ActionRow.Action? = null + override fun createCustomActions(): List = emptyList() + override fun getModifyShareAction(): Runnable? = null + override fun getExcludeSharedTextAction(): Consumer = Consumer {} + } + private val transitionCallback = mock() + private val featureFlagRepository = TestFeatureFlagRepository( + mapOf( + Flags.SHARESHEET_SCROLLABLE_IMAGE_PREVIEW to true + ) + ) + + @Test + fun test_ChooserContentPreview_non_send_intent_action_to_text_preview() { + val targetIntent = Intent(Intent.ACTION_VIEW) + val testSubject = ChooserContentPreviewUi( + targetIntent, + contentResolver, + imageClassifier, + imageLoader, + actionFactory, + transitionCallback, + featureFlagRepository + ) + assertThat(testSubject.preferredContentPreview) + .isEqualTo(ContentPreviewType.CONTENT_PREVIEW_TEXT) + verify(transitionCallback, times(1)).onAllTransitionElementsReady() + } + + @Test + fun test_ChooserContentPreview_text_mime_type_to_text_preview() { + val targetIntent = Intent(Intent.ACTION_SEND).apply { + type = "text/plain" + putExtra(Intent.EXTRA_TEXT, "Text Extra") + } + val testSubject = ChooserContentPreviewUi( + targetIntent, + contentResolver, + imageClassifier, + imageLoader, + actionFactory, + transitionCallback, + featureFlagRepository + ) + assertThat(testSubject.preferredContentPreview) + .isEqualTo(ContentPreviewType.CONTENT_PREVIEW_TEXT) + verify(transitionCallback, times(1)).onAllTransitionElementsReady() + } + + @Test + fun test_ChooserContentPreview_single_image_uri_to_image_preview() { + val uri = Uri.parse("content://$PROVIDER_NAME/test.png") + val targetIntent = Intent(Intent.ACTION_SEND).apply { + putExtra(Intent.EXTRA_STREAM, uri) + } + whenever(contentResolver.getType(uri)).thenReturn("image/png") + val testSubject = ChooserContentPreviewUi( + targetIntent, + contentResolver, + imageClassifier, + imageLoader, + actionFactory, + transitionCallback, + featureFlagRepository + ) + assertThat(testSubject.preferredContentPreview) + .isEqualTo(ContentPreviewType.CONTENT_PREVIEW_IMAGE) + verify(transitionCallback, never()).onAllTransitionElementsReady() + } + + @Test + fun test_ChooserContentPreview_single_non_image_uri_to_file_preview() { + val uri = Uri.parse("content://$PROVIDER_NAME/test.pdf") + val targetIntent = Intent(Intent.ACTION_SEND).apply { + putExtra(Intent.EXTRA_STREAM, uri) + } + whenever(contentResolver.getType(uri)).thenReturn("application/pdf") + val testSubject = ChooserContentPreviewUi( + targetIntent, + contentResolver, + imageClassifier, + imageLoader, + actionFactory, + transitionCallback, + featureFlagRepository + ) + assertThat(testSubject.preferredContentPreview) + .isEqualTo(ContentPreviewType.CONTENT_PREVIEW_FILE) + verify(transitionCallback, times(1)).onAllTransitionElementsReady() + } + + @Test + fun test_ChooserContentPreview_multiple_image_uri_to_image_preview() { + val uri1 = Uri.parse("content://$PROVIDER_NAME/test.png") + val uri2 = Uri.parse("content://$PROVIDER_NAME/test.jpg") + val targetIntent = Intent(Intent.ACTION_SEND_MULTIPLE).apply { + putExtra( + Intent.EXTRA_STREAM, + ArrayList().apply { + add(uri1) + add(uri2) + } + ) + } + whenever(contentResolver.getType(uri1)).thenReturn("image/png") + whenever(contentResolver.getType(uri2)).thenReturn("image/jpeg") + val testSubject = ChooserContentPreviewUi( + targetIntent, + contentResolver, + imageClassifier, + imageLoader, + actionFactory, + transitionCallback, + featureFlagRepository + ) + assertThat(testSubject.preferredContentPreview) + .isEqualTo(ContentPreviewType.CONTENT_PREVIEW_IMAGE) + verify(transitionCallback, never()).onAllTransitionElementsReady() + } + + @Test + fun test_ChooserContentPreview_some_non_image_uri_to_file_preview() { + val uri1 = Uri.parse("content://$PROVIDER_NAME/test.png") + val uri2 = Uri.parse("content://$PROVIDER_NAME/test.pdf") + val targetIntent = Intent(Intent.ACTION_SEND_MULTIPLE).apply { + putExtra( + Intent.EXTRA_STREAM, + ArrayList().apply { + add(uri1) + add(uri2) + } + ) + } + whenever(contentResolver.getType(uri1)).thenReturn("image/png") + whenever(contentResolver.getType(uri2)).thenReturn("application/pdf") + val testSubject = ChooserContentPreviewUi( + targetIntent, + contentResolver, + imageClassifier, + imageLoader, + actionFactory, + transitionCallback, + featureFlagRepository + ) + assertThat(testSubject.preferredContentPreview) + .isEqualTo(ContentPreviewType.CONTENT_PREVIEW_FILE) + verify(transitionCallback, times(1)).onAllTransitionElementsReady() + } +} -- cgit v1.2.3-59-g8ed1b