diff options
29 files changed, 1348 insertions, 917 deletions
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 @@ <LinearLayout xmlns:android="http://schemas.android.com/apk/res/android" xmlns:androidprv="http://schemas.android.com/apk/prv/res/android" + android:id="@+id/chooser_dialog_content" android:background="@drawable/chooser_dialog_background" android:orientation="vertical" android:paddingBottom="8dp" 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"/> </LinearLayout> + <TextView + android:id="@+id/reselection_action" + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:visibility="gone" + android:text="@string/select_files" + android:gravity="center" + style="@style/ReselectionAction" /> + <ViewStub android:id="@+id/action_row_stub" android:layout_width="match_parent" diff --git a/java/res/layout/chooser_grid_preview_image.xml b/java/res/layout/chooser_grid_preview_image.xml index 5c324140..23bc25d7 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"> - <com.android.intentresolver.widget.ImagePreviewView + <com.android.intentresolver.widget.ChooserImagePreviewView android:id="@androidprv:id/content_preview_image_area" android:layout_width="wrap_content" android:layout_height="wrap_content" @@ -33,6 +33,15 @@ android:paddingBottom="@dimen/chooser_view_spacing" android:background="?android:attr/colorBackground" /> + <TextView + android:id="@+id/reselection_action" + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:visibility="gone" + android:text="@string/select_images" + android:gravity="center" + style="@style/ReselectionAction" /> + <ViewStub android:id="@+id/action_row_stub" android:layout_width="match_parent" diff --git a/java/res/layout/chooser_grid_preview_text.xml b/java/res/layout/chooser_grid_preview_text.xml index db7282e3..49a2edff 100644 --- a/java/res/layout/chooser_grid_preview_text.xml +++ b/java/res/layout/chooser_grid_preview_text.xml @@ -52,6 +52,15 @@ </RelativeLayout> + <TextView + android:id="@+id/reselection_action" + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:visibility="gone" + android:text="@string/select_text" + android:gravity="center" + style="@style/ReselectionAction" /> + <ViewStub android:id="@+id/action_row_stub" android:layout_width="match_parent" 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 @@ <com.android.intentresolver.widget.RoundedRectImageView android:id="@androidprv:id/content_preview_image_1_large" + android:transitionName="screenshot_preview_image" android:layout_width="120dp" android:layout_height="104dp" android:layout_alignParentTop="true" diff --git a/java/res/values/strings.xml b/java/res/values/strings.xml index a536d3bc..59179504 100644 --- a/java/res/values/strings.xml +++ b/java/res/values/strings.xml @@ -101,4 +101,11 @@ <string name="miniresolver_use_personal_browser">Use personal browser</string> <!-- Button option. Open the link in the work browser. [CHAR LIMIT=NONE] --> <string name="miniresolver_use_work_browser">Use work browser</string> + + <!-- Tittle for a button. Launches client-provided content reselection action. --> + <string name="select_files">Select Files</string> + <!-- Tittle for a button. Launches client-provided content reselection action. --> + <string name="select_images">Select Images</string> + <!-- Tittle for a button. Launches client-provided content reselection action. --> + <string name="select_text">Select Text</string> </resources> 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 @@ <item name="*android:iconfactoryIconSize">@dimen/chooser_icon_size</item> <item name="*android:iconfactoryBadgeSize">@dimen/chooser_badge_size</item> </style> + + <style name="ReselectionAction"> + <item name="android:paddingTop">5dp</item> + <item name="android:paddingBottom">5dp</item> + <item name="android:textColor">?android:attr/textColorPrimary</item> + </style> </resources> diff --git a/java/src/com/android/intentresolver/ChooserActivity.java b/java/src/com/android/intentresolver/ChooserActivity.java index ceab62b2..7fa715c3 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; @@ -54,13 +55,13 @@ 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; 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; @@ -70,10 +71,10 @@ 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.Size; import android.util.Slog; import android.util.SparseArray; import android.view.View; @@ -112,8 +113,9 @@ 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; import java.lang.annotation.RetentionPolicy; import java.text.Collator; @@ -158,6 +160,8 @@ 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"; @@ -241,7 +245,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; @@ -265,7 +269,11 @@ public class ChooserActivity extends ResolverActivity implements try { mChooserRequest = new ChooserRequestParameters( - getIntent(), getReferrer(), getNearbySharingComponent()); + getIntent(), + getReferrer(), + getNearbySharingComponent(), + ENABLE_CUSTOM_ACTIONS, + ENABLE_RESELECTION_ACTION); } catch (IllegalArgumentException e) { Log.e(TAG, "Caller provided invalid Chooser request parameters", e); finish(); @@ -291,10 +299,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 +712,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); @@ -732,6 +735,32 @@ public class ChooserActivity extends ResolverActivity implements public ActionRow.Action createNearbyButton() { return ChooserActivity.this.createNearbyAction(targetIntent); } + + @Override + public List<ActionRow.Action> createCustomActions() { + ImmutableList<ChooserAction> customActions = + mChooserRequest.getChooserActions(); + List<ActionRow.Action> 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 getReselectionAction() { + if (!ENABLE_RESELECTION_ACTION) { + return null; + } + PendingIntent reselectionAction = mChooserRequest.getReselectionAction(); + return reselectionAction == null + ? null + : createReselectionRunnable(reselectionAction); + } }; ViewGroup layout = ChooserContentPreviewUi.displayContentPreview( @@ -740,19 +769,18 @@ 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, + imageLoader, + mEnterTransitionAnimationDelegate, getContentResolver(), this::isImageType); if (layout != null) { adjustPreviewWidth(getResources().getConfiguration().orientation, layout); } - if (previewType != ChooserContentPreviewUi.CONTENT_PREVIEW_IMAGE) { - mEnterTransitionAnimationDelegate.markImagePreviewReady(false); - } return layout; } @@ -928,6 +956,41 @@ 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(); + } + ); + } + + 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); return firstImage != null && firstImage.isVisibleToUser() ? firstImage : null; @@ -1115,7 +1178,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); @@ -1485,7 +1548,7 @@ public class ChooserActivity extends ResolverActivity implements @Override public View buildContentPreview(ViewGroup parent) { - return createContentPreviewView(parent, mPreviewCoordinator); + return createContentPreviewView(parent, mPreviewImageLoader); } @Override @@ -1500,9 +1563,9 @@ public class ChooserActivity extends ResolverActivity implements .getActiveListAdapter() .targetInfoForPosition( selectedPosition, /* filtered= */ true); - // ItemViewHolder contents should always be "display resolve info" - // targets, but check just to make sure. - if (longPressedTargetInfo.isDisplayResolveInfo()) { + // Only a direct share target or an app target is expected + if (longPressedTargetInfo.isDisplayResolveInfo() + || longPressedTargetInfo.isSelectableTargetInfo()) { showTargetDetails(longPressedTargetInfo); } } @@ -1600,17 +1663,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) { @@ -1845,21 +1899,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, @@ -1946,12 +1999,24 @@ 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 */ protected boolean shouldShowContentPreview() { @@ -1967,7 +2032,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); } } @@ -2159,6 +2224,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; + } } /** 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/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<Bitmap> 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<Bitmap> bitmapFuture = mBackgroundExecutor.submit( - () -> mChooserActivity.loadThumbnail(imageUri, new Size(size, size))); - - Futures.addCallback( - bitmapFuture, - new FutureCallback<Bitmap>() { - @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..f3d00c43 100644 --- a/java/src/com/android/intentresolver/ChooserContentPreviewUi.java +++ b/java/src/com/android/intentresolver/ChooserContentPreviewUi.java @@ -47,6 +47,7 @@ import androidx.annotation.Nullable; 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; @@ -55,7 +56,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; /** * Collection of helpers for building the content preview UI displayed in {@link ChooserActivity}. @@ -72,21 +73,6 @@ 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<Bitmap> 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. * TODO: clarify why action buttons are part of preview logic. @@ -102,6 +88,15 @@ public final class ChooserContentPreviewUi { /** Create an "Share to Nearby" action. */ @Nullable ActionRow.Action createNearbyButton(); + + /** Create custom actions */ + List<ActionRow.Action> createCustomActions(); + + /** + * Provides a re-selection action, if any. + */ + @Nullable + Runnable getReselectionAction(); } /** @@ -181,30 +176,38 @@ public final class ChooserContentPreviewUi { ActionFactory actionFactory, @LayoutRes int actionRowLayout, ViewGroup parent, - ContentPreviewCoordinator previewCoord, - Consumer<Boolean> onTransitionTargetReady, + ImageLoader previewImageLoader, + TransitionElementStatusCallback transitionElementStatusCallback, ContentResolver contentResolver, ImageMimeTypeClassifier imageClassifier) { ViewGroup layout = null; + if (previewType != CONTENT_PREVIEW_IMAGE) { + transitionElementStatusCallback.onAllTransitionElementsReady(); + } + List<ActionRow.Action> customActions = actionFactory.createCustomActions(); switch (previewType) { case CONTENT_PREVIEW_TEXT: layout = displayTextContentPreview( targetIntent, layoutInflater, - createTextPreviewActions(actionFactory), + createActions( + createTextPreviewActions(actionFactory), + customActions), parent, - previewCoord, + previewImageLoader, actionRowLayout); break; case CONTENT_PREVIEW_IMAGE: layout = displayImageContentPreview( targetIntent, layoutInflater, - createImagePreviewActions(actionFactory), + createActions( + createImagePreviewActions(actionFactory), + customActions), parent, - previewCoord, - onTransitionTargetReady, + previewImageLoader, + transitionElementStatusCallback, contentResolver, imageClassifier, actionRowLayout); @@ -214,19 +217,41 @@ public final class ChooserContentPreviewUi { targetIntent, resources, layoutInflater, - createFilePreviewActions(actionFactory), + createActions( + createFilePreviewActions(actionFactory), + customActions), parent, - previewCoord, + previewImageLoader, contentResolver, actionRowLayout); break; 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; } + private static List<ActionRow.Action> createActions( + List<ActionRow.Action> systemActions, List<ActionRow.Action> customActions) { + ArrayList<ActionRow.Action> 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); } @@ -247,7 +272,7 @@ public final class ChooserContentPreviewUi { LayoutInflater layoutInflater, List<ActionRow.Action> 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 +317,7 @@ public final class ChooserContentPreviewUi { if (previewThumbnail == null) { previewThumbnailView.setVisibility(View.GONE); } else { - previewCoord.loadImage( + previewImageLoader.loadImage( previewThumbnail, (bitmap) -> updateViewWithImage( contentPreviewLayout.findViewById( @@ -319,8 +344,8 @@ public final class ChooserContentPreviewUi { LayoutInflater layoutInflater, List<ActionRow.Action> actions, ViewGroup parent, - ContentPreviewCoordinator previewCoord, - Consumer<Boolean> onTransitionTargetReady, + ImageLoader imageLoader, + TransitionElementStatusCallback transitionElementStatusCallback, ContentResolver contentResolver, ImageMimeTypeClassifier imageClassifier, @LayoutRes int actionRowLayout) { @@ -334,33 +359,26 @@ public final class ChooserContentPreviewUi { actionRow.setActions(actions); } - final ImagePreviewImageLoader imageLoader = new ImagePreviewImageLoader(previewCoord); - final ArrayList<Uri> 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<Uri> 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<Uri> 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"); - imagePreview.setVisibility(View.GONE); - onTransitionTargetReady.accept(false); + ((View) imagePreview).setVisibility(View.GONE); + transitionElementStatusCallback.onAllTransitionElementsReady(); return contentPreviewLayout; } - imagePreview.setSharedElementTransitionTarget( - ChooserActivity.FIRST_IMAGE_PREVIEW_TRANSITION_NAME, - onTransitionTargetReady); + imagePreview.setTransitionElementStatusCallback(transitionElementStatusCallback); imagePreview.setImages(imageUris, imageLoader); return contentPreviewLayout; @@ -387,59 +405,74 @@ public final class ChooserContentPreviewUi { LayoutInflater layoutInflater, List<ActionRow.Action> 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<Uri> 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<String, Object> 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<Uri> extractContentUris(Intent targetIntent) { + List<Uri> 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<Uri> 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<String, Object> 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<Uri> 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<ActionRow.Action> createFilePreviewActions(ActionFactory actionFactory) { @@ -473,7 +506,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 +515,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/ChooserRequestParameters.java b/java/src/com/android/intentresolver/ChooserRequestParameters.java index 81481bf1..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; @@ -26,6 +27,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 +72,8 @@ public class ChooserRequestParameters { private final Intent mReferrerFillInIntent; private final ImmutableList<ComponentName> mFilteredComponentNames; private final ImmutableList<ChooserTarget> mCallerChooserTargets; + private final ImmutableList<ChooserAction> mChooserActions; + private final PendingIntent mReselectionAction; private final boolean mRetainInOnStop; @Nullable @@ -96,7 +100,9 @@ public class ChooserRequestParameters { public ChooserRequestParameters( final Intent clientIntent, final Uri referrer, - @Nullable final ComponentName nearbySharingComponent) { + @Nullable final ComponentName nearbySharingComponent, + boolean extractCustomActions, + boolean extractReslectionAction) { final Intent requestedTarget = parseTargetIntentExtra( clientIntent.getParcelableExtra(Intent.EXTRA_INTENT)); mTarget = intentWithModifiedLaunchFlags(requestedTarget); @@ -130,6 +136,13 @@ public class ChooserRequestParameters { mSharedText = mTarget.getStringExtra(Intent.EXTRA_TEXT); mTargetIntentFilter = getTargetIntentFilter(mTarget); + + mChooserActions = extractCustomActions + ? getChooserActions(clientIntent) + : ImmutableList.of(); + mReselectionAction = extractReslectionAction + ? getReselectionActionExtra(clientIntent) + : null; } public Intent getTargetIntent() { @@ -171,6 +184,15 @@ public class ChooserRequestParameters { return mCallerChooserTargets; } + public ImmutableList<ChooserAction> getChooserActions() { + return mChooserActions; + } + + @Nullable + public PendingIntent getReselectionAction() { + return mReselectionAction; + } + /** * Whether the {@link ChooserActivity.EXTRA_PRIVATE_RETAIN_IN_ON_STOP} behavior was requested. */ @@ -300,6 +322,31 @@ public class ChooserRequestParameters { .collect(toImmutableList()); } + private static ImmutableList<ChooserAction> getChooserActions(Intent intent) { + return streamParcelableArrayExtra( + intent, + Intent.EXTRA_CHOOSER_CUSTOM_ACTIONS, + ChooserAction.class, + true, + true) + .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 <T> Collector<T, ?, ImmutableList<T>> toImmutableList() { return Collectors.collectingAndThen(Collectors.toList(), ImmutableList::copyOf); } diff --git a/java/src/com/android/intentresolver/EnterTransitionAnimationDelegate.kt b/java/src/com/android/intentresolver/EnterTransitionAnimationDelegate.kt index a0bf61b6..b1178aa5 100644 --- a/java/src/com/android/intentresolver/EnterTransitionAnimationDelegate.kt +++ b/java/src/com/android/intentresolver/EnterTransitionAnimationDelegate.kt @@ -15,23 +15,31 @@ */ 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.intentresolver.widget.ImagePreviewView.TransitionElementStatusCallback +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<ResolverDrawerLayout?> -) : View.OnLayoutChangeListener { - private var removeSharedElements = false +@VisibleForTesting(visibility = VisibleForTesting.Visibility.PACKAGE) +class EnterTransitionAnimationDelegate( + private val activity: ComponentActivity, + private val transitionTargetSupplier: Supplier<View?>, +) : View.OnLayoutChangeListener, TransitionElementStatusCallback { + + private val transitionElements = HashSet<String>() private var previewReady = false private var offsetCalculated = false + private var timeoutJob: Job? = null init { activity.setEnterSharedElementCallback( @@ -46,12 +54,27 @@ internal class EnterTransitionAnimationDelegate( }) } - fun postponeTransition() = activity.postponeEnterTransition() - - fun markImagePreviewReady(runTransitionAnimation: Boolean) { - if (!runTransitionAnimation) { - removeSharedElements = true + 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. + onAllTransitionElementsReady() + } + + override fun onTransitionElementReady(name: String) { + transitionElements.add(name) + } + + override fun onAllTransitionElementsReady() { + timeoutJob?.cancel() if (!previewReady) { previewReady = true maybeStartListenForLayout() @@ -69,15 +92,12 @@ internal class EnterTransitionAnimationDelegate( names: MutableList<String>, sharedElements: MutableMap<String, View> ) { - 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() { - val drawer = resolverDrawerLayoutSupplier.get() + val drawer = transitionTargetSupplier.get() if (previewReady && offsetCalculated && drawer != null) { if (drawer.isInLayout) { startPostponedEnterTransition() @@ -98,7 +118,7 @@ internal 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/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<Bitmap?>) +} 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?> { 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<Bitmap?>) { + 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/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 * <p>Can only be used if there is a work profile. * <p>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; 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. - * <p> - * 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. - * </p> - * <p> - * 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. - * </p> - */ -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<Consumer<Result>> mCallback = new AtomicReference<>(); - private final AtomicReference<Request> 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<Result> 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<Result> 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<ShortcutManager.ShareShortcutInfo> 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<ShortcutManager.ShareShortcutInfo> shortcuts = new ArrayList<>(); - List<AppTarget> 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<ShortcutManager.ShareShortcutInfo> shortcuts, - boolean isFromAppPredictor, - @Nullable List<AppTarget> 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<ChooserTarget, AppTarget> directShareAppTargetCache = new HashMap<>(); - HashMap<ChooserTarget, ShortcutInfo> 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<ShortcutResultInfo> resultRecords = new ArrayList<>(); - for (DisplayResolveInfo displayResolveInfo : appTargets) { - List<ShortcutManager.ShareShortcutInfo> matchingShortcuts = - filterShortcutsByTargetComponentName( - shortcuts, displayResolveInfo.getResolvedComponentName()); - if (matchingShortcuts.isEmpty()) { - continue; - } - - List<ChooserTarget> 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<Result> 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<ShortcutManager.ShareShortcutInfo> filterShortcutsByTargetComponentName( - List<ShortcutManager.ShareShortcutInfo> allShortcuts, ComponentName requiredTarget) { - List<ShortcutManager.ShareShortcutInfo> 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<ChooserTarget, AppTarget> directShareAppTargetCache; - public final Map<ChooserTarget, ShortcutInfo> directShareShortcutInfoCache; - - @VisibleForTesting - public Result( - boolean isFromAppPredictor, - DisplayResolveInfo[] appTargets, - ShortcutResultInfo[] shortcutsByApp, - Map<ChooserTarget, AppTarget> directShareAppTargetCache, - Map<ChooserTarget, ShortcutInfo> 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<ChooserTarget> shortcuts; - - public ShortcutResultInfo(DisplayResolveInfo appTarget, List<ChooserTarget> 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<Result> +) { + 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<Result> + ) : 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<DisplayResolveInfo>) { + 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<ShareShortcutInfo> { + 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<AppTarget>) { + 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<AppTarget>.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<ShareShortcutInfo>).add( + ShareShortcutInfo(shortcutInfo, ComponentName(packageName, className)) + ) + (acc.appTargets as ArrayList<AppTarget>).add(appTarget) + } + acc + } + + @WorkerThread + private fun sendShareShortcutInfoList( + shortcuts: List<ShareShortcutInfo>, + isFromAppPredictor: Boolean, + appPredictorTargets: List<AppTarget>? + ) { + 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<ChooserTarget, AppTarget>() + val directShareShortcutInfoCache = HashMap<ChooserTarget, ShortcutInfo>() + // 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<ShortcutResultInfo> = 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<DisplayResolveInfo>) + + /** + * 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<DisplayResolveInfo>, + /** + * Shortcuts grouped by app target. + */ + val shortcutsByApp: Array<ShortcutResultInfo>, + val directShareAppTargetCache: Map<ChooserTarget, AppTarget>, + val directShareShortcutInfoCache: Map<ChooserTarget, ShortcutInfo> + ) + + /** + * Shortcuts grouped by app. + */ + class ShortcutResultInfo( + val appTarget: DisplayResolveInfo, + val shortcuts: List<ChooserTarget?> + ) + + private class ShortcutsAppTargetsPair( + val shortcuts: List<ShareShortcutInfo>, + val appTargets: List<AppTarget>? + ) + + /** + * 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/src/com/android/intentresolver/widget/ChooserImagePreviewView.kt b/java/src/com/android/intentresolver/widget/ChooserImagePreviewView.kt new file mode 100644 index 00000000..bf10bfaa --- /dev/null +++ b/java/src/com/android/intentresolver/widget/ChooserImagePreviewView.kt @@ -0,0 +1,162 @@ +/* + * 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.widget + +import android.animation.ObjectAnimator +import android.content.Context +import android.net.Uri +import android.util.AttributeSet +import android.view.LayoutInflater +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 com.android.internal.R as IntR + +private const val IMAGE_FADE_IN_MILLIS = 150L + +class ChooserImagePreviewView : RelativeLayout, ImagePreviewView { + + 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 transitionStatusElementCallback: TransitionElementStatusCallback? = 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 readiness callback. The callback will be + * invoked once when views preparation is done. + * Should be called before [setImages]. + */ + override fun setTransitionElementStatusCallback(callback: TransitionElementStatusCallback?) { + transitionStatusElementCallback = callback + } + + override fun setImages(uris: List<Uri>, 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() + } + + private suspend fun showOneImage(uris: List<Uri>, imageLoader: ImageLoader) { + secondLargeImage.isVisible = false + secondSmallImage.isVisible = false + thirdImage.isVisible = false + showImages(uris, imageLoader, mainImage) + } + + private suspend fun showTwoImages(uris: List<Uri>, imageLoader: ImageLoader) { + secondSmallImage.isVisible = false + thirdImage.isVisible = false + showImages(uris, imageLoader, mainImage, secondLargeImage) + } + + private suspend fun showThreeImages(uris: List<Uri>, imageLoader: ImageLoader) { + secondLargeImage.isVisible = false + showImages(uris, imageLoader, mainImage, secondSmallImage, thirdImage) + thirdImage.setExtraImageCount(uris.size - 3) + } + + private suspend fun showImages( + uris: List<Uri>, 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() + } + } 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 && transitionStatusElementCallback != null) { + view.waitForPreDraw() + invokeTransitionViewReadyCallback() + } + } + } + + private fun invokeTransitionViewReadyCallback() { + transitionStatusElementCallback?.apply { + if (mainImage.isVisible && mainImage.drawable != null) { + mainImage.transitionName?.let { onTransitionElementReady(it) } + } + onAllTransitionElementsReady() + } + transitionStatusElementCallback = null + } +} diff --git a/java/src/com/android/intentresolver/widget/ImagePreviewView.kt b/java/src/com/android/intentresolver/widget/ImagePreviewView.kt index a37ef954..a166ef27 100644 --- a/java/src/com/android/intentresolver/widget/ImagePreviewView.kt +++ b/java/src/com/android/intentresolver/widget/ImagePreviewView.kt @@ -16,163 +16,34 @@ 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 -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<Boolean>? = 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 { + fun setTransitionElementStatusCallback(callback: TransitionElementStatusCallback?) + fun setImages(uris: List<Uri>, 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<Boolean>) { - mainImage.transitionName = name - onTransitionViewReadyCallback = onViewReady - } - - fun setImages(uris: List<Uri>, 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<Uri>, imageLoader: ImageLoader) { - secondLargeImage.isVisible = false - secondSmallImage.isVisible = false - thirdImage.isVisible = false - showImages(uris, imageLoader, mainImage) - } - - private suspend fun showTwoImages(uris: List<Uri>, imageLoader: ImageLoader) { - secondSmallImage.isVisible = false - thirdImage.isVisible = false - showImages(uris, imageLoader, mainImage, secondLargeImage) - } - - private suspend fun showThreeImages(uris: List<Uri>, imageLoader: ImageLoader) { - secondLargeImage.isVisible = false - showImages(uris, imageLoader, mainImage, secondSmallImage, thirdImage) - thirdImage.setExtraImageCount(uris.size - 3) - } - - private suspend fun showImages( - uris: List<Uri>, 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 + 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) + + /** + * 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/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/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 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..9ea9dfa7 --- /dev/null +++ b/java/tests/src/com/android/intentresolver/EnterTransitionAnimationDelegateTest.kt @@ -0,0 +1,112 @@ +/* + * Copyright (C) 2023 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.intentresolver + +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 elementName = "shared-element" + private val scheduler = TestCoroutineScheduler() + private val dispatcher = StandardTestDispatcher(scheduler) + private val lifecycleOwner = TestLifecycleOwner() + + private val transitionTargetView = mock<View> { + // avoid the request-layout path in the delegate + whenever(isInLayout).thenReturn(true) + } + + private val windowMock = mock<Window>() + private val resourcesMock = mock<Resources> { + whenever(getInteger(anyInt())).thenReturn(TIMEOUT_MS) + } + private val activity = mock<ComponentActivity> { + 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.onTransitionElementReady(elementName) + testSubject.markOffsetCalculated() + testSubject.onTransitionElementReady(elementName) + + 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.onAllTransitionElementsReady() + 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<Bitmap?>) { + val override = imageOverride() + if (override != null) { + callback.accept(override) + } else { + imageLoader.loadImage(uri, callback) + } + } + + override suspend fun invoke(uri: Uri): Bitmap? = imageOverride() ?: imageLoader(uri) +} diff --git a/java/tests/src/com/android/intentresolver/UnbundledChooserActivityTest.java b/java/tests/src/com/android/intentresolver/UnbundledChooserActivityTest.java index af2557ef..d7af8925 100644 --- a/java/tests/src/com/android/intentresolver/UnbundledChooserActivityTest.java +++ b/java/tests/src/com/android/intentresolver/UnbundledChooserActivityTest.java @@ -20,6 +20,7 @@ import static android.app.Activity.RESULT_OK; import static androidx.test.espresso.Espresso.onView; import static androidx.test.espresso.action.ViewActions.click; +import static androidx.test.espresso.action.ViewActions.longClick; import static androidx.test.espresso.action.ViewActions.swipeUp; import static androidx.test.espresso.assertion.ViewAssertions.doesNotExist; import static androidx.test.espresso.assertion.ViewAssertions.matches; @@ -55,13 +56,16 @@ import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; +import android.app.PendingIntent; import android.app.usage.UsageStatsManager; +import android.content.BroadcastReceiver; import android.content.ClipData; import android.content.ClipDescription; import android.content.ClipboardManager; import android.content.ComponentName; import android.content.Context; import android.content.Intent; +import android.content.IntentFilter; import android.content.pm.ActivityInfo; import android.content.pm.ApplicationInfo; import android.content.pm.PackageManager; @@ -69,6 +73,7 @@ import android.content.pm.ResolveInfo; import android.content.pm.ShortcutInfo; import android.content.pm.ShortcutManager.ShareShortcutInfo; import android.content.res.Configuration; +import android.content.res.Resources; import android.database.Cursor; import android.graphics.Bitmap; import android.graphics.Canvas; @@ -79,6 +84,7 @@ import android.net.Uri; import android.os.Bundle; import android.os.UserHandle; import android.provider.DeviceConfig; +import android.service.chooser.ChooserAction; import android.service.chooser.ChooserTarget; import android.util.HashedStringCache; import android.util.Pair; @@ -117,6 +123,7 @@ import java.util.Collection; import java.util.HashMap; import java.util.List; import java.util.Map; +import java.util.concurrent.CountDownLatch; import java.util.function.Consumer; import java.util.function.Function; @@ -1665,6 +1672,61 @@ public class UnbundledChooserActivityTest { } @Test + public void testLaunchWithCustomAction() throws InterruptedException { + if (!ChooserActivity.ENABLE_CUSTOM_ACTIONS) { + return; + } + List<ResolvedComponentInfo> resolvedComponentInfos = createResolvedComponentsForTest(2); + when( + ChooserActivityOverrideData + .getInstance() + .resolverListController + .getResolversForIntent( + Mockito.anyBoolean(), + Mockito.anyBoolean(), + Mockito.anyBoolean(), + Mockito.isA(List.class))) + .thenReturn(resolvedComponentInfos); + + Context testContext = InstrumentationRegistry.getInstrumentation().getContext(); + final String customActionLabel = "Custom Action"; + final String testAction = "test-broadcast-receiver-action"; + Intent chooserIntent = Intent.createChooser(createSendTextIntent(), null); + chooserIntent.putExtra( + Intent.EXTRA_CHOOSER_CUSTOM_ACTIONS, + new ChooserAction[] { + new ChooserAction.Builder( + Icon.createWithResource("", Resources.ID_NULL), + customActionLabel, + PendingIntent.getBroadcast( + testContext, + 123, + new Intent(testAction), + PendingIntent.FLAG_IMMUTABLE | PendingIntent.FLAG_ONE_SHOT)) + .build() + }); + // Start activity + mActivityRule.launchActivity(chooserIntent); + waitForIdle(); + + final CountDownLatch broadcastInvoked = new CountDownLatch(1); + BroadcastReceiver testReceiver = new BroadcastReceiver() { + @Override + public void onReceive(Context context, Intent intent) { + broadcastInvoked.countDown(); + } + }; + testContext.registerReceiver(testReceiver, new IntentFilter(testAction)); + + try { + onView(withText(customActionLabel)).perform(click()); + broadcastInvoked.await(); + } finally { + testContext.unregisterReceiver(testReceiver); + } + } + + @Test public void testUpdateMaxTargetsPerRow_columnCountIsUpdated() throws InterruptedException { updateMaxTargetsPerRowResource(/* targetsPerRow= */ 4); givenAppTargets(/* appCount= */ 16); @@ -2130,6 +2192,72 @@ public class UnbundledChooserActivityTest { /* selectionCost= */ anyLong()); } + @Test + public void testDirectTargetPinningDialog() { + Intent sendIntent = createSendTextIntent(); + // We need app targets for direct targets to get displayed + List<ResolvedComponentInfo> 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<Pair<ShortcutLoader, Consumer<ShortcutLoader.Result>>> shortcutLoaders = + new SparseArray<>(); + ChooserActivityOverrideData.getInstance().shortcutLoaderFactory = + (userHandle, callback) -> { + Pair<ShortcutLoader, Consumer<ShortcutLoader.Result>> 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<DisplayResolveInfo[]> appTargets = + ArgumentCaptor.forClass(DisplayResolveInfo[].class); + verify(shortcutLoaders.get(0).first, times(1)) + .queryShortcuts(appTargets.capture()); + + // send shortcuts + List<ChooserTarget> 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(); 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<PackageManager> { whenever(getApplicationInfo(any(), any<ApplicationInfoFlags>())).thenReturn(appInfo) } + val userManager = mock<UserManager> { + whenever(isUserRunning(any<UserHandle>())).thenReturn(true) + whenever(isUserUnlocked(any<UserHandle>())).thenReturn(true) + whenever(isQuietModeEnabled(any<UserHandle>())).thenReturn(false) + } private val context = mock<Context> { 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<IntentFilter>() @@ -66,7 +74,7 @@ class ShortcutLoaderTest { private val callback = mock<Consumer<ShortcutLoader.Result>>() @Test - fun test_app_predictor_result() { + fun test_queryShortcuts_result_consistency_with_AppPredictor() { val componentName = ComponentName("pkg", "Class") val appTarget = mock<DisplayResolveInfo> { 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<AppPredictor.Callback>() + 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<ShortcutLoader.Result>() + 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<DisplayResolveInfo> { 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<ShortcutLoader.Result>() + 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<DisplayResolveInfo> { 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<AppPredictor.Callback>() 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<ShortcutLoader.Result>() + 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<UserManager> { + 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<UserManager> { + with(userManager) { whenever(isUserRunning(userHandle)).thenReturn(isUserRunning) whenever(isUserUnlocked(userHandle)).thenReturn(isUserUnlocked) whenever(isQuietModeEnabled(userHandle)).thenReturn(isQuietModeEnabled) |