diff options
| author | 2023-06-21 15:16:10 -0700 | |
|---|---|---|
| committer | 2023-06-21 15:16:10 -0700 | |
| commit | 30382776c7fd40a7e08c0fd69cc4b1712724b4dc (patch) | |
| tree | 970a428d34bfaad9fd8e70ad12619586033d18a7 /java/src | |
| parent | 3cc92ab20b1ee37e283dc8791abc80e7fce94196 (diff) | |
| parent | 4042e26988acfecda45dbcc4d01ac1be7813b42e (diff) | |
Merge Android 13 QPR3
Bug: 275386652
Merged-In: Ib64b6b991713c518faaab01935cad9e8a57e0d98
Change-Id: Ica573da71a0a3cb7283e195572505add1435578b
Diffstat (limited to 'java/src')
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() } +} |