diff options
| author | 2023-03-13 23:11:31 -0700 | |
|---|---|---|
| committer | 2023-03-13 23:11:31 -0700 | |
| commit | 7b21dc4a35cae1218308a2f04fc61d6247faa17b (patch) | |
| tree | 1448e0c76772f25db02c8931f588e0d32673d1d4 /java/src | |
| parent | cc64c57aa426bf71e88dc073b8197748fd720856 (diff) | |
| parent | 1606e219c8db1c233713f9dc2546225533718eca (diff) | |
Merge Android 13 QPR2
Bug: 273316506
Merged-In: Ia56e92ed5358ca66185f5011abd139392ee73785
Change-Id: Ib152678de052bf41ad0716401561c7e505614fe5
Diffstat (limited to 'java/src')
54 files changed, 8707 insertions, 4543 deletions
diff --git a/java/src/com/android/intentresolver/AbstractMultiProfilePagerAdapter.java b/java/src/com/android/intentresolver/AbstractMultiProfilePagerAdapter.java index 4f6c0bf1..17dbb8f2 100644 --- a/java/src/com/android/intentresolver/AbstractMultiProfilePagerAdapter.java +++ b/java/src/com/android/intentresolver/AbstractMultiProfilePagerAdapter.java @@ -16,27 +16,25 @@ package com.android.intentresolver; import android.annotation.IntDef; +import android.annotation.NonNull; import android.annotation.Nullable; +import android.annotation.UserIdInt; import android.app.AppGlobals; -import android.app.admin.DevicePolicyEventLogger; import android.content.ContentResolver; import android.content.Context; import android.content.Intent; import android.content.pm.IPackageManager; -import android.content.pm.ResolveInfo; -import android.os.AsyncTask; import android.os.Trace; import android.os.UserHandle; -import android.os.UserManager; -import android.stats.devicepolicy.DevicePolicyEnums; import android.view.View; import android.view.ViewGroup; import android.widget.Button; import android.widget.TextView; +import androidx.viewpager.widget.PagerAdapter; +import androidx.viewpager.widget.ViewPager; + import com.android.internal.annotations.VisibleForTesting; -import com.android.internal.widget.PagerAdapter; -import com.android.internal.widget.ViewPager; import java.util.HashSet; import java.util.List; @@ -59,73 +57,32 @@ public abstract class AbstractMultiProfilePagerAdapter extends PagerAdapter { private final Context mContext; private int mCurrentPage; private OnProfileSelectedListener mOnProfileSelectedListener; - private OnSwitchOnWorkSelectedListener mOnSwitchOnWorkSelectedListener; + private Set<Integer> mLoadedPages; - private final UserHandle mPersonalProfileUserHandle; + private final EmptyStateProvider mEmptyStateProvider; private final UserHandle mWorkProfileUserHandle; - private Injector mInjector; - private boolean mIsWaitingToEnableWorkProfile; + private final QuietModeManager mQuietModeManager; AbstractMultiProfilePagerAdapter(Context context, int currentPage, - UserHandle personalProfileUserHandle, + EmptyStateProvider emptyStateProvider, + QuietModeManager quietModeManager, UserHandle workProfileUserHandle) { mContext = Objects.requireNonNull(context); mCurrentPage = currentPage; mLoadedPages = new HashSet<>(); - mPersonalProfileUserHandle = personalProfileUserHandle; mWorkProfileUserHandle = workProfileUserHandle; - UserManager userManager = context.getSystemService(UserManager.class); - mInjector = new Injector() { - @Override - public boolean hasCrossProfileIntents(List<Intent> intents, int sourceUserId, - int targetUserId) { - return AbstractMultiProfilePagerAdapter.this - .hasCrossProfileIntents(intents, sourceUserId, targetUserId); - } - - @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; - } - }; - } - - protected void markWorkProfileEnabledBroadcastReceived() { - mIsWaitingToEnableWorkProfile = false; - } - - protected boolean isWaitingToEnableWorkProfile() { - return mIsWaitingToEnableWorkProfile; - } - - /** - * Overrides the default {@link Injector} for testing purposes. - */ - @VisibleForTesting - public void setInjector(Injector injector) { - mInjector = injector; + mEmptyStateProvider = emptyStateProvider; + mQuietModeManager = quietModeManager; } - protected boolean isQuietModeEnabled(UserHandle workProfileUserHandle) { - return mInjector.isQuietModeEnabled(workProfileUserHandle); + private boolean isQuietModeEnabled(UserHandle workProfileUserHandle) { + return mQuietModeManager.isQuietModeEnabled(workProfileUserHandle); } void setOnProfileSelectedListener(OnProfileSelectedListener listener) { mOnProfileSelectedListener = listener; } - void setOnSwitchOnWorkSelectedListener(OnSwitchOnWorkSelectedListener listener) { - mOnSwitchOnWorkSelectedListener = listener; - } - Context getContext() { return mContext; } @@ -191,7 +148,7 @@ public abstract class AbstractMultiProfilePagerAdapter extends PagerAdapter { @VisibleForTesting public UserHandle getCurrentUserHandle() { - return getActiveListAdapter().mResolverListController.getUserHandle(); + return getActiveListAdapter().getUserHandle(); } @Override @@ -216,6 +173,10 @@ public abstract class AbstractMultiProfilePagerAdapter extends PagerAdapter { */ abstract ProfileDescriptor getItem(int pageIndex); + protected ViewGroup getEmptyStateView(int pageIndex) { + return getItem(pageIndex).getEmptyStateView(); + } + /** * Returns the number of {@link ProfileDescriptor} objects. * <p>For a normal consumer device with only one user returns <code>1</code>. @@ -279,8 +240,6 @@ public abstract class AbstractMultiProfilePagerAdapter extends PagerAdapter { abstract @Nullable ViewGroup getInactiveAdapterView(); - abstract String getMetricsCategory(); - /** * Rebuilds the tab that is currently visible to the user. * <p>Returns {@code true} if rebuild has completed. @@ -308,7 +267,7 @@ public abstract class AbstractMultiProfilePagerAdapter extends PagerAdapter { } private int userHandleToPageIndex(UserHandle userHandle) { - if (userHandle.equals(getPersonalListAdapter().mResolverListController.getUserHandle())) { + if (userHandle.equals(getPersonalListAdapter().getUserHandle())) { return PROFILE_PERSONAL; } else { return PROFILE_WORK; @@ -316,41 +275,18 @@ public abstract class AbstractMultiProfilePagerAdapter extends PagerAdapter { } private boolean rebuildTab(ResolverListAdapter activeListAdapter, boolean doPostProcessing) { - if (shouldShowNoCrossProfileIntentsEmptyState(activeListAdapter)) { + if (shouldSkipRebuild(activeListAdapter)) { activeListAdapter.postListReadyRunnable(doPostProcessing, /* rebuildCompleted */ true); return false; } return activeListAdapter.rebuildList(doPostProcessing); } - private boolean shouldShowNoCrossProfileIntentsEmptyState( - ResolverListAdapter activeListAdapter) { - UserHandle listUserHandle = activeListAdapter.getUserHandle(); - return UserHandle.myUserId() != listUserHandle.getIdentifier() - && allowShowNoCrossProfileIntentsEmptyState() - && !mInjector.hasCrossProfileIntents(activeListAdapter.getIntents(), - UserHandle.myUserId(), listUserHandle.getIdentifier()); - } - - boolean allowShowNoCrossProfileIntentsEmptyState() { - return true; + private boolean shouldSkipRebuild(ResolverListAdapter activeListAdapter) { + EmptyState emptyState = mEmptyStateProvider.getEmptyState(activeListAdapter); + return emptyState != null && emptyState.shouldSkipDataRebuild(); } - protected abstract void showWorkProfileOffEmptyState( - ResolverListAdapter activeListAdapter, View.OnClickListener listener); - - protected abstract void showNoPersonalToWorkIntentsEmptyState( - ResolverListAdapter activeListAdapter); - - protected abstract void showNoPersonalAppsAvailableEmptyState( - ResolverListAdapter activeListAdapter); - - protected abstract void showNoWorkAppsAvailableEmptyState( - ResolverListAdapter activeListAdapter); - - protected abstract void showNoWorkToPersonalIntentsEmptyState( - ResolverListAdapter activeListAdapter); - /** * The empty state screens are shown according to their priority: * <ol> @@ -365,103 +301,88 @@ public abstract class AbstractMultiProfilePagerAdapter extends PagerAdapter { * anyway. */ void showEmptyResolverListEmptyState(ResolverListAdapter listAdapter) { - if (maybeShowNoCrossProfileIntentsEmptyState(listAdapter)) { + final EmptyState emptyState = mEmptyStateProvider.getEmptyState(listAdapter); + + if (emptyState == null) { return; } - if (maybeShowWorkProfileOffEmptyState(listAdapter)) { - return; + + emptyState.onEmptyStateShown(); + + View.OnClickListener clickListener = null; + + if (emptyState.getButtonClickListener() != null) { + clickListener = v -> emptyState.getButtonClickListener().onClick(() -> { + ProfileDescriptor descriptor = getItem( + userHandleToPageIndex(listAdapter.getUserHandle())); + AbstractMultiProfilePagerAdapter.this.showSpinner(descriptor.getEmptyStateView()); + }); } - maybeShowNoAppsAvailableEmptyState(listAdapter); + + showEmptyState(listAdapter, emptyState, clickListener); } - private boolean maybeShowNoCrossProfileIntentsEmptyState(ResolverListAdapter listAdapter) { - if (!shouldShowNoCrossProfileIntentsEmptyState(listAdapter)) { - return false; - } - if (listAdapter.getUserHandle().equals(mPersonalProfileUserHandle)) { - DevicePolicyEventLogger.createEvent( - DevicePolicyEnums.RESOLVER_EMPTY_STATE_NO_SHARING_TO_PERSONAL) - .setStrings(getMetricsCategory()) - .write(); - showNoWorkToPersonalIntentsEmptyState(listAdapter); - } else { - DevicePolicyEventLogger.createEvent( - DevicePolicyEnums.RESOLVER_EMPTY_STATE_NO_SHARING_TO_WORK) - .setStrings(getMetricsCategory()) - .write(); - showNoPersonalToWorkIntentsEmptyState(listAdapter); + /** + * Class to get user id of the current process + */ + public static class MyUserIdProvider { + /** + * @return user id of the current process + */ + public int getMyUserId() { + return UserHandle.myUserId(); } - return true; } /** - * Returns {@code true} if the work profile off empty state screen is shown. + * Utility class to check if there are cross profile intents, it is in a separate class so + * it could be mocked in tests */ - private boolean maybeShowWorkProfileOffEmptyState(ResolverListAdapter listAdapter) { - UserHandle listUserHandle = listAdapter.getUserHandle(); - if (!listUserHandle.equals(mWorkProfileUserHandle) - || !mInjector.isQuietModeEnabled(mWorkProfileUserHandle) - || listAdapter.getCount() == 0) { - return false; - } - DevicePolicyEventLogger - .createEvent(DevicePolicyEnums.RESOLVER_EMPTY_STATE_WORK_APPS_DISABLED) - .setStrings(getMetricsCategory()) - .write(); - showWorkProfileOffEmptyState(listAdapter, - v -> { - ProfileDescriptor descriptor = getItem( - userHandleToPageIndex(listAdapter.getUserHandle())); - showSpinner(descriptor.getEmptyStateView()); - if (mOnSwitchOnWorkSelectedListener != null) { - mOnSwitchOnWorkSelectedListener.onSwitchOnWorkSelected(); - } - mInjector.requestQuietModeEnabled(false, mWorkProfileUserHandle); - }); - return true; - } - - private void maybeShowNoAppsAvailableEmptyState(ResolverListAdapter listAdapter) { - UserHandle listUserHandle = listAdapter.getUserHandle(); - if (mWorkProfileUserHandle != null - && (UserHandle.myUserId() == listUserHandle.getIdentifier() - || !hasAppsInOtherProfile(listAdapter))) { - DevicePolicyEventLogger.createEvent( - DevicePolicyEnums.RESOLVER_EMPTY_STATE_NO_APPS_RESOLVED) - .setStrings(getMetricsCategory()) - .setBoolean(/*isPersonalProfile*/ listUserHandle == mPersonalProfileUserHandle) - .write(); - if (listUserHandle == mPersonalProfileUserHandle) { - showNoPersonalAppsAvailableEmptyState(listAdapter); - } else { - showNoWorkAppsAvailableEmptyState(listAdapter); - } - } else if (mWorkProfileUserHandle == null) { - showConsumerUserNoAppsAvailableEmptyState(listAdapter); + public static class CrossProfileIntentsChecker { + + private final ContentResolver mContentResolver; + + public CrossProfileIntentsChecker(@NonNull ContentResolver contentResolver) { + mContentResolver = contentResolver; } - } - protected void showEmptyState(ResolverListAdapter activeListAdapter, String title, - String subtitle) { - showEmptyState(activeListAdapter, title, subtitle, /* buttonOnClick */ null); + /** + * Returns {@code true} if at least one of the provided {@code intents} can be forwarded + * from {@code source} (user id) to {@code target} (user id). + */ + public boolean hasCrossProfileIntents(List<Intent> intents, @UserIdInt int source, + @UserIdInt int target) { + IPackageManager packageManager = AppGlobals.getPackageManager(); + + return intents.stream().anyMatch(intent -> + null != IntentForwarderActivity.canForward(intent, source, target, + packageManager, mContentResolver)); + } } - protected void showEmptyState(ResolverListAdapter activeListAdapter, - String title, String subtitle, View.OnClickListener buttonOnClick) { + protected void showEmptyState(ResolverListAdapter activeListAdapter, EmptyState emptyState, + View.OnClickListener buttonOnClick) { ProfileDescriptor descriptor = getItem( userHandleToPageIndex(activeListAdapter.getUserHandle())); descriptor.rootView.findViewById(com.android.internal.R.id.resolver_list).setVisibility(View.GONE); ViewGroup emptyStateView = descriptor.getEmptyStateView(); - resetViewVisibilitiesForWorkProfileEmptyState(emptyStateView); + resetViewVisibilitiesForEmptyState(emptyStateView); emptyStateView.setVisibility(View.VISIBLE); View container = emptyStateView.findViewById(com.android.internal.R.id.resolver_empty_state_container); setupContainerPadding(container); TextView titleView = emptyStateView.findViewById(com.android.internal.R.id.resolver_empty_state_title); - titleView.setText(title); + String title = emptyState.getTitle(); + if (title != null) { + titleView.setVisibility(View.VISIBLE); + titleView.setText(title); + } else { + titleView.setVisibility(View.GONE); + } TextView subtitleView = emptyStateView.findViewById(com.android.internal.R.id.resolver_empty_state_subtitle); + String subtitle = emptyState.getSubtitle(); if (subtitle != null) { subtitleView.setVisibility(View.VISIBLE); subtitleView.setText(subtitle); @@ -469,6 +390,9 @@ public abstract class AbstractMultiProfilePagerAdapter extends PagerAdapter { subtitleView.setVisibility(View.GONE); } + View defaultEmptyText = emptyStateView.findViewById(com.android.internal.R.id.empty); + defaultEmptyText.setVisibility(emptyState.useDefaultEmptyView() ? View.VISIBLE : View.GONE); + Button button = emptyStateView.findViewById(com.android.internal.R.id.resolver_empty_state_button); button.setVisibility(buttonOnClick != null ? View.VISIBLE : View.GONE); button.setOnClickListener(buttonOnClick); @@ -482,22 +406,6 @@ public abstract class AbstractMultiProfilePagerAdapter extends PagerAdapter { */ protected void setupContainerPadding(View container) {} - private void showConsumerUserNoAppsAvailableEmptyState(ResolverListAdapter activeListAdapter) { - ProfileDescriptor descriptor = getItem( - userHandleToPageIndex(activeListAdapter.getUserHandle())); - descriptor.rootView.findViewById(com.android.internal.R.id.resolver_list).setVisibility(View.GONE); - View emptyStateView = descriptor.getEmptyStateView(); - resetViewVisibilitiesForConsumerUserEmptyState(emptyStateView); - emptyStateView.setVisibility(View.VISIBLE); - - activeListAdapter.markTabLoaded(); - } - - private boolean isSpinnerShowing(View emptyStateView) { - return emptyStateView.findViewById(com.android.internal.R.id.resolver_empty_state_progress).getVisibility() - == View.VISIBLE; - } - private void showSpinner(View emptyStateView) { emptyStateView.findViewById(com.android.internal.R.id.resolver_empty_state_title).setVisibility(View.INVISIBLE); emptyStateView.findViewById(com.android.internal.R.id.resolver_empty_state_button).setVisibility(View.INVISIBLE); @@ -505,7 +413,7 @@ public abstract class AbstractMultiProfilePagerAdapter extends PagerAdapter { emptyStateView.findViewById(com.android.internal.R.id.empty).setVisibility(View.GONE); } - private void resetViewVisibilitiesForWorkProfileEmptyState(View emptyStateView) { + private void resetViewVisibilitiesForEmptyState(View emptyStateView) { emptyStateView.findViewById(com.android.internal.R.id.resolver_empty_state_title).setVisibility(View.VISIBLE); emptyStateView.findViewById(com.android.internal.R.id.resolver_empty_state_subtitle).setVisibility(View.VISIBLE); emptyStateView.findViewById(com.android.internal.R.id.resolver_empty_state_button).setVisibility(View.INVISIBLE); @@ -513,14 +421,6 @@ public abstract class AbstractMultiProfilePagerAdapter extends PagerAdapter { emptyStateView.findViewById(com.android.internal.R.id.empty).setVisibility(View.GONE); } - private void resetViewVisibilitiesForConsumerUserEmptyState(View emptyStateView) { - emptyStateView.findViewById(com.android.internal.R.id.resolver_empty_state_title).setVisibility(View.GONE); - emptyStateView.findViewById(com.android.internal.R.id.resolver_empty_state_subtitle).setVisibility(View.GONE); - emptyStateView.findViewById(com.android.internal.R.id.resolver_empty_state_button).setVisibility(View.GONE); - emptyStateView.findViewById(com.android.internal.R.id.resolver_empty_state_progress).setVisibility(View.GONE); - emptyStateView.findViewById(com.android.internal.R.id.empty).setVisibility(View.VISIBLE); - } - protected void showListView(ResolverListAdapter activeListAdapter) { ProfileDescriptor descriptor = getItem( userHandleToPageIndex(activeListAdapter.getUserHandle())); @@ -529,33 +429,6 @@ public abstract class AbstractMultiProfilePagerAdapter extends PagerAdapter { emptyStateView.setVisibility(View.GONE); } - private boolean hasCrossProfileIntents(List<Intent> intents, int source, int target) { - IPackageManager packageManager = AppGlobals.getPackageManager(); - ContentResolver contentResolver = mContext.getContentResolver(); - for (Intent intent : intents) { - if (IntentForwarderActivity.canForward(intent, source, target, packageManager, - contentResolver) != null) { - return true; - } - } - return false; - } - - private boolean hasAppsInOtherProfile(ResolverListAdapter adapter) { - if (mWorkProfileUserHandle == null) { - return false; - } - List<ResolverActivity.ResolvedComponentInfo> resolversForIntent = - adapter.getResolversForUser(UserHandle.of(UserHandle.myUserId())); - for (ResolverActivity.ResolvedComponentInfo info : resolversForIntent) { - ResolveInfo resolveInfo = info.getResolveInfoAt(0); - if (resolveInfo.targetUserId != UserHandle.USER_CURRENT) { - return true; - } - } - return false; - } - boolean shouldShowEmptyStateScreen(ResolverListAdapter listAdapter) { int count = listAdapter.getUnfilteredCount(); return (count == 0 && listAdapter.getPlaceholderCount() == 0) @@ -563,7 +436,7 @@ public abstract class AbstractMultiProfilePagerAdapter extends PagerAdapter { && isQuietModeEnabled(mWorkProfileUserHandle)); } - protected class ProfileDescriptor { + protected static class ProfileDescriptor { final ViewGroup rootView; private final ViewGroup mEmptyStateView; ProfileDescriptor(ViewGroup rootView) { @@ -599,6 +472,99 @@ public abstract class AbstractMultiProfilePagerAdapter extends PagerAdapter { } /** + * Returns an empty state to show for the current profile page (tab) if necessary. + * This could be used e.g. to show a blocker on a tab if device management policy doesn't + * allow to use it or there are no apps available. + */ + public interface EmptyStateProvider { + /** + * When a non-null empty state is returned the corresponding profile page will show + * this empty state + * @param resolverListAdapter the current adapter + */ + @Nullable + default EmptyState getEmptyState(ResolverListAdapter resolverListAdapter) { + return null; + } + } + + /** + * Empty state provider that combines multiple providers. Providers earlier in the list have + * priority, that is if there is a provider that returns non-null empty state then all further + * providers will be ignored. + */ + public static class CompositeEmptyStateProvider implements EmptyStateProvider { + + private final EmptyStateProvider[] mProviders; + + public CompositeEmptyStateProvider(EmptyStateProvider... providers) { + mProviders = providers; + } + + @Nullable + @Override + public EmptyState getEmptyState(ResolverListAdapter resolverListAdapter) { + for (EmptyStateProvider provider : mProviders) { + EmptyState emptyState = provider.getEmptyState(resolverListAdapter); + if (emptyState != null) { + return emptyState; + } + } + return null; + } + } + + /** + * Describes how the blocked empty state should look like for a profile tab + */ + public interface EmptyState { + /** + * Title that will be shown on the empty state + */ + @Nullable + default String getTitle() { return null; } + + /** + * Subtitle that will be shown underneath the title on the empty state + */ + @Nullable + default String getSubtitle() { return null; } + + /** + * If non-null then a button will be shown and this listener will be called + * when the button is clicked + */ + @Nullable + default ClickListener getButtonClickListener() { return null; } + + /** + * If true then default text ('No apps can perform this action') and style for the empty + * state will be applied, title and subtitle will be ignored. + */ + default boolean useDefaultEmptyView() { return false; } + + /** + * Returns true if for this empty state we should skip rebuilding of the apps list + * for this tab. + */ + default boolean shouldSkipDataRebuild() { return false; } + + /** + * Called when empty state is shown, could be used e.g. to track analytics events + */ + default void onEmptyStateShown() {} + + interface ClickListener { + void onClick(TabControl currentTab); + } + + interface TabControl { + void showSpinner(); + } + } + + + /** * Listener for when the user switches on the work profile from the work tab. */ interface OnSwitchOnWorkSelectedListener { @@ -611,14 +577,7 @@ public abstract class AbstractMultiProfilePagerAdapter extends PagerAdapter { /** * Describes an injector to be used for cross profile functionality. Overridable for testing. */ - @VisibleForTesting - public interface Injector { - /** - * Returns {@code true} if at least one of the provided {@code intents} can be forwarded - * from {@code sourceUserId} to {@code targetUserId}. - */ - boolean hasCrossProfileIntents(List<Intent> intents, int sourceUserId, int targetUserId); - + public interface QuietModeManager { /** * Returns whether the given profile is in quiet mode or not. */ @@ -628,5 +587,15 @@ public abstract class AbstractMultiProfilePagerAdapter extends PagerAdapter { * 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/ChooserActivity.java b/java/src/com/android/intentresolver/ChooserActivity.java index 14d77427..ceab62b2 100644 --- a/java/src/com/android/intentresolver/ChooserActivity.java +++ b/java/src/com/android/intentresolver/ChooserActivity.java @@ -16,29 +16,26 @@ package com.android.intentresolver; -import static com.android.internal.util.LatencyTracker.ACTION_LOAD_SHARE_SHEET; +import static android.app.admin.DevicePolicyResources.Strings.Core.RESOLVER_CANT_ACCESS_PERSONAL; +import static android.app.admin.DevicePolicyResources.Strings.Core.RESOLVER_CANT_ACCESS_WORK; +import static android.app.admin.DevicePolicyResources.Strings.Core.RESOLVER_CANT_SHARE_WITH_PERSONAL; +import static android.app.admin.DevicePolicyResources.Strings.Core.RESOLVER_CANT_SHARE_WITH_WORK; +import static android.app.admin.DevicePolicyResources.Strings.Core.RESOLVER_CROSS_PROFILE_BLOCKED_TITLE; +import static android.stats.devicepolicy.nano.DevicePolicyEnums.RESOLVER_EMPTY_STATE_NO_SHARING_TO_PERSONAL; +import static android.stats.devicepolicy.nano.DevicePolicyEnums.RESOLVER_EMPTY_STATE_NO_SHARING_TO_WORK; -import static java.lang.annotation.RetentionPolicy.SOURCE; +import static com.android.internal.util.LatencyTracker.ACTION_LOAD_SHARE_SHEET; -import android.animation.Animator; -import android.animation.AnimatorListenerAdapter; -import android.animation.AnimatorSet; -import android.animation.ObjectAnimator; -import android.animation.ValueAnimator; import android.annotation.IntDef; import android.annotation.NonNull; import android.annotation.Nullable; import android.app.Activity; import android.app.ActivityManager; import android.app.ActivityOptions; -import android.app.SharedElementCallback; -import android.app.prediction.AppPredictionContext; -import android.app.prediction.AppPredictionManager; import android.app.prediction.AppPredictor; import android.app.prediction.AppTarget; import android.app.prediction.AppTargetEvent; import android.app.prediction.AppTargetId; -import android.compat.annotation.UnsupportedAppUsage; import android.content.ClipData; import android.content.ClipboardManager; import android.content.ComponentName; @@ -50,96 +47,75 @@ import android.content.IntentSender; import android.content.IntentSender.SendIntentException; import android.content.SharedPreferences; import android.content.pm.ActivityInfo; -import android.content.pm.ApplicationInfo; import android.content.pm.PackageManager; import android.content.pm.PackageManager.NameNotFoundException; import android.content.pm.ResolveInfo; import android.content.pm.ShortcutInfo; -import android.content.pm.ShortcutManager; import android.content.res.Configuration; import android.content.res.Resources; import android.database.Cursor; -import android.database.DataSetObserver; import android.graphics.Bitmap; -import android.graphics.Canvas; -import android.graphics.Color; import android.graphics.Insets; -import android.graphics.Paint; -import android.graphics.Path; -import android.graphics.drawable.AnimatedVectorDrawable; import android.graphics.drawable.Drawable; -import android.metrics.LogMaker; import android.net.Uri; -import android.os.AsyncTask; import android.os.Bundle; import android.os.Environment; import android.os.Handler; -import android.os.Message; 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.DocumentsContract; -import android.provider.Downloads; -import android.provider.OpenableColumns; import android.provider.Settings; import android.service.chooser.ChooserTarget; import android.text.TextUtils; -import android.util.AttributeSet; -import android.util.HashedStringCache; import android.util.Log; -import android.util.PluralsMessageFormatter; import android.util.Size; import android.util.Slog; -import android.view.LayoutInflater; +import android.util.SparseArray; import android.view.View; -import android.view.View.MeasureSpec; -import android.view.View.OnClickListener; import android.view.ViewGroup; import android.view.ViewGroup.LayoutParams; import android.view.ViewTreeObserver; import android.view.WindowInsets; -import android.view.animation.AccelerateInterpolator; import android.view.animation.AlphaAnimation; import android.view.animation.Animation; -import android.view.animation.DecelerateInterpolator; import android.view.animation.LinearInterpolator; -import android.widget.Button; -import android.widget.ImageView; -import android.widget.Space; import android.widget.TextView; -import com.android.intentresolver.ResolverListAdapter.ActivityInfoPresentationGetter; -import com.android.intentresolver.ResolverListAdapter.ViewHolder; -import com.android.intentresolver.chooser.ChooserTargetInfo; +import androidx.annotation.MainThread; +import androidx.recyclerview.widget.GridLayoutManager; +import androidx.recyclerview.widget.RecyclerView; +import androidx.viewpager.widget.ViewPager; + +import com.android.intentresolver.AbstractMultiProfilePagerAdapter.EmptyState; +import com.android.intentresolver.AbstractMultiProfilePagerAdapter.EmptyStateProvider; +import com.android.intentresolver.NoCrossProfileEmptyStateProvider.DevicePolicyBlockerEmptyState; import com.android.intentresolver.chooser.DisplayResolveInfo; import com.android.intentresolver.chooser.MultiDisplayResolveInfo; -import com.android.intentresolver.chooser.NotSelectableTargetInfo; -import com.android.intentresolver.chooser.SelectableTargetInfo; -import com.android.intentresolver.chooser.SelectableTargetInfo.SelectableTargetInfoCommunicator; import com.android.intentresolver.chooser.TargetInfo; - +import com.android.intentresolver.grid.ChooserGridAdapter; +import com.android.intentresolver.grid.DirectShareViewHolder; +import com.android.intentresolver.model.AbstractResolverComparator; +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.MetricsLogger; import com.android.internal.logging.nano.MetricsProto.MetricsEvent; import com.android.internal.util.FrameworkStatsLog; -import com.android.internal.widget.GridLayoutManager; -import com.android.internal.widget.RecyclerView; -import com.android.internal.widget.ResolverDrawerLayout; -import com.android.internal.widget.ViewPager; - -import com.google.android.collect.Lists; import java.io.File; import java.io.IOException; import java.lang.annotation.Retention; import java.lang.annotation.RetentionPolicy; -import java.net.URISyntaxException; import java.text.Collator; import java.util.ArrayList; import java.util.Collections; @@ -148,25 +124,19 @@ import java.util.HashMap; import java.util.List; import java.util.Map; import java.util.Objects; -import java.util.function.Supplier; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.function.Consumer; /** * The Chooser Activity handles intent resolution specifically for sharing intents - - * for example, those generated by @see android.content.Intent#createChooser(Intent, CharSequence). + * for example, as generated by {@see android.content.Intent#createChooser(Intent, CharSequence)}. * */ public class ChooserActivity extends ResolverActivity implements - ChooserListAdapter.ChooserListCommunicator, - SelectableTargetInfoCommunicator { + ResolverListAdapter.ResolverListCommunicator { private static final String TAG = "ChooserActivity"; - private AppPredictor mPersonalAppPredictor; - private AppPredictor mWorkAppPredictor; - private boolean mShouldDisplayLandscape; - - @UnsupportedAppUsage - public ChooserActivity() { - } /** * Boolean extra to change the following behavior: Normally, ChooserActivity finishes itself * in onStop when launched in a new task. If this extra is set to true, we do not finish @@ -175,7 +145,6 @@ public class ChooserActivity extends ResolverActivity implements public static final String EXTRA_PRIVATE_RETAIN_IN_ON_STOP = "com.android.internal.app.ChooserActivity.EXTRA_PRIVATE_RETAIN_IN_ON_STOP"; - /** * Transition name for the first image preview. * To be used for shared element transition into this activity. @@ -190,44 +159,31 @@ public class ChooserActivity extends ResolverActivity implements private static final boolean DEBUG = true; - private static final boolean USE_PREDICTION_MANAGER_FOR_SHARE_ACTIVITIES = true; - // TODO(b/123088566) Share these in a better way. - private static final String APP_PREDICTION_SHARE_UI_SURFACE = "share"; public static final String LAUNCH_LOCATION_DIRECT_SHARE = "direct_share"; - public static final String CHOOSER_TARGET = "chooser_target"; private static final String SHORTCUT_TARGET = "shortcut_target"; - private static final int APP_PREDICTION_SHARE_TARGET_QUERY_PACKAGE_LIMIT = 20; - public static final String APP_PREDICTION_INTENT_FILTER_KEY = "intent_filter"; - private static final String SHARED_TEXT_KEY = "shared_text"; private static final String PLURALS_COUNT = "count"; private static final String PLURALS_FILE_NAME = "file_name"; private static final String IMAGE_EDITOR_SHARED_ELEMENT = "screenshot_preview_image"; - private boolean mIsAppPredictorComponentAvailable; - private Map<ChooserTarget, AppTarget> mDirectShareAppTargetCache; - private Map<ChooserTarget, ShortcutInfo> mDirectShareShortcutInfoCache; + // TODO: these data structures are for one-time use in shuttling data from where they're + // populated in `ShortcutToChooserTargetConverter` to where they're consumed in + // `ShortcutSelectionLogic` which packs the appropriate elements into the final `TargetInfo`. + // That flow should be refactored so that `ChooserActivity` isn't responsible for holding their + // intermediate data, and then these members can be removed. + private final Map<ChooserTarget, AppTarget> mDirectShareAppTargetCache = new HashMap<>(); + private final Map<ChooserTarget, ShortcutInfo> mDirectShareShortcutInfoCache = new HashMap<>(); public static final int TARGET_TYPE_DEFAULT = 0; public static final int TARGET_TYPE_CHOOSER_TARGET = 1; public static final int TARGET_TYPE_SHORTCUTS_FROM_SHORTCUT_MANAGER = 2; public static final int TARGET_TYPE_SHORTCUTS_FROM_PREDICTION_SERVICE = 3; - public static final int SELECTION_TYPE_SERVICE = 1; - public static final int SELECTION_TYPE_APP = 2; - public static final int SELECTION_TYPE_STANDARD = 3; - public static final int SELECTION_TYPE_COPY = 4; - public static final int SELECTION_TYPE_NEARBY = 5; - public static final int SELECTION_TYPE_EDIT = 6; - private static final int SCROLL_STATUS_IDLE = 0; private static final int SCROLL_STATUS_SCROLLING_VERTICAL = 1; private static final int SCROLL_STATUS_SCROLLING_HORIZONTAL = 2; - // statsd logger wrapper - protected ChooserActivityLogger mChooserActivityLogger; - @IntDef(flag = false, prefix = { "TARGET_TYPE_" }, value = { TARGET_TYPE_DEFAULT, TARGET_TYPE_CHOOSER_TARGET, @@ -237,294 +193,68 @@ public class ChooserActivity extends ResolverActivity implements @Retention(RetentionPolicy.SOURCE) public @interface ShareTargetType {} - /** - * The transition time between placeholders for direct share to a message - * indicating that non are available. - */ - private static final int NO_DIRECT_SHARE_ANIM_IN_MILLIS = 200; - - private static final float DIRECT_SHARE_EXPANSION_RATE = 0.78f; + public static final float DIRECT_SHARE_EXPANSION_RATE = 0.78f; private static final int DEFAULT_SALT_EXPIRATION_DAYS = 7; - private int mMaxHashSaltDays = DeviceConfig.getInt(DeviceConfig.NAMESPACE_SYSTEMUI, + private final int mMaxHashSaltDays = DeviceConfig.getInt(DeviceConfig.NAMESPACE_SYSTEMUI, SystemUiDeviceConfigFlags.HASH_SALT_MAX_DAYS, DEFAULT_SALT_EXPIRATION_DAYS); - private static final boolean DEFAULT_IS_NEARBY_SHARE_FIRST_TARGET_IN_RANKED_APP = false; - private boolean mIsNearbyShareFirstTargetInRankedApp = - DeviceConfig.getBoolean(DeviceConfig.NAMESPACE_SYSTEMUI, - SystemUiDeviceConfigFlags.IS_NEARBY_SHARE_FIRST_TARGET_IN_RANKED_APP, - DEFAULT_IS_NEARBY_SHARE_FIRST_TARGET_IN_RANKED_APP); - - private static final int DEFAULT_LIST_VIEW_UPDATE_DELAY_MS = 0; - 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; - @VisibleForTesting - int mListViewUpdateDelayMs = DeviceConfig.getInt(DeviceConfig.NAMESPACE_SYSTEMUI, - SystemUiDeviceConfigFlags.SHARESHEET_LIST_VIEW_UPDATE_DELAY, - DEFAULT_LIST_VIEW_UPDATE_DELAY_MS); + /* 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 + * matches our Activity's create/destroy lifecycle, not its Java object lifecycle) then we + * should be able to make this assignment as "final." + */ + @Nullable + private ChooserRequestParameters mChooserRequest; - private Bundle mReplacementExtras; - private IntentSender mChosenComponentSender; - private IntentSender mRefinementIntentSender; - private RefinementResultReceiver mRefinementResultReceiver; - private ChooserTarget[] mCallerChooserTargets; - private ComponentName[] mFilteredComponentNames; + private boolean mShouldDisplayLandscape; + // statsd logger wrapper + protected ChooserActivityLogger mChooserActivityLogger; - private Intent mReferrerFillInIntent; + @Nullable + private RefinementResultReceiver mRefinementResultReceiver; private long mChooserShownTime; protected boolean mIsSuccessfullySelected; - private long mQueriedSharingShortcutsTimeMs; - private int mCurrAvailableWidth = 0; private Insets mLastAppliedInsets = null; private int mLastNumberOfChildren = -1; private int mMaxTargetsPerRow = 1; - private static final String TARGET_DETAILS_FRAGMENT_TAG = "targetDetailsFragment"; - private static final int MAX_LOG_RANK_POSITION = 12; + // TODO: are these used anywhere? They should probably be migrated to ChooserRequestParameters. private static final int MAX_EXTRA_INITIAL_INTENTS = 2; private static final int MAX_EXTRA_CHOOSER_TARGETS = 2; private SharedPreferences mPinnedSharedPrefs; private static final String PINNED_SHARED_PREFS_NAME = "chooser_pin_settings"; - @Retention(SOURCE) - @IntDef({CONTENT_PREVIEW_FILE, CONTENT_PREVIEW_IMAGE, CONTENT_PREVIEW_TEXT}) - private @interface ContentPreviewType { - } + private final ExecutorService mBackgroundThreadPoolExecutor = Executors.newFixedThreadPool(5); - // Starting at 1 since 0 is considered "undefined" for some of the database transformations - // of tron logs. - protected static final int CONTENT_PREVIEW_IMAGE = 1; - protected static final int CONTENT_PREVIEW_FILE = 2; - protected static final int CONTENT_PREVIEW_TEXT = 3; - protected MetricsLogger mMetricsLogger; + @Nullable + private ChooserContentPreviewCoordinator mPreviewCoordinator; - private ContentPreviewCoordinator mPreviewCoord; private int mScrollStatus = SCROLL_STATUS_IDLE; @VisibleForTesting protected ChooserMultiProfilePagerAdapter mChooserMultiProfilePagerAdapter; private final EnterTransitionAnimationDelegate mEnterTransitionAnimationDelegate = - new EnterTransitionAnimationDelegate(); - - private boolean mRemoveSharedElements = false; + new EnterTransitionAnimationDelegate(this, () -> mResolverDrawerLayout); private View mContentView = null; - private class ContentPreviewCoordinator { - private static final int IMAGE_FADE_IN_MILLIS = 150; - private static final int IMAGE_LOAD_TIMEOUT = 1; - private static final int IMAGE_LOAD_INTO_VIEW = 2; - - private final int mImageLoadTimeoutMillis = - getResources().getInteger(R.integer.config_shortAnimTime); - - private final View mParentView; - private boolean mHideParentOnFail; - private boolean mAtLeastOneLoaded = false; - - class LoadUriTask { - public final Uri mUri; - public final int mImageResourceId; - public final int mExtraCount; - public final Bitmap mBmp; - - LoadUriTask(int imageResourceId, Uri uri, int extraCount, Bitmap bmp) { - this.mImageResourceId = imageResourceId; - this.mUri = uri; - this.mExtraCount = extraCount; - this.mBmp = bmp; - } - } - - // If at least one image loads within the timeout period, allow other - // loads to continue. Otherwise terminate and optionally hide - // the parent area - private final Handler mHandler = new Handler() { - @Override - public void handleMessage(Message msg) { - switch (msg.what) { - case IMAGE_LOAD_TIMEOUT: - maybeHideContentPreview(); - break; - - case IMAGE_LOAD_INTO_VIEW: - if (isFinishing()) break; - - LoadUriTask task = (LoadUriTask) msg.obj; - RoundedRectImageView imageView = mParentView.findViewById( - task.mImageResourceId); - if (task.mBmp == null) { - imageView.setVisibility(View.GONE); - maybeHideContentPreview(); - return; - } - - mAtLeastOneLoaded = true; - imageView.setVisibility(View.VISIBLE); - imageView.setAlpha(0.0f); - imageView.setImageBitmap(task.mBmp); - - ValueAnimator fadeAnim = ObjectAnimator.ofFloat(imageView, "alpha", 0.0f, - 1.0f); - fadeAnim.setInterpolator(new DecelerateInterpolator(1.0f)); - fadeAnim.setDuration(IMAGE_FADE_IN_MILLIS); - fadeAnim.start(); - - if (task.mExtraCount > 0) { - imageView.setExtraImageCount(task.mExtraCount); - } - - setupPreDrawForSharedElementTransition(imageView); - } - } - }; - - private void setupPreDrawForSharedElementTransition(View v) { - v.getViewTreeObserver().addOnPreDrawListener(new ViewTreeObserver.OnPreDrawListener() { - @Override - public boolean onPreDraw() { - v.getViewTreeObserver().removeOnPreDrawListener(this); - - if (!mRemoveSharedElements && isActivityTransitionRunning()) { - // Disable the window animations as it interferes with the - // transition animation. - getWindow().setWindowAnimations(0); - } - mEnterTransitionAnimationDelegate.markImagePreviewReady(); - return true; - } - }); - } + private final SparseArray<ProfileRecord> mProfileRecords = new SparseArray<>(); - ContentPreviewCoordinator(View parentView, boolean hideParentOnFail) { - super(); - - this.mParentView = parentView; - this.mHideParentOnFail = hideParentOnFail; - } - - private void loadUriIntoView(final int imageResourceId, final Uri uri, - final int extraImages) { - mHandler.sendEmptyMessageDelayed(IMAGE_LOAD_TIMEOUT, mImageLoadTimeoutMillis); - - AsyncTask.THREAD_POOL_EXECUTOR.execute(() -> { - int size = getResources().getDimensionPixelSize( - R.dimen.chooser_preview_image_max_dimen); - final Bitmap bmp = loadThumbnail(uri, new Size(size, size)); - final Message msg = Message.obtain(); - msg.what = IMAGE_LOAD_INTO_VIEW; - msg.obj = new LoadUriTask(imageResourceId, uri, extraImages, bmp); - mHandler.sendMessage(msg); - }); - } - - private void cancelLoads() { - mHandler.removeMessages(IMAGE_LOAD_INTO_VIEW); - mHandler.removeMessages(IMAGE_LOAD_TIMEOUT); - } - - private void maybeHideContentPreview() { - if (!mAtLeastOneLoaded) { - if (mHideParentOnFail) { - Log.i(TAG, "Hiding image preview area. Timed out waiting for preview to load" - + " within " + mImageLoadTimeoutMillis + "ms."); - collapseParentView(); - if (shouldShowTabs()) { - hideStickyContentPreview(); - } else if (mChooserMultiProfilePagerAdapter.getCurrentRootAdapter() != null) { - mChooserMultiProfilePagerAdapter.getCurrentRootAdapter() - .hideContentPreview(); - } - mHideParentOnFail = false; - } - mRemoveSharedElements = true; - mEnterTransitionAnimationDelegate.markImagePreviewReady(); - } - } - - private void collapseParentView() { - // This will effectively hide the content preview row by forcing the height - // to zero. It is faster than forcing a relayout of the listview - final View v = mParentView; - int widthSpec = MeasureSpec.makeMeasureSpec(v.getWidth(), MeasureSpec.EXACTLY); - int heightSpec = MeasureSpec.makeMeasureSpec(0, MeasureSpec.EXACTLY); - v.measure(widthSpec, heightSpec); - v.getLayoutParams().height = 0; - v.layout(v.getLeft(), v.getTop(), v.getRight(), v.getTop()); - v.invalidate(); - } - } - - private final ChooserHandler mChooserHandler = new ChooserHandler(); - - private class ChooserHandler extends Handler { - private static final int LIST_VIEW_UPDATE_MESSAGE = 6; - private static final int SHORTCUT_MANAGER_ALL_SHARE_TARGET_RESULTS = 7; - - private void removeAllMessages() { - removeMessages(LIST_VIEW_UPDATE_MESSAGE); - removeMessages(SHORTCUT_MANAGER_ALL_SHARE_TARGET_RESULTS); - } - - @Override - public void handleMessage(Message msg) { - if (mChooserMultiProfilePagerAdapter.getActiveListAdapter() == null || isDestroyed()) { - return; - } - - switch (msg.what) { - case LIST_VIEW_UPDATE_MESSAGE: - if (DEBUG) { - Log.d(TAG, "LIST_VIEW_UPDATE_MESSAGE; "); - } - - UserHandle userHandle = (UserHandle) msg.obj; - mChooserMultiProfilePagerAdapter.getListAdapterForUserHandle(userHandle) - .refreshListView(); - break; - - case SHORTCUT_MANAGER_ALL_SHARE_TARGET_RESULTS: - if (DEBUG) Log.d(TAG, "SHORTCUT_MANAGER_ALL_SHARE_TARGET_RESULTS"); - final ServiceResultInfo[] resultInfos = (ServiceResultInfo[]) msg.obj; - for (ServiceResultInfo resultInfo : resultInfos) { - if (resultInfo.resultTargets != null) { - ChooserListAdapter adapterForUserHandle = - mChooserMultiProfilePagerAdapter.getListAdapterForUserHandle( - resultInfo.userHandle); - if (adapterForUserHandle != null) { - adapterForUserHandle.addServiceResults( - resultInfo.originalTarget, - resultInfo.resultTargets, msg.arg1, - mDirectShareShortcutInfoCache); - } - } - } - - logDirectShareTargetReceived( - MetricsEvent.ACTION_DIRECT_SHARE_TARGETS_LOADED_SHORTCUT_MANAGER); - sendVoiceChoicesIfNeeded(); - getChooserActivityLogger().logSharesheetDirectLoadComplete(); - - mChooserMultiProfilePagerAdapter.getActiveListAdapter() - .completeServiceTargetLoading(); - break; - - default: - super.handleMessage(msg); - } - } - }; + public ChooserActivity() {} @Override protected void onCreate(Bundle savedInstanceState) { @@ -532,168 +262,59 @@ public class ChooserActivity extends ResolverActivity implements mLatencyTracker.onActionStart(ACTION_LOAD_SHARE_SHEET); getChooserActivityLogger().logSharesheetTriggered(); - // This is the only place this value is being set. Effectively final. - mIsAppPredictorComponentAvailable = isAppPredictionServiceAvailable(); - - mIsSuccessfullySelected = false; - Intent intent = getIntent(); - Parcelable targetParcelable = intent.getParcelableExtra(Intent.EXTRA_INTENT); - if (targetParcelable instanceof Uri) { - try { - targetParcelable = Intent.parseUri(targetParcelable.toString(), - Intent.URI_INTENT_SCHEME); - } catch (URISyntaxException ex) { - // doesn't parse as an intent; let the next test fail and error out - } - } - if (!(targetParcelable instanceof Intent)) { - Log.w("ChooserActivity", "Target is not an intent: " + targetParcelable); + try { + mChooserRequest = new ChooserRequestParameters( + getIntent(), getReferrer(), getNearbySharingComponent()); + } catch (IllegalArgumentException e) { + Log.e(TAG, "Caller provided invalid Chooser request parameters", e); finish(); - super.onCreate(null); + super_onCreate(null); return; } - Intent target = (Intent) targetParcelable; - if (target != null) { - modifyTargetIntent(target); - } - Parcelable[] targetsParcelable - = intent.getParcelableArrayExtra(Intent.EXTRA_ALTERNATE_INTENTS); - if (targetsParcelable != null) { - final boolean offset = target == null; - Intent[] additionalTargets = - new Intent[offset ? targetsParcelable.length - 1 : targetsParcelable.length]; - for (int i = 0; i < targetsParcelable.length; i++) { - if (!(targetsParcelable[i] instanceof Intent)) { - Log.w(TAG, "EXTRA_ALTERNATE_INTENTS array entry #" + i + " is not an Intent: " - + targetsParcelable[i]); - finish(); - super.onCreate(null); - return; - } - final Intent additionalTarget = (Intent) targetsParcelable[i]; - if (i == 0 && target == null) { - target = additionalTarget; - modifyTargetIntent(target); - } else { - additionalTargets[offset ? i - 1 : i] = additionalTarget; - modifyTargetIntent(additionalTarget); - } - } - setAdditionalTargets(additionalTargets); - } - - mReplacementExtras = intent.getBundleExtra(Intent.EXTRA_REPLACEMENT_EXTRAS); - - // Do not allow the title to be changed when sharing content - CharSequence title = null; - if (target != null) { - if (!isSendAction(target)) { - title = intent.getCharSequenceExtra(Intent.EXTRA_TITLE); - } else { - Log.w(TAG, "Ignoring intent's EXTRA_TITLE, deprecated in P. You may wish to set a" - + " preview title by using EXTRA_TITLE property of the wrapped" - + " EXTRA_INTENT."); - } - } - - int defaultTitleRes = 0; - if (title == null) { - defaultTitleRes = com.android.internal.R.string.chooseActivity; - } - - Parcelable[] pa = intent.getParcelableArrayExtra(Intent.EXTRA_INITIAL_INTENTS); - Intent[] initialIntents = null; - if (pa != null) { - int count = Math.min(pa.length, MAX_EXTRA_INITIAL_INTENTS); - initialIntents = new Intent[count]; - for (int i = 0; i < count; i++) { - if (!(pa[i] instanceof Intent)) { - Log.w(TAG, "Initial intent #" + i + " not an Intent: " + pa[i]); - finish(); - super.onCreate(null); - return; - } - final Intent in = (Intent) pa[i]; - modifyTargetIntent(in); - initialIntents[i] = in; - } - } - mReferrerFillInIntent = new Intent().putExtra(Intent.EXTRA_REFERRER, getReferrer()); + setAdditionalTargets(mChooserRequest.getAdditionalTargets()); - mChosenComponentSender = intent.getParcelableExtra( - Intent.EXTRA_CHOSEN_COMPONENT_INTENT_SENDER); - mRefinementIntentSender = intent.getParcelableExtra( - Intent.EXTRA_CHOOSER_REFINEMENT_INTENT_SENDER); setSafeForwardingMode(true); mPinnedSharedPrefs = getPinnedSharedPrefs(this); - pa = intent.getParcelableArrayExtra(Intent.EXTRA_EXCLUDE_COMPONENTS); - - - // Exclude out Nearby from main list if chip is present, to avoid duplication - ComponentName nearbySharingComponent = getNearbySharingComponent(); - boolean shouldFilterNearby = !shouldNearbyShareBeFirstInRankedRow() - && nearbySharingComponent != null; - - if (pa != null) { - ComponentName[] names = new ComponentName[pa.length + (shouldFilterNearby ? 1 : 0)]; - for (int i = 0; i < pa.length; i++) { - if (!(pa[i] instanceof ComponentName)) { - Log.w(TAG, "Filtered component #" + i + " not a ComponentName: " + pa[i]); - names = null; - break; - } - names[i] = (ComponentName) pa[i]; - } - if (shouldFilterNearby) { - names[names.length - 1] = nearbySharingComponent; - } - - mFilteredComponentNames = names; - } else if (shouldFilterNearby) { - mFilteredComponentNames = new ComponentName[1]; - mFilteredComponentNames[0] = nearbySharingComponent; - } - - pa = intent.getParcelableArrayExtra(Intent.EXTRA_CHOOSER_TARGETS); - if (pa != null) { - int count = Math.min(pa.length, MAX_EXTRA_CHOOSER_TARGETS); - ChooserTarget[] targets = new ChooserTarget[count]; - for (int i = 0; i < count; i++) { - if (!(pa[i] instanceof ChooserTarget)) { - Log.w(TAG, "Chooser target #" + i + " not a ChooserTarget: " + pa[i]); - targets = null; - break; - } - targets[i] = (ChooserTarget) pa[i]; - } - mCallerChooserTargets = targets; - } - mMaxTargetsPerRow = getResources().getInteger(R.integer.config_chooser_max_targets_per_row); mShouldDisplayLandscape = shouldDisplayLandscape(getResources().getConfiguration().orientation); - setRetainInOnStop(intent.getBooleanExtra(EXTRA_PRIVATE_RETAIN_IN_ON_STOP, false)); - super.onCreate(savedInstanceState, target, title, defaultTitleRes, initialIntents, - null, false); + setRetainInOnStop(mChooserRequest.shouldRetainInOnStop()); + + createProfileRecords( + new AppPredictorFactory( + getApplicationContext(), + mChooserRequest.getSharedText(), + mChooserRequest.getTargetIntentFilter()), + mChooserRequest.getTargetIntentFilter()); + + mPreviewCoordinator = new ChooserContentPreviewCoordinator( + mBackgroundThreadPoolExecutor, + this, + () -> mEnterTransitionAnimationDelegate.markImagePreviewReady(false)); + + super.onCreate( + savedInstanceState, + mChooserRequest.getTargetIntent(), + mChooserRequest.getTitle(), + mChooserRequest.getDefaultTitleResource(), + mChooserRequest.getInitialIntents(), + /* rList: List<ResolveInfo> = */ null, + /* supportsAlwaysUseOption = */ false); mChooserShownTime = System.currentTimeMillis(); final long systemCost = mChooserShownTime - intentReceivedTime; - - getMetricsLogger().write(new LogMaker(MetricsEvent.ACTION_ACTIVITY_CHOOSER_SHOWN) - .setSubtype(isWorkProfile() ? MetricsEvent.MANAGED_PROFILE : - MetricsEvent.PARENT_PROFILE) - .addTaggedData(MetricsEvent.FIELD_SHARESHEET_MIMETYPE, target.getType()) - .addTaggedData(MetricsEvent.FIELD_TIME_TO_APP_TARGETS, systemCost)); + getChooserActivityLogger().logChooserActivityShown( + isWorkProfile(), mChooserRequest.getTargetType(), systemCost); if (mResolverDrawerLayout != null) { mResolverDrawerLayout.addOnLayoutChangeListener(this::handleLayoutChange); // expand/shrink direct share 4 -> 8 viewgroup - if (isSendAction(target)) { + if (mChooserRequest.isSendActionTarget()) { mResolverDrawerLayout.setOnScrollChangeListener(this::handleScroll); } @@ -722,26 +343,16 @@ public class ChooserActivity extends ResolverActivity implements getChooserActivityLogger().logShareStarted( FrameworkStatsLog.SHARESHEET_STARTED, getReferrerPackageName(), - target.getType(), - mCallerChooserTargets == null ? 0 : mCallerChooserTargets.length, - initialIntents == null ? 0 : initialIntents.length, + mChooserRequest.getTargetType(), + mChooserRequest.getCallerChooserTargets().size(), + (mChooserRequest.getInitialIntents() == null) + ? 0 : mChooserRequest.getInitialIntents().length, isWorkProfile(), - findPreferredContentPreview(getTargetIntent(), getContentResolver()), - target.getAction() + ChooserContentPreviewUi.findPreferredContentPreview( + getTargetIntent(), getContentResolver(), this::isImageType), + mChooserRequest.getTargetAction() ); - mDirectShareShortcutInfoCache = new HashMap<>(); - setEnterSharedElementCallback(new SharedElementCallback() { - @Override - public void onMapSharedElements(List<String> names, Map<String, View> sharedElements) { - if (mRemoveSharedElements) { - names.remove(FIRST_IMAGE_PREVIEW_TRANSITION_NAME); - sharedElements.remove(FIRST_IMAGE_PREVIEW_TRANSITION_NAME); - } - super.onMapSharedElements(names, sharedElements); - mRemoveSharedElements = false; - } - }); mEnterTransitionAnimationDelegate.postponeTransition(); } @@ -750,52 +361,51 @@ public class ChooserActivity extends ResolverActivity implements return R.style.Theme_DeviceDefault_Chooser; } - private AppPredictor setupAppPredictorForUser(UserHandle userHandle, - AppPredictor.Callback appPredictorCallback) { - AppPredictor appPredictor = getAppPredictorForDirectShareIfEnabled(userHandle); - if (appPredictor == null) { - return null; + private void createProfileRecords( + AppPredictorFactory factory, IntentFilter targetIntentFilter) { + UserHandle mainUserHandle = getPersonalProfileUserHandle(); + createProfileRecord(mainUserHandle, targetIntentFilter, factory); + + UserHandle workUserHandle = getWorkProfileUserHandle(); + if (workUserHandle != null) { + createProfileRecord(workUserHandle, targetIntentFilter, factory); } - mDirectShareAppTargetCache = new HashMap<>(); - appPredictor.registerPredictionUpdates(this.getMainExecutor(), appPredictorCallback); - return appPredictor; } - private AppPredictor.Callback createAppPredictorCallback( - ChooserListAdapter chooserListAdapter) { - return resultList -> { - if (isFinishing() || isDestroyed()) { - return; - } - if (chooserListAdapter.getCount() == 0) { - return; - } - if (resultList.isEmpty() - && shouldQueryShortcutManager(chooserListAdapter.getUserHandle())) { - // APS may be disabled, so try querying targets ourselves. - queryDirectShareTargets(chooserListAdapter, true); - return; - } - final List<ShortcutManager.ShareShortcutInfo> shareShortcutInfos = - new ArrayList<>(); + private void createProfileRecord( + UserHandle userHandle, IntentFilter targetIntentFilter, AppPredictorFactory factory) { + AppPredictor appPredictor = factory.create(userHandle); + ShortcutLoader shortcutLoader = ActivityManager.isLowRamDeviceStatic() + ? null + : createShortcutLoader( + getApplicationContext(), + appPredictor, + userHandle, + targetIntentFilter, + shortcutsResult -> onShortcutsLoaded(userHandle, shortcutsResult)); + mProfileRecords.put( + userHandle.getIdentifier(), + new ProfileRecord(appPredictor, shortcutLoader)); + } - List<AppTarget> shortcutResults = new ArrayList<>(); - for (AppTarget appTarget : resultList) { - if (appTarget.getShortcutInfo() == null) { - continue; - } - shortcutResults.add(appTarget); - } - resultList = shortcutResults; - for (AppTarget appTarget : resultList) { - shareShortcutInfos.add(new ShortcutManager.ShareShortcutInfo( - appTarget.getShortcutInfo(), - new ComponentName( - appTarget.getPackageName(), appTarget.getClassName()))); - } - sendShareShortcutInfoList(shareShortcutInfos, chooserListAdapter, resultList, - chooserListAdapter.getUserHandle()); - }; + @Nullable + private ProfileRecord getProfileRecord(UserHandle userHandle) { + return mProfileRecords.get(userHandle.getIdentifier(), null); + } + + @VisibleForTesting + protected ShortcutLoader createShortcutLoader( + Context context, + AppPredictor appPredictor, + UserHandle userHandle, + IntentFilter targetIntentFilter, + Consumer<ShortcutLoader.Result> callback) { + return new ShortcutLoader( + context, + appPredictor, + userHandle, + targetIntentFilter, + callback); } static SharedPreferences getPinnedSharedPrefs(Context context) { @@ -829,6 +439,41 @@ public class ChooserActivity extends ResolverActivity implements return mChooserMultiProfilePagerAdapter; } + @Override + protected EmptyStateProvider createBlockerEmptyStateProvider() { + final boolean isSendAction = mChooserRequest.isSendActionTarget(); + + final EmptyState noWorkToPersonalEmptyState = + new DevicePolicyBlockerEmptyState( + /* context= */ this, + /* devicePolicyStringTitleId= */ RESOLVER_CROSS_PROFILE_BLOCKED_TITLE, + /* defaultTitleResource= */ R.string.resolver_cross_profile_blocked, + /* devicePolicyStringSubtitleId= */ + isSendAction ? RESOLVER_CANT_SHARE_WITH_PERSONAL : RESOLVER_CANT_ACCESS_PERSONAL, + /* defaultSubtitleResource= */ + isSendAction ? R.string.resolver_cant_share_with_personal_apps_explanation + : R.string.resolver_cant_access_personal_apps_explanation, + /* devicePolicyEventId= */ RESOLVER_EMPTY_STATE_NO_SHARING_TO_PERSONAL, + /* devicePolicyEventCategory= */ ResolverActivity.METRICS_CATEGORY_CHOOSER); + + final EmptyState noPersonalToWorkEmptyState = + new DevicePolicyBlockerEmptyState( + /* context= */ this, + /* devicePolicyStringTitleId= */ RESOLVER_CROSS_PROFILE_BLOCKED_TITLE, + /* defaultTitleResource= */ R.string.resolver_cross_profile_blocked, + /* devicePolicyStringSubtitleId= */ + isSendAction ? RESOLVER_CANT_SHARE_WITH_WORK : RESOLVER_CANT_ACCESS_WORK, + /* defaultSubtitleResource= */ + isSendAction ? R.string.resolver_cant_share_with_work_apps_explanation + : R.string.resolver_cant_access_work_apps_explanation, + /* devicePolicyEventId= */ RESOLVER_EMPTY_STATE_NO_SHARING_TO_WORK, + /* devicePolicyEventCategory= */ ResolverActivity.METRICS_CATEGORY_CHOOSER); + + return new NoCrossProfileEmptyStateProvider(getPersonalProfileUserHandle(), + noWorkToPersonalEmptyState, noPersonalToWorkEmptyState, + createCrossProfileIntentsChecker(), createMyUserIdProvider()); + } + private ChooserMultiProfilePagerAdapter createChooserMultiProfilePagerAdapterForOneProfile( Intent[] initialIntents, List<ResolveInfo> rList, @@ -843,9 +488,10 @@ public class ChooserActivity extends ResolverActivity implements return new ChooserMultiProfilePagerAdapter( /* context */ this, adapter, - getPersonalProfileUserHandle(), + createEmptyStateProvider(/* workProfileUserHandle= */ null), + mQuietModeManager, /* workProfileUserHandle= */ null, - isSendAction(getTargetIntent()), mMaxTargetsPerRow); + mMaxTargetsPerRow); } private ChooserMultiProfilePagerAdapter createChooserMultiProfilePagerAdapterForTwoProfiles( @@ -871,10 +517,11 @@ public class ChooserActivity extends ResolverActivity implements /* context */ this, personalAdapter, workAdapter, + createEmptyStateProvider(/* workProfileUserHandle= */ getWorkProfileUserHandle()), + mQuietModeManager, selectedProfile, - getPersonalProfileUserHandle(), getWorkProfileUserHandle(), - isSendAction(getTargetIntent()), mMaxTargetsPerRow); + mMaxTargetsPerRow); } private int findSelectedProfile() { @@ -891,19 +538,14 @@ public class ChooserActivity extends ResolverActivity implements if (shouldShowStickyContentPreview() || mChooserMultiProfilePagerAdapter .getCurrentRootAdapter().getSystemRowCount() != 0) { - logActionShareWithPreview(); + getChooserActivityLogger().logActionShareWithPreview( + ChooserContentPreviewUi.findPreferredContentPreview( + getTargetIntent(), getContentResolver(), this::isImageType)); } return postRebuildListInternal(rebuildCompleted); } /** - * Returns true if app prediction service is defined and the component exists on device. - */ - private boolean isAppPredictionServiceAvailable() { - return getPackageManager().getAppPredictionServicePackageName() != null; - } - - /** * Check if the profile currently used is a work profile. * @return true if it is work profile, false if it is parent profile (or no work profile is * set up) @@ -949,7 +591,7 @@ public class ChooserActivity extends ResolverActivity implements updateProfileViewButton(); } - private void onCopyButtonClicked(View v) { + private void onCopyButtonClicked() { Intent targetIntent = getTargetIntent(); if (targetIntent == null) { finish(); @@ -987,15 +629,7 @@ public class ChooserActivity extends ResolverActivity implements Context.CLIPBOARD_SERVICE); clipboardManager.setPrimaryClipAsPackage(clipData, getReferrerPackageName()); - // Log share completion via copy - LogMaker targetLogMaker = new LogMaker( - MetricsEvent.ACTION_ACTIVITY_CHOOSER_PICKED_SYSTEM_TARGET).setSubtype(1); - getMetricsLogger().write(targetLogMaker); - getChooserActivityLogger().logShareTargetSelected( - SELECTION_TYPE_COPY, - "", - -1, - false); + getChooserActivityLogger().logActionSelected(ChooserActivityLogger.SELECTION_TYPE_COPY); setResult(RESULT_OK); finish(); @@ -1068,10 +702,59 @@ public class ChooserActivity extends ResolverActivity implements } } - private ViewGroup createContentPreviewView(ViewGroup parent) { + /** + * Create a view that will be shown in the content preview area + * @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 = findPreferredContentPreview(targetIntent, getContentResolver()); - return displayContentPreview(previewType, targetIntent, getLayoutInflater(), parent); + 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, + getResources(), + getLayoutInflater(), + actionFactory, + R.layout.chooser_action_row, + parent, + previewCoordinator, + mEnterTransitionAnimationDelegate::markImagePreviewReady, + getContentResolver(), + this::isImageType); + + if (layout != null) { + adjustPreviewWidth(getResources().getConfiguration().orientation, layout); + } + if (previewType != ChooserContentPreviewUi.CONTENT_PREVIEW_IMAGE) { + mEnterTransitionAnimationDelegate.markImagePreviewReady(false); + } + + return layout; } @VisibleForTesting @@ -1108,6 +791,19 @@ public class ChooserActivity extends ResolverActivity implements 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) { @@ -1116,9 +812,15 @@ public class ChooserActivity extends ResolverActivity implements return null; } - final DisplayResolveInfo dri = new DisplayResolveInfo( - originalIntent, ri, getString(com.android.internal.R.string.screenshot_edit), "", resolveIntent, null); - dri.setDisplayIcon(getDrawable(com.android.internal.R.drawable.ic_screenshot_edit)); + 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; } @@ -1160,70 +862,55 @@ public class ChooserActivity extends ResolverActivity implements icon = ri.loadIcon(getPackageManager()); } - final DisplayResolveInfo dri = new DisplayResolveInfo( + final DisplayResolveInfo dri = DisplayResolveInfo.newDisplayResolveInfo( originalIntent, ri, name, "", resolveIntent, null); - dri.setDisplayIcon(icon); + dri.getDisplayIconHolder().setDisplayIcon(icon); return dri; } - private Button createActionButton(Drawable icon, CharSequence title, View.OnClickListener r) { - Button b = (Button) LayoutInflater.from(this).inflate(R.layout.chooser_action_button, null); - if (icon != null) { - final int size = getResources() - .getDimensionPixelSize(R.dimen.chooser_action_button_icon_size); - icon.setBounds(0, 0, size, size); - b.setCompoundDrawablesRelative(icon, null, null, null); - } - b.setText(title); - b.setOnClickListener(r); - return b; - } - - private Button createCopyButton() { - final Button b = createActionButton( + 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), - getString(com.android.internal.R.string.copy), this::onCopyButtonClicked); - b.setId(com.android.internal.R.id.chooser_copy_button); - return b; + this::onCopyButtonClicked); } - private @Nullable Button createNearbyButton(Intent originalIntent) { + @Nullable + private ActionRow.Action createNearbyAction(Intent originalIntent) { final TargetInfo ti = getNearbySharingTarget(originalIntent); - if (ti == null) return null; + if (ti == null) { + return null; + } - final Button b = createActionButton( - ti.getDisplayIcon(this), + return new ActionRow.Action( + com.android.internal.R.id.chooser_nearby_button, ti.getDisplayLabel(), - (View unused) -> { - // Log share completion via nearby - getChooserActivityLogger().logShareTargetSelected( - SELECTION_TYPE_NEARBY, - "", - -1, - false); + ti.getDisplayIconHolder().getDisplayIcon(), + () -> { + getChooserActivityLogger().logActionSelected( + ChooserActivityLogger.SELECTION_TYPE_NEARBY); // Action bar is user-independent, always start as primary safelyStartActivityAsUser(ti, getPersonalProfileUserHandle()); finish(); - } - ); - b.setId(com.android.internal.R.id.chooser_nearby_button); - return b; + }); } - private @Nullable Button createEditButton(Intent originalIntent) { + @Nullable + private ActionRow.Action createEditAction(Intent originalIntent) { final TargetInfo ti = getEditSharingTarget(originalIntent); - if (ti == null) return null; + if (ti == null) { + return null; + } - final Button b = createActionButton( - ti.getDisplayIcon(this), + return new ActionRow.Action( + com.android.internal.R.id.chooser_edit_button, ti.getDisplayLabel(), - (View unused) -> { + ti.getDisplayIconHolder().getDisplayIcon(), + () -> { // Log share completion via edit - getChooserActivityLogger().logShareTargetSelected( - SELECTION_TYPE_EDIT, - "", - -1, - false); + getChooserActivityLogger().logActionSelected( + ChooserActivityLogger.SELECTION_TYPE_EDIT); View firstImgView = getFirstVisibleImgPreviewView(); // Action bar is user-independent, always start as primary if (firstImgView == null) { @@ -1238,8 +925,6 @@ public class ChooserActivity extends ResolverActivity implements } } ); - b.setId(com.android.internal.R.id.chooser_edit_button); - return b; } @Nullable @@ -1248,165 +933,6 @@ public class ChooserActivity extends ResolverActivity implements return firstImage != null && firstImage.isVisibleToUser() ? firstImage : null; } - private void addActionButton(ViewGroup parent, Button b) { - if (b == null) return; - final ViewGroup.MarginLayoutParams lp = new ViewGroup.MarginLayoutParams( - LayoutParams.WRAP_CONTENT, - LayoutParams.WRAP_CONTENT - ); - final int gap = getResources().getDimensionPixelSize(R.dimen.resolver_icon_margin) / 2; - lp.setMarginsRelative(gap, 0, gap, 0); - parent.addView(b, lp); - } - - private ViewGroup displayContentPreview(@ContentPreviewType int previewType, - Intent targetIntent, LayoutInflater layoutInflater, ViewGroup parent) { - ViewGroup layout = null; - - switch (previewType) { - case CONTENT_PREVIEW_TEXT: - layout = displayTextContentPreview(targetIntent, layoutInflater, parent); - break; - case CONTENT_PREVIEW_IMAGE: - layout = displayImageContentPreview(targetIntent, layoutInflater, parent); - break; - case CONTENT_PREVIEW_FILE: - layout = displayFileContentPreview(targetIntent, layoutInflater, parent); - break; - default: - Log.e(TAG, "Unexpected content preview type: " + previewType); - } - - if (layout != null) { - adjustPreviewWidth(getResources().getConfiguration().orientation, layout); - } - if (previewType != CONTENT_PREVIEW_IMAGE) { - mEnterTransitionAnimationDelegate.markImagePreviewReady(); - } - - return layout; - } - - private ViewGroup displayTextContentPreview(Intent targetIntent, LayoutInflater layoutInflater, - ViewGroup parent) { - ViewGroup contentPreviewLayout = (ViewGroup) layoutInflater.inflate( - R.layout.chooser_grid_preview_text, parent, false); - - final ViewGroup actionRow = - (ViewGroup) contentPreviewLayout.findViewById(com.android.internal.R.id.chooser_action_row); - addActionButton(actionRow, createCopyButton()); - if (shouldNearbyShareBeIncludedAsActionButton()) { - addActionButton(actionRow, createNearbyButton(targetIntent)); - } - - 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 { - mPreviewCoord = new ContentPreviewCoordinator(contentPreviewLayout, false); - mPreviewCoord.loadUriIntoView(com.android.internal.R.id.content_preview_thumbnail, previewThumbnail, 0); - } - } - - return contentPreviewLayout; - } - - private ViewGroup displayImageContentPreview(Intent targetIntent, LayoutInflater layoutInflater, - ViewGroup parent) { - ViewGroup contentPreviewLayout = (ViewGroup) layoutInflater.inflate( - R.layout.chooser_grid_preview_image, parent, false); - ViewGroup imagePreview = contentPreviewLayout.findViewById(com.android.internal.R.id.content_preview_image_area); - - final ViewGroup actionRow = - (ViewGroup) contentPreviewLayout.findViewById(com.android.internal.R.id.chooser_action_row); - //TODO: addActionButton(actionRow, createCopyButton()); - if (shouldNearbyShareBeIncludedAsActionButton()) { - addActionButton(actionRow, createNearbyButton(targetIntent)); - } - addActionButton(actionRow, createEditButton(targetIntent)); - - mPreviewCoord = new ContentPreviewCoordinator(contentPreviewLayout, false); - - String action = targetIntent.getAction(); - if (Intent.ACTION_SEND.equals(action)) { - Uri uri = targetIntent.getParcelableExtra(Intent.EXTRA_STREAM); - imagePreview.findViewById(com.android.internal.R.id.content_preview_image_1_large) - .setTransitionName(ChooserActivity.FIRST_IMAGE_PREVIEW_TRANSITION_NAME); - mPreviewCoord.loadUriIntoView(com.android.internal.R.id.content_preview_image_1_large, uri, 0); - } else { - ContentResolver resolver = getContentResolver(); - - List<Uri> uris = targetIntent.getParcelableArrayListExtra(Intent.EXTRA_STREAM); - List<Uri> imageUris = new ArrayList<>(); - for (Uri uri : uris) { - if (isImageType(resolver.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); - return contentPreviewLayout; - } - - imagePreview.findViewById(com.android.internal.R.id.content_preview_image_1_large) - .setTransitionName(ChooserActivity.FIRST_IMAGE_PREVIEW_TRANSITION_NAME); - mPreviewCoord.loadUriIntoView(com.android.internal.R.id.content_preview_image_1_large, imageUris.get(0), 0); - - if (imageUris.size() == 2) { - mPreviewCoord.loadUriIntoView(com.android.internal.R.id.content_preview_image_2_large, - imageUris.get(1), 0); - } else if (imageUris.size() > 2) { - mPreviewCoord.loadUriIntoView(com.android.internal.R.id.content_preview_image_2_small, - imageUris.get(1), 0); - mPreviewCoord.loadUriIntoView(com.android.internal.R.id.content_preview_image_3_small, - imageUris.get(2), imageUris.size() - 3); - } - } - - return contentPreviewLayout; - } - - private static class FileInfo { - public final String name; - public final boolean hasThumbnail; - - FileInfo(String name, boolean hasThumbnail) { - this.name = name; - this.hasThumbnail = hasThumbnail; - } - } - /** * Wrapping the ContentResolver call to expose for easier mocking, * and to avoid mocking Android core classes. @@ -1416,175 +942,11 @@ public class ChooserActivity extends ResolverActivity implements return resolver.query(uri, null, null, null, null); } - private 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 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 ViewGroup displayFileContentPreview(Intent targetIntent, LayoutInflater layoutInflater, - ViewGroup parent) { - - ViewGroup contentPreviewLayout = (ViewGroup) layoutInflater.inflate( - R.layout.chooser_grid_preview_file, parent, false); - - final ViewGroup actionRow = - (ViewGroup) contentPreviewLayout.findViewById(com.android.internal.R.id.chooser_action_row); - //TODO(b/120417119): addActionButton(actionRow, createCopyButton()); - if (shouldNearbyShareBeIncludedAsActionButton()) { - addActionButton(actionRow, createNearbyButton(targetIntent)); - } - - String action = targetIntent.getAction(); - if (Intent.ACTION_SEND.equals(action)) { - Uri uri = targetIntent.getParcelableExtra(Intent.EXTRA_STREAM); - loadFileUriIntoView(uri, contentPreviewLayout); - } 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); - } else { - FileInfo fileInfo = extractFileInfo(uris.get(0), getContentResolver()); - 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( - getResources(), - 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 void loadFileUriIntoView(final Uri uri, final View parent) { - FileInfo fileInfo = extractFileInfo(uri, getContentResolver()); - - TextView fileNameView = parent.findViewById(com.android.internal.R.id.content_preview_filename); - fileNameView.setText(fileInfo.name); - - if (fileInfo.hasThumbnail) { - mPreviewCoord = new ContentPreviewCoordinator(parent, false); - mPreviewCoord.loadUriIntoView(com.android.internal.R.id.content_preview_file_thumbnail, uri, 0); - } 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); - } - } - @VisibleForTesting protected boolean isImageType(String mimeType) { return mimeType != null && mimeType.startsWith("image/"); } - @ContentPreviewType - private int findPreferredContentPreview(Uri uri, ContentResolver resolver) { - if (uri == null) { - return CONTENT_PREVIEW_TEXT; - } - - String mimeType = resolver.getType(uri); - return isImageType(mimeType) ? CONTENT_PREVIEW_IMAGE : CONTENT_PREVIEW_FILE; - } - - /** - * 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 - * the preferred type, in order of IMAGE, FILE, TEXT. - */ - @ContentPreviewType - private int findPreferredContentPreview(Intent targetIntent, ContentResolver resolver) { - String action = targetIntent.getAction(); - if (Intent.ACTION_SEND.equals(action)) { - Uri uri = targetIntent.getParcelableExtra(Intent.EXTRA_STREAM); - return findPreferredContentPreview(uri, resolver); - } 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 - if (findPreferredContentPreview(uri, resolver) == CONTENT_PREVIEW_FILE) { - return CONTENT_PREVIEW_FILE; - } - } - - return CONTENT_PREVIEW_IMAGE; - } - - return CONTENT_PREVIEW_TEXT; - } - private int getNumSheetExpansions() { return getPreferences(Context.MODE_PRIVATE).getInt(PREF_NUM_SHEET_EXPANSIONS, 0); } @@ -1614,23 +976,29 @@ public class ChooserActivity extends ResolverActivity implements mRefinementResultReceiver.destroy(); mRefinementResultReceiver = null; } - mChooserHandler.removeAllMessages(); - if (mPreviewCoord != null) mPreviewCoord.cancelLoads(); + mBackgroundThreadPoolExecutor.shutdownNow(); - mChooserMultiProfilePagerAdapter.getActiveListAdapter().destroyAppPredictor(); - if (mChooserMultiProfilePagerAdapter.getInactiveListAdapter() != null) { - mChooserMultiProfilePagerAdapter.getInactiveListAdapter().destroyAppPredictor(); + destroyProfileRecords(); + } + + private void destroyProfileRecords() { + for (int i = 0; i < mProfileRecords.size(); ++i) { + mProfileRecords.valueAt(i).destroy(); } - mPersonalAppPredictor = null; - mWorkAppPredictor = null; + mProfileRecords.clear(); } @Override // ResolverListCommunicator public Intent getReplacementIntent(ActivityInfo aInfo, Intent defIntent) { + if (mChooserRequest == null) { + return defIntent; + } + Intent result = defIntent; - if (mReplacementExtras != null) { - final Bundle replExtras = mReplacementExtras.getBundle(aInfo.packageName); + if (mChooserRequest.getReplacementExtras() != null) { + final Bundle replExtras = + mChooserRequest.getReplacementExtras().getBundle(aInfo.packageName); if (replExtras != null) { result = new Intent(defIntent); result.putExtras(replExtras); @@ -1651,12 +1019,13 @@ public class ChooserActivity extends ResolverActivity implements @Override public void onActivityStarted(TargetInfo cti) { - if (mChosenComponentSender != null) { + if (mChooserRequest.getChosenComponentSender() != null) { final ComponentName target = cti.getResolvedComponentName(); if (target != null) { final Intent fillIn = new Intent().putExtra(Intent.EXTRA_CHOSEN_COMPONENT, target); try { - mChosenComponentSender.sendIntent(this, Activity.RESULT_OK, fillIn, null, null); + mChooserRequest.getChosenComponentSender().sendIntent( + this, Activity.RESULT_OK, fillIn, null, null); } catch (IntentSender.SendIntentException e) { Slog.e(TAG, "Unable to launch supplied IntentSender to report " + "the chosen component: " + e); @@ -1667,12 +1036,13 @@ public class ChooserActivity extends ResolverActivity implements @Override public void addUseDifferentAppLabelIfNecessary(ResolverListAdapter adapter) { - if (mCallerChooserTargets != null && mCallerChooserTargets.length > 0) { + if (mChooserRequest.getCallerChooserTargets().size() > 0) { mChooserMultiProfilePagerAdapter.getActiveListAdapter().addServiceResults( /* origTarget */ null, - Lists.newArrayList(mCallerChooserTargets), + new ArrayList<>(mChooserRequest.getCallerChooserTargets()), TARGET_TYPE_DEFAULT, - /* directShareShortcutInfoCache */ null); + /* directShareShortcutInfoCache */ Collections.emptyMap(), + /* directShareAppTargetCache */ Collections.emptyMap()); } } @@ -1701,57 +1071,34 @@ public class ChooserActivity extends ResolverActivity implements private void showTargetDetails(TargetInfo targetInfo) { if (targetInfo == null) return; - ArrayList<DisplayResolveInfo> targetList; - ChooserTargetActionsDialogFragment fragment = new ChooserTargetActionsDialogFragment(); - Bundle bundle = new Bundle(); - - if (targetInfo instanceof SelectableTargetInfo) { - SelectableTargetInfo selectableTargetInfo = (SelectableTargetInfo) targetInfo; - if (selectableTargetInfo.getDisplayResolveInfo() == null - || selectableTargetInfo.getChooserTarget() == null) { - Log.e(TAG, "displayResolveInfo or chooserTarget in selectableTargetInfo are null"); - return; - } - targetList = new ArrayList<>(); - targetList.add(selectableTargetInfo.getDisplayResolveInfo()); - bundle.putString(ChooserTargetActionsDialogFragment.SHORTCUT_ID_KEY, - selectableTargetInfo.getChooserTarget().getIntentExtras().getString( - Intent.EXTRA_SHORTCUT_ID)); - bundle.putBoolean(ChooserTargetActionsDialogFragment.IS_SHORTCUT_PINNED_KEY, - selectableTargetInfo.isPinned()); - bundle.putParcelable(ChooserTargetActionsDialogFragment.INTENT_FILTER_KEY, - getTargetIntentFilter()); - if (selectableTargetInfo.getDisplayLabel() != null) { - bundle.putString(ChooserTargetActionsDialogFragment.SHORTCUT_TITLE_KEY, - selectableTargetInfo.getDisplayLabel().toString()); - } - } else if (targetInfo instanceof MultiDisplayResolveInfo) { - // For multiple targets, include info on all targets - MultiDisplayResolveInfo mti = (MultiDisplayResolveInfo) targetInfo; - targetList = mti.getTargets(); - } else { - targetList = new ArrayList<DisplayResolveInfo>(); - targetList.add((DisplayResolveInfo) targetInfo); + List<DisplayResolveInfo> targetList = targetInfo.getAllDisplayTargets(); + if (targetList.isEmpty()) { + Log.e(TAG, "No displayable data to show target details"); + return; } - bundle.putParcelable(ChooserTargetActionsDialogFragment.USER_HANDLE_KEY, - mChooserMultiProfilePagerAdapter.getCurrentUserHandle()); - bundle.putParcelableArrayList(ChooserTargetActionsDialogFragment.TARGET_INFOS_KEY, - targetList); - fragment.setArguments(bundle); - fragment.show(getFragmentManager(), TARGET_DETAILS_FRAGMENT_TAG); - } + // TODO: implement these type-conditioned behaviors polymorphically, and consider moving + // the logic into `ChooserTargetActionsDialogFragment.show()`. + boolean isShortcutPinned = targetInfo.isSelectableTargetInfo() && targetInfo.isPinned(); + IntentFilter intentFilter = targetInfo.isSelectableTargetInfo() + ? mChooserRequest.getTargetIntentFilter() : null; + String shortcutTitle = targetInfo.isSelectableTargetInfo() + ? targetInfo.getDisplayLabel().toString() : null; + String shortcutIdKey = targetInfo.getDirectShareShortcutId(); - private void modifyTargetIntent(Intent in) { - if (isSendAction(in)) { - in.addFlags(Intent.FLAG_ACTIVITY_NEW_DOCUMENT | - Intent.FLAG_ACTIVITY_MULTIPLE_TASK); - } + ChooserTargetActionsDialogFragment.show( + getSupportFragmentManager(), + targetList, + mChooserMultiProfilePagerAdapter.getCurrentUserHandle(), + shortcutIdKey, + shortcutTitle, + isShortcutPinned, + intentFilter); } @Override protected boolean onTargetSelected(TargetInfo target, boolean alwaysCheck) { - if (mRefinementIntentSender != null) { + if (mChooserRequest.getRefinementIntentSender() != null) { final Intent fillIn = new Intent(); final List<Intent> sourceIntents = target.getAllSourceIntents(); if (!sourceIntents.isEmpty()) { @@ -1770,7 +1117,8 @@ public class ChooserActivity extends ResolverActivity implements fillIn.putExtra(Intent.EXTRA_RESULT_RECEIVER, mRefinementResultReceiver); try { - mRefinementIntentSender.sendIntent(this, 0, fillIn, null, null); + mChooserRequest.getRefinementIntentSender().sendIntent( + this, 0, fillIn, null, null); return false; } catch (SendIntentException e) { Log.e(TAG, "Refinement IntentSender failed to send", e); @@ -1787,25 +1135,20 @@ public class ChooserActivity extends ResolverActivity implements mChooserMultiProfilePagerAdapter.getActiveListAdapter(); TargetInfo targetInfo = currentListAdapter .targetInfoForPosition(which, filtered); - if (targetInfo != null && targetInfo instanceof NotSelectableTargetInfo) { + if (targetInfo != null && targetInfo.isNotSelectableTargetInfo()) { return; } final long selectionCost = System.currentTimeMillis() - mChooserShownTime; - if (targetInfo instanceof MultiDisplayResolveInfo) { + if (targetInfo.isMultiDisplayResolveInfo()) { MultiDisplayResolveInfo mti = (MultiDisplayResolveInfo) targetInfo; if (!mti.hasSelected()) { - ChooserStackedAppDialogFragment f = new ChooserStackedAppDialogFragment(); - Bundle b = new Bundle(); - b.putParcelable(ChooserTargetActionsDialogFragment.USER_HANDLE_KEY, + ChooserStackedAppDialogFragment.show( + getSupportFragmentManager(), + mti, + which, mChooserMultiProfilePagerAdapter.getCurrentUserHandle()); - b.putObject(ChooserStackedAppDialogFragment.MULTI_DRI_KEY, - mti); - b.putInt(ChooserStackedAppDialogFragment.WHICH_KEY, which); - f.setArguments(b); - - f.show(getFragmentManager(), TARGET_DETAILS_FRAGMENT_TAG); return; } } @@ -1813,103 +1156,65 @@ public class ChooserActivity extends ResolverActivity implements super.startSelected(which, always, filtered); if (currentListAdapter.getCount() > 0) { - // Log the index of which type of target the user picked. - // Lower values mean the ranking was better. - int cat = 0; - int value = which; - int directTargetAlsoRanked = -1; - int numCallerProvided = 0; - HashedStringCache.HashResult directTargetHashed = null; switch (currentListAdapter.getPositionTargetType(which)) { case ChooserListAdapter.TARGET_SERVICE: - cat = MetricsEvent.ACTION_ACTIVITY_CHOOSER_PICKED_SERVICE_TARGET; - // Log the package name + target name to answer the question if most users - // share to mostly the same person or to a bunch of different people. - ChooserTarget target = currentListAdapter.getChooserTargetForValue(value); - directTargetHashed = HashedStringCache.getInstance().hashString( - this, - TAG, - target.getComponentName().getPackageName() - + target.getTitle().toString(), - mMaxHashSaltDays); - SelectableTargetInfo selectableTargetInfo = (SelectableTargetInfo) targetInfo; - directTargetAlsoRanked = getRankedPosition(selectableTargetInfo); - - if (mCallerChooserTargets != null) { - numCallerProvided = mCallerChooserTargets.length; - } getChooserActivityLogger().logShareTargetSelected( - SELECTION_TYPE_SERVICE, + ChooserActivityLogger.SELECTION_TYPE_SERVICE, targetInfo.getResolveInfo().activityInfo.processName, - value, - selectableTargetInfo.isPinned() + which, + /* directTargetAlsoRanked= */ getRankedPosition(targetInfo), + mChooserRequest.getCallerChooserTargets().size(), + targetInfo.getHashedTargetIdForMetrics(this), + targetInfo.isPinned(), + mIsSuccessfullySelected, + selectionCost ); - break; + return; case ChooserListAdapter.TARGET_CALLER: case ChooserListAdapter.TARGET_STANDARD: - cat = MetricsEvent.ACTION_ACTIVITY_CHOOSER_PICKED_APP_TARGET; - value -= currentListAdapter.getSurfacedTargetInfo().size(); - numCallerProvided = currentListAdapter.getCallerTargetCount(); getChooserActivityLogger().logShareTargetSelected( - SELECTION_TYPE_APP, + ChooserActivityLogger.SELECTION_TYPE_APP, targetInfo.getResolveInfo().activityInfo.processName, - value, - targetInfo.isPinned() + (which - currentListAdapter.getSurfacedTargetInfo().size()), + /* directTargetAlsoRanked= */ -1, + currentListAdapter.getCallerTargetCount(), + /* directTargetHashed= */ null, + targetInfo.isPinned(), + mIsSuccessfullySelected, + selectionCost ); - break; + return; case ChooserListAdapter.TARGET_STANDARD_AZ: - // A-Z targets are unranked standard targets; we use -1 to mark that they - // are from the alphabetical pool. - value = -1; - cat = MetricsEvent.ACTION_ACTIVITY_CHOOSER_PICKED_STANDARD_TARGET; + // A-Z targets are unranked standard targets; we use a value of -1 to mark that + // they are from the alphabetical pool. + // TODO: why do we log a different selection type if the -1 value already + // designates the same condition? getChooserActivityLogger().logShareTargetSelected( - SELECTION_TYPE_STANDARD, + ChooserActivityLogger.SELECTION_TYPE_STANDARD, targetInfo.getResolveInfo().activityInfo.processName, - value, - false + /* value= */ -1, + /* directTargetAlsoRanked= */ -1, + /* numCallerProvided= */ 0, + /* directTargetHashed= */ null, + /* isPinned= */ false, + mIsSuccessfullySelected, + selectionCost ); - break; - } - - if (cat != 0) { - LogMaker targetLogMaker = new LogMaker(cat).setSubtype(value); - if (directTargetHashed != null) { - targetLogMaker.addTaggedData( - MetricsEvent.FIELD_HASHED_TARGET_NAME, directTargetHashed.hashedString); - targetLogMaker.addTaggedData( - MetricsEvent.FIELD_HASHED_TARGET_SALT_GEN, - directTargetHashed.saltGeneration); - targetLogMaker.addTaggedData(MetricsEvent.FIELD_RANKED_POSITION, - directTargetAlsoRanked); - } - targetLogMaker.addTaggedData(MetricsEvent.FIELD_IS_CATEGORY_USED, - numCallerProvided); - getMetricsLogger().write(targetLogMaker); - } - - if (mIsSuccessfullySelected) { - if (DEBUG) { - Log.d(TAG, "User Selection Time Cost is " + selectionCost); - Log.d(TAG, "position of selected app/service/caller is " + - Integer.toString(value)); - } - MetricsLogger.histogram(null, "user_selection_cost_for_smart_sharing", - (int) selectionCost); - MetricsLogger.histogram(null, "app_position_for_smart_sharing", value); + return; } } } - private int getRankedPosition(SelectableTargetInfo targetInfo) { + private int getRankedPosition(TargetInfo targetInfo) { String targetPackageName = - targetInfo.getChooserTarget().getComponentName().getPackageName(); + targetInfo.getChooserTargetComponentName().getPackageName(); ChooserListAdapter currentListAdapter = mChooserMultiProfilePagerAdapter.getActiveListAdapter(); - int maxRankedResults = Math.min(currentListAdapter.mDisplayList.size(), - MAX_LOG_RANK_POSITION); + int maxRankedResults = Math.min( + currentListAdapter.getDisplayResolveInfoCount(), MAX_LOG_RANK_POSITION); for (int i = 0; i < maxRankedResults; i++) { - if (currentListAdapter.mDisplayList.get(i) + if (currentListAdapter.getDisplayResolveInfo(i) .getResolveInfo().activityInfo.packageName.equals(targetPackageName)) { return i; } @@ -1933,8 +1238,11 @@ public class ChooserActivity extends ResolverActivity implements } private IntentFilter getTargetIntentFilter() { + return getTargetIntentFilter(getTargetIntent()); + } + + private IntentFilter getTargetIntentFilter(final Intent intent) { try { - final Intent intent = getTargetIntent(); String dataString = intent.getDataString(); if (intent.getType() == null) { if (!TextUtils.isEmpty(dataString)) { @@ -1968,218 +1276,18 @@ public class ChooserActivity extends ResolverActivity implements } } - @VisibleForTesting - protected void queryDirectShareTargets( - ChooserListAdapter adapter, boolean skipAppPredictionService) { - mQueriedSharingShortcutsTimeMs = System.currentTimeMillis(); - UserHandle userHandle = adapter.getUserHandle(); - if (!skipAppPredictionService) { - AppPredictor appPredictor = getAppPredictorForDirectShareIfEnabled(userHandle); - if (appPredictor != null) { - appPredictor.requestPredictionUpdate(); - return; - } - } - // Default to just querying ShortcutManager if AppPredictor not present. - final IntentFilter filter = getTargetIntentFilter(); - if (filter == null) { + private void logDirectShareTargetReceived(UserHandle forUser) { + ProfileRecord profileRecord = getProfileRecord(forUser); + if (profileRecord == null) { return; } - - AsyncTask.execute(() -> { - Context selectedProfileContext = createContextAsUser(userHandle, 0 /* flags */); - ShortcutManager sm = (ShortcutManager) selectedProfileContext - .getSystemService(Context.SHORTCUT_SERVICE); - List<ShortcutManager.ShareShortcutInfo> resultList = sm.getShareTargets(filter); - sendShareShortcutInfoList(resultList, adapter, null, userHandle); - }); - } - - /** - * Returns {@code false} if {@code userHandle} is the work profile and it's either - * in quiet mode or not running. - */ - private boolean shouldQueryShortcutManager(UserHandle userHandle) { - if (!shouldShowTabs()) { - return true; - } - if (!getWorkProfileUserHandle().equals(userHandle)) { - return true; - } - if (!isUserRunning(userHandle)) { - return false; - } - if (!isUserUnlocked(userHandle)) { - return false; - } - if (isQuietModeEnabled(userHandle)) { - return false; - } - return true; - } - - private void sendShareShortcutInfoList( - List<ShortcutManager.ShareShortcutInfo> resultList, - ChooserListAdapter chooserListAdapter, - @Nullable List<AppTarget> appTargets, UserHandle userHandle) { - if (appTargets != null && appTargets.size() != resultList.size()) { - throw new RuntimeException("resultList and appTargets must have the same size." - + " resultList.size()=" + resultList.size() - + " appTargets.size()=" + appTargets.size()); - } - Context selectedProfileContext = createContextAsUser(userHandle, 0 /* flags */); - for (int i = resultList.size() - 1; i >= 0; i--) { - final String packageName = resultList.get(i).getTargetComponent().getPackageName(); - if (!isPackageEnabled(selectedProfileContext, packageName)) { - resultList.remove(i); - if (appTargets != null) { - appTargets.remove(i); - } - } - } - - // If |appTargets| is not null, results are from AppPredictionService and already sorted. - final int shortcutType = (appTargets == null ? TARGET_TYPE_SHORTCUTS_FROM_SHORTCUT_MANAGER : - TARGET_TYPE_SHORTCUTS_FROM_PREDICTION_SERVICE); - - // 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. - List<ServiceResultInfo> resultRecords = new ArrayList<>(); - for (int i = 0; i < chooserListAdapter.getDisplayResolveInfoCount(); i++) { - DisplayResolveInfo displayResolveInfo = chooserListAdapter.getDisplayResolveInfo(i); - List<ShortcutManager.ShareShortcutInfo> matchingShortcuts = - filterShortcutsByTargetComponentName( - resultList, displayResolveInfo.getResolvedComponentName()); - if (matchingShortcuts.isEmpty()) { - continue; - } - List<ChooserTarget> chooserTargets = convertToChooserTarget( - matchingShortcuts, resultList, appTargets, shortcutType); - - ServiceResultInfo resultRecord = new ServiceResultInfo( - displayResolveInfo, chooserTargets, userHandle); - resultRecords.add(resultRecord); - } - - sendShortcutManagerShareTargetResults( - shortcutType, resultRecords.toArray(new ServiceResultInfo[0])); - } - - private 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 void sendShortcutManagerShareTargetResults( - int shortcutType, ServiceResultInfo[] results) { - final Message msg = Message.obtain(); - msg.what = ChooserHandler.SHORTCUT_MANAGER_ALL_SHARE_TARGET_RESULTS; - msg.obj = results; - msg.arg1 = shortcutType; - mChooserHandler.sendMessage(msg); - } - - private boolean isPackageEnabled(Context context, String packageName) { - if (TextUtils.isEmpty(packageName)) { - return false; - } - ApplicationInfo appInfo; - try { - appInfo = context.getPackageManager().getApplicationInfo(packageName, 0); - } catch (NameNotFoundException e) { - return false; - } - - if (appInfo != null && appInfo.enabled - && (appInfo.flags & ApplicationInfo.FLAG_SUSPENDED) == 0) { - return true; - } - return false; - } - - /** - * Converts a list of ShareShortcutInfos to ChooserTargets. - * @param matchingShortcuts List of shortcuts, all from the same package, that match the current - * share intent filter. - * @param allShortcuts List of all the shortcuts from all the packages on the device that are - * returned for the current sharing action. - * @param allAppTargets List of AppTargets. Null if the results are not from prediction service. - * @param shortcutType One of the values TARGET_TYPE_SHORTCUTS_FROM_SHORTCUT_MANAGER or - * TARGET_TYPE_SHORTCUTS_FROM_PREDICTION_SERVICE - * @return A list of ChooserTargets sorted by score in descending order. - */ - @VisibleForTesting - @NonNull - public List<ChooserTarget> convertToChooserTarget( - @NonNull List<ShortcutManager.ShareShortcutInfo> matchingShortcuts, - @NonNull List<ShortcutManager.ShareShortcutInfo> allShortcuts, - @Nullable List<AppTarget> allAppTargets, @ShareTargetType int shortcutType) { - // A set of distinct scores for the matched shortcuts. We use index of a rank in the sorted - // list instead of the actual rank value when converting a rank to a score. - List<Integer> scoreList = new ArrayList<>(); - if (shortcutType == TARGET_TYPE_SHORTCUTS_FROM_SHORTCUT_MANAGER) { - for (int i = 0; i < matchingShortcuts.size(); i++) { - int shortcutRank = matchingShortcuts.get(i).getShortcutInfo().getRank(); - if (!scoreList.contains(shortcutRank)) { - scoreList.add(shortcutRank); - } - } - Collections.sort(scoreList); - } - - List<ChooserTarget> chooserTargetList = new ArrayList<>(matchingShortcuts.size()); - for (int i = 0; i < matchingShortcuts.size(); i++) { - ShortcutInfo shortcutInfo = matchingShortcuts.get(i).getShortcutInfo(); - int indexInAllShortcuts = allShortcuts.indexOf(matchingShortcuts.get(i)); - - float score; - if (shortcutType == TARGET_TYPE_SHORTCUTS_FROM_PREDICTION_SERVICE) { - // Incoming results are ordered. Create a score based on index in the original list. - score = Math.max(1.0f - (0.01f * indexInAllShortcuts), 0.0f); - } else { - // Create a score based on the rank of the shortcut. - int rankIndex = scoreList.indexOf(shortcutInfo.getRank()); - score = Math.max(1.0f - (0.01f * rankIndex), 0.0f); - } - - Bundle extras = new Bundle(); - extras.putString(Intent.EXTRA_SHORTCUT_ID, shortcutInfo.getId()); - - ChooserTarget chooserTarget = new ChooserTarget( - shortcutInfo.getLabel(), - null, // Icon will be loaded later if this target is selected to be shown. - score, matchingShortcuts.get(i).getTargetComponent().clone(), extras); - - chooserTargetList.add(chooserTarget); - if (mDirectShareAppTargetCache != null && allAppTargets != null) { - mDirectShareAppTargetCache.put(chooserTarget, - allAppTargets.get(indexInAllShortcuts)); - } - if (mDirectShareShortcutInfoCache != null) { - mDirectShareShortcutInfoCache.put(chooserTarget, shortcutInfo); - } - } - // Sort ChooserTargets by score in descending order - Comparator<ChooserTarget> byScore = - (ChooserTarget a, ChooserTarget b) -> -Float.compare(a.getScore(), b.getScore()); - Collections.sort(chooserTargetList, byScore); - return chooserTargetList; - } - - private void logDirectShareTargetReceived(int logCategory) { - final int apiLatency = (int) (System.currentTimeMillis() - mQueriedSharingShortcutsTimeMs); - getMetricsLogger().write(new LogMaker(logCategory).setSubtype(apiLatency)); + getChooserActivityLogger().logDirectShareTargetReceived( + MetricsEvent.ACTION_DIRECT_SHARE_TARGETS_LOADED_SHORTCUT_MANAGER, + (int) (SystemClock.elapsedRealtime() - profileRecord.loadingStartTime)); } void updateModelAndChooserCounts(TargetInfo info) { - if (info != null && info instanceof MultiDisplayResolveInfo) { + if (info != null && info.isMultiDisplayResolveInfo()) { info = ((MultiDisplayResolveInfo) info).getSelectedTarget(); } if (info != null) { @@ -2200,31 +1308,35 @@ public class ChooserActivity extends ResolverActivity implements Log.d(TAG, "Action to be updated is " + targetIntent.getAction()); } } else if (DEBUG) { - Log.d(TAG, "Can not log Chooser Counts of null ResovleInfo"); + Log.d(TAG, "Can not log Chooser Counts of null ResolveInfo"); } } mIsSuccessfullySelected = true; } private void sendImpressionToAppPredictor(TargetInfo targetInfo, ChooserListAdapter adapter) { - AppPredictor directShareAppPredictor = getAppPredictorForDirectShareIfEnabled( - mChooserMultiProfilePagerAdapter.getCurrentUserHandle()); - if (directShareAppPredictor == null) { + // Send DS target impression info to AppPredictor, only when user chooses app share. + if (targetInfo.isChooserTargetInfo()) { return; } - // Send DS target impression info to AppPredictor, only when user chooses app share. - if (targetInfo instanceof ChooserTargetInfo) { + + AppPredictor directShareAppPredictor = getAppPredictor( + mChooserMultiProfilePagerAdapter.getCurrentUserHandle()); + if (directShareAppPredictor == null) { return; } - List<ChooserTargetInfo> surfacedTargetInfo = adapter.getSurfacedTargetInfo(); + List<TargetInfo> surfacedTargetInfo = adapter.getSurfacedTargetInfo(); List<AppTargetId> targetIds = new ArrayList<>(); - for (ChooserTargetInfo chooserTargetInfo : surfacedTargetInfo) { - ChooserTarget chooserTarget = chooserTargetInfo.getChooserTarget(); - ComponentName componentName = chooserTarget.getComponentName(); - if (mDirectShareShortcutInfoCache.containsKey(chooserTarget)) { - String shortcutId = mDirectShareShortcutInfoCache.get(chooserTarget).getId(); + for (TargetInfo chooserTargetInfo : surfacedTargetInfo) { + ShortcutInfo shortcutInfo = chooserTargetInfo.getDirectShareShortcutInfo(); + if (shortcutInfo != null) { + ComponentName componentName = + chooserTargetInfo.getChooserTargetComponentName(); targetIds.add(new AppTargetId( - String.format("%s/%s/%s", shortcutId, componentName.flattenToString(), + String.format( + "%s/%s/%s", + shortcutInfo.getId(), + componentName.flattenToString(), SHORTCUT_TARGET))); } } @@ -2232,21 +1344,18 @@ public class ChooserActivity extends ResolverActivity implements } private void sendClickToAppPredictor(TargetInfo targetInfo) { - AppPredictor directShareAppPredictor = getAppPredictorForDirectShareIfEnabled( - mChooserMultiProfilePagerAdapter.getCurrentUserHandle()); - if (directShareAppPredictor == null) { + if (!targetInfo.isChooserTargetInfo()) { return; } - if (!(targetInfo instanceof ChooserTargetInfo)) { + + AppPredictor directShareAppPredictor = getAppPredictor( + mChooserMultiProfilePagerAdapter.getCurrentUserHandle()); + if (directShareAppPredictor == null) { return; } - ChooserTarget chooserTarget = ((ChooserTargetInfo) targetInfo).getChooserTarget(); - AppTarget appTarget = null; - if (mDirectShareAppTargetCache != null) { - appTarget = mDirectShareAppTargetCache.get(chooserTarget); - } - // This is a direct share click that was provided by the APS + AppTarget appTarget = targetInfo.getDirectShareAppTarget(); if (appTarget != null) { + // This is a direct share click that was provided by the APS directShareAppPredictor.notifyAppTargetEvent( new AppTargetEvent.Builder(appTarget, AppTargetEvent.ACTION_LAUNCH) .setLaunchLocation(LAUNCH_LOCATION_DIRECT_SHARE) @@ -2255,70 +1364,9 @@ public class ChooserActivity extends ResolverActivity implements } @Nullable - private AppPredictor createAppPredictor(UserHandle userHandle) { - if (!mIsAppPredictorComponentAvailable) { - return null; - } - - if (getPersonalProfileUserHandle().equals(userHandle)) { - if (mPersonalAppPredictor != null) { - return mPersonalAppPredictor; - } - } else { - if (mWorkAppPredictor != null) { - return mWorkAppPredictor; - } - } - - // TODO(b/148230574): Currently AppPredictor fetches only the same-profile app targets. - // Make AppPredictor work cross-profile. - Context contextAsUser = createContextAsUser(userHandle, 0 /* flags */); - final IntentFilter filter = getTargetIntentFilter(); - Bundle extras = new Bundle(); - extras.putParcelable(APP_PREDICTION_INTENT_FILTER_KEY, filter); - populateTextContent(extras); - AppPredictionContext appPredictionContext = new AppPredictionContext.Builder(contextAsUser) - .setUiSurface(APP_PREDICTION_SHARE_UI_SURFACE) - .setPredictedTargetCount(APP_PREDICTION_SHARE_TARGET_QUERY_PACKAGE_LIMIT) - .setExtras(extras) - .build(); - AppPredictionManager appPredictionManager = - contextAsUser - .getSystemService(AppPredictionManager.class); - AppPredictor appPredictionSession = appPredictionManager.createAppPredictionSession( - appPredictionContext); - if (getPersonalProfileUserHandle().equals(userHandle)) { - mPersonalAppPredictor = appPredictionSession; - } else { - mWorkAppPredictor = appPredictionSession; - } - return appPredictionSession; - } - - private void populateTextContent(Bundle extras) { - final Intent intent = getTargetIntent(); - String sharedText = intent.getStringExtra(Intent.EXTRA_TEXT); - extras.putString(SHARED_TEXT_KEY, sharedText); - } - - /** - * This will return an app predictor if it is enabled for direct share sorting - * and if one exists. Otherwise, it returns null. - * @param userHandle - */ - @Nullable - private AppPredictor getAppPredictorForDirectShareIfEnabled(UserHandle userHandle) { - return ChooserFlags.USE_PREDICTION_MANAGER_FOR_DIRECT_TARGETS - && !ActivityManager.isLowRamDeviceStatic() ? createAppPredictor(userHandle) : null; - } - - /** - * This will return an app predictor if it is enabled for share activity sorting - * and if one exists. Otherwise, it returns null. - */ - @Nullable - private AppPredictor getAppPredictorForShareActivitiesIfEnabled(UserHandle userHandle) { - return USE_PREDICTION_MANAGER_FOR_SHARE_ACTIVITIES ? createAppPredictor(userHandle) : null; + private AppPredictor getAppPredictor(UserHandle userHandle) { + ProfileRecord record = getProfileRecord(userHandle); + return (record == null) ? null : record.appPredictor; } void onRefinementResult(TargetInfo selectedTarget, Intent matchingIntent) { @@ -2377,16 +1425,9 @@ public class ChooserActivity extends ResolverActivity implements } } - protected MetricsLogger getMetricsLogger() { - if (mMetricsLogger == null) { - mMetricsLogger = new MetricsLogger(); - } - return mMetricsLogger; - } - protected ChooserActivityLogger getChooserActivityLogger() { if (mChooserActivityLogger == null) { - mChooserActivityLogger = new ChooserActivityLoggerImpl(); + mChooserActivityLogger = new ChooserActivityLogger(); } return mChooserActivityLogger; } @@ -2405,56 +1446,139 @@ public class ChooserActivity extends ResolverActivity implements @Override boolean isComponentFiltered(ComponentName name) { - if (mFilteredComponentNames == null) { - return false; - } - for (ComponentName filteredComponentName : mFilteredComponentNames) { - if (name.equals(filteredComponentName)) { - return true; - } - } - return false; + return mChooserRequest.getFilteredComponentNames().contains(name); } @Override public boolean isComponentPinned(ComponentName name) { return mPinnedSharedPrefs.getBoolean(name.flattenToString(), false); } - - @Override - public boolean isFixedAtTop(ComponentName name) { - return name != null && name.equals(getNearbySharingComponent()) - && shouldNearbyShareBeFirstInRankedRow(); - } } @VisibleForTesting - public ChooserGridAdapter createChooserGridAdapter(Context context, - List<Intent> payloadIntents, Intent[] initialIntents, List<ResolveInfo> rList, - boolean filterLastUsed, UserHandle userHandle) { - ChooserListAdapter chooserListAdapter = createChooserListAdapter(context, payloadIntents, - initialIntents, rList, filterLastUsed, - createListController(userHandle)); - AppPredictor.Callback appPredictorCallback = createAppPredictorCallback(chooserListAdapter); - AppPredictor appPredictor = setupAppPredictorForUser(userHandle, appPredictorCallback); - chooserListAdapter.setAppPredictor(appPredictor); - chooserListAdapter.setAppPredictorCallback(appPredictorCallback); - return new ChooserGridAdapter(chooserListAdapter); + public ChooserGridAdapter createChooserGridAdapter( + Context context, + List<Intent> payloadIntents, + Intent[] initialIntents, + List<ResolveInfo> rList, + boolean filterLastUsed, + UserHandle userHandle) { + ChooserListAdapter chooserListAdapter = createChooserListAdapter( + context, + payloadIntents, + initialIntents, + rList, + filterLastUsed, + createListController(userHandle), + userHandle, + getTargetIntent(), + mChooserRequest, + mMaxTargetsPerRow); + + return new ChooserGridAdapter( + context, + new ChooserGridAdapter.ChooserActivityDelegate() { + @Override + public boolean shouldShowTabs() { + return ChooserActivity.this.shouldShowTabs(); + } + + @Override + public View buildContentPreview(ViewGroup parent) { + return createContentPreviewView(parent, mPreviewCoordinator); + } + + @Override + public void onTargetSelected(int itemIndex) { + startSelected(itemIndex, false, true); + } + + @Override + public void onTargetLongPressed(int selectedPosition) { + final TargetInfo longPressedTargetInfo = + mChooserMultiProfilePagerAdapter + .getActiveListAdapter() + .targetInfoForPosition( + selectedPosition, /* filtered= */ true); + // ItemViewHolder contents should always be "display resolve info" + // targets, but check just to make sure. + if (longPressedTargetInfo.isDisplayResolveInfo()) { + showTargetDetails(longPressedTargetInfo); + } + } + + @Override + public void updateProfileViewButton(View newButtonFromProfileRow) { + mProfileView = newButtonFromProfileRow; + mProfileView.setOnClickListener(ChooserActivity.this::onProfileClick); + ChooserActivity.this.updateProfileViewButton(); + } + + @Override + public int getValidTargetCount() { + return mChooserMultiProfilePagerAdapter + .getActiveListAdapter() + .getSelectableServiceTargetCount(); + } + + @Override + public void updateDirectShareExpansion(DirectShareViewHolder directShareGroup) { + RecyclerView activeAdapterView = + mChooserMultiProfilePagerAdapter.getActiveAdapterView(); + if (mResolverDrawerLayout.isCollapsed()) { + directShareGroup.collapse(activeAdapterView); + } else { + directShareGroup.expand(activeAdapterView); + } + } + + @Override + public void handleScrollToExpandDirectShare( + DirectShareViewHolder directShareGroup, int y, int oldy) { + directShareGroup.handleScroll( + mChooserMultiProfilePagerAdapter.getActiveAdapterView(), + y, + oldy, + mMaxTargetsPerRow); + } + }, + chooserListAdapter, + shouldShowContentPreview(), + mMaxTargetsPerRow, + getNumSheetExpansions()); } @VisibleForTesting - public ChooserListAdapter createChooserListAdapter(Context context, - List<Intent> payloadIntents, Intent[] initialIntents, List<ResolveInfo> rList, - boolean filterLastUsed, ResolverListController resolverListController) { - return new ChooserListAdapter(context, payloadIntents, initialIntents, rList, - filterLastUsed, resolverListController, this, - this, context.getPackageManager(), - getChooserActivityLogger()); + public ChooserListAdapter createChooserListAdapter( + Context context, + List<Intent> payloadIntents, + Intent[] initialIntents, + List<ResolveInfo> rList, + boolean filterLastUsed, + ResolverListController resolverListController, + UserHandle userHandle, + Intent targetIntent, + ChooserRequestParameters chooserRequest, + int maxTargetsPerRow) { + return new ChooserListAdapter( + context, + payloadIntents, + initialIntents, + rList, + filterLastUsed, + resolverListController, + userHandle, + targetIntent, + this, + context.getPackageManager(), + getChooserActivityLogger(), + chooserRequest, + maxTargetsPerRow); } @VisibleForTesting protected ResolverListController createListController(UserHandle userHandle) { - AppPredictor appPredictor = getAppPredictorForShareActivitiesIfEnabled(userHandle); + AppPredictor appPredictor = getAppPredictor(userHandle); AbstractResolverComparator resolverComparator; if (appPredictor != null) { resolverComparator = new AppPredictionServiceResolverComparator(this, getTargetIntent(), @@ -2484,28 +1608,11 @@ public class ChooserActivity extends ResolverActivity implements try { return getContentResolver().loadThumbnail(uri, size, null); } catch (IOException | NullPointerException | SecurityException ex) { - logContentPreviewWarning(uri); + getChooserActivityLogger().logContentPreviewWarning(uri); } return null; } - static final class PlaceHolderTargetInfo extends NotSelectableTargetInfo { - public Drawable getDisplayIcon(Context context) { - AnimatedVectorDrawable avd = (AnimatedVectorDrawable) - context.getDrawable(R.drawable.chooser_direct_share_icon_placeholder); - avd.start(); // Start animation after generation - return avd; - } - } - - protected static final class EmptyTargetInfo extends NotSelectableTargetInfo { - public EmptyTargetInfo() {} - - public Drawable getDisplayIcon(Context context) { - return null; - } - } - private void handleScroll(View view, int x, int y, int oldx, int oldy) { if (mChooserMultiProfilePagerAdapter.getCurrentRootAdapter() != null) { mChooserMultiProfilePagerAdapter.getCurrentRootAdapter().handleScroll(view, y, oldy); @@ -2532,8 +1639,8 @@ public class ChooserActivity extends ResolverActivity implements } final int availableWidth = right - left - v.getPaddingLeft() - v.getPaddingRight(); - boolean isLayoutUpdated = gridAdapter.consumeLayoutRequest() - || gridAdapter.calculateChooserTargetWidth(availableWidth) + boolean isLayoutUpdated = + gridAdapter.calculateChooserTargetWidth(availableWidth) || recyclerView.getAdapter() == null || availableWidth != mCurrAvailableWidth; @@ -2639,7 +1746,7 @@ public class ChooserActivity extends ResolverActivity implements boolean isExpandable = getResources().getConfiguration().orientation == Configuration.ORIENTATION_PORTRAIT && !isInMultiWindowMode(); - if (directShareHeight != 0 && isSendAction(getTargetIntent()) + if (directShareHeight != 0 && shouldShowContentPreview() && isExpandable) { // make sure to leave room for direct share 4->8 expansion int requiredExpansionHeight = @@ -2688,15 +1795,7 @@ public class ChooserActivity extends ResolverActivity implements private ViewGroup getActiveEmptyStateView() { int currentPage = mChooserMultiProfilePagerAdapter.getCurrentPage(); - return mChooserMultiProfilePagerAdapter.getItem(currentPage).getEmptyStateView(); - } - - static class BaseChooserTargetComparator implements Comparator<ChooserTarget> { - @Override - public int compare(ChooserTarget lhs, ChooserTarget rhs) { - // Descending order - return (int) Math.signum(rhs.getScore() - lhs.getScore()); - } + return mChooserMultiProfilePagerAdapter.getEmptyStateView(currentPage); } @Override // ResolverListCommunicator @@ -2705,29 +1804,6 @@ public class ChooserActivity extends ResolverActivity implements super.onHandlePackagesChanged(listAdapter); } - @Override // SelectableTargetInfoCommunicator - public ActivityInfoPresentationGetter makePresentationGetter(ActivityInfo info) { - return mChooserMultiProfilePagerAdapter.getActiveListAdapter().makePresentationGetter(info); - } - - @Override // SelectableTargetInfoCommunicator - public Intent getReferrerFillInIntent() { - return mReferrerFillInIntent; - } - - @Override // ChooserListCommunicator - public int getMaxRankedTargets() { - return mMaxTargetsPerRow; - } - - @Override // ChooserListCommunicator - public void sendListViewUpdateMessage(UserHandle userHandle) { - Message msg = Message.obtain(); - msg.what = ChooserHandler.LIST_VIEW_UPDATE_MESSAGE; - msg.obj = userHandle; - mChooserHandler.sendMessageDelayed(msg, mListViewUpdateDelayMs); - } - @Override public void onListRebuilt(ResolverListAdapter listAdapter, boolean rebuildComplete) { setupScrollListener(); @@ -2742,8 +1818,7 @@ public class ChooserActivity extends ResolverActivity implements .setupListAdapter(mChooserMultiProfilePagerAdapter.getCurrentPage()); } - if (chooserListAdapter.mDisplayList == null - || chooserListAdapter.mDisplayList.isEmpty()) { + if (chooserListAdapter.getDisplayResolveInfoCount() == 0) { chooserListAdapter.notifyDataSetChanged(); } else { chooserListAdapter.updateAlphabeticalList(); @@ -2757,41 +1832,45 @@ public class ChooserActivity extends ResolverActivity implements } private void maybeQueryAdditionalPostProcessingTargets(ChooserListAdapter chooserListAdapter) { - // don't support direct share on low ram devices - if (ActivityManager.isLowRamDeviceStatic()) { + UserHandle userHandle = chooserListAdapter.getUserHandle(); + ProfileRecord record = getProfileRecord(userHandle); + if (record == null) { return; } - - // no need to query direct share for work profile when its locked or disabled - if (!shouldQueryShortcutManager(chooserListAdapter.getUserHandle())) { + if (record.shortcutLoader == null) { return; } + record.loadingStartTime = SystemClock.elapsedRealtime(); + record.shortcutLoader.queryShortcuts(chooserListAdapter.getDisplayResolveInfos()); + } - if (ChooserFlags.USE_PREDICTION_MANAGER_FOR_DIRECT_TARGETS) { - if (DEBUG) { - Log.d(TAG, "querying direct share targets from ShortcutManager"); + @MainThread + private void onShortcutsLoaded( + UserHandle userHandle, ShortcutLoader.Result shortcutsResult) { + if (DEBUG) { + Log.d(TAG, "onShortcutsLoaded for user: " + userHandle); + } + mDirectShareShortcutInfoCache.putAll(shortcutsResult.directShareShortcutInfoCache); + mDirectShareAppTargetCache.putAll(shortcutsResult.directShareAppTargetCache); + ChooserListAdapter adapter = + mChooserMultiProfilePagerAdapter.getListAdapterForUserHandle(userHandle); + if (adapter != null) { + for (ShortcutLoader.ShortcutResultInfo resultInfo : shortcutsResult.shortcutsByApp) { + adapter.addServiceResults( + resultInfo.appTarget, + resultInfo.shortcuts, + shortcutsResult.isFromAppPredictor + ? TARGET_TYPE_SHORTCUTS_FROM_PREDICTION_SERVICE + : TARGET_TYPE_SHORTCUTS_FROM_SHORTCUT_MANAGER, + mDirectShareShortcutInfoCache, + mDirectShareAppTargetCache); } - - queryDirectShareTargets(chooserListAdapter, false); + adapter.completeServiceTargetLoading(); } - } - - @VisibleForTesting - protected boolean isUserRunning(UserHandle userHandle) { - UserManager userManager = getSystemService(UserManager.class); - return userManager.isUserRunning(userHandle); - } - @VisibleForTesting - protected boolean isUserUnlocked(UserHandle userHandle) { - UserManager userManager = getSystemService(UserManager.class); - return userManager.isUserUnlocked(userHandle); - } - - @VisibleForTesting - protected boolean isQuietModeEnabled(UserHandle userHandle) { - UserManager userManager = getSystemService(UserManager.class); - return userManager.isQuietModeEnabled(userHandle); + logDirectShareTargetReceived(userHandle); + sendVoiceChoicesIfNeeded(); + getChooserActivityLogger().logSharesheetDirectLoadComplete(); } private void setupScrollListener() { @@ -2855,24 +1934,6 @@ public class ChooserActivity extends ResolverActivity implements }); } - @Override // ChooserListCommunicator - public boolean isSendAction(Intent targetIntent) { - if (targetIntent == null) { - return false; - } - - String action = targetIntent.getAction(); - if (action == null) { - return false; - } - - if (Intent.ACTION_SEND.equals(action) || Intent.ACTION_SEND_MULTIPLE.equals(action)) { - return true; - } - - return false; - } - /** * The sticky content preview is shown only when we have a tabbed view. It's shown above * the tabs so it is not part of the scrollable list. If we are not in tabbed view, @@ -2887,7 +1948,14 @@ public class ChooserActivity extends ResolverActivity implements return shouldShowTabs() && mMultiProfilePagerAdapter.getListAdapterForUserHandle( UserHandle.of(UserHandle.myUserId())).getCount() > 0 - && isSendAction(getTargetIntent()); + && shouldShowContentPreview(); + } + + /** + * @return true if we want to show the content preview area + */ + protected boolean shouldShowContentPreview() { + return (mChooserRequest != null) && mChooserRequest.isSendActionTarget(); } private void updateStickyContentPreview() { @@ -2898,7 +1966,8 @@ public class ChooserActivity extends ResolverActivity implements // then always preload it to avoid subsequent resizing of the share sheet. ViewGroup contentPreviewContainer = findViewById(com.android.internal.R.id.content_preview_container); if (contentPreviewContainer.getChildCount() == 0) { - ViewGroup contentPreviewView = createContentPreviewView(contentPreviewContainer); + ViewGroup contentPreviewView = + createContentPreviewView(contentPreviewContainer, mPreviewCoordinator); contentPreviewContainer.addView(contentPreviewView); } } @@ -2930,21 +1999,16 @@ public class ChooserActivity extends ResolverActivity implements contentPreviewContainer.setVisibility(View.GONE); } - private void logActionShareWithPreview() { - Intent targetIntent = getTargetIntent(); - int previewType = findPreferredContentPreview(targetIntent, getContentResolver()); - getMetricsLogger().write(new LogMaker(MetricsEvent.ACTION_SHARE_WITH_PREVIEW) - .setSubtype(previewType)); - } - private void startFinishAnimation() { View rootView = findRootView(); - rootView.startAnimation(new FinishAnimation(this, rootView)); + if (rootView != null) { + rootView.startAnimation(new FinishAnimation(this, rootView)); + } } private boolean maybeCancelFinishAnimation() { View rootView = findRootView(); - Animation animation = rootView.getAnimation(); + Animation animation = (rootView == null) ? null : rootView.getAnimation(); if (animation instanceof FinishAnimation) { boolean hasEnded = animation.hasEnded(); animation.cancel(); @@ -2961,69 +2025,6 @@ public class ChooserActivity extends ResolverActivity implements return mContentView; } - abstract static class ViewHolderBase extends RecyclerView.ViewHolder { - private int mViewType; - - ViewHolderBase(View itemView, int viewType) { - super(itemView); - this.mViewType = viewType; - } - - int getViewType() { - return mViewType; - } - } - - /** - * Used to bind types of individual item including - * {@link ChooserGridAdapter#VIEW_TYPE_NORMAL}, - * {@link ChooserGridAdapter#VIEW_TYPE_CONTENT_PREVIEW}, - * {@link ChooserGridAdapter#VIEW_TYPE_PROFILE}, - * and {@link ChooserGridAdapter#VIEW_TYPE_AZ_LABEL}. - */ - final class ItemViewHolder extends ViewHolderBase { - ResolverListAdapter.ViewHolder mWrappedViewHolder; - int mListPosition = ChooserListAdapter.NO_POSITION; - - ItemViewHolder(View itemView, boolean isClickable, int viewType) { - super(itemView, viewType); - mWrappedViewHolder = new ResolverListAdapter.ViewHolder(itemView); - if (isClickable) { - itemView.setOnClickListener(v -> startSelected(mListPosition, - false/* always */, true/* filterd */)); - - itemView.setOnLongClickListener(v -> { - final TargetInfo ti = mChooserMultiProfilePagerAdapter.getActiveListAdapter() - .targetInfoForPosition(mListPosition, /* filtered */ true); - - // This should always be the case for ItemViewHolder, check for validity - if (ti instanceof DisplayResolveInfo && shouldShowTargetDetails(ti)) { - showTargetDetails((DisplayResolveInfo) ti); - } - return true; - }); - } - } - } - - private boolean shouldShowTargetDetails(TargetInfo ti) { - ComponentName nearbyShare = getNearbySharingComponent(); - // Suppress target details for nearby share to hide pin/unpin action - boolean isNearbyShare = nearbyShare != null && nearbyShare.equals( - ti.getResolvedComponentName()) && shouldNearbyShareBeFirstInRankedRow(); - return ti instanceof SelectableTargetInfo - || (ti instanceof DisplayResolveInfo && !isNearbyShare); - } - - /** - * Add a footer to the list, to support scrolling behavior below the navbar. - */ - static final class FooterViewHolder extends ViewHolderBase { - FooterViewHolder(View itemView, int viewType) { - super(itemView, viewType); - } - } - /** * Intentionally override the {@link ResolverActivity} implementation as we only need that * implementation for the intent resolver case. @@ -3100,763 +2101,6 @@ public class ChooserActivity extends ResolverActivity implements } } - /** - * Adapter for all types of items and targets in ShareSheet. - * Note that ranked sections like Direct Share - while appearing grid-like - are handled on the - * row level by this adapter but not on the item level. Individual targets within the row are - * handled by {@link ChooserListAdapter} - */ - @VisibleForTesting - public final class ChooserGridAdapter extends RecyclerView.Adapter<RecyclerView.ViewHolder> { - private ChooserListAdapter mChooserListAdapter; - private final LayoutInflater mLayoutInflater; - - private DirectShareViewHolder mDirectShareViewHolder; - private int mChooserTargetWidth = 0; - private boolean mShowAzLabelIfPoss; - private boolean mLayoutRequested = false; - - private int mFooterHeight = 0; - - private static final int VIEW_TYPE_DIRECT_SHARE = 0; - private static final int VIEW_TYPE_NORMAL = 1; - private static final int VIEW_TYPE_CONTENT_PREVIEW = 2; - private static final int VIEW_TYPE_PROFILE = 3; - private static final int VIEW_TYPE_AZ_LABEL = 4; - private static final int VIEW_TYPE_CALLER_AND_RANK = 5; - private static final int VIEW_TYPE_FOOTER = 6; - - private static final int NUM_EXPANSIONS_TO_HIDE_AZ_LABEL = 20; - - ChooserGridAdapter(ChooserListAdapter wrappedAdapter) { - super(); - mChooserListAdapter = wrappedAdapter; - mLayoutInflater = LayoutInflater.from(ChooserActivity.this); - - mShowAzLabelIfPoss = getNumSheetExpansions() < NUM_EXPANSIONS_TO_HIDE_AZ_LABEL; - - wrappedAdapter.registerDataSetObserver(new DataSetObserver() { - @Override - public void onChanged() { - super.onChanged(); - notifyDataSetChanged(); - } - - @Override - public void onInvalidated() { - super.onInvalidated(); - notifyDataSetChanged(); - } - }); - } - - public void setFooterHeight(int height) { - mFooterHeight = height; - } - - /** - * Calculate the chooser target width to maximize space per item - * - * @param width The new row width to use for recalculation - * @return true if the view width has changed - */ - public boolean calculateChooserTargetWidth(int width) { - if (width == 0) { - return false; - } - - // Limit width to the maximum width of the chooser activity - int maxWidth = getResources().getDimensionPixelSize(R.dimen.chooser_width); - width = Math.min(maxWidth, width); - - int newWidth = width / mMaxTargetsPerRow; - if (newWidth != mChooserTargetWidth) { - mChooserTargetWidth = newWidth; - return true; - } - - return false; - } - - /** - * Hides the list item content preview. - * <p>Not to be confused with the sticky content preview which is above the - * personal and work tabs. - */ - public void hideContentPreview() { - mLayoutRequested = true; - notifyDataSetChanged(); - } - - public boolean consumeLayoutRequest() { - boolean oldValue = mLayoutRequested; - mLayoutRequested = false; - return oldValue; - } - - public int getRowCount() { - return (int) ( - getSystemRowCount() - + getProfileRowCount() - + getServiceTargetRowCount() - + getCallerAndRankedTargetRowCount() - + getAzLabelRowCount() - + Math.ceil( - (float) mChooserListAdapter.getAlphaTargetCount() - / mMaxTargetsPerRow) - ); - } - - /** - * Whether the "system" row of targets is displayed. - * This area includes the content preview (if present) and action row. - */ - public int getSystemRowCount() { - // For the tabbed case we show the sticky content preview above the tabs, - // please refer to shouldShowStickyContentPreview - if (shouldShowTabs()) { - return 0; - } - - if (!isSendAction(getTargetIntent())) { - return 0; - } - - if (mChooserListAdapter == null || mChooserListAdapter.getCount() == 0) { - return 0; - } - - return 1; - } - - public int getProfileRowCount() { - if (shouldShowTabs()) { - return 0; - } - return mChooserListAdapter.getOtherProfile() == null ? 0 : 1; - } - - public int getFooterRowCount() { - return 1; - } - - public int getCallerAndRankedTargetRowCount() { - return (int) Math.ceil( - ((float) mChooserListAdapter.getCallerTargetCount() - + mChooserListAdapter.getRankedTargetCount()) / mMaxTargetsPerRow); - } - - // There can be at most one row in the listview, that is internally - // a ViewGroup with 2 rows - public int getServiceTargetRowCount() { - if (isSendAction(getTargetIntent()) - && !ActivityManager.isLowRamDeviceStatic()) { - return 1; - } - return 0; - } - - public int getAzLabelRowCount() { - // Only show a label if the a-z list is showing - return (mShowAzLabelIfPoss && mChooserListAdapter.getAlphaTargetCount() > 0) ? 1 : 0; - } - - @Override - public int getItemCount() { - return (int) ( - getSystemRowCount() - + getProfileRowCount() - + getServiceTargetRowCount() - + getCallerAndRankedTargetRowCount() - + getAzLabelRowCount() - + mChooserListAdapter.getAlphaTargetCount() - + getFooterRowCount() - ); - } - - @Override - public RecyclerView.ViewHolder onCreateViewHolder(ViewGroup parent, int viewType) { - switch (viewType) { - case VIEW_TYPE_CONTENT_PREVIEW: - return new ItemViewHolder(createContentPreviewView(parent), false, viewType); - case VIEW_TYPE_PROFILE: - return new ItemViewHolder(createProfileView(parent), false, viewType); - case VIEW_TYPE_AZ_LABEL: - return new ItemViewHolder(createAzLabelView(parent), false, viewType); - case VIEW_TYPE_NORMAL: - return new ItemViewHolder( - mChooserListAdapter.createView(parent), true, viewType); - case VIEW_TYPE_DIRECT_SHARE: - case VIEW_TYPE_CALLER_AND_RANK: - return createItemGroupViewHolder(viewType, parent); - case VIEW_TYPE_FOOTER: - Space sp = new Space(parent.getContext()); - sp.setLayoutParams(new RecyclerView.LayoutParams( - LayoutParams.MATCH_PARENT, mFooterHeight)); - return new FooterViewHolder(sp, viewType); - default: - // Since we catch all possible viewTypes above, no chance this is being called. - return null; - } - } - - @Override - public void onBindViewHolder(RecyclerView.ViewHolder holder, int position) { - int viewType = ((ViewHolderBase) holder).getViewType(); - switch (viewType) { - case VIEW_TYPE_DIRECT_SHARE: - case VIEW_TYPE_CALLER_AND_RANK: - bindItemGroupViewHolder(position, (ItemGroupViewHolder) holder); - break; - case VIEW_TYPE_NORMAL: - bindItemViewHolder(position, (ItemViewHolder) holder); - break; - default: - } - } - - @Override - public int getItemViewType(int position) { - int count; - - int countSum = (count = getSystemRowCount()); - if (count > 0 && position < countSum) return VIEW_TYPE_CONTENT_PREVIEW; - - countSum += (count = getProfileRowCount()); - if (count > 0 && position < countSum) return VIEW_TYPE_PROFILE; - - countSum += (count = getServiceTargetRowCount()); - if (count > 0 && position < countSum) return VIEW_TYPE_DIRECT_SHARE; - - countSum += (count = getCallerAndRankedTargetRowCount()); - if (count > 0 && position < countSum) return VIEW_TYPE_CALLER_AND_RANK; - - countSum += (count = getAzLabelRowCount()); - if (count > 0 && position < countSum) return VIEW_TYPE_AZ_LABEL; - - if (position == getItemCount() - 1) return VIEW_TYPE_FOOTER; - - return VIEW_TYPE_NORMAL; - } - - public int getTargetType(int position) { - return mChooserListAdapter.getPositionTargetType(getListPosition(position)); - } - - private View createProfileView(ViewGroup parent) { - View profileRow = mLayoutInflater.inflate(R.layout.chooser_profile_row, parent, false); - mProfileView = profileRow.findViewById(com.android.internal.R.id.profile_button); - mProfileView.setOnClickListener(ChooserActivity.this::onProfileClick); - updateProfileViewButton(); - return profileRow; - } - - private View createAzLabelView(ViewGroup parent) { - return mLayoutInflater.inflate(R.layout.chooser_az_label_row, parent, false); - } - - private ItemGroupViewHolder loadViewsIntoGroup(ItemGroupViewHolder holder) { - final int spec = MeasureSpec.makeMeasureSpec(0, MeasureSpec.UNSPECIFIED); - final int exactSpec = MeasureSpec.makeMeasureSpec(mChooserTargetWidth, - MeasureSpec.EXACTLY); - int columnCount = holder.getColumnCount(); - - final boolean isDirectShare = holder instanceof DirectShareViewHolder; - - for (int i = 0; i < columnCount; i++) { - final View v = mChooserListAdapter.createView(holder.getRowByIndex(i)); - final int column = i; - v.setOnClickListener(new OnClickListener() { - @Override - public void onClick(View v) { - startSelected(holder.getItemIndex(column), false, true); - } - }); - - // Show menu for both direct share and app share targets after long click. - v.setOnLongClickListener(v1 -> { - TargetInfo ti = mChooserListAdapter.targetInfoForPosition( - holder.getItemIndex(column), true); - if (shouldShowTargetDetails(ti)) { - showTargetDetails(ti); - } - return true; - }); - - holder.addView(i, v); - - // Force Direct Share to be 2 lines and auto-wrap to second line via hoz scroll = - // false. TextView#setHorizontallyScrolling must be reset after #setLines. Must be - // done before measuring. - if (isDirectShare) { - final ViewHolder vh = (ViewHolder) v.getTag(); - vh.text.setLines(2); - vh.text.setHorizontallyScrolling(false); - vh.text2.setVisibility(View.GONE); - } - - // Force height to be a given so we don't have visual disruption during scaling. - v.measure(exactSpec, spec); - setViewBounds(v, v.getMeasuredWidth(), v.getMeasuredHeight()); - } - - final ViewGroup viewGroup = holder.getViewGroup(); - - // Pre-measure and fix height so we can scale later. - holder.measure(); - setViewBounds(viewGroup, LayoutParams.MATCH_PARENT, holder.getMeasuredRowHeight()); - - if (isDirectShare) { - DirectShareViewHolder dsvh = (DirectShareViewHolder) holder; - setViewBounds(dsvh.getRow(0), LayoutParams.MATCH_PARENT, dsvh.getMinRowHeight()); - setViewBounds(dsvh.getRow(1), LayoutParams.MATCH_PARENT, dsvh.getMinRowHeight()); - } - - viewGroup.setTag(holder); - return holder; - } - - private void setViewBounds(View view, int widthPx, int heightPx) { - LayoutParams lp = view.getLayoutParams(); - if (lp == null) { - lp = new LayoutParams(widthPx, heightPx); - view.setLayoutParams(lp); - } else { - lp.height = heightPx; - lp.width = widthPx; - } - } - - ItemGroupViewHolder createItemGroupViewHolder(int viewType, ViewGroup parent) { - if (viewType == VIEW_TYPE_DIRECT_SHARE) { - ViewGroup parentGroup = (ViewGroup) mLayoutInflater.inflate( - R.layout.chooser_row_direct_share, parent, false); - ViewGroup row1 = (ViewGroup) mLayoutInflater.inflate(R.layout.chooser_row, - parentGroup, false); - ViewGroup row2 = (ViewGroup) mLayoutInflater.inflate(R.layout.chooser_row, - parentGroup, false); - parentGroup.addView(row1); - parentGroup.addView(row2); - - mDirectShareViewHolder = new DirectShareViewHolder(parentGroup, - Lists.newArrayList(row1, row2), mMaxTargetsPerRow, viewType, - mChooserMultiProfilePagerAdapter::getActiveListAdapter); - loadViewsIntoGroup(mDirectShareViewHolder); - - return mDirectShareViewHolder; - } else { - ViewGroup row = (ViewGroup) mLayoutInflater.inflate(R.layout.chooser_row, parent, - false); - ItemGroupViewHolder holder = - new SingleRowViewHolder(row, mMaxTargetsPerRow, viewType); - loadViewsIntoGroup(holder); - - return holder; - } - } - - /** - * Need to merge CALLER + ranked STANDARD into a single row and prevent a separator from - * showing on top of the AZ list if the AZ label is visible. All other types are placed into - * their own row as determined by their target type, and dividers are added in the list to - * separate each type. - */ - int getRowType(int rowPosition) { - // Merge caller and ranked standard into a single row - int positionType = mChooserListAdapter.getPositionTargetType(rowPosition); - if (positionType == ChooserListAdapter.TARGET_CALLER) { - return ChooserListAdapter.TARGET_STANDARD; - } - - // If an the A-Z label is shown, prevent a separator from appearing by making the A-Z - // row type the same as the suggestion row type - if (getAzLabelRowCount() > 0 && positionType == ChooserListAdapter.TARGET_STANDARD_AZ) { - return ChooserListAdapter.TARGET_STANDARD; - } - - return positionType; - } - - void bindItemViewHolder(int position, ItemViewHolder holder) { - View v = holder.itemView; - int listPosition = getListPosition(position); - holder.mListPosition = listPosition; - mChooserListAdapter.bindView(listPosition, v); - } - - void bindItemGroupViewHolder(int position, ItemGroupViewHolder holder) { - final ViewGroup viewGroup = (ViewGroup) holder.itemView; - int start = getListPosition(position); - int startType = getRowType(start); - - int columnCount = holder.getColumnCount(); - int end = start + columnCount - 1; - while (getRowType(end) != startType && end >= start) { - end--; - } - - if (end == start && mChooserListAdapter.getItem(start) instanceof EmptyTargetInfo) { - final TextView textView = viewGroup.findViewById(com.android.internal.R.id.chooser_row_text_option); - - if (textView.getVisibility() != View.VISIBLE) { - textView.setAlpha(0.0f); - textView.setVisibility(View.VISIBLE); - textView.setText(R.string.chooser_no_direct_share_targets); - - ValueAnimator fadeAnim = ObjectAnimator.ofFloat(textView, "alpha", 0.0f, 1.0f); - fadeAnim.setInterpolator(new DecelerateInterpolator(1.0f)); - - float translationInPx = getResources().getDimensionPixelSize( - R.dimen.chooser_row_text_option_translate); - textView.setTranslationY(translationInPx); - ValueAnimator translateAnim = ObjectAnimator.ofFloat(textView, "translationY", - 0.0f); - translateAnim.setInterpolator(new DecelerateInterpolator(1.0f)); - - AnimatorSet animSet = new AnimatorSet(); - animSet.setDuration(NO_DIRECT_SHARE_ANIM_IN_MILLIS); - animSet.setStartDelay(NO_DIRECT_SHARE_ANIM_IN_MILLIS); - animSet.playTogether(fadeAnim, translateAnim); - animSet.start(); - } - } - - for (int i = 0; i < columnCount; i++) { - final View v = holder.getView(i); - - if (start + i <= end) { - holder.setViewVisibility(i, View.VISIBLE); - holder.setItemIndex(i, start + i); - mChooserListAdapter.bindView(holder.getItemIndex(i), v); - } else { - holder.setViewVisibility(i, View.INVISIBLE); - } - } - } - - int getListPosition(int position) { - position -= getSystemRowCount() + getProfileRowCount(); - - final int serviceCount = mChooserListAdapter.getServiceTargetCount(); - final int serviceRows = (int) Math.ceil((float) serviceCount / getMaxRankedTargets()); - if (position < serviceRows) { - return position * mMaxTargetsPerRow; - } - - position -= serviceRows; - - final int callerAndRankedCount = mChooserListAdapter.getCallerTargetCount() - + mChooserListAdapter.getRankedTargetCount(); - final int callerAndRankedRows = getCallerAndRankedTargetRowCount(); - if (position < callerAndRankedRows) { - return serviceCount + position * mMaxTargetsPerRow; - } - - position -= getAzLabelRowCount() + callerAndRankedRows; - - return callerAndRankedCount + serviceCount + position; - } - - public void handleScroll(View v, int y, int oldy) { - boolean canExpandDirectShare = canExpandDirectShare(); - if (mDirectShareViewHolder != null && canExpandDirectShare) { - mDirectShareViewHolder.handleScroll( - mChooserMultiProfilePagerAdapter.getActiveAdapterView(), y, oldy, - mMaxTargetsPerRow); - } - } - - /** - * Only expand direct share area if there is a minimum number of targets. - */ - private boolean canExpandDirectShare() { - // Do not enable until we have confirmed more apps are using sharing shortcuts - // Check git history for enablement logic - return false; - } - - public ChooserListAdapter getListAdapter() { - return mChooserListAdapter; - } - - boolean shouldCellSpan(int position) { - return getItemViewType(position) == VIEW_TYPE_NORMAL; - } - - void updateDirectShareExpansion() { - if (mDirectShareViewHolder == null || !canExpandDirectShare()) { - return; - } - RecyclerView activeAdapterView = - mChooserMultiProfilePagerAdapter.getActiveAdapterView(); - if (mResolverDrawerLayout.isCollapsed()) { - mDirectShareViewHolder.collapse(activeAdapterView); - } else { - mDirectShareViewHolder.expand(activeAdapterView); - } - } - } - - /** - * Used to bind types for group of items including: - * {@link ChooserGridAdapter#VIEW_TYPE_DIRECT_SHARE}, - * and {@link ChooserGridAdapter#VIEW_TYPE_CALLER_AND_RANK}. - */ - abstract static class ItemGroupViewHolder extends ViewHolderBase { - protected int mMeasuredRowHeight; - private int[] mItemIndices; - protected final View[] mCells; - private final int mColumnCount; - - ItemGroupViewHolder(int cellCount, View itemView, int viewType) { - super(itemView, viewType); - this.mCells = new View[cellCount]; - this.mItemIndices = new int[cellCount]; - this.mColumnCount = cellCount; - } - - abstract ViewGroup addView(int index, View v); - - abstract ViewGroup getViewGroup(); - - abstract ViewGroup getRowByIndex(int index); - - abstract ViewGroup getRow(int rowNumber); - - abstract void setViewVisibility(int i, int visibility); - - public int getColumnCount() { - return mColumnCount; - } - - public void measure() { - final int spec = MeasureSpec.makeMeasureSpec(0, MeasureSpec.UNSPECIFIED); - getViewGroup().measure(spec, spec); - mMeasuredRowHeight = getViewGroup().getMeasuredHeight(); - } - - public int getMeasuredRowHeight() { - return mMeasuredRowHeight; - } - - public void setItemIndex(int itemIndex, int listIndex) { - mItemIndices[itemIndex] = listIndex; - } - - public int getItemIndex(int itemIndex) { - return mItemIndices[itemIndex]; - } - - public View getView(int index) { - return mCells[index]; - } - } - - static class SingleRowViewHolder extends ItemGroupViewHolder { - private final ViewGroup mRow; - - SingleRowViewHolder(ViewGroup row, int cellCount, int viewType) { - super(cellCount, row, viewType); - - this.mRow = row; - } - - public ViewGroup getViewGroup() { - return mRow; - } - - public ViewGroup getRowByIndex(int index) { - return mRow; - } - - public ViewGroup getRow(int rowNumber) { - if (rowNumber == 0) return mRow; - return null; - } - - public ViewGroup addView(int index, View v) { - mRow.addView(v); - mCells[index] = v; - - return mRow; - } - - public void setViewVisibility(int i, int visibility) { - getView(i).setVisibility(visibility); - } - } - - static class DirectShareViewHolder extends ItemGroupViewHolder { - private final ViewGroup mParent; - private final List<ViewGroup> mRows; - private int mCellCountPerRow; - - private boolean mHideDirectShareExpansion = false; - private int mDirectShareMinHeight = 0; - private int mDirectShareCurrHeight = 0; - private int mDirectShareMaxHeight = 0; - - private final boolean[] mCellVisibility; - - private final Supplier<ChooserListAdapter> mListAdapterSupplier; - - DirectShareViewHolder(ViewGroup parent, List<ViewGroup> rows, int cellCountPerRow, - int viewType, Supplier<ChooserListAdapter> listAdapterSupplier) { - super(rows.size() * cellCountPerRow, parent, viewType); - - this.mParent = parent; - this.mRows = rows; - this.mCellCountPerRow = cellCountPerRow; - this.mCellVisibility = new boolean[rows.size() * cellCountPerRow]; - this.mListAdapterSupplier = listAdapterSupplier; - } - - public ViewGroup addView(int index, View v) { - ViewGroup row = getRowByIndex(index); - row.addView(v); - mCells[index] = v; - - return row; - } - - public ViewGroup getViewGroup() { - return mParent; - } - - public ViewGroup getRowByIndex(int index) { - return mRows.get(index / mCellCountPerRow); - } - - public ViewGroup getRow(int rowNumber) { - return mRows.get(rowNumber); - } - - public void measure() { - final int spec = MeasureSpec.makeMeasureSpec(0, MeasureSpec.UNSPECIFIED); - getRow(0).measure(spec, spec); - getRow(1).measure(spec, spec); - - mDirectShareMinHeight = getRow(0).getMeasuredHeight(); - mDirectShareCurrHeight = mDirectShareCurrHeight > 0 - ? mDirectShareCurrHeight : mDirectShareMinHeight; - mDirectShareMaxHeight = 2 * mDirectShareMinHeight; - } - - public int getMeasuredRowHeight() { - return mDirectShareCurrHeight; - } - - public int getMinRowHeight() { - return mDirectShareMinHeight; - } - - public void setViewVisibility(int i, int visibility) { - final View v = getView(i); - if (visibility == View.VISIBLE) { - mCellVisibility[i] = true; - v.setVisibility(visibility); - v.setAlpha(1.0f); - } else if (visibility == View.INVISIBLE && mCellVisibility[i]) { - mCellVisibility[i] = false; - - ValueAnimator fadeAnim = ObjectAnimator.ofFloat(v, "alpha", 1.0f, 0f); - fadeAnim.setDuration(NO_DIRECT_SHARE_ANIM_IN_MILLIS); - fadeAnim.setInterpolator(new AccelerateInterpolator(1.0f)); - fadeAnim.addListener(new AnimatorListenerAdapter() { - public void onAnimationEnd(Animator animation) { - v.setVisibility(View.INVISIBLE); - } - }); - fadeAnim.start(); - } - } - - public void handleScroll(RecyclerView view, int y, int oldy, int maxTargetsPerRow) { - // only exit early if fully collapsed, otherwise onListRebuilt() with shifting - // targets can lock us into an expanded mode - boolean notExpanded = mDirectShareCurrHeight == mDirectShareMinHeight; - if (notExpanded) { - if (mHideDirectShareExpansion) { - return; - } - - // only expand if we have more than maxTargetsPerRow, and delay that decision - // until they start to scroll - ChooserListAdapter adapter = mListAdapterSupplier.get(); - int validTargets = adapter.getSelectableServiceTargetCount(); - if (validTargets <= maxTargetsPerRow) { - mHideDirectShareExpansion = true; - return; - } - } - - int yDiff = (int) ((oldy - y) * DIRECT_SHARE_EXPANSION_RATE); - - int prevHeight = mDirectShareCurrHeight; - int newHeight = Math.min(prevHeight + yDiff, mDirectShareMaxHeight); - newHeight = Math.max(newHeight, mDirectShareMinHeight); - yDiff = newHeight - prevHeight; - - updateDirectShareRowHeight(view, yDiff, newHeight); - } - - void expand(RecyclerView view) { - updateDirectShareRowHeight(view, mDirectShareMaxHeight - mDirectShareCurrHeight, - mDirectShareMaxHeight); - } - - void collapse(RecyclerView view) { - updateDirectShareRowHeight(view, mDirectShareMinHeight - mDirectShareCurrHeight, - mDirectShareMinHeight); - } - - private void updateDirectShareRowHeight(RecyclerView view, int yDiff, int newHeight) { - if (view == null || view.getChildCount() == 0 || yDiff == 0) { - return; - } - - // locate the item to expand, and offset the rows below that one - boolean foundExpansion = false; - for (int i = 0; i < view.getChildCount(); i++) { - View child = view.getChildAt(i); - - if (foundExpansion) { - child.offsetTopAndBottom(yDiff); - } else { - if (child.getTag() != null && child.getTag() instanceof DirectShareViewHolder) { - int widthSpec = MeasureSpec.makeMeasureSpec(child.getWidth(), - MeasureSpec.EXACTLY); - int heightSpec = MeasureSpec.makeMeasureSpec(newHeight, - MeasureSpec.EXACTLY); - child.measure(widthSpec, heightSpec); - child.getLayoutParams().height = child.getMeasuredHeight(); - child.layout(child.getLeft(), child.getTop(), child.getRight(), - child.getTop() + child.getMeasuredHeight()); - - foundExpansion = true; - } - } - } - - if (foundExpansion) { - mDirectShareCurrHeight = newHeight; - } - } - } - - static class ServiceResultInfo { - public final DisplayResolveInfo originalTarget; - public final List<ChooserTarget> resultTargets; - public final UserHandle userHandle; - - public ServiceResultInfo(DisplayResolveInfo ot, List<ChooserTarget> rt, - UserHandle userHandle) { - originalTarget = ot; - resultTargets = rt; - this.userHandle = userHandle; - } - } - static class ChooserTargetRankingInfo { public final List<AppTarget> scores; public final UserHandle userHandle; @@ -3918,164 +2162,17 @@ public class ChooserActivity extends ResolverActivity implements } /** - * Used internally to round image corners while obeying view padding. - */ - public static class RoundedRectImageView extends ImageView { - private int mRadius = 0; - private Path mPath = new Path(); - private Paint mOverlayPaint = new Paint(0); - private Paint mRoundRectPaint = new Paint(0); - private Paint mTextPaint = new Paint(Paint.ANTI_ALIAS_FLAG); - private String mExtraImageCount = null; - - public RoundedRectImageView(Context context) { - super(context); - } - - public RoundedRectImageView(Context context, AttributeSet attrs) { - this(context, attrs, 0); - } - - public RoundedRectImageView(Context context, AttributeSet attrs, int defStyleAttr) { - this(context, attrs, defStyleAttr, 0); - } - - public RoundedRectImageView(Context context, AttributeSet attrs, int defStyleAttr, - int defStyleRes) { - super(context, attrs, defStyleAttr, defStyleRes); - mRadius = context.getResources().getDimensionPixelSize(R.dimen.chooser_corner_radius); - - mOverlayPaint.setColor(0x99000000); - mOverlayPaint.setStyle(Paint.Style.FILL); - - mRoundRectPaint.setColor(context.getResources().getColor(R.color.chooser_row_divider)); - mRoundRectPaint.setStyle(Paint.Style.STROKE); - mRoundRectPaint.setStrokeWidth(context.getResources() - .getDimensionPixelSize(R.dimen.chooser_preview_image_border)); - - mTextPaint.setColor(Color.WHITE); - mTextPaint.setTextSize(context.getResources() - .getDimensionPixelSize(R.dimen.chooser_preview_image_font_size)); - mTextPaint.setTextAlign(Paint.Align.CENTER); - } - - private void updatePath(int width, int height) { - mPath.reset(); - - int imageWidth = width - getPaddingRight() - getPaddingLeft(); - int imageHeight = height - getPaddingBottom() - getPaddingTop(); - mPath.addRoundRect(getPaddingLeft(), getPaddingTop(), imageWidth, imageHeight, mRadius, - mRadius, Path.Direction.CW); - } - - /** - * Sets the corner radius on all corners - * - * param radius 0 for no radius, > 0 for a visible corner radius - */ - public void setRadius(int radius) { - mRadius = radius; - updatePath(getWidth(), getHeight()); - } - - /** - * Display an overlay with extra image count on 3rd image - */ - public void setExtraImageCount(int count) { - if (count > 0) { - this.mExtraImageCount = "+" + count; - } else { - this.mExtraImageCount = null; - } - } - - @Override - protected void onSizeChanged(int width, int height, int oldWidth, int oldHeight) { - super.onSizeChanged(width, height, oldWidth, oldHeight); - updatePath(width, height); - } - - @Override - protected void onDraw(Canvas canvas) { - if (mRadius != 0) { - canvas.clipPath(mPath); - } - - super.onDraw(canvas); - - int x = getPaddingLeft(); - int y = getPaddingRight(); - int width = getWidth() - getPaddingRight() - getPaddingLeft(); - int height = getHeight() - getPaddingBottom() - getPaddingTop(); - if (mExtraImageCount != null) { - canvas.drawRect(x, y, width, height, mOverlayPaint); - - int xPos = canvas.getWidth() / 2; - int yPos = (int) ((canvas.getHeight() / 2.0f) - - ((mTextPaint.descent() + mTextPaint.ascent()) / 2.0f)); - - canvas.drawText(mExtraImageCount, xPos, yPos, mTextPaint); - } - - canvas.drawRoundRect(x, y, width, height, mRadius, mRadius, mRoundRectPaint); - } - } - - /** - * 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. - */ - private class EnterTransitionAnimationDelegate implements View.OnLayoutChangeListener { - private boolean mPreviewReady = false; - private boolean mOffsetCalculated = false; - - void postponeTransition() { - postponeEnterTransition(); - } - - void markImagePreviewReady() { - if (!mPreviewReady) { - mPreviewReady = true; - maybeStartListenForLayout(); - } - } - - void markOffsetCalculated() { - if (!mOffsetCalculated) { - mOffsetCalculated = true; - maybeStartListenForLayout(); - } - } - - private void maybeStartListenForLayout() { - if (mPreviewReady && mOffsetCalculated && mResolverDrawerLayout != null) { - if (mResolverDrawerLayout.isInLayout()) { - startPostponedEnterTransition(); - } else { - mResolverDrawerLayout.addOnLayoutChangeListener(this); - mResolverDrawerLayout.requestLayout(); - } - } - } - - @Override - public void onLayoutChange(View v, int left, int top, int right, int bottom, int oldLeft, - int oldTop, int oldRight, int oldBottom) { - v.removeOnLayoutChangeListener(this); - startPostponedEnterTransition(); - } - } - - /** * Used in combination with the scene transition when launching the image editor */ private static class FinishAnimation extends AlphaAnimation implements Animation.AnimationListener { + @Nullable private Activity mActivity; + @Nullable private View mRootView; private final float mFromAlpha; - FinishAnimation(Activity activity, View rootView) { + FinishAnimation(@NonNull Activity activity, @NonNull View rootView) { super(rootView.getAlpha(), 0.0f); mActivity = activity; mRootView = rootView; @@ -4099,7 +2196,9 @@ public class ChooserActivity extends ResolverActivity implements @Override public void cancel() { - mRootView.setAlpha(mFromAlpha); + if (mRootView != null) { + mRootView.setAlpha(mFromAlpha); + } cleanup(); super.cancel(); } @@ -4110,9 +2209,10 @@ public class ChooserActivity extends ResolverActivity implements @Override public void onAnimationEnd(Animation animation) { - if (mActivity != null) { - mActivity.finish(); - cleanup(); + Activity activity = mActivity; + cleanup(); + if (activity != null) { + activity.finish(); } } @@ -4128,14 +2228,34 @@ public class ChooserActivity extends ResolverActivity implements @Override protected void maybeLogProfileChange() { - getChooserActivityLogger().logShareheetProfileChanged(); + getChooserActivityLogger().logSharesheetProfileChanged(); } - private boolean shouldNearbyShareBeFirstInRankedRow() { - return ActivityManager.isLowRamDeviceStatic() && mIsNearbyShareFirstTargetInRankedApp; - } + private static class ProfileRecord { + /** The {@link AppPredictor} for this profile, if any. */ + @Nullable + public final AppPredictor appPredictor; + /** + * null if we should not load shortcuts. + */ + @Nullable + public final ShortcutLoader shortcutLoader; + public long loadingStartTime; - private boolean shouldNearbyShareBeIncludedAsActionButton() { - return !shouldNearbyShareBeFirstInRankedRow(); + private ProfileRecord( + @Nullable AppPredictor appPredictor, + @Nullable ShortcutLoader shortcutLoader) { + this.appPredictor = appPredictor; + this.shortcutLoader = shortcutLoader; + } + + public void destroy() { + if (shortcutLoader != null) { + shortcutLoader.destroy(); + } + if (appPredictor != null) { + appPredictor.destroy(); + } + } } } diff --git a/java/src/com/android/intentresolver/ChooserActivityLogger.java b/java/src/com/android/intentresolver/ChooserActivityLogger.java index 1daae01a..9109bf93 100644 --- a/java/src/com/android/intentresolver/ChooserActivityLogger.java +++ b/java/src/com/android/intentresolver/ChooserActivityLogger.java @@ -16,48 +16,228 @@ package com.android.intentresolver; +import android.annotation.Nullable; import android.content.Intent; +import android.metrics.LogMaker; +import android.net.Uri; import android.provider.MediaStore; +import android.util.HashedStringCache; +import android.util.Log; +import com.android.internal.annotations.VisibleForTesting; import com.android.internal.logging.InstanceId; +import com.android.internal.logging.InstanceIdSequence; +import com.android.internal.logging.MetricsLogger; import com.android.internal.logging.UiEvent; import com.android.internal.logging.UiEventLogger; +import com.android.internal.logging.UiEventLoggerImpl; +import com.android.internal.logging.nano.MetricsProto.MetricsEvent; import com.android.internal.util.FrameworkStatsLog; /** - * Interface for writing Sharesheet atoms to statsd log. + * Helper for writing Sharesheet atoms to statsd log. * @hide */ -public interface ChooserActivityLogger { +public class ChooserActivityLogger { + private static final String TAG = "ChooserActivity"; + private static final boolean DEBUG = true; + + public static final int SELECTION_TYPE_SERVICE = 1; + public static final int SELECTION_TYPE_APP = 2; + public static final int SELECTION_TYPE_STANDARD = 3; + public static final int SELECTION_TYPE_COPY = 4; + public static final int SELECTION_TYPE_NEARBY = 5; + public static final int SELECTION_TYPE_EDIT = 6; + + /** + * This shim is provided only for testing. In production, clients will only ever use a + * {@link DefaultFrameworkStatsLogger}. + */ + @VisibleForTesting + interface FrameworkStatsLogger { + /** Overload to use for logging {@code FrameworkStatsLog.SHARESHEET_STARTED}. */ + void write( + int frameworkEventId, + int appEventId, + String packageName, + int instanceId, + String mimeType, + int numAppProvidedDirectTargets, + int numAppProvidedAppTargets, + boolean isWorkProfile, + int previewType, + int intentType); + + /** Overload to use for logging {@code FrameworkStatsLog.RANKING_SELECTED}. */ + void write( + int frameworkEventId, + int appEventId, + String packageName, + int instanceId, + int positionPicked, + boolean isPinned); + } + + private static final int SHARESHEET_INSTANCE_ID_MAX = (1 << 13); + + // A small per-notification ID, used for statsd logging. + // TODO: consider precomputing and storing as final. + private static InstanceIdSequence sInstanceIdSequence; + private InstanceId mInstanceId; + + private final UiEventLogger mUiEventLogger; + private final FrameworkStatsLogger mFrameworkStatsLogger; + private final MetricsLogger mMetricsLogger; + + public ChooserActivityLogger() { + this(new UiEventLoggerImpl(), new DefaultFrameworkStatsLogger(), new MetricsLogger()); + } + + @VisibleForTesting + ChooserActivityLogger( + UiEventLogger uiEventLogger, + FrameworkStatsLogger frameworkLogger, + MetricsLogger metricsLogger) { + mUiEventLogger = uiEventLogger; + mFrameworkStatsLogger = frameworkLogger; + mMetricsLogger = metricsLogger; + } + + /** Records metrics for the start time of the {@link ChooserActivity}. */ + public void logChooserActivityShown( + boolean isWorkProfile, String targetMimeType, long systemCost) { + mMetricsLogger.write(new LogMaker(MetricsEvent.ACTION_ACTIVITY_CHOOSER_SHOWN) + .setSubtype( + isWorkProfile ? MetricsEvent.MANAGED_PROFILE : MetricsEvent.PARENT_PROFILE) + .addTaggedData(MetricsEvent.FIELD_SHARESHEET_MIMETYPE, targetMimeType) + .addTaggedData(MetricsEvent.FIELD_TIME_TO_APP_TARGETS, systemCost)); + } + /** Logs a UiEventReported event for the system sharesheet completing initial start-up. */ - void logShareStarted(int eventId, String packageName, String mimeType, int appProvidedDirect, - int appProvidedApp, boolean isWorkprofile, int previewType, String intent); + public void logShareStarted(int eventId, String packageName, String mimeType, + int appProvidedDirect, int appProvidedApp, boolean isWorkprofile, int previewType, + String intent) { + mFrameworkStatsLogger.write(FrameworkStatsLog.SHARESHEET_STARTED, + /* event_id = 1 */ SharesheetStartedEvent.SHARE_STARTED.getId(), + /* package_name = 2 */ packageName, + /* instance_id = 3 */ getInstanceId().getId(), + /* mime_type = 4 */ mimeType, + /* num_app_provided_direct_targets = 5 */ appProvidedDirect, + /* num_app_provided_app_targets = 6 */ appProvidedApp, + /* is_workprofile = 7 */ isWorkprofile, + /* previewType = 8 */ typeFromPreviewInt(previewType), + /* intentType = 9 */ typeFromIntentString(intent)); + } - /** Logs a UiEventReported event for the system sharesheet when the user selects a target. */ - void logShareTargetSelected(int targetType, String packageName, int positionPicked, - boolean isPinned); + /** + * Logs a UiEventReported event for the system sharesheet when the user selects a target. + * TODO: document parameters and/or consider breaking up by targetType so we don't have to + * support an overly-generic signature. + */ + public void logShareTargetSelected( + int targetType, + String packageName, + int positionPicked, + int directTargetAlsoRanked, + int numCallerProvided, + @Nullable HashedStringCache.HashResult directTargetHashed, + boolean isPinned, + boolean successfullySelected, + long selectionCost) { + mFrameworkStatsLogger.write(FrameworkStatsLog.RANKING_SELECTED, + /* event_id = 1 */ SharesheetTargetSelectedEvent.fromTargetType(targetType).getId(), + /* package_name = 2 */ packageName, + /* instance_id = 3 */ getInstanceId().getId(), + /* position_picked = 4 */ positionPicked, + /* is_pinned = 5 */ isPinned); + + int category = getTargetSelectionCategory(targetType); + if (category != 0) { + LogMaker targetLogMaker = new LogMaker(category).setSubtype(positionPicked); + if (directTargetHashed != null) { + targetLogMaker.addTaggedData( + MetricsEvent.FIELD_HASHED_TARGET_NAME, directTargetHashed.hashedString); + targetLogMaker.addTaggedData( + MetricsEvent.FIELD_HASHED_TARGET_SALT_GEN, + directTargetHashed.saltGeneration); + targetLogMaker.addTaggedData(MetricsEvent.FIELD_RANKED_POSITION, + directTargetAlsoRanked); + } + targetLogMaker.addTaggedData(MetricsEvent.FIELD_IS_CATEGORY_USED, numCallerProvided); + mMetricsLogger.write(targetLogMaker); + } + + if (successfullySelected) { + if (DEBUG) { + Log.d(TAG, "User Selection Time Cost is " + selectionCost); + Log.d(TAG, "position of selected app/service/caller is " + positionPicked); + } + MetricsLogger.histogram( + null, "user_selection_cost_for_smart_sharing", (int) selectionCost); + MetricsLogger.histogram(null, "app_position_for_smart_sharing", positionPicked); + } + } + + /** Log when direct share targets were received. */ + public void logDirectShareTargetReceived(int category, int latency) { + mMetricsLogger.write(new LogMaker(category).setSubtype(latency)); + } + + /** + * Log when we display a preview UI of the specified {@code previewType} as part of our + * Sharesheet session. + */ + public void logActionShareWithPreview(int previewType) { + mMetricsLogger.write( + new LogMaker(MetricsEvent.ACTION_SHARE_WITH_PREVIEW).setSubtype(previewType)); + } + + /** Log when the user selects an action button with the specified {@code targetType}. */ + public void logActionSelected(int targetType) { + if (targetType == SELECTION_TYPE_COPY) { + LogMaker targetLogMaker = new LogMaker( + MetricsEvent.ACTION_ACTIVITY_CHOOSER_PICKED_SYSTEM_TARGET).setSubtype(1); + mMetricsLogger.write(targetLogMaker); + } + mFrameworkStatsLogger.write(FrameworkStatsLog.RANKING_SELECTED, + /* event_id = 1 */ SharesheetTargetSelectedEvent.fromTargetType(targetType).getId(), + /* package_name = 2 */ "", + /* instance_id = 3 */ getInstanceId().getId(), + /* position_picked = 4 */ -1, + /* is_pinned = 5 */ false); + } + + /** Log a warning that we couldn't display the content preview from the supplied {@code uri}. */ + public 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"); + + } /** Logs a UiEventReported event for the system sharesheet being triggered by the user. */ - default void logSharesheetTriggered() { + public void logSharesheetTriggered() { log(SharesheetStandardEvent.SHARESHEET_TRIGGERED, getInstanceId()); } /** Logs a UiEventReported event for the system sharesheet completing loading app targets. */ - default void logSharesheetAppLoadComplete() { + public void logSharesheetAppLoadComplete() { log(SharesheetStandardEvent.SHARESHEET_APP_LOAD_COMPLETE, getInstanceId()); } /** * Logs a UiEventReported event for the system sharesheet completing loading service targets. */ - default void logSharesheetDirectLoadComplete() { + public void logSharesheetDirectLoadComplete() { log(SharesheetStandardEvent.SHARESHEET_DIRECT_LOAD_COMPLETE, getInstanceId()); } /** * Logs a UiEventReported event for the system sharesheet timing out loading service targets. */ - default void logSharesheetDirectLoadTimeout() { + public void logSharesheetDirectLoadTimeout() { log(SharesheetStandardEvent.SHARESHEET_DIRECT_LOAD_TIMEOUT, getInstanceId()); } @@ -65,12 +245,12 @@ public interface ChooserActivityLogger { * Logs a UiEventReported event for the system sharesheet switching * between work and main profile. */ - default void logShareheetProfileChanged() { + public void logSharesheetProfileChanged() { log(SharesheetStandardEvent.SHARESHEET_PROFILE_CHANGED, getInstanceId()); } /** Logs a UiEventReported event for the system sharesheet getting expanded or collapsed. */ - default void logSharesheetExpansionChanged(boolean isCollapsed) { + public void logSharesheetExpansionChanged(boolean isCollapsed) { log(isCollapsed ? SharesheetStandardEvent.SHARESHEET_COLLAPSED : SharesheetStandardEvent.SHARESHEET_EXPANDED, getInstanceId()); } @@ -78,14 +258,14 @@ public interface ChooserActivityLogger { /** * Logs a UiEventReported event for the system sharesheet app share ranking timing out. */ - default void logSharesheetAppShareRankingTimeout() { + public void logSharesheetAppShareRankingTimeout() { log(SharesheetStandardEvent.SHARESHEET_APP_SHARE_RANKING_TIMEOUT, getInstanceId()); } /** * Logs a UiEventReported event for the system sharesheet when direct share row is empty. */ - default void logSharesheetEmptyDirectShareRow() { + public void logSharesheetEmptyDirectShareRow() { log(SharesheetStandardEvent.SHARESHEET_EMPTY_DIRECT_SHARE_ROW, getInstanceId()); } @@ -94,13 +274,26 @@ public interface ChooserActivityLogger { * @param event * @param instanceId */ - void log(UiEventLogger.UiEventEnum event, InstanceId instanceId); + private void log(UiEventLogger.UiEventEnum event, InstanceId instanceId) { + mUiEventLogger.logWithInstanceId( + event, + 0, + null, + instanceId); + } /** - * - * @return + * @return A unique {@link InstanceId} to join across events recorded by this logger instance. */ - InstanceId getInstanceId(); + private InstanceId getInstanceId() { + if (mInstanceId == null) { + if (sInstanceIdSequence == null) { + sInstanceIdSequence = new InstanceIdSequence(SHARESHEET_INSTANCE_ID_MAX); + } + mInstanceId = sInstanceIdSequence.newInstanceId(); + } + return mInstanceId; + } /** * The UiEvent enums that this class can log. @@ -147,17 +340,17 @@ public interface ChooserActivityLogger { public static SharesheetTargetSelectedEvent fromTargetType(int targetType) { switch(targetType) { - case ChooserActivity.SELECTION_TYPE_SERVICE: + case SELECTION_TYPE_SERVICE: return SHARESHEET_SERVICE_TARGET_SELECTED; - case ChooserActivity.SELECTION_TYPE_APP: + case SELECTION_TYPE_APP: return SHARESHEET_APP_TARGET_SELECTED; - case ChooserActivity.SELECTION_TYPE_STANDARD: + case SELECTION_TYPE_STANDARD: return SHARESHEET_STANDARD_TARGET_SELECTED; - case ChooserActivity.SELECTION_TYPE_COPY: + case SELECTION_TYPE_COPY: return SHARESHEET_COPY_TARGET_SELECTED; - case ChooserActivity.SELECTION_TYPE_NEARBY: + case SELECTION_TYPE_NEARBY: return SHARESHEET_NEARBY_TARGET_SELECTED; - case ChooserActivity.SELECTION_TYPE_EDIT: + case SELECTION_TYPE_EDIT: return SHARESHEET_EDIT_TARGET_SELECTED; default: return INVALID; @@ -201,13 +394,13 @@ public interface ChooserActivityLogger { /** * Returns the enum used in sharesheet started atom to indicate what preview type was used. */ - default int typeFromPreviewInt(int previewType) { + private static int typeFromPreviewInt(int previewType) { switch(previewType) { - case ChooserActivity.CONTENT_PREVIEW_IMAGE: + case ChooserContentPreviewUi.CONTENT_PREVIEW_IMAGE: return FrameworkStatsLog.SHARESHEET_STARTED__PREVIEW_TYPE__CONTENT_PREVIEW_IMAGE; - case ChooserActivity.CONTENT_PREVIEW_FILE: + case ChooserContentPreviewUi.CONTENT_PREVIEW_FILE: return FrameworkStatsLog.SHARESHEET_STARTED__PREVIEW_TYPE__CONTENT_PREVIEW_FILE; - case ChooserActivity.CONTENT_PREVIEW_TEXT: + case ChooserContentPreviewUi.CONTENT_PREVIEW_TEXT: default: return FrameworkStatsLog .SHARESHEET_STARTED__PREVIEW_TYPE__CONTENT_PREVIEW_TYPE_UNKNOWN; @@ -218,7 +411,7 @@ public interface ChooserActivityLogger { * Returns the enum used in sharesheet started atom to indicate what intent triggers the * ChooserActivity. */ - default int typeFromIntentString(String intent) { + private static int typeFromIntentString(String intent) { if (intent == null) { return FrameworkStatsLog.SHARESHEET_STARTED__INTENT_TYPE__INTENT_DEFAULT; } @@ -243,4 +436,62 @@ public interface ChooserActivityLogger { return FrameworkStatsLog.SHARESHEET_STARTED__INTENT_TYPE__INTENT_DEFAULT; } } + + @VisibleForTesting + static int getTargetSelectionCategory(int targetType) { + switch (targetType) { + case SELECTION_TYPE_SERVICE: + return MetricsEvent.ACTION_ACTIVITY_CHOOSER_PICKED_SERVICE_TARGET; + case SELECTION_TYPE_APP: + return MetricsEvent.ACTION_ACTIVITY_CHOOSER_PICKED_APP_TARGET; + case SELECTION_TYPE_STANDARD: + return MetricsEvent.ACTION_ACTIVITY_CHOOSER_PICKED_STANDARD_TARGET; + default: + return 0; + } + } + + private static class DefaultFrameworkStatsLogger implements FrameworkStatsLogger { + @Override + public void write( + int frameworkEventId, + int appEventId, + String packageName, + int instanceId, + String mimeType, + int numAppProvidedDirectTargets, + int numAppProvidedAppTargets, + boolean isWorkProfile, + int previewType, + int intentType) { + FrameworkStatsLog.write( + frameworkEventId, + /* event_id = 1 */ appEventId, + /* package_name = 2 */ packageName, + /* instance_id = 3 */ instanceId, + /* mime_type = 4 */ mimeType, + /* num_app_provided_direct_targets */ numAppProvidedDirectTargets, + /* num_app_provided_app_targets */ numAppProvidedAppTargets, + /* is_workprofile */ isWorkProfile, + /* previewType = 8 */ previewType, + /* intentType = 9 */ intentType); + } + + @Override + public void write( + int frameworkEventId, + int appEventId, + String packageName, + int instanceId, + int positionPicked, + boolean isPinned) { + FrameworkStatsLog.write( + frameworkEventId, + /* event_id = 1 */ appEventId, + /* package_name = 2 */ packageName, + /* instance_id = 3 */ instanceId, + /* position_picked = 4 */ positionPicked, + /* is_pinned = 5 */ isPinned); + } + } } diff --git a/java/src/com/android/intentresolver/ChooserActivityLoggerImpl.java b/java/src/com/android/intentresolver/ChooserActivityLoggerImpl.java deleted file mode 100644 index 08a345bc..00000000 --- a/java/src/com/android/intentresolver/ChooserActivityLoggerImpl.java +++ /dev/null @@ -1,84 +0,0 @@ -/* - * Copyright (C) 2020 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 com.android.internal.logging.InstanceId; -import com.android.internal.logging.InstanceIdSequence; -import com.android.internal.logging.UiEventLogger; -import com.android.internal.logging.UiEventLoggerImpl; -import com.android.internal.util.FrameworkStatsLog; - -/** - * Standard implementation of ChooserActivityLogger interface. - * @hide - */ -public class ChooserActivityLoggerImpl implements ChooserActivityLogger { - private static final int SHARESHEET_INSTANCE_ID_MAX = (1 << 13); - - private UiEventLogger mUiEventLogger = new UiEventLoggerImpl(); - // A small per-notification ID, used for statsd logging. - private InstanceId mInstanceId; - private static InstanceIdSequence sInstanceIdSequence; - - @Override - public void logShareStarted(int eventId, String packageName, String mimeType, - int appProvidedDirect, int appProvidedApp, boolean isWorkprofile, int previewType, - String intent) { - FrameworkStatsLog.write(FrameworkStatsLog.SHARESHEET_STARTED, - /* event_id = 1 */ SharesheetStartedEvent.SHARE_STARTED.getId(), - /* package_name = 2 */ packageName, - /* instance_id = 3 */ getInstanceId().getId(), - /* mime_type = 4 */ mimeType, - /* num_app_provided_direct_targets = 5 */ appProvidedDirect, - /* num_app_provided_app_targets = 6 */ appProvidedApp, - /* is_workprofile = 7 */ isWorkprofile, - /* previewType = 8 */ typeFromPreviewInt(previewType), - /* intentType = 9 */ typeFromIntentString(intent)); - } - - @Override - public void logShareTargetSelected(int targetType, String packageName, int positionPicked, - boolean isPinned) { - FrameworkStatsLog.write(FrameworkStatsLog.RANKING_SELECTED, - /* event_id = 1 */ SharesheetTargetSelectedEvent.fromTargetType(targetType).getId(), - /* package_name = 2 */ packageName, - /* instance_id = 3 */ getInstanceId().getId(), - /* position_picked = 4 */ positionPicked, - /* is_pinned = 5 */ isPinned); - } - - @Override - public void log(UiEventLogger.UiEventEnum event, InstanceId instanceId) { - mUiEventLogger.logWithInstanceId( - event, - 0, - null, - instanceId); - } - - @Override - public InstanceId getInstanceId() { - if (mInstanceId == null) { - if (sInstanceIdSequence == null) { - sInstanceIdSequence = new InstanceIdSequence(SHARESHEET_INSTANCE_ID_MAX); - } - mInstanceId = sInstanceIdSequence.newInstanceId(); - } - return mInstanceId; - } - -} diff --git a/java/src/com/android/intentresolver/ChooserContentPreviewCoordinator.java b/java/src/com/android/intentresolver/ChooserContentPreviewCoordinator.java new file mode 100644 index 00000000..0b8dbe35 --- /dev/null +++ b/java/src/com/android/intentresolver/ChooserContentPreviewCoordinator.java @@ -0,0 +1,132 @@ +/* + * 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 new file mode 100644 index 00000000..ff88e5e1 --- /dev/null +++ b/java/src/com/android/intentresolver/ChooserContentPreviewUi.java @@ -0,0 +1,566 @@ +/* + * 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/ChooserGridLayoutManager.java b/java/src/com/android/intentresolver/ChooserGridLayoutManager.java index 7c4b0c1f..5f373525 100644 --- a/java/src/com/android/intentresolver/ChooserGridLayoutManager.java +++ b/java/src/com/android/intentresolver/ChooserGridLayoutManager.java @@ -19,8 +19,8 @@ package com.android.intentresolver; import android.content.Context; import android.util.AttributeSet; -import com.android.internal.widget.GridLayoutManager; -import com.android.internal.widget.RecyclerView; +import androidx.recyclerview.widget.GridLayoutManager; +import androidx.recyclerview.widget.RecyclerView; /** * For a11y and per {@link RecyclerView#onInitializeAccessibilityNodeInfo}, override diff --git a/java/src/com/android/intentresolver/ChooserListAdapter.java b/java/src/com/android/intentresolver/ChooserListAdapter.java index 6d0c8337..699190f9 100644 --- a/java/src/com/android/intentresolver/ChooserListAdapter.java +++ b/java/src/com/android/intentresolver/ChooserListAdapter.java @@ -19,17 +19,22 @@ package com.android.intentresolver; import static com.android.intentresolver.ChooserActivity.TARGET_TYPE_SHORTCUTS_FROM_PREDICTION_SERVICE; import static com.android.intentresolver.ChooserActivity.TARGET_TYPE_SHORTCUTS_FROM_SHORTCUT_MANAGER; +import android.annotation.Nullable; 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.Intent; import android.content.pm.ActivityInfo; import android.content.pm.LabeledIntent; +import android.content.pm.LauncherApps; import android.content.pm.PackageManager; import android.content.pm.ResolveInfo; import android.content.pm.ShortcutInfo; +import android.graphics.Bitmap; +import android.graphics.drawable.BitmapDrawable; import android.graphics.drawable.Drawable; +import android.graphics.drawable.Icon; import android.os.AsyncTask; import android.os.Trace; import android.os.UserHandle; @@ -42,27 +47,27 @@ import android.view.View; import android.view.ViewGroup; import android.widget.TextView; +import androidx.annotation.WorkerThread; + import com.android.intentresolver.ResolverActivity.ResolvedComponentInfo; -import com.android.intentresolver.chooser.ChooserTargetInfo; import com.android.intentresolver.chooser.DisplayResolveInfo; import com.android.intentresolver.chooser.MultiDisplayResolveInfo; +import com.android.intentresolver.chooser.NotSelectableTargetInfo; import com.android.intentresolver.chooser.SelectableTargetInfo; import com.android.intentresolver.chooser.TargetInfo; import com.android.internal.annotations.VisibleForTesting; import com.android.internal.config.sysui.SystemUiDeviceConfigFlags; import java.util.ArrayList; -import java.util.Collections; import java.util.HashMap; import java.util.List; import java.util.Map; +import java.util.stream.Collectors; public class ChooserListAdapter extends ResolverListAdapter { private static final String TAG = "ChooserListAdapter"; private static final boolean DEBUG = false; - private boolean mEnableStackedApps = true; - public static final int NO_POSITION = -1; public static final int TARGET_BAD = -1; public static final int TARGET_CALLER = 0; @@ -71,40 +76,28 @@ public class ChooserListAdapter extends ResolverListAdapter { public static final int TARGET_STANDARD_AZ = 3; private static final int MAX_SUGGESTED_APP_TARGETS = 4; - private static final int MAX_CHOOSER_TARGETS_PER_APP = 2; /** {@link #getBaseScore} */ public static final float CALLER_TARGET_SCORE_BOOST = 900.f; /** {@link #getBaseScore} */ public static final float SHORTCUT_TARGET_SCORE_BOOST = 90.f; - private static final float PINNED_SHORTCUT_TARGET_SCORE_BOOST = 1000.f; - private final int mMaxShortcutTargetsPerApp; - private final ChooserListCommunicator mChooserListCommunicator; - private final SelectableTargetInfo.SelectableTargetInfoCommunicator - mSelectableTargetInfoCommunicator; + private final ChooserRequestParameters mChooserRequest; + private final int mMaxRankedTargets; + private final ChooserActivityLogger mChooserActivityLogger; - private int mNumShortcutResults = 0; private final Map<TargetInfo, AsyncTask> mIconLoaders = new HashMap<>(); - private boolean mApplySharingAppLimits; // Reserve spots for incoming direct share targets by adding placeholders - private ChooserTargetInfo - mPlaceHolderTargetInfo = new ChooserActivity.PlaceHolderTargetInfo(); - private final List<ChooserTargetInfo> mServiceTargets = new ArrayList<>(); + private final TargetInfo mPlaceHolderTargetInfo; + private final List<TargetInfo> mServiceTargets = new ArrayList<>(); private final List<DisplayResolveInfo> mCallerTargets = new ArrayList<>(); - private final ChooserActivity.BaseChooserTargetComparator mBaseTargetComparator = - new ChooserActivity.BaseChooserTargetComparator(); - private boolean mListViewDataChanged = false; + private final ShortcutSelectionLogic mShortcutSelectionLogic; // Sorted list of DisplayResolveInfos for the alphabetical app section. private List<DisplayResolveInfo> mSortedList = new ArrayList<>(); - private AppPredictor mAppPredictor; - private AppPredictor.Callback mAppPredictorCallback; - - private LoadDirectShareIconTaskProvider mTestLoadDirectShareTaskProvider; // For pinned direct share labels, if the text spans multiple lines, the TextView will consume // the full width, even if the characters actually take up less than that. Measure the actual @@ -137,24 +130,47 @@ public class ChooserListAdapter extends ResolverListAdapter { } }; - public ChooserListAdapter(Context context, List<Intent> payloadIntents, - Intent[] initialIntents, List<ResolveInfo> rList, - boolean filterLastUsed, ResolverListController resolverListController, - ChooserListCommunicator chooserListCommunicator, - SelectableTargetInfo.SelectableTargetInfoCommunicator selectableTargetInfoCommunicator, + public ChooserListAdapter( + Context context, + List<Intent> payloadIntents, + Intent[] initialIntents, + List<ResolveInfo> rList, + boolean filterLastUsed, + ResolverListController resolverListController, + UserHandle userHandle, + Intent targetIntent, + ResolverListCommunicator resolverListCommunicator, PackageManager packageManager, - ChooserActivityLogger chooserActivityLogger) { + ChooserActivityLogger chooserActivityLogger, + ChooserRequestParameters chooserRequest, + int maxRankedTargets) { // Don't send the initial intents through the shared ResolverActivity path, // we want to separate them into a different section. - super(context, payloadIntents, null, rList, filterLastUsed, - resolverListController, chooserListCommunicator, false); - - mMaxShortcutTargetsPerApp = - context.getResources().getInteger(R.integer.config_maxShortcutTargetsPerApp); - mChooserListCommunicator = chooserListCommunicator; + super( + context, + payloadIntents, + null, + rList, + filterLastUsed, + resolverListController, + userHandle, + targetIntent, + resolverListCommunicator, + false); + + mChooserRequest = chooserRequest; + mMaxRankedTargets = maxRankedTargets; + + mPlaceHolderTargetInfo = NotSelectableTargetInfo.newPlaceHolderTargetInfo(context); createPlaceHolders(); - mSelectableTargetInfoCommunicator = selectableTargetInfoCommunicator; mChooserActivityLogger = chooserActivityLogger; + mShortcutSelectionLogic = new ShortcutSelectionLogic( + context.getResources().getInteger(R.integer.config_maxShortcutTargetsPerApp), + DeviceConfig.getBoolean( + DeviceConfig.NAMESPACE_SYSTEMUI, + SystemUiDeviceConfigFlags.APPLY_SHARING_APP_LIMITS_IN_SYSUI, + true) + ); if (initialIntents != null) { for (int i = 0; i < initialIntents.length; i++) { @@ -172,7 +188,9 @@ public class ChooserListAdapter extends ResolverListAdapter { final ComponentName cn = ii.getComponent(); if (cn != null) { try { - ai = packageManager.getActivityInfo(ii.getComponent(), 0); + ai = packageManager.getActivityInfo( + ii.getComponent(), + PackageManager.ComponentInfoFlags.of(PackageManager.GET_META_DATA)); ri = new ResolveInfo(); ri.activityInfo = ai; } catch (PackageManager.NameNotFoundException ignored) { @@ -182,7 +200,9 @@ public class ChooserListAdapter extends ResolverListAdapter { if (ai == null) { // Because of AIDL bug, resolveActivity can't accept subclasses of Intent. final Intent rii = (ii.getClass() == Intent.class) ? ii : new Intent(ii); - ri = packageManager.resolveActivity(rii, PackageManager.MATCH_DEFAULT_ONLY); + ri = packageManager.resolveActivity( + rii, + PackageManager.ResolveInfoFlags.of(PackageManager.MATCH_DEFAULT_ONLY)); ai = ri != null ? ri.activityInfo : null; } if (ai == null) { @@ -203,18 +223,12 @@ public class ChooserListAdapter extends ResolverListAdapter { ri.noResourceId = true; ri.icon = 0; } - mCallerTargets.add(new DisplayResolveInfo(ii, ri, ii, makePresentationGetter(ri))); + DisplayResolveInfo displayResolveInfo = DisplayResolveInfo.newDisplayResolveInfo( + ii, ri, ii, mPresentationFactory.makePresentationGetter(ri)); + mCallerTargets.add(displayResolveInfo); if (mCallerTargets.size() == MAX_SUGGESTED_APP_TARGETS) break; } } - mApplySharingAppLimits = DeviceConfig.getBoolean( - DeviceConfig.NAMESPACE_SYSTEMUI, - SystemUiDeviceConfigFlags.APPLY_SHARING_APP_LIMITS_IN_SYSUI, - true); - } - - AppPredictor getAppPredictor() { - return mAppPredictor; } @Override @@ -223,73 +237,54 @@ public class ChooserListAdapter extends ResolverListAdapter { Log.d(TAG, "clearing queryTargets on package change"); } createPlaceHolders(); - mChooserListCommunicator.onHandlePackagesChanged(this); + mResolverListCommunicator.onHandlePackagesChanged(this); } - @Override - public void notifyDataSetChanged() { - if (!mListViewDataChanged) { - mChooserListCommunicator.sendListViewUpdateMessage(getUserHandle()); - mListViewDataChanged = true; - } - } - - void refreshListView() { - if (mListViewDataChanged) { - super.notifyDataSetChanged(); - } - mListViewDataChanged = false; - } - private void createPlaceHolders() { - mNumShortcutResults = 0; mServiceTargets.clear(); - for (int i = 0; i < mChooserListCommunicator.getMaxRankedTargets(); i++) { + for (int i = 0; i < mMaxRankedTargets; ++i) { mServiceTargets.add(mPlaceHolderTargetInfo); } } @Override View onCreateView(ViewGroup parent) { - return mInflater.inflate( - R.layout.resolve_grid_item, parent, false); + return mInflater.inflate(R.layout.resolve_grid_item, parent, false); } + @VisibleForTesting @Override - protected void onBindView(View view, TargetInfo info, int position) { + public void onBindView(View view, TargetInfo info, int position) { final ViewHolder holder = (ViewHolder) view.getTag(); if (info == null) { - holder.icon.setImageDrawable( - mContext.getDrawable(R.drawable.resolver_icon_placeholder)); + holder.icon.setImageDrawable(loadIconPlaceholder()); return; } - if (info instanceof DisplayResolveInfo) { + holder.bindLabel(info.getDisplayLabel(), info.getExtendedInfo(), alwaysShowSubLabel()); + holder.bindIcon(info); + if (info.isSelectableTargetInfo()) { + // direct share targets should append the application name for a better readout + DisplayResolveInfo rInfo = info.getDisplayResolveInfo(); + CharSequence appName = rInfo != null ? rInfo.getDisplayLabel() : ""; + CharSequence extendedInfo = info.getExtendedInfo(); + String contentDescription = String.join(" ", info.getDisplayLabel(), + extendedInfo != null ? extendedInfo : "", appName); + holder.updateContentDescription(contentDescription); + if (!info.hasDisplayIcon()) { + loadDirectShareIcon((SelectableTargetInfo) info); + } + } else if (info.isDisplayResolveInfo()) { DisplayResolveInfo dri = (DisplayResolveInfo) info; - holder.bindLabel(dri.getDisplayLabel(), dri.getExtendedInfo(), alwaysShowSubLabel()); - startDisplayResolveInfoIconLoading(holder, dri); - } else { - holder.bindLabel(info.getDisplayLabel(), info.getExtendedInfo(), alwaysShowSubLabel()); - - if (info instanceof SelectableTargetInfo) { - SelectableTargetInfo selectableInfo = (SelectableTargetInfo) info; - // direct share targets should append the application name for a better readout - DisplayResolveInfo rInfo = selectableInfo.getDisplayResolveInfo(); - CharSequence appName = rInfo != null ? rInfo.getDisplayLabel() : ""; - CharSequence extendedInfo = selectableInfo.getExtendedInfo(); - String contentDescription = String.join(" ", selectableInfo.getDisplayLabel(), - extendedInfo != null ? extendedInfo : "", appName); - holder.updateContentDescription(contentDescription); - startSelectableTargetInfoIconLoading(holder, selectableInfo); - } else { - holder.bindIcon(info); + if (!dri.hasDisplayIcon()) { + loadIcon(dri); } } // If target is loading, show a special placeholder shape in the label, make unclickable - if (info instanceof ChooserActivity.PlaceHolderTargetInfo) { + if (info.isPlaceHolderTargetInfo()) { final int maxWidth = mContext.getResources().getDimensionPixelSize( R.dimen.chooser_direct_share_label_placeholder_max_width); holder.text.setMaxWidth(maxWidth); @@ -306,7 +301,7 @@ public class ChooserListAdapter extends ResolverListAdapter { // Always remove the spacing listener, attach as needed to direct share targets below. holder.text.removeOnLayoutChangeListener(mPinTextSpacingListener); - if (info instanceof MultiDisplayResolveInfo) { + if (info.isMultiDisplayResolveInfo()) { // If the target is grouped show an indicator Drawable bkg = mContext.getDrawable(R.drawable.chooser_group_background); holder.text.setPaddingRelative(0, 0, bkg.getIntrinsicWidth() /* end */, 0); @@ -325,64 +320,47 @@ public class ChooserListAdapter extends ResolverListAdapter { } } - private void startDisplayResolveInfoIconLoading(ViewHolder holder, DisplayResolveInfo info) { - LoadIconTask task = (LoadIconTask) mIconLoaders.get(info); - if (task == null) { - task = new LoadIconTask(info, holder); - mIconLoaders.put(info, task); - task.execute(); - } else { - // The holder was potentially changed as the underlying items were - // reshuffled, so reset the target holder - task.setViewHolder(holder); - } - } - - private void startSelectableTargetInfoIconLoading( - ViewHolder holder, SelectableTargetInfo info) { + private void loadDirectShareIcon(SelectableTargetInfo info) { LoadDirectShareIconTask task = (LoadDirectShareIconTask) mIconLoaders.get(info); if (task == null) { - task = mTestLoadDirectShareTaskProvider == null - ? new LoadDirectShareIconTask(info) - : mTestLoadDirectShareTaskProvider.get(); + task = createLoadDirectShareIconTask(info); mIconLoaders.put(info, task); task.loadIcon(); } - task.setViewHolder(holder); + } + + @VisibleForTesting + protected LoadDirectShareIconTask createLoadDirectShareIconTask(SelectableTargetInfo info) { + return new LoadDirectShareIconTask( + mContext.createContextAsUser(getUserHandle(), 0), + info); } void updateAlphabeticalList() { + // TODO: this procedure seems like it should be relatively lightweight. Why does it need to + // run in an `AsyncTask`? new AsyncTask<Void, Void, List<DisplayResolveInfo>>() { @Override protected List<DisplayResolveInfo> doInBackground(Void... voids) { List<DisplayResolveInfo> allTargets = new ArrayList<>(); - allTargets.addAll(mDisplayList); + allTargets.addAll(getTargetsInCurrentDisplayList()); allTargets.addAll(mCallerTargets); - if (!mEnableStackedApps) { - return allTargets; - } + // Consolidate multiple targets from same app. - Map<String, DisplayResolveInfo> consolidated = new HashMap<>(); - for (DisplayResolveInfo info : allTargets) { - String resolvedTarget = info.getResolvedComponentName().getPackageName() - + '#' + info.getDisplayLabel(); - DisplayResolveInfo multiDri = consolidated.get(resolvedTarget); - if (multiDri == null) { - consolidated.put(resolvedTarget, info); - } else if (multiDri instanceof MultiDisplayResolveInfo) { - ((MultiDisplayResolveInfo) multiDri).addTarget(info); - } else { - // create consolidated target from the single DisplayResolveInfo - MultiDisplayResolveInfo multiDisplayResolveInfo = - new MultiDisplayResolveInfo(resolvedTarget, multiDri); - multiDisplayResolveInfo.addTarget(info); - consolidated.put(resolvedTarget, multiDisplayResolveInfo); - } - } - List<DisplayResolveInfo> groupedTargets = new ArrayList<>(); - groupedTargets.addAll(consolidated.values()); - Collections.sort(groupedTargets, new ChooserActivity.AzInfoComparator(mContext)); - return groupedTargets; + return allTargets + .stream() + .collect(Collectors.groupingBy(target -> + target.getResolvedComponentName().getPackageName() + + "#" + target.getDisplayLabel() + )) + .values() + .stream() + .map(appTargets -> + (appTargets.size() == 1) + ? appTargets.get(0) + : MultiDisplayResolveInfo.newMultiDisplayResolveInfo(appTargets)) + .sorted(new ChooserActivity.AzInfoComparator(mContext)) + .collect(Collectors.toList()); } @Override protected void onPostExecute(List<DisplayResolveInfo> newList) { @@ -401,8 +379,9 @@ public class ChooserListAdapter extends ResolverListAdapter { @Override public int getUnfilteredCount() { int appTargets = super.getUnfilteredCount(); - if (appTargets > mChooserListCommunicator.getMaxRankedTargets()) { - appTargets = appTargets + mChooserListCommunicator.getMaxRankedTargets(); + if (appTargets > mMaxRankedTargets) { + // TODO: what does this condition mean? + appTargets = appTargets + mMaxRankedTargets; } return appTargets + getSelectableServiceTargetCount() + getCallerTargetCount(); } @@ -417,8 +396,8 @@ public class ChooserListAdapter extends ResolverListAdapter { */ public int getSelectableServiceTargetCount() { int count = 0; - for (ChooserTargetInfo info : mServiceTargets) { - if (info instanceof SelectableTargetInfo) { + for (TargetInfo info : mServiceTargets) { + if (info.isSelectableTargetInfo()) { count++; } } @@ -426,29 +405,37 @@ public class ChooserListAdapter extends ResolverListAdapter { } public int getServiceTargetCount() { - if (mChooserListCommunicator.isSendAction(mChooserListCommunicator.getTargetIntent()) - && !ActivityManager.isLowRamDeviceStatic()) { - return Math.min(mServiceTargets.size(), mChooserListCommunicator.getMaxRankedTargets()); + if (mChooserRequest.isSendActionTarget() && !ActivityManager.isLowRamDeviceStatic()) { + return Math.min(mServiceTargets.size(), mMaxRankedTargets); } return 0; } - int getAlphaTargetCount() { + public int getAlphaTargetCount() { int groupedCount = mSortedList.size(); - int ungroupedCount = mCallerTargets.size() + mDisplayList.size(); - return ungroupedCount > mChooserListCommunicator.getMaxRankedTargets() ? groupedCount : 0; + int ungroupedCount = mCallerTargets.size() + getDisplayResolveInfoCount(); + return (ungroupedCount > mMaxRankedTargets) ? groupedCount : 0; } /** * Fetch ranked app target count */ public int getRankedTargetCount() { - int spacesAvailable = - mChooserListCommunicator.getMaxRankedTargets() - getCallerTargetCount(); + int spacesAvailable = mMaxRankedTargets - getCallerTargetCount(); return Math.min(spacesAvailable, super.getCount()); } + /** Get all the {@link DisplayResolveInfo} data for our targets. */ + public DisplayResolveInfo[] getDisplayResolveInfos() { + int size = getDisplayResolveInfoCount(); + DisplayResolveInfo[] resolvedTargets = new DisplayResolveInfo[size]; + for (int i = 0; i < size; i++) { + resolvedTargets[i] = getDisplayResolveInfo(i); + } + return resolvedTargets; + } + public int getPositionTargetType(int position) { int offset = 0; @@ -483,7 +470,6 @@ public class ChooserListAdapter extends ResolverListAdapter { return targetInfoForPosition(position, true); } - /** * Find target info for a given position. * Since ChooserActivity displays several sections of content, determine which @@ -533,8 +519,8 @@ public class ChooserListAdapter extends ResolverListAdapter { protected boolean shouldAddResolveInfo(DisplayResolveInfo dri) { // Checks if this info is already listed in callerTargets. for (TargetInfo existingInfo : mCallerTargets) { - if (mResolverListCommunicator - .resolveInfoMatch(dri.getResolveInfo(), existingInfo.getResolveInfo())) { + if (mResolverListCommunicator.resolveInfoMatch( + dri.getResolveInfo(), existingInfo.getResolveInfo())) { return false; } } @@ -544,10 +530,9 @@ public class ChooserListAdapter extends ResolverListAdapter { /** * Fetch surfaced direct share target info */ - public List<ChooserTargetInfo> getSurfacedTargetInfo() { - int maxSurfacedTargets = mChooserListCommunicator.getMaxRankedTargets(); + public List<TargetInfo> getSurfacedTargetInfo() { return mServiceTargets.subList(0, - Math.min(maxSurfacedTargets, getSelectableServiceTargetCount())); + Math.min(mMaxRankedTargets, getSelectableServiceTargetCount())); } @@ -555,83 +540,36 @@ public class ChooserListAdapter extends ResolverListAdapter { * Evaluate targets for inclusion in the direct share area. May not be included * if score is too low. */ - public void addServiceResults(DisplayResolveInfo origTarget, List<ChooserTarget> targets, + public void addServiceResults( + @Nullable DisplayResolveInfo origTarget, + List<ChooserTarget> targets, @ChooserActivity.ShareTargetType int targetType, - Map<ChooserTarget, ShortcutInfo> directShareToShortcutInfos) { - if (DEBUG) { - Log.d(TAG, "addServiceResults " + origTarget.getResolvedComponentName() + ", " - + targets.size() - + " targets"); - } - if (targets.size() == 0) { + Map<ChooserTarget, ShortcutInfo> directShareToShortcutInfos, + Map<ChooserTarget, AppTarget> directShareToAppTargets) { + // Avoid inserting any potentially late results. + if ((mServiceTargets.size() == 1) && mServiceTargets.get(0).isEmptyTargetInfo()) { return; } - final float baseScore = getBaseScore(origTarget, targetType); - Collections.sort(targets, mBaseTargetComparator); - final boolean isShortcutResult = - (targetType == TARGET_TYPE_SHORTCUTS_FROM_SHORTCUT_MANAGER - || targetType == TARGET_TYPE_SHORTCUTS_FROM_PREDICTION_SERVICE); - final int maxTargets = isShortcutResult ? mMaxShortcutTargetsPerApp - : MAX_CHOOSER_TARGETS_PER_APP; - final int targetsLimit = mApplySharingAppLimits ? Math.min(targets.size(), maxTargets) - : targets.size(); - float lastScore = 0; - boolean shouldNotify = false; - for (int i = 0, count = targetsLimit; i < count; i++) { - final ChooserTarget target = targets.get(i); - float targetScore = target.getScore(); - if (mApplySharingAppLimits) { - targetScore *= baseScore; - if (i > 0 && targetScore >= lastScore) { - // Apply a decay so that the top app can't crowd out everything else. - // This incents ChooserTargetServices to define what's truly better. - targetScore = lastScore * 0.95f; - } - } - ShortcutInfo shortcutInfo = isShortcutResult ? directShareToShortcutInfos.get(target) - : null; - if ((shortcutInfo != null) && shortcutInfo.isPinned()) { - targetScore += PINNED_SHORTCUT_TARGET_SCORE_BOOST; - } - UserHandle userHandle = getUserHandle(); - Context contextAsUser = mContext.createContextAsUser(userHandle, 0 /* flags */); - boolean isInserted = insertServiceTarget(new SelectableTargetInfo(contextAsUser, - origTarget, target, targetScore, mSelectableTargetInfoCommunicator, - shortcutInfo)); - - if (isInserted && isShortcutResult) { - mNumShortcutResults++; - } - - shouldNotify |= isInserted; - - if (DEBUG) { - Log.d(TAG, " => " + target.toString() + " score=" + targetScore - + " base=" + target.getScore() - + " lastScore=" + lastScore - + " baseScore=" + baseScore - + " applyAppLimit=" + mApplySharingAppLimits); - } - - lastScore = targetScore; - } - - if (shouldNotify) { + boolean isShortcutResult = targetType == TARGET_TYPE_SHORTCUTS_FROM_SHORTCUT_MANAGER + || targetType == TARGET_TYPE_SHORTCUTS_FROM_PREDICTION_SERVICE; + boolean isUpdated = mShortcutSelectionLogic.addServiceResults( + origTarget, + getBaseScore(origTarget, targetType), + targets, + isShortcutResult, + directShareToShortcutInfos, + directShareToAppTargets, + mContext.createContextAsUser(getUserHandle(), 0), + mChooserRequest.getTargetIntent(), + mChooserRequest.getReferrerFillInIntent(), + mMaxRankedTargets, + mServiceTargets); + if (isUpdated) { notifyDataSetChanged(); } } /** - * The return number have to exceed a minimum limit to make direct share area expandable. When - * append direct share targets is enabled, return count of all available targets parking in the - * memory; otherwise, it is shortcuts count which will help reduce the amount of visible - * shuffling due to older-style direct share targets. - */ - int getNumServiceTargetsForExpand() { - return mNumShortcutResults; - } - - /** * Use the scoring system along with artificial boosts to create up to 4 distinct buckets: * <ol> * <li>App-supplied targets @@ -659,54 +597,14 @@ public class ChooserListAdapter extends ResolverListAdapter { * update the direct share area. */ public void completeServiceTargetLoading() { - mServiceTargets.removeIf(o -> o instanceof ChooserActivity.PlaceHolderTargetInfo); + mServiceTargets.removeIf(o -> o.isPlaceHolderTargetInfo()); if (mServiceTargets.isEmpty()) { - mServiceTargets.add(new ChooserActivity.EmptyTargetInfo()); + mServiceTargets.add(NotSelectableTargetInfo.newEmptyTargetInfo()); mChooserActivityLogger.logSharesheetEmptyDirectShareRow(); } notifyDataSetChanged(); } - private boolean insertServiceTarget(ChooserTargetInfo chooserTargetInfo) { - // Avoid inserting any potentially late results - if (mServiceTargets.size() == 1 - && mServiceTargets.get(0) instanceof ChooserActivity.EmptyTargetInfo) { - return false; - } - - // Check for duplicates and abort if found - for (ChooserTargetInfo otherTargetInfo : mServiceTargets) { - if (chooserTargetInfo.isSimilar(otherTargetInfo)) { - return false; - } - } - - int currentSize = mServiceTargets.size(); - final float newScore = chooserTargetInfo.getModifiedScore(); - for (int i = 0; i < Math.min(currentSize, mChooserListCommunicator.getMaxRankedTargets()); - i++) { - final ChooserTargetInfo serviceTarget = mServiceTargets.get(i); - if (serviceTarget == null) { - mServiceTargets.set(i, chooserTargetInfo); - return true; - } else if (newScore > serviceTarget.getModifiedScore()) { - mServiceTargets.add(i, chooserTargetInfo); - return true; - } - } - - if (currentSize < mChooserListCommunicator.getMaxRankedTargets()) { - mServiceTargets.add(chooserTargetInfo); - return true; - } - - return false; - } - - public ChooserTarget getChooserTargetForValue(int value) { - return mServiceTargets.get(value).getChooserTarget(); - } - protected boolean alwaysShowSubLabel() { // Always show a subLabel for visual consistency across list items. Show an empty // subLabel if the subLabel is the same as the label @@ -728,8 +626,7 @@ public class ChooserListAdapter extends ResolverListAdapter { protected List<ResolvedComponentInfo> doInBackground( List<ResolvedComponentInfo>... params) { Trace.beginSection("ChooserListAdapter#SortingTask"); - mResolverListController.topK(params[0], - mChooserListCommunicator.getMaxRankedTargets()); + mResolverListController.topK(params[0], mMaxRankedTargets); Trace.endSection(); return params[0]; } @@ -737,88 +634,95 @@ public class ChooserListAdapter extends ResolverListAdapter { protected void onPostExecute(List<ResolvedComponentInfo> sortedComponents) { processSortedList(sortedComponents, doPostProcessing); if (doPostProcessing) { - mChooserListCommunicator.updateProfileViewButton(); + mResolverListCommunicator.updateProfileViewButton(); notifyDataSetChanged(); } } }; } - public void setAppPredictor(AppPredictor appPredictor) { - mAppPredictor = appPredictor; - } - - public void setAppPredictorCallback(AppPredictor.Callback appPredictorCallback) { - mAppPredictorCallback = appPredictorCallback; - } - - public void destroyAppPredictor() { - if (getAppPredictor() != null) { - getAppPredictor().unregisterPredictionUpdates(mAppPredictorCallback); - getAppPredictor().destroy(); - setAppPredictor(null); - } - } - - /** - * An alias for onBindView to use with unit tests. - */ - @VisibleForTesting - public void testViewBind(View view, TargetInfo info, int position) { - onBindView(view, info, position); - } - - @VisibleForTesting - public void setTestLoadDirectShareTaskProvider(LoadDirectShareIconTaskProvider provider) { - mTestLoadDirectShareTaskProvider = provider; - } - - /** - * Necessary methods to communicate between {@link ChooserListAdapter} - * and {@link ChooserActivity}. - */ - @VisibleForTesting - public interface ChooserListCommunicator extends ResolverListCommunicator { - - int getMaxRankedTargets(); - - void sendListViewUpdateMessage(UserHandle userHandle); - - boolean isSendAction(Intent targetIntent); - } - /** * Loads direct share targets icons. */ @VisibleForTesting - public class LoadDirectShareIconTask extends AsyncTask<Void, Void, Void> { + public class LoadDirectShareIconTask extends AsyncTask<Void, Void, Drawable> { + private final Context mContext; private final SelectableTargetInfo mTargetInfo; - private ViewHolder mViewHolder; - private LoadDirectShareIconTask(SelectableTargetInfo targetInfo) { + private LoadDirectShareIconTask(Context context, SelectableTargetInfo targetInfo) { + mContext = context; mTargetInfo = targetInfo; } @Override - protected Void doInBackground(Void... voids) { - mTargetInfo.loadIcon(); - return null; + protected Drawable doInBackground(Void... voids) { + Drawable drawable; + try { + drawable = getChooserTargetIconDrawable( + mContext, + mTargetInfo.getChooserTargetIcon(), + mTargetInfo.getChooserTargetComponentName(), + mTargetInfo.getDirectShareShortcutInfo()); + } catch (Exception e) { + Log.e(TAG, + "Failed to load shortcut icon for " + + mTargetInfo.getChooserTargetComponentName(), + e); + drawable = loadIconPlaceholder(); + } + return drawable; } @Override - protected void onPostExecute(Void arg) { - if (mViewHolder != null) { - mViewHolder.bindIcon(mTargetInfo); + protected void onPostExecute(@Nullable Drawable icon) { + if (icon != null && !mTargetInfo.hasDisplayIcon()) { + mTargetInfo.getDisplayIconHolder().setDisplayIcon(icon); notifyDataSetChanged(); } } - /** - * Specifies a view holder that will be updated when the task is completed. - */ - public void setViewHolder(ViewHolder viewHolder) { - mViewHolder = viewHolder; - mViewHolder.bindIcon(mTargetInfo); + @WorkerThread + private Drawable getChooserTargetIconDrawable( + Context context, + @Nullable Icon icon, + ComponentName targetComponentName, + @Nullable ShortcutInfo shortcutInfo) { + Drawable directShareIcon = null; + + // First get the target drawable and associated activity info + if (icon != null) { + directShareIcon = icon.loadDrawable(context); + } else if (shortcutInfo != null) { + LauncherApps launcherApps = context.getSystemService(LauncherApps.class); + if (launcherApps != null) { + directShareIcon = launcherApps.getShortcutIconDrawable(shortcutInfo, 0); + } + } + + if (directShareIcon == null) { + return null; + } + + ActivityInfo info = null; + try { + info = context.getPackageManager().getActivityInfo(targetComponentName, 0); + } catch (PackageManager.NameNotFoundException error) { + Log.e(TAG, "Could not find activity associated with ChooserTarget"); + } + + if (info == null) { + return null; + } + + // Now fetch app icon and raster with no badging even in work profile + Bitmap appIcon = mPresentationFactory.makePresentationGetter(info).getIconBitmap(null); + + // Raster target drawable with appIcon as a badge + SimpleIconFactory sif = SimpleIconFactory.obtain(context); + Bitmap directShareBadgedIcon = sif.createAppBadgedIconBitmap(directShareIcon, appIcon); + sif.recycle(); + + return new BitmapDrawable(context.getResources(), directShareBadgedIcon); } /** @@ -828,16 +732,4 @@ public class ChooserListAdapter extends ResolverListAdapter { execute(); } } - - /** - * An interface for the unit tests to override icon loading task creation - */ - @VisibleForTesting - public interface LoadDirectShareIconTaskProvider { - /** - * Provides an instance of the task. - * @return - */ - LoadDirectShareIconTask get(); - } } diff --git a/java/src/com/android/intentresolver/ChooserMultiProfilePagerAdapter.java b/java/src/com/android/intentresolver/ChooserMultiProfilePagerAdapter.java index da78fc81..39d1fab0 100644 --- a/java/src/com/android/intentresolver/ChooserMultiProfilePagerAdapter.java +++ b/java/src/com/android/intentresolver/ChooserMultiProfilePagerAdapter.java @@ -16,306 +16,159 @@ package com.android.intentresolver; -import static android.app.admin.DevicePolicyResources.Strings.Core.RESOLVER_CANT_ACCESS_PERSONAL; -import static android.app.admin.DevicePolicyResources.Strings.Core.RESOLVER_CANT_ACCESS_WORK; -import static android.app.admin.DevicePolicyResources.Strings.Core.RESOLVER_CANT_SHARE_WITH_PERSONAL; -import static android.app.admin.DevicePolicyResources.Strings.Core.RESOLVER_CANT_SHARE_WITH_WORK; -import static android.app.admin.DevicePolicyResources.Strings.Core.RESOLVER_CROSS_PROFILE_BLOCKED_TITLE; -import static android.app.admin.DevicePolicyResources.Strings.Core.RESOLVER_NO_PERSONAL_APPS; -import static android.app.admin.DevicePolicyResources.Strings.Core.RESOLVER_NO_WORK_APPS; -import static android.app.admin.DevicePolicyResources.Strings.Core.RESOLVER_WORK_PAUSED_TITLE; - -import android.annotation.Nullable; -import android.app.admin.DevicePolicyManager; import android.content.Context; import android.os.UserHandle; import android.view.LayoutInflater; -import android.view.View; import android.view.ViewGroup; +import androidx.recyclerview.widget.GridLayoutManager; +import androidx.recyclerview.widget.RecyclerView; +import androidx.viewpager.widget.PagerAdapter; + +import com.android.intentresolver.grid.ChooserGridAdapter; import com.android.internal.annotations.VisibleForTesting; -import com.android.internal.widget.GridLayoutManager; -import com.android.internal.widget.PagerAdapter; -import com.android.internal.widget.RecyclerView; + +import com.google.common.collect.ImmutableList; + +import java.util.Optional; +import java.util.function.Supplier; /** * A {@link PagerAdapter} which describes the work and personal profile share sheet screens. */ @VisibleForTesting -public class ChooserMultiProfilePagerAdapter extends AbstractMultiProfilePagerAdapter { +public class ChooserMultiProfilePagerAdapter extends GenericMultiProfilePagerAdapter< + RecyclerView, ChooserGridAdapter, ChooserListAdapter> { private static final int SINGLE_CELL_SPAN_SIZE = 1; - private final ChooserProfileDescriptor[] mItems; - private final boolean mIsSendAction; - private int mBottomOffset; - private int mMaxTargetsPerRow; + private final ChooserProfileAdapterBinder mAdapterBinder; + private final BottomPaddingOverrideSupplier mBottomPaddingOverrideSupplier; - ChooserMultiProfilePagerAdapter(Context context, - ChooserActivity.ChooserGridAdapter adapter, - UserHandle personalProfileUserHandle, + ChooserMultiProfilePagerAdapter( + Context context, + ChooserGridAdapter adapter, + EmptyStateProvider emptyStateProvider, + QuietModeManager quietModeManager, UserHandle workProfileUserHandle, - boolean isSendAction, int maxTargetsPerRow) { - super(context, /* currentPage */ 0, personalProfileUserHandle, workProfileUserHandle); - mItems = new ChooserProfileDescriptor[] { - createProfileDescriptor(adapter) - }; - mIsSendAction = isSendAction; - mMaxTargetsPerRow = maxTargetsPerRow; - } - - ChooserMultiProfilePagerAdapter(Context context, - ChooserActivity.ChooserGridAdapter personalAdapter, - ChooserActivity.ChooserGridAdapter workAdapter, + int maxTargetsPerRow) { + this( + context, + new ChooserProfileAdapterBinder(maxTargetsPerRow), + ImmutableList.of(adapter), + emptyStateProvider, + quietModeManager, + /* defaultProfile= */ 0, + workProfileUserHandle, + new BottomPaddingOverrideSupplier(context)); + } + + ChooserMultiProfilePagerAdapter( + Context context, + ChooserGridAdapter personalAdapter, + ChooserGridAdapter workAdapter, + EmptyStateProvider emptyStateProvider, + QuietModeManager quietModeManager, @Profile int defaultProfile, - UserHandle personalProfileUserHandle, UserHandle workProfileUserHandle, - boolean isSendAction, int maxTargetsPerRow) { - super(context, /* currentPage */ defaultProfile, personalProfileUserHandle, - workProfileUserHandle); - mItems = new ChooserProfileDescriptor[] { - createProfileDescriptor(personalAdapter), - createProfileDescriptor(workAdapter) - }; - mIsSendAction = isSendAction; - mMaxTargetsPerRow = maxTargetsPerRow; - } - - private ChooserProfileDescriptor createProfileDescriptor( - ChooserActivity.ChooserGridAdapter adapter) { - final LayoutInflater inflater = LayoutInflater.from(getContext()); - final ViewGroup rootView = - (ViewGroup) inflater.inflate(R.layout.chooser_list_per_profile, null, false); - ChooserProfileDescriptor profileDescriptor = - new ChooserProfileDescriptor(rootView, adapter); - profileDescriptor.recyclerView.setAccessibilityDelegateCompat( - new ChooserRecyclerViewAccessibilityDelegate(profileDescriptor.recyclerView)); - return profileDescriptor; + int maxTargetsPerRow) { + this( + context, + new ChooserProfileAdapterBinder(maxTargetsPerRow), + ImmutableList.of(personalAdapter, workAdapter), + emptyStateProvider, + quietModeManager, + defaultProfile, + workProfileUserHandle, + new BottomPaddingOverrideSupplier(context)); + } + + private ChooserMultiProfilePagerAdapter( + Context context, + ChooserProfileAdapterBinder adapterBinder, + ImmutableList<ChooserGridAdapter> gridAdapters, + EmptyStateProvider emptyStateProvider, + QuietModeManager quietModeManager, + @Profile int defaultProfile, + UserHandle workProfileUserHandle, + BottomPaddingOverrideSupplier bottomPaddingOverrideSupplier) { + super( + context, + gridAdapter -> gridAdapter.getListAdapter(), + adapterBinder, + gridAdapters, + emptyStateProvider, + quietModeManager, + defaultProfile, + workProfileUserHandle, + () -> makeProfileView(context), + bottomPaddingOverrideSupplier); + mAdapterBinder = adapterBinder; + mBottomPaddingOverrideSupplier = bottomPaddingOverrideSupplier; } public void setMaxTargetsPerRow(int maxTargetsPerRow) { - mMaxTargetsPerRow = maxTargetsPerRow; + mAdapterBinder.setMaxTargetsPerRow(maxTargetsPerRow); } - RecyclerView getListViewForIndex(int index) { - return getItem(index).recyclerView; + public void setEmptyStateBottomOffset(int bottomOffset) { + mBottomPaddingOverrideSupplier.setEmptyStateBottomOffset(bottomOffset); } - @Override - ChooserProfileDescriptor getItem(int pageIndex) { - return mItems[pageIndex]; + private static ViewGroup makeProfileView(Context context) { + LayoutInflater inflater = LayoutInflater.from(context); + ViewGroup rootView = (ViewGroup) inflater.inflate( + R.layout.chooser_list_per_profile, null, false); + RecyclerView recyclerView = rootView.findViewById(com.android.internal.R.id.resolver_list); + recyclerView.setAccessibilityDelegateCompat( + new ChooserRecyclerViewAccessibilityDelegate(recyclerView)); + return rootView; } - @Override - int getItemCount() { - return mItems.length; - } + private static class BottomPaddingOverrideSupplier implements Supplier<Optional<Integer>> { + private final Context mContext; + private int mBottomOffset; - @Override - @VisibleForTesting - public ChooserActivity.ChooserGridAdapter getAdapterForIndex(int pageIndex) { - return mItems[pageIndex].chooserGridAdapter; - } - - @Override - @Nullable - ChooserListAdapter getListAdapterForUserHandle(UserHandle userHandle) { - if (getActiveListAdapter().getUserHandle().equals(userHandle)) { - return getActiveListAdapter(); - } else if (getInactiveListAdapter() != null - && getInactiveListAdapter().getUserHandle().equals(userHandle)) { - return getInactiveListAdapter(); + BottomPaddingOverrideSupplier(Context context) { + mContext = context; } - return null; - } - @Override - void setupListAdapter(int pageIndex) { - final RecyclerView recyclerView = getItem(pageIndex).recyclerView; - ChooserActivity.ChooserGridAdapter chooserGridAdapter = - getItem(pageIndex).chooserGridAdapter; - GridLayoutManager glm = (GridLayoutManager) recyclerView.getLayoutManager(); - glm.setSpanCount(mMaxTargetsPerRow); - glm.setSpanSizeLookup( - new GridLayoutManager.SpanSizeLookup() { - @Override - public int getSpanSize(int position) { - return chooserGridAdapter.shouldCellSpan(position) - ? SINGLE_CELL_SPAN_SIZE - : glm.getSpanCount(); - } - }); - } - - @Override - @VisibleForTesting - public ChooserListAdapter getActiveListAdapter() { - return getAdapterForIndex(getCurrentPage()).getListAdapter(); - } - - @Override - @VisibleForTesting - public ChooserListAdapter getInactiveListAdapter() { - if (getCount() == 1) { - return null; + public void setEmptyStateBottomOffset(int bottomOffset) { + mBottomOffset = bottomOffset; } - return getAdapterForIndex(1 - getCurrentPage()).getListAdapter(); - } - - @Override - public ResolverListAdapter getPersonalListAdapter() { - return getAdapterForIndex(PROFILE_PERSONAL).getListAdapter(); - } - - @Override - @Nullable - public ResolverListAdapter getWorkListAdapter() { - return getAdapterForIndex(PROFILE_WORK).getListAdapter(); - } - - @Override - ChooserActivity.ChooserGridAdapter getCurrentRootAdapter() { - return getAdapterForIndex(getCurrentPage()); - } - @Override - RecyclerView getActiveAdapterView() { - return getListViewForIndex(getCurrentPage()); - } - - @Override - @Nullable - RecyclerView getInactiveAdapterView() { - if (getCount() == 1) { - return null; + public Optional<Integer> get() { + int initialBottomPadding = mContext.getResources().getDimensionPixelSize( + R.dimen.resolver_empty_state_container_padding_bottom); + return Optional.of(initialBottomPadding + mBottomOffset); } - return getListViewForIndex(1 - getCurrentPage()); - } - - @Override - String getMetricsCategory() { - return ResolverActivity.METRICS_CATEGORY_CHOOSER; } - @Override - protected void showWorkProfileOffEmptyState(ResolverListAdapter activeListAdapter, - View.OnClickListener listener) { - showEmptyState(activeListAdapter, - getWorkAppPausedTitle(), - /* subtitle = */ null, - listener); - } + private static class ChooserProfileAdapterBinder implements + AdapterBinder<RecyclerView, ChooserGridAdapter> { + private int mMaxTargetsPerRow; - @Override - protected void showNoPersonalToWorkIntentsEmptyState(ResolverListAdapter activeListAdapter) { - if (mIsSendAction) { - showEmptyState(activeListAdapter, - getCrossProfileBlockedTitle(), - getCantShareWithWorkMessage()); - } else { - showEmptyState(activeListAdapter, - getCrossProfileBlockedTitle(), - getCantAccessWorkMessage()); + ChooserProfileAdapterBinder(int maxTargetsPerRow) { + mMaxTargetsPerRow = maxTargetsPerRow; } - } - @Override - protected void showNoWorkToPersonalIntentsEmptyState(ResolverListAdapter activeListAdapter) { - if (mIsSendAction) { - showEmptyState(activeListAdapter, - getCrossProfileBlockedTitle(), - getCantShareWithPersonalMessage()); - } else { - showEmptyState(activeListAdapter, - getCrossProfileBlockedTitle(), - getCantAccessPersonalMessage()); + public void setMaxTargetsPerRow(int maxTargetsPerRow) { + mMaxTargetsPerRow = maxTargetsPerRow; } - } - - @Override - protected void showNoPersonalAppsAvailableEmptyState(ResolverListAdapter listAdapter) { - showEmptyState(listAdapter, getNoPersonalAppsAvailableMessage(), /* subtitle= */ null); - - } - - @Override - protected void showNoWorkAppsAvailableEmptyState(ResolverListAdapter listAdapter) { - showEmptyState(listAdapter, getNoWorkAppsAvailableMessage(), /* subtitle = */ null); - } - - private String getWorkAppPausedTitle() { - return getContext().getSystemService(DevicePolicyManager.class).getResources().getString( - RESOLVER_WORK_PAUSED_TITLE, - () -> getContext().getString(R.string.resolver_turn_on_work_apps)); - } - - private String getCrossProfileBlockedTitle() { - return getContext().getSystemService(DevicePolicyManager.class).getResources().getString( - RESOLVER_CROSS_PROFILE_BLOCKED_TITLE, - () -> getContext().getString(R.string.resolver_cross_profile_blocked)); - } - - private String getCantShareWithWorkMessage() { - return getContext().getSystemService(DevicePolicyManager.class).getResources().getString( - RESOLVER_CANT_SHARE_WITH_WORK, - () -> getContext().getString( - R.string.resolver_cant_share_with_work_apps_explanation)); - } - - private String getCantShareWithPersonalMessage() { - return getContext().getSystemService(DevicePolicyManager.class).getResources().getString( - RESOLVER_CANT_SHARE_WITH_PERSONAL, - () -> getContext().getString( - R.string.resolver_cant_share_with_personal_apps_explanation)); - } - - private String getCantAccessWorkMessage() { - return getContext().getSystemService(DevicePolicyManager.class).getResources().getString( - RESOLVER_CANT_ACCESS_WORK, - () -> getContext().getString( - R.string.resolver_cant_access_work_apps_explanation)); - } - - private String getCantAccessPersonalMessage() { - return getContext().getSystemService(DevicePolicyManager.class).getResources().getString( - RESOLVER_CANT_ACCESS_PERSONAL, - () -> getContext().getString( - R.string.resolver_cant_access_personal_apps_explanation)); - } - - private String getNoWorkAppsAvailableMessage() { - return getContext().getSystemService(DevicePolicyManager.class).getResources().getString( - RESOLVER_NO_WORK_APPS, - () -> getContext().getString( - R.string.resolver_no_work_apps_available)); - } - - private String getNoPersonalAppsAvailableMessage() { - return getContext().getSystemService(DevicePolicyManager.class).getResources().getString( - RESOLVER_NO_PERSONAL_APPS, - () -> getContext().getString( - R.string.resolver_no_personal_apps_available)); - } - - - void setEmptyStateBottomOffset(int bottomOffset) { - mBottomOffset = bottomOffset; - } - - @Override - protected void setupContainerPadding(View container) { - int initialBottomPadding = getContext().getResources().getDimensionPixelSize( - R.dimen.resolver_empty_state_container_padding_bottom); - container.setPadding(container.getPaddingLeft(), container.getPaddingTop(), - container.getPaddingRight(), initialBottomPadding + mBottomOffset); - } - class ChooserProfileDescriptor extends ProfileDescriptor { - private ChooserActivity.ChooserGridAdapter chooserGridAdapter; - private RecyclerView recyclerView; - ChooserProfileDescriptor(ViewGroup rootView, ChooserActivity.ChooserGridAdapter adapter) { - super(rootView); - chooserGridAdapter = adapter; - recyclerView = rootView.findViewById(com.android.internal.R.id.resolver_list); + @Override + public void bind( + RecyclerView recyclerView, ChooserGridAdapter chooserGridAdapter) { + GridLayoutManager glm = (GridLayoutManager) recyclerView.getLayoutManager(); + glm.setSpanCount(mMaxTargetsPerRow); + glm.setSpanSizeLookup( + new GridLayoutManager.SpanSizeLookup() { + @Override + public int getSpanSize(int position) { + return chooserGridAdapter.shouldCellSpan(position) + ? SINGLE_CELL_SPAN_SIZE + : glm.getSpanCount(); + } + }); } } } diff --git a/java/src/com/android/intentresolver/ChooserRecyclerViewAccessibilityDelegate.java b/java/src/com/android/intentresolver/ChooserRecyclerViewAccessibilityDelegate.java index 67571b44..250b6827 100644 --- a/java/src/com/android/intentresolver/ChooserRecyclerViewAccessibilityDelegate.java +++ b/java/src/com/android/intentresolver/ChooserRecyclerViewAccessibilityDelegate.java @@ -22,8 +22,8 @@ import android.view.View; import android.view.ViewGroup; import android.view.accessibility.AccessibilityEvent; -import com.android.internal.widget.RecyclerView; -import com.android.internal.widget.RecyclerViewAccessibilityDelegate; +import androidx.recyclerview.widget.RecyclerView; +import androidx.recyclerview.widget.RecyclerViewAccessibilityDelegate; class ChooserRecyclerViewAccessibilityDelegate extends RecyclerViewAccessibilityDelegate { private final Rect mTempRect = new Rect(); diff --git a/java/src/com/android/intentresolver/ChooserRequestParameters.java b/java/src/com/android/intentresolver/ChooserRequestParameters.java new file mode 100644 index 00000000..81481bf1 --- /dev/null +++ b/java/src/com/android/intentresolver/ChooserRequestParameters.java @@ -0,0 +1,441 @@ +/* + * 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.annotation.NonNull; +import android.annotation.Nullable; +import android.content.ComponentName; +import android.content.Intent; +import android.content.IntentFilter; +import android.content.IntentSender; +import android.net.Uri; +import android.os.Bundle; +import android.os.Parcelable; +import android.os.PatternMatcher; +import android.service.chooser.ChooserTarget; +import android.text.TextUtils; +import android.util.Log; +import android.util.Pair; + +import com.google.common.collect.ImmutableList; + +import java.net.URISyntaxException; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import java.util.stream.Collector; +import java.util.stream.Collectors; +import java.util.stream.Stream; + +/** + * Utility to parse and validate parameters from the client-supplied {@link Intent} that launched + * the Sharesheet {@link ChooserActivity}. The validated parameters are stored as immutable ivars. + * + * TODO: field nullability in this class reflects legacy use, and typically would indicate that the + * client's intent didn't provide the respective data. In some cases we may be able to provide + * defaults instead of nulls -- especially for methods that return nullable lists or arrays, if the + * client code could instead handle empty collections equally well. + * + * TODO: some of these fields (especially getTargetIntent() and any other getters that delegate to + * it internally) differ from the legacy model because they're computed directly from the initial + * Chooser intent, where in the past they've been relayed up to ResolverActivity and then retrieved + * through methods on the base class. The base always seems to return them exactly as they were + * provided, so this should be safe -- and clients can reasonably switch to retrieving through these + * parameters instead. For now, the other convention is still used in some places. Ideally we'd like + * to normalize on a single source of truth, but we'll have to clean up the delegation up to the + * resolver (or perhaps this needs to be a subclass of some `ResolverRequestParameters` class?). + */ +public class ChooserRequestParameters { + private static final String TAG = "ChooserActivity"; + + private static final int LAUNCH_FLAGS_FOR_SEND_ACTION = + Intent.FLAG_ACTIVITY_NEW_DOCUMENT | Intent.FLAG_ACTIVITY_MULTIPLE_TASK; + + private final Intent mTarget; + private final Pair<CharSequence, Integer> mTitleSpec; + private final Intent mReferrerFillInIntent; + private final ImmutableList<ComponentName> mFilteredComponentNames; + private final ImmutableList<ChooserTarget> mCallerChooserTargets; + private final boolean mRetainInOnStop; + + @Nullable + private final ImmutableList<Intent> mAdditionalTargets; + + @Nullable + private final Bundle mReplacementExtras; + + @Nullable + private final ImmutableList<Intent> mInitialIntents; + + @Nullable + private final IntentSender mChosenComponentSender; + + @Nullable + private final IntentSender mRefinementIntentSender; + + @Nullable + private final String mSharedText; + + @Nullable + private final IntentFilter mTargetIntentFilter; + + public ChooserRequestParameters( + final Intent clientIntent, + final Uri referrer, + @Nullable final ComponentName nearbySharingComponent) { + final Intent requestedTarget = parseTargetIntentExtra( + clientIntent.getParcelableExtra(Intent.EXTRA_INTENT)); + mTarget = intentWithModifiedLaunchFlags(requestedTarget); + + mAdditionalTargets = intentsWithModifiedLaunchFlagsFromExtraIfPresent( + clientIntent, Intent.EXTRA_ALTERNATE_INTENTS); + + mReplacementExtras = clientIntent.getBundleExtra(Intent.EXTRA_REPLACEMENT_EXTRAS); + + mTitleSpec = makeTitleSpec( + clientIntent.getCharSequenceExtra(Intent.EXTRA_TITLE), + isSendAction(mTarget.getAction())); + + mInitialIntents = intentsWithModifiedLaunchFlagsFromExtraIfPresent( + clientIntent, Intent.EXTRA_INITIAL_INTENTS); + + mReferrerFillInIntent = new Intent().putExtra(Intent.EXTRA_REFERRER, referrer); + + mChosenComponentSender = clientIntent.getParcelableExtra( + Intent.EXTRA_CHOSEN_COMPONENT_INTENT_SENDER); + mRefinementIntentSender = clientIntent.getParcelableExtra( + Intent.EXTRA_CHOOSER_REFINEMENT_INTENT_SENDER); + + mFilteredComponentNames = getFilteredComponentNames(clientIntent, nearbySharingComponent); + + mCallerChooserTargets = parseCallerTargetsFromClientIntent(clientIntent); + + mRetainInOnStop = clientIntent.getBooleanExtra( + ChooserActivity.EXTRA_PRIVATE_RETAIN_IN_ON_STOP, false); + + mSharedText = mTarget.getStringExtra(Intent.EXTRA_TEXT); + + mTargetIntentFilter = getTargetIntentFilter(mTarget); + } + + public Intent getTargetIntent() { + return mTarget; + } + + @Nullable + public String getTargetAction() { + return getTargetIntent().getAction(); + } + + public boolean isSendActionTarget() { + return isSendAction(getTargetAction()); + } + + @Nullable + public String getTargetType() { + return getTargetIntent().getType(); + } + + @Nullable + public CharSequence getTitle() { + return mTitleSpec.first; + } + + public int getDefaultTitleResource() { + return mTitleSpec.second; + } + + public Intent getReferrerFillInIntent() { + return mReferrerFillInIntent; + } + + public ImmutableList<ComponentName> getFilteredComponentNames() { + return mFilteredComponentNames; + } + + public ImmutableList<ChooserTarget> getCallerChooserTargets() { + return mCallerChooserTargets; + } + + /** + * Whether the {@link ChooserActivity.EXTRA_PRIVATE_RETAIN_IN_ON_STOP} behavior was requested. + */ + public boolean shouldRetainInOnStop() { + return mRetainInOnStop; + } + + /** + * TODO: this returns a nullable array for convenience, but if the legacy APIs can be + * refactored, returning {@link mAdditionalTargets} directly is simpler and safer. + */ + @Nullable + public Intent[] getAdditionalTargets() { + return (mAdditionalTargets == null) ? null : mAdditionalTargets.toArray(new Intent[0]); + } + + @Nullable + public Bundle getReplacementExtras() { + return mReplacementExtras; + } + + /** + * TODO: this returns a nullable array for convenience, but if the legacy APIs can be + * refactored, returning {@link mInitialIntents} directly is simpler and safer. + */ + @Nullable + public Intent[] getInitialIntents() { + return (mInitialIntents == null) ? null : mInitialIntents.toArray(new Intent[0]); + } + + @Nullable + public IntentSender getChosenComponentSender() { + return mChosenComponentSender; + } + + @Nullable + public IntentSender getRefinementIntentSender() { + return mRefinementIntentSender; + } + + @Nullable + public String getSharedText() { + return mSharedText; + } + + @Nullable + public IntentFilter getTargetIntentFilter() { + return mTargetIntentFilter; + } + + private static boolean isSendAction(@Nullable String action) { + return (Intent.ACTION_SEND.equals(action) || Intent.ACTION_SEND_MULTIPLE.equals(action)); + } + + private static Intent parseTargetIntentExtra(@Nullable Parcelable targetParcelable) { + if (targetParcelable instanceof Uri) { + try { + targetParcelable = Intent.parseUri(targetParcelable.toString(), + Intent.URI_INTENT_SCHEME); + } catch (URISyntaxException ex) { + throw new IllegalArgumentException("Failed to parse EXTRA_INTENT from URI", ex); + } + } + + if (!(targetParcelable instanceof Intent)) { + throw new IllegalArgumentException( + "EXTRA_INTENT is neither an Intent nor a Uri: " + targetParcelable); + } + + return ((Intent) targetParcelable); + } + + private static Intent intentWithModifiedLaunchFlags(Intent intent) { + if (isSendAction(intent.getAction())) { + intent.addFlags(LAUNCH_FLAGS_FOR_SEND_ACTION); + } + return intent; + } + + /** + * Build a pair of values specifying the title to use from the client request. The first + * ({@link CharSequence}) value is the client-specified title, if there was one and their + * requested target <em>wasn't</em> a send action; otherwise it is null. The second value is + * the resource ID of a default title string; this is nonzero only if the first value is null. + * + * TODO: change the API for how these are passed up to {@link ResolverActivity#onCreate()}, or + * create a real type (not {@link Pair}) to express the semantics described in this comment. + */ + private static Pair<CharSequence, Integer> makeTitleSpec( + @Nullable CharSequence requestedTitle, boolean hasSendActionTarget) { + if (hasSendActionTarget && (requestedTitle != null)) { + // Do not allow the title to be changed when sharing content + Log.w(TAG, "Ignoring intent's EXTRA_TITLE, deprecated in P. You may wish to set a" + + " preview title by using EXTRA_TITLE property of the wrapped" + + " EXTRA_INTENT."); + requestedTitle = null; + } + + int defaultTitleRes = + (requestedTitle == null) ? com.android.internal.R.string.chooseActivity : 0; + + return Pair.create(requestedTitle, defaultTitleRes); + } + + private static ImmutableList<ComponentName> getFilteredComponentNames( + Intent clientIntent, @Nullable ComponentName nearbySharingComponent) { + Stream<ComponentName> filteredComponents = streamParcelableArrayExtra( + clientIntent, Intent.EXTRA_EXCLUDE_COMPONENTS, ComponentName.class, true, true); + + if (nearbySharingComponent != null) { + // Exclude Nearby from main list if chip is present, to avoid duplication. + // TODO: we don't have an explicit guarantee that the chip will be displayed just + // because we have a non-null component; that's ultimately determined by the preview + // layout. Maybe we can make that decision further upstream? + filteredComponents = Stream.concat( + filteredComponents, Stream.of(nearbySharingComponent)); + } + + return filteredComponents.collect(toImmutableList()); + } + + private static ImmutableList<ChooserTarget> parseCallerTargetsFromClientIntent( + Intent clientIntent) { + return + streamParcelableArrayExtra( + clientIntent, Intent.EXTRA_CHOOSER_TARGETS, ChooserTarget.class, true, true) + .collect(toImmutableList()); + } + + private static <T> Collector<T, ?, ImmutableList<T>> toImmutableList() { + return Collectors.collectingAndThen(Collectors.toList(), ImmutableList::copyOf); + } + + @Nullable + private static ImmutableList<Intent> intentsWithModifiedLaunchFlagsFromExtraIfPresent( + Intent clientIntent, String extra) { + Stream<Intent> intents = + streamParcelableArrayExtra(clientIntent, extra, Intent.class, true, false); + if (intents == null) { + return null; + } + return intents + .map(ChooserRequestParameters::intentWithModifiedLaunchFlags) + .collect(toImmutableList()); + } + + /** + * Make a {@link Stream} of the {@link Parcelable} objects given in the provided {@link Intent} + * as the optional parcelable array extra with key {@code extra}. The stream elements, if any, + * are all of the type specified by {@code clazz}. + * + * @param intent The intent that may contain the optional extras. + * @param extra The extras key to identify the parcelable array. + * @param clazz A class that is assignable from any elements in the result stream. + * @param warnOnTypeError Whether to log a warning (and ignore) if the client extra doesn't have + * the required type. If false, throw an {@link IllegalArgumentException} if the extra is + * non-null but can't be assigned to variables of type {@code T}. + * @param streamEmptyIfNull Whether to return an empty stream if the optional extra isn't + * present in the intent (or if it had the wrong type, but {@link warnOnTypeError} is true). + * If false, return null in these cases, and only return an empty stream if the intent + * explicitly provided an empty array for the specified extra. + */ + @Nullable + private static <T extends Parcelable> Stream<T> streamParcelableArrayExtra( + final Intent intent, + String extra, + @NonNull Class<T> clazz, + boolean warnOnTypeError, + boolean streamEmptyIfNull) { + T[] result = null; + + try { + result = getParcelableArrayExtraIfPresent(intent, extra, clazz); + } catch (IllegalArgumentException e) { + if (warnOnTypeError) { + Log.w(TAG, "Ignoring client-requested " + extra, e); + } else { + throw e; + } + } + + if (result != null) { + return Arrays.stream(result); + } else if (streamEmptyIfNull) { + return Stream.empty(); + } else { + return null; + } + } + + /** + * If the specified {@code extra} is provided in the {@code intent}, cast it to type {@code T[]} + * or throw an {@code IllegalArgumentException} if the cast fails. If the {@code extra} isn't + * present in the {@code intent}, return null. + */ + @Nullable + private static <T extends Parcelable> T[] getParcelableArrayExtraIfPresent( + final Intent intent, String extra, @NonNull Class<T> clazz) throws + IllegalArgumentException { + if (!intent.hasExtra(extra)) { + return null; + } + + T[] castResult = intent.getParcelableArrayExtra(extra, clazz); + if (castResult == null) { + Parcelable[] actualExtrasArray = intent.getParcelableArrayExtra(extra); + if (actualExtrasArray != null) { + throw new IllegalArgumentException( + String.format( + "%s is not of type %s[]: %s", + extra, + clazz.getSimpleName(), + Arrays.toString(actualExtrasArray))); + } else if (intent.getParcelableExtra(extra) != null) { + throw new IllegalArgumentException( + String.format( + "%s is not of type %s[] (or any array type): %s", + extra, + clazz.getSimpleName(), + intent.getParcelableExtra(extra))); + } else { + throw new IllegalArgumentException( + String.format( + "%s is not of type %s (or any Parcelable type): %s", + extra, + clazz.getSimpleName(), + intent.getExtras().get(extra))); + } + } + + return castResult; + } + + private static 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; + } + } +} diff --git a/java/src/com/android/intentresolver/ChooserStackedAppDialogFragment.java b/java/src/com/android/intentresolver/ChooserStackedAppDialogFragment.java index ae08ace2..2cfceeae 100644 --- a/java/src/com/android/intentresolver/ChooserStackedAppDialogFragment.java +++ b/java/src/com/android/intentresolver/ChooserStackedAppDialogFragment.java @@ -20,9 +20,10 @@ package com.android.intentresolver; import android.content.DialogInterface; import android.content.pm.PackageManager; import android.graphics.drawable.Drawable; -import android.os.Bundle; import android.os.UserHandle; +import androidx.fragment.app.FragmentManager; + import com.android.intentresolver.chooser.DisplayResolveInfo; import com.android.intentresolver.chooser.MultiDisplayResolveInfo; @@ -30,29 +31,39 @@ import com.android.intentresolver.chooser.MultiDisplayResolveInfo; * Shows individual actions for a "stacked" app target - such as an app with multiple posting * streams represented in the Sharesheet. */ -public class ChooserStackedAppDialogFragment extends ChooserTargetActionsDialogFragment - implements DialogInterface.OnClickListener { - - static final String WHICH_KEY = "which_key"; - static final String MULTI_DRI_KEY = "multi_dri_key"; - - private MultiDisplayResolveInfo mMultiDisplayResolveInfo; - private int mParentWhich; - - public ChooserStackedAppDialogFragment() {} +public class ChooserStackedAppDialogFragment extends ChooserTargetActionsDialogFragment { - void setStateFromBundle(Bundle b) { - mMultiDisplayResolveInfo = (MultiDisplayResolveInfo) b.get(MULTI_DRI_KEY); - mTargetInfos = mMultiDisplayResolveInfo.getTargets(); - mUserHandle = (UserHandle) b.get(USER_HANDLE_KEY); - mParentWhich = b.getInt(WHICH_KEY); + /** + * Display a fragment for the user to select one of the members of a target "stack." + * @param stackedTarget The display info for the full stack to select within. + * @param stackedTargetParentWhich The "which" value that the {@link ChooserActivity} uses to + * identify the {@code stackedTarget} as presented in the chooser menu UI. If the user selects + * a target in this fragment, the selection will be saved in the {@link MultiDisplayResolveInfo} + * and then the {@link ChooserActivity} will receive a {@code #startSelected()} callback using + * this "which" value to identify the stack that's now unambiguously resolved. + * @param userHandle + * + * TODO: consider taking a client-provided callback instead of {@code stackedTargetParentWhich} + * to avoid coupling with {@link ChooserActivity}'s mechanism for handling the selection. + */ + public static void show( + FragmentManager fragmentManager, + MultiDisplayResolveInfo stackedTarget, + int stackedTargetParentWhich, + UserHandle userHandle) { + ChooserStackedAppDialogFragment fragment = new ChooserStackedAppDialogFragment( + stackedTarget, stackedTargetParentWhich, userHandle); + fragment.show(fragmentManager, TARGET_DETAILS_FRAGMENT_TAG); } + private final MultiDisplayResolveInfo mMultiDisplayResolveInfo; + private final int mParentWhich; + @Override - public void onSaveInstanceState(Bundle outState) { - super.onSaveInstanceState(outState); - outState.putInt(WHICH_KEY, mParentWhich); - outState.putParcelable(MULTI_DRI_KEY, mMultiDisplayResolveInfo); + public void onClick(DialogInterface dialog, int which) { + mMultiDisplayResolveInfo.setSelected(which); + ((ChooserActivity) getActivity()).startSelected(mParentWhich, false, true); + dismiss(); } @Override @@ -63,15 +74,16 @@ public class ChooserStackedAppDialogFragment extends ChooserTargetActionsDialogF @Override protected Drawable getItemIcon(DisplayResolveInfo dri) { - // Show no icon for the group disambig dialog, null hides the imageview return null; } - @Override - public void onClick(DialogInterface dialog, int which) { - mMultiDisplayResolveInfo.setSelected(which); - ((ChooserActivity) getActivity()).startSelected(mParentWhich, false, true); - dismiss(); + private ChooserStackedAppDialogFragment( + MultiDisplayResolveInfo stackedTarget, + int stackedTargetParentWhich, + UserHandle userHandle) { + super(stackedTarget.getAllDisplayTargets(), userHandle); + mMultiDisplayResolveInfo = stackedTarget; + mParentWhich = stackedTargetParentWhich; } } diff --git a/java/src/com/android/intentresolver/ChooserTargetActionsDialogFragment.java b/java/src/com/android/intentresolver/ChooserTargetActionsDialogFragment.java index ffd173c7..0aa32505 100644 --- a/java/src/com/android/intentresolver/ChooserTargetActionsDialogFragment.java +++ b/java/src/com/android/intentresolver/ChooserTargetActionsDialogFragment.java @@ -19,15 +19,12 @@ package com.android.intentresolver; import static android.content.Context.ACTIVITY_SERVICE; -import static com.android.intentresolver.ResolverListAdapter.ResolveInfoPresentationGetter; - import static java.util.stream.Collectors.toList; import android.annotation.NonNull; import android.annotation.Nullable; import android.app.ActivityManager; import android.app.Dialog; -import android.app.DialogFragment; import android.content.ComponentName; import android.content.Context; import android.content.DialogInterface; @@ -49,11 +46,12 @@ import android.view.ViewGroup; import android.widget.ImageView; import android.widget.TextView; -import com.android.intentresolver.chooser.DisplayResolveInfo; +import androidx.fragment.app.DialogFragment; +import androidx.fragment.app.FragmentManager; +import androidx.recyclerview.widget.RecyclerView; -import com.android.internal.widget.RecyclerView; +import com.android.intentresolver.chooser.DisplayResolveInfo; -import java.util.ArrayList; import java.util.List; import java.util.Optional; import java.util.stream.Collectors; @@ -64,68 +62,61 @@ import java.util.stream.Collectors; public class ChooserTargetActionsDialogFragment extends DialogFragment implements DialogInterface.OnClickListener { - protected ArrayList<DisplayResolveInfo> mTargetInfos = new ArrayList<>(); - protected UserHandle mUserHandle; - protected String mShortcutId; - protected String mShortcutTitle; - protected boolean mIsShortcutPinned; - protected IntentFilter mIntentFilter; + protected final static String TARGET_DETAILS_FRAGMENT_TAG = "targetDetailsFragment"; + + private final List<DisplayResolveInfo> mTargetInfos; + private final UserHandle mUserHandle; + private final boolean mIsShortcutPinned; + + @Nullable + private final String mShortcutId; - public static final String USER_HANDLE_KEY = "user_handle"; - public static final String TARGET_INFOS_KEY = "target_infos"; - public static final String SHORTCUT_ID_KEY = "shortcut_id"; - public static final String SHORTCUT_TITLE_KEY = "shortcut_title"; - public static final String IS_SHORTCUT_PINNED_KEY = "is_shortcut_pinned"; - public static final String INTENT_FILTER_KEY = "intent_filter"; + @Nullable + private final String mShortcutTitle; - public ChooserTargetActionsDialogFragment() {} + @Nullable + private final IntentFilter mIntentFilter; + + public static void show( + FragmentManager fragmentManager, + List<DisplayResolveInfo> targetInfos, + UserHandle userHandle, + @Nullable String shortcutId, + @Nullable String shortcutTitle, + boolean isShortcutPinned, + @Nullable IntentFilter intentFilter) { + ChooserTargetActionsDialogFragment fragment = new ChooserTargetActionsDialogFragment( + targetInfos, + userHandle, + shortcutId, + shortcutTitle, + isShortcutPinned, + intentFilter); + fragment.show(fragmentManager, TARGET_DETAILS_FRAGMENT_TAG); + } @Override public void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); + if (savedInstanceState != null) { - setStateFromBundle(savedInstanceState); - } else { - setStateFromBundle(getArguments()); + // Bail. It's probably not possible to trigger reloading our fragments from a saved + // instance since Sharesheet isn't kept in history and the entire session will probably + // be lost under any conditions that would've triggered our retention. Nevertheless, if + // we ever *did* try to load from a saved state, we wouldn't be able to populate valid + // data (since we wouldn't be able to get back our original TargetInfos if we had to + // restore them from a Bundle). + dismissAllowingStateLoss(); } } - void setStateFromBundle(Bundle b) { - mTargetInfos = (ArrayList<DisplayResolveInfo>) b.get(TARGET_INFOS_KEY); - mUserHandle = (UserHandle) b.get(USER_HANDLE_KEY); - mShortcutId = b.getString(SHORTCUT_ID_KEY); - mShortcutTitle = b.getString(SHORTCUT_TITLE_KEY); - mIsShortcutPinned = b.getBoolean(IS_SHORTCUT_PINNED_KEY); - mIntentFilter = (IntentFilter) b.get(INTENT_FILTER_KEY); - } - - @Override - public void onSaveInstanceState(Bundle outState) { - super.onSaveInstanceState(outState); - - outState.putParcelable(ChooserTargetActionsDialogFragment.USER_HANDLE_KEY, - mUserHandle); - outState.putParcelableArrayList(ChooserTargetActionsDialogFragment.TARGET_INFOS_KEY, - mTargetInfos); - outState.putString(ChooserTargetActionsDialogFragment.SHORTCUT_ID_KEY, mShortcutId); - outState.putBoolean(ChooserTargetActionsDialogFragment.IS_SHORTCUT_PINNED_KEY, - mIsShortcutPinned); - outState.putString(ChooserTargetActionsDialogFragment.SHORTCUT_TITLE_KEY, mShortcutTitle); - outState.putParcelable(ChooserTargetActionsDialogFragment.INTENT_FILTER_KEY, mIntentFilter); - } - /** - * Recreate the layout from scratch to match new Sharesheet redlines + * Build the menu UI according to our design spec. */ @Override public View onCreateView(LayoutInflater inflater, @Nullable ViewGroup container, Bundle savedInstanceState) { - if (savedInstanceState != null) { - setStateFromBundle(savedInstanceState); - } else { - setStateFromBundle(getArguments()); - } // Make the background transparent to show dialog rounding Optional.of(getDialog()).map(Dialog::getWindow) .ifPresent(window -> { @@ -143,7 +134,7 @@ public class ChooserTargetActionsDialogFragment extends DialogFragment ImageView icon = v.findViewById(com.android.internal.R.id.icon); RecyclerView rv = v.findViewById(com.android.internal.R.id.listContainer); - final ResolveInfoPresentationGetter pg = getProvidingAppPresentationGetter(); + final TargetPresentationGetter pg = getProvidingAppPresentationGetter(); title.setText(isShortcutTarget() ? mShortcutTitle : pg.getLabel()); icon.setImageDrawable(pg.getIcon(mUserHandle)); rv.setAdapter(new VHAdapter(items)); @@ -277,14 +268,14 @@ public class ChooserTargetActionsDialogFragment extends DialogFragment return getPinIcon(isPinned(dri)); } - private ResolveInfoPresentationGetter getProvidingAppPresentationGetter() { + private TargetPresentationGetter getProvidingAppPresentationGetter() { final ActivityManager am = (ActivityManager) getContext() .getSystemService(ACTIVITY_SERVICE); final int iconDpi = am.getLauncherLargeIconDensity(); // Use the matching application icon and label for the title, any TargetInfo will do - return new ResolveInfoPresentationGetter(getContext(), iconDpi, - mTargetInfos.get(0).getResolveInfo()); + return new TargetPresentationGetter.Factory(getContext(), iconDpi) + .makePresentationGetter(mTargetInfos.get(0).getResolveInfo()); } private boolean isPinned(DisplayResolveInfo dri) { @@ -294,4 +285,24 @@ public class ChooserTargetActionsDialogFragment extends DialogFragment private boolean isShortcutTarget() { return mShortcutId != null; } + + protected ChooserTargetActionsDialogFragment( + List<DisplayResolveInfo> targetInfos, UserHandle userHandle) { + this(targetInfos, userHandle, null, null, false, null); + } + + private ChooserTargetActionsDialogFragment( + List<DisplayResolveInfo> targetInfos, + UserHandle userHandle, + @Nullable String shortcutId, + @Nullable String shortcutTitle, + boolean isShortcutPinned, + @Nullable IntentFilter intentFilter) { + mTargetInfos = targetInfos; + mUserHandle = userHandle; + mShortcutId = shortcutId; + mShortcutTitle = shortcutTitle; + mIsShortcutPinned = isShortcutPinned; + mIntentFilter = intentFilter; + } } diff --git a/java/src/com/android/intentresolver/EnterTransitionAnimationDelegate.kt b/java/src/com/android/intentresolver/EnterTransitionAnimationDelegate.kt new file mode 100644 index 00000000..a0bf61b6 --- /dev/null +++ b/java/src/com/android/intentresolver/EnterTransitionAnimationDelegate.kt @@ -0,0 +1,107 @@ +/* + * 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.app.Activity +import android.app.SharedElementCallback +import android.view.View +import com.android.intentresolver.widget.ResolverDrawerLayout +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 + private var previewReady = false + private var offsetCalculated = false + + init { + activity.setEnterSharedElementCallback( + object : SharedElementCallback() { + override fun onMapSharedElements( + names: MutableList<String>, sharedElements: MutableMap<String, View> + ) { + this@EnterTransitionAnimationDelegate.onMapSharedElements( + names, sharedElements + ) + } + }) + } + + fun postponeTransition() = activity.postponeEnterTransition() + + fun markImagePreviewReady(runTransitionAnimation: Boolean) { + if (!runTransitionAnimation) { + removeSharedElements = true + } + if (!previewReady) { + previewReady = true + maybeStartListenForLayout() + } + } + + fun markOffsetCalculated() { + if (!offsetCalculated) { + offsetCalculated = true + maybeStartListenForLayout() + } + } + + private fun onMapSharedElements( + 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 + } + + private fun maybeStartListenForLayout() { + val drawer = resolverDrawerLayoutSupplier.get() + if (previewReady && offsetCalculated && drawer != null) { + if (drawer.isInLayout) { + startPostponedEnterTransition() + } else { + drawer.addOnLayoutChangeListener(this) + drawer.requestLayout() + } + } + } + + override fun onLayoutChange( + v: View, + left: Int, top: Int, right: Int, bottom: Int, + oldLeft: Int, oldTop: Int, oldRight: Int, oldBottom: Int + ) { + v.removeOnLayoutChangeListener(this) + startPostponedEnterTransition() + } + + private fun startPostponedEnterTransition() { + if (!removeSharedElements && activity.isActivityTransitionRunning) { + // Disable the window animations as it interferes with the transition animation. + activity.window.setWindowAnimations(0) + } + activity.startPostponedEnterTransition() + } +} diff --git a/java/src/com/android/intentresolver/GenericMultiProfilePagerAdapter.java b/java/src/com/android/intentresolver/GenericMultiProfilePagerAdapter.java new file mode 100644 index 00000000..9bbdf7c7 --- /dev/null +++ b/java/src/com/android/intentresolver/GenericMultiProfilePagerAdapter.java @@ -0,0 +1,225 @@ +/* + * 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.content.Context; +import android.os.UserHandle; +import android.view.View; +import android.view.ViewGroup; + +import com.android.internal.annotations.VisibleForTesting; + +import com.google.common.collect.ImmutableList; + +import java.util.Optional; +import java.util.function.Function; +import java.util.function.Supplier; + +/** + * Implementation of {@link AbstractMultiProfilePagerAdapter} that consolidates the variation in + * existing implementations; most overrides were only to vary type signatures (which are better + * represented via generic types), and a few minor behavioral customizations are now implemented + * through small injectable delegate classes. + * TODO: now that the existing implementations are shown to be expressible in terms of this new + * generic type, merge up into the base class and simplify the public APIs. + * TODO: attempt to further restrict visibility in the methods we expose. + * TODO: deprecate and audit/fix usages of any methods that refer to the "active" or "inactive" + * adapters; these were marked {@link VisibleForTesting} and their usage seems like an accident + * waiting to happen since clients seem to make assumptions about which adapter will be "active" in + * a particular context, and more explicit APIs would make sure those were valid. + * TODO: consider renaming legacy methods (e.g. why do we know it's a "list", not just a "page"?) + * + * @param <PageViewT> the type of the widget that represents the contents of a page in this adapter + * @param <SinglePageAdapterT> the type of a "root" adapter class to be instantiated and included in + * the per-profile records. + * @param <ListAdapterT> the concrete type of a {@link ResolverListAdapter} implementation to + * control the contents of a given per-profile list. This is provided for convenience, since it must + * be possible to get the list adapter from the page adapter via our {@link mListAdapterExtractor}. + * + * TODO: this class doesn't make any explicit usage of the {@link ResolverListAdapter} API, so the + * type constraint can probably be dropped once the API is merged upwards and cleaned. + */ +class GenericMultiProfilePagerAdapter< + PageViewT extends ViewGroup, + SinglePageAdapterT, + ListAdapterT extends ResolverListAdapter> extends AbstractMultiProfilePagerAdapter { + + /** Delegate to set up a given adapter and page view to be used together. */ + public interface AdapterBinder<PageViewT, SinglePageAdapterT> { + /** + * The given {@code view} will be associated with the given {@code adapter}. Do any work + * necessary to configure them compatibly, introduce them to each other, etc. + */ + void bind(PageViewT view, SinglePageAdapterT adapter); + } + + private final Function<SinglePageAdapterT, ListAdapterT> mListAdapterExtractor; + private final AdapterBinder<PageViewT, SinglePageAdapterT> mAdapterBinder; + private final Supplier<ViewGroup> mPageViewInflater; + private final Supplier<Optional<Integer>> mContainerBottomPaddingOverrideSupplier; + + private final ImmutableList<GenericProfileDescriptor<PageViewT, SinglePageAdapterT>> mItems; + + GenericMultiProfilePagerAdapter( + Context context, + Function<SinglePageAdapterT, ListAdapterT> listAdapterExtractor, + AdapterBinder<PageViewT, SinglePageAdapterT> adapterBinder, + ImmutableList<SinglePageAdapterT> adapters, + EmptyStateProvider emptyStateProvider, + QuietModeManager quietModeManager, + @Profile int defaultProfile, + UserHandle workProfileUserHandle, + Supplier<ViewGroup> pageViewInflater, + Supplier<Optional<Integer>> containerBottomPaddingOverrideSupplier) { + super( + context, + /* currentPage= */ defaultProfile, + emptyStateProvider, + quietModeManager, + workProfileUserHandle); + + mListAdapterExtractor = listAdapterExtractor; + mAdapterBinder = adapterBinder; + mPageViewInflater = pageViewInflater; + mContainerBottomPaddingOverrideSupplier = containerBottomPaddingOverrideSupplier; + + ImmutableList.Builder<GenericProfileDescriptor<PageViewT, SinglePageAdapterT>> items = + new ImmutableList.Builder<>(); + for (SinglePageAdapterT adapter : adapters) { + items.add(createProfileDescriptor(adapter)); + } + mItems = items.build(); + } + + private GenericProfileDescriptor<PageViewT, SinglePageAdapterT> + createProfileDescriptor(SinglePageAdapterT adapter) { + return new GenericProfileDescriptor<>(mPageViewInflater.get(), adapter); + } + + @Override + protected GenericProfileDescriptor<PageViewT, SinglePageAdapterT> getItem(int pageIndex) { + return mItems.get(pageIndex); + } + + @Override + public int getItemCount() { + return mItems.size(); + } + + public PageViewT getListViewForIndex(int index) { + return getItem(index).mView; + } + + @Override + @VisibleForTesting + public SinglePageAdapterT getAdapterForIndex(int index) { + return getItem(index).mAdapter; + } + + @Override + protected void setupListAdapter(int pageIndex) { + mAdapterBinder.bind(getListViewForIndex(pageIndex), getAdapterForIndex(pageIndex)); + } + + @Override + public ViewGroup instantiateItem(ViewGroup container, int position) { + setupListAdapter(position); + return super.instantiateItem(container, position); + } + + @Override + @Nullable + protected ListAdapterT getListAdapterForUserHandle(UserHandle userHandle) { + if (getActiveListAdapter().getUserHandle().equals(userHandle)) { + return getActiveListAdapter(); + } + if ((getInactiveListAdapter() != null) && getInactiveListAdapter().getUserHandle().equals( + userHandle)) { + return getInactiveListAdapter(); + } + return null; + } + + @Override + @VisibleForTesting + public ListAdapterT getActiveListAdapter() { + return mListAdapterExtractor.apply(getAdapterForIndex(getCurrentPage())); + } + + @Override + @VisibleForTesting + public ListAdapterT getInactiveListAdapter() { + if (getCount() < 2) { + return null; + } + return mListAdapterExtractor.apply(getAdapterForIndex(1 - getCurrentPage())); + } + + @Override + public ListAdapterT getPersonalListAdapter() { + return mListAdapterExtractor.apply(getAdapterForIndex(PROFILE_PERSONAL)); + } + + @Override + public ListAdapterT getWorkListAdapter() { + return mListAdapterExtractor.apply(getAdapterForIndex(PROFILE_WORK)); + } + + @Override + protected SinglePageAdapterT getCurrentRootAdapter() { + return getAdapterForIndex(getCurrentPage()); + } + + @Override + protected PageViewT getActiveAdapterView() { + return getListViewForIndex(getCurrentPage()); + } + + @Override + protected PageViewT getInactiveAdapterView() { + if (getCount() < 2) { + return null; + } + return getListViewForIndex(1 - getCurrentPage()); + } + + @Override + protected void setupContainerPadding(View container) { + Optional<Integer> bottomPaddingOverride = mContainerBottomPaddingOverrideSupplier.get(); + bottomPaddingOverride.ifPresent(paddingBottom -> + container.setPadding( + container.getPaddingLeft(), + container.getPaddingTop(), + container.getPaddingRight(), + paddingBottom)); + } + + // TODO: `ChooserActivity` also has a per-profile record type. Maybe the "multi-profile pager" + // should be the owner of all per-profile data (especially now that the API is generic)? + private static class GenericProfileDescriptor<PageViewT, SinglePageAdapterT> extends + ProfileDescriptor { + private final SinglePageAdapterT mAdapter; + private final PageViewT mView; + + GenericProfileDescriptor(ViewGroup rootView, SinglePageAdapterT adapter) { + super(rootView); + mAdapter = adapter; + mView = (PageViewT) rootView.findViewById(com.android.internal.R.id.resolver_list); + } + } +} diff --git a/java/src/com/android/intentresolver/ImagePreviewImageLoader.kt b/java/src/com/android/intentresolver/ImagePreviewImageLoader.kt new file mode 100644 index 00000000..e68eb66a --- /dev/null +++ b/java/src/com/android/intentresolver/ImagePreviewImageLoader.kt @@ -0,0 +1,38 @@ +/* + * 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 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) { + } + } + previewCoordinator.loadImage(uri, callback) + } +} diff --git a/java/src/com/android/intentresolver/IntentForwarderActivity.java b/java/src/com/android/intentresolver/IntentForwarderActivity.java index 9b853c95..78240250 100644 --- a/java/src/com/android/intentresolver/IntentForwarderActivity.java +++ b/java/src/com/android/intentresolver/IntentForwarderActivity.java @@ -28,7 +28,6 @@ import android.app.Activity; import android.app.ActivityThread; import android.app.AppGlobals; import android.app.admin.DevicePolicyManager; -import android.compat.annotation.UnsupportedAppUsage; import android.content.ComponentName; import android.content.ContentResolver; import android.content.Intent; @@ -38,7 +37,6 @@ import android.content.pm.PackageManager; import android.content.pm.ResolveInfo; import android.content.pm.UserInfo; import android.metrics.LogMaker; -import android.os.Build; import android.os.Bundle; import android.os.RemoteException; import android.os.UserHandle; @@ -65,7 +63,6 @@ import java.util.concurrent.Executors; * be passed in and out of a managed profile. */ public class IntentForwarderActivity extends Activity { - @UnsupportedAppUsage(maxTargetSdk = Build.VERSION_CODES.R, trackingBug = 170729553) public static String TAG = "IntentForwarderActivity"; public static String FORWARD_INTENT_TO_PARENT diff --git a/java/src/com/android/intentresolver/NoAppsAvailableEmptyStateProvider.java b/java/src/com/android/intentresolver/NoAppsAvailableEmptyStateProvider.java new file mode 100644 index 00000000..5bf994d6 --- /dev/null +++ b/java/src/com/android/intentresolver/NoAppsAvailableEmptyStateProvider.java @@ -0,0 +1,154 @@ +/* + * 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 android.app.admin.DevicePolicyResources.Strings.Core.RESOLVER_NO_PERSONAL_APPS; +import static android.app.admin.DevicePolicyResources.Strings.Core.RESOLVER_NO_WORK_APPS; + +import android.annotation.NonNull; +import android.annotation.Nullable; +import android.app.admin.DevicePolicyEventLogger; +import android.app.admin.DevicePolicyManager; +import android.content.Context; +import android.content.pm.ResolveInfo; +import android.os.UserHandle; +import android.stats.devicepolicy.nano.DevicePolicyEnums; + +import com.android.intentresolver.AbstractMultiProfilePagerAdapter.EmptyState; +import com.android.intentresolver.AbstractMultiProfilePagerAdapter.EmptyStateProvider; +import com.android.intentresolver.AbstractMultiProfilePagerAdapter.MyUserIdProvider; +import com.android.internal.R; + +import java.util.List; + +/** + * Chooser/ResolverActivity empty state provider that returns empty state which is shown when + * there are no apps available. + */ +public class NoAppsAvailableEmptyStateProvider implements EmptyStateProvider { + + @NonNull + private final Context mContext; + @Nullable + private final UserHandle mWorkProfileUserHandle; + @Nullable + private final UserHandle mPersonalProfileUserHandle; + @NonNull + private final String mMetricsCategory; + @NonNull + private final MyUserIdProvider mMyUserIdProvider; + + public NoAppsAvailableEmptyStateProvider(Context context, UserHandle workProfileUserHandle, + UserHandle personalProfileUserHandle, String metricsCategory, + MyUserIdProvider myUserIdProvider) { + mContext = context; + mWorkProfileUserHandle = workProfileUserHandle; + mPersonalProfileUserHandle = personalProfileUserHandle; + mMetricsCategory = metricsCategory; + mMyUserIdProvider = myUserIdProvider; + } + + @Nullable + @Override + @SuppressWarnings("ReferenceEquality") + public EmptyState getEmptyState(ResolverListAdapter resolverListAdapter) { + UserHandle listUserHandle = resolverListAdapter.getUserHandle(); + + if (mWorkProfileUserHandle != null + && (mMyUserIdProvider.getMyUserId() == listUserHandle.getIdentifier() + || !hasAppsInOtherProfile(resolverListAdapter))) { + + String title; + if (listUserHandle == mPersonalProfileUserHandle) { + title = mContext.getSystemService( + DevicePolicyManager.class).getResources().getString( + RESOLVER_NO_PERSONAL_APPS, + () -> mContext.getString(R.string.resolver_no_personal_apps_available)); + } else { + title = mContext.getSystemService( + DevicePolicyManager.class).getResources().getString( + RESOLVER_NO_WORK_APPS, + () -> mContext.getString(R.string.resolver_no_work_apps_available)); + } + + return new NoAppsAvailableEmptyState( + title, mMetricsCategory, + /* isPersonalProfile= */ listUserHandle == mPersonalProfileUserHandle + ); + } else if (mWorkProfileUserHandle == null) { + // Return default empty state without tracking + return new DefaultEmptyState(); + } + + return null; + } + + private boolean hasAppsInOtherProfile(ResolverListAdapter adapter) { + if (mWorkProfileUserHandle == null) { + return false; + } + List<ResolverActivity.ResolvedComponentInfo> resolversForIntent = + adapter.getResolversForUser(UserHandle.of(mMyUserIdProvider.getMyUserId())); + for (ResolverActivity.ResolvedComponentInfo info : resolversForIntent) { + ResolveInfo resolveInfo = info.getResolveInfoAt(0); + if (resolveInfo.targetUserId != UserHandle.USER_CURRENT) { + return true; + } + } + return false; + } + + public static class DefaultEmptyState implements EmptyState { + @Override + public boolean useDefaultEmptyView() { + return true; + } + } + + public static class NoAppsAvailableEmptyState implements EmptyState { + + @NonNull + private String mTitle; + + @NonNull + private String mMetricsCategory; + + private boolean mIsPersonalProfile; + + public NoAppsAvailableEmptyState(String title, String metricsCategory, + boolean isPersonalProfile) { + mTitle = title; + mMetricsCategory = metricsCategory; + mIsPersonalProfile = isPersonalProfile; + } + + @Nullable + @Override + public String getTitle() { + return mTitle; + } + + @Override + public void onEmptyStateShown() { + DevicePolicyEventLogger.createEvent( + DevicePolicyEnums.RESOLVER_EMPTY_STATE_NO_APPS_RESOLVED) + .setStrings(mMetricsCategory) + .setBoolean(/*isPersonalProfile*/ mIsPersonalProfile) + .write(); + } + } +}
\ No newline at end of file diff --git a/java/src/com/android/intentresolver/NoCrossProfileEmptyStateProvider.java b/java/src/com/android/intentresolver/NoCrossProfileEmptyStateProvider.java new file mode 100644 index 00000000..420d26c5 --- /dev/null +++ b/java/src/com/android/intentresolver/NoCrossProfileEmptyStateProvider.java @@ -0,0 +1,137 @@ +/* + * 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.NonNull; +import android.annotation.Nullable; +import android.annotation.StringRes; +import android.app.admin.DevicePolicyEventLogger; +import android.app.admin.DevicePolicyManager; +import android.content.Context; +import android.os.UserHandle; + +import com.android.intentresolver.AbstractMultiProfilePagerAdapter.CrossProfileIntentsChecker; +import com.android.intentresolver.AbstractMultiProfilePagerAdapter.EmptyState; +import com.android.intentresolver.AbstractMultiProfilePagerAdapter.EmptyStateProvider; +import com.android.intentresolver.AbstractMultiProfilePagerAdapter.MyUserIdProvider; + +/** + * Empty state provider that does not allow cross profile sharing, it will return a blocker + * in case if the profile of the current tab is not the same as the profile of the calling app. + */ +public class NoCrossProfileEmptyStateProvider implements EmptyStateProvider { + + private final UserHandle mPersonalProfileUserHandle; + private final EmptyState mNoWorkToPersonalEmptyState; + private final EmptyState mNoPersonalToWorkEmptyState; + private final CrossProfileIntentsChecker mCrossProfileIntentsChecker; + private final MyUserIdProvider mUserIdProvider; + + public NoCrossProfileEmptyStateProvider(UserHandle personalUserHandle, + EmptyState noWorkToPersonalEmptyState, + EmptyState noPersonalToWorkEmptyState, + CrossProfileIntentsChecker crossProfileIntentsChecker, + MyUserIdProvider myUserIdProvider) { + mPersonalProfileUserHandle = personalUserHandle; + mNoWorkToPersonalEmptyState = noWorkToPersonalEmptyState; + mNoPersonalToWorkEmptyState = noPersonalToWorkEmptyState; + mCrossProfileIntentsChecker = crossProfileIntentsChecker; + mUserIdProvider = myUserIdProvider; + } + + @Nullable + @Override + public EmptyState getEmptyState(ResolverListAdapter resolverListAdapter) { + boolean shouldShowBlocker = + mUserIdProvider.getMyUserId() != resolverListAdapter.getUserHandle().getIdentifier() + && !mCrossProfileIntentsChecker + .hasCrossProfileIntents(resolverListAdapter.getIntents(), + mUserIdProvider.getMyUserId(), + resolverListAdapter.getUserHandle().getIdentifier()); + + if (!shouldShowBlocker) { + return null; + } + + if (resolverListAdapter.getUserHandle().equals(mPersonalProfileUserHandle)) { + return mNoWorkToPersonalEmptyState; + } else { + return mNoPersonalToWorkEmptyState; + } + } + + + /** + * Empty state that gets strings from the device policy manager and tracks events into + * event logger of the device policy events. + */ + public static class DevicePolicyBlockerEmptyState implements EmptyState { + + @NonNull + private final Context mContext; + private final String mDevicePolicyStringTitleId; + @StringRes + private final int mDefaultTitleResource; + private final String mDevicePolicyStringSubtitleId; + @StringRes + private final int mDefaultSubtitleResource; + private final int mEventId; + @NonNull + private final String mEventCategory; + + public DevicePolicyBlockerEmptyState(Context context, String devicePolicyStringTitleId, + @StringRes int defaultTitleResource, String devicePolicyStringSubtitleId, + @StringRes int defaultSubtitleResource, + int devicePolicyEventId, String devicePolicyEventCategory) { + mContext = context; + mDevicePolicyStringTitleId = devicePolicyStringTitleId; + mDefaultTitleResource = defaultTitleResource; + mDevicePolicyStringSubtitleId = devicePolicyStringSubtitleId; + mDefaultSubtitleResource = defaultSubtitleResource; + mEventId = devicePolicyEventId; + mEventCategory = devicePolicyEventCategory; + } + + @Nullable + @Override + public String getTitle() { + return mContext.getSystemService(DevicePolicyManager.class).getResources().getString( + mDevicePolicyStringTitleId, + () -> mContext.getString(mDefaultTitleResource)); + } + + @Nullable + @Override + public String getSubtitle() { + return mContext.getSystemService(DevicePolicyManager.class).getResources().getString( + mDevicePolicyStringSubtitleId, + () -> mContext.getString(mDefaultSubtitleResource)); + } + + @Override + public void onEmptyStateShown() { + DevicePolicyEventLogger.createEvent(mEventId) + .setStrings(mEventCategory) + .write(); + } + + @Override + public boolean shouldSkipDataRebuild() { + return true; + } + } +} diff --git a/java/src/com/android/intentresolver/ResolverActivity.java b/java/src/com/android/intentresolver/ResolverActivity.java index 453a6e84..5573e18a 100644 --- a/java/src/com/android/intentresolver/ResolverActivity.java +++ b/java/src/com/android/intentresolver/ResolverActivity.java @@ -19,6 +19,9 @@ package com.android.intentresolver; import static android.Manifest.permission.INTERACT_ACROSS_PROFILES; import static android.app.admin.DevicePolicyResources.Strings.Core.FORWARD_INTENT_TO_PERSONAL; import static android.app.admin.DevicePolicyResources.Strings.Core.FORWARD_INTENT_TO_WORK; +import static android.app.admin.DevicePolicyResources.Strings.Core.RESOLVER_CANT_ACCESS_PERSONAL; +import static android.app.admin.DevicePolicyResources.Strings.Core.RESOLVER_CANT_ACCESS_WORK; +import static android.app.admin.DevicePolicyResources.Strings.Core.RESOLVER_CROSS_PROFILE_BLOCKED_TITLE; import static android.app.admin.DevicePolicyResources.Strings.Core.RESOLVER_PERSONAL_TAB; import static android.app.admin.DevicePolicyResources.Strings.Core.RESOLVER_PERSONAL_TAB_ACCESSIBILITY; import static android.app.admin.DevicePolicyResources.Strings.Core.RESOLVER_WORK_PROFILE_NOT_SUPPORTED; @@ -26,6 +29,8 @@ import static android.app.admin.DevicePolicyResources.Strings.Core.RESOLVER_WORK import static android.app.admin.DevicePolicyResources.Strings.Core.RESOLVER_WORK_TAB_ACCESSIBILITY; import static android.content.Intent.FLAG_ACTIVITY_NEW_TASK; import static android.content.PermissionChecker.PID_UNKNOWN; +import static android.stats.devicepolicy.nano.DevicePolicyEnums.RESOLVER_EMPTY_STATE_NO_SHARING_TO_PERSONAL; +import static android.stats.devicepolicy.nano.DevicePolicyEnums.RESOLVER_EMPTY_STATE_NO_SHARING_TO_WORK; import static android.view.WindowManager.LayoutParams.SYSTEM_FLAG_HIDE_NON_SYSTEM_OVERLAY_WINDOWS; import android.annotation.Nullable; @@ -39,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.compat.annotation.UnsupportedAppUsage; import android.content.BroadcastReceiver; import android.content.ComponentName; import android.content.Context; @@ -55,7 +59,9 @@ import android.content.pm.UserInfo; import android.content.res.Configuration; 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; @@ -90,18 +96,25 @@ import android.widget.TabWidget; import android.widget.TextView; import android.widget.Toast; +import androidx.fragment.app.FragmentActivity; +import androidx.viewpager.widget.ViewPager; + +import com.android.intentresolver.AbstractMultiProfilePagerAdapter.CompositeEmptyStateProvider; +import com.android.intentresolver.AbstractMultiProfilePagerAdapter.CrossProfileIntentsChecker; +import com.android.intentresolver.AbstractMultiProfilePagerAdapter.EmptyStateProvider; +import com.android.intentresolver.AbstractMultiProfilePagerAdapter.MyUserIdProvider; +import com.android.intentresolver.AbstractMultiProfilePagerAdapter.OnSwitchOnWorkSelectedListener; import com.android.intentresolver.AbstractMultiProfilePagerAdapter.Profile; -import com.android.intentresolver.chooser.ChooserTargetInfo; +import com.android.intentresolver.AbstractMultiProfilePagerAdapter.QuietModeManager; +import com.android.intentresolver.NoCrossProfileEmptyStateProvider.DevicePolicyBlockerEmptyState; import com.android.intentresolver.chooser.DisplayResolveInfo; import com.android.intentresolver.chooser.TargetInfo; - +import com.android.intentresolver.widget.ResolverDrawerLayout; import com.android.internal.annotations.VisibleForTesting; import com.android.internal.content.PackageMonitor; import com.android.internal.logging.MetricsLogger; import com.android.internal.logging.nano.MetricsProto; import com.android.internal.util.LatencyTracker; -import com.android.internal.widget.ResolverDrawerLayout; -import com.android.internal.widget.ViewPager; import java.util.ArrayList; import java.util.Arrays; @@ -109,6 +122,7 @@ import java.util.Iterator; import java.util.List; import java.util.Objects; import java.util.Set; +import java.util.function.Supplier; /** * This activity is displayed when the system attempts to start an Intent for @@ -116,10 +130,9 @@ import java.util.Set; * which to go to. It is not normally used directly by application developers. */ @UiThread -public class ResolverActivity extends Activity implements +public class ResolverActivity extends FragmentActivity implements ResolverListAdapter.ResolverListCommunicator { - @UnsupportedAppUsage public ResolverActivity() { mIsIntentPicker = getClass().equals(ResolverActivity.class); } @@ -149,7 +162,6 @@ public class ResolverActivity extends Activity implements @VisibleForTesting protected boolean mSupportsAlwaysUseOption; protected ResolverDrawerLayout mResolverDrawerLayout; - @UnsupportedAppUsage protected PackageManager mPm; protected int mLaunchedFromUid; @@ -165,17 +177,12 @@ public class ResolverActivity extends Activity implements /** See {@link #setRetainInOnStop}. */ private boolean mRetainInOnStop; - private static final String EXTRA_SHOW_FRAGMENT_ARGS = ":settings:show_fragment_args"; - private static final String EXTRA_FRAGMENT_ARG_KEY = ":settings:fragment_args_key"; - private static final String OPEN_LINKS_COMPONENT_KEY = "app_link_state"; protected static final String METRICS_CATEGORY_RESOLVER = "intent_resolver"; protected static final String METRICS_CATEGORY_CHOOSER = "intent_chooser"; /** Tracks if we should ignore future broadcasts telling us the work profile is enabled */ private boolean mWorkProfileHasBeenEnabled = false; - @VisibleForTesting - public static boolean ENABLE_TABBED_VIEW = true; private static final String TAB_TAG_PERSONAL = "personal"; private static final String TAB_TAG_WORK = "work"; @@ -185,6 +192,8 @@ public class ResolverActivity extends Activity implements @VisibleForTesting protected AbstractMultiProfilePagerAdapter mMultiProfilePagerAdapter; + protected QuietModeManager mQuietModeManager; + // Intent extra for connected audio devices public static final String EXTRA_IS_AUDIO_CAPTURE_DEVICE = "is_audio_capture_device"; @@ -214,7 +223,14 @@ public class ResolverActivity extends Activity implements private BroadcastReceiver mWorkProfileStateReceiver; private UserHandle mHeaderCreatorUser; - private UserHandle mWorkProfileUserHandle; + private Supplier<UserHandle> mLazyWorkProfileUserHandle = () -> { + final UserHandle result = fetchWorkProfileUserProfile(); + mLazyWorkProfileUserHandle = () -> result; + return result; + }; + + @Nullable + private OnSwitchOnWorkSelectedListener mOnSwitchOnWorkSelectedListener; protected final LatencyTracker mLatencyTracker = getLatencyTracker(); @@ -360,7 +376,6 @@ public class ResolverActivity extends Activity implements * Compatibility version for other bundled services that use this overload without * a default title resource */ - @UnsupportedAppUsage protected void onCreate(Bundle savedInstanceState, Intent intent, CharSequence title, Intent[] initialIntents, List<ResolveInfo> rList, boolean supportsAlwaysUseOption) { @@ -374,6 +389,8 @@ public class ResolverActivity extends Activity 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()); @@ -395,7 +412,6 @@ public class ResolverActivity extends Activity implements mDefaultTitleResId = defaultTitleRes; mSupportsAlwaysUseOption = supportsAlwaysUseOption; - mWorkProfileUserHandle = fetchWorkProfileUserProfile(); // The last argument of createResolverListAdapter is whether to do special handling // of the last used choice to highlight it in the list. We need to always @@ -474,6 +490,111 @@ public class ResolverActivity extends Activity 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()); + + if (!shouldShowNoCrossProfileIntentsEmptyState) { + // Implementation that doesn't show any blockers + return new EmptyStateProvider() {}; + } + + final AbstractMultiProfilePagerAdapter.EmptyState + noWorkToPersonalEmptyState = + new DevicePolicyBlockerEmptyState(/* context= */ this, + /* devicePolicyStringTitleId= */ RESOLVER_CROSS_PROFILE_BLOCKED_TITLE, + /* defaultTitleResource= */ R.string.resolver_cross_profile_blocked, + /* devicePolicyStringSubtitleId= */ RESOLVER_CANT_ACCESS_PERSONAL, + /* defaultSubtitleResource= */ + R.string.resolver_cant_access_personal_apps_explanation, + /* devicePolicyEventId= */ RESOLVER_EMPTY_STATE_NO_SHARING_TO_PERSONAL, + /* devicePolicyEventCategory= */ ResolverActivity.METRICS_CATEGORY_RESOLVER); + + final AbstractMultiProfilePagerAdapter.EmptyState noPersonalToWorkEmptyState = + new DevicePolicyBlockerEmptyState(/* context= */ this, + /* devicePolicyStringTitleId= */ RESOLVER_CROSS_PROFILE_BLOCKED_TITLE, + /* defaultTitleResource= */ R.string.resolver_cross_profile_blocked, + /* devicePolicyStringSubtitleId= */ RESOLVER_CANT_ACCESS_WORK, + /* defaultSubtitleResource= */ + R.string.resolver_cant_access_work_apps_explanation, + /* devicePolicyEventId= */ RESOLVER_EMPTY_STATE_NO_SHARING_TO_WORK, + /* devicePolicyEventCategory= */ ResolverActivity.METRICS_CATEGORY_RESOLVER); + + return new NoCrossProfileEmptyStateProvider(getPersonalProfileUserHandle(), + noWorkToPersonalEmptyState, noPersonalToWorkEmptyState, + createCrossProfileIntentsChecker(), createMyUserIdProvider()); + } + + protected EmptyStateProvider createEmptyStateProvider( + @Nullable UserHandle workProfileUserHandle) { + final EmptyStateProvider blockerEmptyStateProvider = createBlockerEmptyStateProvider(); + + final EmptyStateProvider workProfileOffEmptyStateProvider = + new WorkProfilePausedEmptyStateProvider(this, workProfileUserHandle, + mQuietModeManager, + /* onSwitchOnWorkSelectedListener= */ + () -> { if (mOnSwitchOnWorkSelectedListener != null) { + mOnSwitchOnWorkSelectedListener.onSwitchOnWorkSelected(); + }}, + getMetricsCategory()); + + final EmptyStateProvider noAppsEmptyStateProvider = new NoAppsAvailableEmptyStateProvider( + this, + workProfileUserHandle, + getPersonalProfileUserHandle(), + getMetricsCategory(), + createMyUserIdProvider() + ); + + // Return composite provider, the order matters (the higher, the more priority) + return new CompositeEmptyStateProvider( + blockerEmptyStateProvider, + workProfileOffEmptyStateProvider, + noAppsEmptyStateProvider + ); + } + private ResolverMultiProfilePagerAdapter createResolverMultiProfilePagerAdapterForOneProfile( Intent[] initialIntents, List<ResolveInfo> rList, boolean filterLastUsed) { @@ -484,13 +605,21 @@ public class ResolverActivity extends Activity implements rList, filterLastUsed, /* userHandle */ UserHandle.of(UserHandle.myUserId())); + QuietModeManager quietModeManager = createQuietModeManager(); return new ResolverMultiProfilePagerAdapter( /* context */ this, adapter, - getPersonalProfileUserHandle(), + createEmptyStateProvider(/* workProfileUserHandle= */ null), + quietModeManager, /* workProfileUserHandle= */ null); } + private UserHandle getIntentUser() { + return getIntent().hasExtra(EXTRA_CALLING_USER) + ? getIntent().getParcelableExtra(EXTRA_CALLING_USER) + : getUser(); + } + private ResolverMultiProfilePagerAdapter createResolverMultiProfilePagerAdapterForTwoProfiles( Intent[] initialIntents, List<ResolveInfo> rList, @@ -499,9 +628,7 @@ public class ResolverActivity extends Activity implements // the intent resolver is started in the other profile. Since this is the only case when // this happens, we check for it here and set the current profile's tab. int selectedProfile = getCurrentProfile(); - UserHandle intentUser = getIntent().hasExtra(EXTRA_CALLING_USER) - ? getIntent().getParcelableExtra(EXTRA_CALLING_USER) - : getUser(); + UserHandle intentUser = getIntentUser(); if (!getUser().equals(intentUser)) { if (getPersonalProfileUserHandle().equals(intentUser)) { selectedProfile = PROFILE_PERSONAL; @@ -534,14 +661,15 @@ public class ResolverActivity extends Activity implements (filterLastUsed && UserHandle.myUserId() == workProfileUserHandle.getIdentifier()), /* userHandle */ workProfileUserHandle); + QuietModeManager quietModeManager = createQuietModeManager(); return new ResolverMultiProfilePagerAdapter( /* context */ this, personalAdapter, workAdapter, + createEmptyStateProvider(getWorkProfileUserHandle()), + quietModeManager, selectedProfile, - getPersonalProfileUserHandle(), - getWorkProfileUserHandle(), - /* shouldShowNoCrossProfileIntentsEmptyState= */ getUser().equals(intentUser)); + getWorkProfileUserHandle()); } protected int appliedThemeResId() { @@ -574,19 +702,25 @@ public class ResolverActivity extends Activity implements protected UserHandle getPersonalProfileUserHandle() { return UserHandle.of(ActivityManager.getCurrentUser()); } - protected @Nullable UserHandle getWorkProfileUserHandle() { - return mWorkProfileUserHandle; + + @Nullable + protected UserHandle getWorkProfileUserHandle() { + return mLazyWorkProfileUserHandle.get(); } - protected @Nullable UserHandle fetchWorkProfileUserProfile() { - mWorkProfileUserHandle = null; + @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()) { - mWorkProfileUserHandle = userInfo.getUserHandle(); + result = userInfo.getUserHandle(); } } - return mWorkProfileUserHandle; + return result; } private boolean hasWorkProfile() { @@ -594,7 +728,7 @@ public class ResolverActivity extends Activity implements } protected boolean shouldShowTabs() { - return hasWorkProfile() && ENABLE_TABBED_VIEW; + return hasWorkProfile(); } protected void onProfileClick(View v) { @@ -726,7 +860,6 @@ public class ResolverActivity extends Activity implements } } - @Override // SelectableTargetInfoCommunicator ResolverListCommunicator public Intent getTargetIntent() { return mIntents.isEmpty() ? null : mIntents.get(0); } @@ -848,9 +981,9 @@ public class ResolverActivity extends Activity implements } mRegistered = true; } - if (shouldShowTabs() && mMultiProfilePagerAdapter.isWaitingToEnableWorkProfile()) { - if (mMultiProfilePagerAdapter.isQuietModeEnabled(getWorkProfileUserHandle())) { - mMultiProfilePagerAdapter.markWorkProfileEnabledBroadcastReceived(); + if (shouldShowTabs() && mQuietModeManager.isWaitingToEnableWorkProfile()) { + if (mQuietModeManager.isQuietModeEnabled(getWorkProfileUserHandle())) { + mQuietModeManager.markWorkProfileEnabledBroadcastReceived(); } } mMultiProfilePagerAdapter.getActiveListAdapter().handlePackagesChanged(); @@ -1375,7 +1508,7 @@ public class ResolverActivity extends Activity implements .createEvent(DevicePolicyEnums.RESOLVER_CROSS_PROFILE_TARGET_OPENED) .setBoolean(currentUserHandle.equals(getPersonalProfileUserHandle())) .setStrings(getMetricsCategory(), - cti instanceof ChooserTargetInfo ? "direct_share" : "other_target") + cti.isInDirectShareMetricsCategory() ? "direct_share" : "other_target") .write(); } @@ -1407,8 +1540,16 @@ public class ResolverActivity extends Activity implements Intent startIntent = getIntent(); boolean isAudioCaptureDevice = startIntent.getBooleanExtra(EXTRA_IS_AUDIO_CAPTURE_DEVICE, false); - return new ResolverListAdapter(context, payloadIntents, initialIntents, rList, - filterLastUsed, createListController(userHandle), this, + return new ResolverListAdapter( + context, + payloadIntents, + initialIntents, + rList, + filterLastUsed, + createListController(userHandle), + userHandle, + getTargetIntent(), + this, isAudioCaptureDevice); } @@ -1472,17 +1613,25 @@ public class ResolverActivity extends Activity implements setContentView(mLayoutId); DisplayResolveInfo sameProfileResolveInfo = - mMultiProfilePagerAdapter.getActiveListAdapter().mDisplayList.get(0); + mMultiProfilePagerAdapter.getActiveListAdapter().getFirstDisplayResolveInfo(); boolean inWorkProfile = getCurrentProfile() == PROFILE_WORK; - ResolverListAdapter inactiveAdapter = mMultiProfilePagerAdapter.getInactiveListAdapter(); - DisplayResolveInfo otherProfileResolveInfo = inactiveAdapter.mDisplayList.get(0); + final ResolverListAdapter inactiveAdapter = + mMultiProfilePagerAdapter.getInactiveListAdapter(); + final DisplayResolveInfo otherProfileResolveInfo = + inactiveAdapter.getFirstDisplayResolveInfo(); // Load the icon asynchronously ImageView icon = findViewById(com.android.internal.R.id.icon); - ResolverListAdapter.LoadIconTask iconTask = inactiveAdapter.new LoadIconTask( - otherProfileResolveInfo, new ResolverListAdapter.ViewHolder(icon)); - iconTask.execute(); + inactiveAdapter.new LoadIconTask(otherProfileResolveInfo) { + @Override + protected void onPostExecute(Drawable drawable) { + if (!isDestroyed()) { + otherProfileResolveInfo.getDisplayIconHolder().setDisplayIcon(drawable); + new ResolverListAdapter.ViewHolder(icon).bindIcon(otherProfileResolveInfo); + } + } + }.execute(); ((TextView) findViewById(com.android.internal.R.id.open_cross_profile)).setText( getResources().getString( @@ -1521,31 +1670,29 @@ public class ResolverActivity extends Activity implements || mMultiProfilePagerAdapter.getInactiveListAdapter() == null) { return false; } - List<DisplayResolveInfo> sameProfileList = - mMultiProfilePagerAdapter.getActiveListAdapter().mDisplayList; - List<DisplayResolveInfo> otherProfileList = - mMultiProfilePagerAdapter.getInactiveListAdapter().mDisplayList; + ResolverListAdapter sameProfileAdapter = + mMultiProfilePagerAdapter.getActiveListAdapter(); + ResolverListAdapter otherProfileAdapter = + mMultiProfilePagerAdapter.getInactiveListAdapter(); - if (sameProfileList.isEmpty()) { + if (sameProfileAdapter.getDisplayResolveInfoCount() == 0) { Log.d(TAG, "No targets in the current profile"); return false; } - if (otherProfileList.size() != 1) { - Log.d(TAG, "Found " + otherProfileList.size() + " resolvers in the other profile"); + if (otherProfileAdapter.getDisplayResolveInfoCount() != 1) { + Log.d(TAG, "Other-profile count: " + otherProfileAdapter.getDisplayResolveInfoCount()); return false; } - if (otherProfileList.get(0).getResolveInfo().handleAllWebDataURI) { + if (otherProfileAdapter.allResolveInfosHandleAllWebDataUri()) { Log.d(TAG, "Other profile is a web browser"); return false; } - for (DisplayResolveInfo info : sameProfileList) { - if (!info.getResolveInfo().handleAllWebDataURI) { - Log.d(TAG, "Non-browser found in this profile"); - return false; - } + if (!sameProfileAdapter.allResolveInfosHandleAllWebDataUri()) { + Log.d(TAG, "Non-browser found in this profile"); + return false; } return true; @@ -1800,13 +1947,12 @@ public class ResolverActivity extends Activity implements onHorizontalSwipeStateChanged(state); } }); - mMultiProfilePagerAdapter.setOnSwitchOnWorkSelectedListener( - () -> { - final View workTab = tabHost.getTabWidget().getChildAt(1); - workTab.setFocusable(true); - workTab.setFocusableInTouchMode(true); - workTab.requestFocus(); - }); + mOnSwitchOnWorkSelectedListener = () -> { + final View workTab = tabHost.getTabWidget().getChildAt(1); + workTab.setFocusable(true); + workTab.setFocusableInTouchMode(true); + workTab.requestFocus(); + }; } private String getPersonalTabLabel() { @@ -2067,7 +2213,7 @@ public class ResolverActivity extends Activity implements public void onHandlePackagesChanged(ResolverListAdapter listAdapter) { if (listAdapter == mMultiProfilePagerAdapter.getActiveListAdapter()) { if (listAdapter.getUserHandle().equals(getWorkProfileUserHandle()) - && mMultiProfilePagerAdapter.isWaitingToEnableWorkProfile()) { + && 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 @@ -2119,7 +2265,7 @@ public class ResolverActivity extends Activity implements } mWorkProfileHasBeenEnabled = true; - mMultiProfilePagerAdapter.markWorkProfileEnabledBroadcastReceived(); + mQuietModeManager.markWorkProfileEnabledBroadcastReceived(); } else { // Must be an UNAVAILABLE broadcast, so we watch for the next availability mWorkProfileHasBeenEnabled = false; @@ -2135,13 +2281,11 @@ public class ResolverActivity extends Activity implements }; } - @VisibleForTesting 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; - private boolean mFixedAtTop; public ResolvedComponentInfo(ComponentName name, Intent intent, ResolveInfo info) { this.name = name; @@ -2190,14 +2334,6 @@ public class ResolverActivity extends Activity implements public void setPinned(boolean pinned) { mPinned = pinned; } - - public boolean isFixedAtTop() { - return mFixedAtTop; - } - - public void setFixedAtTop(boolean isFixedAtTop) { - mFixedAtTop = isFixedAtTop; - } } class ItemClickListener implements AdapterView.OnItemClickListener, @@ -2254,8 +2390,9 @@ public class ResolverActivity extends Activity implements } - static final boolean isSpecificUriMatch(int match) { - match = match&IntentFilter.MATCH_CATEGORY_MASK; + /** Determine whether a given match result is considered "specific" in our application. */ + public static final boolean isSpecificUriMatch(int match) { + match = (match & IntentFilter.MATCH_CATEGORY_MASK); return match >= IntentFilter.MATCH_CATEGORY_HOST && match <= IntentFilter.MATCH_CATEGORY_PATH; } diff --git a/java/src/com/android/intentresolver/ResolverListAdapter.java b/java/src/com/android/intentresolver/ResolverListAdapter.java index 898d8c8e..eecb914c 100644 --- a/java/src/com/android/intentresolver/ResolverListAdapter.java +++ b/java/src/com/android/intentresolver/ResolverListAdapter.java @@ -26,15 +26,11 @@ import android.content.Context; import android.content.Intent; import android.content.PermissionChecker; import android.content.pm.ActivityInfo; -import android.content.pm.ApplicationInfo; import android.content.pm.LabeledIntent; import android.content.pm.PackageManager; import android.content.pm.ResolveInfo; -import android.content.res.Resources; -import android.graphics.Bitmap; import android.graphics.ColorMatrix; import android.graphics.ColorMatrixColorFilter; -import android.graphics.drawable.BitmapDrawable; import android.graphics.drawable.Drawable; import android.os.AsyncTask; import android.os.RemoteException; @@ -54,44 +50,62 @@ 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; +import com.google.common.collect.ImmutableList; + import java.util.ArrayList; +import java.util.Collection; +import java.util.HashMap; import java.util.List; +import java.util.Map; public class ResolverListAdapter extends BaseAdapter { private static final String TAG = "ResolverListAdapter"; + @Nullable // TODO: other model for lazy computation? Or just precompute? + private static ColorMatrixColorFilter sSuspendedMatrixColorFilter; + + protected final Context mContext; + protected final LayoutInflater mInflater; + protected final ResolverListCommunicator mResolverListCommunicator; + protected final ResolverListController mResolverListController; + protected final TargetPresentationGetter.Factory mPresentationFactory; + private final List<Intent> mIntents; private final Intent[] mInitialIntents; private final List<ResolveInfo> mBaseResolveList; private final PackageManager mPm; - protected final Context mContext; - private static ColorMatrixColorFilter sSuspendedMatrixColorFilter; private final int mIconDpi; - protected ResolveInfo mLastChosen; + private final boolean mIsAudioCaptureDevice; + private final UserHandle mUserHandle; + private final Intent mTargetIntent; + + private final Map<DisplayResolveInfo, LoadIconTask> mIconLoaders = new HashMap<>(); + private final Map<DisplayResolveInfo, LoadLabelTask> mLabelLoaders = new HashMap<>(); + + private ResolveInfo mLastChosen; private DisplayResolveInfo mOtherProfile; - ResolverListController mResolverListController; private int mPlaceholderCount; - protected final LayoutInflater mInflater; - // This one is the list that the Adapter will actually present. - List<DisplayResolveInfo> mDisplayList; + private List<DisplayResolveInfo> mDisplayList; private List<ResolvedComponentInfo> mUnfilteredResolveList; private int mLastChosenPosition = -1; private boolean mFilterLastUsed; - final ResolverListCommunicator mResolverListCommunicator; private Runnable mPostListReadyRunnable; - private final boolean mIsAudioCaptureDevice; private boolean mIsTabLoaded; - public ResolverListAdapter(Context context, List<Intent> payloadIntents, - Intent[] initialIntents, List<ResolveInfo> rList, + public ResolverListAdapter( + Context context, + List<Intent> payloadIntents, + Intent[] initialIntents, + List<ResolveInfo> rList, boolean filterLastUsed, ResolverListController resolverListController, + UserHandle userHandle, + Intent targetIntent, ResolverListCommunicator resolverListCommunicator, boolean isAudioCaptureDevice) { mContext = context; @@ -103,10 +117,21 @@ public class ResolverListAdapter extends BaseAdapter { mDisplayList = new ArrayList<>(); mFilterLastUsed = filterLastUsed; mResolverListController = resolverListController; + mUserHandle = userHandle; + mTargetIntent = targetIntent; mResolverListCommunicator = resolverListCommunicator; mIsAudioCaptureDevice = isAudioCaptureDevice; final ActivityManager am = (ActivityManager) mContext.getSystemService(ACTIVITY_SERVICE); mIconDpi = am.getLauncherLargeIconDensity(); + mPresentationFactory = new TargetPresentationGetter.Factory(mContext, mIconDpi); + } + + public final DisplayResolveInfo getFirstDisplayResolveInfo() { + return mDisplayList.get(0); + } + + public final ImmutableList<DisplayResolveInfo> getTargetsInCurrentDisplayList() { + return ImmutableList.copyOf(mDisplayList); } public void handlePackagesChanged() { @@ -258,7 +283,7 @@ public class ResolverListAdapter extends BaseAdapter { if (mBaseResolveList != null) { List<ResolvedComponentInfo> currentResolveList = new ArrayList<>(); mResolverListController.addResolveListDedupe(currentResolveList, - mResolverListCommunicator.getTargetIntent(), + mTargetIntent, mBaseResolveList); return currentResolveList; } else { @@ -334,7 +359,12 @@ public class ResolverListAdapter extends BaseAdapter { if (otherProfileInfo != null) { mOtherProfile = makeOtherProfileDisplayResolveInfo( - mContext, otherProfileInfo, mPm, mResolverListCommunicator, mIconDpi); + mContext, + otherProfileInfo, + mPm, + mTargetIntent, + mResolverListCommunicator, + mIconDpi); } else { mOtherProfile = null; try { @@ -441,8 +471,13 @@ public class ResolverListAdapter extends BaseAdapter { ri.icon = 0; } - addResolveInfo(new DisplayResolveInfo(ii, ri, - ri.loadLabel(mPm), null, ii, makePresentationGetter(ri))); + addResolveInfo(DisplayResolveInfo.newDisplayResolveInfo( + ii, + ri, + ri.loadLabel(mPm), + null, + ii, + mPresentationFactory.makePresentationGetter(ri))); } } @@ -490,10 +525,12 @@ public class ResolverListAdapter extends BaseAdapter { final Intent replaceIntent = mResolverListCommunicator.getReplacementIntent(add.activityInfo, intent); final Intent defaultIntent = mResolverListCommunicator.getReplacementIntent( - add.activityInfo, mResolverListCommunicator.getTargetIntent()); - final DisplayResolveInfo - dri = new DisplayResolveInfo(intent, add, - replaceIntent != null ? replaceIntent : defaultIntent, makePresentationGetter(add)); + add.activityInfo, mTargetIntent); + final DisplayResolveInfo dri = DisplayResolveInfo.newDisplayResolveInfo( + intent, + add, + (replaceIntent != null) ? replaceIntent : defaultIntent, + mPresentationFactory.makePresentationGetter(add)); dri.setPinned(rci.isPinned()); if (rci.isPinned()) { Log.i(TAG, "Pinned item: " + rci.name); @@ -597,11 +634,15 @@ public class ResolverListAdapter extends BaseAdapter { return position; } - public int getDisplayResolveInfoCount() { + public final int getDisplayResolveInfoCount() { return mDisplayList.size(); } - public DisplayResolveInfo getDisplayResolveInfo(int index) { + public final boolean allResolveInfosHandleAllWebDataUri() { + return mDisplayList.stream().allMatch(t -> t.getResolveInfo().handleAllWebDataURI); + } + + public final DisplayResolveInfo getDisplayResolveInfo(int index) { // Used to query services. We only query services for primary targets, not alternates. return mDisplayList.get(index); } @@ -634,28 +675,49 @@ public class ResolverListAdapter extends BaseAdapter { protected void onBindView(View view, TargetInfo info, int position) { final ViewHolder holder = (ViewHolder) view.getTag(); if (info == null) { - holder.icon.setImageDrawable( - mContext.getDrawable(R.drawable.resolver_icon_placeholder)); + holder.icon.setImageDrawable(loadIconPlaceholder()); + holder.bindLabel("", "", false); return; } - if (info instanceof DisplayResolveInfo - && !((DisplayResolveInfo) info).hasDisplayLabel()) { - getLoadLabelTask((DisplayResolveInfo) info, holder).execute(); - } else { - holder.bindLabel(info.getDisplayLabel(), info.getExtendedInfo(), alwaysShowSubLabel()); + if (info.isDisplayResolveInfo()) { + DisplayResolveInfo dri = (DisplayResolveInfo) info; + if (dri.hasDisplayLabel()) { + holder.bindLabel( + dri.getDisplayLabel(), + dri.getExtendedInfo(), + alwaysShowSubLabel()); + } else { + holder.bindLabel("", "", false); + loadLabel(dri); + } + holder.bindIcon(info); + if (!dri.hasDisplayIcon()) { + loadIcon(dri); + } } + } - if (info instanceof DisplayResolveInfo - && !((DisplayResolveInfo) info).hasDisplayIcon()) { - new LoadIconTask((DisplayResolveInfo) info, holder).execute(); - } else { - holder.bindIcon(info); + protected final void loadIcon(DisplayResolveInfo info) { + LoadIconTask task = mIconLoaders.get(info); + if (task == null) { + task = new LoadIconTask(info); + mIconLoaders.put(info, task); + task.execute(); + } + } + + private void loadLabel(DisplayResolveInfo info) { + LoadLabelTask task = mLabelLoaders.get(info); + if (task == null) { + task = createLoadLabelTask(info); + mLabelLoaders.put(info, task); + task.execute(); } } - protected LoadLabelTask getLoadLabelTask(DisplayResolveInfo info, ViewHolder holder) { - return new LoadLabelTask(info, holder); + protected LoadLabelTask createLoadLabelTask(DisplayResolveInfo info) { + return new LoadLabelTask(info); } public void onDestroy() { @@ -666,6 +728,16 @@ public class ResolverListAdapter extends BaseAdapter { if (mResolverListController != null) { mResolverListController.destroy(); } + cancelTasks(mIconLoaders.values()); + cancelTasks(mLabelLoaders.values()); + mIconLoaders.clear(); + mLabelLoaders.clear(); + } + + private <T extends AsyncTask> void cancelTasks(Collection<T> tasks) { + for (T task: tasks) { + task.cancel(false); + } } private static ColorMatrixColorFilter getSuspendedColorMatrix() { @@ -691,17 +763,13 @@ public class ResolverListAdapter extends BaseAdapter { return sSuspendedMatrixColorFilter; } - ActivityInfoPresentationGetter makePresentationGetter(ActivityInfo ai) { - return new ActivityInfoPresentationGetter(mContext, mIconDpi, ai); - } - - ResolveInfoPresentationGetter makePresentationGetter(ResolveInfo ri) { - return new ResolveInfoPresentationGetter(mContext, mIconDpi, ri); - } - Drawable loadIconForResolveInfo(ResolveInfo ri) { // Load icons based on the current process. If in work profile icons should be badged. - return makePresentationGetter(ri).getIcon(getUserHandle()); + return mPresentationFactory.makePresentationGetter(ri).getIcon(getUserHandle()); + } + + protected final Drawable loadIconPlaceholder() { + return mContext.getDrawable(R.drawable.resolver_icon_placeholder); } void loadFilteredItemIconTaskAsync(@NonNull ImageView iconView) { @@ -710,7 +778,15 @@ public class ResolverListAdapter extends BaseAdapter { new AsyncTask<Void, Void, Drawable>() { @Override protected Drawable doInBackground(Void... params) { - return loadIconForResolveInfo(iconInfo.getResolveInfo()); + Drawable drawable; + try { + drawable = loadIconForResolveInfo(iconInfo.getResolveInfo()); + } catch (Exception e) { + ComponentName componentName = iconInfo.getResolvedComponentName(); + Log.e(TAG, "Failed to load app icon for " + componentName, e); + drawable = loadIconPlaceholder(); + } + return drawable; } @Override @@ -721,9 +797,8 @@ public class ResolverListAdapter extends BaseAdapter { } } - @VisibleForTesting public UserHandle getUserHandle() { - return mResolverListController.getUserHandle(); + return mUserHandle; } protected List<ResolvedComponentInfo> getResolversForUser(UserHandle userHandle) { @@ -779,6 +854,7 @@ public class ResolverListAdapter extends BaseAdapter { Context context, ResolvedComponentInfo resolvedComponentInfo, PackageManager pm, + Intent targetIntent, ResolverListCommunicator resolverListCommunicator, int iconDpi) { ResolveInfo resolveInfo = resolvedComponentInfo.getResolveInfoAt(0); @@ -787,13 +863,13 @@ public class ResolverListAdapter extends BaseAdapter { resolveInfo.activityInfo, resolvedComponentInfo.getIntentAt(0)); Intent replacementIntent = resolverListCommunicator.getReplacementIntent( - resolveInfo.activityInfo, - resolverListCommunicator.getTargetIntent()); + resolveInfo.activityInfo, targetIntent); - ResolveInfoPresentationGetter presentationGetter = - new ResolveInfoPresentationGetter(context, iconDpi, resolveInfo); + TargetPresentationGetter presentationGetter = + new TargetPresentationGetter.Factory(context, iconDpi) + .makePresentationGetter(resolveInfo); - return new DisplayResolveInfo( + return DisplayResolveInfo.newDisplayResolveInfo( resolvedComponentInfo.getIntentAt(0), resolveInfo, resolveInfo.loadLabel(pm), @@ -829,13 +905,12 @@ public class ResolverListAdapter extends BaseAdapter { */ default boolean shouldGetOnlyDefaultActivities() { return true; }; - Intent getTargetIntent(); - void onHandlePackagesChanged(ResolverListAdapter listAdapter); } /** - * A view holder. + * A view holder keeps a reference to a list view and provides functionality for managing its + * state. */ @VisibleForTesting public static class ViewHolder { @@ -877,7 +952,7 @@ public class ResolverListAdapter extends BaseAdapter { } public void bindIcon(TargetInfo info) { - icon.setImageDrawable(info.getDisplayIcon(itemView.getContext())); + icon.setImageDrawable(info.getDisplayIconHolder().getDisplayIcon()); if (info.isSuspended()) { icon.setColorFilter(getSuspendedColorMatrix()); } else { @@ -888,17 +963,15 @@ public class ResolverListAdapter extends BaseAdapter { protected class LoadLabelTask extends AsyncTask<Void, Void, CharSequence[]> { private final DisplayResolveInfo mDisplayResolveInfo; - private final ViewHolder mHolder; - protected LoadLabelTask(DisplayResolveInfo dri, ViewHolder holder) { + protected LoadLabelTask(DisplayResolveInfo dri) { mDisplayResolveInfo = dri; - mHolder = holder; } @Override protected CharSequence[] doInBackground(Void... voids) { - ResolveInfoPresentationGetter pg = - makePresentationGetter(mDisplayResolveInfo.getResolveInfo()); + TargetPresentationGetter pg = mPresentationFactory.makePresentationGetter( + mDisplayResolveInfo.getResolveInfo()); if (mIsAudioCaptureDevice) { // This is an audio capture device, so check record permissions @@ -930,26 +1003,33 @@ public class ResolverListAdapter extends BaseAdapter { @Override protected void onPostExecute(CharSequence[] result) { + if (mDisplayResolveInfo.hasDisplayLabel()) { + return; + } mDisplayResolveInfo.setDisplayLabel(result[0]); mDisplayResolveInfo.setExtendedInfo(result[1]); - mHolder.bindLabel(result[0], result[1], alwaysShowSubLabel()); + notifyDataSetChanged(); } } class LoadIconTask extends AsyncTask<Void, Void, Drawable> { protected final DisplayResolveInfo mDisplayResolveInfo; private final ResolveInfo mResolveInfo; - private ViewHolder mHolder; - LoadIconTask(DisplayResolveInfo dri, ViewHolder holder) { + LoadIconTask(DisplayResolveInfo dri) { mDisplayResolveInfo = dri; mResolveInfo = dri.getResolveInfo(); - mHolder = holder; } @Override protected Drawable doInBackground(Void... params) { - return loadIconForResolveInfo(mResolveInfo); + try { + return loadIconForResolveInfo(mResolveInfo); + } catch (Exception e) { + ComponentName componentName = mDisplayResolveInfo.getResolvedComponentName(); + Log.e(TAG, "Failed to load app icon for " + componentName, e); + return loadIconPlaceholder(); + } } @Override @@ -957,207 +1037,9 @@ public class ResolverListAdapter extends BaseAdapter { if (getOtherProfile() == mDisplayResolveInfo) { mResolverListCommunicator.updateProfileViewButton(); } else if (!mDisplayResolveInfo.hasDisplayIcon()) { - mDisplayResolveInfo.setDisplayIcon(d); - mHolder.bindIcon(mDisplayResolveInfo); - // Notify in case view is already bound to resolve the race conditions on - // low end devices + mDisplayResolveInfo.getDisplayIconHolder().setDisplayIcon(d); notifyDataSetChanged(); } } - - public void setViewHolder(ViewHolder holder) { - mHolder = holder; - mHolder.bindIcon(mDisplayResolveInfo); - } - } - - /** - * Loads the icon and label for the provided ResolveInfo. - */ - @VisibleForTesting - public static class ResolveInfoPresentationGetter extends ActivityInfoPresentationGetter { - private final ResolveInfo mRi; - public ResolveInfoPresentationGetter(Context ctx, int iconDpi, ResolveInfo ri) { - super(ctx, iconDpi, ri.activityInfo); - mRi = ri; - } - - @Override - Drawable getIconSubstituteInternal() { - Drawable dr = null; - try { - // Do not use ResolveInfo#getIconResource() as it defaults to the app - if (mRi.resolvePackageName != null && mRi.icon != 0) { - dr = loadIconFromResource( - mPm.getResourcesForApplication(mRi.resolvePackageName), mRi.icon); - } - } catch (PackageManager.NameNotFoundException e) { - Log.e(TAG, "SUBSTITUTE_SHARE_TARGET_APP_NAME_AND_ICON permission granted but " - + "couldn't find resources for package", e); - } - - // Fall back to ActivityInfo if no icon is found via ResolveInfo - if (dr == null) dr = super.getIconSubstituteInternal(); - - return dr; - } - - @Override - String getAppSubLabelInternal() { - // Will default to app name if no intent filter or activity label set, make sure to - // check if subLabel matches label before final display - return mRi.loadLabel(mPm).toString(); - } - - @Override - String getAppLabelForSubstitutePermission() { - // Will default to app name if no activity label set - return mRi.getComponentInfo().loadLabel(mPm).toString(); - } - } - - /** - * Loads the icon and label for the provided ActivityInfo. - */ - @VisibleForTesting - public static class ActivityInfoPresentationGetter extends - TargetPresentationGetter { - private final ActivityInfo mActivityInfo; - public ActivityInfoPresentationGetter(Context ctx, int iconDpi, - ActivityInfo activityInfo) { - super(ctx, iconDpi, activityInfo.applicationInfo); - mActivityInfo = activityInfo; - } - - @Override - Drawable getIconSubstituteInternal() { - Drawable dr = null; - try { - // Do not use ActivityInfo#getIconResource() as it defaults to the app - if (mActivityInfo.icon != 0) { - dr = loadIconFromResource( - mPm.getResourcesForApplication(mActivityInfo.applicationInfo), - mActivityInfo.icon); - } - } catch (PackageManager.NameNotFoundException e) { - Log.e(TAG, "SUBSTITUTE_SHARE_TARGET_APP_NAME_AND_ICON permission granted but " - + "couldn't find resources for package", e); - } - - return dr; - } - - @Override - String getAppSubLabelInternal() { - // Will default to app name if no activity label set, make sure to check if subLabel - // matches label before final display - return (String) mActivityInfo.loadLabel(mPm); - } - - @Override - String getAppLabelForSubstitutePermission() { - return getAppSubLabelInternal(); - } - } - - /** - * Loads the icon and label for the provided ApplicationInfo. Defaults to using the application - * icon and label over any IntentFilter or Activity icon to increase user understanding, with an - * exception for applications that hold the right permission. Always attempts to use available - * resources over PackageManager loading mechanisms so badging can be done by iconloader. Uses - * Strings to strip creative formatting. - */ - private abstract static class TargetPresentationGetter { - @Nullable abstract Drawable getIconSubstituteInternal(); - @Nullable abstract String getAppSubLabelInternal(); - @Nullable abstract String getAppLabelForSubstitutePermission(); - - private Context mCtx; - private final int mIconDpi; - private final boolean mHasSubstitutePermission; - private final ApplicationInfo mAi; - - protected PackageManager mPm; - - TargetPresentationGetter(Context ctx, int iconDpi, ApplicationInfo ai) { - mCtx = ctx; - mPm = ctx.getPackageManager(); - mAi = ai; - mIconDpi = iconDpi; - mHasSubstitutePermission = PackageManager.PERMISSION_GRANTED == mPm.checkPermission( - android.Manifest.permission.SUBSTITUTE_SHARE_TARGET_APP_NAME_AND_ICON, - mAi.packageName); - } - - public Drawable getIcon(UserHandle userHandle) { - return new BitmapDrawable(mCtx.getResources(), getIconBitmap(userHandle)); - } - - public Bitmap getIconBitmap(@Nullable UserHandle userHandle) { - Drawable dr = null; - if (mHasSubstitutePermission) { - dr = getIconSubstituteInternal(); - } - - if (dr == null) { - try { - if (mAi.icon != 0) { - dr = loadIconFromResource(mPm.getResourcesForApplication(mAi), mAi.icon); - } - } catch (PackageManager.NameNotFoundException ignore) { - } - } - - // Fall back to ApplicationInfo#loadIcon if nothing has been loaded - if (dr == null) { - dr = mAi.loadIcon(mPm); - } - - SimpleIconFactory sif = SimpleIconFactory.obtain(mCtx); - Bitmap icon = sif.createUserBadgedIconBitmap(dr, userHandle); - sif.recycle(); - - return icon; - } - - public String getLabel() { - String label = null; - // Apps with the substitute permission will always show the activity label as the - // app label if provided - if (mHasSubstitutePermission) { - label = getAppLabelForSubstitutePermission(); - } - - if (label == null) { - label = (String) mAi.loadLabel(mPm); - } - - return label; - } - - public String getSubLabel() { - // Apps with the substitute permission will always show the resolve info label as the - // sublabel if provided - if (mHasSubstitutePermission){ - String appSubLabel = getAppSubLabelInternal(); - // Use the resolve info label as sublabel if it is set - if(!TextUtils.isEmpty(appSubLabel) - && !TextUtils.equals(appSubLabel, getLabel())){ - return appSubLabel; - } - return null; - } - return getAppSubLabelInternal(); - } - - protected String loadLabelFromResource(Resources res, int resId) { - return res.getString(resId); - } - - @Nullable - protected Drawable loadIconFromResource(Resources res, int resId) { - return res.getDrawableForDensity(resId, mIconDpi); - } - } } diff --git a/java/src/com/android/intentresolver/ResolverListController.java b/java/src/com/android/intentresolver/ResolverListController.java index ff616ce0..bfffe0d8 100644 --- a/java/src/com/android/intentresolver/ResolverListController.java +++ b/java/src/com/android/intentresolver/ResolverListController.java @@ -32,7 +32,8 @@ import android.os.UserHandle; import android.util.Log; import com.android.intentresolver.chooser.DisplayResolveInfo; - +import com.android.intentresolver.model.AbstractResolverComparator; +import com.android.intentresolver.model.ResolverRankerServiceResolverComparator; import com.android.internal.annotations.VisibleForTesting; import java.util.ArrayList; @@ -187,7 +188,6 @@ public class ResolverListController { final ResolverActivity.ResolvedComponentInfo rci = new ResolverActivity.ResolvedComponentInfo(name, intent, newInfo); rci.setPinned(isComponentPinned(name)); - rci.setFixedAtTop(isFixedAtTop(name)); into.add(rci); } } @@ -202,14 +202,6 @@ public class ResolverListController { return false; } - /** - * Whether this component is fixed at top in the ranked apps list. Always false for Resolver; - * overridden in Chooser. - */ - public boolean isFixedAtTop(ComponentName name) { - return false; - } - // Filter out any activities that the launched uid does not have permission for. // To preserve the inputList, optionally will return the original list if any modification has // been made. @@ -274,19 +266,6 @@ public class ResolverListController { return listToReturn; } - private class ComputeCallback implements AbstractResolverComparator.AfterCompute { - - private CountDownLatch mFinishComputeSignal; - - public ComputeCallback(CountDownLatch finishComputeSignal) { - mFinishComputeSignal = finishComputeSignal; - } - - public void afterCompute () { - mFinishComputeSignal.countDown(); - } - } - private void compute(List<ResolverActivity.ResolvedComponentInfo> inputList) throws InterruptedException { if (mResolverComparator == null) { @@ -294,8 +273,7 @@ public class ResolverListController { return; } final CountDownLatch finishComputeSignal = new CountDownLatch(1); - ComputeCallback callback = new ComputeCallback(finishComputeSignal); - mResolverComparator.setCallBack(callback); + mResolverComparator.setCallBack(() -> finishComputeSignal.countDown()); mResolverComparator.compute(inputList); finishComputeSignal.await(); isComputed = true; diff --git a/java/src/com/android/intentresolver/ResolverMultiProfilePagerAdapter.java b/java/src/com/android/intentresolver/ResolverMultiProfilePagerAdapter.java index 56d326c1..65de9409 100644 --- a/java/src/com/android/intentresolver/ResolverMultiProfilePagerAdapter.java +++ b/java/src/com/android/intentresolver/ResolverMultiProfilePagerAdapter.java @@ -16,264 +16,99 @@ package com.android.intentresolver; -import static android.app.admin.DevicePolicyResources.Strings.Core.RESOLVER_CANT_ACCESS_PERSONAL; -import static android.app.admin.DevicePolicyResources.Strings.Core.RESOLVER_CANT_ACCESS_WORK; -import static android.app.admin.DevicePolicyResources.Strings.Core.RESOLVER_CROSS_PROFILE_BLOCKED_TITLE; -import static android.app.admin.DevicePolicyResources.Strings.Core.RESOLVER_NO_PERSONAL_APPS; -import static android.app.admin.DevicePolicyResources.Strings.Core.RESOLVER_NO_WORK_APPS; -import static android.app.admin.DevicePolicyResources.Strings.Core.RESOLVER_WORK_PAUSED_TITLE; - -import android.annotation.Nullable; -import android.app.admin.DevicePolicyManager; import android.content.Context; import android.os.UserHandle; import android.view.LayoutInflater; -import android.view.View; import android.view.ViewGroup; import android.widget.ListView; +import androidx.viewpager.widget.PagerAdapter; + import com.android.internal.annotations.VisibleForTesting; -import com.android.internal.widget.PagerAdapter; + +import com.google.common.collect.ImmutableList; + +import java.util.Optional; +import java.util.function.Supplier; /** * A {@link PagerAdapter} which describes the work and personal profile intent resolver screens. */ @VisibleForTesting -public class ResolverMultiProfilePagerAdapter extends AbstractMultiProfilePagerAdapter { - - private final ResolverProfileDescriptor[] mItems; - private final boolean mShouldShowNoCrossProfileIntentsEmptyState; - private boolean mUseLayoutWithDefault; +public class ResolverMultiProfilePagerAdapter extends + GenericMultiProfilePagerAdapter<ListView, ResolverListAdapter, ResolverListAdapter> { + private final BottomPaddingOverrideSupplier mBottomPaddingOverrideSupplier; - ResolverMultiProfilePagerAdapter(Context context, + ResolverMultiProfilePagerAdapter( + Context context, ResolverListAdapter adapter, - UserHandle personalProfileUserHandle, + EmptyStateProvider emptyStateProvider, + QuietModeManager quietModeManager, UserHandle workProfileUserHandle) { - super(context, /* currentPage */ 0, personalProfileUserHandle, workProfileUserHandle); - mItems = new ResolverProfileDescriptor[] { - createProfileDescriptor(adapter) - }; - mShouldShowNoCrossProfileIntentsEmptyState = true; + this( + context, + ImmutableList.of(adapter), + emptyStateProvider, + quietModeManager, + /* defaultProfile= */ 0, + workProfileUserHandle, + new BottomPaddingOverrideSupplier()); } ResolverMultiProfilePagerAdapter(Context context, ResolverListAdapter personalAdapter, ResolverListAdapter workAdapter, + EmptyStateProvider emptyStateProvider, + QuietModeManager quietModeManager, + @Profile int defaultProfile, + UserHandle workProfileUserHandle) { + this( + context, + ImmutableList.of(personalAdapter, workAdapter), + emptyStateProvider, + quietModeManager, + defaultProfile, + workProfileUserHandle, + new BottomPaddingOverrideSupplier()); + } + + private ResolverMultiProfilePagerAdapter( + Context context, + ImmutableList<ResolverListAdapter> listAdapters, + EmptyStateProvider emptyStateProvider, + QuietModeManager quietModeManager, @Profile int defaultProfile, - UserHandle personalProfileUserHandle, UserHandle workProfileUserHandle, - boolean shouldShowNoCrossProfileIntentsEmptyState) { - super(context, /* currentPage */ defaultProfile, personalProfileUserHandle, - workProfileUserHandle); - mItems = new ResolverProfileDescriptor[] { - createProfileDescriptor(personalAdapter), - createProfileDescriptor(workAdapter) - }; - mShouldShowNoCrossProfileIntentsEmptyState = shouldShowNoCrossProfileIntentsEmptyState; - } - - private ResolverProfileDescriptor createProfileDescriptor( - ResolverListAdapter adapter) { - final LayoutInflater inflater = LayoutInflater.from(getContext()); - final ViewGroup rootView = - (ViewGroup) inflater.inflate(R.layout.resolver_list_per_profile, null, false); - return new ResolverProfileDescriptor(rootView, adapter); - } - - ListView getListViewForIndex(int index) { - return getItem(index).listView; - } - - @Override - ResolverProfileDescriptor getItem(int pageIndex) { - return mItems[pageIndex]; - } - - @Override - int getItemCount() { - return mItems.length; - } - - @Override - void setupListAdapter(int pageIndex) { - final ListView listView = getItem(pageIndex).listView; - listView.setAdapter(getItem(pageIndex).resolverListAdapter); - } - - @Override - @VisibleForTesting - public ResolverListAdapter getAdapterForIndex(int pageIndex) { - return mItems[pageIndex].resolverListAdapter; - } - - @Override - public ViewGroup instantiateItem(ViewGroup container, int position) { - setupListAdapter(position); - return super.instantiateItem(container, position); - } - - @Override - @Nullable - ResolverListAdapter getListAdapterForUserHandle(UserHandle userHandle) { - if (getActiveListAdapter().getUserHandle().equals(userHandle)) { - return getActiveListAdapter(); - } else if (getInactiveListAdapter() != null - && getInactiveListAdapter().getUserHandle().equals(userHandle)) { - return getInactiveListAdapter(); - } - return null; - } - - @Override - @VisibleForTesting - public ResolverListAdapter getActiveListAdapter() { - return getAdapterForIndex(getCurrentPage()); - } - - @Override - @VisibleForTesting - public ResolverListAdapter getInactiveListAdapter() { - if (getCount() == 1) { - return null; + BottomPaddingOverrideSupplier bottomPaddingOverrideSupplier) { + super( + context, + listAdapter -> listAdapter, + (listView, bindAdapter) -> listView.setAdapter(bindAdapter), + listAdapters, + emptyStateProvider, + quietModeManager, + defaultProfile, + workProfileUserHandle, + () -> (ViewGroup) LayoutInflater.from(context).inflate( + R.layout.resolver_list_per_profile, null, false), + bottomPaddingOverrideSupplier); + mBottomPaddingOverrideSupplier = bottomPaddingOverrideSupplier; + } + + public void setUseLayoutWithDefault(boolean useLayoutWithDefault) { + mBottomPaddingOverrideSupplier.setUseLayoutWithDefault(useLayoutWithDefault); + } + + private static class BottomPaddingOverrideSupplier implements Supplier<Optional<Integer>> { + private boolean mUseLayoutWithDefault; + + public void setUseLayoutWithDefault(boolean useLayoutWithDefault) { + mUseLayoutWithDefault = useLayoutWithDefault; } - return getAdapterForIndex(1 - getCurrentPage()); - } - - @Override - public ResolverListAdapter getPersonalListAdapter() { - return getAdapterForIndex(PROFILE_PERSONAL); - } - - @Override - @Nullable - public ResolverListAdapter getWorkListAdapter() { - return getAdapterForIndex(PROFILE_WORK); - } - - @Override - ResolverListAdapter getCurrentRootAdapter() { - return getActiveListAdapter(); - } - - @Override - ListView getActiveAdapterView() { - return getListViewForIndex(getCurrentPage()); - } - - @Override - @Nullable - ViewGroup getInactiveAdapterView() { - if (getCount() == 1) { - return null; - } - return getListViewForIndex(1 - getCurrentPage()); - } - - @Override - String getMetricsCategory() { - return ResolverActivity.METRICS_CATEGORY_RESOLVER; - } - - @Override - boolean allowShowNoCrossProfileIntentsEmptyState() { - return mShouldShowNoCrossProfileIntentsEmptyState; - } - - @Override - protected void showWorkProfileOffEmptyState(ResolverListAdapter activeListAdapter, - View.OnClickListener listener) { - showEmptyState(activeListAdapter, - getWorkAppPausedTitle(), - /* subtitle = */ null, - listener); - } - - @Override - protected void showNoPersonalToWorkIntentsEmptyState(ResolverListAdapter activeListAdapter) { - showEmptyState(activeListAdapter, - getCrossProfileBlockedTitle(), - getCantAccessWorkMessage()); - } - - @Override - protected void showNoWorkToPersonalIntentsEmptyState(ResolverListAdapter activeListAdapter) { - showEmptyState(activeListAdapter, - getCrossProfileBlockedTitle(), - getCantAccessPersonalMessage()); - } - - @Override - protected void showNoPersonalAppsAvailableEmptyState(ResolverListAdapter listAdapter) { - showEmptyState(listAdapter, - getNoPersonalAppsAvailableMessage(), - /* subtitle = */ null); - } - - @Override - protected void showNoWorkAppsAvailableEmptyState(ResolverListAdapter listAdapter) { - showEmptyState(listAdapter, - getNoWorkAppsAvailableMessage(), - /* subtitle= */ null); - } - - private String getWorkAppPausedTitle() { - return getContext().getSystemService(DevicePolicyManager.class).getResources().getString( - RESOLVER_WORK_PAUSED_TITLE, - () -> getContext().getString(R.string.resolver_turn_on_work_apps)); - } - - private String getCrossProfileBlockedTitle() { - return getContext().getSystemService(DevicePolicyManager.class).getResources().getString( - RESOLVER_CROSS_PROFILE_BLOCKED_TITLE, - () -> getContext().getString(R.string.resolver_cross_profile_blocked)); - } - - private String getCantAccessWorkMessage() { - return getContext().getSystemService(DevicePolicyManager.class).getResources().getString( - RESOLVER_CANT_ACCESS_WORK, - () -> getContext().getString( - R.string.resolver_cant_access_work_apps_explanation)); - } - - private String getCantAccessPersonalMessage() { - return getContext().getSystemService(DevicePolicyManager.class).getResources().getString( - RESOLVER_CANT_ACCESS_PERSONAL, - () -> getContext().getString( - R.string.resolver_cant_access_personal_apps_explanation)); - } - - private String getNoWorkAppsAvailableMessage() { - return getContext().getSystemService(DevicePolicyManager.class).getResources().getString( - RESOLVER_NO_WORK_APPS, - () -> getContext().getString( - R.string.resolver_no_work_apps_available)); - } - - private String getNoPersonalAppsAvailableMessage() { - return getContext().getSystemService(DevicePolicyManager.class).getResources().getString( - RESOLVER_NO_PERSONAL_APPS, - () -> getContext().getString( - R.string.resolver_no_personal_apps_available)); - } - - void setUseLayoutWithDefault(boolean useLayoutWithDefault) { - mUseLayoutWithDefault = useLayoutWithDefault; - } - - @Override - protected void setupContainerPadding(View container) { - int bottom = mUseLayoutWithDefault ? container.getPaddingBottom() : 0; - container.setPadding(container.getPaddingLeft(), container.getPaddingTop(), - container.getPaddingRight(), bottom); - } - class ResolverProfileDescriptor extends ProfileDescriptor { - private ResolverListAdapter resolverListAdapter; - final ListView listView; - ResolverProfileDescriptor(ViewGroup rootView, ResolverListAdapter adapter) { - super(rootView); - resolverListAdapter = adapter; - listView = rootView.findViewById(com.android.internal.R.id.resolver_list); + @Override + public Optional<Integer> get() { + return mUseLayoutWithDefault ? Optional.empty() : Optional.of(0); } } } diff --git a/java/src/com/android/intentresolver/ResolverViewPager.java b/java/src/com/android/intentresolver/ResolverViewPager.java index 1c234526..0804a2b8 100644 --- a/java/src/com/android/intentresolver/ResolverViewPager.java +++ b/java/src/com/android/intentresolver/ResolverViewPager.java @@ -21,7 +21,7 @@ import android.util.AttributeSet; import android.view.MotionEvent; import android.view.View; -import com.android.internal.widget.ViewPager; +import androidx.viewpager.widget.ViewPager; /** * A {@link ViewPager} which wraps around its tallest child's height. @@ -41,15 +41,6 @@ public class ResolverViewPager extends ViewPager { super(context, attrs); } - public ResolverViewPager(Context context, AttributeSet attrs, int defStyleAttr) { - super(context, attrs, defStyleAttr); - } - - public ResolverViewPager(Context context, AttributeSet attrs, - int defStyleAttr, int defStyleRes) { - super(context, attrs, defStyleAttr, defStyleRes); - } - @Override protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) { super.onMeasure(widthMeasureSpec, heightMeasureSpec); diff --git a/java/src/com/android/intentresolver/ShortcutSelectionLogic.java b/java/src/com/android/intentresolver/ShortcutSelectionLogic.java new file mode 100644 index 00000000..645b9391 --- /dev/null +++ b/java/src/com/android/intentresolver/ShortcutSelectionLogic.java @@ -0,0 +1,193 @@ +/* + * 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.prediction.AppTarget; +import android.content.Context; +import android.content.Intent; +import android.content.pm.PackageManager; +import android.content.pm.ResolveInfo; +import android.content.pm.ShortcutInfo; +import android.service.chooser.ChooserTarget; +import android.util.Log; + +import com.android.intentresolver.chooser.DisplayResolveInfo; +import com.android.intentresolver.chooser.SelectableTargetInfo; +import com.android.intentresolver.chooser.TargetInfo; + +import java.util.Collections; +import java.util.Comparator; +import java.util.List; +import java.util.Map; + +class ShortcutSelectionLogic { + private static final String TAG = "ShortcutSelectionLogic"; + private static final boolean DEBUG = false; + private static final float PINNED_SHORTCUT_TARGET_SCORE_BOOST = 1000.f; + private static final int MAX_CHOOSER_TARGETS_PER_APP = 2; + + private final int mMaxShortcutTargetsPerApp; + private final boolean mApplySharingAppLimits; + + // Descending order + private final Comparator<ChooserTarget> mBaseTargetComparator = + (lhs, rhs) -> Float.compare(rhs.getScore(), lhs.getScore()); + + ShortcutSelectionLogic( + int maxShortcutTargetsPerApp, + boolean applySharingAppLimits) { + mMaxShortcutTargetsPerApp = maxShortcutTargetsPerApp; + mApplySharingAppLimits = applySharingAppLimits; + } + + /** + * Evaluate targets for inclusion in the direct share area. May not be included + * if score is too low. + */ + public boolean addServiceResults( + @Nullable DisplayResolveInfo origTarget, + float origTargetScore, + List<ChooserTarget> targets, + boolean isShortcutResult, + Map<ChooserTarget, ShortcutInfo> directShareToShortcutInfos, + Map<ChooserTarget, AppTarget> directShareToAppTargets, + Context userContext, + Intent targetIntent, + Intent referrerFillInIntent, + int maxRankedTargets, + List<TargetInfo> serviceTargets) { + if (DEBUG) { + Log.d(TAG, "addServiceResults " + + (origTarget == null ? null : origTarget.getResolvedComponentName()) + ", " + + targets.size() + + " targets"); + } + if (targets.size() == 0) { + return false; + } + Collections.sort(targets, mBaseTargetComparator); + final int maxTargets = isShortcutResult ? mMaxShortcutTargetsPerApp + : MAX_CHOOSER_TARGETS_PER_APP; + final int targetsLimit = mApplySharingAppLimits ? Math.min(targets.size(), maxTargets) + : targets.size(); + float lastScore = 0; + boolean shouldNotify = false; + for (int i = 0, count = targetsLimit; i < count; i++) { + final ChooserTarget target = targets.get(i); + float targetScore = target.getScore(); + if (mApplySharingAppLimits) { + targetScore *= origTargetScore; + if (i > 0 && targetScore >= lastScore) { + // Apply a decay so that the top app can't crowd out everything else. + // This incents ChooserTargetServices to define what's truly better. + targetScore = lastScore * 0.95f; + } + } + ShortcutInfo shortcutInfo = isShortcutResult ? directShareToShortcutInfos.get(target) + : null; + if ((shortcutInfo != null) && shortcutInfo.isPinned()) { + targetScore += PINNED_SHORTCUT_TARGET_SCORE_BOOST; + } + ResolveInfo backupResolveInfo; + Intent resolvedIntent; + if (origTarget == null) { + resolvedIntent = createResolvedIntentForCallerTarget(target, targetIntent); + backupResolveInfo = userContext.getPackageManager() + .resolveActivity( + resolvedIntent, + PackageManager.ResolveInfoFlags.of(PackageManager.GET_META_DATA)); + } else { + resolvedIntent = origTarget.getResolvedIntent(); + backupResolveInfo = null; + } + boolean isInserted = insertServiceTarget( + SelectableTargetInfo.newSelectableTargetInfo( + origTarget, + backupResolveInfo, + resolvedIntent, + target, + targetScore, + shortcutInfo, + directShareToAppTargets.get(target), + referrerFillInIntent), + maxRankedTargets, + serviceTargets); + + shouldNotify |= isInserted; + + if (DEBUG) { + Log.d(TAG, " => " + target + " score=" + targetScore + + " base=" + target.getScore() + + " lastScore=" + lastScore + + " baseScore=" + origTargetScore + + " applyAppLimit=" + mApplySharingAppLimits); + } + + lastScore = targetScore; + } + + return shouldNotify; + } + + /** + * Creates a resolved intent for a caller-specified target. + * @param target, a caller-specified target. + * @param targetIntent, a target intent for the Chooser (see {@link Intent#EXTRA_INTENT}). + */ + private static Intent createResolvedIntentForCallerTarget( + ChooserTarget target, Intent targetIntent) { + final Intent resolvedIntent = new Intent(targetIntent); + resolvedIntent.setComponent(target.getComponentName()); + resolvedIntent.putExtras(target.getIntentExtras()); + return resolvedIntent; + } + + private boolean insertServiceTarget( + TargetInfo chooserTargetInfo, + int maxRankedTargets, + List<TargetInfo> serviceTargets) { + + // Check for duplicates and abort if found + for (TargetInfo otherTargetInfo : serviceTargets) { + if (chooserTargetInfo.isSimilar(otherTargetInfo)) { + return false; + } + } + + int currentSize = serviceTargets.size(); + final float newScore = chooserTargetInfo.getModifiedScore(); + for (int i = 0; i < Math.min(currentSize, maxRankedTargets); + i++) { + final TargetInfo serviceTarget = serviceTargets.get(i); + if (serviceTarget == null) { + serviceTargets.set(i, chooserTargetInfo); + return true; + } else if (newScore > serviceTarget.getModifiedScore()) { + serviceTargets.add(i, chooserTargetInfo); + return true; + } + } + + if (currentSize < maxRankedTargets) { + serviceTargets.add(chooserTargetInfo); + return true; + } + + return false; + } +} diff --git a/java/src/com/android/intentresolver/SimpleIconFactory.java b/java/src/com/android/intentresolver/SimpleIconFactory.java index b05b4f68..ec5179ac 100644 --- a/java/src/com/android/intentresolver/SimpleIconFactory.java +++ b/java/src/com/android/intentresolver/SimpleIconFactory.java @@ -50,6 +50,8 @@ import android.util.AttributeSet; import android.util.Pools.SynchronizedPool; import android.util.TypedValue; +import com.android.internal.annotations.VisibleForTesting; + import org.xmlpull.v1.XmlPullParser; import java.nio.ByteBuffer; @@ -67,6 +69,7 @@ public class SimpleIconFactory { private static final SynchronizedPool<SimpleIconFactory> sPool = new SynchronizedPool<>(Runtime.getRuntime().availableProcessors()); + private static boolean sPoolEnabled = true; private static final int DEFAULT_WRAPPER_BACKGROUND = Color.WHITE; private static final float BLUR_FACTOR = 1.5f / 48; @@ -90,7 +93,7 @@ public class SimpleIconFactory { */ @Deprecated public static SimpleIconFactory obtain(Context ctx) { - SimpleIconFactory instance = sPool.acquire(); + SimpleIconFactory instance = sPoolEnabled ? sPool.acquire() : null; if (instance == null) { final ActivityManager am = (ActivityManager) ctx.getSystemService(ACTIVITY_SERVICE); final int iconDpi = (am == null) ? 0 : am.getLauncherLargeIconDensity(); @@ -104,6 +107,17 @@ public class SimpleIconFactory { return instance; } + /** + * Enables or disables SimpleIconFactory objects pooling. It is enabled in production, you + * could use this method in tests and disable the pooling to make the icon rendering more + * deterministic because some sizing parameters will not be cached. Please ensure that you + * reset this value back after finishing the test. + */ + @VisibleForTesting + public static void setPoolEnabled(boolean poolEnabled) { + sPoolEnabled = poolEnabled; + } + private static int getAttrDimFromContext(Context ctx, @AttrRes int attrId, String errorMsg) { final Resources res = ctx.getResources(); TypedValue outVal = new TypedValue(); diff --git a/java/src/com/android/intentresolver/TargetPresentationGetter.java b/java/src/com/android/intentresolver/TargetPresentationGetter.java new file mode 100644 index 00000000..f8b36566 --- /dev/null +++ b/java/src/com/android/intentresolver/TargetPresentationGetter.java @@ -0,0 +1,267 @@ +/* + * 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.content.Context; +import android.content.pm.ActivityInfo; +import android.content.pm.ApplicationInfo; +import android.content.pm.PackageManager; +import android.content.pm.ResolveInfo; +import android.content.res.Resources; +import android.graphics.Bitmap; +import android.graphics.drawable.BitmapDrawable; +import android.graphics.drawable.Drawable; +import android.os.UserHandle; +import android.text.TextUtils; +import android.util.Log; + +/** + * Loads the icon and label for the provided ApplicationInfo. Defaults to using the application icon + * and label over any IntentFilter or Activity icon to increase user understanding, with an + * exception for applications that hold the right permission. Always attempts to use available + * resources over PackageManager loading mechanisms so badging can be done by iconloader. Uses + * Strings to strip creative formatting. + * + * Use one of the {@link TargetPresentationGetter#Factory} methods to create an instance of the + * appropriate concrete type. + * + * TODO: once this component (and its tests) are merged, it should be possible to refactor and + * vastly simplify by precomputing conditional logic at initialization. + */ +public abstract class TargetPresentationGetter { + private static final String TAG = "ResolverListAdapter"; + + /** Helper to build appropriate type-specific {@link TargetPresentationGetter} instances. */ + public static class Factory { + private final Context mContext; + private final int mIconDpi; + + public Factory(Context context, int iconDpi) { + mContext = context; + mIconDpi = iconDpi; + } + + /** Make a {@link TargetPresentationGetter} for an {@link ActivityInfo}. */ + public TargetPresentationGetter makePresentationGetter(ActivityInfo activityInfo) { + return new ActivityInfoPresentationGetter(mContext, mIconDpi, activityInfo); + } + + /** Make a {@link TargetPresentationGetter} for a {@link ResolveInfo}. */ + public TargetPresentationGetter makePresentationGetter(ResolveInfo resolveInfo) { + return new ResolveInfoPresentationGetter(mContext, mIconDpi, resolveInfo); + } + } + + @Nullable + protected abstract Drawable getIconSubstituteInternal(); + + @Nullable + protected abstract String getAppSubLabelInternal(); + + @Nullable + protected abstract String getAppLabelForSubstitutePermission(); + + private Context mContext; + private final int mIconDpi; + private final boolean mHasSubstitutePermission; + private final ApplicationInfo mAppInfo; + + protected PackageManager mPm; + + /** + * Retrieve the image that should be displayed as the icon when this target is presented to the + * specified {@code userHandle}. + */ + public Drawable getIcon(UserHandle userHandle) { + return new BitmapDrawable(mContext.getResources(), getIconBitmap(userHandle)); + } + + /** + * Retrieve the image that should be displayed as the icon when this target is presented to the + * specified {@code userHandle}. + */ + public Bitmap getIconBitmap(@Nullable UserHandle userHandle) { + Drawable drawable = null; + if (mHasSubstitutePermission) { + drawable = getIconSubstituteInternal(); + } + + if (drawable == null) { + try { + if (mAppInfo.icon != 0) { + drawable = loadIconFromResource( + mPm.getResourcesForApplication(mAppInfo), mAppInfo.icon); + } + } catch (PackageManager.NameNotFoundException ignore) { } + } + + // Fall back to ApplicationInfo#loadIcon if nothing has been loaded + if (drawable == null) { + drawable = mAppInfo.loadIcon(mPm); + } + + SimpleIconFactory iconFactory = SimpleIconFactory.obtain(mContext); + Bitmap icon = iconFactory.createUserBadgedIconBitmap(drawable, userHandle); + iconFactory.recycle(); + + return icon; + } + + /** Get the label to display for the target. */ + public String getLabel() { + String label = null; + // Apps with the substitute permission will always show the activity label as the app label + // if provided. + if (mHasSubstitutePermission) { + label = getAppLabelForSubstitutePermission(); + } + + if (label == null) { + label = (String) mAppInfo.loadLabel(mPm); + } + + return label; + } + + /** + * Get the sublabel to display for the target. Clients are responsible for deduping their + * presentation if this returns the same value as {@link #getLabel()}. + * TODO: this class should take responsibility for that deduping internally so it's an + * authoritative record of exactly the content that should be presented. + */ + public String getSubLabel() { + // Apps with the substitute permission will always show the resolve info label as the + // sublabel if provided + if (mHasSubstitutePermission) { + String appSubLabel = getAppSubLabelInternal(); + // Use the resolve info label as sublabel if it is set + if (!TextUtils.isEmpty(appSubLabel) && !TextUtils.equals(appSubLabel, getLabel())) { + return appSubLabel; + } + return null; + } + return getAppSubLabelInternal(); + } + + protected String loadLabelFromResource(Resources res, int resId) { + return res.getString(resId); + } + + @Nullable + protected Drawable loadIconFromResource(Resources res, int resId) { + return res.getDrawableForDensity(resId, mIconDpi); + } + + private TargetPresentationGetter(Context context, int iconDpi, ApplicationInfo appInfo) { + mContext = context; + mPm = context.getPackageManager(); + mAppInfo = appInfo; + mIconDpi = iconDpi; + mHasSubstitutePermission = (PackageManager.PERMISSION_GRANTED == mPm.checkPermission( + android.Manifest.permission.SUBSTITUTE_SHARE_TARGET_APP_NAME_AND_ICON, + mAppInfo.packageName)); + } + + /** Loads the icon and label for the provided ResolveInfo. */ + private static class ResolveInfoPresentationGetter extends ActivityInfoPresentationGetter { + private final ResolveInfo mResolveInfo; + + ResolveInfoPresentationGetter( + Context context, int iconDpi, ResolveInfo resolveInfo) { + super(context, iconDpi, resolveInfo.activityInfo); + mResolveInfo = resolveInfo; + } + + @Override + protected Drawable getIconSubstituteInternal() { + Drawable drawable = null; + try { + // Do not use ResolveInfo#getIconResource() as it defaults to the app + if (mResolveInfo.resolvePackageName != null && mResolveInfo.icon != 0) { + drawable = loadIconFromResource( + mPm.getResourcesForApplication(mResolveInfo.resolvePackageName), + mResolveInfo.icon); + } + } catch (PackageManager.NameNotFoundException e) { + Log.e(TAG, "SUBSTITUTE_SHARE_TARGET_APP_NAME_AND_ICON permission granted but " + + "couldn't find resources for package", e); + } + + // Fall back to ActivityInfo if no icon is found via ResolveInfo + if (drawable == null) { + drawable = super.getIconSubstituteInternal(); + } + + return drawable; + } + + @Override + protected String getAppSubLabelInternal() { + // Will default to app name if no intent filter or activity label set, make sure to + // check if subLabel matches label before final display + return mResolveInfo.loadLabel(mPm).toString(); + } + + @Override + protected String getAppLabelForSubstitutePermission() { + // Will default to app name if no activity label set + return mResolveInfo.getComponentInfo().loadLabel(mPm).toString(); + } + } + + /** Loads the icon and label for the provided {@link ActivityInfo}. */ + private static class ActivityInfoPresentationGetter extends TargetPresentationGetter { + private final ActivityInfo mActivityInfo; + + ActivityInfoPresentationGetter( + Context context, int iconDpi, ActivityInfo activityInfo) { + super(context, iconDpi, activityInfo.applicationInfo); + mActivityInfo = activityInfo; + } + + @Override + protected Drawable getIconSubstituteInternal() { + Drawable drawable = null; + try { + // Do not use ActivityInfo#getIconResource() as it defaults to the app + if (mActivityInfo.icon != 0) { + drawable = loadIconFromResource( + mPm.getResourcesForApplication(mActivityInfo.applicationInfo), + mActivityInfo.icon); + } + } catch (PackageManager.NameNotFoundException e) { + Log.e(TAG, "SUBSTITUTE_SHARE_TARGET_APP_NAME_AND_ICON permission granted but " + + "couldn't find resources for package", e); + } + + return drawable; + } + + @Override + protected String getAppSubLabelInternal() { + // Will default to app name if no activity label set, make sure to check if subLabel + // matches label before final display + return (String) mActivityInfo.loadLabel(mPm); + } + + @Override + protected String getAppLabelForSubstitutePermission() { + return getAppSubLabelInternal(); + } + } +} diff --git a/java/src/com/android/intentresolver/WorkProfilePausedEmptyStateProvider.java b/java/src/com/android/intentresolver/WorkProfilePausedEmptyStateProvider.java new file mode 100644 index 00000000..b7c89907 --- /dev/null +++ b/java/src/com/android/intentresolver/WorkProfilePausedEmptyStateProvider.java @@ -0,0 +1,114 @@ +/* + * 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 android.app.admin.DevicePolicyResources.Strings.Core.RESOLVER_WORK_PAUSED_TITLE; + +import android.annotation.NonNull; +import android.annotation.Nullable; +import android.app.admin.DevicePolicyEventLogger; +import android.app.admin.DevicePolicyManager; +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; + +/** + * Chooser/ResolverActivity empty state provider that returns empty state which is shown when + * work profile is paused and we need to show a button to enable it. + */ +public class WorkProfilePausedEmptyStateProvider implements EmptyStateProvider { + + private final UserHandle mWorkProfileUserHandle; + private final QuietModeManager mQuietModeManager; + private final String mMetricsCategory; + private final OnSwitchOnWorkSelectedListener mOnSwitchOnWorkSelectedListener; + private final Context mContext; + + public WorkProfilePausedEmptyStateProvider(@NonNull Context context, + @Nullable UserHandle workProfileUserHandle, + @NonNull QuietModeManager quietModeManager, + @Nullable OnSwitchOnWorkSelectedListener onSwitchOnWorkSelectedListener, + @NonNull String metricsCategory) { + mContext = context; + mWorkProfileUserHandle = workProfileUserHandle; + mQuietModeManager = quietModeManager; + mMetricsCategory = metricsCategory; + mOnSwitchOnWorkSelectedListener = onSwitchOnWorkSelectedListener; + } + + @Nullable + @Override + public EmptyState getEmptyState(ResolverListAdapter resolverListAdapter) { + if (!resolverListAdapter.getUserHandle().equals(mWorkProfileUserHandle) + || !mQuietModeManager.isQuietModeEnabled(mWorkProfileUserHandle) + || resolverListAdapter.getCount() == 0) { + return null; + } + + final String title = mContext.getSystemService(DevicePolicyManager.class) + .getResources().getString(RESOLVER_WORK_PAUSED_TITLE, + () -> mContext.getString(R.string.resolver_turn_on_work_apps)); + + return new WorkProfileOffEmptyState(title, (tab) -> { + tab.showSpinner(); + if (mOnSwitchOnWorkSelectedListener != null) { + mOnSwitchOnWorkSelectedListener.onSwitchOnWorkSelected(); + } + mQuietModeManager.requestQuietModeEnabled(false, mWorkProfileUserHandle); + }, mMetricsCategory); + } + + public static class WorkProfileOffEmptyState implements EmptyState { + + private final String mTitle; + private final ClickListener mOnClick; + private final String mMetricsCategory; + + public WorkProfileOffEmptyState(String title, @NonNull ClickListener onClick, + @NonNull String metricsCategory) { + mTitle = title; + mOnClick = onClick; + mMetricsCategory = metricsCategory; + } + + @Nullable + @Override + public String getTitle() { + return mTitle; + } + + @Nullable + @Override + public ClickListener getButtonClickListener() { + return mOnClick; + } + + @Override + public void onEmptyStateShown() { + DevicePolicyEventLogger + .createEvent(DevicePolicyEnums.RESOLVER_EMPTY_STATE_WORK_APPS_DISABLED) + .setStrings(mMetricsCategory) + .write(); + } + } +} diff --git a/java/src/com/android/intentresolver/chooser/ChooserTargetInfo.java b/java/src/com/android/intentresolver/chooser/ChooserTargetInfo.java index 1c763071..8b9bfb32 100644 --- a/java/src/com/android/intentresolver/chooser/ChooserTargetInfo.java +++ b/java/src/com/android/intentresolver/chooser/ChooserTargetInfo.java @@ -16,38 +16,27 @@ package com.android.intentresolver.chooser; -import android.service.chooser.ChooserTarget; -import android.text.TextUtils; +import java.util.ArrayList; +import java.util.Arrays; /** * A TargetInfo for Direct Share. Includes a {@link ChooserTarget} representing the * Direct Share deep link into an application. */ -public interface ChooserTargetInfo extends TargetInfo { - float getModifiedScore(); +public abstract class ChooserTargetInfo implements TargetInfo { - ChooserTarget getChooserTarget(); - - /** - * Do not label as 'equals', since this doesn't quite work - * as intended with java 8. - */ - default boolean isSimilar(ChooserTargetInfo other) { - if (other == null) return false; - - ChooserTarget ct1 = getChooserTarget(); - ChooserTarget ct2 = other.getChooserTarget(); - - // If either is null, there is not enough info to make an informed decision - // about equality, so just exit - if (ct1 == null || ct2 == null) return false; + @Override + public final boolean isChooserTargetInfo() { + return true; + } - if (ct1.getComponentName().equals(ct2.getComponentName()) - && TextUtils.equals(getDisplayLabel(), other.getDisplayLabel()) - && TextUtils.equals(getExtendedInfo(), other.getExtendedInfo())) { - return true; + @Override + public ArrayList<DisplayResolveInfo> getAllDisplayTargets() { + // TODO: consider making this the default behavior for all `TargetInfo` implementations + // (if it's reasonable for `DisplayResolveInfo.getDisplayResolveInfo()` to return `this`). + if (getDisplayResolveInfo() == null) { + return new ArrayList<>(); } - - return false; + return new ArrayList<>(Arrays.asList(getDisplayResolveInfo())); } } diff --git a/java/src/com/android/intentresolver/chooser/DisplayResolveInfo.java b/java/src/com/android/intentresolver/chooser/DisplayResolveInfo.java index e7ffe3c6..db5ae0b4 100644 --- a/java/src/com/android/intentresolver/chooser/DisplayResolveInfo.java +++ b/java/src/com/android/intentresolver/chooser/DisplayResolveInfo.java @@ -20,84 +20,122 @@ import android.annotation.NonNull; import android.annotation.Nullable; import android.app.Activity; import android.content.ComponentName; -import android.content.Context; import android.content.Intent; import android.content.pm.ActivityInfo; import android.content.pm.ApplicationInfo; import android.content.pm.ResolveInfo; -import android.graphics.drawable.Drawable; import android.os.Bundle; -import android.os.Parcel; -import android.os.Parcelable; import android.os.UserHandle; import com.android.intentresolver.ResolverActivity; -import com.android.intentresolver.ResolverListAdapter.ResolveInfoPresentationGetter; +import com.android.intentresolver.TargetPresentationGetter; import java.util.ArrayList; +import java.util.Arrays; import java.util.List; /** * A TargetInfo plus additional information needed to render it (such as icon and label) and * resolve it to an activity. */ -public class DisplayResolveInfo implements TargetInfo, Parcelable { +public class DisplayResolveInfo implements TargetInfo { private final ResolveInfo mResolveInfo; private CharSequence mDisplayLabel; - private Drawable mDisplayIcon; private CharSequence mExtendedInfo; private final Intent mResolvedIntent; private final List<Intent> mSourceIntents = new ArrayList<>(); - private boolean mIsSuspended; - private ResolveInfoPresentationGetter mResolveInfoPresentationGetter; + private final boolean mIsSuspended; + private TargetPresentationGetter mPresentationGetter; private boolean mPinned = false; - - public DisplayResolveInfo(Intent originalIntent, ResolveInfo pri, Intent pOrigIntent, - ResolveInfoPresentationGetter resolveInfoPresentationGetter) { - this(originalIntent, pri, null /*mDisplayLabel*/, null /*mExtendedInfo*/, pOrigIntent, - resolveInfoPresentationGetter); - } - - public DisplayResolveInfo(Intent originalIntent, ResolveInfo pri, CharSequence pLabel, - CharSequence pInfo, @NonNull Intent resolvedIntent, - @Nullable ResolveInfoPresentationGetter resolveInfoPresentationGetter) { + private final IconHolder mDisplayIconHolder = new SettableIconHolder(); + + /** Create a new {@code DisplayResolveInfo} instance. */ + public static DisplayResolveInfo newDisplayResolveInfo( + Intent originalIntent, + ResolveInfo resolveInfo, + @NonNull Intent resolvedIntent, + @Nullable TargetPresentationGetter presentationGetter) { + return newDisplayResolveInfo( + originalIntent, + resolveInfo, + /* displayLabel=*/ null, + /* extendedInfo=*/ null, + resolvedIntent, + presentationGetter); + } + + /** Create a new {@code DisplayResolveInfo} instance. */ + public static DisplayResolveInfo newDisplayResolveInfo( + Intent originalIntent, + ResolveInfo resolveInfo, + CharSequence displayLabel, + CharSequence extendedInfo, + @NonNull Intent resolvedIntent, + @Nullable TargetPresentationGetter presentationGetter) { + return new DisplayResolveInfo( + originalIntent, + resolveInfo, + displayLabel, + extendedInfo, + resolvedIntent, + presentationGetter); + } + + private DisplayResolveInfo( + Intent originalIntent, + ResolveInfo resolveInfo, + CharSequence displayLabel, + CharSequence extendedInfo, + @NonNull Intent resolvedIntent, + @Nullable TargetPresentationGetter presentationGetter) { mSourceIntents.add(originalIntent); - mResolveInfo = pri; - mDisplayLabel = pLabel; - mExtendedInfo = pInfo; - mResolveInfoPresentationGetter = resolveInfoPresentationGetter; + mResolveInfo = resolveInfo; + mDisplayLabel = displayLabel; + mExtendedInfo = extendedInfo; + mPresentationGetter = presentationGetter; + + 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); - final ActivityInfo ai = mResolveInfo.activityInfo; intent.setComponent(new ComponentName(ai.applicationInfo.packageName, ai.name)); - - mIsSuspended = (ai.applicationInfo.flags & ApplicationInfo.FLAG_SUSPENDED) != 0; - mResolvedIntent = intent; } - private DisplayResolveInfo(DisplayResolveInfo other, Intent fillInIntent, int flags, - ResolveInfoPresentationGetter resolveInfoPresentationGetter) { + private DisplayResolveInfo( + DisplayResolveInfo other, + Intent fillInIntent, + int flags, + TargetPresentationGetter presentationGetter) { mSourceIntents.addAll(other.getAllSourceIntents()); mResolveInfo = other.mResolveInfo; + mIsSuspended = other.mIsSuspended; mDisplayLabel = other.mDisplayLabel; - mDisplayIcon = other.mDisplayIcon; mExtendedInfo = other.mExtendedInfo; mResolvedIntent = new Intent(other.mResolvedIntent); mResolvedIntent.fillIn(fillInIntent, flags); - mResolveInfoPresentationGetter = resolveInfoPresentationGetter; + mPresentationGetter = presentationGetter; + + mDisplayIconHolder.setDisplayIcon(other.mDisplayIconHolder.getDisplayIcon()); } - DisplayResolveInfo(DisplayResolveInfo other) { + protected DisplayResolveInfo(DisplayResolveInfo other) { mSourceIntents.addAll(other.getAllSourceIntents()); mResolveInfo = other.mResolveInfo; + mIsSuspended = other.mIsSuspended; mDisplayLabel = other.mDisplayLabel; - mDisplayIcon = other.mDisplayIcon; mExtendedInfo = other.mExtendedInfo; mResolvedIntent = other.mResolvedIntent; - mResolveInfoPresentationGetter = other.mResolveInfoPresentationGetter; + mPresentationGetter = other.mPresentationGetter; + + mDisplayIconHolder.setDisplayIcon(other.mDisplayIconHolder.getDisplayIcon()); + } + + @Override + public final boolean isDisplayResolveInfo() { + return true; } public ResolveInfo getResolveInfo() { @@ -105,9 +143,9 @@ public class DisplayResolveInfo implements TargetInfo, Parcelable { } public CharSequence getDisplayLabel() { - if (mDisplayLabel == null && mResolveInfoPresentationGetter != null) { - mDisplayLabel = mResolveInfoPresentationGetter.getLabel(); - mExtendedInfo = mResolveInfoPresentationGetter.getSubLabel(); + if (mDisplayLabel == null && mPresentationGetter != null) { + mDisplayLabel = mPresentationGetter.getLabel(); + mExtendedInfo = mPresentationGetter.getSubLabel(); } return mDisplayLabel; } @@ -124,13 +162,14 @@ public class DisplayResolveInfo implements TargetInfo, Parcelable { mExtendedInfo = extendedInfo; } - public Drawable getDisplayIcon(Context context) { - return mDisplayIcon; + @Override + public IconHolder getDisplayIconHolder() { + return mDisplayIconHolder; } @Override public TargetInfo cloneFilledIn(Intent fillInIntent, int flags) { - return new DisplayResolveInfo(this, fillInIntent, flags, mResolveInfoPresentationGetter); + return new DisplayResolveInfo(this, fillInIntent, flags, mPresentationGetter); } @Override @@ -138,16 +177,13 @@ public class DisplayResolveInfo implements TargetInfo, Parcelable { return mSourceIntents; } - public void addAlternateSourceIntent(Intent alt) { - mSourceIntents.add(alt); - } - - public void setDisplayIcon(Drawable icon) { - mDisplayIcon = icon; + @Override + public ArrayList<DisplayResolveInfo> getAllDisplayTargets() { + return new ArrayList<>(Arrays.asList(this)); } - public boolean hasDisplayIcon() { - return mDisplayIcon != null; + public void addAlternateSourceIntent(Intent alt) { + mSourceIntents.add(alt); } public CharSequence getExtendedInfo() { @@ -172,14 +208,14 @@ public class DisplayResolveInfo implements TargetInfo, Parcelable { @Override public boolean startAsCaller(ResolverActivity activity, Bundle options, int userId) { - prepareIntentForCrossProfileLaunch(mResolvedIntent, userId); + TargetInfo.prepareIntentForCrossProfileLaunch(mResolvedIntent, userId); activity.startActivityAsCaller(mResolvedIntent, options, false, userId); return true; } @Override public boolean startAsUser(Activity activity, Bundle options, UserHandle user) { - prepareIntentForCrossProfileLaunch(mResolvedIntent, user.getIdentifier()); + TargetInfo.prepareIntentForCrossProfileLaunch(mResolvedIntent, user.getIdentifier()); activity.startActivityAsUser(mResolvedIntent, options, user); return false; } @@ -196,48 +232,4 @@ public class DisplayResolveInfo implements TargetInfo, Parcelable { public void setPinned(boolean pinned) { mPinned = pinned; } - - @Override - public int describeContents() { - return 0; - } - - @Override - public void writeToParcel(Parcel dest, int flags) { - dest.writeCharSequence(mDisplayLabel); - dest.writeCharSequence(mExtendedInfo); - dest.writeParcelable(mResolvedIntent, 0); - dest.writeTypedList(mSourceIntents); - dest.writeBoolean(mIsSuspended); - dest.writeBoolean(mPinned); - dest.writeParcelable(mResolveInfo, 0); - } - - public static final Parcelable.Creator<DisplayResolveInfo> CREATOR = - new Parcelable.Creator<DisplayResolveInfo>() { - public DisplayResolveInfo createFromParcel(Parcel in) { - return new DisplayResolveInfo(in); - } - - public DisplayResolveInfo[] newArray(int size) { - return new DisplayResolveInfo[size]; - } - }; - - private static void prepareIntentForCrossProfileLaunch(Intent intent, int targetUserId) { - final int currentUserId = UserHandle.myUserId(); - if (targetUserId != currentUserId) { - intent.fixUris(currentUserId); - } - } - - private DisplayResolveInfo(Parcel in) { - mDisplayLabel = in.readCharSequence(); - mExtendedInfo = in.readCharSequence(); - mResolvedIntent = in.readParcelable(null /* ClassLoader */, android.content.Intent.class); - in.readTypedList(mSourceIntents, Intent.CREATOR); - mIsSuspended = in.readBoolean(); - mPinned = in.readBoolean(); - mResolveInfo = in.readParcelable(null /* ClassLoader */, android.content.pm.ResolveInfo.class); - } } diff --git a/java/src/com/android/intentresolver/chooser/MultiDisplayResolveInfo.java b/java/src/com/android/intentresolver/chooser/MultiDisplayResolveInfo.java index 5133d997..29f00a35 100644 --- a/java/src/com/android/intentresolver/chooser/MultiDisplayResolveInfo.java +++ b/java/src/com/android/intentresolver/chooser/MultiDisplayResolveInfo.java @@ -23,6 +23,7 @@ import android.os.UserHandle; import com.android.intentresolver.ResolverActivity; import java.util.ArrayList; +import java.util.List; /** * Represents a "stack" of chooser targets for various activities within the same component. @@ -30,18 +31,31 @@ import java.util.ArrayList; public class MultiDisplayResolveInfo extends DisplayResolveInfo { ArrayList<DisplayResolveInfo> mTargetInfos = new ArrayList<>(); - // We'll use this DRI for basic presentation info - eg icon, name. - final DisplayResolveInfo mBaseInfo; + // Index of selected target private int mSelected = -1; /** - * @param firstInfo A representative DRI to use for the main icon, title, etc for this Info. + * @param targetInfos A list of targets in this stack. The first item is treated as the + * "representative" that provides the main icon, title, etc. */ - public MultiDisplayResolveInfo(String packageName, DisplayResolveInfo firstInfo) { - super(firstInfo); - mBaseInfo = firstInfo; - mTargetInfos.add(firstInfo); + public static MultiDisplayResolveInfo newMultiDisplayResolveInfo( + List<DisplayResolveInfo> targetInfos) { + return new MultiDisplayResolveInfo(targetInfos); + } + + /** + * @param targetInfos A list of targets in this stack. The first item is treated as the + * "representative" that provides the main icon, title, etc. + */ + private MultiDisplayResolveInfo(List<DisplayResolveInfo> targetInfos) { + super(targetInfos.get(0)); + mTargetInfos = new ArrayList<>(targetInfos); + } + + @Override + public final boolean isMultiDisplayResolveInfo() { + return true; } @Override @@ -51,16 +65,12 @@ public class MultiDisplayResolveInfo extends DisplayResolveInfo { } /** - * Add another DisplayResolveInfo to the list included for this target. + * 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. */ - public void addTarget(DisplayResolveInfo target) { - mTargetInfos.add(target); - } - - /** - * List of all DisplayResolveInfos included in this target. - */ - public ArrayList<DisplayResolveInfo> getTargets() { + @Override + public ArrayList<DisplayResolveInfo> getAllDisplayTargets() { return mTargetInfos; } @@ -96,5 +106,4 @@ public class MultiDisplayResolveInfo extends DisplayResolveInfo { public boolean startAsUser(Activity activity, Bundle options, UserHandle user) { return mTargetInfos.get(mSelected).startAsUser(activity, options, user); } - } diff --git a/java/src/com/android/intentresolver/chooser/NotSelectableTargetInfo.java b/java/src/com/android/intentresolver/chooser/NotSelectableTargetInfo.java index 220870f2..d6333374 100644 --- a/java/src/com/android/intentresolver/chooser/NotSelectableTargetInfo.java +++ b/java/src/com/android/intentresolver/chooser/NotSelectableTargetInfo.java @@ -18,12 +18,15 @@ package com.android.intentresolver.chooser; 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 android.service.chooser.ChooserTarget; +import com.android.intentresolver.R; import com.android.intentresolver.ResolverActivity; import java.util.List; @@ -32,7 +35,55 @@ import java.util.List; * 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 implements ChooserTargetInfo { +public abstract class NotSelectableTargetInfo extends ChooserTargetInfo { + /** Create a non-selectable {@link TargetInfo} with no content. */ + public static TargetInfo newEmptyTargetInfo() { + return new NotSelectableTargetInfo() { + @Override + public boolean isEmptyTargetInfo() { + return true; + } + }; + } + + /** + * Create a non-selectable {@link TargetInfo} with placeholder content to be displayed + * 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; @@ -78,10 +129,6 @@ public abstract class NotSelectableTargetInfo implements ChooserTargetInfo { return -0.1f; } - public ChooserTarget getChooserTarget() { - return null; - } - public boolean isSuspended() { return false; } @@ -89,4 +136,17 @@ public abstract class NotSelectableTargetInfo implements ChooserTargetInfo { public boolean isPinned() { return false; } + + @Override + public IconHolder getDisplayIconHolder() { + return new IconHolder() { + @Override + public Drawable getDisplayIcon() { + return null; + } + + @Override + public void setDisplayIcon(Drawable icon) {} + }; + } } diff --git a/java/src/com/android/intentresolver/chooser/SelectableTargetInfo.java b/java/src/com/android/intentresolver/chooser/SelectableTargetInfo.java index 1610d0fd..3ab50175 100644 --- a/java/src/com/android/intentresolver/chooser/SelectableTargetInfo.java +++ b/java/src/com/android/intentresolver/chooser/SelectableTargetInfo.java @@ -18,31 +18,23 @@ package com.android.intentresolver.chooser; 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.ActivityInfo; -import android.content.pm.ApplicationInfo; -import android.content.pm.LauncherApps; -import android.content.pm.PackageManager; import android.content.pm.ResolveInfo; import android.content.pm.ShortcutInfo; -import android.graphics.Bitmap; -import android.graphics.drawable.BitmapDrawable; -import android.graphics.drawable.Drawable; import android.graphics.drawable.Icon; import android.os.Bundle; import android.os.UserHandle; +import android.provider.DeviceConfig; import android.service.chooser.ChooserTarget; import android.text.SpannableStringBuilder; +import android.util.HashedStringCache; import android.util.Log; -import com.android.intentresolver.ChooserActivity; import com.android.intentresolver.ResolverActivity; -import com.android.intentresolver.ResolverListAdapter.ActivityInfoPresentationGetter; -import com.android.intentresolver.SimpleIconFactory; - -import com.android.internal.annotations.GuardedBy; +import com.android.internal.config.sysui.SystemUiDeviceConfigFlags; import java.util.ArrayList; import java.util.List; @@ -51,237 +43,312 @@ import java.util.List; * Live target, currently selectable by the user. * @see NotSelectableTargetInfo */ -public final class SelectableTargetInfo implements ChooserTargetInfo { +public final class SelectableTargetInfo extends ChooserTargetInfo { private static final String TAG = "SelectableTargetInfo"; - private final Context mContext; + private interface TargetHashProvider { + HashedStringCache.HashResult getHashedTargetIdForMetrics(Context context); + } + + private interface TargetActivityStarter { + boolean start(Activity activity, Bundle options); + boolean startAsCaller(Activity activity, Bundle options, int userId); + boolean startAsUser(Activity activity, Bundle options, UserHandle user); + } + + private static final String HASHED_STRING_CACHE_TAG = "ChooserActivity"; // For legacy reasons. + private static final int DEFAULT_SALT_EXPIRATION_DAYS = 7; + + private final int mMaxHashSaltDays = DeviceConfig.getInt( + DeviceConfig.NAMESPACE_SYSTEMUI, + SystemUiDeviceConfigFlags.HASH_SALT_MAX_DAYS, + DEFAULT_SALT_EXPIRATION_DAYS); + + @Nullable private final DisplayResolveInfo mSourceInfo; + @Nullable private final ResolveInfo mBackupResolveInfo; - private final ChooserTarget mChooserTarget; + private final Intent mResolvedIntent; private final String mDisplayLabel; - private final PackageManager mPm; - private final SelectableTargetInfoCommunicator mSelectableTargetInfoCommunicator; - @GuardedBy("this") - private ShortcutInfo mShortcutInfo; - private Drawable mBadgeIcon = null; - private CharSequence mBadgeContentDescription; - @GuardedBy("this") - private Drawable mDisplayIcon; - private final Intent mFillInIntent; + @Nullable + private final AppTarget mAppTarget; + @Nullable + private final ShortcutInfo mShortcutInfo; + + private final ComponentName mChooserTargetComponentName; + 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 boolean mIsSuspended = false; + private final boolean mIsSuspended; + private final ComponentName mResolvedComponentName; + private final Intent mBaseIntentToSend; + private final ResolveInfo mResolveInfo; + private final List<Intent> mAllSourceIntents; + private final IconHolder mDisplayIconHolder = new SettableIconHolder(); + private final TargetHashProvider mHashProvider; + 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}. + */ + private final Intent mReferrerFillInIntent; - public SelectableTargetInfo(Context context, DisplayResolveInfo sourceInfo, + /** + * Create a new {@link TargetInfo} instance representing a selectable target. Some target + * parameters are copied over from the (deprecated) legacy {@link ChooserTarget} structure. + * + * @deprecated Use the overload that doesn't call for a {@link ChooserTarget}. + */ + @Deprecated + public static TargetInfo newSelectableTargetInfo( + @Nullable DisplayResolveInfo sourceInfo, + @Nullable ResolveInfo backupResolveInfo, + Intent resolvedIntent, ChooserTarget chooserTarget, - float modifiedScore, SelectableTargetInfoCommunicator selectableTargetInfoComunicator, - @Nullable ShortcutInfo shortcutInfo) { - mContext = context; + float modifiedScore, + @Nullable ShortcutInfo shortcutInfo, + @Nullable AppTarget appTarget, + Intent referrerFillInIntent) { + return newSelectableTargetInfo( + sourceInfo, + backupResolveInfo, + resolvedIntent, + chooserTarget.getComponentName(), + chooserTarget.getTitle(), + chooserTarget.getIcon(), + chooserTarget.getIntentExtras(), + modifiedScore, + shortcutInfo, + appTarget, + referrerFillInIntent); + } + + /** + * Create a new {@link TargetInfo} instance representing a selectable target. `chooserTarget*` + * parameters were historically retrieved from (now-deprecated) {@link ChooserTarget} structures + * even when the {@link TargetInfo} was a system (internal) synthesized target that never needed + * to be represented as a {@link ChooserTarget}. The values passed here are copied in directly + * as if they had been provided in the legacy representation. + * + * TODO: clarify semantics of how clients use the `getChooserTarget*()` methods; refactor/rename + * to avoid making reference to the legacy type; and reflect the improved semantics in the + * signature (and documentation) of this method. + */ + public static TargetInfo newSelectableTargetInfo( + @Nullable DisplayResolveInfo sourceInfo, + @Nullable ResolveInfo backupResolveInfo, + Intent resolvedIntent, + ComponentName chooserTargetComponentName, + CharSequence chooserTargetUnsanitizedTitle, + Icon chooserTargetIcon, + @Nullable Bundle chooserTargetIntentExtras, + float modifiedScore, + @Nullable ShortcutInfo shortcutInfo, + @Nullable AppTarget appTarget, + Intent referrerFillInIntent) { + return new SelectableTargetInfo( + sourceInfo, + backupResolveInfo, + resolvedIntent, + chooserTargetComponentName, + chooserTargetUnsanitizedTitle, + chooserTargetIcon, + chooserTargetIntentExtras, + modifiedScore, + shortcutInfo, + appTarget, + referrerFillInIntent, + /* fillInIntent = */ null, + /* fillInFlags = */ 0); + } + + private SelectableTargetInfo( + @Nullable DisplayResolveInfo sourceInfo, + @Nullable ResolveInfo backupResolveInfo, + Intent resolvedIntent, + ComponentName chooserTargetComponentName, + CharSequence chooserTargetUnsanitizedTitle, + Icon chooserTargetIcon, + Bundle chooserTargetIntentExtras, + float modifiedScore, + @Nullable ShortcutInfo shortcutInfo, + @Nullable AppTarget appTarget, + Intent referrerFillInIntent, + @Nullable Intent fillInIntent, + int fillInFlags) { mSourceInfo = sourceInfo; - mChooserTarget = chooserTarget; + mBackupResolveInfo = backupResolveInfo; + mResolvedIntent = resolvedIntent; mModifiedScore = modifiedScore; - mPm = mContext.getPackageManager(); - mSelectableTargetInfoCommunicator = selectableTargetInfoComunicator; mShortcutInfo = shortcutInfo; - mIsPinned = shortcutInfo != null && shortcutInfo.isPinned(); - if (sourceInfo != null) { - final ResolveInfo ri = sourceInfo.getResolveInfo(); - if (ri != null) { - final ActivityInfo ai = ri.activityInfo; - if (ai != null && ai.applicationInfo != null) { - final PackageManager pm = mContext.getPackageManager(); - mBadgeIcon = pm.getApplicationIcon(ai.applicationInfo); - mBadgeContentDescription = pm.getApplicationLabel(ai.applicationInfo); - mIsSuspended = - (ai.applicationInfo.flags & ApplicationInfo.FLAG_SUSPENDED) != 0; - } + mAppTarget = appTarget; + mReferrerFillInIntent = referrerFillInIntent; + mFillInIntent = fillInIntent; + mFillInFlags = fillInFlags; + mChooserTargetComponentName = chooserTargetComponentName; + mChooserTargetUnsanitizedTitle = chooserTargetUnsanitizedTitle; + mChooserTargetIcon = chooserTargetIcon; + mChooserTargetIntentExtras = chooserTargetIntentExtras; + + mIsPinned = (shortcutInfo != null) && shortcutInfo.isPinned(); + mDisplayLabel = sanitizeDisplayLabel(mChooserTargetUnsanitizedTitle); + mIsSuspended = (mSourceInfo != null) && mSourceInfo.isSuspended(); + mResolveInfo = (mSourceInfo != null) ? mSourceInfo.getResolveInfo() : mBackupResolveInfo; + + mResolvedComponentName = getResolvedComponentName(mSourceInfo, mBackupResolveInfo); + + mAllSourceIntents = getAllSourceIntents(sourceInfo); + + mBaseIntentToSend = getBaseIntentToSend( + mResolvedIntent, + mFillInIntent, + mFillInFlags, + mReferrerFillInIntent); + + mHashProvider = context -> { + final String plaintext = + getChooserTargetComponentName().getPackageName() + + mChooserTargetUnsanitizedTitle; + return HashedStringCache.getInstance().hashString( + context, + HASHED_STRING_CACHE_TAG, + plaintext, + mMaxHashSaltDays); + }; + + mActivityStarter = new TargetActivityStarter() { + @Override + public boolean start(Activity activity, Bundle options) { + throw new RuntimeException("ChooserTargets should be started as caller."); } - } - if (sourceInfo != null) { - mBackupResolveInfo = null; - } else { - mBackupResolveInfo = - mContext.getPackageManager().resolveActivity(getResolvedIntent(), 0); - } + @Override + public boolean startAsCaller(Activity activity, Bundle options, int userId) { + final Intent intent = mBaseIntentToSend; + if (intent == null) { + return false; + } + intent.setComponent(getChooserTargetComponentName()); + intent.putExtras(mChooserTargetIntentExtras); + TargetInfo.prepareIntentForCrossProfileLaunch(intent, userId); + + // Important: we will ignore the target security checks in ActivityManager if and + // only if the ChooserTarget's target package is the same package where we got the + // ChooserTargetService that provided it. This lets a ChooserTargetService provide + // a non-exported or permission-guarded target for the user to pick. + // + // If mSourceInfo is null, we got this ChooserTarget from the caller or elsewhere + // so we'll obey the caller's normal security checks. + final boolean ignoreTargetSecurity = (mSourceInfo != null) + && mSourceInfo.getResolvedComponentName().getPackageName() + .equals(getChooserTargetComponentName().getPackageName()); + activity.startActivityAsCaller(intent, options, ignoreTargetSecurity, userId); + return true; + } - mFillInIntent = null; - mFillInFlags = 0; + @Override + public boolean startAsUser(Activity activity, Bundle options, UserHandle user) { + throw new RuntimeException("ChooserTargets should be started as caller."); + } + }; + } - mDisplayLabel = sanitizeDisplayLabel(chooserTarget.getTitle()); + private SelectableTargetInfo(SelectableTargetInfo other, Intent fillInIntent, int flags) { + this( + other.mSourceInfo, + other.mBackupResolveInfo, + other.mResolvedIntent, + other.mChooserTargetComponentName, + other.mChooserTargetUnsanitizedTitle, + other.mChooserTargetIcon, + other.mChooserTargetIntentExtras, + other.mModifiedScore, + other.mShortcutInfo, + other.mAppTarget, + other.mReferrerFillInIntent, + fillInIntent, + flags); } - private SelectableTargetInfo(SelectableTargetInfo other, - Intent fillInIntent, int flags) { - mContext = other.mContext; - mPm = other.mPm; - mSelectableTargetInfoCommunicator = other.mSelectableTargetInfoCommunicator; - mSourceInfo = other.mSourceInfo; - mBackupResolveInfo = other.mBackupResolveInfo; - mChooserTarget = other.mChooserTarget; - mBadgeIcon = other.mBadgeIcon; - mBadgeContentDescription = other.mBadgeContentDescription; - synchronized (other) { - mShortcutInfo = other.mShortcutInfo; - mDisplayIcon = other.mDisplayIcon; - } - mFillInIntent = fillInIntent; - mFillInFlags = flags; - mModifiedScore = other.mModifiedScore; - mIsPinned = other.mIsPinned; + @Override + public TargetInfo cloneFilledIn(Intent fillInIntent, int flags) { + return new SelectableTargetInfo(this, fillInIntent, flags); + } - mDisplayLabel = sanitizeDisplayLabel(mChooserTarget.getTitle()); + @Override + public HashedStringCache.HashResult getHashedTargetIdForMetrics(Context context) { + return mHashProvider.getHashedTargetIdForMetrics(context); } - private String sanitizeDisplayLabel(CharSequence label) { - SpannableStringBuilder sb = new SpannableStringBuilder(label); - sb.clearSpans(); - return sb.toString(); + @Override + public boolean isSelectableTargetInfo() { + return true; } + @Override public boolean isSuspended() { return mIsSuspended; } + @Override + @Nullable public DisplayResolveInfo getDisplayResolveInfo() { return mSourceInfo; } - /** - * Load display icon, if needed. - */ - public void loadIcon() { - ShortcutInfo shortcutInfo; - Drawable icon; - synchronized (this) { - shortcutInfo = mShortcutInfo; - icon = mDisplayIcon; - } - if (icon == null && shortcutInfo != null) { - icon = getChooserTargetIconDrawable(mChooserTarget, shortcutInfo); - synchronized (this) { - mDisplayIcon = icon; - mShortcutInfo = null; - } - } - } - - private Drawable getChooserTargetIconDrawable(ChooserTarget target, - @Nullable ShortcutInfo shortcutInfo) { - Drawable directShareIcon = null; - - // First get the target drawable and associated activity info - final Icon icon = target.getIcon(); - if (icon != null) { - directShareIcon = icon.loadDrawable(mContext); - } else if (shortcutInfo != null) { - LauncherApps launcherApps = (LauncherApps) mContext.getSystemService( - Context.LAUNCHER_APPS_SERVICE); - directShareIcon = launcherApps.getShortcutIconDrawable(shortcutInfo, 0); - } - - if (directShareIcon == null) return null; - - ActivityInfo info = null; - try { - info = mPm.getActivityInfo(target.getComponentName(), 0); - } catch (PackageManager.NameNotFoundException error) { - Log.e(TAG, "Could not find activity associated with ChooserTarget"); - } - - if (info == null) return null; - - // Now fetch app icon and raster with no badging even in work profile - Bitmap appIcon = mSelectableTargetInfoCommunicator.makePresentationGetter(info) - .getIconBitmap(null); - - // Raster target drawable with appIcon as a badge - SimpleIconFactory sif = SimpleIconFactory.obtain(mContext); - Bitmap directShareBadgedIcon = sif.createAppBadgedIconBitmap(directShareIcon, appIcon); - sif.recycle(); - - return new BitmapDrawable(mContext.getResources(), directShareBadgedIcon); - } - + @Override public float getModifiedScore() { return mModifiedScore; } @Override public Intent getResolvedIntent() { - if (mSourceInfo != null) { - return mSourceInfo.getResolvedIntent(); - } - - final Intent targetIntent = new Intent(mSelectableTargetInfoCommunicator.getTargetIntent()); - targetIntent.setComponent(mChooserTarget.getComponentName()); - targetIntent.putExtras(mChooserTarget.getIntentExtras()); - return targetIntent; + return mResolvedIntent; } @Override public ComponentName getResolvedComponentName() { - if (mSourceInfo != null) { - return mSourceInfo.getResolvedComponentName(); - } else if (mBackupResolveInfo != null) { - return new ComponentName(mBackupResolveInfo.activityInfo.packageName, - mBackupResolveInfo.activityInfo.name); - } - return null; + return mResolvedComponentName; } - private Intent getBaseIntentToSend() { - Intent result = getResolvedIntent(); - if (result == null) { - Log.e(TAG, "ChooserTargetInfo: no base intent available to send"); - } else { - result = new Intent(result); - if (mFillInIntent != null) { - result.fillIn(mFillInIntent, mFillInFlags); - } - result.fillIn(mSelectableTargetInfoCommunicator.getReferrerFillInIntent(), 0); - } - return result; + @Override + public ComponentName getChooserTargetComponentName() { + return mChooserTargetComponentName; + } + + @Nullable + public Icon getChooserTargetIcon() { + return mChooserTargetIcon; } @Override public boolean start(Activity activity, Bundle options) { - throw new RuntimeException("ChooserTargets should be started as caller."); + return mActivityStarter.start(activity, options); } @Override public boolean startAsCaller(ResolverActivity activity, Bundle options, int userId) { - final Intent intent = getBaseIntentToSend(); - if (intent == null) { - return false; - } - intent.setComponent(mChooserTarget.getComponentName()); - intent.putExtras(mChooserTarget.getIntentExtras()); - - // Important: we will ignore the target security checks in ActivityManager - // if and only if the ChooserTarget's target package is the same package - // where we got the ChooserTargetService that provided it. This lets a - // ChooserTargetService provide a non-exported or permission-guarded target - // to the chooser for the user to pick. - // - // If mSourceInfo is null, we got this ChooserTarget from the caller or elsewhere - // so we'll obey the caller's normal security checks. - final boolean ignoreTargetSecurity = mSourceInfo != null - && mSourceInfo.getResolvedComponentName().getPackageName() - .equals(mChooserTarget.getComponentName().getPackageName()); - activity.startActivityAsCaller(intent, options, ignoreTargetSecurity, userId); - return true; + return mActivityStarter.startAsCaller(activity, options, userId); } @Override public boolean startAsUser(Activity activity, Bundle options, UserHandle user) { - throw new RuntimeException("ChooserTargets should be started as caller."); + return mActivityStarter.startAsUser(activity, options, user); } @Override public ResolveInfo getResolveInfo() { - return mSourceInfo != null ? mSourceInfo.getResolveInfo() : mBackupResolveInfo; + return mResolveInfo; } @Override @@ -296,27 +363,25 @@ public final class SelectableTargetInfo implements ChooserTargetInfo { } @Override - public synchronized Drawable getDisplayIcon(Context context) { - return mDisplayIcon; + public IconHolder getDisplayIconHolder() { + return mDisplayIconHolder; } - public ChooserTarget getChooserTarget() { - return mChooserTarget; + @Override + @Nullable + public ShortcutInfo getDirectShareShortcutInfo() { + return mShortcutInfo; } @Override - public TargetInfo cloneFilledIn(Intent fillInIntent, int flags) { - return new SelectableTargetInfo(this, fillInIntent, flags); + @Nullable + public AppTarget getDirectShareAppTarget() { + return mAppTarget; } @Override public List<Intent> getAllSourceIntents() { - final List<Intent> results = new ArrayList<>(); - if (mSourceInfo != null) { - // We only queried the service for the first one in our sourceinfo. - results.add(mSourceInfo.getAllSourceIntents().get(0)); - } - return results; + return mAllSourceIntents; } @Override @@ -324,16 +389,49 @@ public final class SelectableTargetInfo implements ChooserTargetInfo { return mIsPinned; } - /** - * Necessary methods to communicate between {@link SelectableTargetInfo} - * and {@link ResolverActivity} or {@link ChooserActivity}. - */ - public interface SelectableTargetInfoCommunicator { + private static String sanitizeDisplayLabel(CharSequence label) { + SpannableStringBuilder sb = new SpannableStringBuilder(label); + sb.clearSpans(); + return sb.toString(); + } - ActivityInfoPresentationGetter makePresentationGetter(ActivityInfo info); + private static List<Intent> getAllSourceIntents(@Nullable DisplayResolveInfo sourceInfo) { + final List<Intent> results = new ArrayList<>(); + if (sourceInfo != null) { + // We only queried the service for the first one in our sourceinfo. + results.add(sourceInfo.getAllSourceIntents().get(0)); + } + return results; + } - Intent getTargetIntent(); + private static ComponentName getResolvedComponentName( + @Nullable DisplayResolveInfo sourceInfo, ResolveInfo backupResolveInfo) { + if (sourceInfo != null) { + return sourceInfo.getResolvedComponentName(); + } else if (backupResolveInfo != null) { + return new ComponentName( + backupResolveInfo.activityInfo.packageName, + backupResolveInfo.activityInfo.name); + } + return null; + } - Intent getReferrerFillInIntent(); + @Nullable + private static Intent getBaseIntentToSend( + @Nullable Intent resolvedIntent, + Intent fillInIntent, + int fillInFlags, + Intent referrerFillInIntent) { + Intent result = resolvedIntent; + 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 fabb26c2..72dd1b0b 100644 --- a/java/src/com/android/intentresolver/chooser/TargetInfo.java +++ b/java/src/com/android/intentresolver/chooser/TargetInfo.java @@ -17,23 +17,67 @@ package com.android.intentresolver.chooser; +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.graphics.drawable.Drawable; import android.os.Bundle; import android.os.UserHandle; +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; /** * A single target as represented in the chooser. */ public interface TargetInfo { + + /** + * Container for a {@link TargetInfo}'s (potentially) mutable icon state. This is provided to + * encapsulate the state so that the {@link TargetInfo} itself can be "immutable" (in some + * sense) as long as it always returns the same {@link IconHolder} instance. + * + * TODO: move "stateful" responsibilities out to clients; for more info see the Javadoc comment + * on {@link #getDisplayIconHolder()}. + */ + interface IconHolder { + /** @return the icon (if it's already loaded, or statically available), or null. */ + @Nullable + Drawable getDisplayIcon(); + + /** + * @param icon the icon to return on subsequent calls to {@link #getDisplayIcon()}. + * Implementations may discard this request as a no-op if they don't support setting. + */ + void setDisplayIcon(Drawable icon); + } + + /** A simple mutable-container implementation of {@link IconHolder}. */ + final class SettableIconHolder implements IconHolder { + @Nullable + private Drawable mDisplayIcon; + + @Nullable + public Drawable getDisplayIcon() { + return mDisplayIcon; + } + + public void setDisplayIcon(Drawable icon) { + mDisplayIcon = icon; + } + } + /** * Get the resolved intent that represents this target. Note that this may not be the * intent that will be launched by calling one of the <code>start</code> methods provided; @@ -46,13 +90,34 @@ public interface TargetInfo { /** * 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. + * methods provided; this is the component that will be credited with the launch. This may be + * null if the target was specified by a caller-provided {@link ChooserTarget} that we failed to + * resolve to a component on the system. * * @return the resolved ComponentName for this target */ + @Nullable ComponentName getResolvedComponentName(); /** + * If this target was historically built from a (now-deprecated) {@link ChooserTarget} record, + * get the {@link ComponentName} that would've been provided by that record. + * + * TODO: for (historical) {@link ChooserTargetInfo} targets, this differs from the result of + * {@link #getResolvedComponentName()} only for caller-provided targets that we fail to resolve; + * then this returns the name of the component that was requested, and the other returns null. + * At the time of writing, this method is only called in contexts where the client knows that + * the target was a historical {@link ChooserTargetInfo}. Thus this method could be removed and + * all clients consolidated on the other, if we have some alternate mechanism of tracking this + * discrepancy; or if we know that the distinction won't apply in the conditions when we call + * this method; or if we determine that tracking the distinction isn't a requirement for us. + */ + @Nullable + default ComponentName getChooserTargetComponentName() { + return null; + } + + /** * Start the activity referenced by this target. * * @param activity calling Activity performing the launch @@ -106,12 +171,23 @@ public interface TargetInfo { CharSequence getExtendedInfo(); /** - * @return The drawable that should be used to represent this target including badge - * @param context + * @return the {@link IconHolder} for the icon used to represent this target, including badge. + * + * TODO: while the {@link TargetInfo} may be immutable in always returning the same instance of + * {@link IconHolder} here, the holder itself is mutable state, and could become a problem if we + * ever rely on {@link TargetInfo} immutability elsewhere. Ideally, the {@link TargetInfo} + * should provide an immutable "spec" that tells clients <em>how</em> to load the appropriate + * icon, while leaving the load itself to some external component. */ - Drawable getDisplayIcon(Context context); + IconHolder getDisplayIconHolder(); /** + * @return true if display icon is available. + */ + default boolean hasDisplayIcon() { + return getDisplayIconHolder().getDisplayIcon() != null; + } + /** * Clone this target with the given fill-in information. */ TargetInfo cloneFilledIn(Intent fillInIntent, int flags); @@ -122,6 +198,28 @@ public interface TargetInfo { List<Intent> getAllSourceIntents(); /** + * @return the one or more {@link DisplayResolveInfo}s that this target represents in the UI. + * + * TODO: clarify the semantics of the {@link DisplayResolveInfo} branch of {@link TargetInfo}'s + * class hierarchy. Why is it that {@link MultiDisplayResolveInfo} can stand in for some + * "virtual" {@link DisplayResolveInfo} targets that aren't individually represented in the UI, + * but OTOH a {@link ChooserTargetInfo} (which doesn't inherit from {@link DisplayResolveInfo}) + * can't provide its own UI treatment, and instead needs us to reach into its composed-in + * info via {@link #getDisplayResolveInfo()}? It seems like {@link DisplayResolveInfo} may be + * required to populate views in our UI, while {@link ChooserTargetInfo} may carry some other + * metadata. For non-{@link ChooserTargetInfo} targets (e.g. in {@link ResolverActivity}) the + * "naked" {@link DisplayResolveInfo} might also be taken to provide some of this metadata, but + * this presents a denormalization hazard since the "UI info" ({@link DisplayResolveInfo}) that + * represents a {@link ChooserTargetInfo} might provide different values than its enclosing + * {@link ChooserTargetInfo} (as they both implement {@link TargetInfo}). We could try to + * address this by splitting {@link DisplayResolveInfo} into two types; one (which implements + * the same {@link TargetInfo} interface as {@link ChooserTargetInfo}) provides the previously- + * implicit "metadata", and the other provides only the UI treatment for a target of any type + * (taking over the respective methods that previously belonged to {@link TargetInfo}). + */ + ArrayList<DisplayResolveInfo> getAllDisplayTargets(); + + /** * @return true if this target cannot be selected by the user */ boolean isSuspended(); @@ -130,4 +228,220 @@ public interface TargetInfo { * @return true if this target should be pinned to the front by the request of the user */ boolean isPinned(); + + /** + * Determine whether two targets represent "similar" content that could be de-duped. + * Note an earlier version of this code cautioned maintainers, + * "do not label as 'equals', since this doesn't quite work as intended with java 8." + * This seems to refer to the rule that interfaces can't provide defaults that conflict with the + * definitions of "real" methods in {@code java.lang.Object}, and (if desired) it could be + * presumably resolved by converting {@code TargetInfo} from an interface to an abstract class. + */ + default boolean isSimilar(TargetInfo other) { + if (other == null) { + return false; + } + + // TODO: audit usage and try to reconcile a behavior that doesn't depend on the legacy + // subclass type. Note that the `isSimilar()` method was pulled up from the legacy + // `ChooserTargetInfo`, so no legacy behavior currently depends on calling `isSimilar()` on + // an instance where `isChooserTargetInfo()` would return false (although technically it may + // have been possible for the `other` target to be of a different type). Thus we have + // flexibility in defining the similarity conditions between pairs of non "chooser" targets. + if (isChooserTargetInfo()) { + return other.isChooserTargetInfo() + && Objects.equals( + getChooserTargetComponentName(), other.getChooserTargetComponentName()) + && TextUtils.equals(getDisplayLabel(), other.getDisplayLabel()) + && TextUtils.equals(getExtendedInfo(), other.getExtendedInfo()); + } else { + return !other.isChooserTargetInfo() && Objects.equals(this, other); + } + } + + /** + * @return the target score, including any Chooser-specific modifications that may have been + * applied (either overriding by special-case for "non-selectable" targets, or by twiddling the + * scores of "selectable" targets in {@link ChooserListAdapter}). Higher scores are "better." + * Targets that aren't intended for ranking/scoring should return a negative value. + */ + default float getModifiedScore() { + return -0.1f; + } + + /** + * @return the {@link ShortcutManager} data for any shortcut associated with this target. + */ + @Nullable + default ShortcutInfo getDirectShareShortcutInfo() { + return null; + } + + /** + * @return the ID of the shortcut represented by this target, or null if the target didn't come + * from a {@link ShortcutManager} shortcut. + */ + @Nullable + default String getDirectShareShortcutId() { + ShortcutInfo shortcut = getDirectShareShortcutInfo(); + if (shortcut == null) { + return null; + } + return shortcut.getId(); + } + + /** + * @return the {@link AppTarget} metadata if this target was sourced from App Prediction + * service, or null otherwise. + */ + @Nullable + default AppTarget getDirectShareAppTarget() { + return null; + } + + /** + * Get more info about this target in the form of a {@link DisplayResolveInfo}, if available. + * TODO: this seems to return non-null only for ChooserTargetInfo subclasses. Determine the + * meaning of a TargetInfo (ChooserTargetInfo) embedding another kind of TargetInfo + * (DisplayResolveInfo) in this way, and - at least - improve this documentation; OTOH this + * probably indicates an opportunity to simplify or better separate these APIs. (For example, + * targets that <em>don't</em> descend from ChooserTargetInfo instead descend directly from + * DisplayResolveInfo; should they return `this`? Do we always use DisplayResolveInfo to + * represent visual properties, and then either assume some implicit metadata properties *or* + * embed that visual representation within a ChooserTargetInfo to carry additional metadata? If + * that's the case, maybe we could decouple by saying that all TargetInfos compose-in their + * visual representation [as a DisplayResolveInfo, now the root of its own class hierarchy] and + * then add a new TargetInfo type that explicitly represents the "implicit metadata" that we + * previously assumed for "naked DisplayResolveInfo targets" that weren't wrapped as + * ChooserTargetInfos. Or does all this complexity disappear once we stop relying on the + * deprecated ChooserTarget type?) + */ + @Nullable + default DisplayResolveInfo getDisplayResolveInfo() { + return null; + } + + /** + * @return true if this target represents a legacy {@code ChooserTargetInfo}. These objects were + * historically documented as representing "[a] TargetInfo for Direct Share." However, not all + * of these targets are actually *valid* for direct share; e.g. some represent "empty" items + * (although perhaps only for display in the Direct Share UI?). In even earlier versions, these + * targets may also have been results from peers in the (now-deprecated/unsupported) + * {@code ChooserTargetService} ecosystem; even though we no longer use these services, we're + * still shoehorning other target data into the deprecated {@link ChooserTarget} structure for + * compatibility with some internal APIs. + * TODO: refactor to clarify the semantics of any target for which this method returns true + * (e.g., are they characterized by their application in the Direct Share UI?), and to remove + * the scaffolding that adapts to and from the {@link ChooserTarget} structure. Eventually, we + * expect to remove this method (and others that strictly indicate legacy subclass roles) in + * favor of a more semantic design that expresses the purpose and distinctions in those roles. + */ + default boolean isChooserTargetInfo() { + return false; + } + + /** + * @return true if this target represents a legacy {@code DisplayResolveInfo}. These objects + * were historically documented as an augmented "TargetInfo plus additional information needed + * to render it (such as icon and label) and resolve it to an activity." That description in no + * way distinguishes from the base {@code TargetInfo} API. At the time of writing, these objects + * are most-clearly defined by their opposite; this returns true for exactly those instances of + * {@code TargetInfo} where {@link #isChooserTargetInfo()} returns false (these conditions are + * complementary because they correspond to the immediate {@code TargetInfo} child types that + * historically partitioned all concrete {@code TargetInfo} implementations). These may(?) + * represent any target displayed somewhere other than the Direct Share UI. + */ + default boolean isDisplayResolveInfo() { + return false; + } + + /** + * @return true if this target represents a legacy {@code MultiDisplayResolveInfo}. These + * objects were historically documented as representing "a 'stack' of chooser targets for + * various activities within the same component." For historical reasons this currently can + * return true only if {@link #isDisplayResolveInfo()} returns true (because the legacy classes + * shared an inheritance relationship), but new code should avoid relying on that relationship + * since these APIs are "in transition." + */ + default boolean isMultiDisplayResolveInfo() { + return false; + } + + /** + * @return true if this target represents a legacy {@code SelectableTargetInfo}. Note that this + * is defined for legacy compatibility and may not conform to other notions of a "selectable" + * target. For historical reasons, this method and {@link #isNotSelectableTargetInfo()} only + * partition the {@code TargetInfo} instances for which {@link #isChooserTargetInfo()} returns + * true; otherwise <em>both</em> methods return false. + * TODO: define selectability for targets not historically from {@code ChooserTargetInfo}, + * then attempt to replace this with a new method like {@code TargetInfo#isSelectable()} that + * actually partitions <em>all</em> target types (after updating client usage as needed). + */ + default boolean isSelectableTargetInfo() { + return false; + } + + /** + * @return true if this target represents a legacy {@code NotSelectableTargetInfo} (i.e., a + * target where {@link #isChooserTargetInfo()} is true but {@link #isSelectableTargetInfo()} is + * false). For more information on how this divides the space of targets, see the Javadoc for + * {@link #isSelectableTargetInfo()}. + */ + default boolean isNotSelectableTargetInfo() { + return false; + } + + /** + * @return true if this target represents a legacy {@code ChooserActivity#EmptyTargetInfo}. Note + * that this is defined for legacy compatibility and may not conform to other notions of an + * "empty" target. + */ + default boolean isEmptyTargetInfo() { + return false; + } + + /** + * @return true if this target represents a legacy {@code ChooserActivity#PlaceHolderTargetInfo} + * (defined only for compatibility with historic use in {@link ChooserListAdapter}). For + * historic reasons (owing to a legacy subclass relationship) this can return true only if + * {@link #isNotSelectableTargetInfo()} also returns true. + */ + default boolean isPlaceHolderTargetInfo() { + return false; + } + + /** + * @return true if this target should be logged with the "direct_share" metrics category in + * {@link ResolverActivity#maybeLogCrossProfileTargetLaunch()}. This is defined for legacy + * compatibility and is <em>not</em> likely to be a good indicator of whether this is actually a + * "direct share" target (e.g. because it historically also applies to "empty" and "placeholder" + * targets). + */ + default boolean isInDirectShareMetricsCategory() { + return isChooserTargetInfo(); + } + + /** + * @param context caller's context, to provide the {@link SharedPreferences} for use by the + * {@link HashedStringCache}. + * @return a hashed ID that should be logged along with our target-selection metrics, or null. + * The contents of the plaintext are defined for historical reasons, "the package name + target + * name to answer the question if most users share to mostly the same person + * or to a bunch of different people." Clients should consider this as opaque data for logging + * only; they should not rely on any particular semantics about the value. + */ + default HashedStringCache.HashResult getHashedTargetIdForMetrics(Context context) { + return null; + } + + /** + * Fix the URIs in {@code intent} if cross-profile sharing is required. This should be called + * before launching the intent as another user. + */ + static void prepareIntentForCrossProfileLaunch(Intent intent, int targetUserId) { + final int currentUserId = UserHandle.myUserId(); + if (targetUserId != currentUserId) { + intent.fixUris(currentUserId); + } + } } diff --git a/java/src/com/android/intentresolver/grid/ChooserGridAdapter.java b/java/src/com/android/intentresolver/grid/ChooserGridAdapter.java new file mode 100644 index 00000000..1cf59316 --- /dev/null +++ b/java/src/com/android/intentresolver/grid/ChooserGridAdapter.java @@ -0,0 +1,604 @@ +/* + * 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.grid; + +import android.animation.AnimatorSet; +import android.animation.ObjectAnimator; +import android.animation.ValueAnimator; +import android.app.ActivityManager; +import android.content.Context; +import android.database.DataSetObserver; +import android.view.LayoutInflater; +import android.view.View; +import android.view.View.MeasureSpec; +import android.view.View.OnClickListener; +import android.view.ViewGroup; +import android.view.ViewGroup.LayoutParams; +import android.view.animation.DecelerateInterpolator; +import android.widget.Space; +import android.widget.TextView; + +import androidx.recyclerview.widget.RecyclerView; + +import com.android.intentresolver.ChooserListAdapter; +import com.android.intentresolver.R; +import com.android.intentresolver.ResolverListAdapter.ViewHolder; +import com.android.internal.annotations.VisibleForTesting; + +import com.google.android.collect.Lists; + +/** + * Adapter for all types of items and targets in ShareSheet. + * Note that ranked sections like Direct Share - while appearing grid-like - are handled on the + * row level by this adapter but not on the item level. Individual targets within the row are + * handled by {@link ChooserListAdapter} + */ +@VisibleForTesting +public final class ChooserGridAdapter extends RecyclerView.Adapter<RecyclerView.ViewHolder> { + + /** + * The transition time between placeholders for direct share to a message + * indicating that none are available. + */ + public static final int NO_DIRECT_SHARE_ANIM_IN_MILLIS = 200; + + /** + * Injectable interface for any considerations that should be delegated to other components + * in the {@link ChooserActivity}. + * TODO: determine whether any of these methods return parameters that can safely be + * precomputed; whether any should be converted to `ChooserGridAdapter` setters to be + * invoked by external callbacks; and whether any reflect requirements that should be moved + * out of `ChooserGridAdapter` altogether. + */ + public interface ChooserActivityDelegate { + /** @return whether we're showing a tabbed (multi-profile) UI. */ + boolean shouldShowTabs(); + + /** + * @return a content preview {@link View} that's appropriate for the caller's share + * content, constructed for display in the provided {@code parent} group. + */ + View buildContentPreview(ViewGroup parent); + + /** Notify the client that the item with the selected {@code itemIndex} was selected. */ + void onTargetSelected(int itemIndex); + + /** + * Notify the client that the item with the selected {@code itemIndex} was + * long-pressed. + */ + void onTargetLongPressed(int itemIndex); + + /** + * Notify the client that the provided {@code View} should be configured as the new + * "profile view" button. Callers should attach their own click listeners to implement + * behaviors on this view. + */ + void updateProfileViewButton(View newButtonFromProfileRow); + + /** + * @return the number of "valid" targets in the active list adapter. + * TODO: define "valid." + */ + int getValidTargetCount(); + + /** + * Request that the client update our {@code directShareGroup} to match their desired + * state for the "expansion" UI. + */ + void updateDirectShareExpansion(DirectShareViewHolder directShareGroup); + + /** + * Request that the client handle a scroll event that should be taken as expanding the + * provided {@code directShareGroup}. Note that this currently never happens due to a + * hard-coded condition in {@link #canExpandDirectShare()}. + */ + void handleScrollToExpandDirectShare( + DirectShareViewHolder directShareGroup, int y, int oldy); + } + + private static final int VIEW_TYPE_DIRECT_SHARE = 0; + private static final int VIEW_TYPE_NORMAL = 1; + private static final int VIEW_TYPE_CONTENT_PREVIEW = 2; + private static final int VIEW_TYPE_PROFILE = 3; + private static final int VIEW_TYPE_AZ_LABEL = 4; + private static final int VIEW_TYPE_CALLER_AND_RANK = 5; + private static final int VIEW_TYPE_FOOTER = 6; + + private static final int NUM_EXPANSIONS_TO_HIDE_AZ_LABEL = 20; + + private final ChooserActivityDelegate mChooserActivityDelegate; + private final ChooserListAdapter mChooserListAdapter; + private final LayoutInflater mLayoutInflater; + + private final int mMaxTargetsPerRow; + private final boolean mShouldShowContentPreview; + private final int mChooserWidthPixels; + private final int mChooserRowTextOptionTranslatePixelSize; + private final boolean mShowAzLabelIfPoss; + + private DirectShareViewHolder mDirectShareViewHolder; + private int mChooserTargetWidth = 0; + + private int mFooterHeight = 0; + + public ChooserGridAdapter( + Context context, + ChooserActivityDelegate chooserActivityDelegate, + ChooserListAdapter wrappedAdapter, + boolean shouldShowContentPreview, + int maxTargetsPerRow, + int numSheetExpansions) { + super(); + + mChooserActivityDelegate = chooserActivityDelegate; + + mChooserListAdapter = wrappedAdapter; + mLayoutInflater = LayoutInflater.from(context); + + mShouldShowContentPreview = shouldShowContentPreview; + mMaxTargetsPerRow = maxTargetsPerRow; + + mChooserWidthPixels = context.getResources().getDimensionPixelSize(R.dimen.chooser_width); + mChooserRowTextOptionTranslatePixelSize = context.getResources().getDimensionPixelSize( + R.dimen.chooser_row_text_option_translate); + + mShowAzLabelIfPoss = numSheetExpansions < NUM_EXPANSIONS_TO_HIDE_AZ_LABEL; + + wrappedAdapter.registerDataSetObserver(new DataSetObserver() { + @Override + public void onChanged() { + super.onChanged(); + notifyDataSetChanged(); + } + + @Override + public void onInvalidated() { + super.onInvalidated(); + notifyDataSetChanged(); + } + }); + } + + public void setFooterHeight(int height) { + mFooterHeight = height; + } + + /** + * Calculate the chooser target width to maximize space per item + * + * @param width The new row width to use for recalculation + * @return true if the view width has changed + */ + public boolean calculateChooserTargetWidth(int width) { + if (width == 0) { + return false; + } + + // Limit width to the maximum width of the chooser activity + int maxWidth = mChooserWidthPixels; + width = Math.min(maxWidth, width); + + int newWidth = width / mMaxTargetsPerRow; + if (newWidth != mChooserTargetWidth) { + mChooserTargetWidth = newWidth; + return true; + } + + return false; + } + + public int getRowCount() { + return (int) ( + getSystemRowCount() + + getProfileRowCount() + + getServiceTargetRowCount() + + getCallerAndRankedTargetRowCount() + + getAzLabelRowCount() + + Math.ceil( + (float) mChooserListAdapter.getAlphaTargetCount() + / mMaxTargetsPerRow) + ); + } + + /** + * Whether the "system" row of targets is displayed. + * This area includes the content preview (if present) and action row. + */ + public int getSystemRowCount() { + // For the tabbed case we show the sticky content preview above the tabs, + // please refer to shouldShowStickyContentPreview + if (mChooserActivityDelegate.shouldShowTabs()) { + return 0; + } + + if (!mShouldShowContentPreview) { + return 0; + } + + if (mChooserListAdapter == null || mChooserListAdapter.getCount() == 0) { + return 0; + } + + return 1; + } + + public int getProfileRowCount() { + if (mChooserActivityDelegate.shouldShowTabs()) { + return 0; + } + return mChooserListAdapter.getOtherProfile() == null ? 0 : 1; + } + + public int getFooterRowCount() { + return 1; + } + + public int getCallerAndRankedTargetRowCount() { + return (int) Math.ceil( + ((float) mChooserListAdapter.getCallerTargetCount() + + mChooserListAdapter.getRankedTargetCount()) / mMaxTargetsPerRow); + } + + // There can be at most one row in the listview, that is internally + // a ViewGroup with 2 rows + public int getServiceTargetRowCount() { + if (mShouldShowContentPreview && !ActivityManager.isLowRamDeviceStatic()) { + return 1; + } + return 0; + } + + public int getAzLabelRowCount() { + // Only show a label if the a-z list is showing + return (mShowAzLabelIfPoss && mChooserListAdapter.getAlphaTargetCount() > 0) ? 1 : 0; + } + + @Override + public int getItemCount() { + return (int) ( + getSystemRowCount() + + getProfileRowCount() + + getServiceTargetRowCount() + + getCallerAndRankedTargetRowCount() + + getAzLabelRowCount() + + mChooserListAdapter.getAlphaTargetCount() + + getFooterRowCount() + ); + } + + @Override + public RecyclerView.ViewHolder onCreateViewHolder(ViewGroup parent, int viewType) { + switch (viewType) { + case VIEW_TYPE_CONTENT_PREVIEW: + return new ItemViewHolder( + mChooserActivityDelegate.buildContentPreview(parent), + viewType, + null, + null); + case VIEW_TYPE_PROFILE: + return new ItemViewHolder( + createProfileView(parent), + viewType, + null, + null); + case VIEW_TYPE_AZ_LABEL: + return new ItemViewHolder( + createAzLabelView(parent), + viewType, + null, + null); + case VIEW_TYPE_NORMAL: + return new ItemViewHolder( + mChooserListAdapter.createView(parent), + viewType, + mChooserActivityDelegate::onTargetSelected, + mChooserActivityDelegate::onTargetLongPressed); + case VIEW_TYPE_DIRECT_SHARE: + case VIEW_TYPE_CALLER_AND_RANK: + return createItemGroupViewHolder(viewType, parent); + case VIEW_TYPE_FOOTER: + Space sp = new Space(parent.getContext()); + sp.setLayoutParams(new RecyclerView.LayoutParams( + LayoutParams.MATCH_PARENT, mFooterHeight)); + return new FooterViewHolder(sp, viewType); + default: + // Since we catch all possible viewTypes above, no chance this is being called. + return null; + } + } + + @Override + public void onBindViewHolder(RecyclerView.ViewHolder holder, int position) { + int viewType = ((ViewHolderBase) holder).getViewType(); + switch (viewType) { + case VIEW_TYPE_DIRECT_SHARE: + case VIEW_TYPE_CALLER_AND_RANK: + bindItemGroupViewHolder(position, (ItemGroupViewHolder) holder); + break; + case VIEW_TYPE_NORMAL: + bindItemViewHolder(position, (ItemViewHolder) holder); + break; + default: + } + } + + @Override + public int getItemViewType(int position) { + int count; + + int countSum = (count = getSystemRowCount()); + if (count > 0 && position < countSum) return VIEW_TYPE_CONTENT_PREVIEW; + + countSum += (count = getProfileRowCount()); + if (count > 0 && position < countSum) return VIEW_TYPE_PROFILE; + + countSum += (count = getServiceTargetRowCount()); + if (count > 0 && position < countSum) return VIEW_TYPE_DIRECT_SHARE; + + countSum += (count = getCallerAndRankedTargetRowCount()); + if (count > 0 && position < countSum) return VIEW_TYPE_CALLER_AND_RANK; + + countSum += (count = getAzLabelRowCount()); + if (count > 0 && position < countSum) return VIEW_TYPE_AZ_LABEL; + + if (position == getItemCount() - 1) return VIEW_TYPE_FOOTER; + + return VIEW_TYPE_NORMAL; + } + + public int getTargetType(int position) { + return mChooserListAdapter.getPositionTargetType(getListPosition(position)); + } + + private View createProfileView(ViewGroup parent) { + View profileRow = mLayoutInflater.inflate(R.layout.chooser_profile_row, parent, false); + mChooserActivityDelegate.updateProfileViewButton(profileRow); + return profileRow; + } + + private View createAzLabelView(ViewGroup parent) { + return mLayoutInflater.inflate(R.layout.chooser_az_label_row, parent, false); + } + + private ItemGroupViewHolder loadViewsIntoGroup(ItemGroupViewHolder holder) { + final int spec = MeasureSpec.makeMeasureSpec(0, MeasureSpec.UNSPECIFIED); + final int exactSpec = MeasureSpec.makeMeasureSpec(mChooserTargetWidth, MeasureSpec.EXACTLY); + int columnCount = holder.getColumnCount(); + + final boolean isDirectShare = holder instanceof DirectShareViewHolder; + + for (int i = 0; i < columnCount; i++) { + final View v = mChooserListAdapter.createView(holder.getRowByIndex(i)); + final int column = i; + v.setOnClickListener(new OnClickListener() { + @Override + public void onClick(View v) { + mChooserActivityDelegate.onTargetSelected(holder.getItemIndex(column)); + } + }); + + // Show menu for both direct share and app share targets after long click. + v.setOnLongClickListener(v1 -> { + mChooserActivityDelegate.onTargetLongPressed(holder.getItemIndex(column)); + return true; + }); + + holder.addView(i, v); + + // Force Direct Share to be 2 lines and auto-wrap to second line via hoz scroll = + // false. TextView#setHorizontallyScrolling must be reset after #setLines. Must be + // done before measuring. + if (isDirectShare) { + final ViewHolder vh = (ViewHolder) v.getTag(); + vh.text.setLines(2); + vh.text.setHorizontallyScrolling(false); + vh.text2.setVisibility(View.GONE); + } + + // Force height to be a given so we don't have visual disruption during scaling. + v.measure(exactSpec, spec); + setViewBounds(v, v.getMeasuredWidth(), v.getMeasuredHeight()); + } + + final ViewGroup viewGroup = holder.getViewGroup(); + + // Pre-measure and fix height so we can scale later. + holder.measure(); + setViewBounds(viewGroup, LayoutParams.MATCH_PARENT, holder.getMeasuredRowHeight()); + + if (isDirectShare) { + DirectShareViewHolder dsvh = (DirectShareViewHolder) holder; + setViewBounds(dsvh.getRow(0), LayoutParams.MATCH_PARENT, dsvh.getMinRowHeight()); + setViewBounds(dsvh.getRow(1), LayoutParams.MATCH_PARENT, dsvh.getMinRowHeight()); + } + + viewGroup.setTag(holder); + return holder; + } + + private void setViewBounds(View view, int widthPx, int heightPx) { + LayoutParams lp = view.getLayoutParams(); + if (lp == null) { + lp = new LayoutParams(widthPx, heightPx); + view.setLayoutParams(lp); + } else { + lp.height = heightPx; + lp.width = widthPx; + } + } + + ItemGroupViewHolder createItemGroupViewHolder(int viewType, ViewGroup parent) { + if (viewType == VIEW_TYPE_DIRECT_SHARE) { + ViewGroup parentGroup = (ViewGroup) mLayoutInflater.inflate( + R.layout.chooser_row_direct_share, parent, false); + ViewGroup row1 = (ViewGroup) mLayoutInflater.inflate( + R.layout.chooser_row, parentGroup, false); + ViewGroup row2 = (ViewGroup) mLayoutInflater.inflate( + R.layout.chooser_row, parentGroup, false); + parentGroup.addView(row1); + parentGroup.addView(row2); + + mDirectShareViewHolder = new DirectShareViewHolder(parentGroup, + Lists.newArrayList(row1, row2), mMaxTargetsPerRow, viewType, + mChooserActivityDelegate::getValidTargetCount); + loadViewsIntoGroup(mDirectShareViewHolder); + + return mDirectShareViewHolder; + } else { + ViewGroup row = (ViewGroup) mLayoutInflater.inflate( + R.layout.chooser_row, parent, false); + ItemGroupViewHolder holder = + new SingleRowViewHolder(row, mMaxTargetsPerRow, viewType); + loadViewsIntoGroup(holder); + + return holder; + } + } + + /** + * Need to merge CALLER + ranked STANDARD into a single row and prevent a separator from + * showing on top of the AZ list if the AZ label is visible. All other types are placed into + * their own row as determined by their target type, and dividers are added in the list to + * separate each type. + */ + int getRowType(int rowPosition) { + // Merge caller and ranked standard into a single row + int positionType = mChooserListAdapter.getPositionTargetType(rowPosition); + if (positionType == ChooserListAdapter.TARGET_CALLER) { + return ChooserListAdapter.TARGET_STANDARD; + } + + // If an A-Z label is shown, prevent a separator from appearing by making the A-Z + // row type the same as the suggestion row type + if (getAzLabelRowCount() > 0 && positionType == ChooserListAdapter.TARGET_STANDARD_AZ) { + return ChooserListAdapter.TARGET_STANDARD; + } + + return positionType; + } + + void bindItemViewHolder(int position, ItemViewHolder holder) { + View v = holder.itemView; + int listPosition = getListPosition(position); + holder.setListPosition(listPosition); + mChooserListAdapter.bindView(listPosition, v); + } + + void bindItemGroupViewHolder(int position, ItemGroupViewHolder holder) { + final ViewGroup viewGroup = (ViewGroup) holder.itemView; + int start = getListPosition(position); + int startType = getRowType(start); + + int columnCount = holder.getColumnCount(); + int end = start + columnCount - 1; + while (getRowType(end) != startType && end >= start) { + end--; + } + + if (end == start && mChooserListAdapter.getItem(start).isEmptyTargetInfo()) { + final TextView textView = viewGroup.findViewById( + com.android.internal.R.id.chooser_row_text_option); + + if (textView.getVisibility() != View.VISIBLE) { + textView.setAlpha(0.0f); + textView.setVisibility(View.VISIBLE); + textView.setText(R.string.chooser_no_direct_share_targets); + + ValueAnimator fadeAnim = ObjectAnimator.ofFloat(textView, "alpha", 0.0f, 1.0f); + fadeAnim.setInterpolator(new DecelerateInterpolator(1.0f)); + + textView.setTranslationY(mChooserRowTextOptionTranslatePixelSize); + ValueAnimator translateAnim = + ObjectAnimator.ofFloat(textView, "translationY", 0.0f); + translateAnim.setInterpolator(new DecelerateInterpolator(1.0f)); + + AnimatorSet animSet = new AnimatorSet(); + animSet.setDuration(NO_DIRECT_SHARE_ANIM_IN_MILLIS); + animSet.setStartDelay(NO_DIRECT_SHARE_ANIM_IN_MILLIS); + animSet.playTogether(fadeAnim, translateAnim); + animSet.start(); + } + } + + for (int i = 0; i < columnCount; i++) { + final View v = holder.getView(i); + + if (start + i <= end) { + holder.setViewVisibility(i, View.VISIBLE); + holder.setItemIndex(i, start + i); + mChooserListAdapter.bindView(holder.getItemIndex(i), v); + } else { + holder.setViewVisibility(i, View.INVISIBLE); + } + } + } + + int getListPosition(int position) { + position -= getSystemRowCount() + getProfileRowCount(); + + final int serviceCount = mChooserListAdapter.getServiceTargetCount(); + final int serviceRows = (int) Math.ceil((float) serviceCount / mMaxTargetsPerRow); + if (position < serviceRows) { + return position * mMaxTargetsPerRow; + } + + position -= serviceRows; + + final int callerAndRankedCount = + mChooserListAdapter.getCallerTargetCount() + + mChooserListAdapter.getRankedTargetCount(); + final int callerAndRankedRows = getCallerAndRankedTargetRowCount(); + if (position < callerAndRankedRows) { + return serviceCount + position * mMaxTargetsPerRow; + } + + position -= getAzLabelRowCount() + callerAndRankedRows; + + return callerAndRankedCount + serviceCount + position; + } + + public void handleScroll(View v, int y, int oldy) { + boolean canExpandDirectShare = canExpandDirectShare(); + if (mDirectShareViewHolder != null && canExpandDirectShare) { + mChooserActivityDelegate.handleScrollToExpandDirectShare( + mDirectShareViewHolder, y, oldy); + } + } + + /** Only expand direct share area if there is a minimum number of targets. */ + private boolean canExpandDirectShare() { + // Do not enable until we have confirmed more apps are using sharing shortcuts + // Check git history for enablement logic + return false; + } + + public ChooserListAdapter getListAdapter() { + return mChooserListAdapter; + } + + public boolean shouldCellSpan(int position) { + return getItemViewType(position) == VIEW_TYPE_NORMAL; + } + + public void updateDirectShareExpansion() { + if (mDirectShareViewHolder == null || !canExpandDirectShare()) { + return; + } + mChooserActivityDelegate.updateDirectShareExpansion(mDirectShareViewHolder); + } +} diff --git a/java/src/com/android/intentresolver/grid/DirectShareViewHolder.java b/java/src/com/android/intentresolver/grid/DirectShareViewHolder.java new file mode 100644 index 00000000..316c9f07 --- /dev/null +++ b/java/src/com/android/intentresolver/grid/DirectShareViewHolder.java @@ -0,0 +1,197 @@ +/* + * 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.grid; + +import android.animation.Animator; +import android.animation.AnimatorListenerAdapter; +import android.animation.ObjectAnimator; +import android.animation.ValueAnimator; +import android.view.View; +import android.view.View.MeasureSpec; +import android.view.ViewGroup; +import android.view.animation.AccelerateInterpolator; + +import androidx.recyclerview.widget.RecyclerView; + +import com.android.intentresolver.ChooserActivity; + +import java.util.Arrays; +import java.util.List; +import java.util.function.Supplier; + +/** Holder for direct share targets in the {@link ChooserGridAdapter}. */ +public class DirectShareViewHolder extends ItemGroupViewHolder { + private final ViewGroup mParent; + private final List<ViewGroup> mRows; + private int mCellCountPerRow; + + private boolean mHideDirectShareExpansion = false; + private int mDirectShareMinHeight = 0; + private int mDirectShareCurrHeight = 0; + private int mDirectShareMaxHeight = 0; + + private final boolean[] mCellVisibility; + + private final Supplier<Integer> mDeferredTargetCountSupplier; + + public DirectShareViewHolder( + ViewGroup parent, + List<ViewGroup> rows, + int cellCountPerRow, + int viewType, + Supplier<Integer> deferredTargetCountSupplier) { + super(rows.size() * cellCountPerRow, parent, viewType); + + this.mParent = parent; + this.mRows = rows; + this.mCellCountPerRow = cellCountPerRow; + this.mCellVisibility = new boolean[rows.size() * cellCountPerRow]; + Arrays.fill(mCellVisibility, true); + this.mDeferredTargetCountSupplier = deferredTargetCountSupplier; + } + + public ViewGroup addView(int index, View v) { + ViewGroup row = getRowByIndex(index); + row.addView(v); + mCells[index] = v; + + return row; + } + + public ViewGroup getViewGroup() { + return mParent; + } + + public ViewGroup getRowByIndex(int index) { + return mRows.get(index / mCellCountPerRow); + } + + public ViewGroup getRow(int rowNumber) { + return mRows.get(rowNumber); + } + + public void measure() { + final int spec = MeasureSpec.makeMeasureSpec(0, MeasureSpec.UNSPECIFIED); + getRow(0).measure(spec, spec); + getRow(1).measure(spec, spec); + + mDirectShareMinHeight = getRow(0).getMeasuredHeight(); + mDirectShareCurrHeight = (mDirectShareCurrHeight > 0) + ? mDirectShareCurrHeight : mDirectShareMinHeight; + mDirectShareMaxHeight = 2 * mDirectShareMinHeight; + } + + public int getMeasuredRowHeight() { + return mDirectShareCurrHeight; + } + + public int getMinRowHeight() { + return mDirectShareMinHeight; + } + + public void setViewVisibility(int i, int visibility) { + final View v = getView(i); + if (visibility == View.VISIBLE) { + mCellVisibility[i] = true; + v.setVisibility(visibility); + v.setAlpha(1.0f); + } else if (visibility == View.INVISIBLE && mCellVisibility[i]) { + mCellVisibility[i] = false; + + ValueAnimator fadeAnim = ObjectAnimator.ofFloat(v, "alpha", 1.0f, 0f); + fadeAnim.setDuration(ChooserGridAdapter.NO_DIRECT_SHARE_ANIM_IN_MILLIS); + fadeAnim.setInterpolator(new AccelerateInterpolator(1.0f)); + fadeAnim.addListener(new AnimatorListenerAdapter() { + public void onAnimationEnd(Animator animation) { + v.setVisibility(View.INVISIBLE); + } + }); + fadeAnim.start(); + } + } + + public void handleScroll(RecyclerView view, int y, int oldy, int maxTargetsPerRow) { + // only exit early if fully collapsed, otherwise onListRebuilt() with shifting + // targets can lock us into an expanded mode + boolean notExpanded = mDirectShareCurrHeight == mDirectShareMinHeight; + if (notExpanded) { + if (mHideDirectShareExpansion) { + return; + } + + // only expand if we have more than maxTargetsPerRow, and delay that decision + // until they start to scroll + final int validTargets = this.mDeferredTargetCountSupplier.get(); + if (validTargets <= maxTargetsPerRow) { + mHideDirectShareExpansion = true; + return; + } + } + + int yDiff = (int) ((oldy - y) * ChooserActivity.DIRECT_SHARE_EXPANSION_RATE); + + int prevHeight = mDirectShareCurrHeight; + int newHeight = Math.min(prevHeight + yDiff, mDirectShareMaxHeight); + newHeight = Math.max(newHeight, mDirectShareMinHeight); + yDiff = newHeight - prevHeight; + + updateDirectShareRowHeight(view, yDiff, newHeight); + } + + public void expand(RecyclerView view) { + updateDirectShareRowHeight( + view, mDirectShareMaxHeight - mDirectShareCurrHeight, mDirectShareMaxHeight); + } + + public void collapse(RecyclerView view) { + updateDirectShareRowHeight( + view, mDirectShareMinHeight - mDirectShareCurrHeight, mDirectShareMinHeight); + } + + private void updateDirectShareRowHeight(RecyclerView view, int yDiff, int newHeight) { + if (view == null || view.getChildCount() == 0 || yDiff == 0) { + return; + } + + // locate the item to expand, and offset the rows below that one + boolean foundExpansion = false; + for (int i = 0; i < view.getChildCount(); i++) { + View child = view.getChildAt(i); + + if (foundExpansion) { + child.offsetTopAndBottom(yDiff); + } else { + if (child.getTag() != null && child.getTag() instanceof DirectShareViewHolder) { + int widthSpec = MeasureSpec.makeMeasureSpec(child.getWidth(), + MeasureSpec.EXACTLY); + int heightSpec = MeasureSpec.makeMeasureSpec(newHeight, + MeasureSpec.EXACTLY); + child.measure(widthSpec, heightSpec); + child.getLayoutParams().height = child.getMeasuredHeight(); + child.layout(child.getLeft(), child.getTop(), child.getRight(), + child.getTop() + child.getMeasuredHeight()); + + foundExpansion = true; + } + } + } + + if (foundExpansion) { + mDirectShareCurrHeight = newHeight; + } + } +} diff --git a/java/src/com/android/intentresolver/ChooserFlags.java b/java/src/com/android/intentresolver/grid/FooterViewHolder.java index 67f9046f..0c94e3ed 100644 --- a/java/src/com/android/intentresolver/ChooserFlags.java +++ b/java/src/com/android/intentresolver/grid/FooterViewHolder.java @@ -1,5 +1,5 @@ /* - * Copyright (C) 2019 The Android Open Source Project + * 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. @@ -14,20 +14,15 @@ * limitations under the License. */ -package com.android.intentresolver; +package com.android.intentresolver.grid; -import android.app.prediction.AppPredictionManager; +import android.view.View; /** - * Common flags for {@link ChooserListAdapter} and {@link ChooserActivity}. + * A footer on the list, to support scrolling behavior below the navbar. */ -public class ChooserFlags { - - /** - * Whether to use {@link AppPredictionManager} to query for direct share targets (as opposed to - * talking directly to {@link android.content.pm.ShortcutManager}. - */ - // TODO(b/123089490): Replace with system flag - static final boolean USE_PREDICTION_MANAGER_FOR_DIRECT_TARGETS = true; +public final class FooterViewHolder extends ViewHolderBase { + public FooterViewHolder(View itemView, int viewType) { + super(itemView, viewType); + } } - diff --git a/java/src/com/android/intentresolver/grid/ItemGroupViewHolder.java b/java/src/com/android/intentresolver/grid/ItemGroupViewHolder.java new file mode 100644 index 00000000..5470506b --- /dev/null +++ b/java/src/com/android/intentresolver/grid/ItemGroupViewHolder.java @@ -0,0 +1,76 @@ +/* + * 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.grid; + +import android.view.View; +import android.view.View.MeasureSpec; +import android.view.ViewGroup; + +/** + * Used to bind types for group of items including: + * {@link ChooserGridAdapter#VIEW_TYPE_DIRECT_SHARE}, + * and {@link ChooserGridAdapter#VIEW_TYPE_CALLER_AND_RANK}. + */ +public abstract class ItemGroupViewHolder extends ViewHolderBase { + protected int mMeasuredRowHeight; + private int[] mItemIndices; + protected final View[] mCells; + private final int mColumnCount; + + public ItemGroupViewHolder(int cellCount, View itemView, int viewType) { + super(itemView, viewType); + this.mCells = new View[cellCount]; + this.mItemIndices = new int[cellCount]; + this.mColumnCount = cellCount; + } + + public abstract ViewGroup addView(int index, View v); + + public abstract ViewGroup getViewGroup(); + + public abstract ViewGroup getRowByIndex(int index); + + public abstract ViewGroup getRow(int rowNumber); + + public abstract void setViewVisibility(int i, int visibility); + + public int getColumnCount() { + return mColumnCount; + } + + public void measure() { + final int spec = MeasureSpec.makeMeasureSpec(0, MeasureSpec.UNSPECIFIED); + getViewGroup().measure(spec, spec); + mMeasuredRowHeight = getViewGroup().getMeasuredHeight(); + } + + public int getMeasuredRowHeight() { + return mMeasuredRowHeight; + } + + public void setItemIndex(int itemIndex, int listIndex) { + mItemIndices[itemIndex] = listIndex; + } + + public int getItemIndex(int itemIndex) { + return mItemIndices[itemIndex]; + } + + public View getView(int index) { + return mCells[index]; + } +} diff --git a/java/src/com/android/intentresolver/grid/ItemViewHolder.java b/java/src/com/android/intentresolver/grid/ItemViewHolder.java new file mode 100644 index 00000000..2ec56b1b --- /dev/null +++ b/java/src/com/android/intentresolver/grid/ItemViewHolder.java @@ -0,0 +1,63 @@ +/* + * 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.grid; + +import android.view.View; + +import androidx.annotation.Nullable; + +import com.android.intentresolver.ChooserListAdapter; +import com.android.intentresolver.ResolverListAdapter; + +import java.util.function.Consumer; + +/** + * Used to bind types of individual item including + * {@link ChooserGridAdapter#VIEW_TYPE_NORMAL}, + * {@link ChooserGridAdapter#VIEW_TYPE_CONTENT_PREVIEW}, + * {@link ChooserGridAdapter#VIEW_TYPE_PROFILE}, + * and {@link ChooserGridAdapter#VIEW_TYPE_AZ_LABEL}. + */ +public final class ItemViewHolder extends ViewHolderBase { + private final ResolverListAdapter.ViewHolder mWrappedViewHolder; + + private int mListPosition = ChooserListAdapter.NO_POSITION; + + public ItemViewHolder( + View itemView, + int viewType, + @Nullable Consumer<Integer> onClick, + @Nullable Consumer<Integer> onLongClick) { + super(itemView, viewType); + mWrappedViewHolder = new ResolverListAdapter.ViewHolder(itemView); + + if (onClick != null) { + itemView.setOnClickListener(v -> onClick.accept(mListPosition)); + } + + if (onLongClick != null) { + itemView.setOnLongClickListener(v -> { + onLongClick.accept(mListPosition); + return true; + }); + } + } + + public void setListPosition(int listPosition) { + mListPosition = listPosition; + } +} diff --git a/java/src/com/android/intentresolver/grid/SingleRowViewHolder.java b/java/src/com/android/intentresolver/grid/SingleRowViewHolder.java new file mode 100644 index 00000000..a72da7aa --- /dev/null +++ b/java/src/com/android/intentresolver/grid/SingleRowViewHolder.java @@ -0,0 +1,73 @@ +/* + * 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.grid; + +import android.view.View; +import android.view.ViewGroup; + +/** Holder for a group of items displayed in a single row of the {@link ChooserGridAdapter}. */ +public final class SingleRowViewHolder extends ItemGroupViewHolder { + private final ViewGroup mRow; + + public SingleRowViewHolder(ViewGroup row, int cellCount, int viewType) { + super(cellCount, row, viewType); + + this.mRow = row; + } + + /** Get the group of all views in this holder. */ + public ViewGroup getViewGroup() { + return mRow; + } + + /** + * Get the group of views for the row containing the specified cell index. + * TODO: unclear if that's what this `index` meant. It doesn't matter for our "single row" + * holders, and it doesn't look like this is an override from some other interface; maybe we can + * just remove? + */ + public ViewGroup getRowByIndex(int index) { + return mRow; + } + + /** Get the group of views for the specified {@code rowNumber}, if any. */ + public ViewGroup getRow(int rowNumber) { + if (rowNumber == 0) { + return mRow; + } + return null; + } + + /** + * @param index the index of the cell to add the view into. + * @param v the view to add into the cell. + */ + public ViewGroup addView(int index, View v) { + mRow.addView(v); + mCells[index] = v; + + return mRow; + } + + /** + * @param i the index of the cell containing the view to modify. + * @param visibility the new visibility to set on the view with the specified index. + */ + public void setViewVisibility(int i, int visibility) { + getView(i).setVisibility(visibility); + } +} diff --git a/java/src/com/android/intentresolver/grid/ViewHolderBase.java b/java/src/com/android/intentresolver/grid/ViewHolderBase.java new file mode 100644 index 00000000..78e9104a --- /dev/null +++ b/java/src/com/android/intentresolver/grid/ViewHolderBase.java @@ -0,0 +1,35 @@ +/* + * 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.grid; + +import android.view.View; + +import androidx.recyclerview.widget.RecyclerView; + +/** Base class for all {@link RecyclerView.ViewHolder} types in the {@link ChooserGridAdapter}. */ +public abstract class ViewHolderBase extends RecyclerView.ViewHolder { + private int mViewType; + + ViewHolderBase(View itemView, int viewType) { + super(itemView); + this.mViewType = viewType; + } + + public int getViewType() { + return mViewType; + } +} diff --git a/java/src/com/android/intentresolver/AbstractResolverComparator.java b/java/src/com/android/intentresolver/model/AbstractResolverComparator.java index 6f802876..271c6f98 100644 --- a/java/src/com/android/intentresolver/AbstractResolverComparator.java +++ b/java/src/com/android/intentresolver/model/AbstractResolverComparator.java @@ -14,7 +14,7 @@ * limitations under the License. */ -package com.android.intentresolver; +package com.android.intentresolver.model; import android.app.usage.UsageStatsManager; import android.content.ComponentName; @@ -29,6 +29,8 @@ import android.os.Message; import android.os.UserHandle; import android.util.Log; +import com.android.intentresolver.ChooserActivityLogger; +import com.android.intentresolver.ResolverActivity; import com.android.intentresolver.ResolverActivity.ResolvedComponentInfo; import java.text.Collator; @@ -47,7 +49,7 @@ public abstract class AbstractResolverComparator implements Comparator<ResolvedC private static final boolean DEBUG = true; private static final String TAG = "AbstractResolverComp"; - protected AfterCompute mAfterCompute; + protected Runnable mAfterCompute; protected final PackageManager mPm; protected final UsageStatsManager mUsm; protected String[] mAnnotations; @@ -129,15 +131,7 @@ public abstract class AbstractResolverComparator implements Comparator<ResolvedC } } - /** - * Callback to be called when {@link #compute(List)} finishes. This signals to stop waiting. - */ - interface AfterCompute { - - void afterCompute(); - } - - void setCallBack(AfterCompute afterCompute) { + public void setCallBack(Runnable afterCompute) { mAfterCompute = afterCompute; } @@ -150,9 +144,9 @@ public abstract class AbstractResolverComparator implements Comparator<ResolvedC } protected final void afterCompute() { - final AfterCompute afterCompute = mAfterCompute; + final Runnable afterCompute = mAfterCompute; if (afterCompute != null) { - afterCompute.afterCompute(); + afterCompute.run(); } } @@ -161,11 +155,6 @@ public abstract class AbstractResolverComparator implements Comparator<ResolvedC final ResolveInfo lhs = lhsp.getResolveInfoAt(0); final ResolveInfo rhs = rhsp.getResolveInfoAt(0); - final boolean lFixedAtTop = lhsp.isFixedAtTop(); - final boolean rFixedAtTop = rhsp.isFixedAtTop(); - if (lFixedAtTop && !rFixedAtTop) return -1; - if (!lFixedAtTop && rFixedAtTop) return 1; - // We want to put the one targeted to another user at the end of the dialog. if (lhs.targetUserId != UserHandle.USER_CURRENT) { return rhs.targetUserId != UserHandle.USER_CURRENT ? 0 : 1; @@ -214,7 +203,7 @@ public abstract class AbstractResolverComparator implements Comparator<ResolvedC * ResolvedComponentInfo} by {@link ComponentName}. {@link #beforeCompute()} will be called * before doing any computing. */ - final void compute(List<ResolvedComponentInfo> targets) { + public final void compute(List<ResolvedComponentInfo> targets) { beforeCompute(); doCompute(targets); } @@ -226,7 +215,7 @@ public abstract class AbstractResolverComparator implements Comparator<ResolvedC * Returns the score that was calculated for the corresponding {@link ResolvedComponentInfo} * when {@link #compute(List)} was called before this. */ - abstract float getScore(ComponentName name); + public abstract float getScore(ComponentName name); /** Handles result message sent to mHandler. */ abstract void handleResultMessage(Message message); @@ -234,7 +223,7 @@ public abstract class AbstractResolverComparator implements Comparator<ResolvedC /** * Reports to UsageStats what was chosen. */ - final void updateChooserCounts(String packageName, int userId, String action) { + public final void updateChooserCounts(String packageName, int userId, String action) { if (mUsm != null) { mUsm.reportChooserSelection(packageName, userId, mContentType, mAnnotations, action); } @@ -248,7 +237,7 @@ public abstract class AbstractResolverComparator implements Comparator<ResolvedC * * @param componentName the component that the user clicked */ - void updateModel(ComponentName componentName) { + public void updateModel(ComponentName componentName) { } /** Called before {@link #doCompute(List)}. Sets up 500ms timeout. */ @@ -266,7 +255,7 @@ public abstract class AbstractResolverComparator implements Comparator<ResolvedC * this call needs to happen at a different time during destroy, the method should be * overridden. */ - void destroy() { + public void destroy() { mHandler.removeMessages(RANKER_SERVICE_RESULT); mHandler.removeMessages(RANKER_RESULT_TIMEOUT); afterCompute(); diff --git a/java/src/com/android/intentresolver/AppPredictionServiceResolverComparator.java b/java/src/com/android/intentresolver/model/AppPredictionServiceResolverComparator.java index 9b9fc1c0..c6bb2b85 100644 --- a/java/src/com/android/intentresolver/AppPredictionServiceResolverComparator.java +++ b/java/src/com/android/intentresolver/model/AppPredictionServiceResolverComparator.java @@ -14,7 +14,7 @@ * limitations under the License. */ -package com.android.intentresolver; +package com.android.intentresolver.model; import static android.app.prediction.AppTargetEvent.ACTION_LAUNCH; @@ -31,6 +31,7 @@ import android.os.Message; import android.os.UserHandle; import android.util.Log; +import com.android.intentresolver.ChooserActivityLogger; import com.android.intentresolver.ResolverActivity.ResolvedComponentInfo; import java.util.ArrayList; @@ -45,7 +46,7 @@ import java.util.concurrent.Executors; * disabled by returning an empty sorted target list, {@link AppPredictionServiceResolverComparator} * will fallback to using a {@link ResolverRankerServiceResolverComparator}. */ -class AppPredictionServiceResolverComparator extends AbstractResolverComparator { +public class AppPredictionServiceResolverComparator extends AbstractResolverComparator { private static final String TAG = "APSResolverComparator"; @@ -62,7 +63,7 @@ class AppPredictionServiceResolverComparator extends AbstractResolverComparator private ResolverRankerServiceResolverComparator mResolverRankerService; private AppPredictionServiceComparatorModel mComparatorModel; - AppPredictionServiceResolverComparator( + public AppPredictionServiceResolverComparator( Context context, Intent intent, String referrerPackage, @@ -166,17 +167,17 @@ class AppPredictionServiceResolverComparator extends AbstractResolverComparator } @Override - float getScore(ComponentName name) { + public float getScore(ComponentName name) { return mComparatorModel.getScore(name); } @Override - void updateModel(ComponentName componentName) { + public void updateModel(ComponentName componentName) { mComparatorModel.notifyOnTargetSelected(componentName); } @Override - void destroy() { + public void destroy() { if (mResolverRankerService != null) { mResolverRankerService.destroy(); mResolverRankerService = null; diff --git a/java/src/com/android/intentresolver/ResolverComparatorModel.java b/java/src/com/android/intentresolver/model/ResolverComparatorModel.java index 79160c84..3616a853 100644 --- a/java/src/com/android/intentresolver/ResolverComparatorModel.java +++ b/java/src/com/android/intentresolver/model/ResolverComparatorModel.java @@ -14,13 +14,12 @@ * limitations under the License. */ -package com.android.intentresolver; +package com.android.intentresolver.model; import android.content.ComponentName; import android.content.pm.ResolveInfo; import java.util.Comparator; -import java.util.List; /** * A ranking model for resolver targets, providing ordering and (optionally) numerical scoring. diff --git a/java/src/com/android/intentresolver/ResolverRankerServiceResolverComparator.java b/java/src/com/android/intentresolver/model/ResolverRankerServiceResolverComparator.java index be3e6f18..4382f109 100644 --- a/java/src/com/android/intentresolver/ResolverRankerServiceResolverComparator.java +++ b/java/src/com/android/intentresolver/model/ResolverRankerServiceResolverComparator.java @@ -15,7 +15,7 @@ */ -package com.android.intentresolver; +package com.android.intentresolver.model; import android.app.usage.UsageStats; import android.content.ComponentName; @@ -37,8 +37,8 @@ import android.service.resolver.ResolverRankerService; import android.service.resolver.ResolverTarget; import android.util.Log; +import com.android.intentresolver.ChooserActivityLogger; import com.android.intentresolver.ResolverActivity.ResolvedComponentInfo; - import com.android.internal.logging.MetricsLogger; import com.android.internal.logging.nano.MetricsProto.MetricsEvent; @@ -54,7 +54,7 @@ import java.util.concurrent.TimeUnit; /** * Ranks and compares packages based on usage stats and uses the {@link ResolverRankerService}. */ -class ResolverRankerServiceResolverComparator extends AbstractResolverComparator { +public class ResolverRankerServiceResolverComparator extends AbstractResolverComparator { private static final String TAG = "RRSResolverComparator"; private static final boolean DEBUG = false; @@ -87,7 +87,7 @@ class ResolverRankerServiceResolverComparator extends AbstractResolverComparator private ResolverRankerServiceComparatorModel mComparatorModel; public ResolverRankerServiceResolverComparator(Context context, Intent intent, - String referrerPackage, AfterCompute afterCompute, + String referrerPackage, Runnable afterCompute, ChooserActivityLogger chooserActivityLogger) { super(context, intent); mCollator = Collator.getInstance(context.getResources().getConfiguration().locale); @@ -191,9 +191,9 @@ class ResolverRankerServiceResolverComparator extends AbstractResolverComparator if (mAction == null) { Log.d(TAG, "Action type is null"); } else { - Log.d(TAG, "Chooser Count of " + mAction + ":" + - target.name.getPackageName() + " is " + - Float.toString(chooserScore)); + Log.d(TAG, "Chooser Count of " + mAction + ":" + + target.name.getPackageName() + " is " + + Float.toString(chooserScore)); } } resolverTarget.setChooserScore(chooserScore); @@ -333,7 +333,7 @@ class ResolverRankerServiceResolverComparator extends AbstractResolverComparator private class ResolverRankerServiceConnection implements ServiceConnection { private final CountDownLatch mConnectSignal; - public ResolverRankerServiceConnection(CountDownLatch connectSignal) { + ResolverRankerServiceConnection(CountDownLatch connectSignal) { mConnectSignal = connectSignal; } @@ -424,8 +424,10 @@ class ResolverRankerServiceResolverComparator extends AbstractResolverComparator // adds select prob as the default values, according to a pre-trained Logistic Regression model. private void addDefaultSelectProbability(ResolverTarget target) { - float sum = 2.5543f * target.getLaunchScore() + 2.8412f * target.getTimeSpentScore() + - 0.269f * target.getRecencyScore() + 4.2222f * target.getChooserScore(); + float sum = (2.5543f * target.getLaunchScore()) + + (2.8412f * target.getTimeSpentScore()) + + (0.269f * target.getRecencyScore()) + + (4.2222f * target.getChooserScore()); target.setSelectProbability((float) (1.0 / (1.0 + Math.exp(1.6568f - sum)))); } @@ -440,8 +442,8 @@ class ResolverRankerServiceResolverComparator extends AbstractResolverComparator static boolean isPersistentProcess(ResolvedComponentInfo rci) { if (rci != null && rci.getCount() > 0) { - return (rci.getResolveInfoAt(0).activityInfo.applicationInfo.flags & - ApplicationInfo.FLAG_PERSISTENT) != 0; + int flags = rci.getResolveInfoAt(0).activityInfo.applicationInfo.flags; + return (flags & ApplicationInfo.FLAG_PERSISTENT) != 0; } return false; } diff --git a/java/src/com/android/intentresolver/shortcuts/AppPredictorFactory.kt b/java/src/com/android/intentresolver/shortcuts/AppPredictorFactory.kt new file mode 100644 index 00000000..82f40b91 --- /dev/null +++ b/java/src/com/android/intentresolver/shortcuts/AppPredictorFactory.kt @@ -0,0 +1,67 @@ +/* + * 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.prediction.AppPredictionContext +import android.app.prediction.AppPredictionManager +import android.app.prediction.AppPredictor +import android.content.Context +import android.content.IntentFilter +import android.os.Bundle +import android.os.UserHandle + +// TODO(b/123088566) Share these in a better way. +private const val APP_PREDICTION_SHARE_UI_SURFACE = "share" +private const val APP_PREDICTION_SHARE_TARGET_QUERY_PACKAGE_LIMIT = 20 +private const val APP_PREDICTION_INTENT_FILTER_KEY = "intent_filter" +private const val SHARED_TEXT_KEY = "shared_text" + +/** + * A factory to create an AppPredictor instance for a profile, if available. + * @param context, application context + * @param sharedText, a shared text associated with the Chooser's target intent + * (see [android.content.Intent.EXTRA_TEXT]). + * Will be mapped to app predictor's "shared_text" parameter. + * @param targetIntentFilter, an IntentFilter to match direct share targets against. + * Will be mapped app predictor's "intent_filter" parameter. + */ +class AppPredictorFactory( + private val context: Context, + private val sharedText: String?, + private val targetIntentFilter: IntentFilter? +) { + private val mIsComponentAvailable = + context.packageManager.appPredictionServicePackageName != null + + /** + * Creates an AppPredictor instance for a profile or `null` if app predictor is not available. + */ + fun create(userHandle: UserHandle): AppPredictor? { + if (!mIsComponentAvailable) return null + val contextAsUser = context.createContextAsUser(userHandle, 0 /* flags */) + val extras = Bundle().apply { + putParcelable(APP_PREDICTION_INTENT_FILTER_KEY, targetIntentFilter) + putString(SHARED_TEXT_KEY, sharedText) + } + val appPredictionContext = AppPredictionContext.Builder(contextAsUser) + .setUiSurface(APP_PREDICTION_SHARE_UI_SURFACE) + .setPredictedTargetCount(APP_PREDICTION_SHARE_TARGET_QUERY_PACKAGE_LIMIT) + .setExtras(extras) + .build() + return contextAsUser.getSystemService(AppPredictionManager::class.java) + ?.createAppPredictionSession(appPredictionContext) + } +} diff --git a/java/src/com/android/intentresolver/shortcuts/ShortcutLoader.java b/java/src/com/android/intentresolver/shortcuts/ShortcutLoader.java new file mode 100644 index 00000000..1cfa2c8d --- /dev/null +++ b/java/src/com/android/intentresolver/shortcuts/ShortcutLoader.java @@ -0,0 +1,426 @@ +/* + * 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/ShortcutToChooserTargetConverter.java b/java/src/com/android/intentresolver/shortcuts/ShortcutToChooserTargetConverter.java new file mode 100644 index 00000000..a37d6558 --- /dev/null +++ b/java/src/com/android/intentresolver/shortcuts/ShortcutToChooserTargetConverter.java @@ -0,0 +1,109 @@ +/* + * 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.annotation.NonNull; +import android.annotation.Nullable; +import android.app.prediction.AppTarget; +import android.content.Intent; +import android.content.pm.ShortcutInfo; +import android.content.pm.ShortcutManager; +import android.os.Bundle; +import android.service.chooser.ChooserTarget; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.Comparator; +import java.util.List; +import java.util.Map; + +class ShortcutToChooserTargetConverter { + + /** + * Converts a list of ShareShortcutInfos to ChooserTargets. + * @param matchingShortcuts List of shortcuts, all from the same package, that match the current + * share intent filter. + * @param allShortcuts List of all the shortcuts from all the packages on the device that are + * returned for the current sharing action. + * @param allAppTargets List of AppTargets. Null if the results are not from prediction service. + * @param directShareAppTargetCache An optional map to store mapping for the new ChooserTarget + * instances back to original allAppTargets. + * @param directShareShortcutInfoCache An optional map to store mapping from the new + * ChooserTarget instances back to the original matchingShortcuts' {@code getShortcutInfo()} + * @return A list of ChooserTargets sorted by score in descending order. + */ + @NonNull + public List<ChooserTarget> convertToChooserTarget( + @NonNull List<ShortcutManager.ShareShortcutInfo> matchingShortcuts, + @NonNull List<ShortcutManager.ShareShortcutInfo> allShortcuts, + @Nullable List<AppTarget> allAppTargets, + @Nullable Map<ChooserTarget, AppTarget> directShareAppTargetCache, + @Nullable Map<ChooserTarget, ShortcutInfo> directShareShortcutInfoCache) { + // If |appTargets| is not null, results are from AppPredictionService and already sorted. + final boolean isFromAppPredictor = allAppTargets != null; + // A set of distinct scores for the matched shortcuts. We use index of a rank in the sorted + // list instead of the actual rank value when converting a rank to a score. + List<Integer> scoreList = new ArrayList<>(); + if (!isFromAppPredictor) { + for (int i = 0; i < matchingShortcuts.size(); i++) { + int shortcutRank = matchingShortcuts.get(i).getShortcutInfo().getRank(); + if (!scoreList.contains(shortcutRank)) { + scoreList.add(shortcutRank); + } + } + Collections.sort(scoreList); + } + + List<ChooserTarget> chooserTargetList = new ArrayList<>(matchingShortcuts.size()); + for (int i = 0; i < matchingShortcuts.size(); i++) { + ShortcutInfo shortcutInfo = matchingShortcuts.get(i).getShortcutInfo(); + int indexInAllShortcuts = allShortcuts.indexOf(matchingShortcuts.get(i)); + + float score; + if (isFromAppPredictor) { + // Incoming results are ordered. Create a score based on index in the original list. + score = Math.max(1.0f - (0.01f * indexInAllShortcuts), 0.0f); + } else { + // Create a score based on the rank of the shortcut. + int rankIndex = scoreList.indexOf(shortcutInfo.getRank()); + score = Math.max(1.0f - (0.01f * rankIndex), 0.0f); + } + + Bundle extras = new Bundle(); + extras.putString(Intent.EXTRA_SHORTCUT_ID, shortcutInfo.getId()); + + ChooserTarget chooserTarget = new ChooserTarget( + shortcutInfo.getLabel(), + null, // Icon will be loaded later if this target is selected to be shown. + score, matchingShortcuts.get(i).getTargetComponent().clone(), extras); + + chooserTargetList.add(chooserTarget); + if (directShareAppTargetCache != null && allAppTargets != null) { + directShareAppTargetCache.put(chooserTarget, + allAppTargets.get(indexInAllShortcuts)); + } + if (directShareShortcutInfoCache != null) { + directShareShortcutInfoCache.put(chooserTarget, shortcutInfo); + } + } + // Sort ChooserTargets by score in descending order + Comparator<ChooserTarget> byScore = + (ChooserTarget a, ChooserTarget b) -> -Float.compare(a.getScore(), b.getScore()); + Collections.sort(chooserTargetList, byScore); + return chooserTargetList; + } +} diff --git a/java/src/com/android/intentresolver/widget/ActionRow.kt b/java/src/com/android/intentresolver/widget/ActionRow.kt new file mode 100644 index 00000000..6764d3ae --- /dev/null +++ b/java/src/com/android/intentresolver/widget/ActionRow.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.widget + +import android.content.res.Resources.ID_NULL +import android.graphics.drawable.Drawable + +interface ActionRow { + fun setActions(actions: List<Action>) + + class Action @JvmOverloads constructor( + // TODO: apparently, IDs set to this field are used in unit tests only; evaluate whether we + // get rid of them + val id: Int = ID_NULL, + val label: CharSequence?, + val icon: Drawable?, + val onClicked: Runnable, + ) +} diff --git a/java/src/com/android/intentresolver/widget/ChooserActionRow.kt b/java/src/com/android/intentresolver/widget/ChooserActionRow.kt new file mode 100644 index 00000000..a4656bb5 --- /dev/null +++ b/java/src/com/android/intentresolver/widget/ChooserActionRow.kt @@ -0,0 +1,81 @@ +/* + * 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.annotation.LayoutRes +import android.content.Context +import android.os.Parcelable +import android.util.AttributeSet +import android.view.LayoutInflater +import android.widget.Button +import android.widget.LinearLayout +import com.android.intentresolver.R +import com.android.intentresolver.widget.ActionRow.Action + +class ChooserActionRow : LinearLayout, ActionRow { + 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) { + orientation = HORIZONTAL + } + + @LayoutRes + private val itemLayout = R.layout.chooser_action_button + private val itemMargin = + context.resources.getDimensionPixelSize(R.dimen.resolver_icon_margin) / 2 + private var actions: List<Action> = emptyList() + + override fun onRestoreInstanceState(state: Parcelable?) { + super.onRestoreInstanceState(state) + setActions(actions) + } + + override fun setActions(actions: List<Action>) { + removeAllViews() + this.actions = ArrayList(actions) + for (action in actions) { + addAction(action) + } + } + + private fun addAction(action: Action) { + val b = LayoutInflater.from(context).inflate(itemLayout, null) as Button + if (action.icon != null) { + val size = resources + .getDimensionPixelSize(R.dimen.chooser_action_button_icon_size) + action.icon.setBounds(0, 0, size, size) + b.setCompoundDrawablesRelative(action.icon, null, null, null) + } + b.text = action.label ?: "" + b.setOnClickListener { + action.onClicked.run() + } + b.id = action.id + addView(b) + } + + override fun generateDefaultLayoutParams(): LayoutParams = + super.generateDefaultLayoutParams().apply { + setMarginsRelative(itemMargin, 0, itemMargin, 0) + } +} diff --git a/java/src/com/android/intentresolver/widget/ImagePreviewView.kt b/java/src/com/android/intentresolver/widget/ImagePreviewView.kt new file mode 100644 index 00000000..a37ef954 --- /dev/null +++ b/java/src/com/android/intentresolver/widget/ImagePreviewView.kt @@ -0,0 +1,178 @@ +/* + * 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.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? + +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) + } + + /** + * 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. + */ + 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 + } +} diff --git a/java/src/com/android/intentresolver/widget/ResolverDrawerLayout.java b/java/src/com/android/intentresolver/widget/ResolverDrawerLayout.java new file mode 100644 index 00000000..f5e20510 --- /dev/null +++ b/java/src/com/android/intentresolver/widget/ResolverDrawerLayout.java @@ -0,0 +1,1280 @@ +/* + * Copyright (C) 2014 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 static android.content.res.Resources.ID_NULL; + +import android.annotation.IdRes; +import android.content.Context; +import android.content.res.TypedArray; +import android.graphics.Canvas; +import android.graphics.Rect; +import android.graphics.drawable.Drawable; +import android.metrics.LogMaker; +import android.os.Bundle; +import android.os.Parcel; +import android.os.Parcelable; +import android.util.AttributeSet; +import android.util.Log; +import android.view.MotionEvent; +import android.view.VelocityTracker; +import android.view.View; +import android.view.ViewConfiguration; +import android.view.ViewGroup; +import android.view.ViewParent; +import android.view.ViewTreeObserver; +import android.view.accessibility.AccessibilityEvent; +import android.view.accessibility.AccessibilityNodeInfo; +import android.view.accessibility.AccessibilityNodeInfo.AccessibilityAction; +import android.view.animation.AnimationUtils; +import android.widget.AbsListView; +import android.widget.OverScroller; + +import androidx.recyclerview.widget.RecyclerView; + +import com.android.intentresolver.R; +import com.android.internal.logging.MetricsLogger; +import com.android.internal.logging.nano.MetricsProto.MetricsEvent; + +public class ResolverDrawerLayout extends ViewGroup { + private static final String TAG = "ResolverDrawerLayout"; + private MetricsLogger mMetricsLogger; + + /** + * Max width of the whole drawer layout + */ + private final int mMaxWidth; + + /** + * Max total visible height of views not marked always-show when in the closed/initial state + */ + private int mMaxCollapsedHeight; + + /** + * Max total visible height of views not marked always-show when in the closed/initial state + * when a default option is present + */ + private int mMaxCollapsedHeightSmall; + + /** + * Whether {@code mMaxCollapsedHeightSmall} was set explicitly as a layout attribute or + * inferred by {@code mMaxCollapsedHeight}. + */ + private final boolean mIsMaxCollapsedHeightSmallExplicit; + + private boolean mSmallCollapsed; + + /** + * Move views down from the top by this much in px + */ + private float mCollapseOffset; + + /** + * Track fractions of pixels from drag calculations. Without this, the view offsets get + * out of sync due to frequently dropping fractions of a pixel from '(int) dy' casts. + */ + private float mDragRemainder = 0.0f; + private int mHeightUsed; + private int mCollapsibleHeight; + private int mAlwaysShowHeight; + + /** + * The height in pixels of reserved space added to the top of the collapsed UI; + * e.g. chooser targets + */ + private int mCollapsibleHeightReserved; + + private int mTopOffset; + private boolean mShowAtTop; + @IdRes + private int mIgnoreOffsetTopLimitViewId = ID_NULL; + + private boolean mIsDragging; + private boolean mOpenOnClick; + private boolean mOpenOnLayout; + private boolean mDismissOnScrollerFinished; + private final int mTouchSlop; + private final float mMinFlingVelocity; + private final OverScroller mScroller; + private final VelocityTracker mVelocityTracker; + + private Drawable mScrollIndicatorDrawable; + + private OnDismissedListener mOnDismissedListener; + private RunOnDismissedListener mRunOnDismissedListener; + private OnCollapsedChangedListener mOnCollapsedChangedListener; + + private boolean mDismissLocked; + + private float mInitialTouchX; + private float mInitialTouchY; + private float mLastTouchY; + private int mActivePointerId = MotionEvent.INVALID_POINTER_ID; + + private final Rect mTempRect = new Rect(); + + private AbsListView mNestedListChild; + private RecyclerView mNestedRecyclerChild; + + private final ViewTreeObserver.OnTouchModeChangeListener mTouchModeChangeListener = + new ViewTreeObserver.OnTouchModeChangeListener() { + @Override + public void onTouchModeChanged(boolean isInTouchMode) { + if (!isInTouchMode && hasFocus() && isDescendantClipped(getFocusedChild())) { + smoothScrollTo(0, 0); + } + } + }; + + public ResolverDrawerLayout(Context context) { + this(context, null); + } + + public ResolverDrawerLayout(Context context, AttributeSet attrs) { + this(context, attrs, 0); + } + + public ResolverDrawerLayout(Context context, AttributeSet attrs, int defStyleAttr) { + super(context, attrs, defStyleAttr); + + final TypedArray a = context.obtainStyledAttributes(attrs, R.styleable.ResolverDrawerLayout, + defStyleAttr, 0); + mMaxWidth = a.getDimensionPixelSize(R.styleable.ResolverDrawerLayout_android_maxWidth, -1); + mMaxCollapsedHeight = a.getDimensionPixelSize( + R.styleable.ResolverDrawerLayout_maxCollapsedHeight, 0); + mMaxCollapsedHeightSmall = a.getDimensionPixelSize( + R.styleable.ResolverDrawerLayout_maxCollapsedHeightSmall, + mMaxCollapsedHeight); + mIsMaxCollapsedHeightSmallExplicit = + a.hasValue(R.styleable.ResolverDrawerLayout_maxCollapsedHeightSmall); + mShowAtTop = a.getBoolean(R.styleable.ResolverDrawerLayout_showAtTop, false); + if (a.hasValue(R.styleable.ResolverDrawerLayout_ignoreOffsetTopLimit)) { + mIgnoreOffsetTopLimitViewId = a.getResourceId( + R.styleable.ResolverDrawerLayout_ignoreOffsetTopLimit, ID_NULL); + } + a.recycle(); + + mScrollIndicatorDrawable = mContext.getDrawable( + com.android.internal.R.drawable.scroll_indicator_material); + + mScroller = new OverScroller(context, AnimationUtils.loadInterpolator(context, + android.R.interpolator.decelerate_quint)); + mVelocityTracker = VelocityTracker.obtain(); + + final ViewConfiguration vc = ViewConfiguration.get(context); + mTouchSlop = vc.getScaledTouchSlop(); + mMinFlingVelocity = vc.getScaledMinimumFlingVelocity(); + + setImportantForAccessibility(View.IMPORTANT_FOR_ACCESSIBILITY_YES); + } + + /** + * Dynamically set the max collapsed height. Note this also updates the small collapsed + * height if it wasn't specified explicitly. + */ + public void setMaxCollapsedHeight(int heightInPixels) { + if (heightInPixels == mMaxCollapsedHeight) { + return; + } + mMaxCollapsedHeight = heightInPixels; + if (!mIsMaxCollapsedHeightSmallExplicit) { + mMaxCollapsedHeightSmall = mMaxCollapsedHeight; + } + requestLayout(); + } + + public void setSmallCollapsed(boolean smallCollapsed) { + if (mSmallCollapsed != smallCollapsed) { + mSmallCollapsed = smallCollapsed; + requestLayout(); + } + } + + public boolean isSmallCollapsed() { + return mSmallCollapsed; + } + + public boolean isCollapsed() { + return mCollapseOffset > 0; + } + + public void setShowAtTop(boolean showOnTop) { + if (mShowAtTop != showOnTop) { + mShowAtTop = showOnTop; + requestLayout(); + } + } + + public boolean getShowAtTop() { + return mShowAtTop; + } + + public void setCollapsed(boolean collapsed) { + if (!isLaidOut()) { + mOpenOnLayout = !collapsed; + } else { + smoothScrollTo(collapsed ? mCollapsibleHeight : 0, 0); + } + } + + public void setCollapsibleHeightReserved(int heightPixels) { + final int oldReserved = mCollapsibleHeightReserved; + mCollapsibleHeightReserved = heightPixels; + if (oldReserved != mCollapsibleHeightReserved) { + requestLayout(); + } + + final int dReserved = mCollapsibleHeightReserved - oldReserved; + if (dReserved != 0 && mIsDragging) { + mLastTouchY -= dReserved; + } + + final int oldCollapsibleHeight = updateCollapsibleHeight(); + if (updateCollapseOffset(oldCollapsibleHeight, !isDragging())) { + return; + } + + invalidate(); + } + + public void setDismissLocked(boolean locked) { + mDismissLocked = locked; + } + + private boolean isMoving() { + return mIsDragging || !mScroller.isFinished(); + } + + private boolean isDragging() { + return mIsDragging || getNestedScrollAxes() == SCROLL_AXIS_VERTICAL; + } + + private boolean updateCollapseOffset(int oldCollapsibleHeight, boolean remainClosed) { + if (oldCollapsibleHeight == mCollapsibleHeight) { + return false; + } + + if (getShowAtTop()) { + // Keep the drawer fully open. + setCollapseOffset(0); + return false; + } + + if (isLaidOut()) { + final boolean isCollapsedOld = mCollapseOffset != 0; + if (remainClosed && (oldCollapsibleHeight < mCollapsibleHeight + && mCollapseOffset == oldCollapsibleHeight)) { + // Stay closed even at the new height. + setCollapseOffset(mCollapsibleHeight); + } else { + setCollapseOffset(Math.min(mCollapseOffset, mCollapsibleHeight)); + } + final boolean isCollapsedNew = mCollapseOffset != 0; + if (isCollapsedOld != isCollapsedNew) { + onCollapsedChanged(isCollapsedNew); + } + } else { + // Start out collapsed at first unless we restored state for otherwise + setCollapseOffset(mOpenOnLayout ? 0 : mCollapsibleHeight); + } + return true; + } + + private void setCollapseOffset(float collapseOffset) { + if (mCollapseOffset != collapseOffset) { + mCollapseOffset = collapseOffset; + requestLayout(); + } + } + + private int getMaxCollapsedHeight() { + return (isSmallCollapsed() ? mMaxCollapsedHeightSmall : mMaxCollapsedHeight) + + mCollapsibleHeightReserved; + } + + public void setOnDismissedListener(OnDismissedListener listener) { + mOnDismissedListener = listener; + } + + private boolean isDismissable() { + return mOnDismissedListener != null && !mDismissLocked; + } + + public void setOnCollapsedChangedListener(OnCollapsedChangedListener listener) { + mOnCollapsedChangedListener = listener; + } + + @Override + public boolean onInterceptTouchEvent(MotionEvent ev) { + final int action = ev.getActionMasked(); + + if (action == MotionEvent.ACTION_DOWN) { + mVelocityTracker.clear(); + } + + mVelocityTracker.addMovement(ev); + + switch (action) { + case MotionEvent.ACTION_DOWN: { + final float x = ev.getX(); + final float y = ev.getY(); + mInitialTouchX = x; + mInitialTouchY = mLastTouchY = y; + mOpenOnClick = isListChildUnderClipped(x, y) && mCollapseOffset > 0; + } + break; + + case MotionEvent.ACTION_MOVE: { + final float x = ev.getX(); + final float y = ev.getY(); + final float dy = y - mInitialTouchY; + if (Math.abs(dy) > mTouchSlop && findChildUnder(x, y) != null && + (getNestedScrollAxes() & SCROLL_AXIS_VERTICAL) == 0) { + mActivePointerId = ev.getPointerId(0); + mIsDragging = true; + mLastTouchY = Math.max(mLastTouchY - mTouchSlop, + Math.min(mLastTouchY + dy, mLastTouchY + mTouchSlop)); + } + } + break; + + case MotionEvent.ACTION_POINTER_UP: { + onSecondaryPointerUp(ev); + } + break; + + case MotionEvent.ACTION_CANCEL: + case MotionEvent.ACTION_UP: { + resetTouch(); + } + break; + } + + if (mIsDragging) { + abortAnimation(); + } + return mIsDragging || mOpenOnClick; + } + + private boolean isNestedListChildScrolled() { + return mNestedListChild != null + && mNestedListChild.getChildCount() > 0 + && (mNestedListChild.getFirstVisiblePosition() > 0 + || mNestedListChild.getChildAt(0).getTop() < 0); + } + + private boolean isNestedRecyclerChildScrolled() { + if (mNestedRecyclerChild != null && mNestedRecyclerChild.getChildCount() > 0) { + final RecyclerView.ViewHolder vh = + mNestedRecyclerChild.findViewHolderForAdapterPosition(0); + return vh == null || vh.itemView.getTop() < 0; + } + return false; + } + + @Override + public boolean onTouchEvent(MotionEvent ev) { + final int action = ev.getActionMasked(); + + mVelocityTracker.addMovement(ev); + + boolean handled = false; + switch (action) { + case MotionEvent.ACTION_DOWN: { + final float x = ev.getX(); + final float y = ev.getY(); + mInitialTouchX = x; + mInitialTouchY = mLastTouchY = y; + mActivePointerId = ev.getPointerId(0); + final boolean hitView = findChildUnder(mInitialTouchX, mInitialTouchY) != null; + handled = isDismissable() || mCollapsibleHeight > 0; + mIsDragging = hitView && handled; + abortAnimation(); + } + break; + + case MotionEvent.ACTION_MOVE: { + int index = ev.findPointerIndex(mActivePointerId); + if (index < 0) { + Log.e(TAG, "Bad pointer id " + mActivePointerId + ", resetting"); + index = 0; + mActivePointerId = ev.getPointerId(0); + mInitialTouchX = ev.getX(); + mInitialTouchY = mLastTouchY = ev.getY(); + } + final float x = ev.getX(index); + final float y = ev.getY(index); + if (!mIsDragging) { + final float dy = y - mInitialTouchY; + if (Math.abs(dy) > mTouchSlop && findChildUnder(x, y) != null) { + handled = mIsDragging = true; + mLastTouchY = Math.max(mLastTouchY - mTouchSlop, + Math.min(mLastTouchY + dy, mLastTouchY + mTouchSlop)); + } + } + if (mIsDragging) { + final float dy = y - mLastTouchY; + if (dy > 0 && isNestedListChildScrolled()) { + mNestedListChild.smoothScrollBy((int) -dy, 0); + } else if (dy > 0 && isNestedRecyclerChildScrolled()) { + mNestedRecyclerChild.scrollBy(0, (int) -dy); + } else { + performDrag(dy); + } + } + mLastTouchY = y; + } + break; + + case MotionEvent.ACTION_POINTER_DOWN: { + final int pointerIndex = ev.getActionIndex(); + mActivePointerId = ev.getPointerId(pointerIndex); + mInitialTouchX = ev.getX(pointerIndex); + mInitialTouchY = mLastTouchY = ev.getY(pointerIndex); + } + break; + + case MotionEvent.ACTION_POINTER_UP: { + onSecondaryPointerUp(ev); + } + break; + + case MotionEvent.ACTION_UP: { + final boolean wasDragging = mIsDragging; + mIsDragging = false; + if (!wasDragging && findChildUnder(mInitialTouchX, mInitialTouchY) == null && + findChildUnder(ev.getX(), ev.getY()) == null) { + if (isDismissable()) { + dispatchOnDismissed(); + resetTouch(); + return true; + } + } + if (mOpenOnClick && Math.abs(ev.getX() - mInitialTouchX) < mTouchSlop && + Math.abs(ev.getY() - mInitialTouchY) < mTouchSlop) { + smoothScrollTo(0, 0); + return true; + } + mVelocityTracker.computeCurrentVelocity(1000); + final float yvel = mVelocityTracker.getYVelocity(mActivePointerId); + if (Math.abs(yvel) > mMinFlingVelocity) { + if (getShowAtTop()) { + if (isDismissable() && yvel < 0) { + abortAnimation(); + dismiss(); + } else { + smoothScrollTo(yvel < 0 ? 0 : mCollapsibleHeight, yvel); + } + } else { + if (isDismissable() + && yvel > 0 && mCollapseOffset > mCollapsibleHeight) { + smoothScrollTo(mHeightUsed, yvel); + mDismissOnScrollerFinished = true; + } else { + scrollNestedScrollableChildBackToTop(); + smoothScrollTo(yvel < 0 ? 0 : mCollapsibleHeight, yvel); + } + } + }else { + smoothScrollTo( + mCollapseOffset < mCollapsibleHeight / 2 ? 0 : mCollapsibleHeight, 0); + } + resetTouch(); + } + break; + + case MotionEvent.ACTION_CANCEL: { + if (mIsDragging) { + smoothScrollTo( + mCollapseOffset < mCollapsibleHeight / 2 ? 0 : mCollapsibleHeight, 0); + } + resetTouch(); + return true; + } + } + + return handled; + } + + /** + * Scroll nested scrollable child back to top if it has been scrolled. + */ + public void scrollNestedScrollableChildBackToTop() { + if (isNestedListChildScrolled()) { + mNestedListChild.smoothScrollToPosition(0); + } else if (isNestedRecyclerChildScrolled()) { + mNestedRecyclerChild.smoothScrollToPosition(0); + } + } + + private void onSecondaryPointerUp(MotionEvent ev) { + final int pointerIndex = ev.getActionIndex(); + final int pointerId = ev.getPointerId(pointerIndex); + if (pointerId == mActivePointerId) { + // This was our active pointer going up. Choose a new + // active pointer and adjust accordingly. + final int newPointerIndex = pointerIndex == 0 ? 1 : 0; + mInitialTouchX = ev.getX(newPointerIndex); + mInitialTouchY = mLastTouchY = ev.getY(newPointerIndex); + mActivePointerId = ev.getPointerId(newPointerIndex); + } + } + + private void resetTouch() { + mActivePointerId = MotionEvent.INVALID_POINTER_ID; + mIsDragging = false; + mOpenOnClick = false; + mInitialTouchX = mInitialTouchY = mLastTouchY = 0; + mVelocityTracker.clear(); + } + + private void dismiss() { + mRunOnDismissedListener = new RunOnDismissedListener(); + post(mRunOnDismissedListener); + } + + @Override + public void computeScroll() { + super.computeScroll(); + if (mScroller.computeScrollOffset()) { + final boolean keepGoing = !mScroller.isFinished(); + performDrag(mScroller.getCurrY() - mCollapseOffset); + if (keepGoing) { + postInvalidateOnAnimation(); + } else if (mDismissOnScrollerFinished && mOnDismissedListener != null) { + dismiss(); + } + } + } + + private void abortAnimation() { + mScroller.abortAnimation(); + mRunOnDismissedListener = null; + mDismissOnScrollerFinished = false; + } + + private float performDrag(float dy) { + if (getShowAtTop()) { + return 0; + } + + final float newPos = Math.max(0, Math.min(mCollapseOffset + dy, mHeightUsed)); + if (newPos != mCollapseOffset) { + dy = newPos - mCollapseOffset; + + mDragRemainder += dy - (int) dy; + if (mDragRemainder >= 1.0f) { + mDragRemainder -= 1.0f; + dy += 1.0f; + } else if (mDragRemainder <= -1.0f) { + mDragRemainder += 1.0f; + dy -= 1.0f; + } + + boolean isIgnoreOffsetLimitSet = false; + int ignoreOffsetLimit = 0; + View ignoreOffsetLimitView = findIgnoreOffsetLimitView(); + if (ignoreOffsetLimitView != null) { + LayoutParams lp = (LayoutParams) ignoreOffsetLimitView.getLayoutParams(); + ignoreOffsetLimit = ignoreOffsetLimitView.getBottom() + lp.bottomMargin; + isIgnoreOffsetLimitSet = true; + } + final int childCount = getChildCount(); + for (int i = 0; i < childCount; i++) { + final View child = getChildAt(i); + if (child.getVisibility() == View.GONE) { + continue; + } + final LayoutParams lp = (LayoutParams) child.getLayoutParams(); + if (!lp.ignoreOffset) { + child.offsetTopAndBottom((int) dy); + } else if (isIgnoreOffsetLimitSet) { + int top = child.getTop(); + int targetTop = Math.max( + (int) (ignoreOffsetLimit + lp.topMargin + dy), + lp.mFixedTop); + if (top != targetTop) { + child.offsetTopAndBottom(targetTop - top); + } + ignoreOffsetLimit = child.getBottom() + lp.bottomMargin; + } + } + final boolean isCollapsedOld = mCollapseOffset != 0; + mCollapseOffset = newPos; + mTopOffset += dy; + final boolean isCollapsedNew = newPos != 0; + if (isCollapsedOld != isCollapsedNew) { + onCollapsedChanged(isCollapsedNew); + getMetricsLogger().write( + new LogMaker(MetricsEvent.ACTION_SHARESHEET_COLLAPSED_CHANGED) + .setSubtype(isCollapsedNew ? 1 : 0)); + } + onScrollChanged(0, (int) newPos, 0, (int) (newPos - dy)); + postInvalidateOnAnimation(); + return dy; + } + return 0; + } + + private void onCollapsedChanged(boolean isCollapsed) { + notifyViewAccessibilityStateChangedIfNeeded( + AccessibilityEvent.CONTENT_CHANGE_TYPE_UNDEFINED); + + if (mScrollIndicatorDrawable != null) { + setWillNotDraw(!isCollapsed); + } + + if (mOnCollapsedChangedListener != null) { + mOnCollapsedChangedListener.onCollapsedChanged(isCollapsed); + } + } + + void dispatchOnDismissed() { + if (mOnDismissedListener != null) { + mOnDismissedListener.onDismissed(); + } + if (mRunOnDismissedListener != null) { + removeCallbacks(mRunOnDismissedListener); + mRunOnDismissedListener = null; + } + } + + private void smoothScrollTo(int yOffset, float velocity) { + abortAnimation(); + final int sy = (int) mCollapseOffset; + int dy = yOffset - sy; + if (dy == 0) { + return; + } + + final int height = getHeight(); + final int halfHeight = height / 2; + final float distanceRatio = Math.min(1f, 1.0f * Math.abs(dy) / height); + final float distance = halfHeight + halfHeight * + distanceInfluenceForSnapDuration(distanceRatio); + + int duration = 0; + velocity = Math.abs(velocity); + if (velocity > 0) { + duration = 4 * Math.round(1000 * Math.abs(distance / velocity)); + } else { + final float pageDelta = (float) Math.abs(dy) / height; + duration = (int) ((pageDelta + 1) * 100); + } + duration = Math.min(duration, 300); + + mScroller.startScroll(0, sy, 0, dy, duration); + postInvalidateOnAnimation(); + } + + private float distanceInfluenceForSnapDuration(float f) { + f -= 0.5f; // center the values about 0. + f *= 0.3f * Math.PI / 2.0f; + return (float) Math.sin(f); + } + + /** + * Note: this method doesn't take Z into account for overlapping views + * since it is only used in contexts where this doesn't affect the outcome. + */ + private View findChildUnder(float x, float y) { + return findChildUnder(this, x, y); + } + + private static View findChildUnder(ViewGroup parent, float x, float y) { + final int childCount = parent.getChildCount(); + for (int i = childCount - 1; i >= 0; i--) { + final View child = parent.getChildAt(i); + if (isChildUnder(child, x, y)) { + return child; + } + } + return null; + } + + private View findListChildUnder(float x, float y) { + View v = findChildUnder(x, y); + while (v != null) { + x -= v.getX(); + y -= v.getY(); + if (v instanceof AbsListView) { + // One more after this. + return findChildUnder((ViewGroup) v, x, y); + } + v = v instanceof ViewGroup ? findChildUnder((ViewGroup) v, x, y) : null; + } + return v; + } + + /** + * This only checks clipping along the bottom edge. + */ + private boolean isListChildUnderClipped(float x, float y) { + final View listChild = findListChildUnder(x, y); + return listChild != null && isDescendantClipped(listChild); + } + + private boolean isDescendantClipped(View child) { + mTempRect.set(0, 0, child.getWidth(), child.getHeight()); + offsetDescendantRectToMyCoords(child, mTempRect); + View directChild; + if (child.getParent() == this) { + directChild = child; + } else { + View v = child; + ViewParent p = child.getParent(); + while (p != this) { + v = (View) p; + p = v.getParent(); + } + directChild = v; + } + + // ResolverDrawerLayout lays out vertically in child order; + // the next view and forward is what to check against. + int clipEdge = getHeight() - getPaddingBottom(); + final int childCount = getChildCount(); + for (int i = indexOfChild(directChild) + 1; i < childCount; i++) { + final View nextChild = getChildAt(i); + if (nextChild.getVisibility() == GONE) { + continue; + } + clipEdge = Math.min(clipEdge, nextChild.getTop()); + } + return mTempRect.bottom > clipEdge; + } + + private static boolean isChildUnder(View child, float x, float y) { + final float left = child.getX(); + final float top = child.getY(); + final float right = left + child.getWidth(); + final float bottom = top + child.getHeight(); + return x >= left && y >= top && x < right && y < bottom; + } + + @Override + public void requestChildFocus(View child, View focused) { + super.requestChildFocus(child, focused); + if (!isInTouchMode() && isDescendantClipped(focused)) { + smoothScrollTo(0, 0); + } + } + + @Override + protected void onAttachedToWindow() { + super.onAttachedToWindow(); + getViewTreeObserver().addOnTouchModeChangeListener(mTouchModeChangeListener); + } + + @Override + protected void onDetachedFromWindow() { + super.onDetachedFromWindow(); + getViewTreeObserver().removeOnTouchModeChangeListener(mTouchModeChangeListener); + abortAnimation(); + } + + @Override + public boolean onStartNestedScroll(View child, View target, int nestedScrollAxes) { + if ((nestedScrollAxes & View.SCROLL_AXIS_VERTICAL) != 0) { + if (target instanceof AbsListView) { + mNestedListChild = (AbsListView) target; + } + if (target instanceof RecyclerView) { + mNestedRecyclerChild = (RecyclerView) target; + } + return true; + } + return false; + } + + @Override + public void onNestedScrollAccepted(View child, View target, int axes) { + super.onNestedScrollAccepted(child, target, axes); + } + + @Override + public void onStopNestedScroll(View child) { + super.onStopNestedScroll(child); + if (mScroller.isFinished()) { + smoothScrollTo(mCollapseOffset < mCollapsibleHeight / 2 ? 0 : mCollapsibleHeight, 0); + } + } + + @Override + public void onNestedScroll(View target, int dxConsumed, int dyConsumed, + int dxUnconsumed, int dyUnconsumed) { + if (dyUnconsumed < 0) { + performDrag(-dyUnconsumed); + } + } + + @Override + public void onNestedPreScroll(View target, int dx, int dy, int[] consumed) { + if (dy > 0) { + consumed[1] = (int) -performDrag(-dy); + } + } + + @Override + public boolean onNestedPreFling(View target, float velocityX, float velocityY) { + if (!getShowAtTop() && velocityY > mMinFlingVelocity && mCollapseOffset != 0) { + smoothScrollTo(0, velocityY); + return true; + } + return false; + } + + @Override + public boolean onNestedFling(View target, float velocityX, float velocityY, boolean consumed) { + if (!consumed && Math.abs(velocityY) > mMinFlingVelocity) { + if (getShowAtTop()) { + if (isDismissable() && velocityY > 0) { + abortAnimation(); + dismiss(); + } else { + smoothScrollTo(velocityY < 0 ? mCollapsibleHeight : 0, velocityY); + } + } else { + if (isDismissable() + && velocityY < 0 && mCollapseOffset > mCollapsibleHeight) { + smoothScrollTo(mHeightUsed, velocityY); + mDismissOnScrollerFinished = true; + } else { + smoothScrollTo(velocityY > 0 ? 0 : mCollapsibleHeight, velocityY); + } + } + return true; + } + return false; + } + + private boolean performAccessibilityActionCommon(int action) { + switch (action) { + case AccessibilityNodeInfo.ACTION_SCROLL_FORWARD: + case AccessibilityNodeInfo.ACTION_EXPAND: + case com.android.internal.R.id.accessibilityActionScrollDown: + if (mCollapseOffset != 0) { + smoothScrollTo(0, 0); + return true; + } + break; + case AccessibilityNodeInfo.ACTION_COLLAPSE: + if (mCollapseOffset < mCollapsibleHeight) { + smoothScrollTo(mCollapsibleHeight, 0); + return true; + } + break; + case AccessibilityNodeInfo.ACTION_DISMISS: + if ((mCollapseOffset < mHeightUsed) && isDismissable()) { + smoothScrollTo(mHeightUsed, 0); + mDismissOnScrollerFinished = true; + return true; + } + break; + } + + return false; + } + + @Override + public boolean onNestedPrePerformAccessibilityAction(View target, int action, Bundle args) { + if (super.onNestedPrePerformAccessibilityAction(target, action, args)) { + return true; + } + + return performAccessibilityActionCommon(action); + } + + @Override + public CharSequence getAccessibilityClassName() { + // Since we support scrolling, make this ViewGroup look like a + // ScrollView. This is kind of a hack until we have support for + // specifying auto-scroll behavior. + return android.widget.ScrollView.class.getName(); + } + + @Override + public void onInitializeAccessibilityNodeInfoInternal(AccessibilityNodeInfo info) { + super.onInitializeAccessibilityNodeInfoInternal(info); + + if (isEnabled()) { + if (mCollapseOffset != 0) { + info.addAction(AccessibilityAction.ACTION_SCROLL_FORWARD); + info.addAction(AccessibilityAction.ACTION_EXPAND); + info.addAction(AccessibilityAction.ACTION_SCROLL_DOWN); + info.setScrollable(true); + } + if ((mCollapseOffset < mHeightUsed) + && ((mCollapseOffset < mCollapsibleHeight) || isDismissable())) { + info.addAction(AccessibilityAction.ACTION_SCROLL_UP); + info.setScrollable(true); + } + if (mCollapseOffset < mCollapsibleHeight) { + info.addAction(AccessibilityAction.ACTION_COLLAPSE); + } + if (mCollapseOffset < mHeightUsed && isDismissable()) { + info.addAction(AccessibilityAction.ACTION_DISMISS); + } + } + + // This view should never get accessibility focus, but it's interactive + // via nested scrolling, so we can't hide it completely. + info.removeAction(AccessibilityAction.ACTION_ACCESSIBILITY_FOCUS); + } + + @Override + public boolean performAccessibilityActionInternal(int action, Bundle arguments) { + if (action == AccessibilityAction.ACTION_ACCESSIBILITY_FOCUS.getId()) { + // This view should never get accessibility focus. + return false; + } + + if (super.performAccessibilityActionInternal(action, arguments)) { + return true; + } + + return performAccessibilityActionCommon(action); + } + + @Override + public void onDrawForeground(Canvas canvas) { + if (mScrollIndicatorDrawable != null) { + mScrollIndicatorDrawable.draw(canvas); + } + + super.onDrawForeground(canvas); + } + + @Override + protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) { + final int sourceWidth = MeasureSpec.getSize(widthMeasureSpec); + int widthSize = sourceWidth; + final int heightSize = MeasureSpec.getSize(heightMeasureSpec); + + // Single-use layout; just ignore the mode and use available space. + // Clamp to maxWidth. + if (mMaxWidth >= 0) { + widthSize = Math.min(widthSize, mMaxWidth + getPaddingLeft() + getPaddingRight()); + } + + final int widthSpec = MeasureSpec.makeMeasureSpec(widthSize, MeasureSpec.EXACTLY); + final int heightSpec = MeasureSpec.makeMeasureSpec(heightSize, MeasureSpec.EXACTLY); + + // Currently we allot more height than is really needed so that the entirety of the + // sheet may be pulled up. + // TODO: Restrict the height here to be the right value. + int heightUsed = 0; + + // Measure always-show children first. + final int childCount = getChildCount(); + for (int i = 0; i < childCount; i++) { + final View child = getChildAt(i); + final LayoutParams lp = (LayoutParams) child.getLayoutParams(); + if (lp.alwaysShow && child.getVisibility() != GONE) { + if (lp.maxHeight != -1) { + final int remainingHeight = heightSize - heightUsed; + measureChildWithMargins(child, widthSpec, 0, + MeasureSpec.makeMeasureSpec(lp.maxHeight, MeasureSpec.AT_MOST), + lp.maxHeight > remainingHeight ? lp.maxHeight - remainingHeight : 0); + } else { + measureChildWithMargins(child, widthSpec, 0, heightSpec, heightUsed); + } + heightUsed += child.getMeasuredHeight(); + } + } + + mAlwaysShowHeight = heightUsed; + + // And now the rest. + for (int i = 0; i < childCount; i++) { + final View child = getChildAt(i); + + final LayoutParams lp = (LayoutParams) child.getLayoutParams(); + if (!lp.alwaysShow && child.getVisibility() != GONE) { + if (lp.maxHeight != -1) { + final int remainingHeight = heightSize - heightUsed; + measureChildWithMargins(child, widthSpec, 0, + MeasureSpec.makeMeasureSpec(lp.maxHeight, MeasureSpec.AT_MOST), + lp.maxHeight > remainingHeight ? lp.maxHeight - remainingHeight : 0); + } else { + measureChildWithMargins(child, widthSpec, 0, heightSpec, heightUsed); + } + heightUsed += child.getMeasuredHeight(); + } + } + + mHeightUsed = heightUsed; + int oldCollapsibleHeight = updateCollapsibleHeight(); + updateCollapseOffset(oldCollapsibleHeight, !isDragging()); + + if (getShowAtTop()) { + mTopOffset = 0; + } else { + mTopOffset = Math.max(0, heightSize - mHeightUsed) + (int) mCollapseOffset; + } + + setMeasuredDimension(sourceWidth, heightSize); + } + + private int updateCollapsibleHeight() { + final int oldCollapsibleHeight = mCollapsibleHeight; + mCollapsibleHeight = Math.max(0, mHeightUsed - mAlwaysShowHeight - getMaxCollapsedHeight()); + return oldCollapsibleHeight; + } + + /** + * @return The space reserved by views with 'alwaysShow=true' + */ + public int getAlwaysShowHeight() { + return mAlwaysShowHeight; + } + + @Override + protected void onLayout(boolean changed, int l, int t, int r, int b) { + final int width = getWidth(); + + View indicatorHost = null; + + int ypos = mTopOffset; + final int leftEdge = getPaddingLeft(); + final int rightEdge = width - getPaddingRight(); + final int widthAvailable = rightEdge - leftEdge; + + boolean isIgnoreOffsetLimitSet = false; + int ignoreOffsetLimit = 0; + final int childCount = getChildCount(); + for (int i = 0; i < childCount; i++) { + final View child = getChildAt(i); + final LayoutParams lp = (LayoutParams) child.getLayoutParams(); + if (lp.hasNestedScrollIndicator) { + indicatorHost = child; + } + + if (child.getVisibility() == GONE) { + continue; + } + + if (mIgnoreOffsetTopLimitViewId != ID_NULL && !isIgnoreOffsetLimitSet) { + if (mIgnoreOffsetTopLimitViewId == child.getId()) { + ignoreOffsetLimit = child.getBottom() + lp.bottomMargin; + isIgnoreOffsetLimitSet = true; + } + } + + int top = ypos + lp.topMargin; + if (lp.ignoreOffset) { + if (!isDragging()) { + lp.mFixedTop = (int) (top - mCollapseOffset); + } + if (isIgnoreOffsetLimitSet) { + top = Math.max(ignoreOffsetLimit + lp.topMargin, (int) (top - mCollapseOffset)); + ignoreOffsetLimit = top + child.getMeasuredHeight() + lp.bottomMargin; + } else { + top -= mCollapseOffset; + } + } + final int bottom = top + child.getMeasuredHeight(); + + final int childWidth = child.getMeasuredWidth(); + final int left = leftEdge + (widthAvailable - childWidth) / 2; + final int right = left + childWidth; + + child.layout(left, top, right, bottom); + + ypos = bottom + lp.bottomMargin; + } + + if (mScrollIndicatorDrawable != null) { + if (indicatorHost != null) { + final int left = indicatorHost.getLeft(); + final int right = indicatorHost.getRight(); + final int bottom = indicatorHost.getTop(); + final int top = bottom - mScrollIndicatorDrawable.getIntrinsicHeight(); + mScrollIndicatorDrawable.setBounds(left, top, right, bottom); + setWillNotDraw(!isCollapsed()); + } else { + mScrollIndicatorDrawable = null; + setWillNotDraw(true); + } + } + } + + @Override + public ViewGroup.LayoutParams generateLayoutParams(AttributeSet attrs) { + return new LayoutParams(getContext(), attrs); + } + + @Override + protected ViewGroup.LayoutParams generateLayoutParams(ViewGroup.LayoutParams p) { + if (p instanceof LayoutParams) { + return new LayoutParams((LayoutParams) p); + } else if (p instanceof MarginLayoutParams) { + return new LayoutParams((MarginLayoutParams) p); + } + return new LayoutParams(p); + } + + @Override + protected ViewGroup.LayoutParams generateDefaultLayoutParams() { + return new LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT); + } + + @Override + protected Parcelable onSaveInstanceState() { + final SavedState ss = new SavedState(super.onSaveInstanceState()); + ss.open = mCollapsibleHeight > 0 && mCollapseOffset == 0; + ss.mCollapsibleHeightReserved = mCollapsibleHeightReserved; + return ss; + } + + @Override + protected void onRestoreInstanceState(Parcelable state) { + final SavedState ss = (SavedState) state; + super.onRestoreInstanceState(ss.getSuperState()); + mOpenOnLayout = ss.open; + mCollapsibleHeightReserved = ss.mCollapsibleHeightReserved; + } + + private View findIgnoreOffsetLimitView() { + if (mIgnoreOffsetTopLimitViewId == ID_NULL) { + return null; + } + View v = findViewById(mIgnoreOffsetTopLimitViewId); + if (v != null && v != this && v.getParent() == this && v.getVisibility() != View.GONE) { + return v; + } + return null; + } + + public static class LayoutParams extends MarginLayoutParams { + public boolean alwaysShow; + public boolean ignoreOffset; + public boolean hasNestedScrollIndicator; + public int maxHeight; + int mFixedTop; + + public LayoutParams(Context c, AttributeSet attrs) { + super(c, attrs); + + final TypedArray a = c.obtainStyledAttributes(attrs, + R.styleable.ResolverDrawerLayout_LayoutParams); + alwaysShow = a.getBoolean( + R.styleable.ResolverDrawerLayout_LayoutParams_layout_alwaysShow, + false); + ignoreOffset = a.getBoolean( + R.styleable.ResolverDrawerLayout_LayoutParams_layout_ignoreOffset, + false); + hasNestedScrollIndicator = a.getBoolean( + R.styleable.ResolverDrawerLayout_LayoutParams_layout_hasNestedScrollIndicator, + false); + maxHeight = a.getDimensionPixelSize( + R.styleable.ResolverDrawerLayout_LayoutParams_layout_maxHeight, -1); + a.recycle(); + } + + public LayoutParams(int width, int height) { + super(width, height); + } + + public LayoutParams(LayoutParams source) { + super(source); + this.alwaysShow = source.alwaysShow; + this.ignoreOffset = source.ignoreOffset; + this.hasNestedScrollIndicator = source.hasNestedScrollIndicator; + this.maxHeight = source.maxHeight; + } + + public LayoutParams(MarginLayoutParams source) { + super(source); + } + + public LayoutParams(ViewGroup.LayoutParams source) { + super(source); + } + } + + static class SavedState extends BaseSavedState { + boolean open; + private int mCollapsibleHeightReserved; + + SavedState(Parcelable superState) { + super(superState); + } + + private SavedState(Parcel in) { + super(in); + open = in.readInt() != 0; + mCollapsibleHeightReserved = in.readInt(); + } + + @Override + public void writeToParcel(Parcel out, int flags) { + super.writeToParcel(out, flags); + out.writeInt(open ? 1 : 0); + out.writeInt(mCollapsibleHeightReserved); + } + + public static final Parcelable.Creator<SavedState> CREATOR = + new Parcelable.Creator<SavedState>() { + @Override + public SavedState createFromParcel(Parcel in) { + return new SavedState(in); + } + + @Override + public SavedState[] newArray(int size) { + return new SavedState[size]; + } + }; + } + + /** + * Listener for sheet dismissed events. + */ + public interface OnDismissedListener { + /** + * Callback when the sheet is dismissed by the user. + */ + void onDismissed(); + } + + /** + * Listener for sheet collapsed / expanded events. + */ + public interface OnCollapsedChangedListener { + /** + * Callback when the sheet is either fully expanded or collapsed. + * @param isCollapsed true when collapsed, false when expanded. + */ + void onCollapsedChanged(boolean isCollapsed); + } + + private class RunOnDismissedListener implements Runnable { + @Override + public void run() { + dispatchOnDismissed(); + } + } + + private MetricsLogger getMetricsLogger() { + if (mMetricsLogger == null) { + mMetricsLogger = new MetricsLogger(); + } + return mMetricsLogger; + } +} diff --git a/java/src/com/android/intentresolver/widget/RoundedRectImageView.java b/java/src/com/android/intentresolver/widget/RoundedRectImageView.java new file mode 100644 index 00000000..8538041b --- /dev/null +++ b/java/src/com/android/intentresolver/widget/RoundedRectImageView.java @@ -0,0 +1,132 @@ +/* + * 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.widget; + +import android.content.Context; +import android.graphics.Canvas; +import android.graphics.Color; +import android.graphics.Paint; +import android.graphics.Path; +import android.util.AttributeSet; +import android.widget.ImageView; + +import com.android.intentresolver.R; + +/** + * {@link ImageView} that rounds the corners around the presented image while obeying view padding. + */ +public class RoundedRectImageView extends ImageView { + private int mRadius = 0; + private Path mPath = new Path(); + private Paint mOverlayPaint = new Paint(0); + private Paint mRoundRectPaint = new Paint(0); + private Paint mTextPaint = new Paint(Paint.ANTI_ALIAS_FLAG); + private String mExtraImageCount = null; + + public RoundedRectImageView(Context context) { + super(context); + } + + public RoundedRectImageView(Context context, AttributeSet attrs) { + this(context, attrs, 0); + } + + public RoundedRectImageView(Context context, AttributeSet attrs, int defStyleAttr) { + this(context, attrs, defStyleAttr, 0); + } + + public RoundedRectImageView( + Context context, AttributeSet attrs, int defStyleAttr, int defStyleRes) { + super(context, attrs, defStyleAttr, defStyleRes); + mRadius = context.getResources().getDimensionPixelSize(R.dimen.chooser_corner_radius); + + mOverlayPaint.setColor(0x99000000); + mOverlayPaint.setStyle(Paint.Style.FILL); + + mRoundRectPaint.setColor(context.getResources().getColor(R.color.chooser_row_divider)); + mRoundRectPaint.setStyle(Paint.Style.STROKE); + mRoundRectPaint.setStrokeWidth(context.getResources() + .getDimensionPixelSize(R.dimen.chooser_preview_image_border)); + + mTextPaint.setColor(Color.WHITE); + mTextPaint.setTextSize(context.getResources() + .getDimensionPixelSize(R.dimen.chooser_preview_image_font_size)); + mTextPaint.setTextAlign(Paint.Align.CENTER); + } + + private void updatePath(int width, int height) { + mPath.reset(); + + int imageWidth = width - getPaddingRight() - getPaddingLeft(); + int imageHeight = height - getPaddingBottom() - getPaddingTop(); + mPath.addRoundRect(getPaddingLeft(), getPaddingTop(), imageWidth, imageHeight, mRadius, + mRadius, Path.Direction.CW); + } + + /** + * Sets the corner radius on all corners + * + * param radius 0 for no radius, > 0 for a visible corner radius + */ + public void setRadius(int radius) { + mRadius = radius; + updatePath(getWidth(), getHeight()); + } + + /** + * Display an overlay with extra image count on 3rd image + */ + public void setExtraImageCount(int count) { + if (count > 0) { + this.mExtraImageCount = "+" + count; + } else { + this.mExtraImageCount = null; + } + invalidate(); + } + + @Override + protected void onSizeChanged(int width, int height, int oldWidth, int oldHeight) { + super.onSizeChanged(width, height, oldWidth, oldHeight); + updatePath(width, height); + } + + @Override + protected void onDraw(Canvas canvas) { + if (mRadius != 0) { + canvas.clipPath(mPath); + } + + super.onDraw(canvas); + + int x = getPaddingLeft(); + int y = getPaddingRight(); + int width = getWidth() - getPaddingRight() - getPaddingLeft(); + int height = getHeight() - getPaddingBottom() - getPaddingTop(); + if (mExtraImageCount != null) { + canvas.drawRect(x, y, width, height, mOverlayPaint); + + int xPos = canvas.getWidth() / 2; + int yPos = (int) ((canvas.getHeight() / 2.0f) + - ((mTextPaint.descent() + mTextPaint.ascent()) / 2.0f)); + + canvas.drawText(mExtraImageCount, xPos, yPos, mTextPaint); + } + + canvas.drawRoundRect(x, y, width, height, mRadius, mRadius, mRoundRectPaint); + } +} diff --git a/java/src/com/android/intentresolver/widget/ScrollableActionRow.kt b/java/src/com/android/intentresolver/widget/ScrollableActionRow.kt new file mode 100644 index 00000000..a941b97a --- /dev/null +++ b/java/src/com/android/intentresolver/widget/ScrollableActionRow.kt @@ -0,0 +1,130 @@ +/* + * 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.content.Context +import android.graphics.drawable.Drawable +import android.util.AttributeSet +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.TextView +import androidx.recyclerview.widget.LinearLayoutManager +import androidx.recyclerview.widget.RecyclerView +import com.android.intentresolver.R + +class ScrollableActionRow : RecyclerView, ActionRow { + 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) + } + + private val actionsAdapter get() = adapter as Adapter + + override fun setActions(actions: List<ActionRow.Action>) { + actionsAdapter.setActions(actions) + } + + 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 + ) + } + + 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) + private val itemLayout = R.layout.chooser_action_view + private var actions: List<ActionRow.Action> = emptyList() + + override fun onCreateViewHolder(parent: ViewGroup, type: Int): ViewHolder = + ViewHolder( + LayoutInflater.from(context).inflate(itemLayout, null) as TextView, + iconSize + ) + + override fun onBindViewHolder(holder: ViewHolder, position: Int) { + holder.bind(actions[position]) + } + + override fun getItemCount() = actions.size + + override fun onViewRecycled(holder: ViewHolder) { + holder.unbind() + } + + override fun onFailedToRecycleView(holder: ViewHolder): Boolean { + holder.unbind() + return super.onFailedToRecycleView(holder) + } + + fun setActions(actions: List<ActionRow.Action>) { + this.actions = ArrayList(actions) + notifyDataSetChanged() + } + } + + private class ViewHolder( + private val view: TextView, private val iconSize: Int + ) : RecyclerView.ViewHolder(view) { + + fun bind(action: ActionRow.Action) { + if (action.icon != null) { + action.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) + } + view.text = action.label ?: "" + view.setOnClickListener { + action.onClicked.run() + } + view.id = action.id + } + + fun unbind() { + view.setOnClickListener(null) + } + + private fun tintIcon(drawable: Drawable, view: TextView) { + val tintList = view.compoundDrawableTintList ?: return + drawable.setTintList(tintList) + view.compoundDrawableTintMode?.let { drawable.setTintMode(it) } + view.compoundDrawableTintBlendMode?.let { drawable.setTintBlendMode(it) } + } + } +} |