summaryrefslogtreecommitdiff
path: root/java/src
diff options
context:
space:
mode:
author Xin Li <delphij@google.com> 2023-04-18 16:34:38 -0700
committer Xin Li <delphij@google.com> 2023-04-18 16:34:38 -0700
commit4042e26988acfecda45dbcc4d01ac1be7813b42e (patch)
tree970a428d34bfaad9fd8e70ad12619586033d18a7 /java/src
parentd36ad81bb94eb3178d53a227c98692e559465ea2 (diff)
parent4bc99bb4b351fe2304b3c7e147248a28c507ae57 (diff)
Merge Android 13 QPR3 tm-qpr-dev-plus-aosp-without-vendor@9936994
Bug: 275386652 Merged-In: I392de610b3d3e044e23c83d29fd11061fbc7192d Change-Id: Ib64b6b991713c518faaab01935cad9e8a57e0d98
Diffstat (limited to 'java/src')
-rw-r--r--java/src/com/android/intentresolver/AbstractMultiProfilePagerAdapter.java42
-rw-r--r--java/src/com/android/intentresolver/AnnotatedUserHandles.java113
-rw-r--r--java/src/com/android/intentresolver/ChooserActionFactory.java515
-rw-r--r--java/src/com/android/intentresolver/ChooserActivity.java661
-rw-r--r--java/src/com/android/intentresolver/ChooserActivityLogger.java63
-rw-r--r--java/src/com/android/intentresolver/ChooserContentPreviewCoordinator.java132
-rw-r--r--java/src/com/android/intentresolver/ChooserContentPreviewUi.java566
-rw-r--r--java/src/com/android/intentresolver/ChooserIntegratedDeviceComponents.java83
-rw-r--r--java/src/com/android/intentresolver/ChooserListAdapter.java3
-rw-r--r--java/src/com/android/intentresolver/ChooserMultiProfilePagerAdapter.java12
-rw-r--r--java/src/com/android/intentresolver/ChooserRefinementManager.java194
-rw-r--r--java/src/com/android/intentresolver/ChooserRequestParameters.java73
-rw-r--r--java/src/com/android/intentresolver/EnterTransitionAnimationDelegate.kt58
-rw-r--r--java/src/com/android/intentresolver/GenericMultiProfilePagerAdapter.java4
-rw-r--r--java/src/com/android/intentresolver/ImageLoader.kt26
-rw-r--r--java/src/com/android/intentresolver/ImagePreviewImageLoader.kt79
-rw-r--r--java/src/com/android/intentresolver/NoAppsAvailableEmptyStateProvider.java6
-rw-r--r--java/src/com/android/intentresolver/ResolvedComponentInfo.java105
-rw-r--r--java/src/com/android/intentresolver/ResolverActivity.java1516
-rw-r--r--java/src/com/android/intentresolver/ResolverListAdapter.java33
-rw-r--r--java/src/com/android/intentresolver/ResolverListController.java77
-rw-r--r--java/src/com/android/intentresolver/ResolverMultiProfilePagerAdapter.java12
-rw-r--r--java/src/com/android/intentresolver/SecureSettings.kt29
-rw-r--r--java/src/com/android/intentresolver/WorkProfileAvailabilityManager.java166
-rw-r--r--java/src/com/android/intentresolver/WorkProfilePausedEmptyStateProvider.java13
-rw-r--r--java/src/com/android/intentresolver/chooser/DisplayResolveInfo.java60
-rw-r--r--java/src/com/android/intentresolver/chooser/ImmutableTargetInfo.java633
-rw-r--r--java/src/com/android/intentresolver/chooser/MultiDisplayResolveInfo.java44
-rw-r--r--java/src/com/android/intentresolver/chooser/NotSelectableTargetInfo.java139
-rw-r--r--java/src/com/android/intentresolver/chooser/SelectableTargetInfo.java70
-rw-r--r--java/src/com/android/intentresolver/chooser/TargetInfo.java42
-rw-r--r--java/src/com/android/intentresolver/contentpreview/ChooserContentPreviewUi.java310
-rw-r--r--java/src/com/android/intentresolver/contentpreview/ContentPreviewType.java35
-rw-r--r--java/src/com/android/intentresolver/contentpreview/ContentPreviewUi.java130
-rw-r--r--java/src/com/android/intentresolver/contentpreview/FileContentPreviewUi.java236
-rw-r--r--java/src/com/android/intentresolver/contentpreview/ImageContentPreviewUi.java179
-rw-r--r--java/src/com/android/intentresolver/contentpreview/IsHttpUri.kt27
-rw-r--r--java/src/com/android/intentresolver/contentpreview/NoContextPreviewUi.kt33
-rw-r--r--java/src/com/android/intentresolver/contentpreview/TextContentPreviewUi.java138
-rw-r--r--java/src/com/android/intentresolver/flags/DeviceConfigProxy.kt33
-rw-r--r--java/src/com/android/intentresolver/flags/FeatureFlagRepository.kt25
-rw-r--r--java/src/com/android/intentresolver/flags/Flags.kt55
-rw-r--r--java/src/com/android/intentresolver/model/AbstractResolverComparator.java2
-rw-r--r--java/src/com/android/intentresolver/model/AppPredictionServiceResolverComparator.java2
-rw-r--r--java/src/com/android/intentresolver/model/ResolverRankerServiceResolverComparator.java2
-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.kt163
-rw-r--r--java/src/com/android/intentresolver/widget/ImagePreviewView.kt173
-rw-r--r--java/src/com/android/intentresolver/widget/RecyclerViewExtensions.kt36
-rw-r--r--java/src/com/android/intentresolver/widget/ScrollableActionRow.kt22
-rw-r--r--java/src/com/android/intentresolver/widget/ScrollableImagePreviewView.kt178
-rw-r--r--java/src/com/android/intentresolver/widget/ViewExtensions.kt39
53 files changed, 5189 insertions, 2950 deletions
diff --git a/java/src/com/android/intentresolver/AbstractMultiProfilePagerAdapter.java b/java/src/com/android/intentresolver/AbstractMultiProfilePagerAdapter.java
index 17dbb8f2..e3f1b233 100644
--- a/java/src/com/android/intentresolver/AbstractMultiProfilePagerAdapter.java
+++ b/java/src/com/android/intentresolver/AbstractMultiProfilePagerAdapter.java
@@ -40,6 +40,7 @@ import java.util.HashSet;
import java.util.List;
import java.util.Objects;
import java.util.Set;
+import java.util.function.Supplier;
/**
* Skeletal {@link PagerAdapter} implementation of a work or personal profile page for
@@ -61,22 +62,20 @@ public abstract class AbstractMultiProfilePagerAdapter extends PagerAdapter {
private Set<Integer> mLoadedPages;
private final EmptyStateProvider mEmptyStateProvider;
private final UserHandle mWorkProfileUserHandle;
- private final QuietModeManager mQuietModeManager;
+ private final Supplier<Boolean> mWorkProfileQuietModeChecker; // True when work is quiet.
- AbstractMultiProfilePagerAdapter(Context context, int currentPage,
+ AbstractMultiProfilePagerAdapter(
+ Context context,
+ int currentPage,
EmptyStateProvider emptyStateProvider,
- QuietModeManager quietModeManager,
+ Supplier<Boolean> workProfileQuietModeChecker,
UserHandle workProfileUserHandle) {
mContext = Objects.requireNonNull(context);
mCurrentPage = currentPage;
mLoadedPages = new HashSet<>();
mWorkProfileUserHandle = workProfileUserHandle;
mEmptyStateProvider = emptyStateProvider;
- mQuietModeManager = quietModeManager;
- }
-
- private boolean isQuietModeEnabled(UserHandle workProfileUserHandle) {
- return mQuietModeManager.isQuietModeEnabled(workProfileUserHandle);
+ mWorkProfileQuietModeChecker = workProfileQuietModeChecker;
}
void setOnProfileSelectedListener(OnProfileSelectedListener listener) {
@@ -433,7 +432,7 @@ public abstract class AbstractMultiProfilePagerAdapter extends PagerAdapter {
int count = listAdapter.getUnfilteredCount();
return (count == 0 && listAdapter.getPlaceholderCount() == 0)
|| (listAdapter.getUserHandle().equals(mWorkProfileUserHandle)
- && isQuietModeEnabled(mWorkProfileUserHandle));
+ && mWorkProfileQuietModeChecker.get());
}
protected static class ProfileDescriptor {
@@ -573,29 +572,4 @@ public abstract class AbstractMultiProfilePagerAdapter extends PagerAdapter {
*/
void onSwitchOnWorkSelected();
}
-
- /**
- * Describes an injector to be used for cross profile functionality. Overridable for testing.
- */
- public interface QuietModeManager {
- /**
- * Returns whether the given profile is in quiet mode or not.
- */
- boolean isQuietModeEnabled(UserHandle workProfileUserHandle);
-
- /**
- * Enables or disables quiet mode for a managed profile.
- */
- void requestQuietModeEnabled(boolean enabled, UserHandle workProfileUserHandle);
-
- /**
- * Should be called when the work profile enabled broadcast received
- */
- void markWorkProfileEnabledBroadcastReceived();
-
- /**
- * Returns true if enabling of work profile is in progress
- */
- boolean isWaitingToEnableWorkProfile();
- }
}
diff --git a/java/src/com/android/intentresolver/AnnotatedUserHandles.java b/java/src/com/android/intentresolver/AnnotatedUserHandles.java
new file mode 100644
index 00000000..b4365b84
--- /dev/null
+++ b/java/src/com/android/intentresolver/AnnotatedUserHandles.java
@@ -0,0 +1,113 @@
+/*
+ * Copyright (C) 2022 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.intentresolver;
+
+import android.annotation.Nullable;
+import android.app.Activity;
+import android.app.ActivityManager;
+import android.os.UserHandle;
+import android.os.UserManager;
+
+/**
+ * Helper class to precompute the (immutable) designations of various user handles in the system
+ * that may contribute to the current Sharesheet session.
+ */
+public final class AnnotatedUserHandles {
+ /** The user id of the app that started the share activity. */
+ public final int userIdOfCallingApp;
+
+ /**
+ * The {@link UserHandle} that launched Sharesheet.
+ * TODO: I believe this would always be the handle corresponding to {@code userIdOfCallingApp}
+ * except possibly if the caller used {@link Activity#startActivityAsUser()} to launch
+ * Sharesheet as a different user than they themselves were running as. Verify and document.
+ */
+ public final UserHandle userHandleSharesheetLaunchedAs;
+
+ /**
+ * The {@link UserHandle} that owns the "personal tab" in a tabbed share UI (or the *only* 'tab'
+ * in a non-tabbed UI).
+ *
+ * This is never a work or clone user, but may either be the root user (0) or a "secondary"
+ * multi-user profile (i.e., one that's not root, work, nor clone). This is a "secondary"
+ * profile only when that user is the active "foreground" user.
+ *
+ * In the current implementation, we can assert that this is the root user (0) any time we
+ * display a tabbed UI (i.e., any time `workProfileUserHandle` is non-null), or any time that we
+ * have a clone profile. This note is only provided for informational purposes; clients should
+ * avoid making any reliances on that assumption.
+ */
+ public final UserHandle personalProfileUserHandle;
+
+ /**
+ * The {@link UserHandle} that owns the "work tab" in a tabbed share UI. This is (an arbitrary)
+ * one of the "managed" profiles associated with {@link personalProfileUserHandle}.
+ */
+ @Nullable
+ public final UserHandle workProfileUserHandle;
+
+ /**
+ * The {@link UserHandle} of the clone profile belonging to {@link personalProfileUserHandle}.
+ */
+ @Nullable
+ public final UserHandle cloneProfileUserHandle;
+
+ /**
+ * The "tab owner" user handle (i.e., either {@link personalProfileUserHandle} or
+ * {@link workProfileUserHandle}) that either matches or owns the profile of the
+ * {@link userHandleSharesheetLaunchedAs}.
+ *
+ * In the current implementation, we can assert that this is the same as
+ * `userHandleSharesheetLaunchedAs` except when the latter is the clone profile; then this is
+ * the "personal" profile owning that clone profile (which we currently know must belong to
+ * user 0, but clients should avoid making any reliances on that assumption).
+ */
+ public final UserHandle tabOwnerUserHandleForLaunch;
+
+ public AnnotatedUserHandles(Activity forShareActivity) {
+ userIdOfCallingApp = forShareActivity.getLaunchedFromUid();
+ if ((userIdOfCallingApp < 0) || UserHandle.isIsolated(userIdOfCallingApp)) {
+ throw new SecurityException("Can't start a resolver from uid " + userIdOfCallingApp);
+ }
+
+ // TODO: integrate logic for `ResolverActivity.EXTRA_CALLING_USER`.
+ userHandleSharesheetLaunchedAs = UserHandle.of(UserHandle.myUserId());
+
+ personalProfileUserHandle = UserHandle.of(ActivityManager.getCurrentUser());
+
+ UserManager userManager = forShareActivity.getSystemService(UserManager.class);
+ workProfileUserHandle = getWorkProfileForUser(userManager, personalProfileUserHandle);
+ cloneProfileUserHandle = getCloneProfileForUser(userManager, personalProfileUserHandle);
+
+ tabOwnerUserHandleForLaunch = (userHandleSharesheetLaunchedAs == workProfileUserHandle)
+ ? workProfileUserHandle : personalProfileUserHandle;
+ }
+
+ @Nullable
+ private static UserHandle getWorkProfileForUser(
+ UserManager userManager, UserHandle profileOwnerUserHandle) {
+ return userManager.getProfiles(profileOwnerUserHandle.getIdentifier()).stream()
+ .filter(info -> info.isManagedProfile()).findFirst()
+ .map(info -> info.getUserHandle()).orElse(null);
+ }
+
+ @Nullable
+ private static UserHandle getCloneProfileForUser(
+ UserManager userManager, UserHandle profileOwnerUserHandle) {
+ return null; // Not yet supported in framework.
+ }
+}
diff --git a/java/src/com/android/intentresolver/ChooserActionFactory.java b/java/src/com/android/intentresolver/ChooserActionFactory.java
new file mode 100644
index 00000000..947155f3
--- /dev/null
+++ b/java/src/com/android/intentresolver/ChooserActionFactory.java
@@ -0,0 +1,515 @@
+/*
+ * Copyright (C) 2023 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.intentresolver;
+
+import android.annotation.Nullable;
+import android.app.Activity;
+import android.app.ActivityOptions;
+import android.app.PendingIntent;
+import android.content.ClipData;
+import android.content.ClipboardManager;
+import android.content.ComponentName;
+import android.content.Context;
+import android.content.Intent;
+import android.content.pm.PackageManager;
+import android.content.pm.PackageManager.NameNotFoundException;
+import android.content.pm.ResolveInfo;
+import android.content.res.Resources;
+import android.graphics.drawable.Drawable;
+import android.net.Uri;
+import android.os.Bundle;
+import android.service.chooser.ChooserAction;
+import android.text.TextUtils;
+import android.util.Log;
+import android.view.View;
+
+import com.android.intentresolver.chooser.DisplayResolveInfo;
+import com.android.intentresolver.chooser.TargetInfo;
+import com.android.intentresolver.contentpreview.ChooserContentPreviewUi;
+import com.android.intentresolver.flags.FeatureFlagRepository;
+import com.android.intentresolver.flags.Flags;
+import com.android.intentresolver.widget.ActionRow;
+import com.android.internal.annotations.VisibleForTesting;
+
+import com.google.common.collect.ImmutableList;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.concurrent.Callable;
+import java.util.function.Consumer;
+
+/**
+ * Implementation of {@link ChooserContentPreviewUi.ActionFactory} specialized to the application
+ * requirements of Sharesheet / {@link ChooserActivity}.
+ */
+public final class ChooserActionFactory implements ChooserContentPreviewUi.ActionFactory {
+ /** Delegate interface to launch activities when the actions are selected. */
+ public interface ActionActivityStarter {
+ /**
+ * Request an activity launch for the provided target. Implementations may choose to exit
+ * the current activity when the target is launched.
+ */
+ void safelyStartActivityAsPersonalProfileUser(TargetInfo info);
+
+ /**
+ * Request an activity launch for the provided target, optionally employing the specified
+ * shared element transition. Implementations may choose to exit the current activity when
+ * the target is launched.
+ */
+ default void safelyStartActivityAsPersonalProfileUserWithSharedElementTransition(
+ TargetInfo info, View sharedElement, String sharedElementName) {
+ safelyStartActivityAsPersonalProfileUser(info);
+ }
+ }
+
+ private static final String TAG = "ChooserActions";
+
+ private static final int URI_PERMISSION_INTENT_FLAGS = Intent.FLAG_GRANT_READ_URI_PERMISSION
+ | Intent.FLAG_GRANT_WRITE_URI_PERMISSION
+ | Intent.FLAG_GRANT_PERSISTABLE_URI_PERMISSION
+ | Intent.FLAG_GRANT_PREFIX_URI_PERMISSION;
+
+ private static final String CHIP_LABEL_METADATA_KEY = "android.service.chooser.chip_label";
+ private static final String CHIP_ICON_METADATA_KEY = "android.service.chooser.chip_icon";
+
+ private static final String IMAGE_EDITOR_SHARED_ELEMENT = "screenshot_preview_image";
+
+ private final Context mContext;
+ private final String mCopyButtonLabel;
+ private final Drawable mCopyButtonDrawable;
+ private final Runnable mOnCopyButtonClicked;
+ private final TargetInfo mEditSharingTarget;
+ private final Runnable mOnEditButtonClicked;
+ private final TargetInfo mNearbySharingTarget;
+ private final Runnable mOnNearbyButtonClicked;
+ private final ImmutableList<ChooserAction> mCustomActions;
+ private final Runnable mOnModifyShareClicked;
+ private final Consumer<Boolean> mExcludeSharedTextAction;
+ private final Consumer</* @Nullable */ Integer> mFinishCallback;
+ private final ChooserActivityLogger mLogger;
+
+ /**
+ * @param context
+ * @param chooserRequest data about the invocation of the current Sharesheet session.
+ * @param featureFlagRepository feature flags that may control the eligibility of some actions.
+ * @param integratedDeviceComponents info about other components that are available on this
+ * device to implement the supported action types.
+ * @param onUpdateSharedTextIsExcluded a delegate to be invoked when the "exclude shared text"
+ * setting is updated. The argument is whether the shared text is to be excluded.
+ * @param firstVisibleImageQuery a delegate that provides a reference to the first visible image
+ * View in the Sharesheet UI, if any, or null.
+ * @param activityStarter a delegate to launch activities when actions are selected.
+ * @param finishCallback a delegate to close the Sharesheet UI (e.g. because some action was
+ * completed).
+ */
+ public ChooserActionFactory(
+ Context context,
+ ChooserRequestParameters chooserRequest,
+ FeatureFlagRepository featureFlagRepository,
+ ChooserIntegratedDeviceComponents integratedDeviceComponents,
+ ChooserActivityLogger logger,
+ Consumer<Boolean> onUpdateSharedTextIsExcluded,
+ Callable</* @Nullable */ View> firstVisibleImageQuery,
+ ActionActivityStarter activityStarter,
+ Consumer</* @Nullable */ Integer> finishCallback) {
+ this(
+ context,
+ context.getString(com.android.internal.R.string.copy),
+ context.getDrawable(com.android.internal.R.drawable.ic_menu_copy_material),
+ makeOnCopyRunnable(
+ context,
+ chooserRequest.getTargetIntent(),
+ chooserRequest.getReferrerPackageName(),
+ finishCallback,
+ logger),
+ getEditSharingTarget(
+ context,
+ chooserRequest.getTargetIntent(),
+ integratedDeviceComponents),
+ makeOnEditRunnable(
+ getEditSharingTarget(
+ context,
+ chooserRequest.getTargetIntent(),
+ integratedDeviceComponents),
+ firstVisibleImageQuery,
+ activityStarter,
+ logger),
+ getNearbySharingTarget(
+ context,
+ chooserRequest.getTargetIntent(),
+ integratedDeviceComponents),
+ makeOnNearbyShareRunnable(
+ getNearbySharingTarget(
+ context,
+ chooserRequest.getTargetIntent(),
+ integratedDeviceComponents),
+ activityStarter,
+ finishCallback,
+ logger),
+ chooserRequest.getChooserActions(),
+ (featureFlagRepository.isEnabled(Flags.SHARESHEET_RESELECTION_ACTION)
+ ? createModifyShareRunnable(
+ chooserRequest.getModifyShareAction(),
+ finishCallback,
+ logger)
+ : null),
+ onUpdateSharedTextIsExcluded,
+ logger,
+ finishCallback);
+ }
+
+ @VisibleForTesting
+ ChooserActionFactory(
+ Context context,
+ String copyButtonLabel,
+ Drawable copyButtonDrawable,
+ Runnable onCopyButtonClicked,
+ TargetInfo editSharingTarget,
+ Runnable onEditButtonClicked,
+ TargetInfo nearbySharingTarget,
+ Runnable onNearbyButtonClicked,
+ List<ChooserAction> customActions,
+ @Nullable Runnable onModifyShareClicked,
+ Consumer<Boolean> onUpdateSharedTextIsExcluded,
+ ChooserActivityLogger logger,
+ Consumer</* @Nullable */ Integer> finishCallback) {
+ mContext = context;
+ mCopyButtonLabel = copyButtonLabel;
+ mCopyButtonDrawable = copyButtonDrawable;
+ mOnCopyButtonClicked = onCopyButtonClicked;
+ mEditSharingTarget = editSharingTarget;
+ mOnEditButtonClicked = onEditButtonClicked;
+ mNearbySharingTarget = nearbySharingTarget;
+ mOnNearbyButtonClicked = onNearbyButtonClicked;
+ mCustomActions = ImmutableList.copyOf(customActions);
+ mOnModifyShareClicked = onModifyShareClicked;
+ mExcludeSharedTextAction = onUpdateSharedTextIsExcluded;
+ mLogger = logger;
+ mFinishCallback = finishCallback;
+ }
+
+ /** Create an action that copies the share content to the clipboard. */
+ @Override
+ public ActionRow.Action createCopyButton() {
+ return new ActionRow.Action(
+ com.android.internal.R.id.chooser_copy_button,
+ mCopyButtonLabel,
+ mCopyButtonDrawable,
+ mOnCopyButtonClicked);
+ }
+
+ /** Create an action that opens the share content in a system-default editor. */
+ @Override
+ @Nullable
+ public ActionRow.Action createEditButton() {
+ if (mEditSharingTarget == null) {
+ return null;
+ }
+
+ return new ActionRow.Action(
+ com.android.internal.R.id.chooser_edit_button,
+ mEditSharingTarget.getDisplayLabel(),
+ mEditSharingTarget.getDisplayIconHolder().getDisplayIcon(),
+ mOnEditButtonClicked);
+ }
+
+ /** Create a "Share to Nearby" action. */
+ @Override
+ @Nullable
+ public ActionRow.Action createNearbyButton() {
+ if (mNearbySharingTarget == null) {
+ return null;
+ }
+
+ return new ActionRow.Action(
+ com.android.internal.R.id.chooser_nearby_button,
+ mNearbySharingTarget.getDisplayLabel(),
+ mNearbySharingTarget.getDisplayIconHolder().getDisplayIcon(),
+ mOnNearbyButtonClicked);
+ }
+
+ /** Create custom actions */
+ @Override
+ public List<ActionRow.Action> createCustomActions() {
+ List<ActionRow.Action> actions = new ArrayList<>();
+ for (int i = 0; i < mCustomActions.size(); i++) {
+ ActionRow.Action actionRow = createCustomAction(
+ mContext, mCustomActions.get(i), mFinishCallback, i, mLogger);
+ if (actionRow != null) {
+ actions.add(actionRow);
+ }
+ }
+ return actions;
+ }
+
+ /**
+ * Provides a share modification action, if any.
+ */
+ @Override
+ @Nullable
+ public Runnable getModifyShareAction() {
+ return mOnModifyShareClicked;
+ }
+
+ private static Runnable createModifyShareRunnable(
+ PendingIntent pendingIntent,
+ Consumer<Integer> finishCallback,
+ ChooserActivityLogger logger) {
+ if (pendingIntent == null) {
+ return null;
+ }
+
+ return () -> {
+ try {
+ pendingIntent.send();
+ } catch (PendingIntent.CanceledException e) {
+ Log.d(TAG, "Payload reselection action has been cancelled");
+ }
+ logger.logActionSelected(ChooserActivityLogger.SELECTION_TYPE_MODIFY_SHARE);
+ finishCallback.accept(Activity.RESULT_OK);
+ };
+ }
+
+ /**
+ * <p>
+ * Creates an exclude-text action that can be called when the user changes shared text
+ * status in the Media + Text preview.
+ * </p>
+ * <p>
+ * <code>true</code> argument value indicates that the text should be excluded.
+ * </p>
+ */
+ @Override
+ public Consumer<Boolean> getExcludeSharedTextAction() {
+ return mExcludeSharedTextAction;
+ }
+
+ private static Runnable makeOnCopyRunnable(
+ Context context,
+ Intent targetIntent,
+ String referrerPackageName,
+ Consumer<Integer> finishCallback,
+ ChooserActivityLogger logger) {
+ return () -> {
+ if (targetIntent == null) {
+ finishCallback.accept(null);
+ return;
+ }
+
+ final String action = targetIntent.getAction();
+
+ ClipData clipData = null;
+ if (Intent.ACTION_SEND.equals(action)) {
+ String extraText = targetIntent.getStringExtra(Intent.EXTRA_TEXT);
+ Uri extraStream = targetIntent.getParcelableExtra(Intent.EXTRA_STREAM);
+
+ if (extraText != null) {
+ clipData = ClipData.newPlainText(null, extraText);
+ } else if (extraStream != null) {
+ clipData = ClipData.newUri(context.getContentResolver(), null, extraStream);
+ } else {
+ Log.w(TAG, "No data available to copy to clipboard");
+ return;
+ }
+ } else if (Intent.ACTION_SEND_MULTIPLE.equals(action)) {
+ final ArrayList<Uri> streams = targetIntent.getParcelableArrayListExtra(
+ Intent.EXTRA_STREAM);
+ clipData = ClipData.newUri(context.getContentResolver(), null, streams.get(0));
+ for (int i = 1; i < streams.size(); i++) {
+ clipData.addItem(
+ context.getContentResolver(),
+ new ClipData.Item(streams.get(i)));
+ }
+ } else {
+ // expected to only be visible with ACTION_SEND or ACTION_SEND_MULTIPLE
+ // so warn about unexpected action
+ Log.w(TAG, "Action (" + action + ") not supported for copying to clipboard");
+ return;
+ }
+
+ ClipboardManager clipboardManager = (ClipboardManager) context.getSystemService(
+ Context.CLIPBOARD_SERVICE);
+ clipboardManager.setPrimaryClipAsPackage(clipData, referrerPackageName);
+
+ logger.logActionSelected(ChooserActivityLogger.SELECTION_TYPE_COPY);
+ finishCallback.accept(Activity.RESULT_OK);
+ };
+ }
+
+ private static TargetInfo getEditSharingTarget(
+ Context context,
+ Intent originalIntent,
+ ChooserIntegratedDeviceComponents integratedComponents) {
+ final ComponentName editorComponent = integratedComponents.getEditSharingComponent();
+
+ final Intent resolveIntent = new Intent(originalIntent);
+ // Retain only URI permission grant flags if present. Other flags may prevent the scene
+ // transition animation from running (i.e FLAG_ACTIVITY_NO_ANIMATION,
+ // FLAG_ACTIVITY_NEW_TASK, FLAG_ACTIVITY_NEW_DOCUMENT) but also not needed.
+ resolveIntent.setFlags(originalIntent.getFlags() & URI_PERMISSION_INTENT_FLAGS);
+ resolveIntent.setComponent(editorComponent);
+ resolveIntent.setAction(Intent.ACTION_EDIT);
+ String originalAction = originalIntent.getAction();
+ if (Intent.ACTION_SEND.equals(originalAction)) {
+ if (resolveIntent.getData() == null) {
+ Uri uri = resolveIntent.getParcelableExtra(Intent.EXTRA_STREAM);
+ if (uri != null) {
+ String mimeType = context.getContentResolver().getType(uri);
+ resolveIntent.setDataAndType(uri, mimeType);
+ }
+ }
+ } else {
+ Log.e(TAG, originalAction + " is not supported.");
+ return null;
+ }
+ final ResolveInfo ri = context.getPackageManager().resolveActivity(
+ resolveIntent, PackageManager.GET_META_DATA);
+ if (ri == null || ri.activityInfo == null) {
+ Log.e(TAG, "Device-specified editor (" + editorComponent + ") not available");
+ return null;
+ }
+
+ final DisplayResolveInfo dri = DisplayResolveInfo.newDisplayResolveInfo(
+ originalIntent,
+ ri,
+ context.getString(com.android.internal.R.string.screenshot_edit),
+ "",
+ resolveIntent,
+ null);
+ dri.getDisplayIconHolder().setDisplayIcon(
+ context.getDrawable(com.android.internal.R.drawable.ic_screenshot_edit));
+ return dri;
+ }
+
+ private static Runnable makeOnEditRunnable(
+ TargetInfo editSharingTarget,
+ Callable</* @Nullable */ View> firstVisibleImageQuery,
+ ActionActivityStarter activityStarter,
+ ChooserActivityLogger logger) {
+ return () -> {
+ // Log share completion via edit.
+ logger.logActionSelected(ChooserActivityLogger.SELECTION_TYPE_EDIT);
+
+ View firstImageView = null;
+ try {
+ firstImageView = firstVisibleImageQuery.call();
+ } catch (Exception e) { /* ignore */ }
+ // Action bar is user-independent; always start as primary.
+ if (firstImageView == null) {
+ activityStarter.safelyStartActivityAsPersonalProfileUser(editSharingTarget);
+ } else {
+ activityStarter.safelyStartActivityAsPersonalProfileUserWithSharedElementTransition(
+ editSharingTarget, firstImageView, IMAGE_EDITOR_SHARED_ELEMENT);
+ }
+ };
+ }
+
+ private static TargetInfo getNearbySharingTarget(
+ Context context,
+ Intent originalIntent,
+ ChooserIntegratedDeviceComponents integratedComponents) {
+ final ComponentName cn = integratedComponents.getNearbySharingComponent();
+ if (cn == null) {
+ return null;
+ }
+
+ final Intent resolveIntent = new Intent(originalIntent);
+ resolveIntent.setComponent(cn);
+ final ResolveInfo ri = context.getPackageManager().resolveActivity(
+ resolveIntent, PackageManager.GET_META_DATA);
+ if (ri == null || ri.activityInfo == null) {
+ Log.e(TAG, "Device-specified nearby sharing component (" + cn
+ + ") not available");
+ return null;
+ }
+
+ // Allow the nearby sharing component to provide a more appropriate icon and label
+ // for the chip.
+ CharSequence name = null;
+ Drawable icon = null;
+ final Bundle metaData = ri.activityInfo.metaData;
+ if (metaData != null) {
+ try {
+ final Resources pkgRes = context.getPackageManager().getResourcesForActivity(cn);
+ final int nameResId = metaData.getInt(CHIP_LABEL_METADATA_KEY);
+ name = pkgRes.getString(nameResId);
+ final int resId = metaData.getInt(CHIP_ICON_METADATA_KEY);
+ icon = pkgRes.getDrawable(resId);
+ } catch (NameNotFoundException | Resources.NotFoundException ex) { /* ignore */ }
+ }
+ if (TextUtils.isEmpty(name)) {
+ name = ri.loadLabel(context.getPackageManager());
+ }
+ if (icon == null) {
+ icon = ri.loadIcon(context.getPackageManager());
+ }
+
+ final DisplayResolveInfo dri = DisplayResolveInfo.newDisplayResolveInfo(
+ originalIntent, ri, name, "", resolveIntent, null);
+ dri.getDisplayIconHolder().setDisplayIcon(icon);
+ return dri;
+ }
+
+ private static Runnable makeOnNearbyShareRunnable(
+ TargetInfo nearbyShareTarget,
+ ActionActivityStarter activityStarter,
+ Consumer<Integer> finishCallback,
+ ChooserActivityLogger logger) {
+ return () -> {
+ logger.logActionSelected(ChooserActivityLogger.SELECTION_TYPE_NEARBY);
+ // Action bar is user-independent; always start as primary.
+ activityStarter.safelyStartActivityAsPersonalProfileUser(nearbyShareTarget);
+ };
+ }
+
+ @Nullable
+ private static ActionRow.Action createCustomAction(
+ Context context,
+ ChooserAction action,
+ Consumer<Integer> finishCallback,
+ int position,
+ ChooserActivityLogger logger) {
+ Drawable icon = action.getIcon().loadDrawable(context);
+ if (icon == null && TextUtils.isEmpty(action.getLabel())) {
+ return null;
+ }
+ return new ActionRow.Action(
+ action.getLabel(),
+ icon,
+ () -> {
+ try {
+ action.getAction().send(
+ null,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ ActivityOptions.makeCustomAnimation(
+ context,
+ R.anim.slide_in_right,
+ R.anim.slide_out_left)
+ .toBundle());
+ } catch (PendingIntent.CanceledException e) {
+ Log.d(TAG, "Custom action, " + action.getLabel() + ", has been cancelled");
+ }
+ logger.logCustomActionSelected(position);
+ finishCallback.accept(Activity.RESULT_OK);
+ }
+ );
+ }
+}
diff --git a/java/src/com/android/intentresolver/ChooserActivity.java b/java/src/com/android/intentresolver/ChooserActivity.java
index ceab62b2..ae5be26d 100644
--- a/java/src/com/android/intentresolver/ChooserActivity.java
+++ b/java/src/com/android/intentresolver/ChooserActivity.java
@@ -36,44 +36,30 @@ import android.app.prediction.AppPredictor;
import android.app.prediction.AppTarget;
import android.app.prediction.AppTargetEvent;
import android.app.prediction.AppTargetId;
-import android.content.ClipData;
-import android.content.ClipboardManager;
import android.content.ComponentName;
import android.content.ContentResolver;
import android.content.Context;
import android.content.Intent;
import android.content.IntentFilter;
import android.content.IntentSender;
-import android.content.IntentSender.SendIntentException;
import android.content.SharedPreferences;
import android.content.pm.ActivityInfo;
import android.content.pm.PackageManager;
-import android.content.pm.PackageManager.NameNotFoundException;
import android.content.pm.ResolveInfo;
import android.content.pm.ShortcutInfo;
import android.content.res.Configuration;
-import android.content.res.Resources;
import android.database.Cursor;
-import android.graphics.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.Parcelable;
-import android.os.PatternMatcher;
-import android.os.ResultReceiver;
import android.os.SystemClock;
import android.os.UserHandle;
import android.os.UserManager;
import android.os.storage.StorageManager;
import android.provider.DeviceConfig;
-import android.provider.Settings;
import android.service.chooser.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;
@@ -97,6 +83,10 @@ import com.android.intentresolver.NoCrossProfileEmptyStateProvider.DevicePolicyB
import com.android.intentresolver.chooser.DisplayResolveInfo;
import com.android.intentresolver.chooser.MultiDisplayResolveInfo;
import com.android.intentresolver.chooser.TargetInfo;
+import com.android.intentresolver.contentpreview.ChooserContentPreviewUi;
+import com.android.intentresolver.flags.FeatureFlagRepository;
+import com.android.intentresolver.flags.FeatureFlagRepositoryFactory;
+import com.android.intentresolver.flags.Flags;
import com.android.intentresolver.grid.ChooserGridAdapter;
import com.android.intentresolver.grid.DirectShareViewHolder;
import com.android.intentresolver.model.AbstractResolverComparator;
@@ -104,16 +94,13 @@ import com.android.intentresolver.model.AppPredictionServiceResolverComparator;
import com.android.intentresolver.model.ResolverRankerServiceResolverComparator;
import com.android.intentresolver.shortcuts.AppPredictorFactory;
import com.android.intentresolver.shortcuts.ShortcutLoader;
-import com.android.intentresolver.widget.ActionRow;
import com.android.intentresolver.widget.ResolverDrawerLayout;
import com.android.internal.annotations.VisibleForTesting;
import com.android.internal.config.sysui.SystemUiDeviceConfigFlags;
import com.android.internal.content.PackageMonitor;
import com.android.internal.logging.nano.MetricsProto.MetricsEvent;
-import com.android.internal.util.FrameworkStatsLog;
import java.io.File;
-import java.io.IOException;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.text.Collator;
@@ -205,6 +192,8 @@ public class ChooserActivity extends ResolverActivity implements
| Intent.FLAG_GRANT_PERSISTABLE_URI_PERMISSION
| Intent.FLAG_GRANT_PREFIX_URI_PERMISSION;
+ private ChooserIntegratedDeviceComponents mIntegratedDeviceComponents;
+
/* TODO: this is `nullable` because we have to defer the assignment til onCreate(). We make the
* only assignment there, and expect it to be ready by the time we ever use it --
* someday if we move all the usage to a component with a narrower lifecycle (something that
@@ -214,13 +203,15 @@ public class ChooserActivity extends ResolverActivity implements
@Nullable
private ChooserRequestParameters mChooserRequest;
+ private ChooserRefinementManager mRefinementManager;
+
+ private FeatureFlagRepository mFeatureFlagRepository;
+ private ChooserContentPreviewUi mChooserContentPreviewUi;
+
private boolean mShouldDisplayLandscape;
// statsd logger wrapper
protected ChooserActivityLogger mChooserActivityLogger;
- @Nullable
- private RefinementResultReceiver mRefinementResultReceiver;
-
private long mChooserShownTime;
protected boolean mIsSuccessfullySelected;
@@ -240,9 +231,6 @@ public class ChooserActivity extends ResolverActivity implements
private final ExecutorService mBackgroundThreadPoolExecutor = Executors.newFixedThreadPool(5);
- @Nullable
- private ChooserContentPreviewCoordinator mPreviewCoordinator;
-
private int mScrollStatus = SCROLL_STATUS_IDLE;
@VisibleForTesting
@@ -254,6 +242,8 @@ public class ChooserActivity extends ResolverActivity implements
private final SparseArray<ProfileRecord> mProfileRecords = new SparseArray<>();
+ private boolean mExcludeSharedText = false;
+
public ChooserActivity() {}
@Override
@@ -263,9 +253,16 @@ public class ChooserActivity extends ResolverActivity implements
getChooserActivityLogger().logSharesheetTriggered();
+ mFeatureFlagRepository = createFeatureFlagRepository();
+ mIntegratedDeviceComponents = getIntegratedDeviceComponents();
+
try {
mChooserRequest = new ChooserRequestParameters(
- getIntent(), getReferrer(), getNearbySharingComponent());
+ getIntent(),
+ getReferrerPackageName(),
+ getReferrer(),
+ mIntegratedDeviceComponents,
+ mFeatureFlagRepository);
} catch (IllegalArgumentException e) {
Log.e(TAG, "Caller provided invalid Chooser request parameters", e);
finish();
@@ -273,6 +270,29 @@ public class ChooserActivity extends ResolverActivity implements
return;
}
+ mRefinementManager = new ChooserRefinementManager(
+ this,
+ mChooserRequest.getRefinementIntentSender(),
+ (validatedRefinedTarget) -> {
+ maybeRemoveSharedText(validatedRefinedTarget);
+ if (super.onTargetSelected(validatedRefinedTarget, false)) {
+ finish();
+ }
+ },
+ () -> {
+ mRefinementManager.destroy();
+ finish();
+ });
+
+ mChooserContentPreviewUi = new ChooserContentPreviewUi(
+ mChooserRequest.getTargetIntent(),
+ getContentResolver(),
+ this::isImageType,
+ createPreviewImageLoader(),
+ createChooserActionFactory(),
+ mEnterTransitionAnimationDelegate,
+ mFeatureFlagRepository);
+
setAdditionalTargets(mChooserRequest.getAdditionalTargets());
setSafeForwardingMode(true);
@@ -291,11 +311,6 @@ public class ChooserActivity extends ResolverActivity implements
mChooserRequest.getTargetIntentFilter()),
mChooserRequest.getTargetIntentFilter());
- mPreviewCoordinator = new ChooserContentPreviewCoordinator(
- mBackgroundThreadPoolExecutor,
- this,
- () -> mEnterTransitionAnimationDelegate.markImagePreviewReady(false));
-
super.onCreate(
savedInstanceState,
mChooserRequest.getTargetIntent(),
@@ -341,26 +356,35 @@ public class ChooserActivity extends ResolverActivity implements
}
getChooserActivityLogger().logShareStarted(
- FrameworkStatsLog.SHARESHEET_STARTED,
getReferrerPackageName(),
mChooserRequest.getTargetType(),
mChooserRequest.getCallerChooserTargets().size(),
(mChooserRequest.getInitialIntents() == null)
? 0 : mChooserRequest.getInitialIntents().length,
isWorkProfile(),
- ChooserContentPreviewUi.findPreferredContentPreview(
- getTargetIntent(), getContentResolver(), this::isImageType),
- mChooserRequest.getTargetAction()
+ mChooserContentPreviewUi.getPreferredContentPreview(),
+ mChooserRequest.getTargetAction(),
+ mChooserRequest.getChooserActions().size(),
+ mChooserRequest.getModifyShareAction() != null
);
mEnterTransitionAnimationDelegate.postponeTransition();
}
+ @VisibleForTesting
+ protected ChooserIntegratedDeviceComponents getIntegratedDeviceComponents() {
+ return ChooserIntegratedDeviceComponents.get(this, new SecureSettings());
+ }
+
@Override
protected int appliedThemeResId() {
return R.style.Theme_DeviceDefault_Chooser;
}
+ protected FeatureFlagRepository createFeatureFlagRepository() {
+ return new FeatureFlagRepositoryFactory().create(getApplicationContext());
+ }
+
private void createProfileRecords(
AppPredictorFactory factory, IntentFilter targetIntentFilter) {
UserHandle mainUserHandle = getPersonalProfileUserHandle();
@@ -489,7 +513,7 @@ public class ChooserActivity extends ResolverActivity implements
/* context */ this,
adapter,
createEmptyStateProvider(/* workProfileUserHandle= */ null),
- mQuietModeManager,
+ /* workProfileQuietModeChecker= */ () -> false,
/* workProfileUserHandle= */ null,
mMaxTargetsPerRow);
}
@@ -518,7 +542,7 @@ public class ChooserActivity extends ResolverActivity implements
personalAdapter,
workAdapter,
createEmptyStateProvider(/* workProfileUserHandle= */ getWorkProfileUserHandle()),
- mQuietModeManager,
+ () -> mWorkProfileAvailability.isQuietModeEnabled(),
selectedProfile,
getWorkProfileUserHandle(),
mMaxTargetsPerRow);
@@ -539,8 +563,7 @@ public class ChooserActivity extends ResolverActivity implements
|| mChooserMultiProfilePagerAdapter
.getCurrentRootAdapter().getSystemRowCount() != 0) {
getChooserActivityLogger().logActionShareWithPreview(
- ChooserContentPreviewUi.findPreferredContentPreview(
- getTargetIntent(), getContentResolver(), this::isImageType));
+ mChooserContentPreviewUi.getPreferredContentPreview());
}
return postRebuildListInternal(rebuildCompleted);
}
@@ -591,51 +614,6 @@ public class ChooserActivity extends ResolverActivity implements
updateProfileViewButton();
}
- private void onCopyButtonClicked() {
- Intent targetIntent = getTargetIntent();
- if (targetIntent == null) {
- finish();
- } else {
- final String action = targetIntent.getAction();
-
- ClipData clipData = null;
- if (Intent.ACTION_SEND.equals(action)) {
- String extraText = targetIntent.getStringExtra(Intent.EXTRA_TEXT);
- Uri extraStream = targetIntent.getParcelableExtra(Intent.EXTRA_STREAM);
-
- if (extraText != null) {
- clipData = ClipData.newPlainText(null, extraText);
- } else if (extraStream != null) {
- clipData = ClipData.newUri(getContentResolver(), null, extraStream);
- } else {
- Log.w(TAG, "No data available to copy to clipboard");
- return;
- }
- } else if (Intent.ACTION_SEND_MULTIPLE.equals(action)) {
- final ArrayList<Uri> streams = targetIntent.getParcelableArrayListExtra(
- Intent.EXTRA_STREAM);
- clipData = ClipData.newUri(getContentResolver(), null, streams.get(0));
- for (int i = 1; i < streams.size(); i++) {
- clipData.addItem(getContentResolver(), new ClipData.Item(streams.get(i)));
- }
- } else {
- // expected to only be visible with ACTION_SEND or ACTION_SEND_MULTIPLE
- // so warn about unexpected action
- Log.w(TAG, "Action (" + action + ") not supported for copying to clipboard");
- return;
- }
-
- ClipboardManager clipboardManager = (ClipboardManager) getSystemService(
- Context.CLIPBOARD_SERVICE);
- clipboardManager.setPrimaryClipAsPackage(clipData, getReferrerPackageName());
-
- getChooserActivityLogger().logActionSelected(ChooserActivityLogger.SELECTION_TYPE_COPY);
-
- setResult(RESULT_OK);
- finish();
- }
- }
-
@Override
protected void onResume() {
super.onResume();
@@ -707,226 +685,19 @@ 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) {
- Intent targetIntent = getTargetIntent();
- int previewType = ChooserContentPreviewUi.findPreferredContentPreview(
- targetIntent, getContentResolver(), this::isImageType);
-
- ChooserContentPreviewUi.ActionFactory actionFactory =
- new ChooserContentPreviewUi.ActionFactory() {
- @Override
- public ActionRow.Action createCopyButton() {
- return ChooserActivity.this.createCopyAction();
- }
-
- @Nullable
- @Override
- public ActionRow.Action createEditButton() {
- return ChooserActivity.this.createEditAction(targetIntent);
- }
-
- @Nullable
- @Override
- public ActionRow.Action createNearbyButton() {
- return ChooserActivity.this.createNearbyAction(targetIntent);
- }
- };
-
- ViewGroup layout = ChooserContentPreviewUi.displayContentPreview(
- previewType,
- targetIntent,
+ protected ViewGroup createContentPreviewView(ViewGroup parent) {
+ ViewGroup layout = mChooserContentPreviewUi.displayContentPreview(
getResources(),
getLayoutInflater(),
- actionFactory,
- R.layout.chooser_action_row,
- parent,
- previewCoordinator,
- mEnterTransitionAnimationDelegate::markImagePreviewReady,
- getContentResolver(),
- this::isImageType);
+ parent);
if (layout != null) {
adjustPreviewWidth(getResources().getConfiguration().orientation, layout);
}
- if (previewType != ChooserContentPreviewUi.CONTENT_PREVIEW_IMAGE) {
- mEnterTransitionAnimationDelegate.markImagePreviewReady(false);
- }
return layout;
}
- @VisibleForTesting
- protected ComponentName getNearbySharingComponent() {
- String nearbyComponent = Settings.Secure.getString(
- getContentResolver(),
- Settings.Secure.NEARBY_SHARING_COMPONENT);
- if (TextUtils.isEmpty(nearbyComponent)) {
- nearbyComponent = getString(R.string.config_defaultNearbySharingComponent);
- }
- if (TextUtils.isEmpty(nearbyComponent)) {
- return null;
- }
- return ComponentName.unflattenFromString(nearbyComponent);
- }
-
- @VisibleForTesting
- protected @Nullable ComponentName getEditSharingComponent() {
- String editorPackage = getApplicationContext().getString(R.string.config_systemImageEditor);
- if (editorPackage == null || TextUtils.isEmpty(editorPackage)) {
- return null;
- }
- return ComponentName.unflattenFromString(editorPackage);
- }
-
- @VisibleForTesting
- protected TargetInfo getEditSharingTarget(Intent originalIntent) {
- final ComponentName cn = getEditSharingComponent();
-
- final Intent resolveIntent = new Intent(originalIntent);
- // Retain only URI permission grant flags if present. Other flags may prevent the scene
- // transition animation from running (i.e FLAG_ACTIVITY_NO_ANIMATION,
- // FLAG_ACTIVITY_NEW_TASK, FLAG_ACTIVITY_NEW_DOCUMENT) but also not needed.
- resolveIntent.setFlags(originalIntent.getFlags() & URI_PERMISSION_INTENT_FLAGS);
- resolveIntent.setComponent(cn);
- resolveIntent.setAction(Intent.ACTION_EDIT);
- String originalAction = originalIntent.getAction();
- if (Intent.ACTION_SEND.equals(originalAction)) {
- if (resolveIntent.getData() == null) {
- Uri uri = resolveIntent.getParcelableExtra(Intent.EXTRA_STREAM);
- if (uri != null) {
- String mimeType = getContentResolver().getType(uri);
- resolveIntent.setDataAndType(uri, mimeType);
- }
- }
- } else {
- Log.e(TAG, originalAction + " is not supported.");
- return null;
- }
- final ResolveInfo ri = getPackageManager().resolveActivity(
- resolveIntent, PackageManager.GET_META_DATA);
- if (ri == null || ri.activityInfo == null) {
- Log.e(TAG, "Device-specified image edit component (" + cn
- + ") not available");
- return null;
- }
-
- final DisplayResolveInfo dri = DisplayResolveInfo.newDisplayResolveInfo(
- originalIntent,
- ri,
- getString(com.android.internal.R.string.screenshot_edit),
- "",
- resolveIntent,
- null);
- dri.getDisplayIconHolder().setDisplayIcon(
- getDrawable(com.android.internal.R.drawable.ic_screenshot_edit));
- return dri;
- }
-
- @VisibleForTesting
- protected TargetInfo getNearbySharingTarget(Intent originalIntent) {
- final ComponentName cn = getNearbySharingComponent();
- if (cn == null) return null;
-
- final Intent resolveIntent = new Intent(originalIntent);
- resolveIntent.setComponent(cn);
- final ResolveInfo ri = getPackageManager().resolveActivity(
- resolveIntent, PackageManager.GET_META_DATA);
- if (ri == null || ri.activityInfo == null) {
- Log.e(TAG, "Device-specified nearby sharing component (" + cn
- + ") not available");
- return null;
- }
-
- // Allow the nearby sharing component to provide a more appropriate icon and label
- // for the chip.
- CharSequence name = null;
- Drawable icon = null;
- final Bundle metaData = ri.activityInfo.metaData;
- if (metaData != null) {
- try {
- final Resources pkgRes = getPackageManager().getResourcesForActivity(cn);
- final int nameResId = metaData.getInt(CHIP_LABEL_METADATA_KEY);
- name = pkgRes.getString(nameResId);
- final int resId = metaData.getInt(CHIP_ICON_METADATA_KEY);
- icon = pkgRes.getDrawable(resId);
- } catch (Resources.NotFoundException ex) {
- } catch (NameNotFoundException ex) {
- }
- }
- if (TextUtils.isEmpty(name)) {
- name = ri.loadLabel(getPackageManager());
- }
- if (icon == null) {
- icon = ri.loadIcon(getPackageManager());
- }
-
- final DisplayResolveInfo dri = DisplayResolveInfo.newDisplayResolveInfo(
- originalIntent, ri, name, "", resolveIntent, null);
- dri.getDisplayIconHolder().setDisplayIcon(icon);
- return dri;
- }
-
- private ActionRow.Action createCopyAction() {
- return new ActionRow.Action(
- com.android.internal.R.id.chooser_copy_button,
- getString(com.android.internal.R.string.copy),
- getDrawable(com.android.internal.R.drawable.ic_menu_copy_material),
- this::onCopyButtonClicked);
- }
-
- @Nullable
- private ActionRow.Action createNearbyAction(Intent originalIntent) {
- final TargetInfo ti = getNearbySharingTarget(originalIntent);
- if (ti == null) {
- return null;
- }
-
- return new ActionRow.Action(
- com.android.internal.R.id.chooser_nearby_button,
- ti.getDisplayLabel(),
- ti.getDisplayIconHolder().getDisplayIcon(),
- () -> {
- getChooserActivityLogger().logActionSelected(
- ChooserActivityLogger.SELECTION_TYPE_NEARBY);
- // Action bar is user-independent, always start as primary
- safelyStartActivityAsUser(ti, getPersonalProfileUserHandle());
- finish();
- });
- }
-
- @Nullable
- private ActionRow.Action createEditAction(Intent originalIntent) {
- final TargetInfo ti = getEditSharingTarget(originalIntent);
- if (ti == null) {
- return null;
- }
-
- return new ActionRow.Action(
- com.android.internal.R.id.chooser_edit_button,
- ti.getDisplayLabel(),
- ti.getDisplayIconHolder().getDisplayIcon(),
- () -> {
- // Log share completion via edit
- getChooserActivityLogger().logActionSelected(
- ChooserActivityLogger.SELECTION_TYPE_EDIT);
- View firstImgView = getFirstVisibleImgPreviewView();
- // Action bar is user-independent, always start as primary
- if (firstImgView == null) {
- safelyStartActivityAsUser(ti, getPersonalProfileUserHandle());
- finish();
- } else {
- ActivityOptions options = ActivityOptions.makeSceneTransitionAnimation(
- this, firstImgView, IMAGE_EDITOR_SHARED_ELEMENT);
- safelyStartActivityAsUser(
- ti, getPersonalProfileUserHandle(), options.toBundle());
- startFinishAnimation();
- }
- }
- );
- }
-
@Nullable
private View getFirstVisibleImgPreviewView() {
View firstImage = findViewById(com.android.internal.R.id.content_preview_image_1_large);
@@ -972,9 +743,9 @@ public class ChooserActivity extends ResolverActivity implements
mLatencyTracker.onActionCancel(ACTION_LOAD_SHARE_SHEET);
}
- if (mRefinementResultReceiver != null) {
- mRefinementResultReceiver.destroy();
- mRefinementResultReceiver = null;
+ if (mRefinementManager != null) { // TODO: null-checked in case of early-destroy, or skip?
+ mRefinementManager.destroy();
+ mRefinementManager = null;
}
mBackgroundThreadPoolExecutor.shutdownNow();
@@ -1098,34 +869,11 @@ public class ChooserActivity extends ResolverActivity implements
@Override
protected boolean onTargetSelected(TargetInfo target, boolean alwaysCheck) {
- if (mChooserRequest.getRefinementIntentSender() != null) {
- final Intent fillIn = new Intent();
- final List<Intent> sourceIntents = target.getAllSourceIntents();
- if (!sourceIntents.isEmpty()) {
- fillIn.putExtra(Intent.EXTRA_INTENT, sourceIntents.get(0));
- if (sourceIntents.size() > 1) {
- final Intent[] alts = new Intent[sourceIntents.size() - 1];
- for (int i = 1, N = sourceIntents.size(); i < N; i++) {
- alts[i - 1] = sourceIntents.get(i);
- }
- fillIn.putExtra(Intent.EXTRA_ALTERNATE_INTENTS, alts);
- }
- if (mRefinementResultReceiver != null) {
- mRefinementResultReceiver.destroy();
- }
- mRefinementResultReceiver = new RefinementResultReceiver(this, target, null);
- fillIn.putExtra(Intent.EXTRA_RESULT_RECEIVER,
- mRefinementResultReceiver);
- try {
- mChooserRequest.getRefinementIntentSender().sendIntent(
- this, 0, fillIn, null, null);
- return false;
- } catch (SendIntentException e) {
- Log.e(TAG, "Refinement IntentSender failed to send", e);
- }
- }
+ if (mRefinementManager.maybeHandleSelection(target)) {
+ return false;
}
updateModelAndChooserCounts(target);
+ maybeRemoveSharedText(target);
return super.onTargetSelected(target, alwaysCheck);
}
@@ -1237,45 +985,6 @@ public class ChooserActivity extends ResolverActivity implements
}
}
- private IntentFilter getTargetIntentFilter() {
- return getTargetIntentFilter(getTargetIntent());
- }
-
- private IntentFilter getTargetIntentFilter(final Intent intent) {
- try {
- String dataString = intent.getDataString();
- if (intent.getType() == null) {
- if (!TextUtils.isEmpty(dataString)) {
- return new IntentFilter(intent.getAction(), dataString);
- }
- Log.e(TAG, "Failed to get target intent filter: intent data and type are null");
- return null;
- }
- IntentFilter intentFilter = new IntentFilter(intent.getAction(), intent.getType());
- List<Uri> contentUris = new ArrayList<>();
- if (Intent.ACTION_SEND.equals(intent.getAction())) {
- Uri uri = (Uri) intent.getParcelableExtra(Intent.EXTRA_STREAM);
- if (uri != null) {
- contentUris.add(uri);
- }
- } else {
- List<Uri> uris = intent.getParcelableArrayListExtra(Intent.EXTRA_STREAM);
- if (uris != null) {
- contentUris.addAll(uris);
- }
- }
- for (Uri uri : contentUris) {
- intentFilter.addDataScheme(uri.getScheme());
- intentFilter.addDataAuthority(uri.getAuthority(), null);
- intentFilter.addDataPath(uri.getPath(), PatternMatcher.PATTERN_LITERAL);
- }
- return intentFilter;
- } catch (Exception e) {
- Log.e(TAG, "Failed to get target intent filter", e);
- return null;
- }
- }
-
private void logDirectShareTargetReceived(UserHandle forUser) {
ProfileRecord profileRecord = getProfileRecord(forUser);
if (profileRecord == null) {
@@ -1314,6 +1023,27 @@ public class ChooserActivity extends ResolverActivity implements
mIsSuccessfullySelected = true;
}
+ private void maybeRemoveSharedText(@androidx.annotation.NonNull TargetInfo targetInfo) {
+ Intent targetIntent = targetInfo.getTargetIntent();
+ if (targetIntent == null) {
+ return;
+ }
+ Intent originalTargetIntent = new Intent(mChooserRequest.getTargetIntent());
+ // Our TargetInfo implementations add associated component to the intent, let's do the same
+ // for the sake of the comparison below.
+ if (targetIntent.getComponent() != null) {
+ originalTargetIntent.setComponent(targetIntent.getComponent());
+ }
+ // Use filterEquals as a way to check that the primary intent is in use (and not an
+ // alternative one). For example, an app is sharing an image and a link with mime type
+ // "image/png" and provides an alternative intent to share only the link with mime type
+ // "text/uri". Should there be a target that accepts only the latter, the alternative intent
+ // will be used and we don't want to exclude the link from it.
+ if (mExcludeSharedText && originalTargetIntent.filterEquals(targetIntent)) {
+ targetIntent.removeExtra(Intent.EXTRA_TEXT);
+ }
+ }
+
private void sendImpressionToAppPredictor(TargetInfo targetInfo, ChooserListAdapter adapter) {
// Send DS target impression info to AppPredictor, only when user chooses app share.
if (targetInfo.isChooserTargetInfo()) {
@@ -1369,46 +1099,6 @@ public class ChooserActivity extends ResolverActivity implements
return (record == null) ? null : record.appPredictor;
}
- void onRefinementResult(TargetInfo selectedTarget, Intent matchingIntent) {
- if (mRefinementResultReceiver != null) {
- mRefinementResultReceiver.destroy();
- mRefinementResultReceiver = null;
- }
- if (selectedTarget == null) {
- Log.e(TAG, "Refinement result intent did not match any known targets; canceling");
- } else if (!checkTargetSourceIntent(selectedTarget, matchingIntent)) {
- Log.e(TAG, "onRefinementResult: Selected target " + selectedTarget
- + " cannot match refined source intent " + matchingIntent);
- } else {
- TargetInfo clonedTarget = selectedTarget.cloneFilledIn(matchingIntent, 0);
- if (super.onTargetSelected(clonedTarget, false)) {
- updateModelAndChooserCounts(clonedTarget);
- finish();
- return;
- }
- }
- onRefinementCanceled();
- }
-
- void onRefinementCanceled() {
- if (mRefinementResultReceiver != null) {
- mRefinementResultReceiver.destroy();
- mRefinementResultReceiver = null;
- }
- finish();
- }
-
- boolean checkTargetSourceIntent(TargetInfo target, Intent matchingIntent) {
- final List<Intent> targetIntents = target.getAllSourceIntents();
- for (int i = 0, N = targetIntents.size(); i < N; i++) {
- final Intent targetIntent = targetIntents.get(i);
- if (targetIntent.filterEquals(matchingIntent)) {
- return true;
- }
- }
- return false;
- }
-
/**
* Sort intents alphabetically based on display label.
*/
@@ -1433,14 +1123,19 @@ public class ChooserActivity extends ResolverActivity implements
}
public class ChooserListController extends ResolverListController {
- public ChooserListController(Context context,
+ public ChooserListController(
+ Context context,
PackageManager pm,
Intent targetIntent,
String referrerPackageName,
int launchedFromUid,
- UserHandle userId,
AbstractResolverComparator resolverComparator) {
- super(context, pm, targetIntent, referrerPackageName, launchedFromUid, userId,
+ super(
+ context,
+ pm,
+ targetIntent,
+ referrerPackageName,
+ launchedFromUid,
resolverComparator);
}
@@ -1485,7 +1180,7 @@ public class ChooserActivity extends ResolverActivity implements
@Override
public View buildContentPreview(ViewGroup parent) {
- return createContentPreviewView(parent, mPreviewCoordinator);
+ return createContentPreviewView(parent);
}
@Override
@@ -1500,9 +1195,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);
}
}
@@ -1576,8 +1271,9 @@ public class ChooserActivity extends ResolverActivity implements
maxTargetsPerRow);
}
+ @Override
@VisibleForTesting
- protected ResolverListController createListController(UserHandle userHandle) {
+ protected ChooserListController createListController(UserHandle userHandle) {
AppPredictor appPredictor = getAppPredictor(userHandle);
AbstractResolverComparator resolverComparator;
if (appPredictor != null) {
@@ -1594,23 +1290,55 @@ public class ChooserActivity extends ResolverActivity implements
mPm,
getTargetIntent(),
getReferrerPackageName(),
- mLaunchedFromUid,
- userHandle,
+ getAnnotatedUserHandles().userIdOfCallingApp,
resolverComparator);
}
@VisibleForTesting
- protected Bitmap loadThumbnail(Uri uri, Size size) {
- if (uri == null || size == null) {
- return null;
+ protected ImageLoader createPreviewImageLoader() {
+ final int cacheSize;
+ if (mFeatureFlagRepository.isEnabled(Flags.SHARESHEET_SCROLLABLE_IMAGE_PREVIEW)) {
+ float chooserWidth = getResources().getDimension(R.dimen.chooser_width);
+ float imageWidth = getResources().getDimension(R.dimen.chooser_preview_image_width);
+ cacheSize = (int) (Math.ceil(chooserWidth / imageWidth) + 2);
+ } else {
+ cacheSize = 3;
}
+ return new ImagePreviewImageLoader(this, getLifecycle(), cacheSize);
+ }
- try {
- return getContentResolver().loadThumbnail(uri, size, null);
- } catch (IOException | NullPointerException | SecurityException ex) {
- getChooserActivityLogger().logContentPreviewWarning(uri);
- }
- return null;
+ private ChooserActionFactory createChooserActionFactory() {
+ return new ChooserActionFactory(
+ this,
+ mChooserRequest,
+ mFeatureFlagRepository,
+ mIntegratedDeviceComponents,
+ getChooserActivityLogger(),
+ (isExcluded) -> mExcludeSharedText = isExcluded,
+ this::getFirstVisibleImgPreviewView,
+ new ChooserActionFactory.ActionActivityStarter() {
+ @Override
+ public void safelyStartActivityAsPersonalProfileUser(TargetInfo targetInfo) {
+ safelyStartActivityAsUser(targetInfo, getPersonalProfileUserHandle());
+ finish();
+ }
+
+ @Override
+ public void safelyStartActivityAsPersonalProfileUserWithSharedElementTransition(
+ TargetInfo targetInfo, View sharedElement, String sharedElementName) {
+ ActivityOptions options = ActivityOptions.makeSceneTransitionAnimation(
+ ChooserActivity.this, sharedElement, sharedElementName);
+ safelyStartActivityAsUser(
+ targetInfo, getPersonalProfileUserHandle(), options.toBundle());
+ startFinishAnimation();
+ }
+ },
+ (status) -> {
+ if (status != null) {
+ setResult(status);
+ }
+ finish();
+ });
}
private void handleScroll(View view, int x, int y, int oldx, int oldy) {
@@ -1845,21 +1573,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 +1673,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() {
@@ -1964,10 +1703,10 @@ public class ChooserActivity extends ResolverActivity implements
// We don't show it in landscape as otherwise there is no room for scrolling.
// If the sticky content preview will be shown at some point with orientation change,
// then always preload it to avoid subsequent resizing of the share sheet.
- ViewGroup contentPreviewContainer = findViewById(com.android.internal.R.id.content_preview_container);
+ ViewGroup contentPreviewContainer =
+ findViewById(com.android.internal.R.id.content_preview_container);
if (contentPreviewContainer.getChildCount() == 0) {
- ViewGroup contentPreviewView =
- createContentPreviewView(contentPreviewContainer, mPreviewCoordinator);
+ ViewGroup contentPreviewView = createContentPreviewView(contentPreviewContainer);
contentPreviewContainer.addView(contentPreviewView);
}
}
@@ -2101,66 +1840,6 @@ public class ChooserActivity extends ResolverActivity implements
}
}
- static class ChooserTargetRankingInfo {
- public final List<AppTarget> scores;
- public final UserHandle userHandle;
-
- ChooserTargetRankingInfo(List<AppTarget> chooserTargetScores,
- UserHandle userHandle) {
- this.scores = chooserTargetScores;
- this.userHandle = userHandle;
- }
- }
-
- static class RefinementResultReceiver extends ResultReceiver {
- private ChooserActivity mChooserActivity;
- private TargetInfo mSelectedTarget;
-
- public RefinementResultReceiver(ChooserActivity host, TargetInfo target,
- Handler handler) {
- super(handler);
- mChooserActivity = host;
- mSelectedTarget = target;
- }
-
- @Override
- protected void onReceiveResult(int resultCode, Bundle resultData) {
- if (mChooserActivity == null) {
- Log.e(TAG, "Destroyed RefinementResultReceiver received a result");
- return;
- }
- if (resultData == null) {
- Log.e(TAG, "RefinementResultReceiver received null resultData");
- return;
- }
-
- switch (resultCode) {
- case RESULT_CANCELED:
- mChooserActivity.onRefinementCanceled();
- break;
- case RESULT_OK:
- Parcelable intentParcelable = resultData.getParcelable(Intent.EXTRA_INTENT);
- if (intentParcelable instanceof Intent) {
- mChooserActivity.onRefinementResult(mSelectedTarget,
- (Intent) intentParcelable);
- } else {
- Log.e(TAG, "RefinementResultReceiver received RESULT_OK but no Intent"
- + " in resultData with key Intent.EXTRA_INTENT");
- }
- break;
- default:
- Log.w(TAG, "Unknown result code " + resultCode
- + " sent to RefinementResultReceiver");
- break;
- }
- }
-
- public void destroy() {
- mChooserActivity = null;
- mSelectedTarget = null;
- }
- }
-
/**
* Used in combination with the scene transition when launching the image editor
*/
diff --git a/java/src/com/android/intentresolver/ChooserActivityLogger.java b/java/src/com/android/intentresolver/ChooserActivityLogger.java
index 9109bf93..1f606f26 100644
--- a/java/src/com/android/intentresolver/ChooserActivityLogger.java
+++ b/java/src/com/android/intentresolver/ChooserActivityLogger.java
@@ -24,6 +24,7 @@ import android.provider.MediaStore;
import android.util.HashedStringCache;
import android.util.Log;
+import com.android.intentresolver.contentpreview.ContentPreviewType;
import com.android.internal.annotations.VisibleForTesting;
import com.android.internal.logging.InstanceId;
import com.android.internal.logging.InstanceIdSequence;
@@ -48,6 +49,8 @@ public class ChooserActivityLogger {
public static final int SELECTION_TYPE_COPY = 4;
public static final int SELECTION_TYPE_NEARBY = 5;
public static final int SELECTION_TYPE_EDIT = 6;
+ public static final int SELECTION_TYPE_MODIFY_SHARE = 7;
+ public static final int SELECTION_TYPE_CUSTOM_ACTION = 8;
/**
* This shim is provided only for testing. In production, clients will only ever use a
@@ -66,7 +69,9 @@ public class ChooserActivityLogger {
int numAppProvidedAppTargets,
boolean isWorkProfile,
int previewType,
- int intentType);
+ int intentType,
+ int numCustomActions,
+ boolean modifyShareActionProvided);
/** Overload to use for logging {@code FrameworkStatsLog.RANKING_SELECTED}. */
void write(
@@ -114,9 +119,16 @@ public class ChooserActivityLogger {
}
/** Logs a UiEventReported event for the system sharesheet completing initial start-up. */
- public void logShareStarted(int eventId, String packageName, String mimeType,
- int appProvidedDirect, int appProvidedApp, boolean isWorkprofile, int previewType,
- String intent) {
+ public void logShareStarted(
+ String packageName,
+ String mimeType,
+ int appProvidedDirect,
+ int appProvidedApp,
+ boolean isWorkprofile,
+ int previewType,
+ String intent,
+ int customActionCount,
+ boolean modifyShareActionProvided) {
mFrameworkStatsLogger.write(FrameworkStatsLog.SHARESHEET_STARTED,
/* event_id = 1 */ SharesheetStartedEvent.SHARE_STARTED.getId(),
/* package_name = 2 */ packageName,
@@ -126,7 +138,24 @@ 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 */ customActionCount,
+ /* modify_share_action_provided = 11 */ modifyShareActionProvided);
+ }
+
+ /**
+ * Log that a custom action has been tapped by the user.
+ *
+ * @param positionPicked index of the custom action within the list of custom actions.
+ */
+ public void logCustomActionSelected(int positionPicked) {
+ mFrameworkStatsLogger.write(FrameworkStatsLog.RANKING_SELECTED,
+ /* event_id = 1 */
+ SharesheetTargetSelectedEvent.SHARESHEET_CUSTOM_ACTION_SELECTED.getId(),
+ /* package_name = 2 */ null,
+ /* instance_id = 3 */ getInstanceId().getId(),
+ /* position_picked = 4 */ positionPicked,
+ /* is_pinned = 5 */ false);
}
/**
@@ -328,7 +357,11 @@ public class ChooserActivityLogger {
@UiEvent(doc = "User selected the nearby target.")
SHARESHEET_NEARBY_TARGET_SELECTED(626),
@UiEvent(doc = "User selected the edit target.")
- SHARESHEET_EDIT_TARGET_SELECTED(669);
+ SHARESHEET_EDIT_TARGET_SELECTED(669),
+ @UiEvent(doc = "User selected the modify share target.")
+ SHARESHEET_MODIFY_SHARE_SELECTED(1316),
+ @UiEvent(doc = "User selected a custom action.")
+ SHARESHEET_CUSTOM_ACTION_SELECTED(1317);
private final int mId;
SharesheetTargetSelectedEvent(int id) {
@@ -352,6 +385,10 @@ public class ChooserActivityLogger {
return SHARESHEET_NEARBY_TARGET_SELECTED;
case SELECTION_TYPE_EDIT:
return SHARESHEET_EDIT_TARGET_SELECTED;
+ case SELECTION_TYPE_MODIFY_SHARE:
+ return SHARESHEET_MODIFY_SHARE_SELECTED;
+ case SELECTION_TYPE_CUSTOM_ACTION:
+ return SHARESHEET_CUSTOM_ACTION_SELECTED;
default:
return INVALID;
}
@@ -396,11 +433,11 @@ public class ChooserActivityLogger {
*/
private static int typeFromPreviewInt(int previewType) {
switch(previewType) {
- case ChooserContentPreviewUi.CONTENT_PREVIEW_IMAGE:
+ case ContentPreviewType.CONTENT_PREVIEW_IMAGE:
return FrameworkStatsLog.SHARESHEET_STARTED__PREVIEW_TYPE__CONTENT_PREVIEW_IMAGE;
- case ChooserContentPreviewUi.CONTENT_PREVIEW_FILE:
+ case ContentPreviewType.CONTENT_PREVIEW_FILE:
return FrameworkStatsLog.SHARESHEET_STARTED__PREVIEW_TYPE__CONTENT_PREVIEW_FILE;
- case ChooserContentPreviewUi.CONTENT_PREVIEW_TEXT:
+ case ContentPreviewType.CONTENT_PREVIEW_TEXT:
default:
return FrameworkStatsLog
.SHARESHEET_STARTED__PREVIEW_TYPE__CONTENT_PREVIEW_TYPE_UNKNOWN;
@@ -463,7 +500,9 @@ public class ChooserActivityLogger {
int numAppProvidedAppTargets,
boolean isWorkProfile,
int previewType,
- int intentType) {
+ int intentType,
+ int numCustomActions,
+ boolean modifyShareActionProvided) {
FrameworkStatsLog.write(
frameworkEventId,
/* event_id = 1 */ appEventId,
@@ -474,7 +513,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,
+ /* modify_share_action_provided = 11 */ modifyShareActionProvided);
}
@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
deleted file mode 100644
index ff88e5e1..00000000
--- a/java/src/com/android/intentresolver/ChooserContentPreviewUi.java
+++ /dev/null
@@ -1,566 +0,0 @@
-/*
- * Copyright (C) 2022 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-package com.android.intentresolver;
-
-import static java.lang.annotation.RetentionPolicy.SOURCE;
-
-import android.animation.ObjectAnimator;
-import android.animation.ValueAnimator;
-import android.annotation.IntDef;
-import android.content.ClipData;
-import android.content.ContentResolver;
-import android.content.Intent;
-import android.content.res.Resources;
-import android.database.Cursor;
-import android.graphics.Bitmap;
-import android.net.Uri;
-import android.provider.DocumentsContract;
-import android.provider.Downloads;
-import android.provider.OpenableColumns;
-import android.text.TextUtils;
-import android.util.Log;
-import android.util.PluralsMessageFormatter;
-import android.view.LayoutInflater;
-import android.view.View;
-import android.view.ViewGroup;
-import android.view.ViewStub;
-import android.view.animation.DecelerateInterpolator;
-import android.widget.ImageView;
-import android.widget.TextView;
-
-import androidx.annotation.LayoutRes;
-import androidx.annotation.Nullable;
-
-import com.android.intentresolver.widget.ActionRow;
-import com.android.intentresolver.widget.ImagePreviewView;
-import com.android.intentresolver.widget.RoundedRectImageView;
-import com.android.internal.annotations.VisibleForTesting;
-
-import java.lang.annotation.Retention;
-import java.util.ArrayList;
-import java.util.HashMap;
-import java.util.List;
-import java.util.Map;
-import java.util.function.Consumer;
-
-/**
- * Collection of helpers for building the content preview UI displayed in {@link ChooserActivity}.
- *
- * TODO: this "namespace" was pulled out of {@link ChooserActivity} as a bucket of static methods
- * to show that they're one-shot procedures with no dependencies back to {@link ChooserActivity}
- * state other than the delegates that are explicitly provided. There may be more appropriate
- * abstractions (e.g., maybe this can be a "widget" added directly to the view hierarchy to show the
- * appropriate preview), or it may at least be safe (and more convenient) to adopt a more "object
- * oriented" design where the static specifiers are removed and some of the dependencies are cached
- * as ivars when this "class" is initialized.
- */
-public final class ChooserContentPreviewUi {
- private static final int IMAGE_FADE_IN_MILLIS = 150;
-
- /**
- * Delegate to 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.
- */
- public interface ActionFactory {
- /** Create an action that copies the share content to the clipboard. */
- ActionRow.Action createCopyButton();
-
- /** Create an action that opens the share content in a system-default editor. */
- @Nullable
- ActionRow.Action createEditButton();
-
- /** Create an "Share to Nearby" action. */
- @Nullable
- ActionRow.Action createNearbyButton();
- }
-
- /**
- * Testing shim to specify whether a given mime type is considered to be an "image."
- *
- * TODO: move away from {@link ChooserActivityOverrideData} as a model to configure our tests,
- * then migrate {@link ChooserActivity#isImageType(String)} into this class.
- */
- public interface ImageMimeTypeClassifier {
- /** @return whether the specified {@code mimeType} is classified as an "image" type. */
- boolean isImageType(String mimeType);
- }
-
- @Retention(SOURCE)
- @IntDef({CONTENT_PREVIEW_FILE, CONTENT_PREVIEW_IMAGE, CONTENT_PREVIEW_TEXT})
- private @interface ContentPreviewType {
- }
-
- // Starting at 1 since 0 is considered "undefined" for some of the database transformations
- // of tron logs.
- @VisibleForTesting
- public static final int CONTENT_PREVIEW_IMAGE = 1;
- @VisibleForTesting
- public static final int CONTENT_PREVIEW_FILE = 2;
- @VisibleForTesting
- public static final int CONTENT_PREVIEW_TEXT = 3;
-
- private static final String TAG = "ChooserPreview";
-
- private static final String PLURALS_COUNT = "count";
- private static final String PLURALS_FILE_NAME = "file_name";
-
- /** Determine the most appropriate type of preview to show for the provided {@link Intent}. */
- @ContentPreviewType
- public static int findPreferredContentPreview(
- Intent targetIntent,
- ContentResolver resolver,
- ImageMimeTypeClassifier imageClassifier) {
- /* In {@link android.content.Intent#getType}, the app may specify a very general mime type
- * that broadly covers all data being shared, such as {@literal *}/* when sending an image
- * and text. We therefore should inspect each item for the preferred type, in order: IMAGE,
- * FILE, TEXT. */
- String action = targetIntent.getAction();
- if (Intent.ACTION_SEND.equals(action)) {
- Uri uri = targetIntent.getParcelableExtra(Intent.EXTRA_STREAM);
- return findPreferredContentPreview(uri, resolver, imageClassifier);
- } else if (Intent.ACTION_SEND_MULTIPLE.equals(action)) {
- List<Uri> uris = targetIntent.getParcelableArrayListExtra(Intent.EXTRA_STREAM);
- if (uris == null || uris.isEmpty()) {
- return CONTENT_PREVIEW_TEXT;
- }
-
- for (Uri uri : uris) {
- // Defaulting to file preview when there are mixed image/file types is
- // preferable, as it shows the user the correct number of items being shared
- int uriPreviewType = findPreferredContentPreview(uri, resolver, imageClassifier);
- if (uriPreviewType == CONTENT_PREVIEW_FILE) {
- return CONTENT_PREVIEW_FILE;
- }
- }
-
- return CONTENT_PREVIEW_IMAGE;
- }
-
- return CONTENT_PREVIEW_TEXT;
- }
-
- /**
- * Display a content preview of the specified {@code previewType} to preview the content of the
- * specified {@code intent}.
- */
- public static ViewGroup displayContentPreview(
- @ContentPreviewType int previewType,
- Intent targetIntent,
- Resources resources,
- LayoutInflater layoutInflater,
- ActionFactory actionFactory,
- @LayoutRes int actionRowLayout,
- ViewGroup parent,
- ContentPreviewCoordinator previewCoord,
- Consumer<Boolean> onTransitionTargetReady,
- ContentResolver contentResolver,
- ImageMimeTypeClassifier imageClassifier) {
- ViewGroup layout = null;
-
- switch (previewType) {
- case CONTENT_PREVIEW_TEXT:
- layout = displayTextContentPreview(
- targetIntent,
- layoutInflater,
- createTextPreviewActions(actionFactory),
- parent,
- previewCoord,
- actionRowLayout);
- break;
- case CONTENT_PREVIEW_IMAGE:
- layout = displayImageContentPreview(
- targetIntent,
- layoutInflater,
- createImagePreviewActions(actionFactory),
- parent,
- previewCoord,
- onTransitionTargetReady,
- contentResolver,
- imageClassifier,
- actionRowLayout);
- break;
- case CONTENT_PREVIEW_FILE:
- layout = displayFileContentPreview(
- targetIntent,
- resources,
- layoutInflater,
- createFilePreviewActions(actionFactory),
- parent,
- previewCoord,
- contentResolver,
- actionRowLayout);
- break;
- default:
- Log.e(TAG, "Unexpected content preview type: " + previewType);
- }
-
- return layout;
- }
-
- private static Cursor queryResolver(ContentResolver resolver, Uri uri) {
- return resolver.query(uri, null, null, null, null);
- }
-
- @ContentPreviewType
- private static int findPreferredContentPreview(
- Uri uri, ContentResolver resolver, ImageMimeTypeClassifier imageClassifier) {
- if (uri == null) {
- return CONTENT_PREVIEW_TEXT;
- }
-
- String mimeType = resolver.getType(uri);
- return imageClassifier.isImageType(mimeType) ? CONTENT_PREVIEW_IMAGE : CONTENT_PREVIEW_FILE;
- }
-
- private static ViewGroup displayTextContentPreview(
- Intent targetIntent,
- LayoutInflater layoutInflater,
- List<ActionRow.Action> actions,
- ViewGroup parent,
- ContentPreviewCoordinator previewCoord,
- @LayoutRes int actionRowLayout) {
- ViewGroup contentPreviewLayout = (ViewGroup) layoutInflater.inflate(
- R.layout.chooser_grid_preview_text, parent, false);
-
- final ActionRow actionRow = inflateActionRow(contentPreviewLayout, actionRowLayout);
- if (actionRow != null) {
- actionRow.setActions(actions);
- }
-
- CharSequence sharingText = targetIntent.getCharSequenceExtra(Intent.EXTRA_TEXT);
- if (sharingText == null) {
- contentPreviewLayout
- .findViewById(com.android.internal.R.id.content_preview_text_layout)
- .setVisibility(View.GONE);
- } else {
- TextView textView = contentPreviewLayout.findViewById(
- com.android.internal.R.id.content_preview_text);
- textView.setText(sharingText);
- }
-
- String previewTitle = targetIntent.getStringExtra(Intent.EXTRA_TITLE);
- if (TextUtils.isEmpty(previewTitle)) {
- contentPreviewLayout
- .findViewById(com.android.internal.R.id.content_preview_title_layout)
- .setVisibility(View.GONE);
- } else {
- TextView previewTitleView = contentPreviewLayout.findViewById(
- com.android.internal.R.id.content_preview_title);
- previewTitleView.setText(previewTitle);
-
- ClipData previewData = targetIntent.getClipData();
- Uri previewThumbnail = null;
- if (previewData != null) {
- if (previewData.getItemCount() > 0) {
- ClipData.Item previewDataItem = previewData.getItemAt(0);
- previewThumbnail = previewDataItem.getUri();
- }
- }
-
- ImageView previewThumbnailView = contentPreviewLayout.findViewById(
- com.android.internal.R.id.content_preview_thumbnail);
- if (previewThumbnail == null) {
- previewThumbnailView.setVisibility(View.GONE);
- } else {
- previewCoord.loadImage(
- previewThumbnail,
- (bitmap) -> updateViewWithImage(
- contentPreviewLayout.findViewById(
- com.android.internal.R.id.content_preview_thumbnail),
- bitmap));
- }
- }
-
- return contentPreviewLayout;
- }
-
- private static List<ActionRow.Action> createTextPreviewActions(ActionFactory actionFactory) {
- ArrayList<ActionRow.Action> actions = new ArrayList<>(2);
- actions.add(actionFactory.createCopyButton());
- ActionRow.Action nearbyAction = actionFactory.createNearbyButton();
- if (nearbyAction != null) {
- actions.add(nearbyAction);
- }
- return actions;
- }
-
- private static ViewGroup displayImageContentPreview(
- Intent targetIntent,
- LayoutInflater layoutInflater,
- List<ActionRow.Action> actions,
- ViewGroup parent,
- ContentPreviewCoordinator previewCoord,
- Consumer<Boolean> onTransitionTargetReady,
- ContentResolver contentResolver,
- ImageMimeTypeClassifier imageClassifier,
- @LayoutRes int actionRowLayout) {
- ViewGroup contentPreviewLayout = (ViewGroup) layoutInflater.inflate(
- R.layout.chooser_grid_preview_image, parent, false);
- ImagePreviewView imagePreview = contentPreviewLayout.findViewById(
- com.android.internal.R.id.content_preview_image_area);
-
- final ActionRow actionRow = inflateActionRow(contentPreviewLayout, actionRowLayout);
- if (actionRow != null) {
- 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);
- }
- }
- }
-
- 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);
- return contentPreviewLayout;
- }
-
- imagePreview.setSharedElementTransitionTarget(
- ChooserActivity.FIRST_IMAGE_PREVIEW_TRANSITION_NAME,
- onTransitionTargetReady);
- imagePreview.setImages(imageUris, imageLoader);
-
- return contentPreviewLayout;
- }
-
- private static List<ActionRow.Action> createImagePreviewActions(
- ActionFactory buttonFactory) {
- ArrayList<ActionRow.Action> actions = new ArrayList<>(2);
- //TODO: add copy action;
- ActionRow.Action action = buttonFactory.createNearbyButton();
- if (action != null) {
- actions.add(action);
- }
- action = buttonFactory.createEditButton();
- if (action != null) {
- actions.add(action);
- }
- return actions;
- }
-
- private static ViewGroup displayFileContentPreview(
- Intent targetIntent,
- Resources resources,
- LayoutInflater layoutInflater,
- List<ActionRow.Action> actions,
- ViewGroup parent,
- ContentPreviewCoordinator previewCoord,
- ContentResolver contentResolver,
- @LayoutRes int actionRowLayout) {
- ViewGroup contentPreviewLayout = (ViewGroup) layoutInflater.inflate(
- R.layout.chooser_grid_preview_file, parent, false);
-
- final ActionRow actionRow = inflateActionRow(contentPreviewLayout, actionRowLayout);
- if (actionRow != null) {
- actionRow.setActions(actions);
- }
-
- String action = targetIntent.getAction();
- if (Intent.ACTION_SEND.equals(action)) {
- Uri uri = targetIntent.getParcelableExtra(Intent.EXTRA_STREAM);
- loadFileUriIntoView(uri, contentPreviewLayout, previewCoord, contentResolver);
- } 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);
- }
- }
-
- return contentPreviewLayout;
- }
-
- private static List<ActionRow.Action> createFilePreviewActions(ActionFactory actionFactory) {
- List<ActionRow.Action> actions = new ArrayList<>(1);
- //TODO(b/120417119):
- // add action buttonFactory.createCopyButton()
- ActionRow.Action action = actionFactory.createNearbyButton();
- if (action != null) {
- actions.add(action);
- }
- return actions;
- }
-
- private static ActionRow inflateActionRow(ViewGroup parent, @LayoutRes int actionRowLayout) {
- final ViewStub stub = parent.findViewById(com.android.intentresolver.R.id.action_row_stub);
- if (stub != null) {
- stub.setLayoutResource(actionRowLayout);
- stub.inflate();
- }
- return parent.findViewById(com.android.internal.R.id.chooser_action_row);
- }
-
- private static void logContentPreviewWarning(Uri uri) {
- // The ContentResolver already logs the exception. Log something more informative.
- Log.w(TAG, "Could not load (" + uri.toString() + ") thumbnail/name for preview. If "
- + "desired, consider using Intent#createChooser to launch the ChooserActivity, "
- + "and set your Intent's clipData and flags in accordance with that method's "
- + "documentation");
- }
-
- private static void loadFileUriIntoView(
- final Uri uri,
- final View parent,
- final ContentPreviewCoordinator previewCoord,
- final ContentResolver contentResolver) {
- FileInfo fileInfo = extractFileInfo(uri, contentResolver);
-
- TextView fileNameView = parent.findViewById(
- com.android.internal.R.id.content_preview_filename);
- fileNameView.setText(fileInfo.name);
-
- if (fileInfo.hasThumbnail) {
- previewCoord.loadImage(
- uri,
- (bitmap) -> updateViewWithImage(
- parent.findViewById(
- com.android.internal.R.id.content_preview_file_thumbnail),
- bitmap));
- } else {
- View thumbnailView = parent.findViewById(
- com.android.internal.R.id.content_preview_file_thumbnail);
- thumbnailView.setVisibility(View.GONE);
-
- ImageView fileIconView = parent.findViewById(
- com.android.internal.R.id.content_preview_file_icon);
- fileIconView.setVisibility(View.VISIBLE);
- fileIconView.setImageResource(R.drawable.chooser_file_generic);
- }
- }
-
- private static void updateViewWithImage(RoundedRectImageView imageView, Bitmap image) {
- if (image == null) {
- imageView.setVisibility(View.GONE);
- return;
- }
- imageView.setVisibility(View.VISIBLE);
- imageView.setAlpha(0.0f);
- imageView.setImageBitmap(image);
-
- ValueAnimator fadeAnim = ObjectAnimator.ofFloat(imageView, "alpha", 0.0f, 1.0f);
- fadeAnim.setInterpolator(new DecelerateInterpolator(1.0f));
- fadeAnim.setDuration(IMAGE_FADE_IN_MILLIS);
- fadeAnim.start();
- }
-
- private static FileInfo extractFileInfo(Uri uri, ContentResolver resolver) {
- String fileName = null;
- boolean hasThumbnail = false;
-
- try (Cursor cursor = queryResolver(resolver, uri)) {
- if (cursor != null && cursor.getCount() > 0) {
- int nameIndex = cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME);
- int titleIndex = cursor.getColumnIndex(Downloads.Impl.COLUMN_TITLE);
- int flagsIndex = cursor.getColumnIndex(DocumentsContract.Document.COLUMN_FLAGS);
-
- cursor.moveToFirst();
- if (nameIndex != -1) {
- fileName = cursor.getString(nameIndex);
- } else if (titleIndex != -1) {
- fileName = cursor.getString(titleIndex);
- }
-
- if (flagsIndex != -1) {
- hasThumbnail = (cursor.getInt(flagsIndex)
- & DocumentsContract.Document.FLAG_SUPPORTS_THUMBNAIL) != 0;
- }
- }
- } catch (SecurityException | NullPointerException e) {
- logContentPreviewWarning(uri);
- }
-
- if (TextUtils.isEmpty(fileName)) {
- fileName = uri.getPath();
- int index = fileName.lastIndexOf('/');
- if (index != -1) {
- fileName = fileName.substring(index + 1);
- }
- }
-
- return new FileInfo(fileName, hasThumbnail);
- }
-
- private static class FileInfo {
- public final String name;
- public final boolean hasThumbnail;
-
- FileInfo(String name, boolean hasThumbnail) {
- this.name = name;
- this.hasThumbnail = hasThumbnail;
- }
- }
-
- private ChooserContentPreviewUi() {}
-}
diff --git a/java/src/com/android/intentresolver/ChooserIntegratedDeviceComponents.java b/java/src/com/android/intentresolver/ChooserIntegratedDeviceComponents.java
new file mode 100644
index 00000000..5fbf03a0
--- /dev/null
+++ b/java/src/com/android/intentresolver/ChooserIntegratedDeviceComponents.java
@@ -0,0 +1,83 @@
+/*
+ * Copyright (C) 2023 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.intentresolver;
+
+import android.annotation.Nullable;
+import android.content.ComponentName;
+import android.content.Context;
+import android.provider.Settings;
+import android.text.TextUtils;
+
+import com.android.internal.annotations.VisibleForTesting;
+
+/**
+ * Helper to look up the components available on this device to handle assorted built-in actions
+ * like "Edit" that may be displayed for certain content/preview types. The components are queried
+ * when this record is instantiated, and are then immutable for a given instance.
+ *
+ * Because this describes the app's external execution environment, test methods may prefer to
+ * provide explicit values to override the default lookup logic.
+ */
+public class ChooserIntegratedDeviceComponents {
+ @Nullable
+ private final ComponentName mEditSharingComponent;
+
+ @Nullable
+ private final ComponentName mNearbySharingComponent;
+
+ /** Look up the integrated components available on this device. */
+ public static ChooserIntegratedDeviceComponents get(
+ Context context,
+ SecureSettings secureSettings) {
+ return new ChooserIntegratedDeviceComponents(
+ getEditSharingComponent(context),
+ getNearbySharingComponent(context, secureSettings));
+ }
+
+ @VisibleForTesting
+ ChooserIntegratedDeviceComponents(
+ ComponentName editSharingComponent, ComponentName nearbySharingComponent) {
+ mEditSharingComponent = editSharingComponent;
+ mNearbySharingComponent = nearbySharingComponent;
+ }
+
+ public ComponentName getEditSharingComponent() {
+ return mEditSharingComponent;
+ }
+
+ public ComponentName getNearbySharingComponent() {
+ return mNearbySharingComponent;
+ }
+
+ private static ComponentName getEditSharingComponent(Context context) {
+ String editorComponent = context.getApplicationContext().getString(
+ R.string.config_systemImageEditor);
+ return TextUtils.isEmpty(editorComponent)
+ ? null : ComponentName.unflattenFromString(editorComponent);
+ }
+
+ private static ComponentName getNearbySharingComponent(Context context,
+ SecureSettings secureSettings) {
+ String nearbyComponent = secureSettings.getString(
+ context.getContentResolver(), Settings.Secure.NEARBY_SHARING_COMPONENT);
+ if (TextUtils.isEmpty(nearbyComponent)) {
+ nearbyComponent = context.getString(R.string.config_defaultNearbySharingComponent);
+ }
+ return TextUtils.isEmpty(nearbyComponent)
+ ? null : ComponentName.unflattenFromString(nearbyComponent);
+ }
+}
diff --git a/java/src/com/android/intentresolver/ChooserListAdapter.java b/java/src/com/android/intentresolver/ChooserListAdapter.java
index 699190f9..f0651360 100644
--- a/java/src/com/android/intentresolver/ChooserListAdapter.java
+++ b/java/src/com/android/intentresolver/ChooserListAdapter.java
@@ -49,7 +49,6 @@ import android.widget.TextView;
import androidx.annotation.WorkerThread;
-import com.android.intentresolver.ResolverActivity.ResolvedComponentInfo;
import com.android.intentresolver.chooser.DisplayResolveInfo;
import com.android.intentresolver.chooser.MultiDisplayResolveInfo;
import com.android.intentresolver.chooser.NotSelectableTargetInfo;
@@ -264,7 +263,7 @@ public class ChooserListAdapter extends ResolverListAdapter {
}
holder.bindLabel(info.getDisplayLabel(), info.getExtendedInfo(), alwaysShowSubLabel());
- holder.bindIcon(info);
+ holder.bindIcon(info, /*animate =*/ true);
if (info.isSelectableTargetInfo()) {
// direct share targets should append the application name for a better readout
DisplayResolveInfo rInfo = info.getDisplayResolveInfo();
diff --git a/java/src/com/android/intentresolver/ChooserMultiProfilePagerAdapter.java b/java/src/com/android/intentresolver/ChooserMultiProfilePagerAdapter.java
index 39d1fab0..3e2ea473 100644
--- a/java/src/com/android/intentresolver/ChooserMultiProfilePagerAdapter.java
+++ b/java/src/com/android/intentresolver/ChooserMultiProfilePagerAdapter.java
@@ -48,7 +48,7 @@ public class ChooserMultiProfilePagerAdapter extends GenericMultiProfilePagerAda
Context context,
ChooserGridAdapter adapter,
EmptyStateProvider emptyStateProvider,
- QuietModeManager quietModeManager,
+ Supplier<Boolean> workProfileQuietModeChecker,
UserHandle workProfileUserHandle,
int maxTargetsPerRow) {
this(
@@ -56,7 +56,7 @@ public class ChooserMultiProfilePagerAdapter extends GenericMultiProfilePagerAda
new ChooserProfileAdapterBinder(maxTargetsPerRow),
ImmutableList.of(adapter),
emptyStateProvider,
- quietModeManager,
+ workProfileQuietModeChecker,
/* defaultProfile= */ 0,
workProfileUserHandle,
new BottomPaddingOverrideSupplier(context));
@@ -67,7 +67,7 @@ public class ChooserMultiProfilePagerAdapter extends GenericMultiProfilePagerAda
ChooserGridAdapter personalAdapter,
ChooserGridAdapter workAdapter,
EmptyStateProvider emptyStateProvider,
- QuietModeManager quietModeManager,
+ Supplier<Boolean> workProfileQuietModeChecker,
@Profile int defaultProfile,
UserHandle workProfileUserHandle,
int maxTargetsPerRow) {
@@ -76,7 +76,7 @@ public class ChooserMultiProfilePagerAdapter extends GenericMultiProfilePagerAda
new ChooserProfileAdapterBinder(maxTargetsPerRow),
ImmutableList.of(personalAdapter, workAdapter),
emptyStateProvider,
- quietModeManager,
+ workProfileQuietModeChecker,
defaultProfile,
workProfileUserHandle,
new BottomPaddingOverrideSupplier(context));
@@ -87,7 +87,7 @@ public class ChooserMultiProfilePagerAdapter extends GenericMultiProfilePagerAda
ChooserProfileAdapterBinder adapterBinder,
ImmutableList<ChooserGridAdapter> gridAdapters,
EmptyStateProvider emptyStateProvider,
- QuietModeManager quietModeManager,
+ Supplier<Boolean> workProfileQuietModeChecker,
@Profile int defaultProfile,
UserHandle workProfileUserHandle,
BottomPaddingOverrideSupplier bottomPaddingOverrideSupplier) {
@@ -97,7 +97,7 @@ public class ChooserMultiProfilePagerAdapter extends GenericMultiProfilePagerAda
adapterBinder,
gridAdapters,
emptyStateProvider,
- quietModeManager,
+ workProfileQuietModeChecker,
defaultProfile,
workProfileUserHandle,
() -> makeProfileView(context),
diff --git a/java/src/com/android/intentresolver/ChooserRefinementManager.java b/java/src/com/android/intentresolver/ChooserRefinementManager.java
new file mode 100644
index 00000000..3ddc1c7c
--- /dev/null
+++ b/java/src/com/android/intentresolver/ChooserRefinementManager.java
@@ -0,0 +1,194 @@
+/*
+ * Copyright (C) 2023 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.intentresolver;
+
+import android.annotation.Nullable;
+import android.app.Activity;
+import android.content.Context;
+import android.content.Intent;
+import android.content.IntentSender;
+import android.content.IntentSender.SendIntentException;
+import android.os.Bundle;
+import android.os.Handler;
+import android.os.Parcel;
+import android.os.Parcelable;
+import android.os.ResultReceiver;
+import android.util.Log;
+
+import com.android.intentresolver.chooser.TargetInfo;
+
+import java.util.List;
+import java.util.function.Consumer;
+
+/**
+ * Helper class to manage Sharesheet's "refinement" flow, where callers supply a "refinement
+ * activity" that will be invoked when a target is selected, allowing the calling app to add
+ * additional extras and other refinements (subject to {@link Intent#filterEquals()}), e.g., to
+ * convert the format of the payload, or lazy-download some data that was deferred in the original
+ * call).
+ */
+public final class ChooserRefinementManager {
+ private static final String TAG = "ChooserRefinement";
+
+ @Nullable
+ private final IntentSender mRefinementIntentSender;
+
+ private final Context mContext;
+ private final Consumer<TargetInfo> mOnSelectionRefined;
+ private final Runnable mOnRefinementCancelled;
+
+ @Nullable
+ private RefinementResultReceiver mRefinementResultReceiver;
+
+ public ChooserRefinementManager(
+ Context context,
+ @Nullable IntentSender refinementIntentSender,
+ Consumer<TargetInfo> onSelectionRefined,
+ Runnable onRefinementCancelled) {
+ mContext = context;
+ mRefinementIntentSender = refinementIntentSender;
+ mOnSelectionRefined = onSelectionRefined;
+ mOnRefinementCancelled = onRefinementCancelled;
+ }
+
+ /**
+ * Delegate the user's {@code selectedTarget} to the refinement flow, if possible.
+ * @return true if the selection should wait for a now-started refinement flow, or false if it
+ * can proceed by the default (non-refinement) logic.
+ */
+ public boolean maybeHandleSelection(TargetInfo selectedTarget) {
+ if (mRefinementIntentSender == null) {
+ return false;
+ }
+ if (selectedTarget.getAllSourceIntents().isEmpty()) {
+ return false;
+ }
+
+ destroy(); // Terminate any prior sessions.
+ mRefinementResultReceiver = new RefinementResultReceiver(
+ refinedIntent -> {
+ destroy();
+ TargetInfo refinedTarget =
+ selectedTarget.tryToCloneWithAppliedRefinement(refinedIntent);
+ if (refinedTarget != null) {
+ mOnSelectionRefined.accept(refinedTarget);
+ } else {
+ Log.e(TAG, "Failed to apply refinement to any matching source intent");
+ mOnRefinementCancelled.run();
+ }
+ },
+ mOnRefinementCancelled,
+ mContext.getMainThreadHandler());
+
+ Intent refinementRequest = makeRefinementRequest(mRefinementResultReceiver, selectedTarget);
+ try {
+ mRefinementIntentSender.sendIntent(mContext, 0, refinementRequest, null, null);
+ return true;
+ } catch (SendIntentException e) {
+ Log.e(TAG, "Refinement IntentSender failed to send", e);
+ }
+ return false;
+ }
+
+ /** Clean up any ongoing refinement session. */
+ public void destroy() {
+ if (mRefinementResultReceiver != null) {
+ mRefinementResultReceiver.destroy();
+ mRefinementResultReceiver = null;
+ }
+ }
+
+ private static Intent makeRefinementRequest(
+ RefinementResultReceiver resultReceiver, TargetInfo originalTarget) {
+ final Intent fillIn = new Intent();
+ final List<Intent> sourceIntents = originalTarget.getAllSourceIntents();
+ fillIn.putExtra(Intent.EXTRA_INTENT, sourceIntents.get(0));
+ final int sourceIntentCount = sourceIntents.size();
+ if (sourceIntentCount > 1) {
+ fillIn.putExtra(
+ Intent.EXTRA_ALTERNATE_INTENTS,
+ sourceIntents
+ .subList(1, sourceIntentCount)
+ .toArray(new Intent[sourceIntentCount - 1]));
+ }
+ fillIn.putExtra(Intent.EXTRA_RESULT_RECEIVER, resultReceiver.copyForSending());
+ return fillIn;
+ }
+
+ private static class RefinementResultReceiver extends ResultReceiver {
+ private final Consumer<Intent> mOnSelectionRefined;
+ private final Runnable mOnRefinementCancelled;
+
+ private boolean mDestroyed;
+
+ RefinementResultReceiver(
+ Consumer<Intent> onSelectionRefined,
+ Runnable onRefinementCancelled,
+ Handler handler) {
+ super(handler);
+ mOnSelectionRefined = onSelectionRefined;
+ mOnRefinementCancelled = onRefinementCancelled;
+ }
+
+ public void destroy() {
+ mDestroyed = true;
+ }
+
+ @Override
+ protected void onReceiveResult(int resultCode, Bundle resultData) {
+ if (mDestroyed) {
+ Log.e(TAG, "Destroyed RefinementResultReceiver received a result");
+ return;
+ }
+ if (resultData == null) {
+ Log.e(TAG, "RefinementResultReceiver received null resultData");
+ // TODO: treat as cancellation?
+ return;
+ }
+
+ switch (resultCode) {
+ case Activity.RESULT_CANCELED:
+ mOnRefinementCancelled.run();
+ break;
+ case Activity.RESULT_OK:
+ Parcelable intentParcelable = resultData.getParcelable(Intent.EXTRA_INTENT);
+ if (intentParcelable instanceof Intent) {
+ mOnSelectionRefined.accept((Intent) intentParcelable);
+ } else {
+ Log.e(TAG, "No valid Intent.EXTRA_INTENT in 'OK' refinement result data");
+ }
+ break;
+ default:
+ Log.w(TAG, "Received unknown refinement result " + resultCode);
+ break;
+ }
+ }
+
+ /**
+ * Apps can't load this class directly, so we need a regular ResultReceiver copy for
+ * sending. Obtain this by parceling and unparceling (one weird trick).
+ */
+ ResultReceiver copyForSending() {
+ Parcel parcel = Parcel.obtain();
+ writeToParcel(parcel, 0);
+ parcel.setDataPosition(0);
+ ResultReceiver receiverForSending = ResultReceiver.CREATOR.createFromParcel(parcel);
+ parcel.recycle();
+ return receiverForSending;
+ }
+ }
+}
diff --git a/java/src/com/android/intentresolver/ChooserRequestParameters.java b/java/src/com/android/intentresolver/ChooserRequestParameters.java
index 81481bf1..3d99e475 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,11 +27,15 @@ 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;
import android.util.Pair;
+import com.android.intentresolver.flags.FeatureFlagRepository;
+import com.android.intentresolver.flags.Flags;
+
import com.google.common.collect.ImmutableList;
import java.net.URISyntaxException;
@@ -66,10 +71,14 @@ public class ChooserRequestParameters {
Intent.FLAG_ACTIVITY_NEW_DOCUMENT | Intent.FLAG_ACTIVITY_MULTIPLE_TASK;
private final Intent mTarget;
+ private final ChooserIntegratedDeviceComponents mIntegratedDeviceComponents;
+ private final String mReferrerPackageName;
private final Pair<CharSequence, Integer> mTitleSpec;
private final Intent mReferrerFillInIntent;
private final ImmutableList<ComponentName> mFilteredComponentNames;
private final ImmutableList<ChooserTarget> mCallerChooserTargets;
+ private final @NonNull ImmutableList<ChooserAction> mChooserActions;
+ private final PendingIntent mModifyShareAction;
private final boolean mRetainInOnStop;
@Nullable
@@ -95,12 +104,18 @@ public class ChooserRequestParameters {
public ChooserRequestParameters(
final Intent clientIntent,
+ String referrerPackageName,
final Uri referrer,
- @Nullable final ComponentName nearbySharingComponent) {
+ ChooserIntegratedDeviceComponents integratedDeviceComponents,
+ FeatureFlagRepository featureFlags) {
final Intent requestedTarget = parseTargetIntentExtra(
clientIntent.getParcelableExtra(Intent.EXTRA_INTENT));
mTarget = intentWithModifiedLaunchFlags(requestedTarget);
+ mIntegratedDeviceComponents = integratedDeviceComponents;
+
+ mReferrerPackageName = referrerPackageName;
+
mAdditionalTargets = intentsWithModifiedLaunchFlagsFromExtraIfPresent(
clientIntent, Intent.EXTRA_ALTERNATE_INTENTS);
@@ -120,7 +135,8 @@ public class ChooserRequestParameters {
mRefinementIntentSender = clientIntent.getParcelableExtra(
Intent.EXTRA_CHOOSER_REFINEMENT_INTENT_SENDER);
- mFilteredComponentNames = getFilteredComponentNames(clientIntent, nearbySharingComponent);
+ mFilteredComponentNames = getFilteredComponentNames(
+ clientIntent, mIntegratedDeviceComponents.getNearbySharingComponent());
mCallerChooserTargets = parseCallerTargetsFromClientIntent(clientIntent);
@@ -130,6 +146,13 @@ public class ChooserRequestParameters {
mSharedText = mTarget.getStringExtra(Intent.EXTRA_TEXT);
mTargetIntentFilter = getTargetIntentFilter(mTarget);
+
+ mChooserActions = featureFlags.isEnabled(Flags.SHARESHEET_CUSTOM_ACTIONS)
+ ? getChooserActions(clientIntent)
+ : ImmutableList.of();
+ mModifyShareAction = featureFlags.isEnabled(Flags.SHARESHEET_RESELECTION_ACTION)
+ ? getModifyShareAction(clientIntent)
+ : null;
}
public Intent getTargetIntent() {
@@ -150,6 +173,10 @@ public class ChooserRequestParameters {
return getTargetIntent().getType();
}
+ public String getReferrerPackageName() {
+ return mReferrerPackageName;
+ }
+
@Nullable
public CharSequence getTitle() {
return mTitleSpec.first;
@@ -171,8 +198,18 @@ public class ChooserRequestParameters {
return mCallerChooserTargets;
}
+ @NonNull
+ public ImmutableList<ChooserAction> getChooserActions() {
+ return mChooserActions;
+ }
+
+ @Nullable
+ public PendingIntent getModifyShareAction() {
+ return mModifyShareAction;
+ }
+
/**
- * Whether the {@link ChooserActivity.EXTRA_PRIVATE_RETAIN_IN_ON_STOP} behavior was requested.
+ * Whether the {@link ChooserActivity#EXTRA_PRIVATE_RETAIN_IN_ON_STOP} behavior was requested.
*/
public boolean shouldRetainInOnStop() {
return mRetainInOnStop;
@@ -221,6 +258,10 @@ public class ChooserRequestParameters {
return mTargetIntentFilter;
}
+ public ChooserIntegratedDeviceComponents getIntegratedDeviceComponents() {
+ return mIntegratedDeviceComponents;
+ }
+
private static boolean isSendAction(@Nullable String action) {
return (Intent.ACTION_SEND.equals(action) || Intent.ACTION_SEND_MULTIPLE.equals(action));
}
@@ -300,6 +341,32 @@ public class ChooserRequestParameters {
.collect(toImmutableList());
}
+ @NonNull
+ 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 getModifyShareAction(Intent intent) {
+ try {
+ return intent.getParcelableExtra(
+ Intent.EXTRA_CHOOSER_MODIFY_SHARE_ACTION,
+ PendingIntent.class);
+ } catch (Throwable t) {
+ Log.w(
+ TAG,
+ "Unable to retrieve Intent.EXTRA_CHOOSER_MODIFY_SHARE_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/GenericMultiProfilePagerAdapter.java b/java/src/com/android/intentresolver/GenericMultiProfilePagerAdapter.java
index 9bbdf7c7..7613f35f 100644
--- a/java/src/com/android/intentresolver/GenericMultiProfilePagerAdapter.java
+++ b/java/src/com/android/intentresolver/GenericMultiProfilePagerAdapter.java
@@ -81,7 +81,7 @@ class GenericMultiProfilePagerAdapter<
AdapterBinder<PageViewT, SinglePageAdapterT> adapterBinder,
ImmutableList<SinglePageAdapterT> adapters,
EmptyStateProvider emptyStateProvider,
- QuietModeManager quietModeManager,
+ Supplier<Boolean> workProfileQuietModeChecker,
@Profile int defaultProfile,
UserHandle workProfileUserHandle,
Supplier<ViewGroup> pageViewInflater,
@@ -90,7 +90,7 @@ class GenericMultiProfilePagerAdapter<
context,
/* currentPage= */ defaultProfile,
emptyStateProvider,
- quietModeManager,
+ workProfileQuietModeChecker,
workProfileUserHandle);
mListAdapterExtractor = listAdapterExtractor;
diff --git a/java/src/com/android/intentresolver/ImageLoader.kt b/java/src/com/android/intentresolver/ImageLoader.kt
new file mode 100644
index 00000000..0ed8b122
--- /dev/null
+++ b/java/src/com/android/intentresolver/ImageLoader.kt
@@ -0,0 +1,26 @@
+/*
+ * 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?>)
+ fun prePopulate(uris: List<Uri>)
+}
diff --git a/java/src/com/android/intentresolver/ImagePreviewImageLoader.kt b/java/src/com/android/intentresolver/ImagePreviewImageLoader.kt
index e68eb66a..7b6651a2 100644
--- a/java/src/com/android/intentresolver/ImagePreviewImageLoader.kt
+++ b/java/src/com/android/intentresolver/ImagePreviewImageLoader.kt
@@ -16,23 +16,72 @@
package com.android.intentresolver
+import android.content.Context
import android.graphics.Bitmap
import android.net.Uri
-import kotlinx.coroutines.suspendCancellableCoroutine
-
-// TODO: convert ChooserContentPreviewCoordinator to Kotlin and merge this class into it.
-internal class ImagePreviewImageLoader(
- private val previewCoordinator: ChooserContentPreviewUi.ContentPreviewCoordinator
-) : suspend (Uri) -> Bitmap? {
-
- 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) {
- }
+import android.util.Size
+import androidx.annotation.GuardedBy
+import androidx.annotation.VisibleForTesting
+import androidx.collection.LruCache
+import androidx.lifecycle.Lifecycle
+import androidx.lifecycle.coroutineScope
+import kotlinx.coroutines.CompletableDeferred
+import kotlinx.coroutines.CoroutineDispatcher
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.isActive
+import kotlinx.coroutines.launch
+import java.util.function.Consumer
+
+@VisibleForTesting
+class ImagePreviewImageLoader @JvmOverloads constructor(
+ private val context: Context,
+ private val lifecycle: Lifecycle,
+ cacheSize: Int,
+ private val dispatcher: CoroutineDispatcher = Dispatchers.IO
+) : ImageLoader {
+
+ private val thumbnailSize: Size =
+ context.resources.getDimensionPixelSize(R.dimen.chooser_preview_image_max_dimen).let {
+ Size(it, it)
+ }
+
+ @GuardedBy("self")
+ private val cache = LruCache<Uri, CompletableDeferred<Bitmap?>>(cacheSize)
+
+ 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)
}
+ }
+
+ override fun prePopulate(uris: List<Uri>) {
+ uris.asSequence().take(cache.maxSize()).forEach { uri ->
+ lifecycle.coroutineScope.launch {
+ loadImageAsync(uri)
+ }
+ }
+ }
+
+ private suspend fun loadImageAsync(uri: Uri): Bitmap? {
+ return synchronized(cache) {
+ cache.get(uri) ?: CompletableDeferred<Bitmap?>().also { result ->
+ cache.put(uri, result)
+ lifecycle.coroutineScope.launch(dispatcher) {
+ result.loadBitmap(uri)
+ }
+ }
+ }.await()
+ }
+
+ private fun CompletableDeferred<Bitmap?>.loadBitmap(uri: Uri) {
+ val bitmap = runCatching {
+ context.contentResolver.loadThumbnail(uri, thumbnailSize, null)
+ }.getOrNull()
+ complete(bitmap)
+ }
}
diff --git a/java/src/com/android/intentresolver/NoAppsAvailableEmptyStateProvider.java b/java/src/com/android/intentresolver/NoAppsAvailableEmptyStateProvider.java
index 5bf994d6..c1373f4b 100644
--- a/java/src/com/android/intentresolver/NoAppsAvailableEmptyStateProvider.java
+++ b/java/src/com/android/intentresolver/NoAppsAvailableEmptyStateProvider.java
@@ -101,9 +101,9 @@ public class NoAppsAvailableEmptyStateProvider implements EmptyStateProvider {
if (mWorkProfileUserHandle == null) {
return false;
}
- List<ResolverActivity.ResolvedComponentInfo> resolversForIntent =
+ List<ResolvedComponentInfo> resolversForIntent =
adapter.getResolversForUser(UserHandle.of(mMyUserIdProvider.getMyUserId()));
- for (ResolverActivity.ResolvedComponentInfo info : resolversForIntent) {
+ for (ResolvedComponentInfo info : resolversForIntent) {
ResolveInfo resolveInfo = info.getResolveInfoAt(0);
if (resolveInfo.targetUserId != UserHandle.USER_CURRENT) {
return true;
@@ -151,4 +151,4 @@ public class NoAppsAvailableEmptyStateProvider implements EmptyStateProvider {
.write();
}
}
-} \ No newline at end of file
+}
diff --git a/java/src/com/android/intentresolver/ResolvedComponentInfo.java b/java/src/com/android/intentresolver/ResolvedComponentInfo.java
new file mode 100644
index 00000000..ecb72cbf
--- /dev/null
+++ b/java/src/com/android/intentresolver/ResolvedComponentInfo.java
@@ -0,0 +1,105 @@
+/*
+ * Copyright (C) 2023 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.intentresolver;
+
+import android.content.ComponentName;
+import android.content.Intent;
+import android.content.pm.ResolveInfo;
+
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * Record type to store all resolutions that are deduped to a single target component, along with
+ * other metadata about the component (which applies to all of the resolutions in the record).
+ * This record is assembled when we're first processing resolutions, and then later it's used to
+ * derive the {@link TargetInfo} record(s) that specify how the resolutions will be presented as
+ * targets in the UI.
+ */
+public final class ResolvedComponentInfo {
+ public final ComponentName name;
+ private final List<Intent> mIntents = new ArrayList<>();
+ private final List<ResolveInfo> mResolveInfos = new ArrayList<>();
+ private boolean mPinned;
+
+ /**
+ * @param name the name of the component that owns all the resolutions added to this record.
+ * @param intent an initial {@link Intent} to add to this record
+ * @param info the {@link ResolveInfo} associated with the given {@code intent}.
+ */
+ public ResolvedComponentInfo(ComponentName name, Intent intent, ResolveInfo info) {
+ this.name = name;
+ add(intent, info);
+ }
+
+ /**
+ * Add an {@link Intent} and associated {@link ResolveInfo} as resolutions for this component.
+ */
+ public void add(Intent intent, ResolveInfo info) {
+ mIntents.add(intent);
+ mResolveInfos.add(info);
+ }
+
+ /** @return the number of {@link Intent}/{@link ResolveInfo} pairs added to this record. */
+ public int getCount() {
+ return mIntents.size();
+ }
+
+ /** @return the {@link Intent} at the specified {@code index}, if any, or else null. */
+ public Intent getIntentAt(int index) {
+ return (index >= 0) ? mIntents.get(index) : null;
+ }
+
+ /** @return the {@link ResolveInfo} at the specified {@code index}, if any, or else null. */
+ public ResolveInfo getResolveInfoAt(int index) {
+ return (index >= 0) ? mResolveInfos.get(index) : null;
+ }
+
+ /**
+ * @return the index of the provided {@link Intent} among those that have been added to this
+ * {@link ResolvedComponentInfo}, or -1 if it has't been added.
+ */
+ public int findIntent(Intent intent) {
+ return mIntents.indexOf(intent);
+ }
+
+ /**
+ * @return the index of the provided {@link ResolveInfo} among those that have been added to
+ * this {@link ResolvedComponentInfo}, or -1 if it has't been added.
+ */
+ public int findResolveInfo(ResolveInfo info) {
+ return mResolveInfos.indexOf(info);
+ }
+
+ /**
+ * @return whether this component was pinned by a call to {@link #setPinned()}.
+ * TODO: consolidate sources of pinning data and/or document how this differs from other places
+ * we make a "pinning" determination.
+ */
+ public boolean isPinned() {
+ return mPinned;
+ }
+
+ /**
+ * Set whether this component will be considered pinned in future calls to {@link #isPinned()}.
+ * TODO: consolidate sources of pinning data and/or document how this differs from other places
+ * we make a "pinning" determination.
+ */
+ public void setPinned(boolean pinned) {
+ mPinned = pinned;
+ }
+}
diff --git a/java/src/com/android/intentresolver/ResolverActivity.java b/java/src/com/android/intentresolver/ResolverActivity.java
index 5573e18a..d224299e 100644
--- a/java/src/com/android/intentresolver/ResolverActivity.java
+++ b/java/src/com/android/intentresolver/ResolverActivity.java
@@ -44,7 +44,6 @@ import android.app.VoiceInteractor.PickOptionRequest.Option;
import android.app.VoiceInteractor.Prompt;
import android.app.admin.DevicePolicyEventLogger;
import android.app.admin.DevicePolicyManager;
-import android.content.BroadcastReceiver;
import android.content.ComponentName;
import android.content.Context;
import android.content.Intent;
@@ -61,7 +60,6 @@ import android.content.res.TypedArray;
import android.graphics.Insets;
import android.graphics.drawable.Drawable;
import android.net.Uri;
-import android.os.AsyncTask;
import android.os.Build;
import android.os.Bundle;
import android.os.PatternMatcher;
@@ -105,7 +103,6 @@ import com.android.intentresolver.AbstractMultiProfilePagerAdapter.EmptyStatePro
import com.android.intentresolver.AbstractMultiProfilePagerAdapter.MyUserIdProvider;
import com.android.intentresolver.AbstractMultiProfilePagerAdapter.OnSwitchOnWorkSelectedListener;
import com.android.intentresolver.AbstractMultiProfilePagerAdapter.Profile;
-import com.android.intentresolver.AbstractMultiProfilePagerAdapter.QuietModeManager;
import com.android.intentresolver.NoCrossProfileEmptyStateProvider.DevicePolicyBlockerEmptyState;
import com.android.intentresolver.chooser.DisplayResolveInfo;
import com.android.intentresolver.chooser.TargetInfo;
@@ -163,7 +160,6 @@ public class ResolverActivity extends FragmentActivity implements
protected boolean mSupportsAlwaysUseOption;
protected ResolverDrawerLayout mResolverDrawerLayout;
protected PackageManager mPm;
- protected int mLaunchedFromUid;
private static final String TAG = "ResolverActivity";
private static final boolean DEBUG = false;
@@ -192,7 +188,7 @@ public class ResolverActivity extends FragmentActivity implements
@VisibleForTesting
protected AbstractMultiProfilePagerAdapter mMultiProfilePagerAdapter;
- protected QuietModeManager mQuietModeManager;
+ protected WorkProfileAvailabilityManager mWorkProfileAvailability;
// Intent extra for connected audio devices
public static final String EXTRA_IS_AUDIO_CAPTURE_DEVICE = "is_audio_capture_device";
@@ -202,7 +198,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,15 +213,20 @@ 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;
- private Supplier<UserHandle> mLazyWorkProfileUserHandle = () -> {
- final UserHandle result = fetchWorkProfileUserProfile();
- mLazyWorkProfileUserHandle = () -> result;
+ // User handle annotations are lazy-initialized to ensure that they're computed exactly once
+ // (even though they can't be computed prior to activity creation).
+ // TODO: use a less ad-hoc pattern for lazy initialization (by switching to Dagger or
+ // introducing a common `LazySingletonSupplier` API, etc), and/or migrate all dependents to a
+ // new component whose lifecycle is limited to the "created" Activity (so that we can just hold
+ // the annotations as a `final` ivar, which is a better way to show immutability).
+ private Supplier<AnnotatedUserHandles> mLazyAnnotatedUserHandles = () -> {
+ final AnnotatedUserHandles result = new AnnotatedUserHandles(this);
+ mLazyAnnotatedUserHandles = () -> result;
return result;
};
@@ -234,22 +235,6 @@ public class ResolverActivity extends FragmentActivity implements
protected final LatencyTracker mLatencyTracker = getLatencyTracker();
- private LatencyTracker getLatencyTracker() {
- return LatencyTracker.getInstance(this);
- }
-
- /**
- * Get the string resource to be used as a label for the link to the resolver activity for an
- * action.
- *
- * @param action The action to resolve
- *
- * @return The string resource to be used as a label
- */
- public static @StringRes int getLabelRes(String action) {
- return ActionTitle.forAction(action).labelRes;
- }
-
private enum ActionTitle {
VIEW(Intent.ACTION_VIEW,
com.android.internal.R.string.whichViewApplication,
@@ -333,27 +318,6 @@ public class ResolverActivity extends FragmentActivity implements
};
}
- private Intent makeMyIntent() {
- Intent intent = new Intent(getIntent());
- intent.setComponent(null);
- // The resolver activity is set to be hidden from recent tasks.
- // we don't want this attribute to be propagated to the next activity
- // being launched. Note that if the original Intent also had this
- // flag set, we are now losing it. That should be a very rare case
- // and we can live with this.
- intent.setFlags(intent.getFlags()&~Intent.FLAG_ACTIVITY_EXCLUDE_FROM_RECENTS);
- return intent;
- }
-
- /**
- * Call {@link Activity#onCreate} without initializing anything further. This should
- * only be used when the activity is about to be immediately finished to avoid wasting
- * initializing steps and leaking resources.
- */
- protected void super_onCreate(Bundle savedInstanceState) {
- super.onCreate(savedInstanceState);
- }
-
@Override
protected void onCreate(Bundle savedInstanceState) {
// Use a specialized prompt when we're handling the 'Home' app startActivity()
@@ -389,18 +353,15 @@ public class ResolverActivity extends FragmentActivity implements
setTheme(appliedThemeResId());
super.onCreate(savedInstanceState);
- mQuietModeManager = createQuietModeManager();
-
// Determine whether we should show that intent is forwarded
// from managed profile to owner or other way around.
setProfileSwitchMessage(intent.getContentUserHint());
- mLaunchedFromUid = getLaunchedFromUid();
- if (mLaunchedFromUid < 0 || UserHandle.isIsolated(mLaunchedFromUid)) {
- // Gulp!
- finish();
- return;
- }
+ // Force computation of user handle annotations in order to validate the caller ID. (See the
+ // associated TODO comment to explain why this is structured as a lazy computation.)
+ AnnotatedUserHandles unusedReferenceToHandles = mLazyAnnotatedUserHandles.get();
+
+ mWorkProfileAvailability = createWorkProfileAvailabilityManager();
mPm = getPackageManager();
@@ -490,48 +451,6 @@ public class ResolverActivity extends FragmentActivity implements
return resolverMultiProfilePagerAdapter;
}
- @VisibleForTesting
- protected MyUserIdProvider createMyUserIdProvider() {
- return new MyUserIdProvider();
- }
-
- @VisibleForTesting
- protected CrossProfileIntentsChecker createCrossProfileIntentsChecker() {
- return new CrossProfileIntentsChecker(getContentResolver());
- }
-
- @VisibleForTesting
- protected QuietModeManager createQuietModeManager() {
- UserManager userManager = getSystemService(UserManager.class);
- return new QuietModeManager() {
-
- private boolean mIsWaitingToEnableWorkProfile = false;
-
- @Override
- public boolean isQuietModeEnabled(UserHandle workProfileUserHandle) {
- return userManager.isQuietModeEnabled(workProfileUserHandle);
- }
-
- @Override
- public void requestQuietModeEnabled(boolean enabled, UserHandle workProfileUserHandle) {
- AsyncTask.THREAD_POOL_EXECUTOR.execute(() -> {
- userManager.requestQuietModeEnabled(enabled, workProfileUserHandle);
- });
- mIsWaitingToEnableWorkProfile = true;
- }
-
- @Override
- public void markWorkProfileEnabledBroadcastReceived() {
- mIsWaitingToEnableWorkProfile = false;
- }
-
- @Override
- public boolean isWaitingToEnableWorkProfile() {
- return mIsWaitingToEnableWorkProfile;
- }
- };
- }
-
protected EmptyStateProvider createBlockerEmptyStateProvider() {
final boolean shouldShowNoCrossProfileIntentsEmptyState = getUser().equals(getIntentUser());
@@ -549,7 +468,8 @@ public class ResolverActivity extends FragmentActivity implements
/* defaultSubtitleResource= */
R.string.resolver_cant_access_personal_apps_explanation,
/* devicePolicyEventId= */ RESOLVER_EMPTY_STATE_NO_SHARING_TO_PERSONAL,
- /* devicePolicyEventCategory= */ ResolverActivity.METRICS_CATEGORY_RESOLVER);
+ /* devicePolicyEventCategory= */
+ ResolverActivity.METRICS_CATEGORY_RESOLVER);
final AbstractMultiProfilePagerAdapter.EmptyState noPersonalToWorkEmptyState =
new DevicePolicyBlockerEmptyState(/* context= */ this,
@@ -559,24 +479,605 @@ public class ResolverActivity extends FragmentActivity implements
/* defaultSubtitleResource= */
R.string.resolver_cant_access_work_apps_explanation,
/* devicePolicyEventId= */ RESOLVER_EMPTY_STATE_NO_SHARING_TO_WORK,
- /* devicePolicyEventCategory= */ ResolverActivity.METRICS_CATEGORY_RESOLVER);
+ /* devicePolicyEventCategory= */
+ ResolverActivity.METRICS_CATEGORY_RESOLVER);
return new NoCrossProfileEmptyStateProvider(getPersonalProfileUserHandle(),
noWorkToPersonalEmptyState, noPersonalToWorkEmptyState,
createCrossProfileIntentsChecker(), createMyUserIdProvider());
}
- protected EmptyStateProvider createEmptyStateProvider(
+ protected int appliedThemeResId() {
+ return R.style.Theme_DeviceDefault_Resolver;
+ }
+
+ /**
+ * Numerous layouts are supported, each with optional ViewGroups.
+ * Make sure the inset gets added to the correct View, using
+ * a footer for Lists so it can properly scroll under the navbar.
+ */
+ protected boolean shouldAddFooterView() {
+ if (useLayoutWithDefault()) return true;
+
+ View buttonBar = findViewById(com.android.internal.R.id.button_bar);
+ if (buttonBar == null || buttonBar.getVisibility() == View.GONE) return true;
+
+ return false;
+ }
+
+ protected void applyFooterView(int height) {
+ if (mFooterSpacer == null) {
+ mFooterSpacer = new Space(getApplicationContext());
+ } else {
+ ((ResolverMultiProfilePagerAdapter) mMultiProfilePagerAdapter)
+ .getActiveAdapterView().removeFooterView(mFooterSpacer);
+ }
+ mFooterSpacer.setLayoutParams(new AbsListView.LayoutParams(LayoutParams.MATCH_PARENT,
+ mSystemWindowInsets.bottom));
+ ((ResolverMultiProfilePagerAdapter) mMultiProfilePagerAdapter)
+ .getActiveAdapterView().addFooterView(mFooterSpacer);
+ }
+
+ protected WindowInsets onApplyWindowInsets(View v, WindowInsets insets) {
+ mSystemWindowInsets = insets.getSystemWindowInsets();
+
+ mResolverDrawerLayout.setPadding(mSystemWindowInsets.left, mSystemWindowInsets.top,
+ mSystemWindowInsets.right, 0);
+
+ resetButtonBar();
+
+ if (shouldUseMiniResolver()) {
+ View buttonContainer = findViewById(com.android.internal.R.id.button_bar_container);
+ buttonContainer.setPadding(0, 0, 0, mSystemWindowInsets.bottom
+ + getResources().getDimensionPixelOffset(R.dimen.resolver_button_bar_spacing));
+ }
+
+ // Need extra padding so the list can fully scroll up
+ if (shouldAddFooterView()) {
+ applyFooterView(mSystemWindowInsets.bottom);
+ }
+
+ return insets.consumeSystemWindowInsets();
+ }
+
+ @Override
+ public void onConfigurationChanged(Configuration newConfig) {
+ super.onConfigurationChanged(newConfig);
+ mMultiProfilePagerAdapter.getActiveListAdapter().handlePackagesChanged();
+ if (mIsIntentPicker && shouldShowTabs() && !useLayoutWithDefault()
+ && !shouldUseMiniResolver()) {
+ updateIntentPickerPaddings();
+ }
+
+ if (mSystemWindowInsets != null) {
+ mResolverDrawerLayout.setPadding(mSystemWindowInsets.left, mSystemWindowInsets.top,
+ mSystemWindowInsets.right, 0);
+ }
+ }
+
+ public int getLayoutResource() {
+ return R.layout.resolver_list;
+ }
+
+ @Override
+ protected void onStop() {
+ super.onStop();
+
+ final Window window = this.getWindow();
+ final WindowManager.LayoutParams attrs = window.getAttributes();
+ attrs.privateFlags &= ~SYSTEM_FLAG_HIDE_NON_SYSTEM_OVERLAY_WINDOWS;
+ window.setAttributes(attrs);
+
+ if (mRegistered) {
+ mPersonalPackageMonitor.unregister();
+ if (mWorkPackageMonitor != null) {
+ mWorkPackageMonitor.unregister();
+ }
+ mRegistered = false;
+ }
+ final Intent intent = getIntent();
+ if ((intent.getFlags() & FLAG_ACTIVITY_NEW_TASK) != 0 && !isVoiceInteraction()
+ && !mResolvingHome && !mRetainInOnStop) {
+ // This resolver is in the unusual situation where it has been
+ // launched at the top of a new task. We don't let it be added
+ // to the recent tasks shown to the user, and we need to make sure
+ // that each time we are launched we get the correct launching
+ // uid (not re-using the same resolver from an old launching uid),
+ // so we will now finish ourself since being no longer visible,
+ // the user probably can't get back to us.
+ if (!isChangingConfigurations()) {
+ finish();
+ }
+ }
+ // TODO: should we clean up the work-profile manager before we potentially finish() above?
+ mWorkProfileAvailability.unregisterWorkProfileStateReceiver(this);
+ }
+
+ @Override
+ protected void onDestroy() {
+ super.onDestroy();
+ if (!isChangingConfigurations() && mPickOptionRequest != null) {
+ mPickOptionRequest.cancel();
+ }
+ if (mMultiProfilePagerAdapter != null
+ && mMultiProfilePagerAdapter.getActiveListAdapter() != null) {
+ mMultiProfilePagerAdapter.getActiveListAdapter().onDestroy();
+ }
+ }
+
+ public void onButtonClick(View v) {
+ final int id = v.getId();
+ ListView listView = (ListView) mMultiProfilePagerAdapter.getActiveAdapterView();
+ ResolverListAdapter currentListAdapter = mMultiProfilePagerAdapter.getActiveListAdapter();
+ int which = currentListAdapter.hasFilteredItem()
+ ? currentListAdapter.getFilteredPosition()
+ : listView.getCheckedItemPosition();
+ boolean hasIndexBeenFiltered = !currentListAdapter.hasFilteredItem();
+ startSelected(which, id == com.android.internal.R.id.button_always, hasIndexBeenFiltered);
+ }
+
+ public void startSelected(int which, boolean always, boolean hasIndexBeenFiltered) {
+ if (isFinishing()) {
+ return;
+ }
+ ResolveInfo ri = mMultiProfilePagerAdapter.getActiveListAdapter()
+ .resolveInfoForPosition(which, hasIndexBeenFiltered);
+ if (mResolvingHome && hasManagedProfile() && !supportsManagedProfiles(ri)) {
+ Toast.makeText(this,
+ getWorkProfileNotSupportedMsg(
+ ri.activityInfo.loadLabel(getPackageManager()).toString()),
+ Toast.LENGTH_LONG).show();
+ return;
+ }
+
+ TargetInfo target = mMultiProfilePagerAdapter.getActiveListAdapter()
+ .targetInfoForPosition(which, hasIndexBeenFiltered);
+ if (target == null) {
+ return;
+ }
+ if (onTargetSelected(target, always)) {
+ if (always && mSupportsAlwaysUseOption) {
+ MetricsLogger.action(
+ this, MetricsProto.MetricsEvent.ACTION_APP_DISAMBIG_ALWAYS);
+ } else if (mSupportsAlwaysUseOption) {
+ MetricsLogger.action(
+ this, MetricsProto.MetricsEvent.ACTION_APP_DISAMBIG_JUST_ONCE);
+ } else {
+ MetricsLogger.action(
+ this, MetricsProto.MetricsEvent.ACTION_APP_DISAMBIG_TAP);
+ }
+ MetricsLogger.action(this,
+ mMultiProfilePagerAdapter.getActiveListAdapter().hasFilteredItem()
+ ? MetricsProto.MetricsEvent.ACTION_HIDE_APP_DISAMBIG_APP_FEATURED
+ : MetricsProto.MetricsEvent.ACTION_HIDE_APP_DISAMBIG_NONE_FEATURED);
+ finish();
+ }
+ }
+
+ /**
+ * Replace me in subclasses!
+ */
+ @Override // ResolverListCommunicator
+ public Intent getReplacementIntent(ActivityInfo aInfo, Intent defIntent) {
+ return defIntent;
+ }
+
+ protected void onListRebuilt(ResolverListAdapter listAdapter, boolean rebuildCompleted) {
+ final ItemClickListener listener = new ItemClickListener();
+ setupAdapterListView((ListView) mMultiProfilePagerAdapter.getActiveAdapterView(), listener);
+ if (shouldShowTabs() && mIsIntentPicker) {
+ final ResolverDrawerLayout rdl = findViewById(com.android.internal.R.id.contentPanel);
+ if (rdl != null) {
+ rdl.setMaxCollapsedHeight(getResources()
+ .getDimensionPixelSize(useLayoutWithDefault()
+ ? R.dimen.resolver_max_collapsed_height_with_default_with_tabs
+ : R.dimen.resolver_max_collapsed_height_with_tabs));
+ }
+ }
+ }
+
+ protected boolean onTargetSelected(TargetInfo target, boolean always) {
+ final ResolveInfo ri = target.getResolveInfo();
+ final Intent intent = target != null ? target.getResolvedIntent() : null;
+
+ if (intent != null && (mSupportsAlwaysUseOption
+ || mMultiProfilePagerAdapter.getActiveListAdapter().hasFilteredItem())
+ && mMultiProfilePagerAdapter.getActiveListAdapter().getUnfilteredResolveList() != null) {
+ // Build a reasonable intent filter, based on what matched.
+ IntentFilter filter = new IntentFilter();
+ Intent filterIntent;
+
+ if (intent.getSelector() != null) {
+ filterIntent = intent.getSelector();
+ } else {
+ filterIntent = intent;
+ }
+
+ String action = filterIntent.getAction();
+ if (action != null) {
+ filter.addAction(action);
+ }
+ Set<String> categories = filterIntent.getCategories();
+ if (categories != null) {
+ for (String cat : categories) {
+ filter.addCategory(cat);
+ }
+ }
+ filter.addCategory(Intent.CATEGORY_DEFAULT);
+
+ int cat = ri.match & IntentFilter.MATCH_CATEGORY_MASK;
+ Uri data = filterIntent.getData();
+ if (cat == IntentFilter.MATCH_CATEGORY_TYPE) {
+ String mimeType = filterIntent.resolveType(this);
+ if (mimeType != null) {
+ try {
+ filter.addDataType(mimeType);
+ } catch (IntentFilter.MalformedMimeTypeException e) {
+ Log.w("ResolverActivity", e);
+ filter = null;
+ }
+ }
+ }
+ if (data != null && data.getScheme() != null) {
+ // We need the data specification if there was no type,
+ // OR if the scheme is not one of our magical "file:"
+ // or "content:" schemes (see IntentFilter for the reason).
+ if (cat != IntentFilter.MATCH_CATEGORY_TYPE
+ || (!"file".equals(data.getScheme())
+ && !"content".equals(data.getScheme()))) {
+ filter.addDataScheme(data.getScheme());
+
+ // Look through the resolved filter to determine which part
+ // of it matched the original Intent.
+ Iterator<PatternMatcher> pIt = ri.filter.schemeSpecificPartsIterator();
+ if (pIt != null) {
+ String ssp = data.getSchemeSpecificPart();
+ while (ssp != null && pIt.hasNext()) {
+ PatternMatcher p = pIt.next();
+ if (p.match(ssp)) {
+ filter.addDataSchemeSpecificPart(p.getPath(), p.getType());
+ break;
+ }
+ }
+ }
+ Iterator<IntentFilter.AuthorityEntry> aIt = ri.filter.authoritiesIterator();
+ if (aIt != null) {
+ while (aIt.hasNext()) {
+ IntentFilter.AuthorityEntry a = aIt.next();
+ if (a.match(data) >= 0) {
+ int port = a.getPort();
+ filter.addDataAuthority(a.getHost(),
+ port >= 0 ? Integer.toString(port) : null);
+ break;
+ }
+ }
+ }
+ pIt = ri.filter.pathsIterator();
+ if (pIt != null) {
+ String path = data.getPath();
+ while (path != null && pIt.hasNext()) {
+ PatternMatcher p = pIt.next();
+ if (p.match(path)) {
+ filter.addDataPath(p.getPath(), p.getType());
+ break;
+ }
+ }
+ }
+ }
+ }
+
+ if (filter != null) {
+ final int N = mMultiProfilePagerAdapter.getActiveListAdapter()
+ .getUnfilteredResolveList().size();
+ ComponentName[] set;
+ // If we don't add back in the component for forwarding the intent to a managed
+ // profile, the preferred activity may not be updated correctly (as the set of
+ // components we tell it we knew about will have changed).
+ final boolean needToAddBackProfileForwardingComponent =
+ mMultiProfilePagerAdapter.getActiveListAdapter().getOtherProfile() != null;
+ if (!needToAddBackProfileForwardingComponent) {
+ set = new ComponentName[N];
+ } else {
+ set = new ComponentName[N + 1];
+ }
+
+ int bestMatch = 0;
+ for (int i=0; i<N; i++) {
+ ResolveInfo r = mMultiProfilePagerAdapter.getActiveListAdapter()
+ .getUnfilteredResolveList().get(i).getResolveInfoAt(0);
+ set[i] = new ComponentName(r.activityInfo.packageName,
+ r.activityInfo.name);
+ if (r.match > bestMatch) bestMatch = r.match;
+ }
+
+ if (needToAddBackProfileForwardingComponent) {
+ set[N] = mMultiProfilePagerAdapter.getActiveListAdapter()
+ .getOtherProfile().getResolvedComponentName();
+ final int otherProfileMatch = mMultiProfilePagerAdapter.getActiveListAdapter()
+ .getOtherProfile().getResolveInfo().match;
+ if (otherProfileMatch > bestMatch) bestMatch = otherProfileMatch;
+ }
+
+ if (always) {
+ final int userId = getUserId();
+ final PackageManager pm = getPackageManager();
+
+ // Set the preferred Activity
+ pm.addUniquePreferredActivity(filter, bestMatch, set, intent.getComponent());
+
+ if (ri.handleAllWebDataURI) {
+ // Set default Browser if needed
+ final String packageName = pm.getDefaultBrowserPackageNameAsUser(userId);
+ if (TextUtils.isEmpty(packageName)) {
+ pm.setDefaultBrowserPackageNameAsUser(ri.activityInfo.packageName, userId);
+ }
+ }
+ } else {
+ try {
+ mMultiProfilePagerAdapter.getActiveListAdapter()
+ .mResolverListController.setLastChosen(intent, filter, bestMatch);
+ } catch (RemoteException re) {
+ Log.d(TAG, "Error calling setLastChosenActivity\n" + re);
+ }
+ }
+ }
+ }
+
+ if (target != null) {
+ safelyStartActivity(target);
+
+ // Rely on the ActivityManager to pop up a dialog regarding app suspension
+ // and return false
+ if (target.isSuspended()) {
+ return false;
+ }
+ }
+
+ return true;
+ }
+
+ public void onActivityStarted(TargetInfo cti) {
+ // Do nothing
+ }
+
+ @Override // ResolverListCommunicator
+ public boolean shouldGetActivityMetadata() {
+ return false;
+ }
+
+ public boolean shouldAutoLaunchSingleChoice(TargetInfo target) {
+ return !target.isSuspended();
+ }
+
+ // TODO: this method takes an unused `UserHandle` because the override in `ChooserActivity` uses
+ // that data to set up other components as dependencies of the controller. In reality, these
+ // methods don't require polymorphism, because they're only invoked from within their respective
+ // concrete class; `ResolverActivity` will never call this method expecting to get a
+ // `ChooserListController` (subclass) result, because `ResolverActivity` only invokes this
+ // method as part of handling `createMultiProfilePagerAdapter()`, which is itself overridden in
+ // `ChooserActivity`. A future refactoring could better express the coupling between the adapter
+ // and controller types; in the meantime, structuring as an override (with matching signatures)
+ // shows that these methods are *structurally* related, and helps to prevent any regressions in
+ // the future if resolver *were* to make any (non-overridden) calls to a version that used a
+ // different signature (and thus didn't return the subclass type).
+ @VisibleForTesting
+ protected ResolverListController createListController(UserHandle unused) {
+ return new ResolverListController(
+ this,
+ mPm,
+ getTargetIntent(),
+ getReferrerPackageName(),
+ getAnnotatedUserHandles().userIdOfCallingApp);
+ }
+
+ /**
+ * Finishing procedures to be performed after the list has been rebuilt.
+ * </p>Subclasses must call postRebuildListInternal at the end of postRebuildList.
+ * @param rebuildCompleted
+ * @return <code>true</code> if the activity is finishing and creation should halt.
+ */
+ protected boolean postRebuildList(boolean rebuildCompleted) {
+ return postRebuildListInternal(rebuildCompleted);
+ }
+
+ void onHorizontalSwipeStateChanged(int state) {}
+
+ /**
+ * Callback called when user changes the profile tab.
+ * <p>This method is intended to be overridden by subclasses.
+ */
+ protected void onProfileTabSelected() { }
+
+ /**
+ * Add a label to signify that the user can pick a different app.
+ * @param adapter The adapter used to provide data to item views.
+ */
+ public void addUseDifferentAppLabelIfNecessary(ResolverListAdapter adapter) {
+ final boolean useHeader = adapter.hasFilteredItem();
+ if (useHeader) {
+ FrameLayout stub = findViewById(com.android.internal.R.id.stub);
+ stub.setVisibility(View.VISIBLE);
+ TextView textView = (TextView) LayoutInflater.from(this).inflate(
+ R.layout.resolver_different_item_header, null, false);
+ if (shouldShowTabs()) {
+ textView.setGravity(Gravity.CENTER);
+ }
+ stub.addView(textView);
+ }
+ }
+
+ protected void resetButtonBar() {
+ if (!mSupportsAlwaysUseOption) {
+ return;
+ }
+ final ViewGroup buttonLayout = findViewById(com.android.internal.R.id.button_bar);
+ if (buttonLayout == null) {
+ Log.e(TAG, "Layout unexpectedly does not have a button bar");
+ return;
+ }
+ ResolverListAdapter activeListAdapter =
+ mMultiProfilePagerAdapter.getActiveListAdapter();
+ View buttonBarDivider = findViewById(com.android.internal.R.id.resolver_button_bar_divider);
+ if (!useLayoutWithDefault()) {
+ int inset = mSystemWindowInsets != null ? mSystemWindowInsets.bottom : 0;
+ buttonLayout.setPadding(buttonLayout.getPaddingLeft(), buttonLayout.getPaddingTop(),
+ buttonLayout.getPaddingRight(), getResources().getDimensionPixelSize(
+ R.dimen.resolver_button_bar_spacing) + inset);
+ }
+ if (activeListAdapter.isTabLoaded()
+ && mMultiProfilePagerAdapter.shouldShowEmptyStateScreen(activeListAdapter)
+ && !useLayoutWithDefault()) {
+ buttonLayout.setVisibility(View.INVISIBLE);
+ if (buttonBarDivider != null) {
+ buttonBarDivider.setVisibility(View.INVISIBLE);
+ }
+ setButtonBarIgnoreOffset(/* ignoreOffset */ false);
+ return;
+ }
+ if (buttonBarDivider != null) {
+ buttonBarDivider.setVisibility(View.VISIBLE);
+ }
+ buttonLayout.setVisibility(View.VISIBLE);
+ setButtonBarIgnoreOffset(/* ignoreOffset */ true);
+
+ mOnceButton = (Button) buttonLayout.findViewById(com.android.internal.R.id.button_once);
+ mAlwaysButton = (Button) buttonLayout.findViewById(com.android.internal.R.id.button_always);
+
+ resetAlwaysOrOnceButtonBar();
+ }
+
+ protected String getMetricsCategory() {
+ return METRICS_CATEGORY_RESOLVER;
+ }
+
+ @Override // ResolverListCommunicator
+ public void onHandlePackagesChanged(ResolverListAdapter listAdapter) {
+ if (listAdapter == mMultiProfilePagerAdapter.getActiveListAdapter()) {
+ if (listAdapter.getUserHandle().equals(getWorkProfileUserHandle())
+ && mWorkProfileAvailability.isWaitingToEnableWorkProfile()) {
+ // We have just turned on the work profile and entered the pass code to start it,
+ // now we are waiting to receive the ACTION_USER_UNLOCKED broadcast. There is no
+ // point in reloading the list now, since the work profile user is still
+ // turning on.
+ return;
+ }
+ boolean listRebuilt = mMultiProfilePagerAdapter.rebuildActiveTab(true);
+ if (listRebuilt) {
+ ResolverListAdapter activeListAdapter =
+ mMultiProfilePagerAdapter.getActiveListAdapter();
+ activeListAdapter.notifyDataSetChanged();
+ if (activeListAdapter.getCount() == 0 && !inactiveListAdapterHasItems()) {
+ // We no longer have any items... just finish the activity.
+ finish();
+ }
+ }
+ } else {
+ mMultiProfilePagerAdapter.clearInactiveProfileCache();
+ }
+ }
+
+ protected void maybeLogProfileChange() {}
+
+ // @NonFinalForTesting
+ @VisibleForTesting
+ protected MyUserIdProvider createMyUserIdProvider() {
+ return new MyUserIdProvider();
+ }
+
+ // @NonFinalForTesting
+ @VisibleForTesting
+ protected CrossProfileIntentsChecker createCrossProfileIntentsChecker() {
+ return new CrossProfileIntentsChecker(getContentResolver());
+ }
+
+ // @NonFinalForTesting
+ @VisibleForTesting
+ protected WorkProfileAvailabilityManager createWorkProfileAvailabilityManager() {
+ final UserHandle workUser = getWorkProfileUserHandle();
+
+ return new WorkProfileAvailabilityManager(
+ getSystemService(UserManager.class),
+ workUser,
+ () -> {
+ if (mMultiProfilePagerAdapter.getCurrentUserHandle().equals(workUser)) {
+ mMultiProfilePagerAdapter.rebuildActiveTab(true);
+ } else {
+ mMultiProfilePagerAdapter.clearInactiveProfileCache();
+ }
+ });
+ }
+
+ // TODO: have tests override `getAnnotatedUserHandles()`, and make this method `final`.
+ // @NonFinalForTesting
+ @Nullable
+ protected UserHandle getWorkProfileUserHandle() {
+ return getAnnotatedUserHandles().workProfileUserHandle;
+ }
+
+ // @NonFinalForTesting
+ @VisibleForTesting
+ public void safelyStartActivity(TargetInfo cti) {
+ // We're dispatching intents that might be coming from legacy apps, so
+ // don't kill ourselves.
+ StrictMode.disableDeathOnFileUriExposure();
+ try {
+ UserHandle currentUserHandle = mMultiProfilePagerAdapter.getCurrentUserHandle();
+ safelyStartActivityInternal(cti, currentUserHandle, null);
+ } finally {
+ StrictMode.enableDeathOnFileUriExposure();
+ }
+ }
+
+ // @NonFinalForTesting
+ @VisibleForTesting
+ protected ResolverListAdapter createResolverListAdapter(Context context,
+ List<Intent> payloadIntents, Intent[] initialIntents, List<ResolveInfo> rList,
+ boolean filterLastUsed, UserHandle userHandle) {
+ Intent startIntent = getIntent();
+ boolean isAudioCaptureDevice =
+ startIntent.getBooleanExtra(EXTRA_IS_AUDIO_CAPTURE_DEVICE, false);
+ return new ResolverListAdapter(
+ context,
+ payloadIntents,
+ initialIntents,
+ rList,
+ filterLastUsed,
+ createListController(userHandle),
+ userHandle,
+ getTargetIntent(),
+ this,
+ isAudioCaptureDevice);
+ }
+
+ private LatencyTracker getLatencyTracker() {
+ return LatencyTracker.getInstance(this);
+ }
+
+ /**
+ * Get the string resource to be used as a label for the link to the resolver activity for an
+ * action.
+ *
+ * @param action The action to resolve
+ *
+ * @return The string resource to be used as a label
+ */
+ public static @StringRes int getLabelRes(String action) {
+ return ActionTitle.forAction(action).labelRes;
+ }
+
+ protected final EmptyStateProvider createEmptyStateProvider(
@Nullable UserHandle workProfileUserHandle) {
final EmptyStateProvider blockerEmptyStateProvider = createBlockerEmptyStateProvider();
final EmptyStateProvider workProfileOffEmptyStateProvider =
new WorkProfilePausedEmptyStateProvider(this, workProfileUserHandle,
- mQuietModeManager,
+ mWorkProfileAvailability,
/* onSwitchOnWorkSelectedListener= */
- () -> { if (mOnSwitchOnWorkSelectedListener != null) {
- mOnSwitchOnWorkSelectedListener.onSwitchOnWorkSelected();
- }},
+ () -> {
+ if (mOnSwitchOnWorkSelectedListener != null) {
+ mOnSwitchOnWorkSelectedListener.onSwitchOnWorkSelected();
+ }
+ },
getMetricsCategory());
final EmptyStateProvider noAppsEmptyStateProvider = new NoAppsAvailableEmptyStateProvider(
@@ -595,9 +1096,32 @@ public class ResolverActivity extends FragmentActivity implements
);
}
- private ResolverMultiProfilePagerAdapter createResolverMultiProfilePagerAdapterForOneProfile(
- Intent[] initialIntents,
- List<ResolveInfo> rList, boolean filterLastUsed) {
+ private Intent makeMyIntent() {
+ Intent intent = new Intent(getIntent());
+ intent.setComponent(null);
+ // The resolver activity is set to be hidden from recent tasks.
+ // we don't want this attribute to be propagated to the next activity
+ // being launched. Note that if the original Intent also had this
+ // flag set, we are now losing it. That should be a very rare case
+ // and we can live with this.
+ intent.setFlags(intent.getFlags() & ~Intent.FLAG_ACTIVITY_EXCLUDE_FROM_RECENTS);
+ return intent;
+ }
+
+ /**
+ * Call {@link Activity#onCreate} without initializing anything further. This should
+ * only be used when the activity is about to be immediately finished to avoid wasting
+ * initializing steps and leaking resources.
+ */
+ protected final void super_onCreate(Bundle savedInstanceState) {
+ super.onCreate(savedInstanceState);
+ }
+
+ private ResolverMultiProfilePagerAdapter
+ createResolverMultiProfilePagerAdapterForOneProfile(
+ Intent[] initialIntents,
+ List<ResolveInfo> rList,
+ boolean filterLastUsed) {
ResolverListAdapter adapter = createResolverListAdapter(
/* context */ this,
/* payloadIntents */ mIntents,
@@ -605,12 +1129,11 @@ public class ResolverActivity extends FragmentActivity implements
rList,
filterLastUsed,
/* userHandle */ UserHandle.of(UserHandle.myUserId()));
- QuietModeManager quietModeManager = createQuietModeManager();
return new ResolverMultiProfilePagerAdapter(
/* context */ this,
adapter,
createEmptyStateProvider(/* workProfileUserHandle= */ null),
- quietModeManager,
+ /* workProfileQuietModeChecker= */ () -> false,
/* workProfileUserHandle= */ null);
}
@@ -661,28 +1184,23 @@ public class ResolverActivity extends FragmentActivity implements
(filterLastUsed && UserHandle.myUserId()
== workProfileUserHandle.getIdentifier()),
/* userHandle */ workProfileUserHandle);
- QuietModeManager quietModeManager = createQuietModeManager();
return new ResolverMultiProfilePagerAdapter(
/* context */ this,
personalAdapter,
workAdapter,
createEmptyStateProvider(getWorkProfileUserHandle()),
- quietModeManager,
+ () -> mWorkProfileAvailability.isQuietModeEnabled(),
selectedProfile,
getWorkProfileUserHandle());
}
- protected int appliedThemeResId() {
- return R.style.Theme_DeviceDefault_Resolver;
- }
-
/**
* Returns {@link #PROFILE_PERSONAL} or {@link #PROFILE_WORK} if the {@link
* #EXTRA_SELECTED_PROFILE} extra was supplied, or {@code -1} if no extra was supplied.
* @throws IllegalArgumentException if the value passed to the {@link #EXTRA_SELECTED_PROFILE}
* extra is not {@link #PROFILE_PERSONAL} or {@link #PROFILE_WORK}
*/
- int getSelectedProfileExtra() {
+ final int getSelectedProfileExtra() {
int selectedProfile = -1;
if (getIntent().hasExtra(EXTRA_SELECTED_PROFILE)) {
selectedProfile = getIntent().getIntExtra(EXTRA_SELECTED_PROFILE, /* defValue = */ -1);
@@ -695,43 +1213,27 @@ public class ResolverActivity extends FragmentActivity implements
return selectedProfile;
}
- protected @Profile int getCurrentProfile() {
+ protected final @Profile int getCurrentProfile() {
return (UserHandle.myUserId() == UserHandle.USER_SYSTEM ? PROFILE_PERSONAL : PROFILE_WORK);
}
- protected UserHandle getPersonalProfileUserHandle() {
- return UserHandle.of(ActivityManager.getCurrentUser());
+ protected final AnnotatedUserHandles getAnnotatedUserHandles() {
+ return mLazyAnnotatedUserHandles.get();
}
- @Nullable
- protected UserHandle getWorkProfileUserHandle() {
- return mLazyWorkProfileUserHandle.get();
- }
-
- @Nullable
- private UserHandle fetchWorkProfileUserProfile() {
- UserManager userManager = getSystemService(UserManager.class);
- if (userManager == null) {
- return null;
- }
- UserHandle result = null;
- for (final UserInfo userInfo : userManager.getProfiles(ActivityManager.getCurrentUser())) {
- if (userInfo.isManagedProfile()) {
- result = userInfo.getUserHandle();
- }
- }
- return result;
+ protected final UserHandle getPersonalProfileUserHandle() {
+ return getAnnotatedUserHandles().personalProfileUserHandle;
}
private boolean hasWorkProfile() {
return getWorkProfileUserHandle() != null;
}
- protected boolean shouldShowTabs() {
+ protected final boolean shouldShowTabs() {
return hasWorkProfile();
}
- protected void onProfileClick(View v) {
+ protected final void onProfileClick(View v) {
final DisplayResolveInfo dri =
mMultiProfilePagerAdapter.getActiveListAdapter().getOtherProfile();
if (dri == null) {
@@ -745,70 +1247,6 @@ public class ResolverActivity extends FragmentActivity implements
finish();
}
- /**
- * Numerous layouts are supported, each with optional ViewGroups.
- * Make sure the inset gets added to the correct View, using
- * a footer for Lists so it can properly scroll under the navbar.
- */
- protected boolean shouldAddFooterView() {
- if (useLayoutWithDefault()) return true;
-
- View buttonBar = findViewById(com.android.internal.R.id.button_bar);
- if (buttonBar == null || buttonBar.getVisibility() == View.GONE) return true;
-
- return false;
- }
-
- protected void applyFooterView(int height) {
- if (mFooterSpacer == null) {
- mFooterSpacer = new Space(getApplicationContext());
- } else {
- ((ResolverMultiProfilePagerAdapter) mMultiProfilePagerAdapter)
- .getActiveAdapterView().removeFooterView(mFooterSpacer);
- }
- mFooterSpacer.setLayoutParams(new AbsListView.LayoutParams(LayoutParams.MATCH_PARENT,
- mSystemWindowInsets.bottom));
- ((ResolverMultiProfilePagerAdapter) mMultiProfilePagerAdapter)
- .getActiveAdapterView().addFooterView(mFooterSpacer);
- }
-
- protected WindowInsets onApplyWindowInsets(View v, WindowInsets insets) {
- mSystemWindowInsets = insets.getSystemWindowInsets();
-
- mResolverDrawerLayout.setPadding(mSystemWindowInsets.left, mSystemWindowInsets.top,
- mSystemWindowInsets.right, 0);
-
- resetButtonBar();
-
- if (shouldUseMiniResolver()) {
- View buttonContainer = findViewById(com.android.internal.R.id.button_bar_container);
- buttonContainer.setPadding(0, 0, 0, mSystemWindowInsets.bottom
- + getResources().getDimensionPixelOffset(R.dimen.resolver_button_bar_spacing));
- }
-
- // Need extra padding so the list can fully scroll up
- if (shouldAddFooterView()) {
- applyFooterView(mSystemWindowInsets.bottom);
- }
-
- return insets.consumeSystemWindowInsets();
- }
-
- @Override
- public void onConfigurationChanged(Configuration newConfig) {
- super.onConfigurationChanged(newConfig);
- mMultiProfilePagerAdapter.getActiveListAdapter().handlePackagesChanged();
- if (mIsIntentPicker && shouldShowTabs() && !useLayoutWithDefault()
- && !shouldUseMiniResolver()) {
- updateIntentPickerPaddings();
- }
-
- if (mSystemWindowInsets != null) {
- mResolverDrawerLayout.setPadding(mSystemWindowInsets.left, mSystemWindowInsets.top,
- mSystemWindowInsets.right, 0);
- }
- }
-
private void updateIntentPickerPaddings() {
View titleCont = findViewById(com.android.internal.R.id.title_container);
titleCont.setPadding(
@@ -824,8 +1262,20 @@ public class ResolverActivity extends FragmentActivity implements
getResources().getDimensionPixelSize(R.dimen.resolver_button_bar_spacing));
}
+ private void maybeLogCrossProfileTargetLaunch(TargetInfo cti, UserHandle currentUserHandle) {
+ if (!hasWorkProfile() || currentUserHandle.equals(getUser())) {
+ return;
+ }
+ DevicePolicyEventLogger
+ .createEvent(DevicePolicyEnums.RESOLVER_CROSS_PROFILE_TARGET_OPENED)
+ .setBoolean(currentUserHandle.equals(getPersonalProfileUserHandle()))
+ .setStrings(getMetricsCategory(),
+ cti.isInDirectShareMetricsCategory() ? "direct_share" : "other_target")
+ .write();
+ }
+
@Override // ResolverListCommunicator
- public void sendVoiceChoicesIfNeeded() {
+ public final void sendVoiceChoicesIfNeeded() {
if (!isVoiceInteraction()) {
// Clearly not needed.
return;
@@ -833,7 +1283,7 @@ public class ResolverActivity extends FragmentActivity implements
int count = mMultiProfilePagerAdapter.getActiveListAdapter().getCount();
final Option[] options = new Option[count];
- for (int i = 0, N = options.length; i < N; i++) {
+ for (int i = 0; i < options.length; i++) {
TargetInfo target = mMultiProfilePagerAdapter.getActiveListAdapter().getItem(i);
if (target == null) {
// If this occurs, a new set of targets is being loaded. Let that complete,
@@ -848,7 +1298,7 @@ public class ResolverActivity extends FragmentActivity implements
getVoiceInteractor().submitRequest(mPickOptionRequest);
}
- Option optionForChooserTarget(TargetInfo target, int index) {
+ final Option optionForChooserTarget(TargetInfo target, int index) {
return new Option(target.getDisplayLabel(), index);
}
@@ -860,11 +1310,11 @@ public class ResolverActivity extends FragmentActivity implements
}
}
- public Intent getTargetIntent() {
+ public final Intent getTargetIntent() {
return mIntents.isEmpty() ? null : mIntents.get(0);
}
- protected String getReferrerPackageName() {
+ protected final String getReferrerPackageName() {
final Uri referrer = getReferrer();
if (referrer != null && "android-app".equals(referrer.getScheme())) {
return referrer.getHost();
@@ -872,12 +1322,8 @@ public class ResolverActivity extends FragmentActivity implements
return null;
}
- public int getLayoutResource() {
- return R.layout.resolver_list;
- }
-
@Override // ResolverListCommunicator
- public void updateProfileViewButton() {
+ public final void updateProfileViewButton() {
if (mProfileView == null) {
return;
}
@@ -897,8 +1343,8 @@ public class ResolverActivity extends FragmentActivity implements
}
private void setProfileSwitchMessage(int contentUserHint) {
- if (contentUserHint != UserHandle.USER_CURRENT &&
- contentUserHint != UserHandle.myUserId()) {
+ if ((contentUserHint != UserHandle.USER_CURRENT)
+ && (contentUserHint != UserHandle.myUserId())) {
UserManager userManager = (UserManager) getSystemService(Context.USER_SERVICE);
UserInfo originUserInfo = userManager.getUserInfo(contentUserHint);
boolean originIsManaged = originUserInfo != null ? originUserInfo.isManagedProfile()
@@ -936,11 +1382,11 @@ public class ResolverActivity extends FragmentActivity implements
* more detailed onCreate methods, so that it will be set correctly in the case where
* there is only one intent to resolve and it is thus started immediately.</p>
*/
- public void setSafeForwardingMode(boolean safeForwarding) {
+ public final void setSafeForwardingMode(boolean safeForwarding) {
mSafeForwardingMode = safeForwarding;
}
- protected CharSequence getTitleForAction(Intent intent, int defaultTitleRes) {
+ protected final CharSequence getTitleForAction(Intent intent, int defaultTitleRes) {
final ActionTitle title = mResolvingHome
? ActionTitle.HOME
: ActionTitle.forAction(intent.getAction());
@@ -959,14 +1405,14 @@ public class ResolverActivity extends FragmentActivity implements
}
}
- void dismiss() {
+ final void dismiss() {
if (!isFinishing()) {
finish();
}
}
@Override
- protected void onRestart() {
+ protected final void onRestart() {
super.onRestart();
if (!mRegistered) {
mPersonalPackageMonitor.register(this, getMainLooper(),
@@ -981,9 +1427,9 @@ public class ResolverActivity extends FragmentActivity implements
}
mRegistered = true;
}
- if (shouldShowTabs() && mQuietModeManager.isWaitingToEnableWorkProfile()) {
- if (mQuietModeManager.isQuietModeEnabled(getWorkProfileUserHandle())) {
- mQuietModeManager.markWorkProfileEnabledBroadcastReceived();
+ if (shouldShowTabs() && mWorkProfileAvailability.isWaitingToEnableWorkProfile()) {
+ if (mWorkProfileAvailability.isQuietModeEnabled()) {
+ mWorkProfileAvailability.markWorkProfileEnabledBroadcastReceived();
}
}
mMultiProfilePagerAdapter.getActiveListAdapter().handlePackagesChanged();
@@ -991,84 +1437,17 @@ public class ResolverActivity extends FragmentActivity implements
}
@Override
- protected void onStart() {
+ protected final void onStart() {
super.onStart();
this.getWindow().addSystemFlags(SYSTEM_FLAG_HIDE_NON_SYSTEM_OVERLAY_WINDOWS);
if (shouldShowTabs()) {
- mWorkProfileStateReceiver = createWorkProfileStateReceiver();
- registerWorkProfileStateReceiver();
-
- mWorkProfileHasBeenEnabled = isWorkProfileEnabled();
- }
- }
-
- private boolean isWorkProfileEnabled() {
- UserHandle workUserHandle = getWorkProfileUserHandle();
- UserManager userManager = getSystemService(UserManager.class);
-
- return !userManager.isQuietModeEnabled(workUserHandle)
- && userManager.isUserUnlocked(workUserHandle);
- }
-
- private void registerWorkProfileStateReceiver() {
- IntentFilter filter = new IntentFilter();
- filter.addAction(Intent.ACTION_USER_UNLOCKED);
- filter.addAction(Intent.ACTION_MANAGED_PROFILE_AVAILABLE);
- filter.addAction(Intent.ACTION_MANAGED_PROFILE_UNAVAILABLE);
- registerReceiverAsUser(mWorkProfileStateReceiver, UserHandle.ALL, filter, null, null);
- }
-
- @Override
- protected void onStop() {
- super.onStop();
-
- final Window window = this.getWindow();
- final WindowManager.LayoutParams attrs = window.getAttributes();
- attrs.privateFlags &= ~SYSTEM_FLAG_HIDE_NON_SYSTEM_OVERLAY_WINDOWS;
- window.setAttributes(attrs);
-
- if (mRegistered) {
- mPersonalPackageMonitor.unregister();
- if (mWorkPackageMonitor != null) {
- mWorkPackageMonitor.unregister();
- }
- mRegistered = false;
- }
- final Intent intent = getIntent();
- if ((intent.getFlags() & FLAG_ACTIVITY_NEW_TASK) != 0 && !isVoiceInteraction()
- && !mResolvingHome && !mRetainInOnStop) {
- // This resolver is in the unusual situation where it has been
- // launched at the top of a new task. We don't let it be added
- // to the recent tasks shown to the user, and we need to make sure
- // that each time we are launched we get the correct launching
- // uid (not re-using the same resolver from an old launching uid),
- // so we will now finish ourself since being no longer visible,
- // the user probably can't get back to us.
- if (!isChangingConfigurations()) {
- finish();
- }
- }
- if (mWorkPackageMonitor != null) {
- unregisterReceiver(mWorkProfileStateReceiver);
- mWorkPackageMonitor = null;
+ mWorkProfileAvailability.registerWorkProfileStateReceiver(this);
}
}
@Override
- protected void onDestroy() {
- super.onDestroy();
- if (!isChangingConfigurations() && mPickOptionRequest != null) {
- mPickOptionRequest.cancel();
- }
- if (mMultiProfilePagerAdapter != null
- && mMultiProfilePagerAdapter.getActiveListAdapter() != null) {
- mMultiProfilePagerAdapter.getActiveListAdapter().onDestroy();
- }
- }
-
- @Override
- protected void onSaveInstanceState(Bundle outState) {
+ protected final void onSaveInstanceState(Bundle outState) {
super.onSaveInstanceState(outState);
ViewPager viewPager = findViewById(com.android.internal.R.id.profile_pager);
if (viewPager != null) {
@@ -1077,7 +1456,7 @@ public class ResolverActivity extends FragmentActivity implements
}
@Override
- protected void onRestoreInstanceState(Bundle savedInstanceState) {
+ protected final void onRestoreInstanceState(Bundle savedInstanceState) {
super.onRestoreInstanceState(savedInstanceState);
resetButtonBar();
ViewPager viewPager = findViewById(com.android.internal.R.id.profile_pager);
@@ -1161,55 +1540,6 @@ public class ResolverActivity extends FragmentActivity implements
mAlwaysButton.setEnabled(enabled);
}
- public void onButtonClick(View v) {
- final int id = v.getId();
- ListView listView = (ListView) mMultiProfilePagerAdapter.getActiveAdapterView();
- ResolverListAdapter currentListAdapter = mMultiProfilePagerAdapter.getActiveListAdapter();
- int which = currentListAdapter.hasFilteredItem()
- ? currentListAdapter.getFilteredPosition()
- : listView.getCheckedItemPosition();
- boolean hasIndexBeenFiltered = !currentListAdapter.hasFilteredItem();
- startSelected(which, id == com.android.internal.R.id.button_always, hasIndexBeenFiltered);
- }
-
- public void startSelected(int which, boolean always, boolean hasIndexBeenFiltered) {
- if (isFinishing()) {
- return;
- }
- ResolveInfo ri = mMultiProfilePagerAdapter.getActiveListAdapter()
- .resolveInfoForPosition(which, hasIndexBeenFiltered);
- if (mResolvingHome && hasManagedProfile() && !supportsManagedProfiles(ri)) {
- Toast.makeText(this,
- getWorkProfileNotSupportedMsg(
- ri.activityInfo.loadLabel(getPackageManager()).toString()),
- Toast.LENGTH_LONG).show();
- return;
- }
-
- TargetInfo target = mMultiProfilePagerAdapter.getActiveListAdapter()
- .targetInfoForPosition(which, hasIndexBeenFiltered);
- if (target == null) {
- return;
- }
- if (onTargetSelected(target, always)) {
- if (always && mSupportsAlwaysUseOption) {
- MetricsLogger.action(
- this, MetricsProto.MetricsEvent.ACTION_APP_DISAMBIG_ALWAYS);
- } else if (mSupportsAlwaysUseOption) {
- MetricsLogger.action(
- this, MetricsProto.MetricsEvent.ACTION_APP_DISAMBIG_JUST_ONCE);
- } else {
- MetricsLogger.action(
- this, MetricsProto.MetricsEvent.ACTION_APP_DISAMBIG_TAP);
- }
- MetricsLogger.action(this,
- mMultiProfilePagerAdapter.getActiveListAdapter().hasFilteredItem()
- ? MetricsProto.MetricsEvent.ACTION_HIDE_APP_DISAMBIG_APP_FEATURED
- : MetricsProto.MetricsEvent.ACTION_HIDE_APP_DISAMBIG_NONE_FEATURED);
- finish();
- }
- }
-
private String getWorkProfileNotSupportedMsg(String launcherName) {
return getSystemService(DevicePolicyManager.class).getResources().getString(
RESOLVER_WORK_PROFILE_NOT_SUPPORTED,
@@ -1219,14 +1549,6 @@ public class ResolverActivity extends FragmentActivity implements
launcherName);
}
- /**
- * Replace me in subclasses!
- */
- @Override // ResolverListCommunicator
- public Intent getReplacementIntent(ActivityInfo aInfo, Intent defIntent) {
- return defIntent;
- }
-
@Override // ResolverListCommunicator
public final void onPostListReady(ResolverListAdapter listAdapter, boolean doPostProcessing,
boolean rebuildCompleted) {
@@ -1254,204 +1576,17 @@ public class ResolverActivity extends FragmentActivity implements
}
}
- protected void onListRebuilt(ResolverListAdapter listAdapter, boolean rebuildCompleted) {
- final ItemClickListener listener = new ItemClickListener();
- setupAdapterListView((ListView) mMultiProfilePagerAdapter.getActiveAdapterView(), listener);
- if (shouldShowTabs() && mIsIntentPicker) {
- final ResolverDrawerLayout rdl = findViewById(com.android.internal.R.id.contentPanel);
- if (rdl != null) {
- rdl.setMaxCollapsedHeight(getResources()
- .getDimensionPixelSize(useLayoutWithDefault()
- ? R.dimen.resolver_max_collapsed_height_with_default_with_tabs
- : R.dimen.resolver_max_collapsed_height_with_tabs));
- }
- }
- }
-
- protected boolean onTargetSelected(TargetInfo target, boolean always) {
- final ResolveInfo ri = target.getResolveInfo();
- final Intent intent = target != null ? target.getResolvedIntent() : null;
-
- if (intent != null && (mSupportsAlwaysUseOption
- || mMultiProfilePagerAdapter.getActiveListAdapter().hasFilteredItem())
- && mMultiProfilePagerAdapter.getActiveListAdapter().getUnfilteredResolveList() != null) {
- // Build a reasonable intent filter, based on what matched.
- IntentFilter filter = new IntentFilter();
- Intent filterIntent;
-
- if (intent.getSelector() != null) {
- filterIntent = intent.getSelector();
- } else {
- filterIntent = intent;
- }
-
- String action = filterIntent.getAction();
- if (action != null) {
- filter.addAction(action);
- }
- Set<String> categories = filterIntent.getCategories();
- if (categories != null) {
- for (String cat : categories) {
- filter.addCategory(cat);
- }
- }
- filter.addCategory(Intent.CATEGORY_DEFAULT);
-
- int cat = ri.match & IntentFilter.MATCH_CATEGORY_MASK;
- Uri data = filterIntent.getData();
- if (cat == IntentFilter.MATCH_CATEGORY_TYPE) {
- String mimeType = filterIntent.resolveType(this);
- if (mimeType != null) {
- try {
- filter.addDataType(mimeType);
- } catch (IntentFilter.MalformedMimeTypeException e) {
- Log.w("ResolverActivity", e);
- filter = null;
- }
- }
- }
- if (data != null && data.getScheme() != null) {
- // We need the data specification if there was no type,
- // OR if the scheme is not one of our magical "file:"
- // or "content:" schemes (see IntentFilter for the reason).
- if (cat != IntentFilter.MATCH_CATEGORY_TYPE
- || (!"file".equals(data.getScheme())
- && !"content".equals(data.getScheme()))) {
- filter.addDataScheme(data.getScheme());
-
- // Look through the resolved filter to determine which part
- // of it matched the original Intent.
- Iterator<PatternMatcher> pIt = ri.filter.schemeSpecificPartsIterator();
- if (pIt != null) {
- String ssp = data.getSchemeSpecificPart();
- while (ssp != null && pIt.hasNext()) {
- PatternMatcher p = pIt.next();
- if (p.match(ssp)) {
- filter.addDataSchemeSpecificPart(p.getPath(), p.getType());
- break;
- }
- }
- }
- Iterator<IntentFilter.AuthorityEntry> aIt = ri.filter.authoritiesIterator();
- if (aIt != null) {
- while (aIt.hasNext()) {
- IntentFilter.AuthorityEntry a = aIt.next();
- if (a.match(data) >= 0) {
- int port = a.getPort();
- filter.addDataAuthority(a.getHost(),
- port >= 0 ? Integer.toString(port) : null);
- break;
- }
- }
- }
- pIt = ri.filter.pathsIterator();
- if (pIt != null) {
- String path = data.getPath();
- while (path != null && pIt.hasNext()) {
- PatternMatcher p = pIt.next();
- if (p.match(path)) {
- filter.addDataPath(p.getPath(), p.getType());
- break;
- }
- }
- }
- }
- }
-
- if (filter != null) {
- final int N = mMultiProfilePagerAdapter.getActiveListAdapter()
- .getUnfilteredResolveList().size();
- ComponentName[] set;
- // If we don't add back in the component for forwarding the intent to a managed
- // profile, the preferred activity may not be updated correctly (as the set of
- // components we tell it we knew about will have changed).
- final boolean needToAddBackProfileForwardingComponent =
- mMultiProfilePagerAdapter.getActiveListAdapter().getOtherProfile() != null;
- if (!needToAddBackProfileForwardingComponent) {
- set = new ComponentName[N];
- } else {
- set = new ComponentName[N + 1];
- }
-
- int bestMatch = 0;
- for (int i=0; i<N; i++) {
- ResolveInfo r = mMultiProfilePagerAdapter.getActiveListAdapter()
- .getUnfilteredResolveList().get(i).getResolveInfoAt(0);
- set[i] = new ComponentName(r.activityInfo.packageName,
- r.activityInfo.name);
- if (r.match > bestMatch) bestMatch = r.match;
- }
-
- if (needToAddBackProfileForwardingComponent) {
- set[N] = mMultiProfilePagerAdapter.getActiveListAdapter()
- .getOtherProfile().getResolvedComponentName();
- final int otherProfileMatch = mMultiProfilePagerAdapter.getActiveListAdapter()
- .getOtherProfile().getResolveInfo().match;
- if (otherProfileMatch > bestMatch) bestMatch = otherProfileMatch;
- }
-
- if (always) {
- final int userId = getUserId();
- final PackageManager pm = getPackageManager();
-
- // Set the preferred Activity
- pm.addUniquePreferredActivity(filter, bestMatch, set, intent.getComponent());
-
- if (ri.handleAllWebDataURI) {
- // Set default Browser if needed
- final String packageName = pm.getDefaultBrowserPackageNameAsUser(userId);
- if (TextUtils.isEmpty(packageName)) {
- pm.setDefaultBrowserPackageNameAsUser(ri.activityInfo.packageName, userId);
- }
- }
- } else {
- try {
- mMultiProfilePagerAdapter.getActiveListAdapter()
- .mResolverListController.setLastChosen(intent, filter, bestMatch);
- } catch (RemoteException re) {
- Log.d(TAG, "Error calling setLastChosenActivity\n" + re);
- }
- }
- }
- }
-
- if (target != null) {
- safelyStartActivity(target);
-
- // Rely on the ActivityManager to pop up a dialog regarding app suspension
- // and return false
- if (target.isSuspended()) {
- return false;
- }
- }
-
- return true;
- }
-
- @VisibleForTesting
- public void safelyStartActivity(TargetInfo cti) {
- // We're dispatching intents that might be coming from legacy apps, so
- // don't kill ourselves.
- StrictMode.disableDeathOnFileUriExposure();
- try {
- UserHandle currentUserHandle = mMultiProfilePagerAdapter.getCurrentUserHandle();
- safelyStartActivityInternal(cti, currentUserHandle, null);
- } finally {
- StrictMode.enableDeathOnFileUriExposure();
- }
- }
-
/**
* Start activity as a fixed user handle.
* @param cti TargetInfo to be launched.
* @param user User to launch this activity as.
*/
- @VisibleForTesting
- public void safelyStartActivityAsUser(TargetInfo cti, UserHandle user) {
+ @VisibleForTesting(visibility = VisibleForTesting.Visibility.PROTECTED)
+ public final void safelyStartActivityAsUser(TargetInfo cti, UserHandle user) {
safelyStartActivityAsUser(cti, user, null);
}
- protected void safelyStartActivityAsUser(
+ protected final void safelyStartActivityAsUser(
TargetInfo cti, UserHandle user, @Nullable Bundle options) {
// We're dispatching intents that might be coming from legacy apps, so
// don't kill ourselves.
@@ -1494,76 +1629,20 @@ public class ResolverActivity extends FragmentActivity implements
maybeLogCrossProfileTargetLaunch(cti, user);
}
} catch (RuntimeException e) {
- Slog.wtf(TAG, "Unable to launch as uid " + mLaunchedFromUid
+ Slog.wtf(TAG,
+ "Unable to launch as uid " + getAnnotatedUserHandles().userIdOfCallingApp
+ " package " + getLaunchedFromPackage() + ", while running in "
+ ActivityThread.currentProcessName(), e);
}
}
- private void maybeLogCrossProfileTargetLaunch(TargetInfo cti, UserHandle currentUserHandle) {
- if (!hasWorkProfile() || currentUserHandle.equals(getUser())) {
- return;
- }
- DevicePolicyEventLogger
- .createEvent(DevicePolicyEnums.RESOLVER_CROSS_PROFILE_TARGET_OPENED)
- .setBoolean(currentUserHandle.equals(getPersonalProfileUserHandle()))
- .setStrings(getMetricsCategory(),
- cti.isInDirectShareMetricsCategory() ? "direct_share" : "other_target")
- .write();
- }
-
-
- public void onActivityStarted(TargetInfo cti) {
- // Do nothing
- }
-
- @Override // ResolverListCommunicator
- public boolean shouldGetActivityMetadata() {
- return false;
- }
-
- public boolean shouldAutoLaunchSingleChoice(TargetInfo target) {
- return !target.isSuspended();
- }
-
- void showTargetDetails(ResolveInfo ri) {
+ final void showTargetDetails(ResolveInfo ri) {
Intent in = new Intent().setAction(Settings.ACTION_APPLICATION_DETAILS_SETTINGS)
.setData(Uri.fromParts("package", ri.activityInfo.packageName, null))
.addFlags(Intent.FLAG_ACTIVITY_NEW_DOCUMENT);
startActivityAsUser(in, mMultiProfilePagerAdapter.getCurrentUserHandle());
}
- @VisibleForTesting
- protected ResolverListAdapter createResolverListAdapter(Context context,
- List<Intent> payloadIntents, Intent[] initialIntents, List<ResolveInfo> rList,
- boolean filterLastUsed, UserHandle userHandle) {
- Intent startIntent = getIntent();
- boolean isAudioCaptureDevice =
- startIntent.getBooleanExtra(EXTRA_IS_AUDIO_CAPTURE_DEVICE, false);
- return new ResolverListAdapter(
- context,
- payloadIntents,
- initialIntents,
- rList,
- filterLastUsed,
- createListController(userHandle),
- userHandle,
- getTargetIntent(),
- this,
- isAudioCaptureDevice);
- }
-
- @VisibleForTesting
- protected ResolverListController createListController(UserHandle userHandle) {
- return new ResolverListController(
- this,
- mPm,
- getTargetIntent(),
- getReferrerPackageName(),
- mLaunchedFromUid,
- userHandle);
- }
-
/**
* Sets up the content view.
* @return <code>true</code> if the activity is finishing and creation should halt.
@@ -1650,8 +1729,7 @@ public class ResolverActivity extends FragmentActivity implements
findViewById(com.android.internal.R.id.button_open).setOnClickListener(v -> {
Intent intent = otherProfileResolveInfo.getResolvedIntent();
- safelyStartActivityAsUser(otherProfileResolveInfo,
- inactiveAdapter.mResolverListController.getUserHandle());
+ safelyStartActivityAsUser(otherProfileResolveInfo, inactiveAdapter.getUserHandle());
finish();
});
}
@@ -1700,16 +1778,6 @@ public class ResolverActivity extends FragmentActivity implements
/**
* Finishing procedures to be performed after the list has been rebuilt.
- * </p>Subclasses must call postRebuildListInternal at the end of postRebuildList.
- * @param rebuildCompleted
- * @return <code>true</code> if the activity is finishing and creation should halt.
- */
- protected boolean postRebuildList(boolean rebuildCompleted) {
- return postRebuildListInternal(rebuildCompleted);
- }
-
- /**
- * Finishing procedures to be performed after the list has been rebuilt.
* @param rebuildCompleted
* @return <code>true</code> if the activity is finishing and creation should halt.
*/
@@ -1965,8 +2033,6 @@ public class ResolverActivity extends FragmentActivity implements
RESOLVER_WORK_TAB, () -> getString(R.string.resolver_work_tab));
}
- void onHorizontalSwipeStateChanged(int state) {}
-
private void maybeHideDivider() {
if (!mIsIntentPicker) {
return;
@@ -1978,12 +2044,6 @@ public class ResolverActivity extends FragmentActivity implements
divider.setVisibility(View.GONE);
}
- /**
- * Callback called when user changes the profile tab.
- * <p>This method is intended to be overridden by subclasses.
- */
- protected void onProfileTabSelected() { }
-
private void resetCheckedItem() {
if (!mIsIntentPicker) {
return;
@@ -2030,20 +2090,17 @@ public class ResolverActivity extends FragmentActivity implements
}
/**
- * Add a label to signify that the user can pick a different app.
- * @param adapter The adapter used to provide data to item views.
+ * Updates the button bar container {@code ignoreOffset} layout param.
+ * <p>Setting this to {@code true} means that the button bar will be glued to the bottom of
+ * the screen.
*/
- public void addUseDifferentAppLabelIfNecessary(ResolverListAdapter adapter) {
- final boolean useHeader = adapter.hasFilteredItem();
- if (useHeader) {
- FrameLayout stub = findViewById(com.android.internal.R.id.stub);
- stub.setVisibility(View.VISIBLE);
- TextView textView = (TextView) LayoutInflater.from(this).inflate(
- R.layout.resolver_different_item_header, null, false);
- if (shouldShowTabs()) {
- textView.setGravity(Gravity.CENTER);
- }
- stub.addView(textView);
+ private void setButtonBarIgnoreOffset(boolean ignoreOffset) {
+ View buttonBarContainer = findViewById(com.android.internal.R.id.button_bar_container);
+ if (buttonBarContainer != null) {
+ ResolverDrawerLayout.LayoutParams layoutParams =
+ (ResolverDrawerLayout.LayoutParams) buttonBarContainer.getLayoutParams();
+ layoutParams.ignoreOffset = ignoreOffset;
+ buttonBarContainer.setLayoutParams(layoutParams);
}
}
@@ -2091,61 +2148,6 @@ public class ResolverActivity extends FragmentActivity implements
mHeaderCreatorUser = listAdapter.getUserHandle();
}
- protected void resetButtonBar() {
- if (!mSupportsAlwaysUseOption) {
- return;
- }
- final ViewGroup buttonLayout = findViewById(com.android.internal.R.id.button_bar);
- if (buttonLayout == null) {
- Log.e(TAG, "Layout unexpectedly does not have a button bar");
- return;
- }
- ResolverListAdapter activeListAdapter =
- mMultiProfilePagerAdapter.getActiveListAdapter();
- View buttonBarDivider = findViewById(com.android.internal.R.id.resolver_button_bar_divider);
- if (!useLayoutWithDefault()) {
- int inset = mSystemWindowInsets != null ? mSystemWindowInsets.bottom : 0;
- buttonLayout.setPadding(buttonLayout.getPaddingLeft(), buttonLayout.getPaddingTop(),
- buttonLayout.getPaddingRight(), getResources().getDimensionPixelSize(
- R.dimen.resolver_button_bar_spacing) + inset);
- }
- if (activeListAdapter.isTabLoaded()
- && mMultiProfilePagerAdapter.shouldShowEmptyStateScreen(activeListAdapter)
- && !useLayoutWithDefault()) {
- buttonLayout.setVisibility(View.INVISIBLE);
- if (buttonBarDivider != null) {
- buttonBarDivider.setVisibility(View.INVISIBLE);
- }
- setButtonBarIgnoreOffset(/* ignoreOffset */ false);
- return;
- }
- if (buttonBarDivider != null) {
- buttonBarDivider.setVisibility(View.VISIBLE);
- }
- buttonLayout.setVisibility(View.VISIBLE);
- setButtonBarIgnoreOffset(/* ignoreOffset */ true);
-
- mOnceButton = (Button) buttonLayout.findViewById(com.android.internal.R.id.button_once);
- mAlwaysButton = (Button) buttonLayout.findViewById(com.android.internal.R.id.button_always);
-
- resetAlwaysOrOnceButtonBar();
- }
-
- /**
- * Updates the button bar container {@code ignoreOffset} layout param.
- * <p>Setting this to {@code true} means that the button bar will be glued to the bottom of
- * the screen.
- */
- private void setButtonBarIgnoreOffset(boolean ignoreOffset) {
- View buttonBarContainer = findViewById(com.android.internal.R.id.button_bar_container);
- if (buttonBarContainer != null) {
- ResolverDrawerLayout.LayoutParams layoutParams =
- (ResolverDrawerLayout.LayoutParams) buttonBarContainer.getLayoutParams();
- layoutParams.ignoreOffset = ignoreOffset;
- buttonBarContainer.setLayoutParams(layoutParams);
- }
- }
-
private void resetAlwaysOrOnceButtonBar() {
// Disable both buttons initially
setAlwaysButtonEnabled(false, ListView.INVALID_POSITION, false);
@@ -2171,7 +2173,7 @@ public class ResolverActivity extends FragmentActivity implements
}
@Override // ResolverListCommunicator
- public boolean useLayoutWithDefault() {
+ public final boolean useLayoutWithDefault() {
// We only use the default app layout when the profile of the active user has a
// filtered item. We always show the same default app even in the inactive user profile.
boolean currentUserAdapterHasFilteredItem;
@@ -2190,7 +2192,7 @@ public class ResolverActivity extends FragmentActivity implements
* If {@code retainInOnStop} is set to true, we will not finish ourselves when onStop gets
* called and we are launched in a new task.
*/
- protected void setRetainInOnStop(boolean retainInOnStop) {
+ protected final void setRetainInOnStop(boolean retainInOnStop) {
mRetainInOnStop = retainInOnStop;
}
@@ -2198,43 +2200,13 @@ public class ResolverActivity extends FragmentActivity implements
* Check a simple match for the component of two ResolveInfos.
*/
@Override // ResolverListCommunicator
- public boolean resolveInfoMatch(ResolveInfo lhs, ResolveInfo rhs) {
+ public final boolean resolveInfoMatch(ResolveInfo lhs, ResolveInfo rhs) {
return lhs == null ? rhs == null
: lhs.activityInfo == null ? rhs.activityInfo == null
: Objects.equals(lhs.activityInfo.name, rhs.activityInfo.name)
&& Objects.equals(lhs.activityInfo.packageName, rhs.activityInfo.packageName);
}
- protected String getMetricsCategory() {
- return METRICS_CATEGORY_RESOLVER;
- }
-
- @Override // ResolverListCommunicator
- public void onHandlePackagesChanged(ResolverListAdapter listAdapter) {
- if (listAdapter == mMultiProfilePagerAdapter.getActiveListAdapter()) {
- if (listAdapter.getUserHandle().equals(getWorkProfileUserHandle())
- && mQuietModeManager.isWaitingToEnableWorkProfile()) {
- // We have just turned on the work profile and entered the pass code to start it,
- // now we are waiting to receive the ACTION_USER_UNLOCKED broadcast. There is no
- // point in reloading the list now, since the work profile user is still
- // turning on.
- return;
- }
- boolean listRebuilt = mMultiProfilePagerAdapter.rebuildActiveTab(true);
- if (listRebuilt) {
- ResolverListAdapter activeListAdapter =
- mMultiProfilePagerAdapter.getActiveListAdapter();
- activeListAdapter.notifyDataSetChanged();
- if (activeListAdapter.getCount() == 0 && !inactiveListAdapterHasItems()) {
- // We no longer have any items... just finish the activity.
- finish();
- }
- }
- } else {
- mMultiProfilePagerAdapter.clearInactiveProfileCache();
- }
- }
-
private boolean inactiveListAdapterHasItems() {
if (!shouldShowTabs()) {
return false;
@@ -2242,101 +2214,7 @@ public class ResolverActivity extends FragmentActivity implements
return mMultiProfilePagerAdapter.getInactiveListAdapter().getCount() > 0;
}
- private BroadcastReceiver createWorkProfileStateReceiver() {
- return new BroadcastReceiver() {
- @Override
- public void onReceive(Context context, Intent intent) {
- String action = intent.getAction();
- if (!TextUtils.equals(action, Intent.ACTION_USER_UNLOCKED)
- && !TextUtils.equals(action, Intent.ACTION_MANAGED_PROFILE_UNAVAILABLE)
- && !TextUtils.equals(action, Intent.ACTION_MANAGED_PROFILE_AVAILABLE)) {
- return;
- }
-
- int userId = intent.getIntExtra(Intent.EXTRA_USER_HANDLE, -1);
-
- if (userId != getWorkProfileUserHandle().getIdentifier()) {
- return;
- }
-
- if (isWorkProfileEnabled()) {
- if (mWorkProfileHasBeenEnabled) {
- return;
- }
-
- mWorkProfileHasBeenEnabled = true;
- mQuietModeManager.markWorkProfileEnabledBroadcastReceived();
- } else {
- // Must be an UNAVAILABLE broadcast, so we watch for the next availability
- mWorkProfileHasBeenEnabled = false;
- }
-
- if (mMultiProfilePagerAdapter.getCurrentUserHandle()
- .equals(getWorkProfileUserHandle())) {
- mMultiProfilePagerAdapter.rebuildActiveTab(true);
- } else {
- mMultiProfilePagerAdapter.clearInactiveProfileCache();
- }
- }
- };
- }
-
- public static final class ResolvedComponentInfo {
- public final ComponentName name;
- private final List<Intent> mIntents = new ArrayList<>();
- private final List<ResolveInfo> mResolveInfos = new ArrayList<>();
- private boolean mPinned;
-
- public ResolvedComponentInfo(ComponentName name, Intent intent, ResolveInfo info) {
- this.name = name;
- add(intent, info);
- }
-
- public void add(Intent intent, ResolveInfo info) {
- mIntents.add(intent);
- mResolveInfos.add(info);
- }
-
- public int getCount() {
- return mIntents.size();
- }
-
- public Intent getIntentAt(int index) {
- return index >= 0 ? mIntents.get(index) : null;
- }
-
- public ResolveInfo getResolveInfoAt(int index) {
- return index >= 0 ? mResolveInfos.get(index) : null;
- }
-
- public int findIntent(Intent intent) {
- for (int i = 0, N = mIntents.size(); i < N; i++) {
- if (intent.equals(mIntents.get(i))) {
- return i;
- }
- }
- return -1;
- }
-
- public int findResolveInfo(ResolveInfo info) {
- for (int i = 0, N = mResolveInfos.size(); i < N; i++) {
- if (info.equals(mResolveInfos.get(i))) {
- return i;
- }
- }
- return -1;
- }
-
- public boolean isPinned() {
- return mPinned;
- }
-
- public void setPinned(boolean pinned) {
- mPinned = pinned;
- }
- }
-
- class ItemClickListener implements AdapterView.OnItemClickListener,
+ final class ItemClickListener implements AdapterView.OnItemClickListener,
AdapterView.OnItemLongClickListener {
@Override
public void onItemClick(AdapterView<?> parent, View view, int position, long id) {
@@ -2397,7 +2275,7 @@ public class ResolverActivity extends FragmentActivity implements
&& match <= IntentFilter.MATCH_CATEGORY_PATH;
}
- static class PickTargetOptionRequest extends PickOptionRequest {
+ static final class PickTargetOptionRequest extends PickOptionRequest {
public PickTargetOptionRequest(@Nullable Prompt prompt, Option[] options,
@Nullable Bundle extras) {
super(prompt, options, extras);
@@ -2433,6 +2311,4 @@ public class ResolverActivity extends FragmentActivity implements
}
}
}
-
- protected void maybeLogProfileChange() {}
}
diff --git a/java/src/com/android/intentresolver/ResolverListAdapter.java b/java/src/com/android/intentresolver/ResolverListAdapter.java
index eecb914c..eac275cc 100644
--- a/java/src/com/android/intentresolver/ResolverListAdapter.java
+++ b/java/src/com/android/intentresolver/ResolverListAdapter.java
@@ -18,6 +18,7 @@ package com.android.intentresolver;
import static android.content.Context.ACTIVITY_SERVICE;
+import android.animation.ObjectAnimator;
import android.annotation.NonNull;
import android.annotation.Nullable;
import android.app.ActivityManager;
@@ -42,12 +43,12 @@ import android.util.Log;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
+import android.view.animation.DecelerateInterpolator;
import android.widget.AbsListView;
import android.widget.BaseAdapter;
import android.widget.ImageView;
import android.widget.TextView;
-import com.android.intentresolver.ResolverActivity.ResolvedComponentInfo;
import com.android.intentresolver.chooser.DisplayResolveInfo;
import com.android.intentresolver.chooser.TargetInfo;
import com.android.internal.annotations.VisibleForTesting;
@@ -287,11 +288,7 @@ public class ResolverListAdapter extends BaseAdapter {
mBaseResolveList);
return currentResolveList;
} else {
- return mResolverListController.getResolversForIntent(
- /* shouldGetResolvedFilter= */ true,
- mResolverListCommunicator.shouldGetActivityMetadata(),
- mResolverListCommunicator.shouldGetOnlyDefaultActivities(),
- mIntents);
+ return getResolversForUser(mUserHandle);
}
}
@@ -802,10 +799,12 @@ public class ResolverListAdapter extends BaseAdapter {
}
protected List<ResolvedComponentInfo> getResolversForUser(UserHandle userHandle) {
- return mResolverListController.getResolversForIntentAsUser(true,
+ return mResolverListController.getResolversForIntentAsUser(
+ /* shouldGetResolvedFilter= */ true,
mResolverListCommunicator.shouldGetActivityMetadata(),
mResolverListCommunicator.shouldGetOnlyDefaultActivities(),
- mIntents, userHandle);
+ mIntents,
+ userHandle);
}
protected List<Intent> getIntents() {
@@ -914,6 +913,7 @@ public class ResolverListAdapter extends BaseAdapter {
*/
@VisibleForTesting
public static class ViewHolder {
+ private static final long IMAGE_FADE_IN_MILLIS = 150;
public View itemView;
public Drawable defaultItemViewBackground;
@@ -952,7 +952,22 @@ public class ResolverListAdapter extends BaseAdapter {
}
public void bindIcon(TargetInfo info) {
- icon.setImageDrawable(info.getDisplayIconHolder().getDisplayIcon());
+ bindIcon(info, false);
+ }
+
+ /**
+ * Bind view holder to a TargetInfo, run icon reveal animation, if required.
+ */
+ public void bindIcon(TargetInfo info, boolean animate) {
+ Drawable displayIcon = info.getDisplayIconHolder().getDisplayIcon();
+ boolean runAnimation = animate && (icon.getDrawable() == null) && (displayIcon != null);
+ icon.setImageDrawable(displayIcon);
+ if (runAnimation) {
+ ObjectAnimator animator = ObjectAnimator.ofFloat(icon, "alpha", 0.0f, 1.0f);
+ animator.setInterpolator(new DecelerateInterpolator(1.0f));
+ animator.setDuration(IMAGE_FADE_IN_MILLIS);
+ animator.start();
+ }
if (info.isSuspended()) {
icon.setColorFilter(getSuspendedColorMatrix());
} else {
diff --git a/java/src/com/android/intentresolver/ResolverListController.java b/java/src/com/android/intentresolver/ResolverListController.java
index bfffe0d8..b4544c43 100644
--- a/java/src/com/android/intentresolver/ResolverListController.java
+++ b/java/src/com/android/intentresolver/ResolverListController.java
@@ -58,7 +58,6 @@ public class ResolverListController {
private static final String TAG = "ResolverListController";
private static final boolean DEBUG = false;
- private final UserHandle mUserHandle;
private AbstractResolverComparator mResolverComparator;
private boolean isComputed = false;
@@ -68,9 +67,8 @@ public class ResolverListController {
PackageManager pm,
Intent targetIntent,
String referrerPackage,
- int launchedFromUid,
- UserHandle userHandle) {
- this(context, pm, targetIntent, referrerPackage, launchedFromUid, userHandle,
+ int launchedFromUid) {
+ this(context, pm, targetIntent, referrerPackage, launchedFromUid,
new ResolverRankerServiceResolverComparator(
context, targetIntent, referrerPackage, null, null));
}
@@ -81,14 +79,12 @@ public class ResolverListController {
Intent targetIntent,
String referrerPackage,
int launchedFromUid,
- UserHandle userHandle,
AbstractResolverComparator resolverComparator) {
mContext = context;
mpm = pm;
mLaunchedFromUid = launchedFromUid;
mTargetIntent = targetIntent;
mReferrerPackage = referrerPackage;
- mUserHandle = userHandle;
mResolverComparator = resolverComparator;
}
@@ -108,17 +104,11 @@ public class ResolverListController {
filter, match, intent.getComponent());
}
- @VisibleForTesting
- public List<ResolverActivity.ResolvedComponentInfo> getResolversForIntent(
- boolean shouldGetResolvedFilter,
- boolean shouldGetActivityMetadata,
- boolean shouldGetOnlyDefaultActivities,
- List<Intent> intents) {
- return getResolversForIntentAsUser(shouldGetResolvedFilter, shouldGetActivityMetadata,
- shouldGetOnlyDefaultActivities, intents, mUserHandle);
- }
-
- public List<ResolverActivity.ResolvedComponentInfo> getResolversForIntentAsUser(
+ /**
+ * Get data about all the ways the user with the specified handle can resolve any of the
+ * provided {@code intents}.
+ */
+ public List<ResolvedComponentInfo> getResolversForIntentAsUser(
boolean shouldGetResolvedFilter,
boolean shouldGetActivityMetadata,
boolean shouldGetOnlyDefaultActivities,
@@ -132,11 +122,9 @@ public class ResolverListController {
return getResolversForIntentAsUserInternal(intents, userHandle, baseFlags);
}
- private List<ResolverActivity.ResolvedComponentInfo> getResolversForIntentAsUserInternal(
- List<Intent> intents,
- UserHandle userHandle,
- int baseFlags) {
- List<ResolverActivity.ResolvedComponentInfo> resolvedComponents = null;
+ private List<ResolvedComponentInfo> getResolversForIntentAsUserInternal(
+ List<Intent> intents, UserHandle userHandle, int baseFlags) {
+ List<ResolvedComponentInfo> resolvedComponents = null;
for (int i = 0, N = intents.size(); i < N; i++) {
Intent intent = intents.get(i);
int flags = baseFlags;
@@ -160,14 +148,8 @@ public class ResolverListController {
}
@VisibleForTesting
- public UserHandle getUserHandle() {
- return mUserHandle;
- }
-
- @VisibleForTesting
- public void addResolveListDedupe(List<ResolverActivity.ResolvedComponentInfo> into,
- Intent intent,
- List<ResolveInfo> from) {
+ public void addResolveListDedupe(
+ List<ResolvedComponentInfo> into, Intent intent, List<ResolveInfo> from) {
final int fromCount = from.size();
final int intoCount = into.size();
for (int i = 0; i < fromCount; i++) {
@@ -175,7 +157,7 @@ public class ResolverListController {
boolean found = false;
// Only loop to the end of into as it was before we started; no dupes in from.
for (int j = 0; j < intoCount; j++) {
- final ResolverActivity.ResolvedComponentInfo rci = into.get(j);
+ final ResolvedComponentInfo rci = into.get(j);
if (isSameResolvedComponent(newInfo, rci)) {
found = true;
rci.add(intent, newInfo);
@@ -185,8 +167,7 @@ public class ResolverListController {
if (!found) {
final ComponentName name = new ComponentName(
newInfo.activityInfo.packageName, newInfo.activityInfo.name);
- final ResolverActivity.ResolvedComponentInfo rci =
- new ResolverActivity.ResolvedComponentInfo(name, intent, newInfo);
+ final ResolvedComponentInfo rci = new ResolvedComponentInfo(name, intent, newInfo);
rci.setPinned(isComponentPinned(name));
into.add(rci);
}
@@ -206,10 +187,9 @@ public class ResolverListController {
// To preserve the inputList, optionally will return the original list if any modification has
// been made.
@VisibleForTesting
- public ArrayList<ResolverActivity.ResolvedComponentInfo> filterIneligibleActivities(
- List<ResolverActivity.ResolvedComponentInfo> inputList,
- boolean returnCopyOfOriginalListIfModified) {
- ArrayList<ResolverActivity.ResolvedComponentInfo> listToReturn = null;
+ public ArrayList<ResolvedComponentInfo> filterIneligibleActivities(
+ List<ResolvedComponentInfo> inputList, boolean returnCopyOfOriginalListIfModified) {
+ ArrayList<ResolvedComponentInfo> listToReturn = null;
for (int i = inputList.size()-1; i >= 0; i--) {
ActivityInfo ai = inputList.get(i)
.getResolveInfoAt(0).activityInfo;
@@ -235,13 +215,12 @@ public class ResolverListController {
// To preserve the inputList, optionally will return the original list if any modification has
// been made.
@VisibleForTesting
- public ArrayList<ResolverActivity.ResolvedComponentInfo> filterLowPriority(
- List<ResolverActivity.ResolvedComponentInfo> inputList,
- boolean returnCopyOfOriginalListIfModified) {
- ArrayList<ResolverActivity.ResolvedComponentInfo> listToReturn = null;
+ public ArrayList<ResolvedComponentInfo> filterLowPriority(
+ List<ResolvedComponentInfo> inputList, boolean returnCopyOfOriginalListIfModified) {
+ ArrayList<ResolvedComponentInfo> listToReturn = null;
// Only display the first matches that are either of equal
// priority or have asked to be default options.
- ResolverActivity.ResolvedComponentInfo rci0 = inputList.get(0);
+ ResolvedComponentInfo rci0 = inputList.get(0);
ResolveInfo r0 = rci0.getResolveInfoAt(0);
int N = inputList.size();
for (int i = 1; i < N; i++) {
@@ -266,8 +245,7 @@ public class ResolverListController {
return listToReturn;
}
- private void compute(List<ResolverActivity.ResolvedComponentInfo> inputList)
- throws InterruptedException {
+ private void compute(List<ResolvedComponentInfo> inputList) throws InterruptedException {
if (mResolverComparator == null) {
Log.d(TAG, "Comparator has already been destroyed; skipped.");
return;
@@ -281,7 +259,7 @@ public class ResolverListController {
@VisibleForTesting
@WorkerThread
- public void sort(List<ResolverActivity.ResolvedComponentInfo> inputList) {
+ public void sort(List<ResolvedComponentInfo> inputList) {
try {
long beforeRank = System.currentTimeMillis();
if (!isComputed) {
@@ -300,7 +278,7 @@ public class ResolverListController {
@VisibleForTesting
@WorkerThread
- public void topK(List<ResolverActivity.ResolvedComponentInfo> inputList, int k) {
+ public void topK(List<ResolvedComponentInfo> inputList, int k) {
if (inputList == null || inputList.isEmpty() || k <= 0) {
return;
}
@@ -317,7 +295,7 @@ public class ResolverListController {
}
// Top of this heap has lowest rank.
- PriorityQueue<ResolverActivity.ResolvedComponentInfo> minHeap = new PriorityQueue<>(k,
+ PriorityQueue<ResolvedComponentInfo> minHeap = new PriorityQueue<>(k,
(o1, o2) -> -mResolverComparator.compare(o1, o2));
final int size = inputList.size();
// Use this pointer to keep track of the position of next element
@@ -325,7 +303,7 @@ public class ResolverListController {
int pointer = size - 1;
minHeap.addAll(inputList.subList(size - k, size));
for (int i = size - k - 1; i >= 0; --i) {
- ResolverActivity.ResolvedComponentInfo ci = inputList.get(i);
+ ResolvedComponentInfo ci = inputList.get(i);
if (-mResolverComparator.compare(ci, minHeap.peek()) > 0) {
// When ranked higher than top of heap, remove top of heap,
// update input list with it, add this new element to heap.
@@ -354,8 +332,7 @@ public class ResolverListController {
}
}
- private static boolean isSameResolvedComponent(ResolveInfo a,
- ResolverActivity.ResolvedComponentInfo b) {
+ private static boolean isSameResolvedComponent(ResolveInfo a, ResolvedComponentInfo b) {
final ActivityInfo ai = a.activityInfo;
return ai.packageName.equals(b.name.getPackageName())
&& ai.name.equals(b.name.getClassName());
diff --git a/java/src/com/android/intentresolver/ResolverMultiProfilePagerAdapter.java b/java/src/com/android/intentresolver/ResolverMultiProfilePagerAdapter.java
index 65de9409..48e3b62d 100644
--- a/java/src/com/android/intentresolver/ResolverMultiProfilePagerAdapter.java
+++ b/java/src/com/android/intentresolver/ResolverMultiProfilePagerAdapter.java
@@ -43,13 +43,13 @@ public class ResolverMultiProfilePagerAdapter extends
Context context,
ResolverListAdapter adapter,
EmptyStateProvider emptyStateProvider,
- QuietModeManager quietModeManager,
+ Supplier<Boolean> workProfileQuietModeChecker,
UserHandle workProfileUserHandle) {
this(
context,
ImmutableList.of(adapter),
emptyStateProvider,
- quietModeManager,
+ workProfileQuietModeChecker,
/* defaultProfile= */ 0,
workProfileUserHandle,
new BottomPaddingOverrideSupplier());
@@ -59,14 +59,14 @@ public class ResolverMultiProfilePagerAdapter extends
ResolverListAdapter personalAdapter,
ResolverListAdapter workAdapter,
EmptyStateProvider emptyStateProvider,
- QuietModeManager quietModeManager,
+ Supplier<Boolean> workProfileQuietModeChecker,
@Profile int defaultProfile,
UserHandle workProfileUserHandle) {
this(
context,
ImmutableList.of(personalAdapter, workAdapter),
emptyStateProvider,
- quietModeManager,
+ workProfileQuietModeChecker,
defaultProfile,
workProfileUserHandle,
new BottomPaddingOverrideSupplier());
@@ -76,7 +76,7 @@ public class ResolverMultiProfilePagerAdapter extends
Context context,
ImmutableList<ResolverListAdapter> listAdapters,
EmptyStateProvider emptyStateProvider,
- QuietModeManager quietModeManager,
+ Supplier<Boolean> workProfileQuietModeChecker,
@Profile int defaultProfile,
UserHandle workProfileUserHandle,
BottomPaddingOverrideSupplier bottomPaddingOverrideSupplier) {
@@ -86,7 +86,7 @@ public class ResolverMultiProfilePagerAdapter extends
(listView, bindAdapter) -> listView.setAdapter(bindAdapter),
listAdapters,
emptyStateProvider,
- quietModeManager,
+ workProfileQuietModeChecker,
defaultProfile,
workProfileUserHandle,
() -> (ViewGroup) LayoutInflater.from(context).inflate(
diff --git a/java/src/com/android/intentresolver/SecureSettings.kt b/java/src/com/android/intentresolver/SecureSettings.kt
new file mode 100644
index 00000000..a4853fd8
--- /dev/null
+++ b/java/src/com/android/intentresolver/SecureSettings.kt
@@ -0,0 +1,29 @@
+/*
+ * Copyright (C) 2023 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.intentresolver
+
+import android.content.ContentResolver
+import android.provider.Settings
+
+/**
+ * A proxy class for secure settings, for easier testing.
+ */
+open class SecureSettings {
+ open fun getString(resolver: ContentResolver, name: String): String? {
+ return Settings.Secure.getString(resolver, name)
+ }
+}
diff --git a/java/src/com/android/intentresolver/WorkProfileAvailabilityManager.java b/java/src/com/android/intentresolver/WorkProfileAvailabilityManager.java
new file mode 100644
index 00000000..6e51520b
--- /dev/null
+++ b/java/src/com/android/intentresolver/WorkProfileAvailabilityManager.java
@@ -0,0 +1,166 @@
+/*
+ * Copyright (C) 2023 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.intentresolver;
+
+import android.content.BroadcastReceiver;
+import android.content.Context;
+import android.content.Intent;
+import android.content.IntentFilter;
+import android.os.AsyncTask;
+import android.os.UserHandle;
+import android.os.UserManager;
+import android.text.TextUtils;
+
+import androidx.annotation.VisibleForTesting;
+
+/** Monitor for runtime conditions that may disable work profile display. */
+public class WorkProfileAvailabilityManager {
+ private final UserManager mUserManager;
+ private final UserHandle mWorkProfileUserHandle;
+ private final Runnable mOnWorkProfileStateUpdated;
+
+ private BroadcastReceiver mWorkProfileStateReceiver;
+
+ private boolean mIsWaitingToEnableWorkProfile;
+ private boolean mWorkProfileHasBeenEnabled;
+
+ public WorkProfileAvailabilityManager(
+ UserManager userManager,
+ UserHandle workProfileUserHandle,
+ Runnable onWorkProfileStateUpdated) {
+ mUserManager = userManager;
+ mWorkProfileUserHandle = workProfileUserHandle;
+ mWorkProfileHasBeenEnabled = isWorkProfileEnabled();
+ mOnWorkProfileStateUpdated = onWorkProfileStateUpdated;
+ }
+
+ /**
+ * Register a {@link BroadcastReceiver}, if we haven't already, to be notified about work
+ * profile availability changes.
+ *
+ * TODO: this takes the context for testing, because we don't have a context on hand when we
+ * set up this component's default "override" in {@link ChooserActivityOverrideData#reset()}.
+ * The use of these overrides in our testing design is questionable and can hopefully be
+ * improved someday; then this context should be injected in our constructor & held as `final`.
+ *
+ * TODO: consider injecting an optional `Lifecycle` so that this component can automatically
+ * manage its own registration/unregistration. (This would be optional because registration of
+ * the receiver is conditional on having `shouldShowTabs()` in our session.)
+ */
+ public void registerWorkProfileStateReceiver(Context context) {
+ if (mWorkProfileStateReceiver != null) {
+ return;
+ }
+ mWorkProfileStateReceiver = createWorkProfileStateReceiver();
+
+ IntentFilter filter = new IntentFilter();
+ filter.addAction(Intent.ACTION_USER_UNLOCKED);
+ filter.addAction(Intent.ACTION_MANAGED_PROFILE_AVAILABLE);
+ filter.addAction(Intent.ACTION_MANAGED_PROFILE_UNAVAILABLE);
+ context.registerReceiverAsUser(
+ mWorkProfileStateReceiver, UserHandle.ALL, filter, null, null);
+ }
+
+ /**
+ * Unregister any {@link BroadcastReceiver} currently waiting for a work-enabled broadcast.
+ *
+ * TODO: this takes the context for testing, because we don't have a context on hand when we
+ * set up this component's default "override" in {@link ChooserActivityOverrideData#reset()}.
+ * The use of these overrides in our testing design is questionable and can hopefully be
+ * improved someday; then this context should be injected in our constructor & held as `final`.
+ */
+ public void unregisterWorkProfileStateReceiver(Context context) {
+ if (mWorkProfileStateReceiver == null) {
+ return;
+ }
+ context.unregisterReceiver(mWorkProfileStateReceiver);
+ mWorkProfileStateReceiver = null;
+ }
+
+ public boolean isQuietModeEnabled() {
+ return mUserManager.isQuietModeEnabled(mWorkProfileUserHandle);
+ }
+
+ // TODO: why do clients only care about the result of `isQuietModeEnabled()`, even though
+ // internally (in `isWorkProfileEnabled()`) we also check this 'unlocked' condition?
+ @VisibleForTesting
+ public boolean isWorkProfileUserUnlocked() {
+ return mUserManager.isUserUnlocked(mWorkProfileUserHandle);
+ }
+
+ /**
+ * Request that quiet mode be enabled (or disabled) for the work profile.
+ * TODO: this is only used to disable quiet mode; should that be hard-coded?
+ */
+ public void requestQuietModeEnabled(boolean enabled) {
+ AsyncTask.THREAD_POOL_EXECUTOR.execute(
+ () -> mUserManager.requestQuietModeEnabled(enabled, mWorkProfileUserHandle));
+ mIsWaitingToEnableWorkProfile = true;
+ }
+
+ /**
+ * Stop waiting for a work-enabled broadcast.
+ * TODO: this seems strangely low-level to include as part of the public API. Maybe some
+ * responsibilities need to be pulled over from the client?
+ */
+ public void markWorkProfileEnabledBroadcastReceived() {
+ mIsWaitingToEnableWorkProfile = false;
+ }
+
+ public boolean isWaitingToEnableWorkProfile() {
+ return mIsWaitingToEnableWorkProfile;
+ }
+
+ private boolean isWorkProfileEnabled() {
+ return (mWorkProfileUserHandle != null)
+ && !isQuietModeEnabled()
+ && isWorkProfileUserUnlocked();
+ }
+
+ private BroadcastReceiver createWorkProfileStateReceiver() {
+ return new BroadcastReceiver() {
+ @Override
+ public void onReceive(Context context, Intent intent) {
+ String action = intent.getAction();
+ if (!TextUtils.equals(action, Intent.ACTION_USER_UNLOCKED)
+ && !TextUtils.equals(action, Intent.ACTION_MANAGED_PROFILE_UNAVAILABLE)
+ && !TextUtils.equals(action, Intent.ACTION_MANAGED_PROFILE_AVAILABLE)) {
+ return;
+ }
+
+ if (intent.getIntExtra(Intent.EXTRA_USER_HANDLE, -1)
+ != mWorkProfileUserHandle.getIdentifier()) {
+ return;
+ }
+
+ if (isWorkProfileEnabled()) {
+ if (mWorkProfileHasBeenEnabled) {
+ return;
+ }
+ mWorkProfileHasBeenEnabled = true;
+ mIsWaitingToEnableWorkProfile = false;
+ } else {
+ // Must be an UNAVAILABLE broadcast, so we watch for the next availability.
+ // TODO: confirm the above reasoning (& handling of "UNAVAILABLE" in general).
+ mWorkProfileHasBeenEnabled = false;
+ }
+
+ mOnWorkProfileStateUpdated.run();
+ }
+ };
+ }
+}
diff --git a/java/src/com/android/intentresolver/WorkProfilePausedEmptyStateProvider.java b/java/src/com/android/intentresolver/WorkProfilePausedEmptyStateProvider.java
index b7c89907..0333039b 100644
--- a/java/src/com/android/intentresolver/WorkProfilePausedEmptyStateProvider.java
+++ b/java/src/com/android/intentresolver/WorkProfilePausedEmptyStateProvider.java
@@ -26,11 +26,10 @@ import android.content.Context;
import android.os.UserHandle;
import android.stats.devicepolicy.nano.DevicePolicyEnums;
-import com.android.internal.R;
import com.android.intentresolver.AbstractMultiProfilePagerAdapter.EmptyState;
import com.android.intentresolver.AbstractMultiProfilePagerAdapter.EmptyStateProvider;
import com.android.intentresolver.AbstractMultiProfilePagerAdapter.OnSwitchOnWorkSelectedListener;
-import com.android.intentresolver.AbstractMultiProfilePagerAdapter.QuietModeManager;
+import com.android.internal.R;
/**
* Chooser/ResolverActivity empty state provider that returns empty state which is shown when
@@ -39,19 +38,19 @@ import com.android.intentresolver.AbstractMultiProfilePagerAdapter.QuietModeMana
public class WorkProfilePausedEmptyStateProvider implements EmptyStateProvider {
private final UserHandle mWorkProfileUserHandle;
- private final QuietModeManager mQuietModeManager;
+ private final WorkProfileAvailabilityManager mWorkProfileAvailability;
private final String mMetricsCategory;
private final OnSwitchOnWorkSelectedListener mOnSwitchOnWorkSelectedListener;
private final Context mContext;
public WorkProfilePausedEmptyStateProvider(@NonNull Context context,
@Nullable UserHandle workProfileUserHandle,
- @NonNull QuietModeManager quietModeManager,
+ @NonNull WorkProfileAvailabilityManager workProfileAvailability,
@Nullable OnSwitchOnWorkSelectedListener onSwitchOnWorkSelectedListener,
@NonNull String metricsCategory) {
mContext = context;
mWorkProfileUserHandle = workProfileUserHandle;
- mQuietModeManager = quietModeManager;
+ mWorkProfileAvailability = workProfileAvailability;
mMetricsCategory = metricsCategory;
mOnSwitchOnWorkSelectedListener = onSwitchOnWorkSelectedListener;
}
@@ -60,7 +59,7 @@ public class WorkProfilePausedEmptyStateProvider implements EmptyStateProvider {
@Override
public EmptyState getEmptyState(ResolverListAdapter resolverListAdapter) {
if (!resolverListAdapter.getUserHandle().equals(mWorkProfileUserHandle)
- || !mQuietModeManager.isQuietModeEnabled(mWorkProfileUserHandle)
+ || !mWorkProfileAvailability.isQuietModeEnabled()
|| resolverListAdapter.getCount() == 0) {
return null;
}
@@ -74,7 +73,7 @@ public class WorkProfilePausedEmptyStateProvider implements EmptyStateProvider {
if (mOnSwitchOnWorkSelectedListener != null) {
mOnSwitchOnWorkSelectedListener.onSwitchOnWorkSelected();
}
- mQuietModeManager.requestQuietModeEnabled(false, mWorkProfileUserHandle);
+ mWorkProfileAvailability.requestQuietModeEnabled(false);
}, mMetricsCategory);
}
diff --git a/java/src/com/android/intentresolver/chooser/DisplayResolveInfo.java b/java/src/com/android/intentresolver/chooser/DisplayResolveInfo.java
index db5ae0b4..29be6dc6 100644
--- a/java/src/com/android/intentresolver/chooser/DisplayResolveInfo.java
+++ b/java/src/com/android/intentresolver/chooser/DisplayResolveInfo.java
@@ -27,7 +27,6 @@ import android.content.pm.ResolveInfo;
import android.os.Bundle;
import android.os.UserHandle;
-import com.android.intentresolver.ResolverActivity;
import com.android.intentresolver.TargetPresentationGetter;
import java.util.ArrayList;
@@ -97,25 +96,22 @@ public class DisplayResolveInfo implements TargetInfo {
final ActivityInfo ai = mResolveInfo.activityInfo;
mIsSuspended = (ai.applicationInfo.flags & ApplicationInfo.FLAG_SUSPENDED) != 0;
- final Intent intent = new Intent(resolvedIntent);
- intent.addFlags(Intent.FLAG_ACTIVITY_FORWARD_RESULT
- | Intent.FLAG_ACTIVITY_PREVIOUS_IS_TOP);
- intent.setComponent(new ComponentName(ai.applicationInfo.packageName, ai.name));
- mResolvedIntent = intent;
+ mResolvedIntent = createResolvedIntent(resolvedIntent, ai);
}
private DisplayResolveInfo(
DisplayResolveInfo other,
- Intent fillInIntent,
- int flags,
+ @Nullable Intent baseIntentToSend,
TargetPresentationGetter presentationGetter) {
mSourceIntents.addAll(other.getAllSourceIntents());
mResolveInfo = other.mResolveInfo;
mIsSuspended = other.mIsSuspended;
mDisplayLabel = other.mDisplayLabel;
mExtendedInfo = other.mExtendedInfo;
- mResolvedIntent = new Intent(other.mResolvedIntent);
- mResolvedIntent.fillIn(fillInIntent, flags);
+
+ mResolvedIntent = createResolvedIntent(
+ baseIntentToSend == null ? other.mResolvedIntent : baseIntentToSend,
+ mResolveInfo.activityInfo);
mPresentationGetter = presentationGetter;
mDisplayIconHolder.setDisplayIcon(other.mDisplayIconHolder.getDisplayIcon());
@@ -133,6 +129,14 @@ public class DisplayResolveInfo implements TargetInfo {
mDisplayIconHolder.setDisplayIcon(other.mDisplayIconHolder.getDisplayIcon());
}
+ private static Intent createResolvedIntent(Intent resolvedIntent, ActivityInfo ai) {
+ final Intent result = new Intent(resolvedIntent);
+ result.addFlags(Intent.FLAG_ACTIVITY_FORWARD_RESULT
+ | Intent.FLAG_ACTIVITY_PREVIOUS_IS_TOP);
+ result.setComponent(new ComponentName(ai.applicationInfo.packageName, ai.name));
+ return result;
+ }
+
@Override
public final boolean isDisplayResolveInfo() {
return true;
@@ -168,8 +172,21 @@ public class DisplayResolveInfo implements TargetInfo {
}
@Override
- public TargetInfo cloneFilledIn(Intent fillInIntent, int flags) {
- return new DisplayResolveInfo(this, fillInIntent, flags, mPresentationGetter);
+ @Nullable
+ public DisplayResolveInfo tryToCloneWithAppliedRefinement(Intent proposedRefinement) {
+ Intent matchingBase =
+ getAllSourceIntents()
+ .stream()
+ .filter(i -> i.filterEquals(proposedRefinement))
+ .findFirst()
+ .orElse(null);
+ if (matchingBase == null) {
+ return null;
+ }
+
+ Intent merged = new Intent(matchingBase);
+ merged.fillIn(proposedRefinement, 0);
+ return new DisplayResolveInfo(this, merged, mPresentationGetter);
}
@Override
@@ -201,13 +218,7 @@ public class DisplayResolveInfo implements TargetInfo {
}
@Override
- public boolean start(Activity activity, Bundle options) {
- activity.startActivity(mResolvedIntent, options);
- return true;
- }
-
- @Override
- public boolean startAsCaller(ResolverActivity activity, Bundle options, int userId) {
+ public boolean startAsCaller(Activity activity, Bundle options, int userId) {
TargetInfo.prepareIntentForCrossProfileLaunch(mResolvedIntent, userId);
activity.startActivityAsCaller(mResolvedIntent, options, false, userId);
return true;
@@ -216,10 +227,21 @@ public class DisplayResolveInfo implements TargetInfo {
@Override
public boolean startAsUser(Activity activity, Bundle options, UserHandle user) {
TargetInfo.prepareIntentForCrossProfileLaunch(mResolvedIntent, user.getIdentifier());
+ // TODO: is this equivalent to `startActivityAsCaller` with `ignoreTargetSecurity=true`? If
+ // so, we can consolidate on the one API method to show that this flag is the only
+ // distinction between `startAsCaller` and `startAsUser`. We can even bake that flag into
+ // the `TargetActivityStarter` upfront since it just reflects our "safe forwarding mode" --
+ // which is constant for the duration of our lifecycle, leaving clients no other
+ // responsibilities in this logic.
activity.startActivityAsUser(mResolvedIntent, options, user);
return false;
}
+ @Override
+ public Intent getTargetIntent() {
+ return mResolvedIntent;
+ }
+
public boolean isSuspended() {
return mIsSuspended;
}
diff --git a/java/src/com/android/intentresolver/chooser/ImmutableTargetInfo.java b/java/src/com/android/intentresolver/chooser/ImmutableTargetInfo.java
new file mode 100644
index 00000000..2d9683e1
--- /dev/null
+++ b/java/src/com/android/intentresolver/chooser/ImmutableTargetInfo.java
@@ -0,0 +1,633 @@
+/*
+ * Copyright (C) 2023 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.intentresolver.chooser;
+
+import android.annotation.NonNull;
+import android.annotation.Nullable;
+import android.app.Activity;
+import android.app.prediction.AppTarget;
+import android.content.ComponentName;
+import android.content.Context;
+import android.content.Intent;
+import android.content.pm.ResolveInfo;
+import android.content.pm.ShortcutInfo;
+import android.os.Bundle;
+import android.os.UserHandle;
+import android.util.HashedStringCache;
+
+import androidx.annotation.VisibleForTesting;
+
+import com.google.common.collect.ImmutableList;
+
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * An implementation of {@link TargetInfo} with immutable data. Any modifications must be made by
+ * creating a new instance (e.g., via {@link ImmutableTargetInfo#toBuilder()}).
+ */
+public final class ImmutableTargetInfo implements TargetInfo {
+ private static final String TAG = "TargetInfo";
+
+ /** Delegate interface to implement {@link TargetInfo#getHashedTargetIdForMetrics()}. */
+ public interface TargetHashProvider {
+ /** Request a hash for the specified {@code target}. */
+ HashedStringCache.HashResult getHashedTargetIdForMetrics(
+ TargetInfo target, Context context);
+ }
+
+ /** Delegate interface to request that the target be launched by a particular API. */
+ public interface TargetActivityStarter {
+ /**
+ * Request that the delegate use the {@link Activity#startAsCaller()} API to launch the
+ * specified {@code target}.
+ *
+ * @return true if the target was launched successfully.
+ */
+ boolean startAsCaller(TargetInfo target, Activity activity, Bundle options, int userId);
+
+ /**
+ * Request that the delegate use the {@link Activity#startAsUser()} API to launch the
+ * specified {@code target}.
+ *
+ * @return true if the target was launched successfully.
+ */
+ boolean startAsUser(TargetInfo target, Activity activity, Bundle options, UserHandle user);
+ }
+
+ enum LegacyTargetType {
+ NOT_LEGACY_TARGET,
+ EMPTY_TARGET_INFO,
+ PLACEHOLDER_TARGET_INFO,
+ SELECTABLE_TARGET_INFO,
+ DISPLAY_RESOLVE_INFO,
+ MULTI_DISPLAY_RESOLVE_INFO
+ };
+
+ /** Builder API to construct {@code ImmutableTargetInfo} instances. */
+ public static class Builder {
+ @Nullable
+ private ComponentName mResolvedComponentName;
+
+ @Nullable
+ private Intent mResolvedIntent;
+
+ @Nullable
+ private Intent mBaseIntentToSend;
+
+ @Nullable
+ private Intent mTargetIntent;
+
+ @Nullable
+ private ComponentName mChooserTargetComponentName;
+
+ @Nullable
+ private ShortcutInfo mDirectShareShortcutInfo;
+
+ @Nullable
+ private AppTarget mDirectShareAppTarget;
+
+ @Nullable
+ private DisplayResolveInfo mDisplayResolveInfo;
+
+ @Nullable
+ private TargetHashProvider mHashProvider;
+
+ @Nullable
+ private Intent mReferrerFillInIntent;
+
+ @Nullable
+ private TargetActivityStarter mActivityStarter;
+
+ @Nullable
+ private ResolveInfo mResolveInfo;
+
+ @Nullable
+ private CharSequence mDisplayLabel;
+
+ @Nullable
+ private CharSequence mExtendedInfo;
+
+ @Nullable
+ private IconHolder mDisplayIconHolder;
+
+ private boolean mIsSuspended;
+ private boolean mIsPinned;
+ private float mModifiedScore = -0.1f;
+ private LegacyTargetType mLegacyType = LegacyTargetType.NOT_LEGACY_TARGET;
+
+ private ImmutableList<Intent> mAlternateSourceIntents = ImmutableList.of();
+ private ImmutableList<DisplayResolveInfo> mAllDisplayTargets = ImmutableList.of();
+
+ /**
+ * Configure an {@link Intent} to be built in to the output target as the resolution for the
+ * requested target data.
+ */
+ public Builder setResolvedIntent(Intent resolvedIntent) {
+ mResolvedIntent = resolvedIntent;
+ return this;
+ }
+
+ /**
+ * Configure an {@link Intent} to be built in to the output target as the "base intent to
+ * send," which may be a refinement of any of our source targets. This is private because
+ * it's only used internally by {@link #tryToCloneWithAppliedRefinement()}; if it's ever
+ * expanded, the builder should probably be responsible for enforcing the refinement check.
+ */
+ private Builder setBaseIntentToSend(Intent baseIntent) {
+ mBaseIntentToSend = baseIntent;
+ return this;
+ }
+
+ /**
+ * Configure an {@link Intent} to be built in to the output as the "target intent."
+ */
+ public Builder setTargetIntent(Intent targetIntent) {
+ mTargetIntent = targetIntent;
+ return this;
+ }
+
+ /**
+ * Configure a fill-in intent provided by the referrer to be used in populating the launch
+ * intent if the output target is ever selected.
+ *
+ * @see android.content.Intent#fillIn(Intent, int)
+ */
+ public Builder setReferrerFillInIntent(@Nullable Intent referrerFillInIntent) {
+ mReferrerFillInIntent = referrerFillInIntent;
+ return this;
+ }
+
+ /**
+ * Configure a {@link ComponentName} to be built in to the output target, as the real
+ * component we were able to resolve on this device given the available target data.
+ */
+ public Builder setResolvedComponentName(@Nullable ComponentName resolvedComponentName) {
+ mResolvedComponentName = resolvedComponentName;
+ return this;
+ }
+
+ /**
+ * Configure a {@link ComponentName} to be built in to the output target, as the component
+ * supposedly associated with a {@link ChooserTarget} from which the builder data is being
+ * derived.
+ */
+ public Builder setChooserTargetComponentName(@Nullable ComponentName componentName) {
+ mChooserTargetComponentName = componentName;
+ return this;
+ }
+
+ /** Configure the {@link TargetActivityStarter} to be built in to the output target. */
+ public Builder setActivityStarter(TargetActivityStarter activityStarter) {
+ mActivityStarter = activityStarter;
+ return this;
+ }
+
+ /** Configure the {@link ResolveInfo} to be built in to the output target. */
+ public Builder setResolveInfo(ResolveInfo resolveInfo) {
+ mResolveInfo = resolveInfo;
+ return this;
+ }
+
+ /** Configure the display label to be built in to the output target. */
+ public Builder setDisplayLabel(CharSequence displayLabel) {
+ mDisplayLabel = displayLabel;
+ return this;
+ }
+
+ /** Configure the extended info to be built in to the output target. */
+ public Builder setExtendedInfo(CharSequence extendedInfo) {
+ mExtendedInfo = extendedInfo;
+ return this;
+ }
+
+ /** Configure the {@link IconHolder} to be built in to the output target. */
+ public Builder setDisplayIconHolder(IconHolder displayIconHolder) {
+ mDisplayIconHolder = displayIconHolder;
+ return this;
+ }
+
+ /** Configure the list of alternate source intents we could resolve for this target. */
+ public Builder setAlternateSourceIntents(List<Intent> sourceIntents) {
+ mAlternateSourceIntents = immutableCopyOrEmpty(sourceIntents);
+ return this;
+ }
+
+ /**
+ * Configure the full list of source intents we could resolve for this target. This is
+ * effectively the same as calling {@link #setResolvedIntent()} with the first element of
+ * the list, and {@link #setAlternateSourceIntents()} with the remainder (or clearing those
+ * fields on the builder if there are no corresponding elements in the list).
+ */
+ public Builder setAllSourceIntents(List<Intent> sourceIntents) {
+ if ((sourceIntents == null) || sourceIntents.isEmpty()) {
+ setResolvedIntent(null);
+ setAlternateSourceIntents(null);
+ return this;
+ }
+
+ setResolvedIntent(sourceIntents.get(0));
+ setAlternateSourceIntents(sourceIntents.subList(1, sourceIntents.size()));
+ return this;
+ }
+
+ /** Configure the list of display targets to be built in to the output target. */
+ public Builder setAllDisplayTargets(List<DisplayResolveInfo> targets) {
+ mAllDisplayTargets = immutableCopyOrEmpty(targets);
+ return this;
+ }
+
+ /** Configure the is-suspended status to be built in to the output target. */
+ public Builder setIsSuspended(boolean isSuspended) {
+ mIsSuspended = isSuspended;
+ return this;
+ }
+
+ /** Configure the is-pinned status to be built in to the output target. */
+ public Builder setIsPinned(boolean isPinned) {
+ mIsPinned = isPinned;
+ return this;
+ }
+
+ /** Configure the modified score to be built in to the output target. */
+ public Builder setModifiedScore(float modifiedScore) {
+ mModifiedScore = modifiedScore;
+ return this;
+ }
+
+ /** Configure the {@link ShortcutInfo} to be built in to the output target. */
+ public Builder setDirectShareShortcutInfo(@Nullable ShortcutInfo shortcutInfo) {
+ mDirectShareShortcutInfo = shortcutInfo;
+ return this;
+ }
+
+ /** Configure the {@link AppTarget} to be built in to the output target. */
+ public Builder setDirectShareAppTarget(@Nullable AppTarget appTarget) {
+ mDirectShareAppTarget = appTarget;
+ return this;
+ }
+
+ /** Configure the {@link DisplayResolveInfo} to be built in to the output target. */
+ public Builder setDisplayResolveInfo(@Nullable DisplayResolveInfo displayResolveInfo) {
+ mDisplayResolveInfo = displayResolveInfo;
+ return this;
+ }
+
+ /** Configure the {@link TargetHashProvider} to be built in to the output target. */
+ public Builder setHashProvider(@Nullable TargetHashProvider hashProvider) {
+ mHashProvider = hashProvider;
+ return this;
+ }
+
+ Builder setLegacyType(@NonNull LegacyTargetType legacyType) {
+ mLegacyType = legacyType;
+ return this;
+ }
+
+ /** Construct an {@code ImmutableTargetInfo} with the current builder data. */
+ public ImmutableTargetInfo build() {
+ List<Intent> sourceIntents = new ArrayList<>();
+ if (mResolvedIntent != null) {
+ sourceIntents.add(mResolvedIntent);
+ }
+ if (mAlternateSourceIntents != null) {
+ sourceIntents.addAll(mAlternateSourceIntents);
+ }
+
+ Intent baseIntentToSend = mBaseIntentToSend;
+ if ((baseIntentToSend == null) && !sourceIntents.isEmpty()) {
+ baseIntentToSend = sourceIntents.get(0);
+ }
+ if (baseIntentToSend != null) {
+ baseIntentToSend = new Intent(baseIntentToSend);
+ if (mReferrerFillInIntent != null) {
+ baseIntentToSend.fillIn(mReferrerFillInIntent, 0);
+ }
+ }
+
+ return new ImmutableTargetInfo(
+ baseIntentToSend,
+ ImmutableList.copyOf(sourceIntents),
+ mTargetIntent,
+ mReferrerFillInIntent,
+ mResolvedComponentName,
+ mChooserTargetComponentName,
+ mActivityStarter,
+ mResolveInfo,
+ mDisplayLabel,
+ mExtendedInfo,
+ mDisplayIconHolder,
+ mAllDisplayTargets,
+ mIsSuspended,
+ mIsPinned,
+ mModifiedScore,
+ mDirectShareShortcutInfo,
+ mDirectShareAppTarget,
+ mDisplayResolveInfo,
+ mHashProvider,
+ mLegacyType);
+ }
+ }
+
+ @Nullable
+ private final Intent mReferrerFillInIntent;
+
+ @Nullable
+ private final ComponentName mResolvedComponentName;
+
+ @Nullable
+ private final ComponentName mChooserTargetComponentName;
+
+ @Nullable
+ private final ShortcutInfo mDirectShareShortcutInfo;
+
+ @Nullable
+ private final AppTarget mDirectShareAppTarget;
+
+ @Nullable
+ private final DisplayResolveInfo mDisplayResolveInfo;
+
+ @Nullable
+ private final TargetHashProvider mHashProvider;
+
+ private final Intent mBaseIntentToSend;
+ private final ImmutableList<Intent> mSourceIntents;
+ private final Intent mTargetIntent;
+ private final TargetActivityStarter mActivityStarter;
+ private final ResolveInfo mResolveInfo;
+ private final CharSequence mDisplayLabel;
+ private final CharSequence mExtendedInfo;
+ private final IconHolder mDisplayIconHolder;
+ private final ImmutableList<DisplayResolveInfo> mAllDisplayTargets;
+ private final boolean mIsSuspended;
+ private final boolean mIsPinned;
+ private final float mModifiedScore;
+ private final LegacyTargetType mLegacyType;
+
+ /** Construct a {@link Builder}. */
+ public static Builder newBuilder() {
+ return new Builder();
+ }
+
+ /** Construct a {@link Builder} pre-initialized to match this target. */
+ public Builder toBuilder() {
+ return newBuilder()
+ .setBaseIntentToSend(getBaseIntentToSend())
+ .setResolvedIntent(getResolvedIntent())
+ .setTargetIntent(getTargetIntent())
+ .setReferrerFillInIntent(getReferrerFillInIntent())
+ .setResolvedComponentName(getResolvedComponentName())
+ .setChooserTargetComponentName(getChooserTargetComponentName())
+ .setActivityStarter(mActivityStarter)
+ .setResolveInfo(getResolveInfo())
+ .setDisplayLabel(getDisplayLabel())
+ .setExtendedInfo(getExtendedInfo())
+ .setDisplayIconHolder(getDisplayIconHolder())
+ .setAllSourceIntents(getAllSourceIntents())
+ .setAllDisplayTargets(getAllDisplayTargets())
+ .setIsSuspended(isSuspended())
+ .setIsPinned(isPinned())
+ .setModifiedScore(getModifiedScore())
+ .setDirectShareShortcutInfo(getDirectShareShortcutInfo())
+ .setDirectShareAppTarget(getDirectShareAppTarget())
+ .setDisplayResolveInfo(getDisplayResolveInfo())
+ .setHashProvider(getHashProvider())
+ .setLegacyType(mLegacyType);
+ }
+
+ @VisibleForTesting
+ Intent getBaseIntentToSend() {
+ return mBaseIntentToSend;
+ }
+
+ @Override
+ @Nullable
+ public ImmutableTargetInfo tryToCloneWithAppliedRefinement(Intent proposedRefinement) {
+ Intent matchingBase =
+ getAllSourceIntents()
+ .stream()
+ .filter(i -> i.filterEquals(proposedRefinement))
+ .findFirst()
+ .orElse(null);
+ if (matchingBase == null) {
+ return null;
+ }
+
+ Intent merged = new Intent(matchingBase);
+ merged.fillIn(proposedRefinement, 0);
+ return toBuilder().setBaseIntentToSend(merged).build();
+ }
+
+ @Override
+ public Intent getResolvedIntent() {
+ return (mSourceIntents.isEmpty() ? null : mSourceIntents.get(0));
+ }
+
+ @Override
+ public Intent getTargetIntent() {
+ return mTargetIntent;
+ }
+
+ @Nullable
+ public Intent getReferrerFillInIntent() {
+ return mReferrerFillInIntent;
+ }
+
+ @Override
+ @Nullable
+ public ComponentName getResolvedComponentName() {
+ return mResolvedComponentName;
+ }
+
+ @Override
+ @Nullable
+ public ComponentName getChooserTargetComponentName() {
+ return mChooserTargetComponentName;
+ }
+
+ @Override
+ public boolean startAsCaller(Activity activity, Bundle options, int userId) {
+ // TODO: make sure that the component name is set in all cases
+ return mActivityStarter.startAsCaller(this, activity, options, userId);
+ }
+
+ @Override
+ public boolean startAsUser(Activity activity, Bundle options, UserHandle user) {
+ // TODO: make sure that the component name is set in all cases
+ return mActivityStarter.startAsUser(this, activity, options, user);
+ }
+
+ @Override
+ public ResolveInfo getResolveInfo() {
+ return mResolveInfo;
+ }
+
+ @Override
+ public CharSequence getDisplayLabel() {
+ return mDisplayLabel;
+ }
+
+ @Override
+ public CharSequence getExtendedInfo() {
+ return mExtendedInfo;
+ }
+
+ @Override
+ public IconHolder getDisplayIconHolder() {
+ return mDisplayIconHolder;
+ }
+
+ @Override
+ public List<Intent> getAllSourceIntents() {
+ return mSourceIntents;
+ }
+
+ @Override
+ public ArrayList<DisplayResolveInfo> getAllDisplayTargets() {
+ ArrayList<DisplayResolveInfo> targets = new ArrayList<>();
+ targets.addAll(mAllDisplayTargets);
+ return targets;
+ }
+
+ @Override
+ public boolean isSuspended() {
+ return mIsSuspended;
+ }
+
+ @Override
+ public boolean isPinned() {
+ return mIsPinned;
+ }
+
+ @Override
+ public float getModifiedScore() {
+ return mModifiedScore;
+ }
+
+ @Override
+ @Nullable
+ public ShortcutInfo getDirectShareShortcutInfo() {
+ return mDirectShareShortcutInfo;
+ }
+
+ @Override
+ @Nullable
+ public AppTarget getDirectShareAppTarget() {
+ return mDirectShareAppTarget;
+ }
+
+ @Override
+ @Nullable
+ public DisplayResolveInfo getDisplayResolveInfo() {
+ return mDisplayResolveInfo;
+ }
+
+ @Override
+ public HashedStringCache.HashResult getHashedTargetIdForMetrics(Context context) {
+ return (mHashProvider == null)
+ ? null : mHashProvider.getHashedTargetIdForMetrics(this, context);
+ }
+
+ @VisibleForTesting
+ @Nullable
+ TargetHashProvider getHashProvider() {
+ return mHashProvider;
+ }
+
+ @Override
+ public boolean isEmptyTargetInfo() {
+ return mLegacyType == LegacyTargetType.EMPTY_TARGET_INFO;
+ }
+
+ @Override
+ public boolean isPlaceHolderTargetInfo() {
+ return mLegacyType == LegacyTargetType.PLACEHOLDER_TARGET_INFO;
+ }
+
+ @Override
+ public boolean isNotSelectableTargetInfo() {
+ return isEmptyTargetInfo() || isPlaceHolderTargetInfo();
+ }
+
+ @Override
+ public boolean isSelectableTargetInfo() {
+ return mLegacyType == LegacyTargetType.SELECTABLE_TARGET_INFO;
+ }
+
+ @Override
+ public boolean isChooserTargetInfo() {
+ return isNotSelectableTargetInfo() || isSelectableTargetInfo();
+ }
+
+ @Override
+ public boolean isMultiDisplayResolveInfo() {
+ return mLegacyType == LegacyTargetType.MULTI_DISPLAY_RESOLVE_INFO;
+ }
+
+ @Override
+ public boolean isDisplayResolveInfo() {
+ return (mLegacyType == LegacyTargetType.DISPLAY_RESOLVE_INFO)
+ || isMultiDisplayResolveInfo();
+ }
+
+ private ImmutableTargetInfo(
+ Intent baseIntentToSend,
+ ImmutableList<Intent> sourceIntents,
+ Intent targetIntent,
+ @Nullable Intent referrerFillInIntent,
+ @Nullable ComponentName resolvedComponentName,
+ @Nullable ComponentName chooserTargetComponentName,
+ TargetActivityStarter activityStarter,
+ ResolveInfo resolveInfo,
+ CharSequence displayLabel,
+ CharSequence extendedInfo,
+ IconHolder iconHolder,
+ ImmutableList<DisplayResolveInfo> allDisplayTargets,
+ boolean isSuspended,
+ boolean isPinned,
+ float modifiedScore,
+ @Nullable ShortcutInfo directShareShortcutInfo,
+ @Nullable AppTarget directShareAppTarget,
+ @Nullable DisplayResolveInfo displayResolveInfo,
+ @Nullable TargetHashProvider hashProvider,
+ LegacyTargetType legacyType) {
+ mBaseIntentToSend = baseIntentToSend;
+ mSourceIntents = sourceIntents;
+ mTargetIntent = targetIntent;
+ mReferrerFillInIntent = referrerFillInIntent;
+ mResolvedComponentName = resolvedComponentName;
+ mChooserTargetComponentName = chooserTargetComponentName;
+ mActivityStarter = activityStarter;
+ mResolveInfo = resolveInfo;
+ mDisplayLabel = displayLabel;
+ mExtendedInfo = extendedInfo;
+ mDisplayIconHolder = iconHolder;
+ mAllDisplayTargets = allDisplayTargets;
+ mIsSuspended = isSuspended;
+ mIsPinned = isPinned;
+ mModifiedScore = modifiedScore;
+ mDirectShareShortcutInfo = directShareShortcutInfo;
+ mDirectShareAppTarget = directShareAppTarget;
+ mDisplayResolveInfo = displayResolveInfo;
+ mHashProvider = hashProvider;
+ mLegacyType = legacyType;
+ }
+
+ private static <E> ImmutableList<E> immutableCopyOrEmpty(@Nullable List<E> source) {
+ return (source == null) ? ImmutableList.of() : ImmutableList.copyOf(source);
+ }
+}
diff --git a/java/src/com/android/intentresolver/chooser/MultiDisplayResolveInfo.java b/java/src/com/android/intentresolver/chooser/MultiDisplayResolveInfo.java
index 29f00a35..b97e6b45 100644
--- a/java/src/com/android/intentresolver/chooser/MultiDisplayResolveInfo.java
+++ b/java/src/com/android/intentresolver/chooser/MultiDisplayResolveInfo.java
@@ -17,12 +17,14 @@
package com.android.intentresolver.chooser;
import android.app.Activity;
+import android.content.Intent;
import android.os.Bundle;
import android.os.UserHandle;
-import com.android.intentresolver.ResolverActivity;
+import androidx.annotation.Nullable;
import java.util.ArrayList;
+import java.util.Collections;
import java.util.List;
/**
@@ -30,7 +32,7 @@ import java.util.List;
*/
public class MultiDisplayResolveInfo extends DisplayResolveInfo {
- ArrayList<DisplayResolveInfo> mTargetInfos = new ArrayList<>();
+ final ArrayList<DisplayResolveInfo> mTargetInfos;
// Index of selected target
private int mSelected = -1;
@@ -66,8 +68,9 @@ public class MultiDisplayResolveInfo extends DisplayResolveInfo {
/**
* List of all {@link DisplayResolveInfo}s included in this target.
- * TODO: provide as a generic {@code List<DisplayResolveInfo>} once {@link ChooserActivity}
- * stops requiring the signature to match that of the other "lists" it builds up.
+ * TODO: provide as a generic {@code List<DisplayResolveInfo>} once
+ * {@link com.android.intentresolver.ChooserActivity} stops requiring the signature to match
+ * that of the other "lists" it builds up.
*/
@Override
public ArrayList<DisplayResolveInfo> getAllDisplayTargets() {
@@ -93,12 +96,27 @@ public class MultiDisplayResolveInfo extends DisplayResolveInfo {
}
@Override
- public boolean start(Activity activity, Bundle options) {
- return mTargetInfos.get(mSelected).start(activity, options);
+ @Nullable
+ public MultiDisplayResolveInfo tryToCloneWithAppliedRefinement(Intent proposedRefinement) {
+ final int size = mTargetInfos.size();
+ ArrayList<DisplayResolveInfo> targetInfos = new ArrayList<>(size);
+ for (int i = 0; i < size; i++) {
+ DisplayResolveInfo target = mTargetInfos.get(i);
+ DisplayResolveInfo targetClone = (i == mSelected)
+ ? target.tryToCloneWithAppliedRefinement(proposedRefinement)
+ : new DisplayResolveInfo(target);
+ if (targetClone == null) {
+ return null;
+ }
+ targetInfos.add(targetClone);
+ }
+ MultiDisplayResolveInfo clone = new MultiDisplayResolveInfo(targetInfos);
+ clone.mSelected = mSelected;
+ return clone;
}
@Override
- public boolean startAsCaller(ResolverActivity activity, Bundle options, int userId) {
+ public boolean startAsCaller(Activity activity, Bundle options, int userId) {
return mTargetInfos.get(mSelected).startAsCaller(activity, options, userId);
}
@@ -106,4 +124,16 @@ public class MultiDisplayResolveInfo extends DisplayResolveInfo {
public boolean startAsUser(Activity activity, Bundle options, UserHandle user) {
return mTargetInfos.get(mSelected).startAsUser(activity, options, user);
}
+
+ @Override
+ public Intent getTargetIntent() {
+ return mTargetInfos.get(mSelected).getTargetIntent();
+ }
+
+ @Override
+ public List<Intent> getAllSourceIntents() {
+ return hasSelected()
+ ? mTargetInfos.get(mSelected).getAllSourceIntents()
+ : Collections.emptyList();
+ }
}
diff --git a/java/src/com/android/intentresolver/chooser/NotSelectableTargetInfo.java b/java/src/com/android/intentresolver/chooser/NotSelectableTargetInfo.java
index d6333374..6444e13b 100644
--- a/java/src/com/android/intentresolver/chooser/NotSelectableTargetInfo.java
+++ b/java/src/com/android/intentresolver/chooser/NotSelectableTargetInfo.java
@@ -16,34 +16,30 @@
package com.android.intentresolver.chooser;
+import android.annotation.Nullable;
import android.app.Activity;
-import android.content.ComponentName;
import android.content.Context;
-import android.content.Intent;
-import android.content.pm.ResolveInfo;
import android.graphics.drawable.AnimatedVectorDrawable;
import android.graphics.drawable.Drawable;
import android.os.Bundle;
import android.os.UserHandle;
import com.android.intentresolver.R;
-import com.android.intentresolver.ResolverActivity;
-import java.util.List;
+import java.util.function.Supplier;
/**
* Distinguish between targets that selectable by the user, vs those that are
* placeholders for the system while information is loading in an async manner.
*/
-public abstract class NotSelectableTargetInfo extends ChooserTargetInfo {
+public final class NotSelectableTargetInfo {
/** Create a non-selectable {@link TargetInfo} with no content. */
public static TargetInfo newEmptyTargetInfo() {
- return new NotSelectableTargetInfo() {
- @Override
- public boolean isEmptyTargetInfo() {
- return true;
- }
- };
+ return ImmutableTargetInfo.newBuilder()
+ .setLegacyType(ImmutableTargetInfo.LegacyTargetType.EMPTY_TARGET_INFO)
+ .setDisplayIconHolder(makeReadOnlyIconHolder(() -> null))
+ .setActivityStarter(makeNoOpActivityStarter())
+ .build();
}
/**
@@ -51,102 +47,51 @@ public abstract class NotSelectableTargetInfo extends ChooserTargetInfo {
* unless/until it can be replaced by the result of a pending asynchronous load.
*/
public static TargetInfo newPlaceHolderTargetInfo(Context context) {
- return new NotSelectableTargetInfo() {
- @Override
- public boolean isPlaceHolderTargetInfo() {
- return true;
- }
-
- @Override
- public IconHolder getDisplayIconHolder() {
- return new IconHolder() {
- @Override
- public Drawable getDisplayIcon() {
- AnimatedVectorDrawable avd = (AnimatedVectorDrawable)
- context.getDrawable(
- R.drawable.chooser_direct_share_icon_placeholder);
- avd.start(); // Start animation after generation.
- return avd;
- }
-
- @Override
- public void setDisplayIcon(Drawable icon) {}
- };
- }
-
- @Override
- public boolean hasDisplayIcon() {
- return true;
- }
- };
- }
-
- public final boolean isNotSelectableTargetInfo() {
- return true;
- }
-
- public Intent getResolvedIntent() {
- return null;
- }
-
- public ComponentName getResolvedComponentName() {
- return null;
- }
-
- public boolean start(Activity activity, Bundle options) {
- return false;
- }
-
- public boolean startAsCaller(ResolverActivity activity, Bundle options, int userId) {
- return false;
- }
-
- public boolean startAsUser(Activity activity, Bundle options, UserHandle user) {
- return false;
- }
-
- public ResolveInfo getResolveInfo() {
- return null;
- }
-
- public CharSequence getDisplayLabel() {
- return null;
- }
-
- public CharSequence getExtendedInfo() {
- return null;
- }
-
- public TargetInfo cloneFilledIn(Intent fillInIntent, int flags) {
- return null;
+ return ImmutableTargetInfo.newBuilder()
+ .setLegacyType(ImmutableTargetInfo.LegacyTargetType.PLACEHOLDER_TARGET_INFO)
+ .setDisplayIconHolder(
+ makeReadOnlyIconHolder(() -> makeStartedPlaceholderDrawable(context)))
+ .setActivityStarter(makeNoOpActivityStarter())
+ .build();
}
- public List<Intent> getAllSourceIntents() {
- return null;
+ private static Drawable makeStartedPlaceholderDrawable(Context context) {
+ AnimatedVectorDrawable avd = (AnimatedVectorDrawable) context.getDrawable(
+ R.drawable.chooser_direct_share_icon_placeholder);
+ avd.start(); // Start animation after generation.
+ return avd;
}
- public float getModifiedScore() {
- return -0.1f;
- }
-
- public boolean isSuspended() {
- return false;
- }
+ private static ImmutableTargetInfo.IconHolder makeReadOnlyIconHolder(
+ Supplier</* @Nullable */ Drawable> iconProvider) {
+ return new ImmutableTargetInfo.IconHolder() {
+ @Override
+ @Nullable
+ public Drawable getDisplayIcon() {
+ return iconProvider.get();
+ }
- public boolean isPinned() {
- return false;
+ @Override
+ public void setDisplayIcon(Drawable icon) {}
+ };
}
- @Override
- public IconHolder getDisplayIconHolder() {
- return new IconHolder() {
+ private static ImmutableTargetInfo.TargetActivityStarter makeNoOpActivityStarter() {
+ return new ImmutableTargetInfo.TargetActivityStarter() {
@Override
- public Drawable getDisplayIcon() {
- return null;
+ public boolean startAsCaller(
+ TargetInfo target, Activity activity, Bundle options, int userId) {
+ return false;
}
@Override
- public void setDisplayIcon(Drawable icon) {}
+ public boolean startAsUser(
+ TargetInfo target, Activity activity, Bundle options, UserHandle user) {
+ return false;
+ }
};
}
+
+ // TODO: merge all the APIs up to a single `TargetInfo` class.
+ private NotSelectableTargetInfo() {}
}
diff --git a/java/src/com/android/intentresolver/chooser/SelectableTargetInfo.java b/java/src/com/android/intentresolver/chooser/SelectableTargetInfo.java
index 3ab50175..1fbe2da7 100644
--- a/java/src/com/android/intentresolver/chooser/SelectableTargetInfo.java
+++ b/java/src/com/android/intentresolver/chooser/SelectableTargetInfo.java
@@ -33,7 +33,6 @@ import android.text.SpannableStringBuilder;
import android.util.HashedStringCache;
import android.util.Log;
-import com.android.intentresolver.ResolverActivity;
import com.android.internal.config.sysui.SystemUiDeviceConfigFlags;
import java.util.ArrayList;
@@ -79,7 +78,6 @@ public final class SelectableTargetInfo extends ChooserTargetInfo {
private final CharSequence mChooserTargetUnsanitizedTitle;
private final Icon mChooserTargetIcon;
private final Bundle mChooserTargetIntentExtras;
- private final int mFillInFlags;
private final boolean mIsPinned;
private final float mModifiedScore;
private final boolean mIsSuspended;
@@ -92,12 +90,6 @@ public final class SelectableTargetInfo extends ChooserTargetInfo {
private final TargetActivityStarter mActivityStarter;
/**
- * A refinement intent from the caller, if any (see
- * {@link Intent#EXTRA_CHOOSER_REFINEMENT_INTENT_SENDER})
- */
- private final Intent mFillInIntent;
-
- /**
* An intent containing referrer URI (see {@link Activity#getReferrer()} (possibly {@code null})
* in its extended data under the key {@link Intent#EXTRA_REFERRER}.
*/
@@ -160,6 +152,7 @@ public final class SelectableTargetInfo extends ChooserTargetInfo {
sourceInfo,
backupResolveInfo,
resolvedIntent,
+ null,
chooserTargetComponentName,
chooserTargetUnsanitizedTitle,
chooserTargetIcon,
@@ -167,15 +160,14 @@ public final class SelectableTargetInfo extends ChooserTargetInfo {
modifiedScore,
shortcutInfo,
appTarget,
- referrerFillInIntent,
- /* fillInIntent = */ null,
- /* fillInFlags = */ 0);
+ referrerFillInIntent);
}
private SelectableTargetInfo(
@Nullable DisplayResolveInfo sourceInfo,
@Nullable ResolveInfo backupResolveInfo,
Intent resolvedIntent,
+ @Nullable Intent baseIntentToSend,
ComponentName chooserTargetComponentName,
CharSequence chooserTargetUnsanitizedTitle,
Icon chooserTargetIcon,
@@ -183,9 +175,7 @@ public final class SelectableTargetInfo extends ChooserTargetInfo {
float modifiedScore,
@Nullable ShortcutInfo shortcutInfo,
@Nullable AppTarget appTarget,
- Intent referrerFillInIntent,
- @Nullable Intent fillInIntent,
- int fillInFlags) {
+ Intent referrerFillInIntent) {
mSourceInfo = sourceInfo;
mBackupResolveInfo = backupResolveInfo;
mResolvedIntent = resolvedIntent;
@@ -193,8 +183,6 @@ public final class SelectableTargetInfo extends ChooserTargetInfo {
mShortcutInfo = shortcutInfo;
mAppTarget = appTarget;
mReferrerFillInIntent = referrerFillInIntent;
- mFillInIntent = fillInIntent;
- mFillInFlags = fillInFlags;
mChooserTargetComponentName = chooserTargetComponentName;
mChooserTargetUnsanitizedTitle = chooserTargetUnsanitizedTitle;
mChooserTargetIcon = chooserTargetIcon;
@@ -210,9 +198,8 @@ public final class SelectableTargetInfo extends ChooserTargetInfo {
mAllSourceIntents = getAllSourceIntents(sourceInfo);
mBaseIntentToSend = getBaseIntentToSend(
+ baseIntentToSend,
mResolvedIntent,
- mFillInIntent,
- mFillInFlags,
mReferrerFillInIntent);
mHashProvider = context -> {
@@ -263,11 +250,12 @@ public final class SelectableTargetInfo extends ChooserTargetInfo {
};
}
- private SelectableTargetInfo(SelectableTargetInfo other, Intent fillInIntent, int flags) {
+ private SelectableTargetInfo(SelectableTargetInfo other, Intent baseIntentToSend) {
this(
other.mSourceInfo,
other.mBackupResolveInfo,
other.mResolvedIntent,
+ baseIntentToSend,
other.mChooserTargetComponentName,
other.mChooserTargetUnsanitizedTitle,
other.mChooserTargetIcon,
@@ -275,14 +263,25 @@ public final class SelectableTargetInfo extends ChooserTargetInfo {
other.mModifiedScore,
other.mShortcutInfo,
other.mAppTarget,
- other.mReferrerFillInIntent,
- fillInIntent,
- flags);
+ other.mReferrerFillInIntent);
}
@Override
- public TargetInfo cloneFilledIn(Intent fillInIntent, int flags) {
- return new SelectableTargetInfo(this, fillInIntent, flags);
+ @Nullable
+ public TargetInfo tryToCloneWithAppliedRefinement(Intent proposedRefinement) {
+ Intent matchingBase =
+ getAllSourceIntents()
+ .stream()
+ .filter(i -> i.filterEquals(proposedRefinement))
+ .findFirst()
+ .orElse(null);
+ if (matchingBase == null) {
+ return null;
+ }
+
+ Intent merged = new Intent(matchingBase);
+ merged.fillIn(proposedRefinement, 0);
+ return new SelectableTargetInfo(this, merged);
}
@Override
@@ -332,12 +331,7 @@ public final class SelectableTargetInfo extends ChooserTargetInfo {
}
@Override
- public boolean start(Activity activity, Bundle options) {
- return mActivityStarter.start(activity, options);
- }
-
- @Override
- public boolean startAsCaller(ResolverActivity activity, Bundle options, int userId) {
+ public boolean startAsCaller(Activity activity, Bundle options, int userId) {
return mActivityStarter.startAsCaller(activity, options, userId);
}
@@ -346,6 +340,12 @@ public final class SelectableTargetInfo extends ChooserTargetInfo {
return mActivityStarter.startAsUser(activity, options, user);
}
+ @Nullable
+ @Override
+ public Intent getTargetIntent() {
+ return mBaseIntentToSend;
+ }
+
@Override
public ResolveInfo getResolveInfo() {
return mResolveInfo;
@@ -418,18 +418,14 @@ public final class SelectableTargetInfo extends ChooserTargetInfo {
@Nullable
private static Intent getBaseIntentToSend(
- @Nullable Intent resolvedIntent,
- Intent fillInIntent,
- int fillInFlags,
+ @Nullable Intent providedBase,
+ @Nullable Intent fallbackBase,
Intent referrerFillInIntent) {
- Intent result = resolvedIntent;
+ Intent result = (providedBase != null) ? providedBase : fallbackBase;
if (result == null) {
Log.e(TAG, "ChooserTargetInfo: no base intent available to send");
} else {
result = new Intent(result);
- if (fillInIntent != null) {
- result.fillIn(fillInIntent, fillInFlags);
- }
result.fillIn(referrerFillInIntent, 0);
}
return result;
diff --git a/java/src/com/android/intentresolver/chooser/TargetInfo.java b/java/src/com/android/intentresolver/chooser/TargetInfo.java
index 72dd1b0b..2f48704c 100644
--- a/java/src/com/android/intentresolver/chooser/TargetInfo.java
+++ b/java/src/com/android/intentresolver/chooser/TargetInfo.java
@@ -32,8 +32,6 @@ import android.service.chooser.ChooserTarget;
import android.text.TextUtils;
import android.util.HashedStringCache;
-import com.android.intentresolver.ResolverActivity;
-
import java.util.ArrayList;
import java.util.List;
import java.util.Objects;
@@ -88,6 +86,12 @@ public interface TargetInfo {
Intent getResolvedIntent();
/**
+ * Get the target intent, the one that will be used with one of the <code>start</code> methods.
+ * @return the intent with target will be launced with.
+ */
+ @Nullable Intent getTargetIntent();
+
+ /**
* Get the resolved component name that represents this target. Note that this may not
* be the component that will be directly launched by calling one of the <code>start</code>
* methods provided; this is the component that will be credited with the launch. This may be
@@ -118,24 +122,15 @@ public interface TargetInfo {
}
/**
- * Start the activity referenced by this target.
- *
- * @param activity calling Activity performing the launch
- * @param options ActivityOptions bundle
- * @return true if the start completed successfully
- */
- boolean start(Activity activity, Bundle options);
-
- /**
- * Start the activity referenced by this target as if the ResolverActivity's caller
- * was performing the start operation.
+ * Start the activity referenced by this target as if the Activity's caller was performing the
+ * start operation.
*
* @param activity calling Activity (actually) performing the launch
* @param options ActivityOptions bundle
* @param userId userId to start as or {@link UserHandle#USER_NULL} for activity's caller
* @return true if the start completed successfully
*/
- boolean startAsCaller(ResolverActivity activity, Bundle options, int userId);
+ boolean startAsCaller(Activity activity, Bundle options, int userId);
/**
* Start the activity referenced by this target as a given user.
@@ -187,10 +182,25 @@ public interface TargetInfo {
default boolean hasDisplayIcon() {
return getDisplayIconHolder().getDisplayIcon() != null;
}
+
/**
- * Clone this target with the given fill-in information.
+ * Attempt to apply a {@code proposedRefinement} that the {@link ChooserRefinementManager}
+ * received from the caller's refinement flow. This may succeed only if the target has a source
+ * intent that matches the filtering parameters of the proposed refinement (according to
+ * {@link Intent#filterEquals()}). Then the first such match is the "base intent," and the
+ * proposed refinement is merged into that base (via {@link Intent#fillIn()}; this can never
+ * result in a change to the {@link Intent#filterEquals()} status of the base, but may e.g. add
+ * new "extras" that weren't previously given in the base intent).
+ *
+ * @return a copy of this {@link TargetInfo} where the "base intent to send" is the result of
+ * merging the refinement into the best-matching source intent, if possible. If there is no
+ * suitable match for the proposed refinement, or if merging fails for any other reason, this
+ * returns null.
+ *
+ * @see android.content.Intent#fillIn(Intent, int)
*/
- TargetInfo cloneFilledIn(Intent fillInIntent, int flags);
+ @Nullable
+ TargetInfo tryToCloneWithAppliedRefinement(Intent proposedRefinement);
/**
* @return the list of supported source intents deduped against this single target
diff --git a/java/src/com/android/intentresolver/contentpreview/ChooserContentPreviewUi.java b/java/src/com/android/intentresolver/contentpreview/ChooserContentPreviewUi.java
new file mode 100644
index 00000000..205be444
--- /dev/null
+++ b/java/src/com/android/intentresolver/contentpreview/ChooserContentPreviewUi.java
@@ -0,0 +1,310 @@
+/*
+ * Copyright (C) 2022 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.intentresolver.contentpreview;
+
+import static com.android.intentresolver.contentpreview.ContentPreviewType.CONTENT_PREVIEW_FILE;
+import static com.android.intentresolver.contentpreview.ContentPreviewType.CONTENT_PREVIEW_IMAGE;
+import static com.android.intentresolver.contentpreview.ContentPreviewType.CONTENT_PREVIEW_TEXT;
+
+import android.content.ClipData;
+import android.content.ClipDescription;
+import android.content.ContentInterface;
+import android.content.Intent;
+import android.content.res.Resources;
+import android.net.Uri;
+import android.os.RemoteException;
+import android.view.LayoutInflater;
+import android.view.ViewGroup;
+
+import androidx.annotation.Nullable;
+
+import com.android.intentresolver.ImageLoader;
+import com.android.intentresolver.flags.FeatureFlagRepository;
+import com.android.intentresolver.widget.ActionRow;
+import com.android.intentresolver.widget.ImagePreviewView;
+import com.android.intentresolver.widget.ImagePreviewView.TransitionElementStatusCallback;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.function.Consumer;
+import java.util.stream.Collectors;
+
+/**
+ * Collection of helpers for building the content preview UI displayed in
+ * {@link com.android.intentresolver.ChooserActivity}.
+ *
+ * A content preview façade.
+ */
+public final class ChooserContentPreviewUi {
+ /**
+ * Delegate to build the default system action buttons to display in the preview layout, if/when
+ * they're determined to be appropriate for the particular preview we display.
+ * TODO: clarify why action buttons are part of preview logic.
+ */
+ public interface ActionFactory {
+ /** Create an action that copies the share content to the clipboard. */
+ ActionRow.Action createCopyButton();
+
+ /** Create an action that opens the share content in a system-default editor. */
+ @Nullable
+ ActionRow.Action createEditButton();
+
+ /** Create an "Share to Nearby" action. */
+ @Nullable
+ ActionRow.Action createNearbyButton();
+
+ /** Create custom actions */
+ List<ActionRow.Action> createCustomActions();
+
+ /**
+ * Provides a share modification action, if any.
+ */
+ @Nullable
+ Runnable getModifyShareAction();
+
+ /**
+ * <p>
+ * Creates an exclude-text action that can be called when the user changes shared text
+ * status in the Media + Text preview.
+ * </p>
+ * <p>
+ * <code>true</code> argument value indicates that the text should be excluded.
+ * </p>
+ */
+ Consumer<Boolean> getExcludeSharedTextAction();
+ }
+
+ /**
+ * Testing shim to specify whether a given mime type is considered to be an "image."
+ *
+ * TODO: move away from {@link ChooserActivityOverrideData} as a model to configure our tests,
+ * then migrate {@link com.android.intentresolver.ChooserActivity#isImageType(String)} into this
+ * class.
+ */
+ public interface ImageMimeTypeClassifier {
+ /** @return whether the specified {@code mimeType} is classified as an "image" type. */
+ boolean isImageType(String mimeType);
+ }
+
+ private final ContentPreviewUi mContentPreviewUi;
+
+ public ChooserContentPreviewUi(
+ Intent targetIntent,
+ ContentInterface contentResolver,
+ ImageMimeTypeClassifier imageClassifier,
+ ImageLoader imageLoader,
+ ActionFactory actionFactory,
+ TransitionElementStatusCallback transitionElementStatusCallback,
+ FeatureFlagRepository featureFlagRepository) {
+
+ mContentPreviewUi = createContentPreview(
+ targetIntent,
+ contentResolver,
+ imageClassifier,
+ imageLoader,
+ actionFactory,
+ transitionElementStatusCallback,
+ featureFlagRepository);
+ if (mContentPreviewUi.getType() != CONTENT_PREVIEW_IMAGE) {
+ transitionElementStatusCallback.onAllTransitionElementsReady();
+ }
+ }
+
+ private ContentPreviewUi createContentPreview(
+ Intent targetIntent,
+ ContentInterface contentResolver,
+ ImageMimeTypeClassifier imageClassifier,
+ ImageLoader imageLoader,
+ ActionFactory actionFactory,
+ TransitionElementStatusCallback transitionElementStatusCallback,
+ FeatureFlagRepository featureFlagRepository) {
+ int type = findPreferredContentPreview(targetIntent, contentResolver, imageClassifier);
+ switch (type) {
+ case CONTENT_PREVIEW_TEXT:
+ return createTextPreview(
+ targetIntent, actionFactory, imageLoader, featureFlagRepository);
+
+ case CONTENT_PREVIEW_FILE:
+ return new FileContentPreviewUi(
+ extractContentUris(targetIntent),
+ actionFactory,
+ imageLoader,
+ contentResolver,
+ featureFlagRepository);
+
+ case CONTENT_PREVIEW_IMAGE:
+ return createImagePreview(
+ targetIntent,
+ actionFactory,
+ contentResolver,
+ imageClassifier,
+ imageLoader,
+ transitionElementStatusCallback,
+ featureFlagRepository);
+ }
+
+ return new NoContextPreviewUi(type);
+ }
+
+ public int getPreferredContentPreview() {
+ return mContentPreviewUi.getType();
+ }
+
+ /**
+ * Display a content preview of the specified {@code previewType} to preview the content of the
+ * specified {@code intent}.
+ */
+ public ViewGroup displayContentPreview(
+ Resources resources, LayoutInflater layoutInflater, ViewGroup parent) {
+
+ return mContentPreviewUi.display(resources, layoutInflater, parent);
+ }
+
+ /** Determine the most appropriate type of preview to show for the provided {@link Intent}. */
+ @ContentPreviewType
+ private static int findPreferredContentPreview(
+ Intent targetIntent,
+ ContentInterface resolver,
+ ImageMimeTypeClassifier imageClassifier) {
+ /* In {@link android.content.Intent#getType}, the app may specify a very general mime type
+ * that broadly covers all data being shared, such as {@literal *}/* when sending an image
+ * and text. We therefore should inspect each item for the preferred type, in order: IMAGE,
+ * FILE, TEXT. */
+ final String action = targetIntent.getAction();
+ final String type = targetIntent.getType();
+ final boolean isSend = Intent.ACTION_SEND.equals(action);
+ final boolean isSendMultiple = Intent.ACTION_SEND_MULTIPLE.equals(action);
+
+ if (!(isSend || isSendMultiple)
+ || (type != null && ClipDescription.compareMimeTypes(type, "text/*"))) {
+ return CONTENT_PREVIEW_TEXT;
+ }
+
+ if (isSend) {
+ Uri uri = targetIntent.getParcelableExtra(Intent.EXTRA_STREAM);
+ return findPreferredContentPreview(uri, resolver, imageClassifier);
+ }
+
+ List<Uri> uris = targetIntent.getParcelableArrayListExtra(Intent.EXTRA_STREAM);
+ if (uris == null || uris.isEmpty()) {
+ return CONTENT_PREVIEW_TEXT;
+ }
+
+ for (Uri uri : uris) {
+ // Defaulting to file preview when there are mixed image/file types is
+ // preferable, as it shows the user the correct number of items being shared
+ int uriPreviewType = findPreferredContentPreview(uri, resolver, imageClassifier);
+ if (uriPreviewType == CONTENT_PREVIEW_FILE) {
+ return CONTENT_PREVIEW_FILE;
+ }
+ }
+
+ return CONTENT_PREVIEW_IMAGE;
+ }
+
+ @ContentPreviewType
+ private static int findPreferredContentPreview(
+ Uri uri, ContentInterface resolver, ImageMimeTypeClassifier imageClassifier) {
+ if (uri == null) {
+ return CONTENT_PREVIEW_TEXT;
+ }
+
+ String mimeType = null;
+ try {
+ mimeType = resolver.getType(uri);
+ } catch (RemoteException ignored) {
+ }
+ return imageClassifier.isImageType(mimeType) ? CONTENT_PREVIEW_IMAGE : CONTENT_PREVIEW_FILE;
+ }
+
+ private static TextContentPreviewUi createTextPreview(
+ Intent targetIntent,
+ ChooserContentPreviewUi.ActionFactory actionFactory,
+ ImageLoader imageLoader,
+ FeatureFlagRepository featureFlagRepository) {
+ CharSequence sharingText = targetIntent.getCharSequenceExtra(Intent.EXTRA_TEXT);
+ String previewTitle = targetIntent.getStringExtra(Intent.EXTRA_TITLE);
+ ClipData previewData = targetIntent.getClipData();
+ Uri previewThumbnail = null;
+ if (previewData != null) {
+ if (previewData.getItemCount() > 0) {
+ ClipData.Item previewDataItem = previewData.getItemAt(0);
+ previewThumbnail = previewDataItem.getUri();
+ }
+ }
+ return new TextContentPreviewUi(
+ sharingText,
+ previewTitle,
+ previewThumbnail,
+ actionFactory,
+ imageLoader,
+ featureFlagRepository);
+ }
+
+ static ImageContentPreviewUi createImagePreview(
+ Intent targetIntent,
+ ChooserContentPreviewUi.ActionFactory actionFactory,
+ ContentInterface contentResolver,
+ ChooserContentPreviewUi.ImageMimeTypeClassifier imageClassifier,
+ ImageLoader imageLoader,
+ ImagePreviewView.TransitionElementStatusCallback transitionElementStatusCallback,
+ FeatureFlagRepository featureFlagRepository) {
+ CharSequence text = targetIntent.getCharSequenceExtra(Intent.EXTRA_TEXT);
+ String action = targetIntent.getAction();
+ // TODO: why don't we use image classifier for single-element ACTION_SEND?
+ final List<Uri> imageUris = Intent.ACTION_SEND.equals(action)
+ ? extractContentUris(targetIntent)
+ : extractContentUris(targetIntent)
+ .stream()
+ .filter(uri -> {
+ String type = null;
+ try {
+ type = contentResolver.getType(uri);
+ } catch (RemoteException ignored) {
+ }
+ return imageClassifier.isImageType(type);
+ })
+ .collect(Collectors.toList());
+ return new ImageContentPreviewUi(
+ imageUris,
+ text,
+ actionFactory,
+ imageLoader,
+ transitionElementStatusCallback,
+ featureFlagRepository);
+ }
+
+ private static List<Uri> extractContentUris(Intent targetIntent) {
+ List<Uri> uris = new ArrayList<>();
+ if (Intent.ACTION_SEND.equals(targetIntent.getAction())) {
+ Uri uri = targetIntent.getParcelableExtra(Intent.EXTRA_STREAM);
+ if (ContentPreviewUi.validForContentPreview(uri)) {
+ uris.add(uri);
+ }
+ } else {
+ List<Uri> receivedUris = targetIntent.getParcelableArrayListExtra(Intent.EXTRA_STREAM);
+ if (receivedUris != null) {
+ for (Uri uri : receivedUris) {
+ if (ContentPreviewUi.validForContentPreview(uri)) {
+ uris.add(uri);
+ }
+ }
+ }
+ }
+ return uris;
+ }
+}
diff --git a/java/src/com/android/intentresolver/contentpreview/ContentPreviewType.java b/java/src/com/android/intentresolver/contentpreview/ContentPreviewType.java
new file mode 100644
index 00000000..ebab147d
--- /dev/null
+++ b/java/src/com/android/intentresolver/contentpreview/ContentPreviewType.java
@@ -0,0 +1,35 @@
+/*
+ * Copyright (C) 2023 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.intentresolver.contentpreview;
+
+import static java.lang.annotation.RetentionPolicy.SOURCE;
+
+import android.annotation.IntDef;
+
+import java.lang.annotation.Retention;
+
+@Retention(SOURCE)
+@IntDef({ContentPreviewType.CONTENT_PREVIEW_FILE,
+ ContentPreviewType.CONTENT_PREVIEW_IMAGE,
+ ContentPreviewType.CONTENT_PREVIEW_TEXT})
+public @interface ContentPreviewType {
+ // Starting at 1 since 0 is considered "undefined" for some of the database transformations
+ // of tron logs.
+ int CONTENT_PREVIEW_IMAGE = 1;
+ int CONTENT_PREVIEW_FILE = 2;
+ int CONTENT_PREVIEW_TEXT = 3;
+}
diff --git a/java/src/com/android/intentresolver/contentpreview/ContentPreviewUi.java b/java/src/com/android/intentresolver/contentpreview/ContentPreviewUi.java
new file mode 100644
index 00000000..39856e66
--- /dev/null
+++ b/java/src/com/android/intentresolver/contentpreview/ContentPreviewUi.java
@@ -0,0 +1,130 @@
+/*
+ * Copyright (C) 2023 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.intentresolver.contentpreview;
+
+import static android.content.ContentProvider.getUserIdFromUri;
+
+import android.animation.ObjectAnimator;
+import android.animation.ValueAnimator;
+import android.content.res.Resources;
+import android.graphics.Bitmap;
+import android.net.Uri;
+import android.os.UserHandle;
+import android.util.Log;
+import android.view.LayoutInflater;
+import android.view.View;
+import android.view.ViewGroup;
+import android.view.ViewStub;
+import android.view.animation.DecelerateInterpolator;
+
+import androidx.annotation.LayoutRes;
+
+import com.android.intentresolver.R;
+import com.android.intentresolver.flags.FeatureFlagRepository;
+import com.android.intentresolver.flags.Flags;
+import com.android.intentresolver.widget.ActionRow;
+import com.android.intentresolver.widget.RoundedRectImageView;
+
+import java.util.ArrayList;
+import java.util.List;
+
+abstract class ContentPreviewUi {
+ private static final int IMAGE_FADE_IN_MILLIS = 150;
+ static final String TAG = "ChooserPreview";
+
+ @ContentPreviewType
+ public abstract int getType();
+
+ public abstract ViewGroup display(
+ Resources resources, LayoutInflater layoutInflater, ViewGroup parent);
+
+ protected static int getActionRowLayout(FeatureFlagRepository featureFlagRepository) {
+ return featureFlagRepository.isEnabled(Flags.SHARESHEET_CUSTOM_ACTIONS)
+ ? R.layout.scrollable_chooser_action_row
+ : R.layout.chooser_action_row;
+ }
+
+ protected static ActionRow inflateActionRow(ViewGroup parent, @LayoutRes int actionRowLayout) {
+ final ViewStub stub = parent.findViewById(com.android.intentresolver.R.id.action_row_stub);
+ if (stub != null) {
+ stub.setLayoutResource(actionRowLayout);
+ stub.inflate();
+ }
+ return parent.findViewById(com.android.internal.R.id.chooser_action_row);
+ }
+
+ protected static List<ActionRow.Action> createActions(
+ List<ActionRow.Action> systemActions,
+ List<ActionRow.Action> customActions,
+ FeatureFlagRepository featureFlagRepository) {
+ ArrayList<ActionRow.Action> actions =
+ new ArrayList<>(systemActions.size() + customActions.size());
+ actions.addAll(systemActions);
+ if (featureFlagRepository.isEnabled(Flags.SHARESHEET_CUSTOM_ACTIONS)) {
+ actions.addAll(customActions);
+ }
+ return actions;
+ }
+
+ /**
+ * Indicate if the incoming content URI should be allowed.
+ *
+ * @param uri the uri to test
+ * @return true if the URI is allowed for content preview
+ */
+ protected static boolean validForContentPreview(Uri uri) throws SecurityException {
+ if (uri == null) {
+ return false;
+ }
+ int userId = getUserIdFromUri(uri, UserHandle.USER_CURRENT);
+ if (userId != UserHandle.USER_CURRENT && userId != UserHandle.myUserId()) {
+ Log.e(ContentPreviewUi.TAG, "dropped invalid content URI belonging to user " + userId);
+ return false;
+ }
+ return true;
+ }
+
+ protected static void updateViewWithImage(RoundedRectImageView imageView, Bitmap image) {
+ if (image == null) {
+ imageView.setVisibility(View.GONE);
+ return;
+ }
+ imageView.setVisibility(View.VISIBLE);
+ imageView.setAlpha(0.0f);
+ imageView.setImageBitmap(image);
+
+ ValueAnimator fadeAnim = ObjectAnimator.ofFloat(imageView, "alpha", 0.0f, 1.0f);
+ fadeAnim.setInterpolator(new DecelerateInterpolator(1.0f));
+ fadeAnim.setDuration(IMAGE_FADE_IN_MILLIS);
+ fadeAnim.start();
+ }
+
+ protected static void displayPayloadReselectionAction(
+ ViewGroup layout,
+ ChooserContentPreviewUi.ActionFactory actionFactory,
+ FeatureFlagRepository featureFlagRepository) {
+ Runnable modifyShareAction = actionFactory.getModifyShareAction();
+ if (modifyShareAction != null && layout != null
+ && featureFlagRepository.isEnabled(Flags.SHARESHEET_RESELECTION_ACTION)) {
+ View modifyShareView = layout.findViewById(R.id.reselection_action);
+ if (modifyShareView != null) {
+ modifyShareView.setVisibility(View.VISIBLE);
+ modifyShareView.setOnClickListener(view -> modifyShareAction.run());
+ }
+ }
+ }
+}
diff --git a/java/src/com/android/intentresolver/contentpreview/FileContentPreviewUi.java b/java/src/com/android/intentresolver/contentpreview/FileContentPreviewUi.java
new file mode 100644
index 00000000..7cd71475
--- /dev/null
+++ b/java/src/com/android/intentresolver/contentpreview/FileContentPreviewUi.java
@@ -0,0 +1,236 @@
+/*
+ * Copyright (C) 2023 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.intentresolver.contentpreview;
+
+import android.content.ContentInterface;
+import android.content.res.Resources;
+import android.database.Cursor;
+import android.net.Uri;
+import android.os.RemoteException;
+import android.provider.DocumentsContract;
+import android.provider.Downloads;
+import android.provider.OpenableColumns;
+import android.text.TextUtils;
+import android.util.Log;
+import android.util.PluralsMessageFormatter;
+import android.view.LayoutInflater;
+import android.view.View;
+import android.view.ViewGroup;
+import android.widget.ImageView;
+import android.widget.TextView;
+
+import androidx.annotation.LayoutRes;
+
+import com.android.intentresolver.ImageLoader;
+import com.android.intentresolver.R;
+import com.android.intentresolver.flags.FeatureFlagRepository;
+import com.android.intentresolver.widget.ActionRow;
+
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+
+class FileContentPreviewUi extends ContentPreviewUi {
+ private static final String PLURALS_COUNT = "count";
+ private static final String PLURALS_FILE_NAME = "file_name";
+
+ private final List<Uri> mUris;
+ private final ChooserContentPreviewUi.ActionFactory mActionFactory;
+ private final ImageLoader mImageLoader;
+ private final ContentInterface mContentResolver;
+ private final FeatureFlagRepository mFeatureFlagRepository;
+
+ FileContentPreviewUi(List<Uri> uris,
+ ChooserContentPreviewUi.ActionFactory actionFactory,
+ ImageLoader imageLoader,
+ ContentInterface contentResolver,
+ FeatureFlagRepository featureFlagRepository) {
+ mUris = uris;
+ mActionFactory = actionFactory;
+ mImageLoader = imageLoader;
+ mContentResolver = contentResolver;
+ mFeatureFlagRepository = featureFlagRepository;
+ }
+
+ @Override
+ public int getType() {
+ return ContentPreviewType.CONTENT_PREVIEW_FILE;
+ }
+
+ @Override
+ public ViewGroup display(Resources resources, LayoutInflater layoutInflater, ViewGroup parent) {
+ ViewGroup layout = displayInternal(resources, layoutInflater, parent);
+ displayPayloadReselectionAction(layout, mActionFactory, mFeatureFlagRepository);
+ return layout;
+ }
+
+ private ViewGroup displayInternal(
+ Resources resources, LayoutInflater layoutInflater, ViewGroup parent) {
+ @LayoutRes int actionRowLayout = getActionRowLayout(mFeatureFlagRepository);
+ ViewGroup contentPreviewLayout = (ViewGroup) layoutInflater.inflate(
+ R.layout.chooser_grid_preview_file, parent, false);
+
+ final int uriCount = mUris.size();
+
+ if (uriCount == 0) {
+ contentPreviewLayout.setVisibility(View.GONE);
+ Log.i(TAG, "Appears to be no uris available in EXTRA_STREAM,"
+ + " removing preview area");
+ return contentPreviewLayout;
+ }
+
+ if (uriCount == 1) {
+ loadFileUriIntoView(mUris.get(0), contentPreviewLayout, mImageLoader, mContentResolver);
+ } else {
+ FileInfo fileInfo = extractFileInfo(mUris.get(0), mContentResolver);
+ int remUriCount = uriCount - 1;
+ Map<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(
+ createActions(
+ createFilePreviewActions(),
+ mActionFactory.createCustomActions(),
+ mFeatureFlagRepository));
+ }
+
+ return contentPreviewLayout;
+ }
+
+ private List<ActionRow.Action> createFilePreviewActions() {
+ List<ActionRow.Action> actions = new ArrayList<>(1);
+ //TODO(b/120417119):
+ // add action buttonFactory.createCopyButton()
+ ActionRow.Action action = mActionFactory.createNearbyButton();
+ if (action != null) {
+ actions.add(action);
+ }
+ return actions;
+ }
+
+ private static void loadFileUriIntoView(
+ final Uri uri,
+ final View parent,
+ final ImageLoader imageLoader,
+ final ContentInterface contentResolver) {
+ FileInfo fileInfo = extractFileInfo(uri, contentResolver);
+
+ TextView fileNameView = parent.findViewById(
+ com.android.internal.R.id.content_preview_filename);
+ fileNameView.setText(fileInfo.name);
+
+ if (fileInfo.hasThumbnail) {
+ imageLoader.loadImage(
+ uri,
+ (bitmap) -> updateViewWithImage(
+ parent.findViewById(
+ com.android.internal.R.id.content_preview_file_thumbnail),
+ bitmap));
+ } else {
+ View thumbnailView = parent.findViewById(
+ com.android.internal.R.id.content_preview_file_thumbnail);
+ thumbnailView.setVisibility(View.GONE);
+
+ ImageView fileIconView = parent.findViewById(
+ com.android.internal.R.id.content_preview_file_icon);
+ fileIconView.setVisibility(View.VISIBLE);
+ fileIconView.setImageResource(R.drawable.chooser_file_generic);
+ }
+ }
+
+ private static FileInfo extractFileInfo(Uri uri, ContentInterface resolver) {
+ String fileName = null;
+ boolean hasThumbnail = false;
+
+ try (Cursor cursor = queryResolver(resolver, uri)) {
+ if (cursor != null && cursor.getCount() > 0) {
+ int nameIndex = cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME);
+ int titleIndex = cursor.getColumnIndex(Downloads.Impl.COLUMN_TITLE);
+ int flagsIndex = cursor.getColumnIndex(DocumentsContract.Document.COLUMN_FLAGS);
+
+ cursor.moveToFirst();
+ if (nameIndex != -1) {
+ fileName = cursor.getString(nameIndex);
+ } else if (titleIndex != -1) {
+ fileName = cursor.getString(titleIndex);
+ }
+
+ if (flagsIndex != -1) {
+ hasThumbnail = (cursor.getInt(flagsIndex)
+ & DocumentsContract.Document.FLAG_SUPPORTS_THUMBNAIL) != 0;
+ }
+ }
+ } catch (SecurityException | NullPointerException e) {
+ // The ContentResolver already logs the exception. Log something more informative.
+ Log.w(
+ TAG,
+ "Could not load (" + uri.toString() + ") thumbnail/name for preview. If "
+ + "desired, consider using Intent#createChooser to launch the ChooserActivity, "
+ + "and set your Intent's clipData and flags in accordance with that method's "
+ + "documentation");
+ }
+
+ if (TextUtils.isEmpty(fileName)) {
+ fileName = uri.getPath();
+ fileName = fileName == null ? "" : fileName;
+ int index = fileName.lastIndexOf('/');
+ if (index != -1) {
+ fileName = fileName.substring(index + 1);
+ }
+ }
+
+ return new FileInfo(fileName, hasThumbnail);
+ }
+
+ private static Cursor queryResolver(ContentInterface resolver, Uri uri) {
+ try {
+ return resolver.query(uri, null, null, null);
+ } catch (RemoteException e) {
+ return null;
+ }
+ }
+
+ private static class FileInfo {
+ public final String name;
+ public final boolean hasThumbnail;
+
+ FileInfo(String name, boolean hasThumbnail) {
+ this.name = name;
+ this.hasThumbnail = hasThumbnail;
+ }
+ }
+}
diff --git a/java/src/com/android/intentresolver/contentpreview/ImageContentPreviewUi.java b/java/src/com/android/intentresolver/contentpreview/ImageContentPreviewUi.java
new file mode 100644
index 00000000..db26ab1b
--- /dev/null
+++ b/java/src/com/android/intentresolver/contentpreview/ImageContentPreviewUi.java
@@ -0,0 +1,179 @@
+/*
+ * Copyright (C) 2023 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.intentresolver.contentpreview;
+
+import static com.android.intentresolver.contentpreview.ContentPreviewType.CONTENT_PREVIEW_IMAGE;
+
+import android.content.res.Resources;
+import android.net.Uri;
+import android.text.TextUtils;
+import android.text.util.Linkify;
+import android.transition.TransitionManager;
+import android.util.Log;
+import android.view.LayoutInflater;
+import android.view.View;
+import android.view.ViewGroup;
+import android.view.ViewStub;
+import android.widget.CheckBox;
+import android.widget.TextView;
+
+import androidx.annotation.LayoutRes;
+import androidx.annotation.Nullable;
+
+import com.android.intentresolver.ImageLoader;
+import com.android.intentresolver.R;
+import com.android.intentresolver.flags.FeatureFlagRepository;
+import com.android.intentresolver.flags.Flags;
+import com.android.intentresolver.widget.ActionRow;
+import com.android.intentresolver.widget.ImagePreviewView;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.function.Consumer;
+
+class ImageContentPreviewUi extends ContentPreviewUi {
+ private final List<Uri> mImageUris;
+ @Nullable
+ private final CharSequence mText;
+ private final ChooserContentPreviewUi.ActionFactory mActionFactory;
+ private final ImageLoader mImageLoader;
+ private final ImagePreviewView.TransitionElementStatusCallback mTransitionElementStatusCallback;
+ private final FeatureFlagRepository mFeatureFlagRepository;
+
+ ImageContentPreviewUi(
+ List<Uri> imageUris,
+ @Nullable CharSequence text,
+ ChooserContentPreviewUi.ActionFactory actionFactory,
+ ImageLoader imageLoader,
+ ImagePreviewView.TransitionElementStatusCallback transitionElementStatusCallback,
+ FeatureFlagRepository featureFlagRepository) {
+ mImageUris = imageUris;
+ mText = text;
+ mActionFactory = actionFactory;
+ mImageLoader = imageLoader;
+ mTransitionElementStatusCallback = transitionElementStatusCallback;
+ mFeatureFlagRepository = featureFlagRepository;
+
+ mImageLoader.prePopulate(mImageUris);
+ }
+
+ @Override
+ public int getType() {
+ return CONTENT_PREVIEW_IMAGE;
+ }
+
+ @Override
+ public ViewGroup display(Resources resources, LayoutInflater layoutInflater, ViewGroup parent) {
+ ViewGroup layout = displayInternal(layoutInflater, parent);
+ displayPayloadReselectionAction(layout, mActionFactory, mFeatureFlagRepository);
+ return layout;
+ }
+
+ private ViewGroup displayInternal(LayoutInflater layoutInflater, ViewGroup parent) {
+ @LayoutRes int actionRowLayout = getActionRowLayout(mFeatureFlagRepository);
+ ViewGroup contentPreviewLayout = (ViewGroup) layoutInflater.inflate(
+ R.layout.chooser_grid_preview_image, parent, false);
+ ImagePreviewView imagePreview = inflateImagePreviewView(contentPreviewLayout);
+
+ final ActionRow actionRow = inflateActionRow(contentPreviewLayout, actionRowLayout);
+ if (actionRow != null) {
+ actionRow.setActions(
+ createActions(
+ createImagePreviewActions(),
+ mActionFactory.createCustomActions(),
+ mFeatureFlagRepository));
+ }
+
+ if (mImageUris.size() == 0) {
+ Log.i(
+ TAG,
+ "Attempted to display image preview area with zero"
+ + " available images detected in EXTRA_STREAM list");
+ ((View) imagePreview).setVisibility(View.GONE);
+ mTransitionElementStatusCallback.onAllTransitionElementsReady();
+ return contentPreviewLayout;
+ }
+
+ setTextInImagePreviewVisibility(contentPreviewLayout, mActionFactory);
+ imagePreview.setTransitionElementStatusCallback(mTransitionElementStatusCallback);
+ imagePreview.setImages(mImageUris, mImageLoader);
+
+ return contentPreviewLayout;
+ }
+
+ private List<ActionRow.Action> createImagePreviewActions() {
+ ArrayList<ActionRow.Action> actions = new ArrayList<>(2);
+ //TODO: add copy action;
+ ActionRow.Action action = mActionFactory.createNearbyButton();
+ if (action != null) {
+ actions.add(action);
+ }
+ action = mActionFactory.createEditButton();
+ if (action != null) {
+ actions.add(action);
+ }
+ return actions;
+ }
+
+ private ImagePreviewView inflateImagePreviewView(ViewGroup previewLayout) {
+ ViewStub stub = previewLayout.findViewById(R.id.image_preview_stub);
+ if (stub != null) {
+ int layoutId =
+ mFeatureFlagRepository.isEnabled(Flags.SHARESHEET_SCROLLABLE_IMAGE_PREVIEW)
+ ? R.layout.scrollable_image_preview_view
+ : R.layout.chooser_image_preview_view;
+ stub.setLayoutResource(layoutId);
+ stub.inflate();
+ }
+ return previewLayout.findViewById(
+ com.android.internal.R.id.content_preview_image_area);
+ }
+
+ private void setTextInImagePreviewVisibility(
+ ViewGroup contentPreview, ChooserContentPreviewUi.ActionFactory actionFactory) {
+ int visibility = mFeatureFlagRepository.isEnabled(Flags.SHARESHEET_IMAGE_AND_TEXT_PREVIEW)
+ && !TextUtils.isEmpty(mText)
+ ? View.VISIBLE
+ : View.GONE;
+
+ final TextView textView = contentPreview
+ .requireViewById(com.android.internal.R.id.content_preview_text);
+ CheckBox actionView = contentPreview
+ .requireViewById(R.id.include_text_action);
+ textView.setVisibility(visibility);
+ boolean isLink = visibility == View.VISIBLE && HttpUriMatcher.isHttpUri(mText.toString());
+ textView.setAutoLinkMask(isLink ? Linkify.WEB_URLS : 0);
+ textView.setText(mText);
+
+ if (visibility == View.VISIBLE) {
+ final int[] actionLabels = isLink
+ ? new int[] { R.string.include_link, R.string.exclude_link }
+ : new int[] { R.string.include_text, R.string.exclude_text };
+ final Consumer<Boolean> shareTextAction = actionFactory.getExcludeSharedTextAction();
+ actionView.setChecked(true);
+ actionView.setText(actionLabels[1]);
+ shareTextAction.accept(false);
+ actionView.setOnCheckedChangeListener((view, isChecked) -> {
+ view.setText(actionLabels[isChecked ? 1 : 0]);
+ TransitionManager.beginDelayedTransition((ViewGroup) textView.getParent());
+ textView.setVisibility(isChecked ? View.VISIBLE : View.GONE);
+ shareTextAction.accept(!isChecked);
+ });
+ }
+ actionView.setVisibility(visibility);
+ }
+}
diff --git a/java/src/com/android/intentresolver/contentpreview/IsHttpUri.kt b/java/src/com/android/intentresolver/contentpreview/IsHttpUri.kt
new file mode 100644
index 00000000..80232537
--- /dev/null
+++ b/java/src/com/android/intentresolver/contentpreview/IsHttpUri.kt
@@ -0,0 +1,27 @@
+/*
+ * Copyright (C) 2023 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+@file:JvmName("HttpUriMatcher")
+package com.android.intentresolver.contentpreview
+
+import java.net.URI
+
+internal fun String.isHttpUri() =
+ kotlin.runCatching {
+ URI(this).scheme.takeIf { scheme ->
+ "http".compareTo(scheme, true) == 0 || "https".compareTo(scheme, true) == 0
+ }
+ }.getOrNull() != null
diff --git a/java/src/com/android/intentresolver/contentpreview/NoContextPreviewUi.kt b/java/src/com/android/intentresolver/contentpreview/NoContextPreviewUi.kt
new file mode 100644
index 00000000..90016932
--- /dev/null
+++ b/java/src/com/android/intentresolver/contentpreview/NoContextPreviewUi.kt
@@ -0,0 +1,33 @@
+/*
+ * Copyright (C) 2023 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.intentresolver.contentpreview
+
+import android.content.res.Resources
+import android.util.Log
+import android.view.LayoutInflater
+import android.view.ViewGroup
+
+internal class NoContextPreviewUi(private val type: Int) : ContentPreviewUi() {
+ override fun getType(): Int = type
+
+ override fun display(
+ resources: Resources?, layoutInflater: LayoutInflater?, parent: ViewGroup?
+ ): ViewGroup? {
+ Log.e(TAG, "Unexpected content preview type: $type")
+ return null
+ }
+}
diff --git a/java/src/com/android/intentresolver/contentpreview/TextContentPreviewUi.java b/java/src/com/android/intentresolver/contentpreview/TextContentPreviewUi.java
new file mode 100644
index 00000000..7901e4cb
--- /dev/null
+++ b/java/src/com/android/intentresolver/contentpreview/TextContentPreviewUi.java
@@ -0,0 +1,138 @@
+/*
+ * Copyright (C) 2023 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.intentresolver.contentpreview;
+
+import android.content.res.Resources;
+import android.net.Uri;
+import android.text.TextUtils;
+import android.view.LayoutInflater;
+import android.view.View;
+import android.view.ViewGroup;
+import android.widget.ImageView;
+import android.widget.TextView;
+
+import androidx.annotation.LayoutRes;
+import androidx.annotation.Nullable;
+
+import com.android.intentresolver.ImageLoader;
+import com.android.intentresolver.R;
+import com.android.intentresolver.flags.FeatureFlagRepository;
+import com.android.intentresolver.widget.ActionRow;
+
+import java.util.ArrayList;
+import java.util.List;
+
+class TextContentPreviewUi extends ContentPreviewUi {
+ @Nullable
+ private final CharSequence mSharingText;
+ @Nullable
+ private final CharSequence mPreviewTitle;
+ @Nullable
+ private final Uri mPreviewThumbnail;
+ private final ImageLoader mImageLoader;
+ private final ChooserContentPreviewUi.ActionFactory mActionFactory;
+ private final FeatureFlagRepository mFeatureFlagRepository;
+
+ TextContentPreviewUi(
+ @Nullable CharSequence sharingText,
+ @Nullable CharSequence previewTitle,
+ @Nullable Uri previewThumbnail,
+ ChooserContentPreviewUi.ActionFactory actionFactory,
+ ImageLoader imageLoader,
+ FeatureFlagRepository featureFlagRepository) {
+ mSharingText = sharingText;
+ mPreviewTitle = previewTitle;
+ mPreviewThumbnail = previewThumbnail;
+ mImageLoader = imageLoader;
+ mActionFactory = actionFactory;
+ mFeatureFlagRepository = featureFlagRepository;
+ }
+
+ @Override
+ public int getType() {
+ return ContentPreviewType.CONTENT_PREVIEW_TEXT;
+ }
+
+ @Override
+ public ViewGroup display(Resources resources, LayoutInflater layoutInflater, ViewGroup parent) {
+ ViewGroup layout = displayInternal(layoutInflater, parent);
+ displayPayloadReselectionAction(layout, mActionFactory, mFeatureFlagRepository);
+ return layout;
+ }
+
+ private ViewGroup displayInternal(
+ LayoutInflater layoutInflater,
+ ViewGroup parent) {
+ @LayoutRes int actionRowLayout = getActionRowLayout(mFeatureFlagRepository);
+ ViewGroup contentPreviewLayout = (ViewGroup) layoutInflater.inflate(
+ R.layout.chooser_grid_preview_text, parent, false);
+
+ final ActionRow actionRow = inflateActionRow(contentPreviewLayout, actionRowLayout);
+ if (actionRow != null) {
+ actionRow.setActions(
+ createActions(
+ createTextPreviewActions(),
+ mActionFactory.createCustomActions(),
+ mFeatureFlagRepository));
+ }
+
+ if (mSharingText == null) {
+ contentPreviewLayout
+ .findViewById(com.android.internal.R.id.content_preview_text_layout)
+ .setVisibility(View.GONE);
+ } else {
+ TextView textView = contentPreviewLayout.findViewById(
+ com.android.internal.R.id.content_preview_text);
+ textView.setText(mSharingText);
+ }
+
+ if (TextUtils.isEmpty(mPreviewTitle)) {
+ contentPreviewLayout
+ .findViewById(com.android.internal.R.id.content_preview_title_layout)
+ .setVisibility(View.GONE);
+ } else {
+ TextView previewTitleView = contentPreviewLayout.findViewById(
+ com.android.internal.R.id.content_preview_title);
+ previewTitleView.setText(mPreviewTitle);
+
+ ImageView previewThumbnailView = contentPreviewLayout.findViewById(
+ com.android.internal.R.id.content_preview_thumbnail);
+ if (!validForContentPreview(mPreviewThumbnail)) {
+ previewThumbnailView.setVisibility(View.GONE);
+ } else {
+ mImageLoader.loadImage(
+ mPreviewThumbnail,
+ (bitmap) -> updateViewWithImage(
+ contentPreviewLayout.findViewById(
+ com.android.internal.R.id.content_preview_thumbnail),
+ bitmap));
+ }
+ }
+
+ return contentPreviewLayout;
+ }
+
+ private List<ActionRow.Action> createTextPreviewActions() {
+ ArrayList<ActionRow.Action> actions = new ArrayList<>(2);
+ actions.add(mActionFactory.createCopyButton());
+ ActionRow.Action nearbyAction = mActionFactory.createNearbyButton();
+ if (nearbyAction != null) {
+ actions.add(nearbyAction);
+ }
+ return actions;
+ }
+}
diff --git a/java/src/com/android/intentresolver/flags/DeviceConfigProxy.kt b/java/src/com/android/intentresolver/flags/DeviceConfigProxy.kt
new file mode 100644
index 00000000..d1494fe7
--- /dev/null
+++ b/java/src/com/android/intentresolver/flags/DeviceConfigProxy.kt
@@ -0,0 +1,33 @@
+/*
+ * Copyright (C) 2022 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.intentresolver.flags
+
+import android.provider.DeviceConfig
+import com.android.systemui.flags.ParcelableFlag
+
+internal class DeviceConfigProxy {
+ fun isEnabled(flag: ParcelableFlag<Boolean>): Boolean? {
+ return runCatching {
+ val hasProperty = DeviceConfig.getProperty(flag.namespace, flag.name) != null
+ if (hasProperty) {
+ DeviceConfig.getBoolean(flag.namespace, flag.name, flag.default)
+ } else {
+ null
+ }
+ }.getOrDefault(null)
+ }
+}
diff --git a/java/src/com/android/intentresolver/flags/FeatureFlagRepository.kt b/java/src/com/android/intentresolver/flags/FeatureFlagRepository.kt
new file mode 100644
index 00000000..5b5d769c
--- /dev/null
+++ b/java/src/com/android/intentresolver/flags/FeatureFlagRepository.kt
@@ -0,0 +1,25 @@
+/*
+ * Copyright (C) 2022 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.intentresolver.flags
+
+import com.android.systemui.flags.ReleasedFlag
+import com.android.systemui.flags.UnreleasedFlag
+
+interface FeatureFlagRepository {
+ fun isEnabled(flag: UnreleasedFlag): Boolean
+ fun isEnabled(flag: ReleasedFlag): Boolean
+}
diff --git a/java/src/com/android/intentresolver/flags/Flags.kt b/java/src/com/android/intentresolver/flags/Flags.kt
new file mode 100644
index 00000000..f4dbeddb
--- /dev/null
+++ b/java/src/com/android/intentresolver/flags/Flags.kt
@@ -0,0 +1,55 @@
+/*
+ * Copyright (C) 2022 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.intentresolver.flags
+
+import com.android.systemui.flags.UnreleasedFlag
+
+// Flag id, name and namespace should be kept in sync with [com.android.systemui.flags.Flags] to
+// make the flags available in the flag flipper app (see go/sysui-flags).
+object Flags {
+ const val SHARESHEET_CUSTOM_ACTIONS_NAME = "sharesheet_custom_actions"
+ const val SHARESHEET_RESELECTION_ACTION_NAME = "sharesheet_reselection_action"
+ const val SHARESHEET_IMAGE_AND_TEXT_PREVIEW_NAME = "sharesheet_image_text_preview"
+ const val SHARESHEET_SCROLLABLE_IMAGE_PREVIEW_NAME = "sharesheet_scrollable_image_preview"
+
+ // TODO(b/266983432) Tracking Bug
+ @JvmField
+ val SHARESHEET_CUSTOM_ACTIONS = unreleasedFlag(
+ 1501, SHARESHEET_CUSTOM_ACTIONS_NAME, teamfood = true
+ )
+
+ // TODO(b/266982749) Tracking Bug
+ @JvmField
+ val SHARESHEET_RESELECTION_ACTION = unreleasedFlag(
+ 1502, SHARESHEET_RESELECTION_ACTION_NAME, teamfood = true
+ )
+
+ // TODO(b/266983474) Tracking Bug
+ @JvmField
+ val SHARESHEET_IMAGE_AND_TEXT_PREVIEW = unreleasedFlag(
+ id = 1503, name = SHARESHEET_IMAGE_AND_TEXT_PREVIEW_NAME, teamfood = true
+ )
+
+ // TODO(b/267355521) Tracking Bug
+ @JvmField
+ val SHARESHEET_SCROLLABLE_IMAGE_PREVIEW = unreleasedFlag(
+ 1504, SHARESHEET_SCROLLABLE_IMAGE_PREVIEW_NAME, teamfood = true
+ )
+
+ private fun unreleasedFlag(id: Int, name: String, teamfood: Boolean = false) =
+ UnreleasedFlag(id, name, "systemui", teamfood)
+}
diff --git a/java/src/com/android/intentresolver/model/AbstractResolverComparator.java b/java/src/com/android/intentresolver/model/AbstractResolverComparator.java
index 271c6f98..ea767568 100644
--- a/java/src/com/android/intentresolver/model/AbstractResolverComparator.java
+++ b/java/src/com/android/intentresolver/model/AbstractResolverComparator.java
@@ -30,8 +30,8 @@ import android.os.UserHandle;
import android.util.Log;
import com.android.intentresolver.ChooserActivityLogger;
+import com.android.intentresolver.ResolvedComponentInfo;
import com.android.intentresolver.ResolverActivity;
-import com.android.intentresolver.ResolverActivity.ResolvedComponentInfo;
import java.text.Collator;
import java.util.ArrayList;
diff --git a/java/src/com/android/intentresolver/model/AppPredictionServiceResolverComparator.java b/java/src/com/android/intentresolver/model/AppPredictionServiceResolverComparator.java
index c6bb2b85..c986ef15 100644
--- a/java/src/com/android/intentresolver/model/AppPredictionServiceResolverComparator.java
+++ b/java/src/com/android/intentresolver/model/AppPredictionServiceResolverComparator.java
@@ -32,7 +32,7 @@ import android.os.UserHandle;
import android.util.Log;
import com.android.intentresolver.ChooserActivityLogger;
-import com.android.intentresolver.ResolverActivity.ResolvedComponentInfo;
+import com.android.intentresolver.ResolvedComponentInfo;
import java.util.ArrayList;
import java.util.Comparator;
diff --git a/java/src/com/android/intentresolver/model/ResolverRankerServiceResolverComparator.java b/java/src/com/android/intentresolver/model/ResolverRankerServiceResolverComparator.java
index 4382f109..0431078c 100644
--- a/java/src/com/android/intentresolver/model/ResolverRankerServiceResolverComparator.java
+++ b/java/src/com/android/intentresolver/model/ResolverRankerServiceResolverComparator.java
@@ -38,7 +38,7 @@ import android.service.resolver.ResolverTarget;
import android.util.Log;
import com.android.intentresolver.ChooserActivityLogger;
-import com.android.intentresolver.ResolverActivity.ResolvedComponentInfo;
+import com.android.intentresolver.ResolvedComponentInfo;
import com.android.internal.logging.MetricsLogger;
import com.android.internal.logging.nano.MetricsProto.MetricsEvent;
diff --git a/java/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..ca94a95d
--- /dev/null
+++ b/java/src/com/android/intentresolver/widget/ChooserImagePreviewView.kt
@@ -0,0 +1,163 @@
+/*
+ * 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.chooser_image_preview_view_internals, this, true)
+ mainImage = requireViewById(IntR.id.content_preview_image_1_large)
+ secondLargeImage = requireViewById(IntR.id.content_preview_image_2_large)
+ secondSmallImage = requireViewById(IntR.id.content_preview_image_2_small)
+ 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/RecyclerViewExtensions.kt b/java/src/com/android/intentresolver/widget/RecyclerViewExtensions.kt
new file mode 100644
index 00000000..a7906001
--- /dev/null
+++ b/java/src/com/android/intentresolver/widget/RecyclerViewExtensions.kt
@@ -0,0 +1,36 @@
+/*
+ * Copyright (C) 2023 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.intentresolver.widget
+
+import android.view.View
+import androidx.recyclerview.widget.RecyclerView
+
+internal val RecyclerView.areAllChildrenVisible: Boolean
+ get() {
+ val count = getChildCount()
+ if (count == 0) return true
+ val first = getChildAt(0)
+ val last = getChildAt(count - 1)
+ val itemCount = adapter?.itemCount ?: 0
+ return getChildAdapterPosition(first) == 0
+ && getChildAdapterPosition(last) == itemCount - 1
+ && isFullyVisible(first)
+ && isFullyVisible(last)
+ }
+
+private fun RecyclerView.isFullyVisible(view: View): Boolean =
+ view.left >= paddingLeft && view.right <= width - paddingRight
diff --git a/java/src/com/android/intentresolver/widget/ScrollableActionRow.kt b/java/src/com/android/intentresolver/widget/ScrollableActionRow.kt
index a941b97a..f2a8b9e8 100644
--- a/java/src/com/android/intentresolver/widget/ScrollableActionRow.kt
+++ b/java/src/com/android/intentresolver/widget/ScrollableActionRow.kt
@@ -50,21 +50,6 @@ class ScrollableActionRow : RecyclerView, ActionRow {
)
}
- private val areAllChildrenVisible: Boolean
- get() {
- val count = getChildCount()
- if (count == 0) return true
- val first = getChildAt(0)
- val last = getChildAt(count - 1)
- return getChildAdapterPosition(first) == 0
- && getChildAdapterPosition(last) == actionsAdapter.itemCount - 1
- && isFullyVisible(first)
- && isFullyVisible(last)
- }
-
- private fun isFullyVisible(view: View): Boolean =
- view.left >= paddingLeft && view.right <= width - paddingRight
-
private class Adapter(private val context: Context) : RecyclerView.Adapter<ViewHolder>() {
private val iconSize: Int =
context.resources.getDimensionPixelSize(R.dimen.chooser_action_view_icon_size)
@@ -103,11 +88,12 @@ class ScrollableActionRow : RecyclerView, ActionRow {
) : RecyclerView.ViewHolder(view) {
fun bind(action: ActionRow.Action) {
- if (action.icon != null) {
- action.icon.setBounds(0, 0, iconSize, iconSize)
+ action.icon?.let { icon ->
+ icon.setBounds(0, 0, iconSize, iconSize)
// some drawables (edit) does not gets tinted when set to the top of the text
// with TextView#setCompoundDrawableRelative
- view.setCompoundDrawablesRelative(null, action.icon, null, null)
+ tintIcon(icon, view)
+ view.setCompoundDrawablesRelative(null, icon, null, null)
}
view.text = action.label ?: ""
view.setOnClickListener {
diff --git a/java/src/com/android/intentresolver/widget/ScrollableImagePreviewView.kt b/java/src/com/android/intentresolver/widget/ScrollableImagePreviewView.kt
new file mode 100644
index 00000000..467c404a
--- /dev/null
+++ b/java/src/com/android/intentresolver/widget/ScrollableImagePreviewView.kt
@@ -0,0 +1,178 @@
+/*
+ * Copyright (C) 2023 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.intentresolver.widget
+
+import android.content.Context
+import android.graphics.Rect
+import android.net.Uri
+import android.util.AttributeSet
+import android.util.TypedValue
+import android.view.LayoutInflater
+import android.view.View
+import android.view.ViewGroup
+import android.widget.ImageView
+import androidx.recyclerview.widget.LinearLayoutManager
+import androidx.recyclerview.widget.RecyclerView
+import com.android.intentresolver.R
+import com.android.intentresolver.widget.ImagePreviewView.TransitionElementStatusCallback
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.MainScope
+import kotlinx.coroutines.cancel
+import kotlinx.coroutines.isActive
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.plus
+
+private const val TRANSITION_NAME = "screenshot_preview_image"
+
+class ScrollableImagePreviewView : RecyclerView, ImagePreviewView {
+ constructor(context: Context) : this(context, null)
+ constructor(context: Context, attrs: AttributeSet?) : this(context, attrs, 0)
+ constructor(
+ context: Context, attrs: AttributeSet?, defStyleAttr: Int
+ ) : super(context, attrs, defStyleAttr) {
+ layoutManager = LinearLayoutManager(context, LinearLayoutManager.HORIZONTAL, false)
+ adapter = Adapter(context)
+ val spacing = TypedValue.applyDimension(
+ TypedValue.COMPLEX_UNIT_DIP, 5f, context.resources.displayMetrics
+ ).toInt()
+ addItemDecoration(SpacingDecoration(spacing))
+ }
+
+ private val previewAdapter get() = adapter as Adapter
+
+ override fun onLayout(changed: Boolean, l: Int, t: Int, r: Int, b: Int) {
+ super.onLayout(changed, l, t, r, b)
+ setOverScrollMode(
+ if (areAllChildrenVisible) View.OVER_SCROLL_NEVER else View.OVER_SCROLL_ALWAYS
+ )
+ }
+
+ override fun setTransitionElementStatusCallback(callback: TransitionElementStatusCallback?) {
+ previewAdapter.transitionStatusElementCallback = callback
+ }
+
+ override fun setImages(uris: List<Uri>, imageLoader: ImageLoader) {
+ previewAdapter.setImages(uris, imageLoader)
+ }
+
+ private class Adapter(private val context: Context) : RecyclerView.Adapter<ViewHolder>() {
+ private val uris = ArrayList<Uri>()
+ private var imageLoader: ImageLoader? = null
+ var transitionStatusElementCallback: TransitionElementStatusCallback? = null
+
+ fun setImages(uris: List<Uri>, imageLoader: ImageLoader) {
+ this.uris.clear()
+ this.uris.addAll(uris)
+ this.imageLoader = imageLoader
+ notifyDataSetChanged()
+ }
+
+ override fun onCreateViewHolder(parent: ViewGroup, itemType: Int): ViewHolder {
+ return ViewHolder(
+ LayoutInflater.from(context)
+ .inflate(R.layout.image_preview_image_item, parent, false)
+ )
+ }
+
+ override fun getItemCount(): Int = uris.size
+
+ override fun onBindViewHolder(vh: ViewHolder, position: Int) {
+ vh.bind(
+ uris[position],
+ imageLoader ?: error("ImageLoader is missing"),
+ if (position == 0 && transitionStatusElementCallback != null) {
+ this::onTransitionElementReady
+ } else {
+ null
+ }
+ )
+ }
+
+ override fun onViewRecycled(vh: ViewHolder) {
+ vh.unbind()
+ }
+
+ override fun onFailedToRecycleView(vh: ViewHolder): Boolean {
+ vh.unbind()
+ return super.onFailedToRecycleView(vh)
+ }
+
+ private fun onTransitionElementReady(name: String) {
+ transitionStatusElementCallback?.apply {
+ onTransitionElementReady(name)
+ onAllTransitionElementsReady()
+ }
+ transitionStatusElementCallback = null
+ }
+ }
+
+ private class ViewHolder(view: View) : RecyclerView.ViewHolder(view) {
+ private val image = view.requireViewById<ImageView>(R.id.image)
+ private var scope: CoroutineScope? = null
+
+ fun bind(
+ uri: Uri,
+ imageLoader: ImageLoader,
+ previewReadyCallback: ((String) -> Unit)?
+ ) {
+ image.setImageDrawable(null)
+ image.transitionName = if (previewReadyCallback != null) {
+ TRANSITION_NAME
+ } else {
+ null
+ }
+ resetScope().launch {
+ loadImage(uri, imageLoader, previewReadyCallback)
+ }
+ }
+
+ private suspend fun loadImage(
+ uri: Uri,
+ imageLoader: ImageLoader,
+ previewReadyCallback: ((String) -> Unit)?
+ ) {
+ val bitmap = runCatching {
+ // it's expected for all loading/caching optimizations to be implemented by the
+ // loader
+ imageLoader(uri)
+ }.getOrNull()
+ image.setImageBitmap(bitmap)
+ previewReadyCallback?.let { callback ->
+ image.waitForPreDraw()
+ callback(TRANSITION_NAME)
+ }
+ }
+
+ private fun resetScope(): CoroutineScope =
+ (MainScope() + Dispatchers.Main.immediate).also {
+ scope?.cancel()
+ scope = it
+ }
+
+ fun unbind() {
+ scope?.cancel()
+ scope = null
+ }
+ }
+
+ private class SpacingDecoration(private val margin: Int) : RecyclerView.ItemDecoration() {
+ override fun getItemOffsets(outRect: Rect, view: View, parent: RecyclerView, state: State) {
+ outRect.set(margin, 0, margin, 0)
+ }
+ }
+}
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() }
+}