Merge "Add custom action and reselection logging params to IntentResolver" into tm-qpr-dev
diff --git a/java/res/layout/chooser_dialog.xml b/java/res/layout/chooser_dialog.xml
index e31712c..19ead35 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 e98c327..9475511 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 5c32414..23bc25d 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 db7282e..49a2edf 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/values/strings.xml b/java/res/values/strings.xml
index a536d3b..5917950 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 cbbf406..229512f 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 6a94d56..f7f131a 100644
--- a/java/src/com/android/intentresolver/ChooserActivity.java
+++ b/java/src/com/android/intentresolver/ChooserActivity.java
@@ -160,6 +160,7 @@
private static final boolean DEBUG = true;
static final boolean ENABLE_CUSTOM_ACTIONS = false;
+ static final boolean ENABLE_RESELECTION_ACTION = false;
public static final String LAUNCH_LOCATION_DIRECT_SHARE = "direct_share";
private static final String SHORTCUT_TARGET = "shortcut_target";
@@ -267,7 +268,11 @@
try {
mChooserRequest = new ChooserRequestParameters(
- getIntent(), getReferrer(), getNearbySharingComponent(), ENABLE_CUSTOM_ACTIONS);
+ getIntent(),
+ getReferrer(),
+ getNearbySharingComponent(),
+ ENABLE_CUSTOM_ACTIONS,
+ ENABLE_RESELECTION_ACTION);
} catch (IllegalArgumentException e) {
Log.e(TAG, "Caller provided invalid Chooser request parameters", e);
finish();
@@ -743,6 +748,18 @@
}
return actions;
}
+
+ @Nullable
+ @Override
+ public Runnable getReselectionAction() {
+ if (!ENABLE_RESELECTION_ACTION) {
+ return null;
+ }
+ PendingIntent reselectionAction = mChooserRequest.getReselectionAction();
+ return reselectionAction == null
+ ? null
+ : createReselectionRunnable(reselectionAction);
+ }
};
ViewGroup layout = ChooserContentPreviewUi.displayContentPreview(
@@ -962,6 +979,19 @@
);
}
+ 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);
@@ -1535,9 +1565,9 @@
.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);
}
}
@@ -1871,21 +1901,20 @@
}
@MainThread
- private void onShortcutsLoaded(
- UserHandle userHandle, ShortcutLoader.Result shortcutsResult) {
+ private void onShortcutsLoaded(UserHandle userHandle, ShortcutLoader.Result result) {
if (DEBUG) {
Log.d(TAG, "onShortcutsLoaded for user: " + userHandle);
}
- mDirectShareShortcutInfoCache.putAll(shortcutsResult.directShareShortcutInfoCache);
- mDirectShareAppTargetCache.putAll(shortcutsResult.directShareAppTargetCache);
+ mDirectShareShortcutInfoCache.putAll(result.getDirectShareShortcutInfoCache());
+ mDirectShareAppTargetCache.putAll(result.getDirectShareAppTargetCache());
ChooserListAdapter adapter =
mChooserMultiProfilePagerAdapter.getListAdapterForUserHandle(userHandle);
if (adapter != null) {
- for (ShortcutLoader.ShortcutResultInfo resultInfo : shortcutsResult.shortcutsByApp) {
+ for (ShortcutLoader.ShortcutResultInfo resultInfo : result.getShortcutsByApp()) {
adapter.addServiceResults(
- resultInfo.appTarget,
- resultInfo.shortcuts,
- shortcutsResult.isFromAppPredictor
+ resultInfo.getAppTarget(),
+ resultInfo.getShortcuts(),
+ result.isFromAppPredictor()
? TARGET_TYPE_SHORTCUTS_FROM_PREDICTION_SERVICE
: TARGET_TYPE_SHORTCUTS_FROM_SHORTCUT_MANAGER,
mDirectShareShortcutInfoCache,
diff --git a/java/src/com/android/intentresolver/ChooserContentPreviewUi.java b/java/src/com/android/intentresolver/ChooserContentPreviewUi.java
index 88f9006..390c47c 100644
--- a/java/src/com/android/intentresolver/ChooserContentPreviewUi.java
+++ b/java/src/com/android/intentresolver/ChooserContentPreviewUi.java
@@ -90,6 +90,12 @@
/** Create custom actions */
List<ActionRow.Action> createCustomActions();
+
+ /**
+ * Provides a re-selection action, if any.
+ */
+ @Nullable
+ Runnable getReselectionAction();
}
/**
@@ -218,6 +224,15 @@
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;
}
@@ -358,7 +373,7 @@
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);
+ ((View) imagePreview).setVisibility(View.GONE);
onTransitionTargetReady.accept(false);
return contentPreviewLayout;
}
diff --git a/java/src/com/android/intentresolver/ChooserRequestParameters.java b/java/src/com/android/intentresolver/ChooserRequestParameters.java
index a7e543a..97bee82 100644
--- a/java/src/com/android/intentresolver/ChooserRequestParameters.java
+++ b/java/src/com/android/intentresolver/ChooserRequestParameters.java
@@ -18,6 +18,7 @@
import android.annotation.NonNull;
import android.annotation.Nullable;
+import android.app.PendingIntent;
import android.content.ComponentName;
import android.content.Intent;
import android.content.IntentFilter;
@@ -72,6 +73,7 @@
private final ImmutableList<ComponentName> mFilteredComponentNames;
private final ImmutableList<ChooserTarget> mCallerChooserTargets;
private final ImmutableList<ChooserAction> mChooserActions;
+ private final PendingIntent mReselectionAction;
private final boolean mRetainInOnStop;
@Nullable
@@ -99,7 +101,8 @@
final Intent clientIntent,
final Uri referrer,
@Nullable final ComponentName nearbySharingComponent,
- boolean extractCustomActions) {
+ boolean extractCustomActions,
+ boolean extractReslectionAction) {
final Intent requestedTarget = parseTargetIntentExtra(
clientIntent.getParcelableExtra(Intent.EXTRA_INTENT));
mTarget = intentWithModifiedLaunchFlags(requestedTarget);
@@ -137,6 +140,9 @@
mChooserActions = extractCustomActions
? getChooserActions(clientIntent)
: ImmutableList.of();
+ mReselectionAction = extractReslectionAction
+ ? getReselectionActionExtra(clientIntent)
+ : null;
}
public Intent getTargetIntent() {
@@ -182,6 +188,11 @@
return mChooserActions;
}
+ @Nullable
+ public PendingIntent getReselectionAction() {
+ return mReselectionAction;
+ }
+
/**
* Whether the {@link ChooserActivity.EXTRA_PRIVATE_RETAIN_IN_ON_STOP} behavior was requested.
*/
@@ -321,6 +332,21 @@
.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/shortcuts/ShortcutLoader.java b/java/src/com/android/intentresolver/shortcuts/ShortcutLoader.java
deleted file mode 100644
index 1cfa2c8..0000000
--- 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 0000000..6f7542f
--- /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 0000000..dd1dd28
--- /dev/null
+++ b/java/src/com/android/intentresolver/widget/ChooserImagePreviewView.kt
@@ -0,0 +1,175 @@
+/*
+ * 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.View
+import android.view.ViewTreeObserver
+import android.view.animation.DecelerateInterpolator
+import android.widget.RelativeLayout
+import androidx.core.view.isVisible
+import com.android.intentresolver.R
+import kotlinx.coroutines.Job
+import kotlinx.coroutines.MainScope
+import kotlinx.coroutines.coroutineScope
+import kotlinx.coroutines.launch
+import java.util.function.Consumer
+import com.android.internal.R as IntR
+
+private 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 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)
+ }
+
+ /**
+ * Specifies a transition animation target name and a readiness callback. The callback will be
+ * invoked once when the view preparation is done i.e. either when an image is loaded into it
+ * and it is laid out (and it is ready to be draw) or image loading has failed.
+ * Should be called before [setImages].
+ * @param name, transition name
+ * @param onViewReady, a callback that will be invoked with `true` if the view is ready to
+ * receive transition animation (the image was loaded successfully) and with `false` otherwise.
+ */
+ override fun setSharedElementTransitionTarget(name: String, onViewReady: Consumer<Boolean>) {
+ mainImage.transitionName = name
+ onTransitionViewReadyCallback = onViewReady
+ }
+
+ 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(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
+ }
+}
diff --git a/java/src/com/android/intentresolver/widget/ImagePreviewView.kt b/java/src/com/android/intentresolver/widget/ImagePreviewView.kt
index c61c7c7..a575605 100644
--- a/java/src/com/android/intentresolver/widget/ImagePreviewView.kt
+++ b/java/src/com/android/intentresolver/widget/ImagePreviewView.kt
@@ -16,58 +16,13 @@
package com.android.intentresolver.widget
-import android.animation.ObjectAnimator
-import android.content.Context
import android.graphics.Bitmap
import android.net.Uri
-import android.util.AttributeSet
-import android.view.LayoutInflater
-import android.view.View
-import android.view.ViewTreeObserver
-import android.view.animation.DecelerateInterpolator
-import android.widget.RelativeLayout
-import androidx.core.view.isVisible
-import com.android.intentresolver.R
-import kotlinx.coroutines.Job
-import kotlinx.coroutines.MainScope
-import kotlinx.coroutines.coroutineScope
-import kotlinx.coroutines.launch
import java.util.function.Consumer
-import com.android.internal.R as IntR
-private typealias ImageLoader = suspend (Uri) -> Bitmap?
+internal typealias ImageLoader = suspend (Uri) -> Bitmap?
-private const val IMAGE_FADE_IN_MILLIS = 150L
-
-class ImagePreviewView : RelativeLayout {
-
- constructor(context: Context) : this(context, null)
- constructor(context: Context, attrs: AttributeSet?) : this(context, attrs, 0)
-
- constructor(
- context: Context, attrs: AttributeSet?, defStyleAttr: Int
- ) : this(context, attrs, defStyleAttr, 0)
-
- constructor(
- context: Context, attrs: AttributeSet?, defStyleAttr: Int, defStyleRes: Int
- ) : super(context, attrs, defStyleAttr, defStyleRes)
-
- private val coroutineScope = MainScope()
- private lateinit var mainImage: RoundedRectImageView
- private lateinit var secondLargeImage: RoundedRectImageView
- private lateinit var secondSmallImage: RoundedRectImageView
- private lateinit var thirdImage: RoundedRectImageView
-
- private var loadImageJob: Job? = null
- private var onTransitionViewReadyCallback: Consumer<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 {
/**
* Specifies a transition animation target name and a readiness callback. The callback will be
@@ -78,101 +33,7 @@
* @param onViewReady, a callback that will be invoked with `true` if the view is ready to
* receive transition animation (the image was loaded successfully) and with `false` otherwise.
*/
- fun setSharedElementTransitionTarget(name: String, onViewReady: Consumer<Boolean>) {
- mainImage.transitionName = name
- onTransitionViewReadyCallback = onViewReady
- }
+ fun setSharedElementTransitionTarget(name: String, onViewReady: Consumer<Boolean>)
- 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
- }
+ fun setImages(uris: List<Uri>, imageLoader: ImageLoader)
}
diff --git a/java/tests/src/com/android/intentresolver/UnbundledChooserActivityTest.java b/java/tests/src/com/android/intentresolver/UnbundledChooserActivityTest.java
index c2d3f21..d7af892 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 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;
@@ -2191,6 +2192,72 @@
/* 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 5756a0c..0c817cb 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.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.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 @@
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 @@
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 @@
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 @@
}
@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 @@
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 @@
}
@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 @@
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 @@
}
@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 @@
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 @@
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)