summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--java/res/layout/chooser_dialog.xml1
-rw-r--r--java/res/layout/chooser_grid_preview_file.xml9
-rw-r--r--java/res/layout/chooser_grid_preview_image.xml11
-rw-r--r--java/res/layout/chooser_grid_preview_text.xml9
-rw-r--r--java/res/layout/image_preview_view.xml1
-rw-r--r--java/res/values/strings.xml7
-rw-r--r--java/res/values/styles.xml6
-rw-r--r--java/src/com/android/intentresolver/ChooserActivity.java166
-rw-r--r--java/src/com/android/intentresolver/ChooserActivityLogger.java16
-rw-r--r--java/src/com/android/intentresolver/ChooserContentPreviewCoordinator.java132
-rw-r--r--java/src/com/android/intentresolver/ChooserContentPreviewUi.java211
-rw-r--r--java/src/com/android/intentresolver/ChooserRequestParameters.java49
-rw-r--r--java/src/com/android/intentresolver/EnterTransitionAnimationDelegate.kt58
-rw-r--r--java/src/com/android/intentresolver/ImageLoader.kt25
-rw-r--r--java/src/com/android/intentresolver/ImagePreviewImageLoader.kt45
-rw-r--r--java/src/com/android/intentresolver/ResolverActivity.java6
-rw-r--r--java/src/com/android/intentresolver/shortcuts/ShortcutLoader.java426
-rw-r--r--java/src/com/android/intentresolver/shortcuts/ShortcutLoader.kt326
-rw-r--r--java/src/com/android/intentresolver/widget/ChooserImagePreviewView.kt162
-rw-r--r--java/src/com/android/intentresolver/widget/ImagePreviewView.kt173
-rw-r--r--java/src/com/android/intentresolver/widget/ViewExtensions.kt39
-rw-r--r--java/tests/Android.bp4
-rw-r--r--java/tests/src/com/android/intentresolver/ChooserActivityLoggerTest.java4
-rw-r--r--java/tests/src/com/android/intentresolver/ChooserWrapperActivity.java11
-rw-r--r--java/tests/src/com/android/intentresolver/EnterTransitionAnimationDelegateTest.kt112
-rw-r--r--java/tests/src/com/android/intentresolver/TestLifecycleOwner.kt33
-rw-r--r--java/tests/src/com/android/intentresolver/TestPreviewImageLoader.kt37
-rw-r--r--java/tests/src/com/android/intentresolver/UnbundledChooserActivityTest.java128
-rw-r--r--java/tests/src/com/android/intentresolver/shortcuts/ShortcutLoaderTest.kt58
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)