diff options
80 files changed, 10523 insertions, 4499 deletions
@@ -36,6 +36,7 @@ android_library { min_sdk_version: "current", srcs: [ "java/src/**/*.java", + "java/src/**/*.kt", ], resource_dirs: [ "java/res", @@ -45,7 +46,12 @@ android_library { static_libs: [ "androidx.annotation_annotation", - "unsupportedappusage", + "androidx.concurrent_concurrent-futures", + "androidx.recyclerview_recyclerview", + "androidx.viewpager_viewpager", + "androidx.lifecycle_lifecycle-common-java8", + "androidx.lifecycle_lifecycle-extensions", + "guava", ], lint: { @@ -67,10 +73,7 @@ android_app { "IntentResolver-core", ], optimize: { - // TODO: consider re-enabling after setting up Proguard rules to - // preserve the name of the ChooserGridLayoutManager class, which is - // referenced by name in the chooser_list_per_profile layout XML. - enabled: false, + enabled: true, }, apex_available: [ "//apex_available:platform", diff --git a/java/res/layout/chooser_dialog.xml b/java/res/layout/chooser_dialog.xml index ff66bbb9..e31712c7 100644 --- a/java/res/layout/chooser_dialog.xml +++ b/java/res/layout/chooser_dialog.xml @@ -50,9 +50,9 @@ </LinearLayout> - <com.android.internal.widget.RecyclerView + <androidx.recyclerview.widget.RecyclerView xmlns:app="http://schemas.android.com/apk/res-auto" - androidprv:layoutManager="com.android.internal.widget.LinearLayoutManager" + app:layoutManager="androidx.recyclerview.widget.LinearLayoutManager" android:id="@androidprv:id/listContainer" android:overScrollMode="never" android:layout_width="match_parent" diff --git a/java/res/layout/chooser_grid.xml b/java/res/layout/chooser_grid.xml index a95b0ebe..d863495d 100644 --- a/java/res/layout/chooser_grid.xml +++ b/java/res/layout/chooser_grid.xml @@ -16,14 +16,15 @@ * limitations under the License. */ --> -<com.android.internal.widget.ResolverDrawerLayout +<com.android.intentresolver.widget.ResolverDrawerLayout xmlns:android="http://schemas.android.com/apk/res/android" xmlns:androidprv="http://schemas.android.com/apk/prv/res/android" + xmlns:app="http://schemas.android.com/apk/res-auto" android:layout_width="match_parent" android:layout_height="match_parent" android:layout_gravity="center" - androidprv:maxCollapsedHeight="0dp" - androidprv:maxCollapsedHeightSmall="56dp" + app:maxCollapsedHeight="0dp" + app:maxCollapsedHeightSmall="56dp" android:maxWidth="@dimen/chooser_width" android:id="@androidprv:id/contentPanel"> @@ -31,7 +32,7 @@ android:id="@androidprv:id/chooser_header" android:layout_width="match_parent" android:layout_height="wrap_content" - androidprv:layout_alwaysShow="true" + app:layout_alwaysShow="true" android:elevation="0dp" android:background="@drawable/bottomsheet_background"> @@ -94,4 +95,4 @@ </LinearLayout> </TabHost> -</com.android.internal.widget.ResolverDrawerLayout> +</com.android.intentresolver.widget.ResolverDrawerLayout> diff --git a/java/res/layout/chooser_grid_preview_file.xml b/java/res/layout/chooser_grid_preview_file.xml index 6d2e76a0..c3392704 100644 --- a/java/res/layout/chooser_grid_preview_file.xml +++ b/java/res/layout/chooser_grid_preview_file.xml @@ -37,7 +37,7 @@ android:layout_marginBottom="@dimen/chooser_view_spacing" android:id="@androidprv:id/content_preview_file_layout"> - <view class="com.android.intentresolver.ChooserActivity$RoundedRectImageView" + <com.android.intentresolver.widget.RoundedRectImageView android:id="@androidprv:id/content_preview_file_thumbnail" android:layout_width="75dp" android:layout_height="75dp" diff --git a/java/res/layout/chooser_grid_preview_image.xml b/java/res/layout/chooser_grid_preview_image.xml index 96054eb5..4d15bf75 100644 --- a/java/res/layout/chooser_grid_preview_image.xml +++ b/java/res/layout/chooser_grid_preview_image.xml @@ -32,7 +32,7 @@ android:paddingBottom="@dimen/chooser_view_spacing" android:background="?android:attr/colorBackground"> - <view class="com.android.intentresolver.ChooserActivity$RoundedRectImageView" + <com.android.intentresolver.widget.RoundedRectImageView android:id="@androidprv:id/content_preview_image_1_large" android:layout_width="120dp" android:layout_height="104dp" @@ -41,7 +41,7 @@ android:gravity="center" android:scaleType="centerCrop"/> - <view class="com.android.intentresolver.ChooserActivity$RoundedRectImageView" + <com.android.intentresolver.widget.RoundedRectImageView android:id="@androidprv:id/content_preview_image_2_large" android:visibility="gone" android:layout_width="120dp" @@ -53,7 +53,7 @@ android:gravity="center" android:scaleType="centerCrop"/> - <view class="com.android.intentresolver.ChooserActivity$RoundedRectImageView" + <com.android.intentresolver.widget.RoundedRectImageView android:id="@androidprv:id/content_preview_image_2_small" android:visibility="gone" android:layout_width="120dp" @@ -65,7 +65,7 @@ android:gravity="center" android:scaleType="centerCrop"/> - <view class="com.android.intentresolver.ChooserActivity$RoundedRectImageView" + <com.android.intentresolver.widget.RoundedRectImageView android:id="@androidprv:id/content_preview_image_3_small" android:visibility="gone" android:layout_width="120dp" diff --git a/java/res/layout/chooser_grid_preview_text.xml b/java/res/layout/chooser_grid_preview_text.xml index a9ed71b7..81fdbd08 100644 --- a/java/res/layout/chooser_grid_preview_text.xml +++ b/java/res/layout/chooser_grid_preview_text.xml @@ -75,7 +75,7 @@ android:background="@androidprv:drawable/chooser_content_preview_rounded" android:id="@androidprv:id/content_preview_title_layout"> - <view class="com.android.intentresolver.ChooserActivity$RoundedRectImageView" + <com.android.intentresolver.widget.RoundedRectImageView android:id="@androidprv:id/content_preview_thumbnail" android:layout_width="75dp" android:layout_height="75dp" diff --git a/java/res/layout/chooser_list_per_profile.xml b/java/res/layout/chooser_list_per_profile.xml index 8d876cdf..1753e2f6 100644 --- a/java/res/layout/chooser_list_per_profile.xml +++ b/java/res/layout/chooser_list_per_profile.xml @@ -16,12 +16,13 @@ <RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android" xmlns:androidprv="http://schemas.android.com/apk/prv/res/android" + xmlns:app="http://schemas.android.com/apk/res-auto" android:layout_width="match_parent" android:layout_height="match_parent"> - <com.android.internal.widget.RecyclerView + <androidx.recyclerview.widget.RecyclerView android:layout_width="match_parent" android:layout_height="match_parent" - androidprv:layoutManager="com.android.intentresolver.ChooserGridLayoutManager" + app:layoutManager="com.android.intentresolver.ChooserGridLayoutManager" android:id="@androidprv:id/resolver_list" android:clipToPadding="false" android:background="?android:attr/colorBackground" diff --git a/java/res/layout/miniresolver.xml b/java/res/layout/miniresolver.xml index ab65aa9b..7e31de57 100644 --- a/java/res/layout/miniresolver.xml +++ b/java/res/layout/miniresolver.xml @@ -14,20 +14,21 @@ ~ See the License for the specific language governing permissions and ~ limitations under the License. --> -<com.android.internal.widget.ResolverDrawerLayout +<com.android.intentresolver.widget.ResolverDrawerLayout xmlns:android="http://schemas.android.com/apk/res/android" xmlns:androidprv="http://schemas.android.com/apk/prv/res/android" + xmlns:app="http://schemas.android.com/apk/res-auto" android:layout_width="match_parent" android:layout_height="match_parent" android:maxWidth="@dimen/resolver_max_width" - androidprv:maxCollapsedHeight="@dimen/resolver_max_collapsed_height" - androidprv:maxCollapsedHeightSmall="56dp" + app:maxCollapsedHeight="@dimen/resolver_max_collapsed_height" + app:maxCollapsedHeightSmall="56dp" android:id="@androidprv:id/contentPanel"> <RelativeLayout android:layout_width="match_parent" android:layout_height="wrap_content" - androidprv:layout_alwaysShow="true" + app:layout_alwaysShow="true" android:elevation="@dimen/resolver_elevation" android:paddingTop="24dp" android:paddingStart="@dimen/resolver_edge_margin" @@ -62,18 +63,18 @@ android:id="@androidprv:id/button_bar_container" android:layout_width="match_parent" android:layout_height="wrap_content" - androidprv:layout_alwaysShow="true" + app:layout_alwaysShow="true" android:paddingTop="32dp" android:paddingBottom="@dimen/resolver_button_bar_spacing" android:orientation="vertical" android:background="?android:attr/colorBackground" - androidprv:layout_ignoreOffset="true"> + app:layout_ignoreOffset="true"> <RelativeLayout style="?android:attr/buttonBarStyle" android:layout_width="match_parent" android:layout_height="wrap_content" - androidprv:layout_ignoreOffset="true" - androidprv:layout_hasNestedScrollIndicator="true" + app:layout_ignoreOffset="true" + app:layout_hasNestedScrollIndicator="true" android:gravity="end|center_vertical" android:orientation="horizontal" android:layoutDirection="locale" @@ -112,4 +113,4 @@ /> </RelativeLayout> </LinearLayout> -</com.android.internal.widget.ResolverDrawerLayout> +</com.android.intentresolver.widget.ResolverDrawerLayout> diff --git a/java/res/layout/resolver_different_item_header.xml b/java/res/layout/resolver_different_item_header.xml index 4f801597..79ce6824 100644 --- a/java/res/layout/resolver_different_item_header.xml +++ b/java/res/layout/resolver_different_item_header.xml @@ -19,9 +19,10 @@ <TextView xmlns:android="http://schemas.android.com/apk/res/android" xmlns:androidprv="http://schemas.android.com/apk/prv/res/android" + xmlns:app="http://schemas.android.com/apk/res-auto" android:layout_width="match_parent" android:layout_height="wrap_content" - androidprv:layout_alwaysShow="true" + app:layout_alwaysShow="true" android:text="@string/use_a_different_app" android:textColor="?android:attr/textColorPrimary" android:fontFamily="@androidprv:string/config_headlineFontFamilyMedium" diff --git a/java/res/layout/resolver_list.xml b/java/res/layout/resolver_list.xml index 179c4073..44b14baf 100644 --- a/java/res/layout/resolver_list.xml +++ b/java/res/layout/resolver_list.xml @@ -16,21 +16,22 @@ * limitations under the License. */ --> -<com.android.internal.widget.ResolverDrawerLayout +<com.android.intentresolver.widget.ResolverDrawerLayout xmlns:android="http://schemas.android.com/apk/res/android" xmlns:androidprv="http://schemas.android.com/apk/prv/res/android" + xmlns:app="http://schemas.android.com/apk/res-auto" android:layout_width="match_parent" android:layout_height="match_parent" android:maxWidth="@dimen/resolver_max_width" - androidprv:maxCollapsedHeight="@dimen/resolver_max_collapsed_height" - androidprv:maxCollapsedHeightSmall="56dp" + app:maxCollapsedHeight="@dimen/resolver_max_collapsed_height" + app:maxCollapsedHeightSmall="56dp" android:id="@androidprv:id/contentPanel"> <RelativeLayout android:id="@androidprv:id/title_container" android:layout_width="match_parent" android:layout_height="wrap_content" - androidprv:layout_alwaysShow="true" + app:layout_alwaysShow="true" android:elevation="@dimen/resolver_elevation" android:paddingTop="@dimen/resolver_small_margin" android:paddingStart="@dimen/resolver_edge_margin" @@ -66,7 +67,7 @@ <View android:id="@androidprv:id/divider" - androidprv:layout_alwaysShow="true" + app:layout_alwaysShow="true" android:layout_width="match_parent" android:layout_height="1dp" android:background="?android:attr/colorBackground" @@ -114,10 +115,10 @@ android:id="@androidprv:id/button_bar_container" android:layout_width="match_parent" android:layout_height="wrap_content" - androidprv:layout_alwaysShow="true" + app:layout_alwaysShow="true" android:orientation="vertical" android:background="?android:attr/colorBackground" - androidprv:layout_ignoreOffset="true"> + app:layout_ignoreOffset="true"> <View android:id="@androidprv:id/resolver_button_bar_divider" android:layout_width="match_parent" @@ -130,8 +131,8 @@ style="?android:attr/buttonBarStyle" android:layout_width="match_parent" android:layout_height="wrap_content" - androidprv:layout_ignoreOffset="true" - androidprv:layout_hasNestedScrollIndicator="true" + app:layout_ignoreOffset="true" + app:layout_hasNestedScrollIndicator="true" android:gravity="end|center_vertical" android:orientation="horizontal" android:layoutDirection="locale" @@ -169,4 +170,4 @@ android:onClick="onButtonClick" /> </LinearLayout> </LinearLayout> -</com.android.internal.widget.ResolverDrawerLayout> +</com.android.intentresolver.widget.ResolverDrawerLayout> diff --git a/java/res/layout/resolver_list_with_default.xml b/java/res/layout/resolver_list_with_default.xml index 341c58e7..192a5983 100644 --- a/java/res/layout/resolver_list_with_default.xml +++ b/java/res/layout/resolver_list_with_default.xml @@ -16,19 +16,20 @@ * limitations under the License. */ --> -<com.android.internal.widget.ResolverDrawerLayout +<com.android.intentresolver.widget.ResolverDrawerLayout xmlns:android="http://schemas.android.com/apk/res/android" xmlns:androidprv="http://schemas.android.com/apk/prv/res/android" + xmlns:app="http://schemas.android.com/apk/res-auto" android:layout_width="match_parent" android:layout_height="match_parent" android:maxWidth="@dimen/resolver_max_width" - androidprv:maxCollapsedHeight="@dimen/resolver_max_collapsed_height_with_default" + app:maxCollapsedHeight="@dimen/resolver_max_collapsed_height_with_default" android:id="@androidprv:id/contentPanel"> <LinearLayout android:layout_width="match_parent" android:layout_height="wrap_content" - androidprv:layout_alwaysShow="true" + app:layout_alwaysShow="true" android:orientation="vertical" android:background="@drawable/bottomsheet_background" android:paddingTop="@dimen/resolver_small_margin" @@ -105,7 +106,7 @@ style="?android:attr/buttonBarStyle" android:layout_width="match_parent" android:layout_height="wrap_content" - androidprv:layout_alwaysShow="true" + app:layout_alwaysShow="true" android:gravity="end|center_vertical" android:orientation="horizontal" android:layoutDirection="locale" @@ -146,7 +147,7 @@ <View android:id="@androidprv:id/divider" - androidprv:layout_alwaysShow="true" + app:layout_alwaysShow="true" android:layout_width="match_parent" android:layout_height="1dp" android:background="?android:attr/colorBackground" @@ -154,14 +155,14 @@ <FrameLayout android:id="@androidprv:id/stub" - androidprv:layout_alwaysShow="true" + app:layout_alwaysShow="true" android:visibility="gone" android:layout_width="match_parent" android:layout_height="wrap_content" android:background="?android:attr/colorBackground"/> <TabHost - androidprv:layout_alwaysShow="true" + app:layout_alwaysShow="true" android:id="@androidprv:id/profile_tabhost" android:layout_width="match_parent" android:layout_height="wrap_content" @@ -198,9 +199,9 @@ </TabHost> <View - androidprv:layout_alwaysShow="true" + app:layout_alwaysShow="true" android:layout_width="match_parent" android:layout_height="1dp" android:background="?android:attr/colorBackground" android:foreground="?android:attr/dividerVertical" /> -</com.android.internal.widget.ResolverDrawerLayout> +</com.android.intentresolver.widget.ResolverDrawerLayout> diff --git a/java/src/com/android/intentresolver/AbstractMultiProfilePagerAdapter.java b/java/src/com/android/intentresolver/AbstractMultiProfilePagerAdapter.java index 4f6c0bf1..8b0b10b0 100644 --- a/java/src/com/android/intentresolver/AbstractMultiProfilePagerAdapter.java +++ b/java/src/com/android/intentresolver/AbstractMultiProfilePagerAdapter.java @@ -17,26 +17,24 @@ package com.android.intentresolver; import android.annotation.IntDef; import android.annotation.Nullable; +import android.annotation.NonNull; +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 @@ -279,8 +236,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 +263,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 +271,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()); + private boolean shouldSkipRebuild(ResolverListAdapter activeListAdapter) { + EmptyState emptyState = mEmptyStateProvider.getEmptyState(activeListAdapter); + return emptyState != null && emptyState.shouldSkipDataRebuild(); } - boolean allowShowNoCrossProfileIntentsEmptyState() { - return true; - } - - 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 +297,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 +386,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 +402,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 +409,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 +417,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 +425,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) @@ -599,6 +468,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 +573,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 +583,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..fe1df879 100644 --- a/java/src/com/android/intentresolver/ChooserActivity.java +++ b/java/src/com/android/intentresolver/ChooserActivity.java @@ -16,12 +16,16 @@ 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; @@ -32,13 +36,10 @@ 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,50 +51,38 @@ 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.util.SparseArray; import android.view.LayoutInflater; import android.view.View; import android.view.View.MeasureSpec; @@ -102,36 +91,44 @@ 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 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.ResolverListAdapter.ViewHolder; -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.SelectableTargetInfo.SelectableTargetInfoCommunicator; import com.android.intentresolver.chooser.TargetInfo; - +import com.android.intentresolver.grid.DirectShareViewHolder; +import com.android.intentresolver.grid.FooterViewHolder; +import com.android.intentresolver.grid.ItemGroupViewHolder; +import com.android.intentresolver.grid.ItemViewHolder; +import com.android.intentresolver.grid.SingleRowViewHolder; +import com.android.intentresolver.grid.ViewHolderBase; +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.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; @@ -139,7 +136,6 @@ 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 +144,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 +165,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,24 +179,21 @@ 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; @@ -225,9 +211,6 @@ public class ChooserActivity extends ResolverActivity implements 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, @@ -241,75 +224,65 @@ public class ChooserActivity extends ResolverActivity implements * 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; + public 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` *primarily* 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." Unfortunately, for now we also have + * a vestigial design where ChooserActivity.onCreate() can invalidate a request, but it still + * has to call up to ResolverActivity.onCreate() before closing, and the base method delegates + * back down to other methods in ChooserActivity that aren't really relevant if we're closing + * (and so they'd normally want to assume it was a valid "creation," with non-null parameters). + * Any client null checks are workarounds for this condition that can be removed once that + * design is cleaned up. */ + @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 { - } - - // 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; - private ContentPreviewCoordinator mPreviewCoord; + private final ExecutorService mBackgroundThreadPoolExecutor = Executors.newFixedThreadPool(5); + + @Nullable + private ChooserContentPreviewCoordinator mPreviewCoordinator; + private int mScrollStatus = SCROLL_STATUS_IDLE; @VisibleForTesting @@ -321,210 +294,30 @@ public class ChooserActivity extends ResolverActivity implements 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; - } - }); - } - - 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); + private final SparseArray<ProfileRecord> mProfileRecords = new SparseArray<>(); - 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); - }); - } + public ChooserActivity() {} - private void cancelLoads() { - mHandler.removeMessages(IMAGE_LOAD_INTO_VIEW); - mHandler.removeMessages(IMAGE_LOAD_TIMEOUT); - } + private void setupPreDrawForSharedElementTransition(View v) { + v.getViewTreeObserver().addOnPreDrawListener(new ViewTreeObserver.OnPreDrawListener() { + @Override + public boolean onPreDraw() { + v.getViewTreeObserver().removeOnPreDrawListener(this); - 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; + if (!mRemoveSharedElements && isActivityTransitionRunning()) { + // Disable the window animations as it interferes with the transition animation. + getWindow().setWindowAnimations(0); } - mRemoveSharedElements = true; mEnterTransitionAnimationDelegate.markImagePreviewReady(); + return true; } - } - - 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); - } - } - }; + private void hideContentPreview() { + mRemoveSharedElements = true; + mEnterTransitionAnimationDelegate.markImagePreviewReady(); + } @Override protected void onCreate(Bundle savedInstanceState) { @@ -532,153 +325,49 @@ 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); 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); + setAdditionalTargets(mChooserRequest.getAdditionalTargets()); - // 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()); - - 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, + this::hideContentPreview, + this::setupPreDrawForSharedElementTransition); + + 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; @@ -686,14 +375,15 @@ public class ChooserActivity extends ResolverActivity implements 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_SHARESHEET_MIMETYPE, mChooserRequest.getTargetType()) .addTaggedData(MetricsEvent.FIELD_TIME_TO_APP_TARGETS, 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,14 +412,15 @@ 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 @@ -750,52 +441,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 +519,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 +568,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 +597,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() { @@ -897,13 +624,6 @@ public class ChooserActivity extends ResolverActivity implements } /** - * 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) @@ -1068,10 +788,55 @@ 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.ActionButtonFactory buttonFactory = + new ChooserContentPreviewUi.ActionButtonFactory() { + @Override + public Button createCopyButton() { + return ChooserActivity.this.createCopyButton(); + } + + @Override + public Button createEditButton() { + return ChooserActivity.this.createEditButton(targetIntent); + } + + @Override + public Button createNearbyButton() { + return ChooserActivity.this.createNearbyButton(targetIntent); + } + }; + + ViewGroup layout = ChooserContentPreviewUi.displayContentPreview( + previewType, + targetIntent, + getResources(), + getLayoutInflater(), + buttonFactory, + parent, + previewCoordinator, + getContentResolver(), + this::isImageType); + + if (layout != null) { + adjustPreviewWidth(getResources().getConfiguration().orientation, layout); + } + if (previewType != ChooserContentPreviewUi.CONTENT_PREVIEW_IMAGE) { + mEnterTransitionAnimationDelegate.markImagePreviewReady(); + } + + return layout; } @VisibleForTesting @@ -1108,6 +873,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,8 +894,13 @@ 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); + final DisplayResolveInfo dri = DisplayResolveInfo.newDisplayResolveInfo( + originalIntent, + ri, + getString(com.android.internal.R.string.screenshot_edit), + "", + resolveIntent, + null); dri.setDisplayIcon(getDrawable(com.android.internal.R.drawable.ic_screenshot_edit)); return dri; } @@ -1160,7 +943,7 @@ 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); return dri; @@ -1192,7 +975,7 @@ public class ChooserActivity extends ResolverActivity implements if (ti == null) return null; final Button b = createActionButton( - ti.getDisplayIcon(this), + ti.getDisplayIcon(), ti.getDisplayLabel(), (View unused) -> { // Log share completion via nearby @@ -1215,7 +998,7 @@ public class ChooserActivity extends ResolverActivity implements if (ti == null) return null; final Button b = createActionButton( - ti.getDisplayIcon(this), + ti.getDisplayIcon(), ti.getDisplayLabel(), (View unused) -> { // Log share completion via edit @@ -1259,154 +1042,6 @@ public class ChooserActivity extends ResolverActivity implements 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,41 +1051,9 @@ 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); + @VisibleForTesting + protected boolean isImageType(String mimeType) { + return mimeType != null && mimeType.startsWith("image/"); } private void logContentPreviewWarning(Uri uri) { @@ -1461,130 +1064,6 @@ public class ChooserActivity extends ResolverActivity implements + "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 +1093,29 @@ public class ChooserActivity extends ResolverActivity implements mRefinementResultReceiver.destroy(); mRefinementResultReceiver = null; } - mChooserHandler.removeAllMessages(); - if (mPreviewCoord != null) mPreviewCoord.cancelLoads(); + mBackgroundThreadPoolExecutor.shutdownNow(); + + destroyProfileRecords(); + } - mChooserMultiProfilePagerAdapter.getActiveListAdapter().destroyAppPredictor(); - if (mChooserMultiProfilePagerAdapter.getInactiveListAdapter() != null) { - mChooserMultiProfilePagerAdapter.getInactiveListAdapter().destroyAppPredictor(); + 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 +1136,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 +1153,17 @@ public class ChooserActivity extends ResolverActivity implements @Override public void addUseDifferentAppLabelIfNecessary(ResolverListAdapter adapter) { - if (mCallerChooserTargets != null && mCallerChooserTargets.length > 0) { + if (mChooserRequest == null) { + return; + } + + if (mChooserRequest.getCallerChooserTargets().size() > 0) { mChooserMultiProfilePagerAdapter.getActiveListAdapter().addServiceResults( /* origTarget */ null, - Lists.newArrayList(mCallerChooserTargets), + mChooserRequest.getCallerChooserTargets(), TARGET_TYPE_DEFAULT, - /* directShareShortcutInfoCache */ null); + /* directShareShortcutInfoCache */ Collections.emptyMap(), + /* directShareAppTargetCache */ Collections.emptyMap()); } } @@ -1701,57 +1192,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 +1238,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 +1256,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; } } @@ -1823,26 +1287,15 @@ public class ChooserActivity extends ResolverActivity implements 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; - } + directTargetHashed = targetInfo.getHashedTargetIdForMetrics(this); + directTargetAlsoRanked = getRankedPosition(targetInfo); + + numCallerProvided = mChooserRequest.getCallerChooserTargets().size(); getChooserActivityLogger().logShareTargetSelected( SELECTION_TYPE_SERVICE, targetInfo.getResolveInfo().activityInfo.processName, value, - selectableTargetInfo.isPinned() + targetInfo.isPinned() ); break; case ChooserListAdapter.TARGET_CALLER: @@ -1900,16 +1353,16 @@ public class ChooserActivity extends ResolverActivity implements } } - 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 +1386,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 +1424,19 @@ 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(int logCategory, 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); + final int apiLatency = + (int) (SystemClock.elapsedRealtime() - profileRecord.loadingStartTime); getMetricsLogger().write(new LogMaker(logCategory).setSubtype(apiLatency)); } void updateModelAndChooserCounts(TargetInfo info) { - if (info != null && info instanceof MultiDisplayResolveInfo) { + if (info != null && info.isMultiDisplayResolveInfo()) { info = ((MultiDisplayResolveInfo) info).getSelectedTarget(); } if (info != null) { @@ -2200,31 +1457,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 +1493,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 +1513,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) { @@ -2386,7 +1583,7 @@ public class ChooserActivity extends ResolverActivity implements protected ChooserActivityLogger getChooserActivityLogger() { if (mChooserActivityLogger == null) { - mChooserActivityLogger = new ChooserActivityLoggerImpl(); + mChooserActivityLogger = new ChooserActivityLogger(); } return mChooserActivityLogger; } @@ -2405,56 +1602,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(), @@ -2489,23 +1769,6 @@ public class ChooserActivity extends ResolverActivity implements 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 +1795,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 +1902,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 = @@ -2691,43 +1954,12 @@ public class ChooserActivity extends ResolverActivity implements 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()); - } - } - @Override // ResolverListCommunicator public void onHandlePackagesChanged(ResolverListAdapter listAdapter) { mChooserMultiProfilePagerAdapter.getActiveListAdapter().notifyDataSetChanged(); 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 +1974,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 +1988,48 @@ 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); - } + logDirectShareTargetReceived( + MetricsEvent.ACTION_DIRECT_SHARE_TARGETS_LOADED_SHORTCUT_MANAGER, + userHandle); - @VisibleForTesting - protected boolean isQuietModeEnabled(UserHandle userHandle) { - UserManager userManager = getSystemService(UserManager.class); - return userManager.isQuietModeEnabled(userHandle); + sendVoiceChoicesIfNeeded(); + getChooserActivityLogger().logSharesheetDirectLoadComplete(); } private void setupScrollListener() { @@ -2855,24 +2093,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 +2107,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 +2125,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); } } @@ -2932,19 +2160,22 @@ public class ChooserActivity extends ResolverActivity implements private void logActionShareWithPreview() { Intent targetIntent = getTargetIntent(); - int previewType = findPreferredContentPreview(targetIntent, getContentResolver()); + int previewType = ChooserContentPreviewUi.findPreferredContentPreview( + targetIntent, getContentResolver(), this::isImageType); 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 +2192,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. @@ -3107,16 +2275,63 @@ public class ChooserActivity extends ResolverActivity implements * handled by {@link ChooserListAdapter} */ @VisibleForTesting - public final class ChooserGridAdapter extends RecyclerView.Adapter<RecyclerView.ViewHolder> { - private ChooserListAdapter mChooserListAdapter; - private final LayoutInflater mLayoutInflater; + public static final class ChooserGridAdapter extends + RecyclerView.Adapter<RecyclerView.ViewHolder> { - private DirectShareViewHolder mDirectShareViewHolder; - private int mChooserTargetWidth = 0; - private boolean mShowAzLabelIfPoss; - private boolean mLayoutRequested = false; - - private int mFooterHeight = 0; + /** + * 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. + */ + 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; @@ -3128,12 +2343,44 @@ public class ChooserActivity extends ResolverActivity implements private static final int NUM_EXPANSIONS_TO_HIDE_AZ_LABEL = 20; - ChooserGridAdapter(ChooserListAdapter wrappedAdapter) { + 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; + + ChooserGridAdapter( + Context context, + ChooserActivityDelegate chooserActivityDelegate, + ChooserListAdapter wrappedAdapter, + boolean shouldShowContentPreview, + int maxTargetsPerRow, + int numSheetExpansions) { super(); + + mChooserActivityDelegate = chooserActivityDelegate; + mChooserListAdapter = wrappedAdapter; - mLayoutInflater = LayoutInflater.from(ChooserActivity.this); + mLayoutInflater = LayoutInflater.from(context); - mShowAzLabelIfPoss = getNumSheetExpansions() < NUM_EXPANSIONS_TO_HIDE_AZ_LABEL; + 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 @@ -3166,7 +2413,7 @@ public class ChooserActivity extends ResolverActivity implements } // Limit width to the maximum width of the chooser activity - int maxWidth = getResources().getDimensionPixelSize(R.dimen.chooser_width); + int maxWidth = mChooserWidthPixels; width = Math.min(maxWidth, width); int newWidth = width / mMaxTargetsPerRow; @@ -3178,22 +2425,6 @@ public class ChooserActivity extends ResolverActivity implements 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() @@ -3214,11 +2445,11 @@ public class ChooserActivity extends ResolverActivity implements public int getSystemRowCount() { // For the tabbed case we show the sticky content preview above the tabs, // please refer to shouldShowStickyContentPreview - if (shouldShowTabs()) { + if (mChooserActivityDelegate.shouldShowTabs()) { return 0; } - if (!isSendAction(getTargetIntent())) { + if (!mShouldShowContentPreview) { return 0; } @@ -3230,7 +2461,7 @@ public class ChooserActivity extends ResolverActivity implements } public int getProfileRowCount() { - if (shouldShowTabs()) { + if (mChooserActivityDelegate.shouldShowTabs()) { return 0; } return mChooserListAdapter.getOtherProfile() == null ? 0 : 1; @@ -3249,8 +2480,7 @@ public class ChooserActivity extends ResolverActivity implements // 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()) { + if (mShouldShowContentPreview && !ActivityManager.isLowRamDeviceStatic()) { return 1; } return 0; @@ -3278,14 +2508,29 @@ public class ChooserActivity extends ResolverActivity implements public RecyclerView.ViewHolder onCreateViewHolder(ViewGroup parent, int viewType) { switch (viewType) { case VIEW_TYPE_CONTENT_PREVIEW: - return new ItemViewHolder(createContentPreviewView(parent), false, viewType); + return new ItemViewHolder( + mChooserActivityDelegate.buildContentPreview(parent), + viewType, + null, + null); case VIEW_TYPE_PROFILE: - return new ItemViewHolder(createProfileView(parent), false, viewType); + return new ItemViewHolder( + createProfileView(parent), + viewType, + null, + null); case VIEW_TYPE_AZ_LABEL: - return new ItemViewHolder(createAzLabelView(parent), false, viewType); + return new ItemViewHolder( + createAzLabelView(parent), + viewType, + null, + null); case VIEW_TYPE_NORMAL: return new ItemViewHolder( - mChooserListAdapter.createView(parent), true, viewType); + mChooserListAdapter.createView(parent), + viewType, + mChooserActivityDelegate::onTargetSelected, + mChooserActivityDelegate::onTargetLongPressed); case VIEW_TYPE_DIRECT_SHARE: case VIEW_TYPE_CALLER_AND_RANK: return createItemGroupViewHolder(viewType, parent); @@ -3345,9 +2590,7 @@ public class ChooserActivity extends ResolverActivity implements 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(); + mChooserActivityDelegate.updateProfileViewButton(profileRow); return profileRow; } @@ -3369,17 +2612,13 @@ public class ChooserActivity extends ResolverActivity implements v.setOnClickListener(new OnClickListener() { @Override public void onClick(View v) { - startSelected(holder.getItemIndex(column), false, true); + mChooserActivityDelegate.onTargetSelected(holder.getItemIndex(column)); } }); // 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); - } + mChooserActivityDelegate.onTargetLongPressed(holder.getItemIndex(column)); return true; }); @@ -3440,7 +2679,7 @@ public class ChooserActivity extends ResolverActivity implements mDirectShareViewHolder = new DirectShareViewHolder(parentGroup, Lists.newArrayList(row1, row2), mMaxTargetsPerRow, viewType, - mChooserMultiProfilePagerAdapter::getActiveListAdapter); + mChooserActivityDelegate::getValidTargetCount); loadViewsIntoGroup(mDirectShareViewHolder); return mDirectShareViewHolder; @@ -3480,7 +2719,7 @@ public class ChooserActivity extends ResolverActivity implements void bindItemViewHolder(int position, ItemViewHolder holder) { View v = holder.itemView; int listPosition = getListPosition(position); - holder.mListPosition = listPosition; + holder.setListPosition(listPosition); mChooserListAdapter.bindView(listPosition, v); } @@ -3495,7 +2734,7 @@ public class ChooserActivity extends ResolverActivity implements end--; } - if (end == start && mChooserListAdapter.getItem(start) instanceof EmptyTargetInfo) { + 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) { @@ -3506,9 +2745,7 @@ public class ChooserActivity extends ResolverActivity implements 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); + textView.setTranslationY(mChooserRowTextOptionTranslatePixelSize); ValueAnimator translateAnim = ObjectAnimator.ofFloat(textView, "translationY", 0.0f); translateAnim.setInterpolator(new DecelerateInterpolator(1.0f)); @@ -3538,7 +2775,7 @@ public class ChooserActivity extends ResolverActivity implements position -= getSystemRowCount() + getProfileRowCount(); final int serviceCount = mChooserListAdapter.getServiceTargetCount(); - final int serviceRows = (int) Math.ceil((float) serviceCount / getMaxRankedTargets()); + final int serviceRows = (int) Math.ceil((float) serviceCount / mMaxTargetsPerRow); if (position < serviceRows) { return position * mMaxTargetsPerRow; } @@ -3560,9 +2797,8 @@ public class ChooserActivity extends ResolverActivity implements public void handleScroll(View v, int y, int oldy) { boolean canExpandDirectShare = canExpandDirectShare(); if (mDirectShareViewHolder != null && canExpandDirectShare) { - mDirectShareViewHolder.handleScroll( - mChooserMultiProfilePagerAdapter.getActiveAdapterView(), y, oldy, - mMaxTargetsPerRow); + mChooserActivityDelegate.handleScrollToExpandDirectShare( + mDirectShareViewHolder, y, oldy); } } @@ -3587,273 +2823,7 @@ public class ChooserActivity extends ResolverActivity implements 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; + mChooserActivityDelegate.updateDirectShareExpansion(mDirectShareViewHolder); } } @@ -3918,110 +2888,6 @@ 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. */ @@ -4071,11 +2937,13 @@ public class ChooserActivity extends ResolverActivity implements */ 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 +2967,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 +2980,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 +2999,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 ProfileRecord( + @Nullable AppPredictor appPredictor, + @Nullable ShortcutLoader shortcutLoader) { + this.appPredictor = appPredictor; + this.shortcutLoader = shortcutLoader; + } - private boolean shouldNearbyShareBeIncludedAsActionButton() { - return !shouldNearbyShareBeFirstInRankedRow(); + 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..811d5f3e 100644 --- a/java/src/com/android/intentresolver/ChooserActivityLogger.java +++ b/java/src/com/android/intentresolver/ChooserActivityLogger.java @@ -19,45 +19,116 @@ package com.android.intentresolver; import android.content.Intent; import android.provider.MediaStore; +import com.android.internal.annotations.VisibleForTesting; import com.android.internal.logging.InstanceId; +import com.android.internal.logging.InstanceIdSequence; import com.android.internal.logging.UiEvent; import com.android.internal.logging.UiEventLogger; +import com.android.internal.logging.UiEventLoggerImpl; 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 { + /** + * 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; + + public ChooserActivityLogger() { + this(new UiEventLoggerImpl(), new DefaultFrameworkStatsLogger()); + } + + @VisibleForTesting + ChooserActivityLogger(UiEventLogger uiEventLogger, FrameworkStatsLogger frameworkLogger) { + mUiEventLogger = uiEventLogger; + mFrameworkStatsLogger = frameworkLogger; + } + /** 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); + public void logShareTargetSelected(int targetType, String packageName, int positionPicked, + boolean isPinned) { + 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); + } /** 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 +136,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 +149,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 +165,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. @@ -201,13 +285,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 +302,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 +327,48 @@ public interface ChooserActivityLogger { return FrameworkStatsLog.SHARESHEET_STARTED__INTENT_TYPE__INTENT_DEFAULT; } } + + 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..fdc58170 --- /dev/null +++ b/java/src/com/android/intentresolver/ChooserContentPreviewCoordinator.java @@ -0,0 +1,180 @@ +/* + * 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.animation.ObjectAnimator; +import android.animation.ValueAnimator; +import android.annotation.NonNull; +import android.graphics.Bitmap; +import android.net.Uri; +import android.os.Handler; +import android.util.Size; +import android.view.View; +import android.view.animation.DecelerateInterpolator; + +import androidx.annotation.MainThread; +import androidx.annotation.Nullable; + +import com.android.intentresolver.widget.RoundedRectImageView; + +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.Callable; +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, + Consumer<View> onSingleImageSuccessCallback) { + this.mBackgroundExecutor = MoreExecutors.listeningDecorator(backgroundExecutor); + this.mChooserActivity = chooserActivity; + this.mOnFailCallback = onFailCallback; + this.mOnSingleImageSuccessCallback = onSingleImageSuccessCallback; + + this.mImageLoadTimeoutMillis = + chooserActivity.getResources().getInteger(R.integer.config_shortAnimTime); + } + + @Override + public void loadUriIntoView( + final Callable<RoundedRectImageView> deferredImageViewProvider, + final Uri imageUri, + final int extraImageCount) { + final int size = mChooserActivity.getResources().getDimensionPixelSize( + R.dimen.chooser_preview_image_max_dimen); + + 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 { + onLoadCompleted( + deferredImageViewProvider.call(), + loadedBitmap, + extraImageCount); + } catch (Exception e) { /* unimportant */ } + } + + @Override + public void onFailure(Throwable t) {} + }, + mHandler::post); + } + + private static final int IMAGE_FADE_IN_MILLIS = 150; + + private final ChooserActivity mChooserActivity; + private final ListeningExecutorService mBackgroundExecutor; + private final Runnable mOnFailCallback; + private final Consumer<View> mOnSingleImageSuccessCallback; + 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 RoundedRectImageView imageView, + @Nullable Bitmap loadedBitmap, + int extraImageCount) { + if (mChooserActivity.isFinishing()) { + return; + } + + // TODO: legacy logic didn't handle a possible null view; handle the same as other + // single-image failures for now (i.e., this is also a factor in the "race" TODO below). + boolean thisLoadSucceeded = (imageView != null) && (loadedBitmap != null); + mAtLeastOneLoaded |= thisLoadSucceeded; + + // TODO: this looks like a race condition. We may know that this specific image failed (i.e. + // it got a null Bitmap), but we'll only report that to the client (thereby failing out our + // pending loads) if we haven't yet succeeded in loading some other non-null Bitmap. But + // there could be other pending loads that would've returned non-null within the timeout + // window, except they end up (effectively) cancelled because this one single-image load + // "finished" (failed) faster. The outcome of that race may be fairly predictable (since we + // *might* imagine that the nulls would usually "load" faster?), but it's not guaranteed + // since the loads are queued in a thread pool (i.e., in parallel). One option for more + // deterministic behavior: don't signal the failure callback on a single-image load unless + // there are no other loads currently pending. + boolean wholeBatchFailed = !mAtLeastOneLoaded; + + if (thisLoadSucceeded) { + onImageLoadedSuccessfully(loadedBitmap, imageView, extraImageCount); + } else if (imageView != null) { + imageView.setVisibility(View.GONE); + } + + if (wholeBatchFailed) { + mOnFailCallback.run(); + } + } + + @MainThread + private void onImageLoadedSuccessfully( + @NonNull Bitmap image, + RoundedRectImageView imageView, + int extraImageCount) { + 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(); + + if (extraImageCount > 0) { + imageView.setExtraImageCount(extraImageCount); + } + + mOnSingleImageSuccessCallback.accept(imageView); + } +} diff --git a/java/src/com/android/intentresolver/ChooserContentPreviewUi.java b/java/src/com/android/intentresolver/ChooserContentPreviewUi.java new file mode 100644 index 00000000..22ff55db --- /dev/null +++ b/java/src/com/android/intentresolver/ChooserContentPreviewUi.java @@ -0,0 +1,539 @@ +/* + * 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.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.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.ViewGroup.LayoutParams; +import android.widget.Button; +import android.widget.ImageView; +import android.widget.TextView; + +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.concurrent.Callable; + +/** + * 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 { + /** + * 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 viewProvider A delegate that will be called exactly once upon completion of the + * load, from the UI thread, to provide the {@link RoundedRectImageView} that should be + * populated with the result (if the load was successful) or hidden (if the load failed). If + * this returns null, the load is discarded as a failure. + * @param imageUri The {@link Uri} of the image to load. + * @param extraImages The "extra image count" to set on the {@link RoundedRectImageView} + * if the image loads successfully. + * + * 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 loadUriIntoView( + Callable<RoundedRectImageView> viewProvider, Uri imageUri, int extraImages); + } + + /** + * 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 ActionButtonFactory { + /** Create a button that copies the share content to the clipboard. */ + Button createCopyButton(); + + /** Create a button that opens the share content in a system-default editor. */ + Button createEditButton(); + + /** Create a "Share to Nearby" button. */ + Button 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, + ActionButtonFactory buttonFactory, + ViewGroup parent, + ContentPreviewCoordinator previewCoord, + ContentResolver contentResolver, + ImageMimeTypeClassifier imageClassifier) { + ViewGroup layout = null; + + switch (previewType) { + case CONTENT_PREVIEW_TEXT: + layout = displayTextContentPreview( + targetIntent, + resources, + layoutInflater, + buttonFactory, + parent, + previewCoord); + break; + case CONTENT_PREVIEW_IMAGE: + layout = displayImageContentPreview( + targetIntent, + resources, + layoutInflater, + buttonFactory, + parent, + previewCoord, + contentResolver, + imageClassifier); + break; + case CONTENT_PREVIEW_FILE: + layout = displayFileContentPreview( + targetIntent, + resources, + layoutInflater, + buttonFactory, + parent, + previewCoord, + contentResolver); + 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, + Resources resources, + LayoutInflater layoutInflater, + ActionButtonFactory buttonFactory, + ViewGroup parent, + ContentPreviewCoordinator previewCoord) { + 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); + final int iconMargin = resources.getDimensionPixelSize(R.dimen.resolver_icon_margin); + addActionButton(actionRow, buttonFactory.createCopyButton(), iconMargin); + addActionButton(actionRow, buttonFactory.createNearbyButton(), iconMargin); + + 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.loadUriIntoView( + () -> contentPreviewLayout.findViewById( + com.android.internal.R.id.content_preview_thumbnail), + previewThumbnail, + 0); + } + } + + return contentPreviewLayout; + } + + private static ViewGroup displayImageContentPreview( + Intent targetIntent, + Resources resources, + LayoutInflater layoutInflater, + ActionButtonFactory buttonFactory, + ViewGroup parent, + ContentPreviewCoordinator previewCoord, + ContentResolver contentResolver, + ImageMimeTypeClassifier imageClassifier) { + 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); + final int iconMargin = resources.getDimensionPixelSize(R.dimen.resolver_icon_margin); + //TODO: addActionButton(actionRow, buttonFactory.createCopyButton(), iconMargin); + addActionButton(actionRow, buttonFactory.createNearbyButton(), iconMargin); + addActionButton(actionRow, buttonFactory.createEditButton(), iconMargin); + + 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); + previewCoord.loadUriIntoView( + () -> contentPreviewLayout.findViewById( + com.android.internal.R.id.content_preview_image_1_large), + uri, + 0); + } else { + List<Uri> uris = targetIntent.getParcelableArrayListExtra(Intent.EXTRA_STREAM); + List<Uri> imageUris = new ArrayList<>(); + 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); + return contentPreviewLayout; + } + + imagePreview.findViewById(com.android.internal.R.id.content_preview_image_1_large) + .setTransitionName(ChooserActivity.FIRST_IMAGE_PREVIEW_TRANSITION_NAME); + previewCoord.loadUriIntoView( + () -> contentPreviewLayout.findViewById( + com.android.internal.R.id.content_preview_image_1_large), + imageUris.get(0), + 0); + + if (imageUris.size() == 2) { + previewCoord.loadUriIntoView( + () -> contentPreviewLayout.findViewById( + com.android.internal.R.id.content_preview_image_2_large), + imageUris.get(1), + 0); + } else if (imageUris.size() > 2) { + previewCoord.loadUriIntoView( + () -> contentPreviewLayout.findViewById( + com.android.internal.R.id.content_preview_image_2_small), + imageUris.get(1), + 0); + previewCoord.loadUriIntoView( + () -> contentPreviewLayout.findViewById( + com.android.internal.R.id.content_preview_image_3_small), + imageUris.get(2), + imageUris.size() - 3); + } + } + + return contentPreviewLayout; + } + + private static ViewGroup displayFileContentPreview( + Intent targetIntent, + Resources resources, + LayoutInflater layoutInflater, + ActionButtonFactory buttonFactory, + ViewGroup parent, + ContentPreviewCoordinator previewCoord, + ContentResolver contentResolver) { + 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); + final int iconMargin = resources.getDimensionPixelSize(R.dimen.resolver_icon_margin); + //TODO(b/120417119): + // addActionButton(actionRow, buttonFactory.createCopyButton(), iconMargin); + addActionButton(actionRow, buttonFactory.createNearbyButton(), iconMargin); + + 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 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.loadUriIntoView( + () -> parent.findViewById( + 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); + } + } + + private static void addActionButton(ViewGroup parent, Button b, int iconMargin) { + if (b == null) { + return; + } + final ViewGroup.MarginLayoutParams lp = new ViewGroup.MarginLayoutParams( + LayoutParams.WRAP_CONTENT, + LayoutParams.WRAP_CONTENT + ); + final int gap = iconMargin / 2; + lp.setMarginsRelative(gap, 0, gap, 0); + parent.addView(b, lp); + } + + 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..59d1a6e3 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, 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,41 +237,25 @@ 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) { @@ -266,30 +264,28 @@ public class ChooserListAdapter extends ResolverListAdapter { 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 +302,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 +321,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 +380,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 +397,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,9 +406,8 @@ 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; @@ -436,19 +415,28 @@ public class ChooserListAdapter extends ResolverListAdapter { 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 +471,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 +520,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 +531,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 +541,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 +598,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 +627,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 +635,85 @@ 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) { + return getChooserTargetIconDrawable( + mContext, + mTargetInfo.getChooserTargetIcon(), + mTargetInfo.getChooserTargetComponentName(), + mTargetInfo.getDirectShareShortcutInfo()); } @Override - protected void onPostExecute(Void arg) { - if (mViewHolder != null) { - mViewHolder.bindIcon(mTargetInfo); + protected void onPostExecute(@Nullable Drawable icon) { + if (icon != null && !mTargetInfo.hasDisplayIcon()) { + mTargetInfo.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 = 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 +723,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..d0463fff 100644 --- a/java/src/com/android/intentresolver/ChooserMultiProfilePagerAdapter.java +++ b/java/src/com/android/intentresolver/ChooserMultiProfilePagerAdapter.java @@ -16,27 +16,18 @@ 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.internal.annotations.VisibleForTesting; -import com.android.internal.widget.GridLayoutManager; -import com.android.internal.widget.PagerAdapter; -import com.android.internal.widget.RecyclerView; /** * A {@link PagerAdapter} which describes the work and personal profile share sheet screens. @@ -46,37 +37,37 @@ public class ChooserMultiProfilePagerAdapter extends AbstractMultiProfilePagerAd private static final int SINGLE_CELL_SPAN_SIZE = 1; private final ChooserProfileDescriptor[] mItems; - private final boolean mIsSendAction; private int mBottomOffset; private int mMaxTargetsPerRow; ChooserMultiProfilePagerAdapter(Context context, ChooserActivity.ChooserGridAdapter adapter, - UserHandle personalProfileUserHandle, + EmptyStateProvider emptyStateProvider, + QuietModeManager quietModeManager, UserHandle workProfileUserHandle, - boolean isSendAction, int maxTargetsPerRow) { - super(context, /* currentPage */ 0, personalProfileUserHandle, workProfileUserHandle); + int maxTargetsPerRow) { + super(context, /* currentPage */ 0, emptyStateProvider, quietModeManager, + workProfileUserHandle); mItems = new ChooserProfileDescriptor[] { createProfileDescriptor(adapter) }; - mIsSendAction = isSendAction; mMaxTargetsPerRow = maxTargetsPerRow; } ChooserMultiProfilePagerAdapter(Context context, ChooserActivity.ChooserGridAdapter personalAdapter, ChooserActivity.ChooserGridAdapter workAdapter, + EmptyStateProvider emptyStateProvider, + QuietModeManager quietModeManager, @Profile int defaultProfile, - UserHandle personalProfileUserHandle, UserHandle workProfileUserHandle, - boolean isSendAction, int maxTargetsPerRow) { - super(context, /* currentPage */ defaultProfile, personalProfileUserHandle, - workProfileUserHandle); + int maxTargetsPerRow) { + super(context, /* currentPage */ defaultProfile, emptyStateProvider, + quietModeManager, workProfileUserHandle); mItems = new ChooserProfileDescriptor[] { createProfileDescriptor(personalAdapter), createProfileDescriptor(workAdapter) }; - mIsSendAction = isSendAction; mMaxTargetsPerRow = maxTargetsPerRow; } @@ -191,112 +182,6 @@ public class ChooserMultiProfilePagerAdapter extends AbstractMultiProfilePagerAd 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); - } - - @Override - protected void showNoPersonalToWorkIntentsEmptyState(ResolverListAdapter activeListAdapter) { - if (mIsSendAction) { - showEmptyState(activeListAdapter, - getCrossProfileBlockedTitle(), - getCantShareWithWorkMessage()); - } else { - showEmptyState(activeListAdapter, - getCrossProfileBlockedTitle(), - getCantAccessWorkMessage()); - } - } - - @Override - protected void showNoWorkToPersonalIntentsEmptyState(ResolverListAdapter activeListAdapter) { - if (mIsSendAction) { - showEmptyState(activeListAdapter, - getCrossProfileBlockedTitle(), - getCantShareWithPersonalMessage()); - } else { - 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 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; } 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..f4d4a6d1 100644 --- a/java/src/com/android/intentresolver/ChooserTargetActionsDialogFragment.java +++ b/java/src/com/android/intentresolver/ChooserTargetActionsDialogFragment.java @@ -27,7 +27,6 @@ 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 +48,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 +64,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; - 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 mShortcutId; - public ChooserTargetActionsDialogFragment() {} + @Nullable + private final String mShortcutTitle; + + @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 -> { @@ -294,4 +287,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/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..5a116b43 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,26 @@ 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.AbstractMultiProfilePagerAdapter.QuietModeManager; +import com.android.intentresolver.NoCrossProfileEmptyStateProvider.DevicePolicyBlockerEmptyState; import com.android.intentresolver.chooser.ChooserTargetInfo; 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; @@ -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"; @@ -216,6 +225,9 @@ public class ResolverActivity extends Activity implements private UserHandle mWorkProfileUserHandle; + @Nullable + private OnSwitchOnWorkSelectedListener mOnSwitchOnWorkSelectedListener; + protected final LatencyTracker mLatencyTracker = getLatencyTracker(); private LatencyTracker getLatencyTracker() { @@ -360,7 +372,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 +385,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()); @@ -474,6 +487,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 +602,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 +625,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 +658,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() { @@ -594,7 +719,7 @@ public class ResolverActivity extends Activity implements } protected boolean shouldShowTabs() { - return hasWorkProfile() && ENABLE_TABBED_VIEW; + return hasWorkProfile(); } protected void onProfileClick(View v) { @@ -726,7 +851,6 @@ public class ResolverActivity extends Activity implements } } - @Override // SelectableTargetInfoCommunicator ResolverListCommunicator public Intent getTargetIntent() { return mIntents.isEmpty() ? null : mIntents.get(0); } @@ -848,9 +972,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 +1499,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 +1531,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 +1604,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.setDisplayIcon(drawable); + new ResolverListAdapter.ViewHolder(icon).bindIcon(otherProfileResolveInfo); + } + } + }.execute(); ((TextView) findViewById(com.android.internal.R.id.open_cross_profile)).setText( getResources().getString( @@ -1521,31 +1661,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 +1938,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 +2204,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 +2256,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 +2272,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 +2325,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 +2381,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..9f654594 100644 --- a/java/src/com/android/intentresolver/ResolverListAdapter.java +++ b/java/src/com/android/intentresolver/ResolverListAdapter.java @@ -54,44 +54,61 @@ 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; + 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,12 +120,22 @@ 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(); } + public final DisplayResolveInfo getFirstDisplayResolveInfo() { + return mDisplayList.get(0); + } + + public final ImmutableList<DisplayResolveInfo> getTargetsInCurrentDisplayList() { + return ImmutableList.copyOf(mDisplayList); + } + public void handlePackagesChanged() { mResolverListCommunicator.onHandlePackagesChanged(this); } @@ -258,7 +285,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 +361,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 +473,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, + makePresentationGetter(ri))); } } @@ -490,10 +527,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, + makePresentationGetter(add)); dri.setPinned(rci.isPinned()); if (rci.isPinned()) { Log.i(TAG, "Pinned item: " + rci.name); @@ -597,11 +636,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); } @@ -636,26 +679,48 @@ public class ResolverListAdapter extends BaseAdapter { if (info == null) { holder.icon.setImageDrawable( mContext.getDrawable(R.drawable.resolver_icon_placeholder)); + 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((DisplayResolveInfo) info); + mIconLoaders.put(info, task); + task.execute(); } } - protected LoadLabelTask getLoadLabelTask(DisplayResolveInfo info, ViewHolder holder) { - return new LoadLabelTask(info, holder); + private void loadLabel(DisplayResolveInfo info) { + LoadLabelTask task = mLabelLoaders.get(info); + if (task == null) { + task = createLoadLabelTask(info); + mLabelLoaders.put(info, task); + task.execute(); + } + } + + protected LoadLabelTask createLoadLabelTask(DisplayResolveInfo info) { + return new LoadLabelTask(info); } public void onDestroy() { @@ -666,6 +731,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() { @@ -721,9 +796,8 @@ public class ResolverListAdapter extends BaseAdapter { } } - @VisibleForTesting public UserHandle getUserHandle() { - return mResolverListController.getUserHandle(); + return mUserHandle; } protected List<ResolvedComponentInfo> getResolversForUser(UserHandle userHandle) { @@ -779,6 +853,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 +862,12 @@ 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); - return new DisplayResolveInfo( + return DisplayResolveInfo.newDisplayResolveInfo( resolvedComponentInfo.getIntentAt(0), resolveInfo, resolveInfo.loadLabel(pm), @@ -829,13 +903,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 +950,7 @@ public class ResolverListAdapter extends BaseAdapter { } public void bindIcon(TargetInfo info) { - icon.setImageDrawable(info.getDisplayIcon(itemView.getContext())); + icon.setImageDrawable(info.getDisplayIcon()); if (info.isSuspended()) { icon.setColorFilter(getSuspendedColorMatrix()); } else { @@ -888,11 +961,9 @@ 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 @@ -930,21 +1001,22 @@ 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 @@ -958,17 +1030,9 @@ public class ResolverListAdapter extends BaseAdapter { 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 notifyDataSetChanged(); } } - - public void setViewHolder(ViewHolder holder) { - mHolder = holder; - mHolder.bindIcon(mDisplayResolveInfo); - } } /** 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..8cf65529 100644 --- a/java/src/com/android/intentresolver/ResolverMultiProfilePagerAdapter.java +++ b/java/src/com/android/intentresolver/ResolverMultiProfilePagerAdapter.java @@ -16,15 +16,7 @@ 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; @@ -32,8 +24,9 @@ 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; /** * A {@link PagerAdapter} which describes the work and personal profile intent resolver screens. @@ -42,34 +35,33 @@ import com.android.internal.widget.PagerAdapter; public class ResolverMultiProfilePagerAdapter extends AbstractMultiProfilePagerAdapter { private final ResolverProfileDescriptor[] mItems; - private final boolean mShouldShowNoCrossProfileIntentsEmptyState; private boolean mUseLayoutWithDefault; ResolverMultiProfilePagerAdapter(Context context, ResolverListAdapter adapter, - UserHandle personalProfileUserHandle, + EmptyStateProvider emptyStateProvider, + QuietModeManager quietModeManager, UserHandle workProfileUserHandle) { - super(context, /* currentPage */ 0, personalProfileUserHandle, workProfileUserHandle); + super(context, /* currentPage */ 0, emptyStateProvider, quietModeManager, + workProfileUserHandle); mItems = new ResolverProfileDescriptor[] { createProfileDescriptor(adapter) }; - mShouldShowNoCrossProfileIntentsEmptyState = true; } ResolverMultiProfilePagerAdapter(Context context, ResolverListAdapter personalAdapter, ResolverListAdapter workAdapter, + EmptyStateProvider emptyStateProvider, + QuietModeManager quietModeManager, @Profile int defaultProfile, - UserHandle personalProfileUserHandle, - UserHandle workProfileUserHandle, - boolean shouldShowNoCrossProfileIntentsEmptyState) { - super(context, /* currentPage */ defaultProfile, personalProfileUserHandle, + UserHandle workProfileUserHandle) { + super(context, /* currentPage */ defaultProfile, emptyStateProvider, quietModeManager, workProfileUserHandle); mItems = new ResolverProfileDescriptor[] { createProfileDescriptor(personalAdapter), createProfileDescriptor(workAdapter) }; - mShouldShowNoCrossProfileIntentsEmptyState = shouldShowNoCrossProfileIntentsEmptyState; } private ResolverProfileDescriptor createProfileDescriptor( @@ -169,93 +161,6 @@ public class ResolverMultiProfilePagerAdapter extends AbstractMultiProfilePagerA 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; } 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/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..16dd28bc 100644 --- a/java/src/com/android/intentresolver/chooser/DisplayResolveInfo.java +++ b/java/src/com/android/intentresolver/chooser/DisplayResolveInfo.java @@ -20,68 +20,100 @@ 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 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 final boolean mIsSuspended; private ResolveInfoPresentationGetter mResolveInfoPresentationGetter; private boolean mPinned = false; - public DisplayResolveInfo(Intent originalIntent, ResolveInfo pri, Intent pOrigIntent, - ResolveInfoPresentationGetter resolveInfoPresentationGetter) { - this(originalIntent, pri, null /*mDisplayLabel*/, null /*mExtendedInfo*/, pOrigIntent, + /** Create a new {@code DisplayResolveInfo} instance. */ + public static DisplayResolveInfo newDisplayResolveInfo( + Intent originalIntent, + ResolveInfo resolveInfo, + @NonNull Intent resolvedIntent, + @Nullable ResolveInfoPresentationGetter 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 ResolveInfoPresentationGetter resolveInfoPresentationGetter) { + return new DisplayResolveInfo( + originalIntent, + resolveInfo, + displayLabel, + extendedInfo, + resolvedIntent, resolveInfoPresentationGetter); } - public DisplayResolveInfo(Intent originalIntent, ResolveInfo pri, CharSequence pLabel, - CharSequence pInfo, @NonNull Intent resolvedIntent, + private DisplayResolveInfo( + Intent originalIntent, + ResolveInfo resolveInfo, + CharSequence displayLabel, + CharSequence extendedInfo, + @NonNull Intent resolvedIntent, @Nullable ResolveInfoPresentationGetter resolveInfoPresentationGetter) { mSourceIntents.add(originalIntent); - mResolveInfo = pri; - mDisplayLabel = pLabel; - mExtendedInfo = pInfo; + mResolveInfo = resolveInfo; + mDisplayLabel = displayLabel; + mExtendedInfo = extendedInfo; mResolveInfoPresentationGetter = resolveInfoPresentationGetter; + 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, + private DisplayResolveInfo( + DisplayResolveInfo other, + Intent fillInIntent, + int flags, ResolveInfoPresentationGetter resolveInfoPresentationGetter) { mSourceIntents.addAll(other.getAllSourceIntents()); mResolveInfo = other.mResolveInfo; + mIsSuspended = other.mIsSuspended; mDisplayLabel = other.mDisplayLabel; mDisplayIcon = other.mDisplayIcon; mExtendedInfo = other.mExtendedInfo; @@ -90,9 +122,10 @@ public class DisplayResolveInfo implements TargetInfo, Parcelable { mResolveInfoPresentationGetter = resolveInfoPresentationGetter; } - 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; @@ -100,6 +133,11 @@ public class DisplayResolveInfo implements TargetInfo, Parcelable { mResolveInfoPresentationGetter = other.mResolveInfoPresentationGetter; } + @Override + public final boolean isDisplayResolveInfo() { + return true; + } + public ResolveInfo getResolveInfo() { return mResolveInfo; } @@ -124,7 +162,8 @@ public class DisplayResolveInfo implements TargetInfo, Parcelable { mExtendedInfo = extendedInfo; } - public Drawable getDisplayIcon(Context context) { + @Override + public Drawable getDisplayIcon() { return mDisplayIcon; } @@ -138,6 +177,11 @@ public class DisplayResolveInfo implements TargetInfo, Parcelable { return mSourceIntents; } + @Override + public ArrayList<DisplayResolveInfo> getAllDisplayTargets() { + return new ArrayList<>(Arrays.asList(this)); + } + public void addAlternateSourceIntent(Intent alt) { mSourceIntents.add(alt); } @@ -146,10 +190,6 @@ public class DisplayResolveInfo implements TargetInfo, Parcelable { mDisplayIcon = icon; } - public boolean hasDisplayIcon() { - return mDisplayIcon != null; - } - public CharSequence getExtendedInfo() { return mExtendedInfo; } @@ -172,14 +212,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 +236,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..3b4b89b1 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,51 @@ 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; + } + + @Override + public Drawable getDisplayIcon() { + return null; + } + }; + } + + /** + * 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 Drawable getDisplayIcon() { + AnimatedVectorDrawable avd = (AnimatedVectorDrawable) + context.getDrawable(R.drawable.chooser_direct_share_icon_placeholder); + avd.start(); // Start animation after generation. + return avd; + } + + @Override + public boolean hasDisplayIcon() { + return true; + } + }; + } + + public final boolean isNotSelectableTargetInfo() { + return true; + } public Intent getResolvedIntent() { return null; @@ -78,10 +125,6 @@ public abstract class NotSelectableTargetInfo implements ChooserTargetInfo { return -0.1f; } - public ChooserTarget getChooserTarget() { - return null; - } - public boolean isSuspended() { return false; } diff --git a/java/src/com/android/intentresolver/chooser/SelectableTargetInfo.java b/java/src/com/android/intentresolver/chooser/SelectableTargetInfo.java index 1610d0fd..51a776db 100644 --- a/java/src/com/android/intentresolver/chooser/SelectableTargetInfo.java +++ b/java/src/com/android/intentresolver/chooser/SelectableTargetInfo.java @@ -18,31 +18,24 @@ 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,173 +44,145 @@ 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 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; + @Nullable + private final AppTarget mAppTarget; + @Nullable + private final ShortcutInfo mShortcutInfo; + + private final ComponentName mChooserTargetComponentName; + private final String mChooserTargetUnsanitizedTitle; + private final Icon mChooserTargetIcon; + private final Bundle mChooserTargetIntentExtras; + + /** + * 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; private final int mFillInFlags; private final boolean mIsPinned; private final float mModifiedScore; - private boolean mIsSuspended = false; - public SelectableTargetInfo(Context context, DisplayResolveInfo sourceInfo, + private Drawable mDisplayIcon; + + /** Create a new {@link TargetInfo} instance representing a selectable target. */ + 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 new SelectableTargetInfo( + sourceInfo, + backupResolveInfo, + resolvedIntent, + chooserTarget, + modifiedScore, + shortcutInfo, + appTarget, + referrerFillInIntent); + } + + private SelectableTargetInfo( + @Nullable DisplayResolveInfo sourceInfo, + @Nullable ResolveInfo backupResolveInfo, + Intent resolvedIntent, + ChooserTarget chooserTarget, + float modifiedScore, + @Nullable ShortcutInfo shortcutInfo, + @Nullable AppTarget appTarget, + Intent referrerFillInIntent) { mSourceInfo = sourceInfo; - mChooserTarget = chooserTarget; mModifiedScore = modifiedScore; - mPm = mContext.getPackageManager(); - mSelectableTargetInfoCommunicator = selectableTargetInfoComunicator; mShortcutInfo = shortcutInfo; + mAppTarget = appTarget; 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; - } - } - } - - if (sourceInfo != null) { - mBackupResolveInfo = null; - } else { - mBackupResolveInfo = - mContext.getPackageManager().resolveActivity(getResolvedIntent(), 0); - } + mBackupResolveInfo = backupResolveInfo; + mResolvedIntent = resolvedIntent; + mReferrerFillInIntent = referrerFillInIntent; mFillInIntent = null; mFillInFlags = 0; - mDisplayLabel = sanitizeDisplayLabel(chooserTarget.getTitle()); + mChooserTargetComponentName = chooserTarget.getComponentName(); + mChooserTargetUnsanitizedTitle = chooserTarget.getTitle().toString(); + mChooserTargetIcon = chooserTarget.getIcon(); + mChooserTargetIntentExtras = chooserTarget.getIntentExtras(); + + mDisplayLabel = sanitizeDisplayLabel(mChooserTargetUnsanitizedTitle); } - private SelectableTargetInfo(SelectableTargetInfo other, - Intent fillInIntent, int flags) { - mContext = other.mContext; - mPm = other.mPm; - mSelectableTargetInfoCommunicator = other.mSelectableTargetInfoCommunicator; + private SelectableTargetInfo(SelectableTargetInfo other, Intent fillInIntent, int flags) { mSourceInfo = other.mSourceInfo; mBackupResolveInfo = other.mBackupResolveInfo; - mChooserTarget = other.mChooserTarget; - mBadgeIcon = other.mBadgeIcon; - mBadgeContentDescription = other.mBadgeContentDescription; - synchronized (other) { - mShortcutInfo = other.mShortcutInfo; - mDisplayIcon = other.mDisplayIcon; - } + mResolvedIntent = other.mResolvedIntent; + mShortcutInfo = other.mShortcutInfo; + mAppTarget = other.mAppTarget; + mDisplayIcon = other.mDisplayIcon; mFillInIntent = fillInIntent; mFillInFlags = flags; mModifiedScore = other.mModifiedScore; mIsPinned = other.mIsPinned; + mReferrerFillInIntent = other.mReferrerFillInIntent; - mDisplayLabel = sanitizeDisplayLabel(mChooserTarget.getTitle()); + mChooserTargetComponentName = other.mChooserTargetComponentName; + mChooserTargetUnsanitizedTitle = other.mChooserTargetUnsanitizedTitle; + mChooserTargetIcon = other.mChooserTargetIcon; + mChooserTargetIntentExtras = other.mChooserTargetIntentExtras; + + mDisplayLabel = sanitizeDisplayLabel(mChooserTargetUnsanitizedTitle); } - 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; + return (mSourceInfo != null) && mSourceInfo.isSuspended(); } + @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 @@ -231,6 +196,16 @@ public final class SelectableTargetInfo implements ChooserTargetInfo { return null; } + @Override + public ComponentName getChooserTargetComponentName() { + return mChooserTargetComponentName; + } + + @Nullable + public Icon getChooserTargetIcon() { + return mChooserTargetIcon; + } + private Intent getBaseIntentToSend() { Intent result = getResolvedIntent(); if (result == null) { @@ -240,7 +215,7 @@ public final class SelectableTargetInfo implements ChooserTargetInfo { if (mFillInIntent != null) { result.fillIn(mFillInIntent, mFillInFlags); } - result.fillIn(mSelectableTargetInfoCommunicator.getReferrerFillInIntent(), 0); + result.fillIn(mReferrerFillInIntent, 0); } return result; } @@ -256,8 +231,9 @@ public final class SelectableTargetInfo implements ChooserTargetInfo { if (intent == null) { return false; } - intent.setComponent(mChooserTarget.getComponentName()); - intent.putExtras(mChooserTarget.getIntentExtras()); + 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 @@ -269,7 +245,7 @@ public final class SelectableTargetInfo implements ChooserTargetInfo { // so we'll obey the caller's normal security checks. final boolean ignoreTargetSecurity = mSourceInfo != null && mSourceInfo.getResolvedComponentName().getPackageName() - .equals(mChooserTarget.getComponentName().getPackageName()); + .equals(getChooserTargetComponentName().getPackageName()); activity.startActivityAsCaller(intent, options, ignoreTargetSecurity, userId); return true; } @@ -296,12 +272,24 @@ public final class SelectableTargetInfo implements ChooserTargetInfo { } @Override - public synchronized Drawable getDisplayIcon(Context context) { + public Drawable getDisplayIcon() { return mDisplayIcon; } - public ChooserTarget getChooserTarget() { - return mChooserTarget; + public void setDisplayIcon(Drawable icon) { + mDisplayIcon = icon; + } + + @Override + @Nullable + public ShortcutInfo getDirectShareShortcutInfo() { + return mShortcutInfo; + } + + @Override + @Nullable + public AppTarget getDirectShareAppTarget() { + return mAppTarget; } @Override @@ -324,16 +312,21 @@ public final class SelectableTargetInfo implements ChooserTargetInfo { return mIsPinned; } - /** - * Necessary methods to communicate between {@link SelectableTargetInfo} - * and {@link ResolverActivity} or {@link ChooserActivity}. - */ - public interface SelectableTargetInfoCommunicator { - - ActivityInfoPresentationGetter makePresentationGetter(ActivityInfo info); - - Intent getTargetIntent(); + @Override + public HashedStringCache.HashResult getHashedTargetIdForMetrics(Context context) { + final String plaintext = + getChooserTargetComponentName().getPackageName() + + mChooserTargetUnsanitizedTitle; + return HashedStringCache.getInstance().hashString( + context, + HASHED_STRING_CACHE_TAG, + plaintext, + mMaxHashSaltDays); + } - Intent getReferrerFillInIntent(); + private static String sanitizeDisplayLabel(CharSequence label) { + SpannableStringBuilder sb = new SpannableStringBuilder(label); + sb.clearSpans(); + return sb.toString(); } } diff --git a/java/src/com/android/intentresolver/chooser/TargetInfo.java b/java/src/com/android/intentresolver/chooser/TargetInfo.java index fabb26c2..0e100d4f 100644 --- a/java/src/com/android/intentresolver/chooser/TargetInfo.java +++ b/java/src/com/android/intentresolver/chooser/TargetInfo.java @@ -17,18 +17,26 @@ 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. @@ -46,13 +54,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 @@ -107,11 +136,17 @@ public interface TargetInfo { /** * @return The drawable that should be used to represent this target including badge - * @param context */ - Drawable getDisplayIcon(Context context); + @Nullable + Drawable getDisplayIcon(); /** + * @return true if display icon is available. + */ + default boolean hasDisplayIcon() { + return getDisplayIcon() != null; + } + /** * Clone this target with the given fill-in information. */ TargetInfo cloneFilledIn(Intent fillInIntent, int flags); @@ -122,6 +157,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 +187,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/DirectShareViewHolder.java b/java/src/com/android/intentresolver/grid/DirectShareViewHolder.java new file mode 100644 index 00000000..cfd54697 --- /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(ChooserActivity.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/ResolverDrawerLayout.java b/java/src/com/android/intentresolver/widget/ResolverDrawerLayout.java new file mode 100644 index 00000000..29821e66 --- /dev/null +++ b/java/src/com/android/intentresolver/widget/ResolverDrawerLayout.java @@ -0,0 +1,1223 @@ +/* + * 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 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 mCollapsibleHeight; + private int mUncollapsibleHeight; + 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; + + 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); + 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 = mCollapsibleHeight; + mCollapsibleHeight = Math.min(mCollapsibleHeight, getMaxCollapsedHeight()); + + 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(mCollapsibleHeight + mUncollapsibleHeight, 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, + mCollapsibleHeight + mUncollapsibleHeight)); + 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; + } + + final int childCount = getChildCount(); + for (int i = 0; i < childCount; i++) { + final View child = getChildAt(i); + final LayoutParams lp = (LayoutParams) child.getLayoutParams(); + if (!lp.ignoreOffset) { + child.offsetTopAndBottom((int) dy); + } + } + 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(mCollapsibleHeight + mUncollapsibleHeight, 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 < mCollapsibleHeight + mUncollapsibleHeight) + && isDismissable()) { + smoothScrollTo(mCollapsibleHeight + mUncollapsibleHeight, 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 < mCollapsibleHeight + mUncollapsibleHeight) + && ((mCollapseOffset < mCollapsibleHeight) || isDismissable())) { + info.addAction(AccessibilityAction.ACTION_SCROLL_UP); + info.setScrollable(true); + } + if (mCollapseOffset < mCollapsibleHeight) { + info.addAction(AccessibilityAction.ACTION_COLLAPSE); + } + if (mCollapseOffset < mCollapsibleHeight + mUncollapsibleHeight && 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(); + } + } + + final int oldCollapsibleHeight = mCollapsibleHeight; + mCollapsibleHeight = Math.max(0, + heightUsed - mAlwaysShowHeight - getMaxCollapsedHeight()); + mUncollapsibleHeight = heightUsed - mCollapsibleHeight; + + updateCollapseOffset(oldCollapsibleHeight, !isDragging()); + + if (getShowAtTop()) { + mTopOffset = 0; + } else { + mTopOffset = Math.max(0, heightSize - heightUsed) + (int) mCollapseOffset; + } + + setMeasuredDimension(sourceWidth, heightSize); + } + + /** + * @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; + + 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; + } + + int top = ypos + lp.topMargin; + if (lp.ignoreOffset) { + 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; + } + + public static class LayoutParams extends MarginLayoutParams { + public boolean alwaysShow; + public boolean ignoreOffset; + public boolean hasNestedScrollIndicator; + public int maxHeight; + + 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..cf7bd543 --- /dev/null +++ b/java/src/com/android/intentresolver/widget/RoundedRectImageView.java @@ -0,0 +1,131 @@ +/* + * 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; + } + } + + @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/tests/Android.bp b/java/tests/Android.bp index fdabc4e0..2913d128 100644 --- a/java/tests/Android.bp +++ b/java/tests/Android.bp @@ -7,7 +7,7 @@ android_test { name: "IntentResolverUnitTests", // Include all test java files. - srcs: ["src/**/*.java"], + srcs: ["src/**/*.java", "src/**/*.kt"], libs: [ "android.test.runner", @@ -19,8 +19,8 @@ android_test { static_libs: [ "IntentResolver-core", - "ChooserActivityTestsLib", "androidx.test.rules", + "androidx.test.ext.junit", "mockito-target-minus-junit4", "androidx.test.espresso.core", "truth-prebuilt", diff --git a/java/tests/AndroidManifest.xml b/java/tests/AndroidManifest.xml index bfe3a39f..306eccb9 100644 --- a/java/tests/AndroidManifest.xml +++ b/java/tests/AndroidManifest.xml @@ -23,10 +23,12 @@ <uses-permission android:name="android.permission.QUERY_USERS"/> <uses-permission android:name="android.permission.READ_CLIPBOARD_IN_BACKGROUND"/> <uses-permission android:name="android.permission.WRITE_DEVICE_CONFIG"/> + <uses-permission android:name="android.permission.READ_DEVICE_CONFIG" /> - <application> + <application android:name="com.android.intentresolver.TestApplication"> <uses-library android:name="android.test.runner" /> <activity android:name="com.android.intentresolver.ChooserWrapperActivity" /> + <activity android:name="com.android.intentresolver.ResolverWrapperActivity" /> </application> <instrumentation android:name="android.testing.TestableInstrumentation" diff --git a/java/tests/AndroidTest.xml b/java/tests/AndroidTest.xml index f4e75c46..d1d77c10 100644 --- a/java/tests/AndroidTest.xml +++ b/java/tests/AndroidTest.xml @@ -14,7 +14,7 @@ limitations under the License. --> <configuration description="Run IntentResolver Tests."> - <!--<target_preparer class="com.android.tradefed.targetprep.TestAppInstallSetup"> + <target_preparer class="com.android.tradefed.targetprep.TestAppInstallSetup"> <option name="test-file-name" value="IntentResolverUnitTests.apk" /> </target_preparer> @@ -24,5 +24,5 @@ <option name="package" value="com.android.intentresolver.tests" /> <option name="runner" value="android.testing.TestableInstrumentation" /> <option name="hidden-api-checks" value="false"/> - </test>--> + </test> </configuration> diff --git a/java/tests/res/drawable/test320x240.png b/java/tests/res/drawable/test320x240.png Binary files differnew file mode 100644 index 00000000..9b5800da --- /dev/null +++ b/java/tests/res/drawable/test320x240.png diff --git a/java/tests/src/com/android/intentresolver/ChooserActivityLoggerFake.java b/java/tests/src/com/android/intentresolver/ChooserActivityLoggerFake.java deleted file mode 100644 index e4146cc5..00000000 --- a/java/tests/src/com/android/intentresolver/ChooserActivityLoggerFake.java +++ /dev/null @@ -1,134 +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.UiEventLogger; -import com.android.internal.util.FrameworkStatsLog; - -import java.util.ArrayList; -import java.util.List; - -public class ChooserActivityLoggerFake implements ChooserActivityLogger { - static class CallRecord { - // shared fields between all logs - public int atomId; - public String packageName; - public InstanceId instanceId; - - // generic log field - public UiEventLogger.UiEventEnum event; - - // share started fields - public String mimeType; - public int appProvidedDirect; - public int appProvidedApp; - public boolean isWorkprofile; - public int previewType; - public String intent; - - // share completed fields - public int targetType; - public int positionPicked; - public boolean isPinned; - - CallRecord(int atomId, UiEventLogger.UiEventEnum eventId, - String packageName, InstanceId instanceId) { - this.atomId = atomId; - this.packageName = packageName; - this.instanceId = instanceId; - this.event = eventId; - } - - CallRecord(int atomId, String packageName, InstanceId instanceId, String mimeType, - int appProvidedDirect, int appProvidedApp, boolean isWorkprofile, int previewType, - String intent) { - this.atomId = atomId; - this.packageName = packageName; - this.instanceId = instanceId; - this.mimeType = mimeType; - this.appProvidedDirect = appProvidedDirect; - this.appProvidedApp = appProvidedApp; - this.isWorkprofile = isWorkprofile; - this.previewType = previewType; - this.intent = intent; - } - - CallRecord(int atomId, String packageName, InstanceId instanceId, int targetType, - int positionPicked, boolean isPinned) { - this.atomId = atomId; - this.packageName = packageName; - this.instanceId = instanceId; - this.targetType = targetType; - this.positionPicked = positionPicked; - this.isPinned = isPinned; - } - - } - private List<CallRecord> mCalls = new ArrayList<>(); - - public int numCalls() { - return mCalls.size(); - } - - List<CallRecord> getCalls() { - return mCalls; - } - - CallRecord get(int index) { - return mCalls.get(index); - } - - UiEventLogger.UiEventEnum event(int index) { - return mCalls.get(index).event; - } - - public void removeCallsForUiEventsOfType(int uiEventType) { - mCalls.removeIf( - call -> - (call.atomId == FrameworkStatsLog.UI_EVENT_REPORTED) - && (call.event.getId() == uiEventType)); - } - - @Override - public void logShareStarted(int eventId, String packageName, String mimeType, - int appProvidedDirect, int appProvidedApp, boolean isWorkprofile, int previewType, - String intent) { - mCalls.add(new CallRecord(FrameworkStatsLog.SHARESHEET_STARTED, packageName, - getInstanceId(), mimeType, appProvidedDirect, appProvidedApp, isWorkprofile, - previewType, intent)); - } - - @Override - public void logShareTargetSelected(int targetType, String packageName, int positionPicked, - boolean isPinned) { - mCalls.add(new CallRecord(FrameworkStatsLog.RANKING_SELECTED, packageName, getInstanceId(), - SharesheetTargetSelectedEvent.fromTargetType(targetType).getId(), positionPicked, - isPinned)); - } - - @Override - public void log(UiEventLogger.UiEventEnum event, InstanceId instanceId) { - mCalls.add(new CallRecord(FrameworkStatsLog.UI_EVENT_REPORTED, - event, "", instanceId)); - } - - @Override - public InstanceId getInstanceId() { - return InstanceId.fakeInstanceId(-1); - } -} diff --git a/java/tests/src/com/android/intentresolver/ChooserActivityLoggerTest.java b/java/tests/src/com/android/intentresolver/ChooserActivityLoggerTest.java new file mode 100644 index 00000000..702e725a --- /dev/null +++ b/java/tests/src/com/android/intentresolver/ChooserActivityLoggerTest.java @@ -0,0 +1,239 @@ +/* + * 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 static com.google.common.truth.Truth.assertThat; + +import static org.mockito.AdditionalMatchers.gt; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyBoolean; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.ArgumentMatchers.isNull; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoMoreInteractions; + +import android.content.Intent; + +import com.android.intentresolver.ChooserActivityLogger.FrameworkStatsLogger; +import com.android.intentresolver.ChooserActivityLogger.SharesheetStandardEvent; +import com.android.intentresolver.ChooserActivityLogger.SharesheetStartedEvent; +import com.android.intentresolver.ChooserActivityLogger.SharesheetTargetSelectedEvent; +import com.android.internal.logging.InstanceId; +import com.android.internal.logging.UiEventLogger; +import com.android.internal.logging.UiEventLogger.UiEventEnum; +import com.android.internal.util.FrameworkStatsLog; + +import org.junit.After; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.junit.MockitoJUnitRunner; + +@RunWith(MockitoJUnitRunner.class) +public final class ChooserActivityLoggerTest { + @Mock private UiEventLogger mUiEventLog; + @Mock private FrameworkStatsLogger mFrameworkLog; + + private ChooserActivityLogger mChooserLogger; + + @Before + public void setUp() { + mChooserLogger = new ChooserActivityLogger(mUiEventLog, mFrameworkLog); + } + + @After + public void tearDown() { + verifyNoMoreInteractions(mUiEventLog); + verifyNoMoreInteractions(mFrameworkLog); + } + + @Test + public void testLogShareStarted() { + final int eventId = -1; // Passed-in eventId is unused. TODO: remove from method signature. + final String packageName = "com.test.foo"; + final String mimeType = "text/plain"; + final int appProvidedDirectTargets = 123; + final int appProvidedAppTargets = 456; + final boolean workProfile = true; + final int previewType = ChooserContentPreviewUi.CONTENT_PREVIEW_FILE; + final String intentAction = Intent.ACTION_SENDTO; + + mChooserLogger.logShareStarted( + eventId, + packageName, + mimeType, + appProvidedDirectTargets, + appProvidedAppTargets, + workProfile, + previewType, + intentAction); + + verify(mFrameworkLog).write( + eq(FrameworkStatsLog.SHARESHEET_STARTED), + eq(SharesheetStartedEvent.SHARE_STARTED.getId()), + eq(packageName), + /* instanceId=*/ gt(0), + eq(mimeType), + eq(appProvidedDirectTargets), + eq(appProvidedAppTargets), + eq(workProfile), + eq(FrameworkStatsLog.SHARESHEET_STARTED__PREVIEW_TYPE__CONTENT_PREVIEW_FILE), + eq(FrameworkStatsLog.SHARESHEET_STARTED__INTENT_TYPE__INTENT_ACTION_SENDTO)); + } + + @Test + public void testLogShareTargetSelected() { + final int targetType = ChooserActivity.SELECTION_TYPE_COPY; + final String packageName = "com.test.foo"; + final int positionPicked = 123; + final boolean pinned = true; + + mChooserLogger.logShareTargetSelected(targetType, packageName, positionPicked, pinned); + + verify(mFrameworkLog).write( + eq(FrameworkStatsLog.RANKING_SELECTED), + eq(SharesheetTargetSelectedEvent.SHARESHEET_COPY_TARGET_SELECTED.getId()), + eq(packageName), + /* instanceId=*/ gt(0), + eq(positionPicked), + eq(pinned)); + } + + @Test + public void testLogSharesheetTriggered() { + mChooserLogger.logSharesheetTriggered(); + verify(mUiEventLog).logWithInstanceId( + eq(SharesheetStandardEvent.SHARESHEET_TRIGGERED), eq(0), isNull(), any()); + } + + @Test + public void testLogSharesheetAppLoadComplete() { + mChooserLogger.logSharesheetAppLoadComplete(); + verify(mUiEventLog).logWithInstanceId( + eq(SharesheetStandardEvent.SHARESHEET_APP_LOAD_COMPLETE), eq(0), isNull(), any()); + } + + @Test + public void testLogSharesheetDirectLoadComplete() { + mChooserLogger.logSharesheetDirectLoadComplete(); + verify(mUiEventLog).logWithInstanceId( + eq(SharesheetStandardEvent.SHARESHEET_DIRECT_LOAD_COMPLETE), + eq(0), + isNull(), + any()); + } + + @Test + public void testLogSharesheetDirectLoadTimeout() { + mChooserLogger.logSharesheetDirectLoadTimeout(); + verify(mUiEventLog).logWithInstanceId( + eq(SharesheetStandardEvent.SHARESHEET_DIRECT_LOAD_TIMEOUT), eq(0), isNull(), any()); + } + + @Test + public void testLogSharesheetProfileChanged() { + mChooserLogger.logSharesheetProfileChanged(); + verify(mUiEventLog).logWithInstanceId( + eq(SharesheetStandardEvent.SHARESHEET_PROFILE_CHANGED), eq(0), isNull(), any()); + } + + @Test + public void testLogSharesheetExpansionChanged_collapsed() { + mChooserLogger.logSharesheetExpansionChanged(/* isCollapsed=*/ true); + verify(mUiEventLog).logWithInstanceId( + eq(SharesheetStandardEvent.SHARESHEET_COLLAPSED), eq(0), isNull(), any()); + } + + @Test + public void testLogSharesheetExpansionChanged_expanded() { + mChooserLogger.logSharesheetExpansionChanged(/* isCollapsed=*/ false); + verify(mUiEventLog).logWithInstanceId( + eq(SharesheetStandardEvent.SHARESHEET_EXPANDED), eq(0), isNull(), any()); + } + + @Test + public void testLogSharesheetAppShareRankingTimeout() { + mChooserLogger.logSharesheetAppShareRankingTimeout(); + verify(mUiEventLog).logWithInstanceId( + eq(SharesheetStandardEvent.SHARESHEET_APP_SHARE_RANKING_TIMEOUT), + eq(0), + isNull(), + any()); + } + + @Test + public void testLogSharesheetEmptyDirectShareRow() { + mChooserLogger.logSharesheetEmptyDirectShareRow(); + verify(mUiEventLog).logWithInstanceId( + eq(SharesheetStandardEvent.SHARESHEET_EMPTY_DIRECT_SHARE_ROW), + eq(0), + isNull(), + any()); + } + + @Test + public void testDifferentLoggerInstancesUseDifferentInstanceIds() { + ArgumentCaptor<Integer> idIntCaptor = ArgumentCaptor.forClass(Integer.class); + ChooserActivityLogger chooserLogger2 = + new ChooserActivityLogger(mUiEventLog, mFrameworkLog); + + final int targetType = ChooserActivity.SELECTION_TYPE_COPY; + final String packageName = "com.test.foo"; + final int positionPicked = 123; + final boolean pinned = true; + + mChooserLogger.logShareTargetSelected(targetType, packageName, positionPicked, pinned); + chooserLogger2.logShareTargetSelected(targetType, packageName, positionPicked, pinned); + + verify(mFrameworkLog, times(2)).write( + anyInt(), anyInt(), anyString(), idIntCaptor.capture(), anyInt(), anyBoolean()); + + int id1 = idIntCaptor.getAllValues().get(0); + int id2 = idIntCaptor.getAllValues().get(1); + + assertThat(id1).isGreaterThan(0); + assertThat(id2).isGreaterThan(0); + assertThat(id1).isNotEqualTo(id2); + } + + @Test + public void testUiAndFrameworkEventsUseSameInstanceIdForSameLoggerInstance() { + ArgumentCaptor<Integer> idIntCaptor = ArgumentCaptor.forClass(Integer.class); + ArgumentCaptor<InstanceId> idObjectCaptor = ArgumentCaptor.forClass(InstanceId.class); + + final int targetType = ChooserActivity.SELECTION_TYPE_COPY; + final String packageName = "com.test.foo"; + final int positionPicked = 123; + final boolean pinned = true; + + mChooserLogger.logShareTargetSelected(targetType, packageName, positionPicked, pinned); + verify(mFrameworkLog).write( + anyInt(), anyInt(), anyString(), idIntCaptor.capture(), anyInt(), anyBoolean()); + + mChooserLogger.logSharesheetTriggered(); + verify(mUiEventLog).logWithInstanceId( + any(UiEventEnum.class), anyInt(), any(), idObjectCaptor.capture()); + + assertThat(idIntCaptor.getValue()).isGreaterThan(0); + assertThat(idObjectCaptor.getValue().getId()).isEqualTo(idIntCaptor.getValue()); + } +} diff --git a/java/tests/src/com/android/intentresolver/ChooserActivityOverrideData.java b/java/tests/src/com/android/intentresolver/ChooserActivityOverrideData.java index 080f1e41..5acdb42c 100644 --- a/java/tests/src/com/android/intentresolver/ChooserActivityOverrideData.java +++ b/java/tests/src/com/android/intentresolver/ChooserActivityOverrideData.java @@ -16,21 +16,29 @@ package com.android.intentresolver; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyInt; import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; -import android.content.Intent; import android.content.pm.PackageManager; import android.content.res.Resources; import android.database.Cursor; import android.graphics.Bitmap; import android.os.UserHandle; +import com.android.intentresolver.AbstractMultiProfilePagerAdapter.CrossProfileIntentsChecker; +import com.android.intentresolver.AbstractMultiProfilePagerAdapter.MyUserIdProvider; +import com.android.intentresolver.AbstractMultiProfilePagerAdapter.QuietModeManager; import com.android.intentresolver.chooser.TargetInfo; +import com.android.intentresolver.shortcuts.ShortcutLoader; import com.android.internal.logging.MetricsLogger; -import java.util.List; +import java.util.function.Consumer; import java.util.function.Function; +import kotlin.jvm.functions.Function2; + /** * Singleton providing overrides to be applied by any {@code IChooserWrapper} used in testing. * We cannot directly mock the activity created since instrumentation creates it, so instead we use @@ -49,7 +57,8 @@ public class ChooserActivityOverrideData { @SuppressWarnings("Since15") public Function<PackageManager, PackageManager> createPackageManager; public Function<TargetInfo, Boolean> onSafelyStartCallback; - public Function<ChooserListAdapter, Void> onQueryDirectShareTargets; + public Function2<UserHandle, Consumer<ShortcutLoader.Result>, ShortcutLoader> + shortcutLoaderFactory = (userHandle, callback) -> null; public ResolverListController resolverListController; public ResolverListController workResolverListController; public Boolean isVoiceInteraction; @@ -64,14 +73,14 @@ public class ChooserActivityOverrideData { public UserHandle workProfileUserHandle; public boolean hasCrossProfileIntents; public boolean isQuietModeEnabled; - public boolean isWorkProfileUserRunning; - public boolean isWorkProfileUserUnlocked; - public AbstractMultiProfilePagerAdapter.Injector multiPagerAdapterInjector; + public Integer myUserId; + public QuietModeManager mQuietModeManager; + public MyUserIdProvider mMyUserIdProvider; + public CrossProfileIntentsChecker mCrossProfileIntentsChecker; public PackageManager packageManager; public void reset() { onSafelyStartCallback = null; - onQueryDirectShareTargets = null; isVoiceInteraction = null; createPackageManager = null; previewThumbnail = null; @@ -81,22 +90,15 @@ public class ChooserActivityOverrideData { resolverListController = mock(ResolverListController.class); workResolverListController = mock(ResolverListController.class); metricsLogger = mock(MetricsLogger.class); - chooserActivityLogger = new ChooserActivityLoggerFake(); + chooserActivityLogger = mock(ChooserActivityLogger.class); alternateProfileSetting = 0; resources = null; workProfileUserHandle = null; hasCrossProfileIntents = true; isQuietModeEnabled = false; - isWorkProfileUserRunning = true; - isWorkProfileUserUnlocked = true; + myUserId = null; packageManager = null; - multiPagerAdapterInjector = new AbstractMultiProfilePagerAdapter.Injector() { - @Override - public boolean hasCrossProfileIntents(List<Intent> intents, int sourceUserId, - int targetUserId) { - return hasCrossProfileIntents; - } - + mQuietModeManager = new QuietModeManager() { @Override public boolean isQuietModeEnabled(UserHandle workProfileUserHandle) { return isQuietModeEnabled; @@ -107,7 +109,28 @@ public class ChooserActivityOverrideData { UserHandle workProfileUserHandle) { isQuietModeEnabled = enabled; } + + @Override + public void markWorkProfileEnabledBroadcastReceived() { + } + + @Override + public boolean isWaitingToEnableWorkProfile() { + return false; + } }; + shortcutLoaderFactory = ((userHandle, resultConsumer) -> null); + + mMyUserIdProvider = new MyUserIdProvider() { + @Override + public int getMyUserId() { + return myUserId != null ? myUserId : UserHandle.myUserId(); + } + }; + + mCrossProfileIntentsChecker = mock(CrossProfileIntentsChecker.class); + when(mCrossProfileIntentsChecker.hasCrossProfileIntents(any(), anyInt(), anyInt())) + .thenAnswer(invocation -> hasCrossProfileIntents); } private ChooserActivityOverrideData() {} diff --git a/java/tests/src/com/android/intentresolver/ChooserListAdapterTest.kt b/java/tests/src/com/android/intentresolver/ChooserListAdapterTest.kt new file mode 100644 index 00000000..6b34f8b9 --- /dev/null +++ b/java/tests/src/com/android/intentresolver/ChooserListAdapterTest.kt @@ -0,0 +1,147 @@ +/* + * 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.content.ComponentName +import android.content.Intent +import android.content.pm.PackageManager +import android.content.pm.PackageManager.ResolveInfoFlags +import android.view.View +import android.widget.FrameLayout +import android.widget.ImageView +import android.widget.TextView +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry +import com.android.intentresolver.ChooserListAdapter.LoadDirectShareIconTask +import com.android.intentresolver.chooser.SelectableTargetInfo +import com.android.intentresolver.chooser.TargetInfo +import com.android.internal.R +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.Mockito.times +import org.mockito.Mockito.verify + +@RunWith(AndroidJUnit4::class) +class ChooserListAdapterTest { + private val packageManager = mock<PackageManager> { + whenever( + resolveActivity(any(), any<ResolveInfoFlags>()) + ).thenReturn(mock()) + } + private val context = InstrumentationRegistry.getInstrumentation().getContext() + private val resolverListController = mock<ResolverListController>() + private val chooserActivityLogger = mock<ChooserActivityLogger>() + + private fun createChooserListAdapter( + taskProvider: (TargetInfo?) -> LoadDirectShareIconTask + ) = object : ChooserListAdapter( + context, + emptyList(), + emptyArray(), + emptyList(), + false, + resolverListController, + null, + Intent(), + mock(), + packageManager, + chooserActivityLogger, + mock(), + 0 + ) { + override fun createLoadDirectShareIconTask( + info: SelectableTargetInfo + ): LoadDirectShareIconTask = taskProvider(info) + } + + @Before + fun setup() { + // ChooserListAdapter reads DeviceConfig and needs a permission for that. + InstrumentationRegistry + .getInstrumentation() + .getUiAutomation() + .adoptShellPermissionIdentity("android.permission.READ_DEVICE_CONFIG") + } + + @Test + fun testDirectShareTargetLoadingIconIsStarted() { + val view = createView() + val viewHolder = ResolverListAdapter.ViewHolder(view) + view.tag = viewHolder + val targetInfo = createSelectableTargetInfo() + val iconTask = mock<LoadDirectShareIconTask>() + val testSubject = createChooserListAdapter { iconTask } + testSubject.onBindView(view, targetInfo, 0) + + verify(iconTask, times(1)).loadIcon() + } + + @Test + fun testOnlyOneTaskPerTarget() { + val view = createView() + val viewHolderOne = ResolverListAdapter.ViewHolder(view) + view.tag = viewHolderOne + val targetInfo = createSelectableTargetInfo() + val iconTaskOne = mock<LoadDirectShareIconTask>() + val testTaskProvider = mock<() -> LoadDirectShareIconTask> { + whenever(invoke()).thenReturn(iconTaskOne) + } + val testSubject = createChooserListAdapter { testTaskProvider.invoke() } + testSubject.onBindView(view, targetInfo, 0) + + val viewHolderTwo = ResolverListAdapter.ViewHolder(view) + view.tag = viewHolderTwo + whenever(testTaskProvider()).thenReturn(mock()) + + testSubject.onBindView(view, targetInfo, 0) + + verify(iconTaskOne, times(1)).loadIcon() + verify(testTaskProvider, times(1)).invoke() + } + + private fun createSelectableTargetInfo(): TargetInfo = + SelectableTargetInfo.newSelectableTargetInfo( + /* sourceInfo = */ mock(), + /* backupResolveInfo = */ mock(), + /* resolvedIntent = */ Intent(), + /* chooserTarget = */ createChooserTarget( + "Target", 0.5f, ComponentName("pkg", "Class"), "id-1" + ), + /* modifiedScore = */ 1f, + /* shortcutInfo = */ createShortcutInfo("id-1", ComponentName("pkg", "Class"), 1), + /* appTarget */ null, + /* referrerFillInIntent = */ Intent() + ) + + private fun createView(): View { + val view = FrameLayout(context) + TextView(context).apply { + id = R.id.text1 + view.addView(this) + } + TextView(context).apply { + id = R.id.text2 + view.addView(this) + } + ImageView(context).apply { + id = R.id.icon + view.addView(this) + } + return view + } +} diff --git a/java/tests/src/com/android/intentresolver/ChooserWrapperActivity.java b/java/tests/src/com/android/intentresolver/ChooserWrapperActivity.java index 0e9f010e..8c842786 100644 --- a/java/tests/src/com/android/intentresolver/ChooserWrapperActivity.java +++ b/java/tests/src/com/android/intentresolver/ChooserWrapperActivity.java @@ -19,11 +19,13 @@ package com.android.intentresolver; import static org.mockito.Mockito.when; import android.annotation.Nullable; +import android.app.prediction.AppPredictor; import android.app.usage.UsageStatsManager; import android.content.ComponentName; import android.content.ContentResolver; import android.content.Context; import android.content.Intent; +import android.content.IntentFilter; import android.content.pm.PackageManager; import android.content.pm.ResolveInfo; import android.content.res.Resources; @@ -33,19 +35,19 @@ import android.net.Uri; import android.os.UserHandle; import android.util.Size; -import com.android.intentresolver.AbstractMultiProfilePagerAdapter; -import com.android.intentresolver.ChooserActivityLogger; -import com.android.intentresolver.ChooserActivityOverrideData; -import com.android.intentresolver.ChooserListAdapter; -import com.android.intentresolver.IChooserWrapper; +import com.android.intentresolver.AbstractMultiProfilePagerAdapter.CrossProfileIntentsChecker; +import com.android.intentresolver.AbstractMultiProfilePagerAdapter.MyUserIdProvider; +import com.android.intentresolver.AbstractMultiProfilePagerAdapter.QuietModeManager; import com.android.intentresolver.ResolverListAdapter.ResolveInfoPresentationGetter; -import com.android.intentresolver.ResolverListController; import com.android.intentresolver.chooser.DisplayResolveInfo; +import com.android.intentresolver.chooser.NotSelectableTargetInfo; import com.android.intentresolver.chooser.TargetInfo; +import com.android.intentresolver.shortcuts.ShortcutLoader; import com.android.internal.logging.MetricsLogger; import com.android.internal.logging.nano.MetricsProto.MetricsEvent; import java.util.List; +import java.util.function.Consumer; /** * Simple wrapper around chooser activity to be able to initiate it under test. For more @@ -64,25 +66,34 @@ public class ChooserWrapperActivity } @Override - protected AbstractMultiProfilePagerAdapter createMultiProfilePagerAdapter( - Intent[] initialIntents, List<ResolveInfo> rList, boolean filterLastUsed) { - AbstractMultiProfilePagerAdapter multiProfilePagerAdapter = - super.createMultiProfilePagerAdapter(initialIntents, rList, filterLastUsed); - multiProfilePagerAdapter.setInjector(sOverrides.multiPagerAdapterInjector); - return multiProfilePagerAdapter; - } - - @Override - public ChooserListAdapter createChooserListAdapter(Context context, List<Intent> payloadIntents, - Intent[] initialIntents, List<ResolveInfo> rList, boolean filterLastUsed, - ResolverListController resolverListController) { + 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) { PackageManager packageManager = sOverrides.packageManager == null ? context.getPackageManager() : sOverrides.packageManager; - return new ChooserListAdapter(context, payloadIntents, initialIntents, rList, - filterLastUsed, resolverListController, - this, this, packageManager, - getChooserActivityLogger()); + return new ChooserListAdapter( + context, + payloadIntents, + initialIntents, + rList, + filterLastUsed, + resolverListController, + userHandle, + targetIntent, + this, + packageManager, + getChooserActivityLogger(), + chooserRequest, + maxTargetsPerRow); } @Override @@ -119,7 +130,7 @@ public class ChooserWrapperActivity @Override protected TargetInfo getNearbySharingTarget(Intent originalIntent) { - return new ChooserWrapperActivity.EmptyTargetInfo(); + return NotSelectableTargetInfo.newEmptyTargetInfo(); } @Override @@ -139,6 +150,30 @@ public class ChooserWrapperActivity } @Override + protected MyUserIdProvider createMyUserIdProvider() { + if (sOverrides.mMyUserIdProvider != null) { + return sOverrides.mMyUserIdProvider; + } + return super.createMyUserIdProvider(); + } + + @Override + protected CrossProfileIntentsChecker createCrossProfileIntentsChecker() { + if (sOverrides.mCrossProfileIntentsChecker != null) { + return sOverrides.mCrossProfileIntentsChecker; + } + return super.createCrossProfileIntentsChecker(); + } + + @Override + protected QuietModeManager createQuietModeManager() { + if (sOverrides.mQuietModeManager != null) { + return sOverrides.mQuietModeManager; + } + return super.createQuietModeManager(); + } + + @Override public void safelyStartActivity(com.android.intentresolver.chooser.TargetInfo cti) { if (sOverrides.onSafelyStartCallback != null && sOverrides.onSafelyStartCallback.apply(cti)) { @@ -221,7 +256,12 @@ public class ChooserWrapperActivity public DisplayResolveInfo createTestDisplayResolveInfo(Intent originalIntent, ResolveInfo pri, CharSequence pLabel, CharSequence pInfo, Intent replacementIntent, @Nullable ResolveInfoPresentationGetter resolveInfoPresentationGetter) { - return new DisplayResolveInfo(originalIntent, pri, pLabel, pInfo, replacementIntent, + return DisplayResolveInfo.newDisplayResolveInfo( + originalIntent, + pri, + pLabel, + pInfo, + replacementIntent, resolveInfoPresentationGetter); } @@ -242,32 +282,18 @@ public class ChooserWrapperActivity } @Override - protected void queryDirectShareTargets(ChooserListAdapter adapter, - boolean skipAppPredictionService) { - if (sOverrides.onQueryDirectShareTargets != null) { - sOverrides.onQueryDirectShareTargets.apply(adapter); - } - super.queryDirectShareTargets(adapter, skipAppPredictionService); - } - - @Override - protected boolean isQuietModeEnabled(UserHandle userHandle) { - return sOverrides.isQuietModeEnabled; - } - - @Override - protected boolean isUserRunning(UserHandle userHandle) { - if (userHandle.equals(UserHandle.SYSTEM)) { - return super.isUserRunning(userHandle); - } - return sOverrides.isWorkProfileUserRunning; - } - - @Override - protected boolean isUserUnlocked(UserHandle userHandle) { - if (userHandle.equals(UserHandle.SYSTEM)) { - return super.isUserUnlocked(userHandle); + protected ShortcutLoader createShortcutLoader( + Context context, + AppPredictor appPredictor, + UserHandle userHandle, + IntentFilter targetIntentFilter, + Consumer<ShortcutLoader.Result> callback) { + ShortcutLoader shortcutLoader = + sOverrides.shortcutLoaderFactory.invoke(userHandle, callback); + if (shortcutLoader != null) { + return shortcutLoader; } - return sOverrides.isWorkProfileUserUnlocked; + return super.createShortcutLoader( + context, appPredictor, userHandle, targetIntentFilter, callback); } } diff --git a/java/tests/src/com/android/intentresolver/IChooserWrapper.java b/java/tests/src/com/android/intentresolver/IChooserWrapper.java index f81cd023..0d44e147 100644 --- a/java/tests/src/com/android/intentresolver/IChooserWrapper.java +++ b/java/tests/src/com/android/intentresolver/IChooserWrapper.java @@ -25,6 +25,8 @@ import android.os.UserHandle; import com.android.intentresolver.ResolverListAdapter.ResolveInfoPresentationGetter; import com.android.intentresolver.chooser.DisplayResolveInfo; +import java.util.concurrent.Executor; + /** * Test-only extended API capabilities that an instrumented ChooserActivity subclass provides in * order to expose the internals for override/inspection. Implementations should apply the overrides @@ -41,4 +43,5 @@ public interface IChooserWrapper { @Nullable ResolveInfoPresentationGetter resolveInfoPresentationGetter); UserHandle getCurrentUserHandle(); ChooserActivityLogger getChooserActivityLogger(); + Executor getMainExecutor(); } diff --git a/java/tests/src/com/android/intentresolver/MockitoKotlinHelpers.kt b/java/tests/src/com/android/intentresolver/MockitoKotlinHelpers.kt new file mode 100644 index 00000000..159c6d6a --- /dev/null +++ b/java/tests/src/com/android/intentresolver/MockitoKotlinHelpers.kt @@ -0,0 +1,146 @@ +/* + * 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 + +/** + * Kotlin versions of popular mockito methods that can return null in situations when Kotlin expects + * a non-null value. Kotlin will throw an IllegalStateException when this takes place ("x must not + * be null"). To fix this, we can use methods that modify the return type to be nullable. This + * causes Kotlin to skip the null checks. + * Cloned from frameworks/base/packages/SystemUI/tests/utils/src/com/android/systemui/util/mockito/KotlinMockitoHelpers.kt + */ + +import org.mockito.ArgumentCaptor +import org.mockito.ArgumentMatcher +import org.mockito.Mockito +import org.mockito.stubbing.OngoingStubbing + +/** + * Returns Mockito.eq() as nullable type to avoid java.lang.IllegalStateException when + * null is returned. + * + * Generic T is nullable because implicitly bounded by Any?. + */ +fun <T> eq(obj: T): T = Mockito.eq<T>(obj) + +/** + * Returns Mockito.any() as nullable type to avoid java.lang.IllegalStateException when + * null is returned. + * + * Generic T is nullable because implicitly bounded by Any?. + */ +fun <T> any(type: Class<T>): T = Mockito.any<T>(type) +inline fun <reified T> any(): T = any(T::class.java) + +/** + * Returns Mockito.argThat() as nullable type to avoid java.lang.IllegalStateException when + * null is returned. + * + * Generic T is nullable because implicitly bounded by Any?. + */ +fun <T> argThat(matcher: ArgumentMatcher<T>): T = Mockito.argThat(matcher) + +/** + * Kotlin type-inferred version of Mockito.nullable() + */ +inline fun <reified T> nullable(): T? = Mockito.nullable(T::class.java) + +/** + * Returns ArgumentCaptor.capture() as nullable type to avoid java.lang.IllegalStateException + * when null is returned. + * + * Generic T is nullable because implicitly bounded by Any?. + */ +fun <T> capture(argumentCaptor: ArgumentCaptor<T>): T = argumentCaptor.capture() + +/** + * Helper function for creating an argumentCaptor in kotlin. + * + * Generic T is nullable because implicitly bounded by Any?. + */ +inline fun <reified T : Any> argumentCaptor(): ArgumentCaptor<T> = + ArgumentCaptor.forClass(T::class.java) + +/** + * Helper function for creating new mocks, without the need to pass in a [Class] instance. + * + * Generic T is nullable because implicitly bounded by Any?. + * + * @param apply builder function to simplify stub configuration by improving type inference. + */ +inline fun <reified T : Any> mock(apply: T.() -> Unit = {}): T = Mockito.mock(T::class.java) + .apply(apply) + +/** + * Helper function for stubbing methods without the need to use backticks. + * + * @see Mockito.when + */ +fun <T> whenever(methodCall: T): OngoingStubbing<T> = Mockito.`when`(methodCall) + +/** + * A kotlin implemented wrapper of [ArgumentCaptor] which prevents the following exception when + * kotlin tests are mocking kotlin objects and the methods take non-null parameters: + * + * java.lang.NullPointerException: capture() must not be null + */ +class KotlinArgumentCaptor<T> constructor(clazz: Class<T>) { + private val wrapped: ArgumentCaptor<T> = ArgumentCaptor.forClass(clazz) + fun capture(): T = wrapped.capture() + val value: T + get() = wrapped.value + val allValues: List<T> + get() = wrapped.allValues +} + +/** + * Helper function for creating an argumentCaptor in kotlin. + * + * Generic T is nullable because implicitly bounded by Any?. + */ +inline fun <reified T : Any> kotlinArgumentCaptor(): KotlinArgumentCaptor<T> = + KotlinArgumentCaptor(T::class.java) + +/** + * Helper function for creating and using a single-use ArgumentCaptor in kotlin. + * + * val captor = argumentCaptor<Foo>() + * verify(...).someMethod(captor.capture()) + * val captured = captor.value + * + * becomes: + * + * val captured = withArgCaptor<Foo> { verify(...).someMethod(capture()) } + * + * NOTE: this uses the KotlinArgumentCaptor to avoid the NullPointerException. + */ +inline fun <reified T : Any> withArgCaptor(block: KotlinArgumentCaptor<T>.() -> Unit): T = + kotlinArgumentCaptor<T>().apply { block() }.value + +/** + * Variant of [withArgCaptor] for capturing multiple arguments. + * + * val captor = argumentCaptor<Foo>() + * verify(...).someMethod(captor.capture()) + * val captured: List<Foo> = captor.allValues + * + * becomes: + * + * val capturedList = captureMany<Foo> { verify(...).someMethod(capture()) } + */ +inline fun <reified T : Any> captureMany(block: KotlinArgumentCaptor<T>.() -> Unit): List<T> = + kotlinArgumentCaptor<T>().apply{ block() }.allValues diff --git a/java/tests/src/com/android/intentresolver/ResolverActivityTest.java b/java/tests/src/com/android/intentresolver/ResolverActivityTest.java new file mode 100644 index 00000000..07cbd6a4 --- /dev/null +++ b/java/tests/src/com/android/intentresolver/ResolverActivityTest.java @@ -0,0 +1,912 @@ +/* + * Copyright (C) 2016 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 androidx.test.espresso.Espresso.onView; +import static androidx.test.espresso.action.ViewActions.click; +import static androidx.test.espresso.action.ViewActions.swipeUp; +import static androidx.test.espresso.assertion.ViewAssertions.matches; +import static androidx.test.espresso.matcher.ViewMatchers.isCompletelyDisplayed; +import static androidx.test.espresso.matcher.ViewMatchers.isDisplayed; +import static androidx.test.espresso.matcher.ViewMatchers.isEnabled; +import static androidx.test.espresso.matcher.ViewMatchers.withId; +import static androidx.test.espresso.matcher.ViewMatchers.withText; + +import static com.android.intentresolver.MatcherUtils.first; +import static com.android.intentresolver.ResolverDataProvider.createPackageManagerMockedInfo; +import static com.android.intentresolver.ResolverWrapperActivity.sOverrides; + +import static org.hamcrest.CoreMatchers.allOf; +import static org.hamcrest.CoreMatchers.is; +import static org.hamcrest.CoreMatchers.not; +import static org.hamcrest.MatcherAssert.assertThat; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.when; +import static org.testng.Assert.assertFalse; +import static org.testng.Assert.fail; + +import android.content.Context; +import android.content.Intent; +import android.content.pm.ResolveInfo; +import android.net.Uri; +import android.os.Bundle; +import android.os.Parcelable; +import android.os.RemoteException; +import android.os.UserHandle; +import android.text.TextUtils; +import android.util.Log; +import android.view.View; +import android.widget.RelativeLayout; +import android.widget.TextView; + +import androidx.test.InstrumentationRegistry; +import androidx.test.core.app.ActivityScenario; +import androidx.test.espresso.Espresso; +import androidx.test.espresso.NoMatchingViewException; +import androidx.test.ext.junit.rules.ActivityScenarioRule; +import androidx.test.rule.ActivityTestRule; +import androidx.test.runner.AndroidJUnit4; + +import com.android.internal.R; +import com.android.intentresolver.ResolverActivity.ResolvedComponentInfo; +import com.android.intentresolver.ResolverDataProvider.PackageManagerMockedInfo; +import com.android.intentresolver.ResolverListAdapter.ActivityInfoPresentationGetter; +import com.android.intentresolver.ResolverListAdapter.ResolveInfoPresentationGetter; +import com.android.intentresolver.widget.ResolverDrawerLayout; + +import org.junit.Before; +import org.junit.Ignore; +import org.junit.Rule; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.mockito.Mockito; + +import java.util.ArrayList; +import java.util.List; + +/** + * Resolver activity instrumentation tests + */ +@RunWith(AndroidJUnit4.class) +public class ResolverActivityTest { + protected Intent getConcreteIntentForLaunch(Intent clientIntent) { + clientIntent.setClass( + androidx.test.platform.app.InstrumentationRegistry.getInstrumentation().getTargetContext(), + ResolverWrapperActivity.class); + return clientIntent; + } + + @Rule + public ActivityTestRule<ResolverWrapperActivity> mActivityRule = + new ActivityTestRule<>(ResolverWrapperActivity.class, false, false); + + @Before + public void setup() { + // TODO: use the other form of `adoptShellPermissionIdentity()` where we explicitly list the + // permissions we require (which we'll read from the manifest at runtime). + androidx.test.platform.app.InstrumentationRegistry + .getInstrumentation() + .getUiAutomation() + .adoptShellPermissionIdentity(); + + sOverrides.reset(); + } + + @Test + public void twoOptionsAndUserSelectsOne() throws InterruptedException { + Intent sendIntent = createSendImageIntent(); + List<ResolvedComponentInfo> resolvedComponentInfos = createResolvedComponentsForTest(2); + + when(sOverrides.resolverListController.getResolversForIntent(Mockito.anyBoolean(), + Mockito.anyBoolean(), + Mockito.anyBoolean(), + Mockito.isA(List.class))).thenReturn(resolvedComponentInfos); + + final ResolverWrapperActivity activity = mActivityRule.launchActivity(sendIntent); + Espresso.registerIdlingResources(activity.getAdapter().getLabelIdlingResource()); + waitForIdle(); + + assertThat(activity.getAdapter().getCount(), is(2)); + + ResolveInfo[] chosen = new ResolveInfo[1]; + sOverrides.onSafelyStartCallback = targetInfo -> { + chosen[0] = targetInfo.getResolveInfo(); + return true; + }; + + ResolveInfo toChoose = resolvedComponentInfos.get(0).getResolveInfoAt(0); + onView(withText(toChoose.activityInfo.name)) + .perform(click()); + onView(withId(R.id.button_once)) + .perform(click()); + waitForIdle(); + assertThat(chosen[0], is(toChoose)); + } + + @Ignore // Failing - b/144929805 + @Test + public void setMaxHeight() throws Exception { + Intent sendIntent = createSendImageIntent(); + List<ResolvedComponentInfo> resolvedComponentInfos = createResolvedComponentsForTest(2); + + when(sOverrides.resolverListController.getResolversForIntent(Mockito.anyBoolean(), + Mockito.anyBoolean(), + Mockito.anyBoolean(), + Mockito.isA(List.class))).thenReturn(resolvedComponentInfos); + waitForIdle(); + + final ResolverWrapperActivity activity = mActivityRule.launchActivity(sendIntent); + final View viewPager = activity.findViewById(R.id.profile_pager); + final int initialResolverHeight = viewPager.getHeight(); + + activity.runOnUiThread(() -> { + ResolverDrawerLayout layout = (ResolverDrawerLayout) + activity.findViewById( + R.id.contentPanel); + ((ResolverDrawerLayout.LayoutParams) viewPager.getLayoutParams()).maxHeight + = initialResolverHeight - 1; + // Force a relayout + layout.invalidate(); + layout.requestLayout(); + }); + waitForIdle(); + assertThat("Drawer should be capped at maxHeight", + viewPager.getHeight() == (initialResolverHeight - 1)); + + activity.runOnUiThread(() -> { + ResolverDrawerLayout layout = (ResolverDrawerLayout) + activity.findViewById( + R.id.contentPanel); + ((ResolverDrawerLayout.LayoutParams) viewPager.getLayoutParams()).maxHeight + = initialResolverHeight + 1; + // Force a relayout + layout.invalidate(); + layout.requestLayout(); + }); + waitForIdle(); + assertThat("Drawer should not change height if its height is less than maxHeight", + viewPager.getHeight() == initialResolverHeight); + } + + @Ignore // Failing - b/144929805 + @Test + public void setShowAtTopToTrue() throws Exception { + Intent sendIntent = createSendImageIntent(); + List<ResolvedComponentInfo> resolvedComponentInfos = createResolvedComponentsForTest(2); + + when(sOverrides.resolverListController.getResolversForIntent(Mockito.anyBoolean(), + Mockito.anyBoolean(), + Mockito.anyBoolean(), + Mockito.isA(List.class))).thenReturn(resolvedComponentInfos); + waitForIdle(); + + final ResolverWrapperActivity activity = mActivityRule.launchActivity(sendIntent); + final View viewPager = activity.findViewById(R.id.profile_pager); + final View divider = activity.findViewById(R.id.divider); + final RelativeLayout profileView = + (RelativeLayout) activity.findViewById(R.id.profile_button).getParent(); + assertThat("Drawer should show at bottom by default", + profileView.getBottom() + divider.getHeight() == viewPager.getTop() + && profileView.getTop() > 0); + + activity.runOnUiThread(() -> { + ResolverDrawerLayout layout = (ResolverDrawerLayout) + activity.findViewById( + R.id.contentPanel); + layout.setShowAtTop(true); + }); + waitForIdle(); + assertThat("Drawer should show at top with new attribute", + profileView.getBottom() + divider.getHeight() == viewPager.getTop() + && profileView.getTop() == 0); + } + + @Test + public void hasLastChosenActivity() throws Exception { + Intent sendIntent = createSendImageIntent(); + List<ResolvedComponentInfo> resolvedComponentInfos = createResolvedComponentsForTest(2); + ResolveInfo toChoose = resolvedComponentInfos.get(0).getResolveInfoAt(0); + + when(sOverrides.resolverListController.getResolversForIntent(Mockito.anyBoolean(), + Mockito.anyBoolean(), + Mockito.anyBoolean(), + Mockito.isA(List.class))).thenReturn(resolvedComponentInfos); + when(sOverrides.resolverListController.getLastChosen()) + .thenReturn(resolvedComponentInfos.get(0).getResolveInfoAt(0)); + + final ResolverWrapperActivity activity = mActivityRule.launchActivity(sendIntent); + waitForIdle(); + + // The other entry is filtered to the last used slot + assertThat(activity.getAdapter().getCount(), is(1)); + assertThat(activity.getAdapter().getPlaceholderCount(), is(1)); + + ResolveInfo[] chosen = new ResolveInfo[1]; + sOverrides.onSafelyStartCallback = targetInfo -> { + chosen[0] = targetInfo.getResolveInfo(); + return true; + }; + + onView(withId(R.id.button_once)).perform(click()); + waitForIdle(); + assertThat(chosen[0], is(toChoose)); + } + + @Test + public void hasOtherProfileOneOption() throws Exception { + List<ResolvedComponentInfo> personalResolvedComponentInfos = + createResolvedComponentsForTestWithOtherProfile(2, /* userId */ 10); + List<ResolvedComponentInfo> workResolvedComponentInfos = createResolvedComponentsForTest(4); + setupResolverControllers(personalResolvedComponentInfos, workResolvedComponentInfos); + markWorkProfileUserAvailable(); + + ResolveInfo toChoose = personalResolvedComponentInfos.get(1).getResolveInfoAt(0); + Intent sendIntent = createSendImageIntent(); + final ResolverWrapperActivity activity = mActivityRule.launchActivity(sendIntent); + Espresso.registerIdlingResources(activity.getAdapter().getLabelIdlingResource()); + waitForIdle(); + + // The other entry is filtered to the last used slot + assertThat(activity.getAdapter().getCount(), is(1)); + + ResolveInfo[] chosen = new ResolveInfo[1]; + sOverrides.onSafelyStartCallback = targetInfo -> { + chosen[0] = targetInfo.getResolveInfo(); + return true; + }; + // Make a stable copy of the components as the original list may be modified + List<ResolvedComponentInfo> stableCopy = + createResolvedComponentsForTestWithOtherProfile(2, /* userId= */ 10); + // We pick the first one as there is another one in the work profile side + onView(first(withText(stableCopy.get(1).getResolveInfoAt(0).activityInfo.name))) + .perform(click()); + onView(withId(R.id.button_once)) + .perform(click()); + waitForIdle(); + assertThat(chosen[0], is(toChoose)); + } + + @Test + public void hasOtherProfileTwoOptionsAndUserSelectsOne() throws Exception { + Intent sendIntent = createSendImageIntent(); + List<ResolvedComponentInfo> resolvedComponentInfos = + createResolvedComponentsForTestWithOtherProfile(3); + ResolveInfo toChoose = resolvedComponentInfos.get(1).getResolveInfoAt(0); + + when(sOverrides.resolverListController.getResolversForIntent(Mockito.anyBoolean(), + Mockito.anyBoolean(), + Mockito.anyBoolean(), + Mockito.isA(List.class))).thenReturn(resolvedComponentInfos); + + final ResolverWrapperActivity activity = mActivityRule.launchActivity(sendIntent); + Espresso.registerIdlingResources(activity.getAdapter().getLabelIdlingResource()); + waitForIdle(); + + // The other entry is filtered to the other profile slot + assertThat(activity.getAdapter().getCount(), is(2)); + + ResolveInfo[] chosen = new ResolveInfo[1]; + sOverrides.onSafelyStartCallback = targetInfo -> { + chosen[0] = targetInfo.getResolveInfo(); + return true; + }; + + // Confirm that the button bar is disabled by default + onView(withId(R.id.button_once)).check(matches(not(isEnabled()))); + + // Make a stable copy of the components as the original list may be modified + List<ResolvedComponentInfo> stableCopy = + createResolvedComponentsForTestWithOtherProfile(2); + + onView(withText(stableCopy.get(1).getResolveInfoAt(0).activityInfo.name)) + .perform(click()); + onView(withId(R.id.button_once)).perform(click()); + waitForIdle(); + assertThat(chosen[0], is(toChoose)); + } + + + @Test + public void hasLastChosenActivityAndOtherProfile() throws Exception { + // In this case we prefer the other profile and don't display anything about the last + // chosen activity. + Intent sendIntent = createSendImageIntent(); + List<ResolvedComponentInfo> resolvedComponentInfos = + createResolvedComponentsForTestWithOtherProfile(3); + ResolveInfo toChoose = resolvedComponentInfos.get(1).getResolveInfoAt(0); + + when(sOverrides.resolverListController.getResolversForIntent(Mockito.anyBoolean(), + Mockito.anyBoolean(), + Mockito.anyBoolean(), + Mockito.isA(List.class))).thenReturn(resolvedComponentInfos); + when(sOverrides.resolverListController.getLastChosen()) + .thenReturn(resolvedComponentInfos.get(1).getResolveInfoAt(0)); + + final ResolverWrapperActivity activity = mActivityRule.launchActivity(sendIntent); + Espresso.registerIdlingResources(activity.getAdapter().getLabelIdlingResource()); + waitForIdle(); + + // The other entry is filtered to the other profile slot + assertThat(activity.getAdapter().getCount(), is(2)); + + ResolveInfo[] chosen = new ResolveInfo[1]; + sOverrides.onSafelyStartCallback = targetInfo -> { + chosen[0] = targetInfo.getResolveInfo(); + return true; + }; + + // Confirm that the button bar is disabled by default + onView(withId(R.id.button_once)).check(matches(not(isEnabled()))); + + // Make a stable copy of the components as the original list may be modified + List<ResolvedComponentInfo> stableCopy = + createResolvedComponentsForTestWithOtherProfile(2); + + onView(withText(stableCopy.get(1).getResolveInfoAt(0).activityInfo.name)) + .perform(click()); + onView(withId(R.id.button_once)).perform(click()); + waitForIdle(); + assertThat(chosen[0], is(toChoose)); + } + + @Test + public void getActivityLabelAndSubLabel() throws Exception { + ActivityInfoPresentationGetter pg; + PackageManagerMockedInfo info; + + info = createPackageManagerMockedInfo(false); + pg = new ActivityInfoPresentationGetter( + info.ctx, 0, info.activityInfo); + assertThat("Label should match app label", pg.getLabel().equals( + info.setAppLabel)); + assertThat("Sublabel should match activity label if set", + pg.getSubLabel().equals(info.setActivityLabel)); + + info = createPackageManagerMockedInfo(true); + pg = new ActivityInfoPresentationGetter( + info.ctx, 0, info.activityInfo); + assertThat("With override permission label should match activity label if set", + pg.getLabel().equals(info.setActivityLabel)); + assertThat("With override permission sublabel should be empty", + TextUtils.isEmpty(pg.getSubLabel())); + } + + @Test + public void getResolveInfoLabelAndSubLabel() throws Exception { + ResolveInfoPresentationGetter pg; + PackageManagerMockedInfo info; + + info = createPackageManagerMockedInfo(false); + pg = new ResolveInfoPresentationGetter( + info.ctx, 0, info.resolveInfo); + assertThat("Label should match app label", pg.getLabel().equals( + info.setAppLabel)); + assertThat("Sublabel should match resolve info label if set", + pg.getSubLabel().equals(info.setResolveInfoLabel)); + + info = createPackageManagerMockedInfo(true); + pg = new ResolveInfoPresentationGetter( + info.ctx, 0, info.resolveInfo); + assertThat("With override permission label should match activity label if set", + pg.getLabel().equals(info.setActivityLabel)); + assertThat("With override permission the sublabel should be the resolve info label", + pg.getSubLabel().equals(info.setResolveInfoLabel)); + } + + @Test + public void testWorkTab_displayedWhenWorkProfileUserAvailable() { + Intent sendIntent = createSendImageIntent(); + markWorkProfileUserAvailable(); + + mActivityRule.launchActivity(sendIntent); + waitForIdle(); + + onView(withId(R.id.tabs)).check(matches(isDisplayed())); + } + + @Test + public void testWorkTab_hiddenWhenWorkProfileUserNotAvailable() { + Intent sendIntent = createSendImageIntent(); + + mActivityRule.launchActivity(sendIntent); + waitForIdle(); + + onView(withId(R.id.tabs)).check(matches(not(isDisplayed()))); + } + + @Test + public void testWorkTab_workTabListPopulatedBeforeGoingToTab() throws InterruptedException { + List<ResolvedComponentInfo> personalResolvedComponentInfos = + createResolvedComponentsForTestWithOtherProfile(3, /* userId = */ 10); + List<ResolvedComponentInfo> workResolvedComponentInfos = createResolvedComponentsForTest(4); + setupResolverControllers(personalResolvedComponentInfos, + new ArrayList<>(workResolvedComponentInfos)); + Intent sendIntent = createSendImageIntent(); + markWorkProfileUserAvailable(); + + final ResolverWrapperActivity activity = mActivityRule.launchActivity(sendIntent); + waitForIdle(); + + assertThat(activity.getCurrentUserHandle().getIdentifier(), is(0)); + // The work list adapter must be populated in advance before tapping the other tab + assertThat(activity.getWorkListAdapter().getCount(), is(4)); + } + + @Test + public void testWorkTab_workTabUsesExpectedAdapter() { + List<ResolvedComponentInfo> personalResolvedComponentInfos = + createResolvedComponentsForTestWithOtherProfile(3, /* userId */ 10); + List<ResolvedComponentInfo> workResolvedComponentInfos = createResolvedComponentsForTest(4); + setupResolverControllers(personalResolvedComponentInfos, workResolvedComponentInfos); + Intent sendIntent = createSendImageIntent(); + markWorkProfileUserAvailable(); + + final ResolverWrapperActivity activity = mActivityRule.launchActivity(sendIntent); + waitForIdle(); + onView(withText(R.string.resolver_work_tab)).perform(click()); + + assertThat(activity.getCurrentUserHandle().getIdentifier(), is(10)); + assertThat(activity.getWorkListAdapter().getCount(), is(4)); + } + + @Test + public void testWorkTab_personalTabUsesExpectedAdapter() { + List<ResolvedComponentInfo> personalResolvedComponentInfos = + createResolvedComponentsForTestWithOtherProfile(3); + List<ResolvedComponentInfo> workResolvedComponentInfos = createResolvedComponentsForTest(4); + setupResolverControllers(personalResolvedComponentInfos, workResolvedComponentInfos); + Intent sendIntent = createSendImageIntent(); + markWorkProfileUserAvailable(); + + final ResolverWrapperActivity activity = mActivityRule.launchActivity(sendIntent); + waitForIdle(); + onView(withText(R.string.resolver_work_tab)).perform(click()); + + assertThat(activity.getCurrentUserHandle().getIdentifier(), is(10)); + assertThat(activity.getPersonalListAdapter().getCount(), is(2)); + } + + @Test + public void testWorkTab_workProfileHasExpectedNumberOfTargets() throws InterruptedException { + markWorkProfileUserAvailable(); + List<ResolvedComponentInfo> personalResolvedComponentInfos = + createResolvedComponentsForTestWithOtherProfile(3, /* userId */ 10); + List<ResolvedComponentInfo> workResolvedComponentInfos = createResolvedComponentsForTest(4); + setupResolverControllers(personalResolvedComponentInfos, workResolvedComponentInfos); + Intent sendIntent = createSendImageIntent(); + + final ResolverWrapperActivity activity = mActivityRule.launchActivity(sendIntent); + waitForIdle(); + + onView(withText(R.string.resolver_work_tab)) + .perform(click()); + waitForIdle(); + assertThat(activity.getWorkListAdapter().getCount(), is(4)); + } + + @Test + public void testWorkTab_selectingWorkTabAppOpensAppInWorkProfile() throws InterruptedException { + markWorkProfileUserAvailable(); + List<ResolvedComponentInfo> personalResolvedComponentInfos = + createResolvedComponentsForTestWithOtherProfile(3, /* userId */ 10); + List<ResolvedComponentInfo> workResolvedComponentInfos = createResolvedComponentsForTest(4); + setupResolverControllers(personalResolvedComponentInfos, workResolvedComponentInfos); + Intent sendIntent = createSendImageIntent(); + ResolveInfo[] chosen = new ResolveInfo[1]; + sOverrides.onSafelyStartCallback = targetInfo -> { + chosen[0] = targetInfo.getResolveInfo(); + return true; + }; + + mActivityRule.launchActivity(sendIntent); + waitForIdle(); + onView(withText(R.string.resolver_work_tab)) + .perform(click()); + waitForIdle(); + onView(first(allOf(withText(workResolvedComponentInfos.get(0) + .getResolveInfoAt(0).activityInfo.applicationInfo.name), isCompletelyDisplayed()))) + .perform(click()); + onView(withId(R.id.button_once)) + .perform(click()); + + waitForIdle(); + assertThat(chosen[0], is(workResolvedComponentInfos.get(0).getResolveInfoAt(0))); + } + + @Test + public void testWorkTab_noPersonalApps_workTabHasExpectedNumberOfTargets() + throws InterruptedException { + markWorkProfileUserAvailable(); + List<ResolvedComponentInfo> personalResolvedComponentInfos = + createResolvedComponentsForTestWithOtherProfile(1); + List<ResolvedComponentInfo> workResolvedComponentInfos = createResolvedComponentsForTest(4); + setupResolverControllers(personalResolvedComponentInfos, workResolvedComponentInfos); + Intent sendIntent = createSendImageIntent(); + + final ResolverWrapperActivity activity = mActivityRule.launchActivity(sendIntent); + waitForIdle(); + onView(withText(R.string.resolver_work_tab)) + .perform(click()); + + waitForIdle(); + assertThat(activity.getWorkListAdapter().getCount(), is(4)); + } + + @Test + public void testWorkTab_headerIsVisibleInPersonalTab() { + markWorkProfileUserAvailable(); + List<ResolvedComponentInfo> personalResolvedComponentInfos = + createResolvedComponentsForTestWithOtherProfile(1); + List<ResolvedComponentInfo> workResolvedComponentInfos = createResolvedComponentsForTest(4); + setupResolverControllers(personalResolvedComponentInfos, workResolvedComponentInfos); + Intent sendIntent = createOpenWebsiteIntent(); + + final ResolverWrapperActivity activity = mActivityRule.launchActivity(sendIntent); + waitForIdle(); + TextView headerText = activity.findViewById(R.id.title); + String initialText = headerText.getText().toString(); + assertFalse(initialText.isEmpty(), "Header text is empty."); + assertThat(headerText.getVisibility(), is(View.VISIBLE)); + } + + @Test + public void testWorkTab_switchTabs_headerStaysSame() { + markWorkProfileUserAvailable(); + List<ResolvedComponentInfo> personalResolvedComponentInfos = + createResolvedComponentsForTestWithOtherProfile(1); + List<ResolvedComponentInfo> workResolvedComponentInfos = createResolvedComponentsForTest(4); + setupResolverControllers(personalResolvedComponentInfos, workResolvedComponentInfos); + Intent sendIntent = createOpenWebsiteIntent(); + + final ResolverWrapperActivity activity = mActivityRule.launchActivity(sendIntent); + waitForIdle(); + TextView headerText = activity.findViewById(R.id.title); + String initialText = headerText.getText().toString(); + onView(withText(R.string.resolver_work_tab)) + .perform(click()); + + waitForIdle(); + String currentText = headerText.getText().toString(); + assertThat(headerText.getVisibility(), is(View.VISIBLE)); + assertThat(String.format("Header text is not the same when switching tabs, personal profile" + + " header was %s but work profile header is %s", initialText, currentText), + TextUtils.equals(initialText, currentText)); + } + + @Test + public void testWorkTab_noPersonalApps_canStartWorkApps() + throws InterruptedException { + markWorkProfileUserAvailable(); + List<ResolvedComponentInfo> personalResolvedComponentInfos = + createResolvedComponentsForTestWithOtherProfile(3, /* userId= */ 10); + List<ResolvedComponentInfo> workResolvedComponentInfos = createResolvedComponentsForTest(4); + setupResolverControllers(personalResolvedComponentInfos, workResolvedComponentInfos); + Intent sendIntent = createSendImageIntent(); + ResolveInfo[] chosen = new ResolveInfo[1]; + sOverrides.onSafelyStartCallback = targetInfo -> { + chosen[0] = targetInfo.getResolveInfo(); + return true; + }; + + mActivityRule.launchActivity(sendIntent); + waitForIdle(); + onView(withText(R.string.resolver_work_tab)) + .perform(click()); + waitForIdle(); + onView(first(allOf( + withText(workResolvedComponentInfos.get(0) + .getResolveInfoAt(0).activityInfo.applicationInfo.name), + isDisplayed()))) + .perform(click()); + onView(withId(R.id.button_once)) + .perform(click()); + waitForIdle(); + + assertThat(chosen[0], is(workResolvedComponentInfos.get(0).getResolveInfoAt(0))); + } + + @Test + public void testWorkTab_crossProfileIntentsDisabled_personalToWork_emptyStateShown() { + markWorkProfileUserAvailable(); + int workProfileTargets = 4; + List<ResolvedComponentInfo> personalResolvedComponentInfos = + createResolvedComponentsForTestWithOtherProfile(3, /* userId */ 10); + List<ResolvedComponentInfo> workResolvedComponentInfos = + createResolvedComponentsForTest(workProfileTargets); + sOverrides.hasCrossProfileIntents = false; + setupResolverControllers(personalResolvedComponentInfos, workResolvedComponentInfos); + Intent sendIntent = createSendImageIntent(); + sendIntent.setType("TestType"); + + mActivityRule.launchActivity(sendIntent); + waitForIdle(); + onView(withText(R.string.resolver_work_tab)).perform(click()); + waitForIdle(); + onView(withId(R.id.contentPanel)) + .perform(swipeUp()); + + onView(withText(R.string.resolver_cross_profile_blocked)) + .check(matches(isDisplayed())); + } + + @Test + public void testWorkTab_workProfileDisabled_emptyStateShown() { + markWorkProfileUserAvailable(); + int workProfileTargets = 4; + List<ResolvedComponentInfo> personalResolvedComponentInfos = + createResolvedComponentsForTestWithOtherProfile(3, /* userId */ 10); + List<ResolvedComponentInfo> workResolvedComponentInfos = + createResolvedComponentsForTest(workProfileTargets); + sOverrides.isQuietModeEnabled = true; + setupResolverControllers(personalResolvedComponentInfos, workResolvedComponentInfos); + Intent sendIntent = createSendImageIntent(); + sendIntent.setType("TestType"); + + mActivityRule.launchActivity(sendIntent); + waitForIdle(); + onView(withId(R.id.contentPanel)) + .perform(swipeUp()); + onView(withText(R.string.resolver_work_tab)).perform(click()); + waitForIdle(); + + onView(withText(R.string.resolver_turn_on_work_apps)) + .check(matches(isDisplayed())); + } + + @Test + public void testWorkTab_noWorkAppsAvailable_emptyStateShown() { + markWorkProfileUserAvailable(); + List<ResolvedComponentInfo> personalResolvedComponentInfos = + createResolvedComponentsForTest(3); + List<ResolvedComponentInfo> workResolvedComponentInfos = + createResolvedComponentsForTest(0); + setupResolverControllers(personalResolvedComponentInfos, workResolvedComponentInfos); + Intent sendIntent = createSendImageIntent(); + sendIntent.setType("TestType"); + + mActivityRule.launchActivity(sendIntent); + waitForIdle(); + onView(withId(R.id.contentPanel)) + .perform(swipeUp()); + onView(withText(R.string.resolver_work_tab)).perform(click()); + waitForIdle(); + + onView(withText(R.string.resolver_no_work_apps_available)) + .check(matches(isDisplayed())); + } + + @Test + public void testWorkTab_xProfileOff_noAppsAvailable_workOff_xProfileOffEmptyStateShown() { + markWorkProfileUserAvailable(); + List<ResolvedComponentInfo> personalResolvedComponentInfos = + createResolvedComponentsForTest(3); + List<ResolvedComponentInfo> workResolvedComponentInfos = + createResolvedComponentsForTest(0); + setupResolverControllers(personalResolvedComponentInfos, workResolvedComponentInfos); + Intent sendIntent = createSendImageIntent(); + sendIntent.setType("TestType"); + sOverrides.isQuietModeEnabled = true; + sOverrides.hasCrossProfileIntents = false; + + mActivityRule.launchActivity(sendIntent); + waitForIdle(); + onView(withId(R.id.contentPanel)) + .perform(swipeUp()); + onView(withText(R.string.resolver_work_tab)).perform(click()); + waitForIdle(); + + onView(withText(R.string.resolver_cross_profile_blocked)) + .check(matches(isDisplayed())); + } + + @Test + public void testMiniResolver() { + markWorkProfileUserAvailable(); + List<ResolvedComponentInfo> personalResolvedComponentInfos = + createResolvedComponentsForTest(1); + List<ResolvedComponentInfo> workResolvedComponentInfos = + createResolvedComponentsForTest(1); + // Personal profile only has a browser + personalResolvedComponentInfos.get(0).getResolveInfoAt(0).handleAllWebDataURI = true; + setupResolverControllers(personalResolvedComponentInfos, workResolvedComponentInfos); + Intent sendIntent = createSendImageIntent(); + sendIntent.setType("TestType"); + + mActivityRule.launchActivity(sendIntent); + waitForIdle(); + onView(withId(R.id.open_cross_profile)).check(matches(isDisplayed())); + } + + @Test + public void testMiniResolver_noCurrentProfileTarget() { + markWorkProfileUserAvailable(); + List<ResolvedComponentInfo> personalResolvedComponentInfos = + createResolvedComponentsForTest(0); + List<ResolvedComponentInfo> workResolvedComponentInfos = + createResolvedComponentsForTest(1); + setupResolverControllers(personalResolvedComponentInfos, workResolvedComponentInfos); + Intent sendIntent = createSendImageIntent(); + sendIntent.setType("TestType"); + + mActivityRule.launchActivity(sendIntent); + waitForIdle(); + + // Need to ensure mini resolver doesn't trigger here. + assertNotMiniResolver(); + } + + private void assertNotMiniResolver() { + try { + onView(withId(R.id.open_cross_profile)).check(matches(isDisplayed())); + } catch (NoMatchingViewException e) { + return; + } + fail("Mini resolver present but shouldn't be"); + } + + @Test + public void testWorkTab_noAppsAvailable_workOff_noAppsAvailableEmptyStateShown() { + markWorkProfileUserAvailable(); + List<ResolvedComponentInfo> personalResolvedComponentInfos = + createResolvedComponentsForTest(3); + List<ResolvedComponentInfo> workResolvedComponentInfos = + createResolvedComponentsForTest(0); + setupResolverControllers(personalResolvedComponentInfos, workResolvedComponentInfos); + Intent sendIntent = createSendImageIntent(); + sendIntent.setType("TestType"); + sOverrides.isQuietModeEnabled = true; + + mActivityRule.launchActivity(sendIntent); + waitForIdle(); + onView(withId(R.id.contentPanel)) + .perform(swipeUp()); + onView(withText(R.string.resolver_work_tab)).perform(click()); + waitForIdle(); + + onView(withText(R.string.resolver_no_work_apps_available)) + .check(matches(isDisplayed())); + } + + @Test + public void testWorkTab_onePersonalTarget_emptyStateOnWorkTarget_autolaunch() { + markWorkProfileUserAvailable(); + int workProfileTargets = 4; + List<ResolvedComponentInfo> personalResolvedComponentInfos = + createResolvedComponentsForTestWithOtherProfile(2, /* userId */ 10); + List<ResolvedComponentInfo> workResolvedComponentInfos = + createResolvedComponentsForTest(workProfileTargets); + sOverrides.hasCrossProfileIntents = false; + setupResolverControllers(personalResolvedComponentInfos, workResolvedComponentInfos); + Intent sendIntent = createSendImageIntent(); + sendIntent.setType("TestType"); + ResolveInfo[] chosen = new ResolveInfo[1]; + sOverrides.onSafelyStartCallback = targetInfo -> { + chosen[0] = targetInfo.getResolveInfo(); + return true; + }; + + mActivityRule.launchActivity(sendIntent); + waitForIdle(); + + assertThat(chosen[0], is(personalResolvedComponentInfos.get(1).getResolveInfoAt(0))); + } + + @Test + public void testLayoutWithDefault_withWorkTab_neverShown() throws RemoteException { + markWorkProfileUserAvailable(); + + // In this case we prefer the other profile and don't display anything about the last + // chosen activity. + Intent sendIntent = createSendImageIntent(); + List<ResolvedComponentInfo> resolvedComponentInfos = + createResolvedComponentsForTest(2); + + when(sOverrides.resolverListController.getResolversForIntent(Mockito.anyBoolean(), + Mockito.anyBoolean(), + Mockito.anyBoolean(), + Mockito.isA(List.class))).thenReturn(resolvedComponentInfos); + when(sOverrides.resolverListController.getLastChosen()) + .thenReturn(resolvedComponentInfos.get(1).getResolveInfoAt(0)); + + final ResolverWrapperActivity activity = mActivityRule.launchActivity(sendIntent); + Espresso.registerIdlingResources(activity.getAdapter().getLabelIdlingResource()); + waitForIdle(); + + // The other entry is filtered to the last used slot + assertThat(activity.getAdapter().hasFilteredItem(), is(false)); + assertThat(activity.getAdapter().getCount(), is(2)); + assertThat(activity.getAdapter().getPlaceholderCount(), is(2)); + } + + private Intent createSendImageIntent() { + Intent sendIntent = new Intent(); + sendIntent.setAction(Intent.ACTION_SEND); + sendIntent.putExtra(Intent.EXTRA_TEXT, "testing intent sending"); + sendIntent.setType("image/jpeg"); + return sendIntent; + } + + private Intent createOpenWebsiteIntent() { + Intent sendIntent = new Intent(); + sendIntent.setAction(Intent.ACTION_VIEW); + sendIntent.setData(Uri.parse("https://google.com")); + return sendIntent; + } + + private List<ResolvedComponentInfo> createResolvedComponentsForTest(int numberOfResults) { + List<ResolvedComponentInfo> infoList = new ArrayList<>(numberOfResults); + for (int i = 0; i < numberOfResults; i++) { + infoList.add(ResolverDataProvider.createResolvedComponentInfo(i)); + } + return infoList; + } + + private List<ResolvedComponentInfo> createResolvedComponentsForTestWithOtherProfile( + int numberOfResults) { + List<ResolvedComponentInfo> infoList = new ArrayList<>(numberOfResults); + for (int i = 0; i < numberOfResults; i++) { + if (i == 0) { + infoList.add(ResolverDataProvider.createResolvedComponentInfoWithOtherId(i)); + } else { + infoList.add(ResolverDataProvider.createResolvedComponentInfo(i)); + } + } + return infoList; + } + + private List<ResolvedComponentInfo> createResolvedComponentsForTestWithOtherProfile( + int numberOfResults, int userId) { + List<ResolvedComponentInfo> infoList = new ArrayList<>(numberOfResults); + for (int i = 0; i < numberOfResults; i++) { + if (i == 0) { + infoList.add( + ResolverDataProvider.createResolvedComponentInfoWithOtherId(i, userId)); + } else { + infoList.add(ResolverDataProvider.createResolvedComponentInfo(i)); + } + } + return infoList; + } + + private void waitForIdle() { + InstrumentationRegistry.getInstrumentation().waitForIdleSync(); + } + + private void markWorkProfileUserAvailable() { + ResolverWrapperActivity.sOverrides.workProfileUserHandle = UserHandle.of(10); + } + + private void setupResolverControllers( + List<ResolvedComponentInfo> personalResolvedComponentInfos, + List<ResolvedComponentInfo> workResolvedComponentInfos) { + when(sOverrides.resolverListController.getResolversForIntent(Mockito.anyBoolean(), + Mockito.anyBoolean(), + Mockito.anyBoolean(), + Mockito.isA(List.class))) + .thenReturn(new ArrayList<>(personalResolvedComponentInfos)); + when(sOverrides.workResolverListController.getResolversForIntent(Mockito.anyBoolean(), + Mockito.anyBoolean(), + Mockito.anyBoolean(), + Mockito.isA(List.class))).thenReturn(workResolvedComponentInfos); + when(sOverrides.workResolverListController.getResolversForIntentAsUser(Mockito.anyBoolean(), + Mockito.anyBoolean(), + Mockito.anyBoolean(), + Mockito.isA(List.class), + eq(UserHandle.SYSTEM))) + .thenReturn(new ArrayList<>(personalResolvedComponentInfos)); + } +} diff --git a/java/tests/src/com/android/intentresolver/ResolverDataProvider.java b/java/tests/src/com/android/intentresolver/ResolverDataProvider.java index 33e7123f..01d07639 100644 --- a/java/tests/src/com/android/intentresolver/ResolverDataProvider.java +++ b/java/tests/src/com/android/intentresolver/ResolverDataProvider.java @@ -32,7 +32,7 @@ import android.test.mock.MockResources; /** * Utility class used by resolver tests to create mock data */ -class ResolverDataProvider { +public class ResolverDataProvider { static private int USER_SOMEONE_ELSE = 10; @@ -52,12 +52,12 @@ class ResolverDataProvider { createResolverIntent(i), createResolveInfo(i, userId)); } - static ComponentName createComponentName(int i) { + public static ComponentName createComponentName(int i) { final String name = "component" + i; return new ComponentName("foo.bar." + name, name); } - static ResolveInfo createResolveInfo(int i, int userId) { + public static ResolveInfo createResolveInfo(int i, int userId) { final ResolveInfo resolveInfo = new ResolveInfo(); resolveInfo.activityInfo = createActivityInfo(i); resolveInfo.targetUserId = userId; diff --git a/java/tests/src/com/android/intentresolver/ResolverWrapperActivity.java b/java/tests/src/com/android/intentresolver/ResolverWrapperActivity.java new file mode 100644 index 00000000..239bffe0 --- /dev/null +++ b/java/tests/src/com/android/intentresolver/ResolverWrapperActivity.java @@ -0,0 +1,227 @@ +/* + * Copyright (C) 2017 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 org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import android.app.usage.UsageStatsManager; +import android.content.Context; +import android.content.Intent; +import android.content.pm.PackageManager; +import android.content.pm.ResolveInfo; +import android.os.Bundle; +import android.os.UserHandle; + +import com.android.intentresolver.AbstractMultiProfilePagerAdapter.CrossProfileIntentsChecker; +import com.android.intentresolver.AbstractMultiProfilePagerAdapter.MyUserIdProvider; +import com.android.intentresolver.AbstractMultiProfilePagerAdapter.QuietModeManager; +import com.android.intentresolver.chooser.TargetInfo; + +import java.util.List; +import java.util.function.Function; + +/* + * Simple wrapper around chooser activity to be able to initiate it under test + */ +public class ResolverWrapperActivity extends ResolverActivity { + static final OverrideData sOverrides = new OverrideData(); + private UsageStatsManager mUsm; + + public ResolverWrapperActivity() { + super(/* isIntentPicker= */ true); + } + + // ResolverActivity inspects the launched-from UID at onCreate and needs to see some + // non-negative value in the test. + @Override + public int getLaunchedFromUid() { + return 1234; + } + + @Override + public ResolverListAdapter createResolverListAdapter(Context context, + List<Intent> payloadIntents, Intent[] initialIntents, List<ResolveInfo> rList, + boolean filterLastUsed, UserHandle userHandle) { + return new ResolverWrapperAdapter( + context, + payloadIntents, + initialIntents, + rList, + filterLastUsed, + createListController(userHandle), + userHandle, + payloadIntents.get(0), // TODO: extract upstream + this); + } + + @Override + protected MyUserIdProvider createMyUserIdProvider() { + if (sOverrides.mMyUserIdProvider != null) { + return sOverrides.mMyUserIdProvider; + } + return super.createMyUserIdProvider(); + } + + @Override + protected CrossProfileIntentsChecker createCrossProfileIntentsChecker() { + if (sOverrides.mCrossProfileIntentsChecker != null) { + return sOverrides.mCrossProfileIntentsChecker; + } + return super.createCrossProfileIntentsChecker(); + } + + @Override + protected QuietModeManager createQuietModeManager() { + if (sOverrides.mQuietModeManager != null) { + return sOverrides.mQuietModeManager; + } + return super.createQuietModeManager(); + } + + ResolverWrapperAdapter getAdapter() { + return (ResolverWrapperAdapter) mMultiProfilePagerAdapter.getActiveListAdapter(); + } + + ResolverListAdapter getPersonalListAdapter() { + return ((ResolverListAdapter) mMultiProfilePagerAdapter.getAdapterForIndex(0)); + } + + ResolverListAdapter getWorkListAdapter() { + if (mMultiProfilePagerAdapter.getInactiveListAdapter() == null) { + return null; + } + return ((ResolverListAdapter) mMultiProfilePagerAdapter.getAdapterForIndex(1)); + } + + @Override + public boolean isVoiceInteraction() { + if (sOverrides.isVoiceInteraction != null) { + return sOverrides.isVoiceInteraction; + } + return super.isVoiceInteraction(); + } + + @Override + public void safelyStartActivity(TargetInfo cti) { + if (sOverrides.onSafelyStartCallback != null && + sOverrides.onSafelyStartCallback.apply(cti)) { + return; + } + super.safelyStartActivity(cti); + } + + @Override + protected ResolverListController createListController(UserHandle userHandle) { + if (userHandle == UserHandle.SYSTEM) { + when(sOverrides.resolverListController.getUserHandle()).thenReturn(UserHandle.SYSTEM); + return sOverrides.resolverListController; + } + when(sOverrides.workResolverListController.getUserHandle()).thenReturn(userHandle); + return sOverrides.workResolverListController; + } + + @Override + public PackageManager getPackageManager() { + if (sOverrides.createPackageManager != null) { + return sOverrides.createPackageManager.apply(super.getPackageManager()); + } + return super.getPackageManager(); + } + + protected UserHandle getCurrentUserHandle() { + return mMultiProfilePagerAdapter.getCurrentUserHandle(); + } + + @Override + protected UserHandle getWorkProfileUserHandle() { + return sOverrides.workProfileUserHandle; + } + + @Override + public void startActivityAsUser(Intent intent, Bundle options, UserHandle user) { + super.startActivityAsUser(intent, options, user); + } + + /** + * We cannot directly mock the activity created since instrumentation creates it. + * <p> + * Instead, we use static instances of this object to modify behavior. + */ + static class OverrideData { + @SuppressWarnings("Since15") + public Function<PackageManager, PackageManager> createPackageManager; + public Function<TargetInfo, Boolean> onSafelyStartCallback; + public ResolverListController resolverListController; + public ResolverListController workResolverListController; + public Boolean isVoiceInteraction; + public UserHandle workProfileUserHandle; + public Integer myUserId; + public boolean hasCrossProfileIntents; + public boolean isQuietModeEnabled; + public QuietModeManager mQuietModeManager; + public MyUserIdProvider mMyUserIdProvider; + public CrossProfileIntentsChecker mCrossProfileIntentsChecker; + + public void reset() { + onSafelyStartCallback = null; + isVoiceInteraction = null; + createPackageManager = null; + resolverListController = mock(ResolverListController.class); + workResolverListController = mock(ResolverListController.class); + workProfileUserHandle = null; + myUserId = null; + hasCrossProfileIntents = true; + isQuietModeEnabled = false; + + mQuietModeManager = new QuietModeManager() { + @Override + public boolean isQuietModeEnabled(UserHandle workProfileUserHandle) { + return isQuietModeEnabled; + } + + @Override + public void requestQuietModeEnabled(boolean enabled, + UserHandle workProfileUserHandle) { + isQuietModeEnabled = enabled; + } + + @Override + public void markWorkProfileEnabledBroadcastReceived() { + } + + @Override + public boolean isWaitingToEnableWorkProfile() { + return false; + } + }; + + mMyUserIdProvider = new MyUserIdProvider() { + @Override + public int getMyUserId() { + return myUserId != null ? myUserId : UserHandle.myUserId(); + } + }; + + mCrossProfileIntentsChecker = mock(CrossProfileIntentsChecker.class); + when(mCrossProfileIntentsChecker.hasCrossProfileIntents(any(), anyInt(), anyInt())) + .thenAnswer(invocation -> hasCrossProfileIntents); + } + } +} diff --git a/java/tests/src/com/android/intentresolver/ResolverWrapperAdapter.java b/java/tests/src/com/android/intentresolver/ResolverWrapperAdapter.java new file mode 100644 index 00000000..a53b41d1 --- /dev/null +++ b/java/tests/src/com/android/intentresolver/ResolverWrapperAdapter.java @@ -0,0 +1,84 @@ +/* + * Copyright (C) 2019 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.intentresolver; + +import android.content.Context; +import android.content.Intent; +import android.content.pm.ResolveInfo; +import android.os.UserHandle; + +import androidx.test.espresso.idling.CountingIdlingResource; + +import com.android.intentresolver.chooser.DisplayResolveInfo; + +import java.util.List; + +public class ResolverWrapperAdapter extends ResolverListAdapter { + + private CountingIdlingResource mLabelIdlingResource = + new CountingIdlingResource("LoadLabelTask"); + + public ResolverWrapperAdapter( + Context context, + List<Intent> payloadIntents, + Intent[] initialIntents, + List<ResolveInfo> rList, + boolean filterLastUsed, + ResolverListController resolverListController, + UserHandle userHandle, + Intent targetIntent, + ResolverListCommunicator resolverListCommunicator) { + super( + context, + payloadIntents, + initialIntents, + rList, + filterLastUsed, + resolverListController, + userHandle, + targetIntent, + resolverListCommunicator, + false); + } + + public CountingIdlingResource getLabelIdlingResource() { + return mLabelIdlingResource; + } + + @Override + protected LoadLabelTask createLoadLabelTask(DisplayResolveInfo info) { + return new LoadLabelWrapperTask(info); + } + + class LoadLabelWrapperTask extends LoadLabelTask { + + protected LoadLabelWrapperTask(DisplayResolveInfo dri) { + super(dri); + } + + @Override + protected void onPreExecute() { + mLabelIdlingResource.increment(); + } + + @Override + protected void onPostExecute(CharSequence[] result) { + super.onPostExecute(result); + mLabelIdlingResource.decrement(); + } + } +} diff --git a/java/tests/src/com/android/intentresolver/ShortcutSelectionLogicTest.kt b/java/tests/src/com/android/intentresolver/ShortcutSelectionLogicTest.kt new file mode 100644 index 00000000..2c56e613 --- /dev/null +++ b/java/tests/src/com/android/intentresolver/ShortcutSelectionLogicTest.kt @@ -0,0 +1,289 @@ +/* + * 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.content.ComponentName +import android.content.Context +import android.content.pm.ShortcutInfo +import android.service.chooser.ChooserTarget +import com.android.intentresolver.chooser.TargetInfo +import androidx.test.filters.SmallTest +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +private const val PACKAGE_A = "package.a" +private const val PACKAGE_B = "package.b" +private const val CLASS_NAME = "./MainActivity" + +@SmallTest +class ShortcutSelectionLogicTest { + private val packageTargets = HashMap<String, Array<ChooserTarget>>().apply { + arrayOf(PACKAGE_A, PACKAGE_B).forEach { pkg -> + // shortcuts in reverse priority order + val targets = Array(3) { i -> + createChooserTarget( + "Shortcut $i", + (i + 1).toFloat() / 10f, + ComponentName(pkg, CLASS_NAME), + pkg.shortcutId(i), + ) + } + this[pkg] = targets + } + } + + private operator fun Map<String, Array<ChooserTarget>>.get(pkg: String, idx: Int) = + this[pkg]?.get(idx) ?: error("missing package $pkg") + + @Test + fun testAddShortcuts_no_limits() { + val serviceResults = ArrayList<TargetInfo>() + val sc1 = packageTargets[PACKAGE_A, 0] + val sc2 = packageTargets[PACKAGE_A, 1] + val testSubject = ShortcutSelectionLogic( + /* maxShortcutTargetsPerApp = */ 1, + /* applySharingAppLimits = */ false + ) + + val isUpdated = testSubject.addServiceResults( + /* origTarget = */ mock(), + /* origTargetScore = */ 0.1f, + /* targets = */ listOf(sc1, sc2), + /* isShortcutResult = */ true, + /* directShareToShortcutInfos = */ emptyMap(), + /* directShareToAppTargets = */ emptyMap(), + /* userContext = */ mock(), + /* targetIntent = */ mock(), + /* refererFillInIntent = */ mock(), + /* maxRankedTargets = */ 4, + /* serviceTargets = */ serviceResults + ) + + assertTrue("Updates are expected", isUpdated) + assertShortcutsInOrder( + listOf(sc2, sc1), + serviceResults, + "Two shortcuts are expected as we do not apply per-app shortcut limit" + ) + } + + @Test + fun testAddShortcuts_same_package_with_per_package_limit() { + val serviceResults = ArrayList<TargetInfo>() + val sc1 = packageTargets[PACKAGE_A, 0] + val sc2 = packageTargets[PACKAGE_A, 1] + val testSubject = ShortcutSelectionLogic( + /* maxShortcutTargetsPerApp = */ 1, + /* applySharingAppLimits = */ true + ) + + val isUpdated = testSubject.addServiceResults( + /* origTarget = */ mock(), + /* origTargetScore = */ 0.1f, + /* targets = */ listOf(sc1, sc2), + /* isShortcutResult = */ true, + /* directShareToShortcutInfos = */ emptyMap(), + /* directShareToAppTargets = */ emptyMap(), + /* userContext = */ mock(), + /* targetIntent = */ mock(), + /* refererFillInIntent = */ mock(), + /* maxRankedTargets = */ 4, + /* serviceTargets = */ serviceResults + ) + + assertTrue("Updates are expected", isUpdated) + assertShortcutsInOrder( + listOf(sc2), + serviceResults, + "One shortcut is expected as we apply per-app shortcut limit" + ) + } + + @Test + fun testAddShortcuts_same_package_no_per_app_limit_with_target_limit() { + val serviceResults = ArrayList<TargetInfo>() + val sc1 = packageTargets[PACKAGE_A, 0] + val sc2 = packageTargets[PACKAGE_A, 1] + val testSubject = ShortcutSelectionLogic( + /* maxShortcutTargetsPerApp = */ 1, + /* applySharingAppLimits = */ false + ) + + val isUpdated = testSubject.addServiceResults( + /* origTarget = */ mock(), + /* origTargetScore = */ 0.1f, + /* targets = */ listOf(sc1, sc2), + /* isShortcutResult = */ true, + /* directShareToShortcutInfos = */ emptyMap(), + /* directShareToAppTargets = */ emptyMap(), + /* userContext = */ mock(), + /* targetIntent = */ mock(), + /* refererFillInIntent = */ mock(), + /* maxRankedTargets = */ 1, + /* serviceTargets = */ serviceResults + ) + + assertTrue("Updates are expected", isUpdated) + assertShortcutsInOrder( + listOf(sc2), + serviceResults, + "One shortcut is expected as we apply overall shortcut limit" + ) + } + + @Test + fun testAddShortcuts_different_packages_with_per_package_limit() { + val serviceResults = ArrayList<TargetInfo>() + val pkgAsc1 = packageTargets[PACKAGE_A, 0] + val pkgAsc2 = packageTargets[PACKAGE_A, 1] + val pkgBsc1 = packageTargets[PACKAGE_B, 0] + val pkgBsc2 = packageTargets[PACKAGE_B, 1] + val testSubject = ShortcutSelectionLogic( + /* maxShortcutTargetsPerApp = */ 1, + /* applySharingAppLimits = */ true + ) + + testSubject.addServiceResults( + /* origTarget = */ mock(), + /* origTargetScore = */ 0.1f, + /* targets = */ listOf(pkgAsc1, pkgAsc2), + /* isShortcutResult = */ true, + /* directShareToShortcutInfos = */ emptyMap(), + /* directShareToAppTargets = */ emptyMap(), + /* userContext = */ mock(), + /* targetIntent = */ mock(), + /* refererFillInIntent = */ mock(), + /* maxRankedTargets = */ 4, + /* serviceTargets = */ serviceResults + ) + testSubject.addServiceResults( + /* origTarget = */ mock(), + /* origTargetScore = */ 0.2f, + /* targets = */ listOf(pkgBsc1, pkgBsc2), + /* isShortcutResult = */ true, + /* directShareToShortcutInfos = */ emptyMap(), + /* directShareToAppTargets = */ emptyMap(), + /* userContext = */ mock(), + /* targetIntent = */ mock(), + /* refererFillInIntent = */ mock(), + /* maxRankedTargets = */ 4, + /* serviceTargets = */ serviceResults + ) + + assertShortcutsInOrder( + listOf(pkgBsc2, pkgAsc2), + serviceResults, + "Two shortcuts are expected as we apply per-app shortcut limit" + ) + } + + @Test + fun testAddShortcuts_pinned_shortcut() { + val serviceResults = ArrayList<TargetInfo>() + val sc1 = packageTargets[PACKAGE_A, 0] + val sc2 = packageTargets[PACKAGE_A, 1] + val testSubject = ShortcutSelectionLogic( + /* maxShortcutTargetsPerApp = */ 1, + /* applySharingAppLimits = */ false + ) + + val isUpdated = testSubject.addServiceResults( + /* origTarget = */ mock(), + /* origTargetScore = */ 0.1f, + /* targets = */ listOf(sc1, sc2), + /* isShortcutResult = */ true, + /* directShareToShortcutInfos = */ mapOf( + sc1 to createShortcutInfo( + PACKAGE_A.shortcutId(1), + sc1.componentName, 1).apply { + addFlags(ShortcutInfo.FLAG_PINNED) + } + ), + /* directShareToAppTargets = */ emptyMap(), + /* userContext = */ mock(), + /* targetIntent = */ mock(), + /* refererFillInIntent = */ mock(), + /* maxRankedTargets = */ 4, + /* serviceTargets = */ serviceResults + ) + + assertTrue("Updates are expected", isUpdated) + assertShortcutsInOrder( + listOf(sc1, sc2), + serviceResults, + "Two shortcuts are expected as we do not apply per-app shortcut limit" + ) + } + + @Test + fun test_available_caller_shortcuts_count_is_limited() { + val serviceResults = ArrayList<TargetInfo>() + val sc1 = packageTargets[PACKAGE_A, 0] + val sc2 = packageTargets[PACKAGE_A, 1] + val sc3 = packageTargets[PACKAGE_A, 2] + val testSubject = ShortcutSelectionLogic( + /* maxShortcutTargetsPerApp = */ 1, + /* applySharingAppLimits = */ true + ) + val context = mock<Context> { + whenever(packageManager).thenReturn(mock()) + } + + testSubject.addServiceResults( + /* origTarget = */ null, + /* origTargetScore = */ 0f, + /* targets = */ listOf(sc1, sc2, sc3), + /* isShortcutResult = */ false, + /* directShareToShortcutInfos = */ emptyMap(), + /* directShareToAppTargets = */ emptyMap(), + /* userContext = */ context, + /* targetIntent = */ mock(), + /* refererFillInIntent = */ mock(), + /* maxRankedTargets = */ 4, + /* serviceTargets = */ serviceResults + ) + + assertShortcutsInOrder( + listOf(sc3, sc2), + serviceResults, + "At most two caller-provided shortcuts are allowed" + ) + } + + // TODO: consider renaming. Not all `ChooserTarget`s are "shortcuts" and many of our test cases + // add results with `isShortcutResult = false` and `directShareToShortcutInfos = emptyMap()`. + private fun assertShortcutsInOrder( + expected: List<ChooserTarget>, actual: List<TargetInfo>, msg: String? = "" + ) { + assertEquals(msg, expected.size, actual.size) + for (i in expected.indices) { + assertEquals( + "Unexpected item at position $i", + expected[i].componentName, + actual[i].chooserTargetComponentName + ) + assertEquals( + "Unexpected item at position $i", + expected[i].title, + actual[i].displayLabel + ) + } + } + + private fun String.shortcutId(id: Int) = "$this.$id" +} diff --git a/java/tests/src/com/android/intentresolver/TestApplication.kt b/java/tests/src/com/android/intentresolver/TestApplication.kt new file mode 100644 index 00000000..849cfbab --- /dev/null +++ b/java/tests/src/com/android/intentresolver/TestApplication.kt @@ -0,0 +1,27 @@ +/* + * 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.Application +import android.content.Context +import android.os.UserHandle + +class TestApplication : Application() { + + // return the current context as a work profile doesn't really exist in these tests + override fun createContextAsUser(user: UserHandle, flags: Int): Context = this +}
\ No newline at end of file diff --git a/java/tests/src/com/android/intentresolver/TestHelpers.kt b/java/tests/src/com/android/intentresolver/TestHelpers.kt new file mode 100644 index 00000000..5b583fef --- /dev/null +++ b/java/tests/src/com/android/intentresolver/TestHelpers.kt @@ -0,0 +1,71 @@ +/* + * 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.prediction.AppTarget +import android.app.prediction.AppTargetId +import android.content.ComponentName +import android.content.Context +import android.content.Intent +import android.content.pm.ShortcutInfo +import android.content.pm.ShortcutManager.ShareShortcutInfo +import android.os.Bundle +import android.service.chooser.ChooserTarget +import org.mockito.Mockito.`when` as whenever + +internal fun createShareShortcutInfo( + id: String, + componentName: ComponentName, + rank: Int +): ShareShortcutInfo = + ShareShortcutInfo( + createShortcutInfo(id, componentName, rank), + componentName + ) + +internal fun createShortcutInfo( + id: String, + componentName: ComponentName, + rank: Int +): ShortcutInfo { + val context = mock<Context>() + whenever(context.packageName).thenReturn(componentName.packageName) + return ShortcutInfo.Builder(context, id) + .setShortLabel("Short Label $id") + .setLongLabel("Long Label $id") + .setActivity(componentName) + .setRank(rank) + .build() +} + +internal fun createAppTarget(shortcutInfo: ShortcutInfo) = + AppTarget( + AppTargetId(shortcutInfo.id), + shortcutInfo, + shortcutInfo.activity?.className ?: error("missing activity info") + ) + +fun createChooserTarget( + title: String, score: Float, componentName: ComponentName, shortcutId: String +): ChooserTarget = + ChooserTarget( + title, + null, + score, + componentName, + Bundle().apply { putString(Intent.EXTRA_SHORTCUT_ID, shortcutId) } + ) diff --git a/java/tests/src/com/android/intentresolver/UnbundledChooserActivityTest.java b/java/tests/src/com/android/intentresolver/UnbundledChooserActivityTest.java index b901fc1e..da72a749 100644 --- a/java/tests/src/com/android/intentresolver/UnbundledChooserActivityTest.java +++ b/java/tests/src/com/android/intentresolver/UnbundledChooserActivityTest.java @@ -38,18 +38,16 @@ import static com.android.intentresolver.MatcherUtils.first; import static com.google.common.truth.Truth.assertThat; -import static junit.framework.Assert.assertFalse; import static junit.framework.Assert.assertNull; -import static junit.framework.Assert.assertTrue; import static org.hamcrest.CoreMatchers.allOf; import static org.hamcrest.CoreMatchers.is; import static org.hamcrest.CoreMatchers.not; import static org.hamcrest.CoreMatchers.notNullValue; import static org.hamcrest.MatcherAssert.assertThat; -import static org.hamcrest.Matchers.greaterThan; import static org.junit.Assert.assertEquals; import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyBoolean; import static org.mockito.ArgumentMatchers.anyInt; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.atLeastOnce; @@ -83,21 +81,24 @@ import android.net.Uri; import android.os.UserHandle; import android.provider.DeviceConfig; import android.service.chooser.ChooserTarget; +import android.util.Pair; +import android.util.SparseArray; import android.view.View; import androidx.annotation.CallSuper; +import androidx.annotation.NonNull; +import androidx.recyclerview.widget.GridLayoutManager; +import androidx.recyclerview.widget.RecyclerView; import androidx.test.espresso.matcher.BoundedDiagnosingMatcher; import androidx.test.platform.app.InstrumentationRegistry; import androidx.test.rule.ActivityTestRule; import com.android.intentresolver.ResolverActivity.ResolvedComponentInfo; import com.android.intentresolver.chooser.DisplayResolveInfo; +import com.android.intentresolver.shortcuts.ShortcutLoader; import com.android.internal.config.sysui.SystemUiDeviceConfigFlags; 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 org.hamcrest.Description; import org.hamcrest.Matcher; @@ -117,6 +118,7 @@ import java.util.Collection; import java.util.HashMap; import java.util.List; import java.util.Map; +import java.util.function.Consumer; import java.util.function.Function; /** @@ -130,7 +132,6 @@ import java.util.function.Function; * TODO: this can simply be renamed to "ChooserActivityTest" if that's ever unambiguous (i.e., if * there's no risk of confusion with the framework tests that currently share the same name). */ -@Ignore("investigate b/241944046 and re-enabled") @RunWith(Parameterized.class) public class UnbundledChooserActivityTest { @@ -252,13 +253,31 @@ public class UnbundledChooserActivityTest { mTestNum = testNum; } + private void setDeviceConfigProperty( + @NonNull String propertyName, + @NonNull String value) { + // TODO: consider running with {@link #runWithShellPermissionIdentity()} to more narrowly + // request WRITE_DEVICE_CONFIG permissions if we get rid of the broad grant we currently + // configure in {@link #setup()}. + // TODO: is it really appropriate that this is always set with makeDefault=true? + boolean valueWasSet = DeviceConfig.setProperty( + DeviceConfig.NAMESPACE_SYSTEMUI, + propertyName, + value, + true /* makeDefault */); + if (!valueWasSet) { + throw new IllegalStateException( + "Could not set " + propertyName + " to " + value); + } + } + public void cleanOverrideData() { ChooserActivityOverrideData.getInstance().reset(); ChooserActivityOverrideData.getInstance().createPackageManager = mPackageManagerOverride; - DeviceConfig.setProperty(DeviceConfig.NAMESPACE_SYSTEMUI, + + setDeviceConfigProperty( SystemUiDeviceConfigFlags.APPLY_SHARING_APP_LIMITS_IN_SYSUI, - Boolean.toString(true), - true /* makeDefault*/); + Boolean.toString(true)); } @Test @@ -282,7 +301,7 @@ public class UnbundledChooserActivityTest { waitForIdle(); assertThat(activity.getAdapter().getCount(), is(2)); assertThat(activity.getAdapter().getServiceTargetCount(), is(0)); - onView(withIdFromRuntimeResource("title")).check(matches(withText("chooser test"))); + onView(withId(android.R.id.title)).check(matches(withText("chooser test"))); } @Test @@ -302,8 +321,8 @@ public class UnbundledChooserActivityTest { .thenReturn(resolvedComponentInfos); mActivityRule.launchActivity(Intent.createChooser(sendIntent, "chooser test")); waitForIdle(); - onView(withIdFromRuntimeResource("title")) - .check(matches(withTextFromRuntimeResource("whichSendApplication"))); + onView(withId(android.R.id.title)) + .check(matches(withText(com.android.internal.R.string.whichSendApplication))); } @Test @@ -323,8 +342,8 @@ public class UnbundledChooserActivityTest { .thenReturn(resolvedComponentInfos); mActivityRule.launchActivity(Intent.createChooser(sendIntent, null)); waitForIdle(); - onView(withIdFromRuntimeResource("title")) - .check(matches(withTextFromRuntimeResource("whichSendApplication"))); + onView(withId(android.R.id.title)) + .check(matches(withText(com.android.internal.R.string.whichSendApplication))); } @Test @@ -344,9 +363,9 @@ public class UnbundledChooserActivityTest { .thenReturn(resolvedComponentInfos); mActivityRule.launchActivity(Intent.createChooser(sendIntent, null)); waitForIdle(); - onView(withIdFromRuntimeResource("content_preview_title")) + onView(withId(com.android.internal.R.id.content_preview_title)) .check(matches(not(isDisplayed()))); - onView(withIdFromRuntimeResource("content_preview_thumbnail")) + onView(withId(com.android.internal.R.id.content_preview_thumbnail)) .check(matches(not(isDisplayed()))); } @@ -368,11 +387,11 @@ public class UnbundledChooserActivityTest { .thenReturn(resolvedComponentInfos); mActivityRule.launchActivity(Intent.createChooser(sendIntent, null)); waitForIdle(); - onView(withIdFromRuntimeResource("content_preview_title")) + onView(withId(com.android.internal.R.id.content_preview_title)) .check(matches(isDisplayed())); - onView(withIdFromRuntimeResource("content_preview_title")) + onView(withId(com.android.internal.R.id.content_preview_title)) .check(matches(withText(previewTitle))); - onView(withIdFromRuntimeResource("content_preview_thumbnail")) + onView(withId(com.android.internal.R.id.content_preview_thumbnail)) .check(matches(not(isDisplayed()))); } @@ -395,8 +414,9 @@ public class UnbundledChooserActivityTest { .thenReturn(resolvedComponentInfos); mActivityRule.launchActivity(Intent.createChooser(sendIntent, null)); waitForIdle(); - onView(withIdFromRuntimeResource("content_preview_title")).check(matches(isDisplayed())); - onView(withIdFromRuntimeResource("content_preview_thumbnail")) + onView(withId(com.android.internal.R.id.content_preview_title)) + .check(matches(isDisplayed())); + onView(withId(com.android.internal.R.id.content_preview_thumbnail)) .check(matches(not(isDisplayed()))); } @@ -405,7 +425,7 @@ public class UnbundledChooserActivityTest { String previewTitle = "My Content Preview Title"; Intent sendIntent = createSendTextIntentWithPreview(previewTitle, Uri.parse("android.resource://com.android.frameworks.coretests/" - + com.android.frameworks.coretests.R.drawable.test320x240)); + + R.drawable.test320x240)); ChooserActivityOverrideData.getInstance().previewThumbnail = createBitmap(); List<ResolvedComponentInfo> resolvedComponentInfos = createResolvedComponentsForTest(2); @@ -421,8 +441,9 @@ public class UnbundledChooserActivityTest { .thenReturn(resolvedComponentInfos); mActivityRule.launchActivity(Intent.createChooser(sendIntent, null)); waitForIdle(); - onView(withIdFromRuntimeResource("content_preview_title")).check(matches(isDisplayed())); - onView(withIdFromRuntimeResource("content_preview_thumbnail")) + onView(withId(com.android.internal.R.id.content_preview_title)) + .check(matches(isDisplayed())); + onView(withId(com.android.internal.R.id.content_preview_thumbnail)) .check(matches(isDisplayed())); } @@ -447,7 +468,7 @@ public class UnbundledChooserActivityTest { waitForIdle(); assertThat(activity.getAdapter().getCount(), is(2)); - onView(withIdFromRuntimeResource("profile_button")).check(doesNotExist()); + onView(withId(com.android.internal.R.id.profile_button)).check(doesNotExist()); ResolveInfo[] chosen = new ResolveInfo[1]; ChooserActivityOverrideData.getInstance().onSafelyStartCallback = targetInfo -> { @@ -580,8 +601,8 @@ public class UnbundledChooserActivityTest { waitForIdle(); assertThat(activity.isFinishing(), is(false)); - onView(withIdFromRuntimeResource("empty")).check(matches(isDisplayed())); - onView(withIdFromRuntimeResource("profile_pager")).check(matches(not(isDisplayed()))); + onView(withId(android.R.id.empty)).check(matches(isDisplayed())); + onView(withId(com.android.internal.R.id.profile_pager)).check(matches(not(isDisplayed()))); InstrumentationRegistry.getInstrumentation().runOnMainSync( () -> wrapper.getAdapter().handlePackagesChanged() ); @@ -619,9 +640,7 @@ public class UnbundledChooserActivityTest { } @Test @Ignore - public void hasOtherProfileOneOption() throws Exception { - // enable the work tab feature flag - ResolverActivity.ENABLE_TABBED_VIEW = true; + public void hasOtherProfileOneOption() { List<ResolvedComponentInfo> personalResolvedComponentInfos = createResolvedComponentsForTestWithOtherProfile(2, /* userId */ 10); List<ResolvedComponentInfo> workResolvedComponentInfos = createResolvedComponentsForTest(4); @@ -647,7 +666,6 @@ public class UnbundledChooserActivityTest { List<ResolvedComponentInfo> stableCopy = createResolvedComponentsForTestWithOtherProfile(2, /* userId= */ 10); waitForIdle(); - Thread.sleep(((ChooserActivity) activity).mListViewUpdateDelayMs); onView(first(withText(stableCopy.get(1).getResolveInfoAt(0).activityInfo.name))) .perform(click()); @@ -657,9 +675,6 @@ public class UnbundledChooserActivityTest { @Test @Ignore public void hasOtherProfileTwoOptionsAndUserSelectsOne() throws Exception { - // enable the work tab feature flag - ResolverActivity.ENABLE_TABBED_VIEW = true; - Intent sendIntent = createSendTextIntent(); List<ResolvedComponentInfo> resolvedComponentInfos = createResolvedComponentsForTestWithOtherProfile(3); @@ -697,9 +712,6 @@ public class UnbundledChooserActivityTest { @Test @Ignore public void hasLastChosenActivityAndOtherProfile() throws Exception { - // enable the work tab feature flag - ResolverActivity.ENABLE_TABBED_VIEW = true; - Intent sendIntent = createSendTextIntent(); List<ResolvedComponentInfo> resolvedComponentInfos = createResolvedComponentsForTestWithOtherProfile(3); @@ -748,8 +760,8 @@ public class UnbundledChooserActivityTest { mActivityRule.launchActivity(Intent.createChooser(sendIntent, null)); waitForIdle(); - onView(withIdFromRuntimeResource("chooser_copy_button")).check(matches(isDisplayed())); - onView(withIdFromRuntimeResource("chooser_copy_button")).perform(click()); + onView(withId(com.android.internal.R.id.chooser_copy_button)).check(matches(isDisplayed())); + onView(withId(com.android.internal.R.id.chooser_copy_button)).perform(click()); ClipboardManager clipboard = (ClipboardManager) activity.getSystemService( Context.CLIPBOARD_SERVICE); ClipData clipData = clipboard.getPrimaryClip(); @@ -778,8 +790,8 @@ public class UnbundledChooserActivityTest { mActivityRule.launchActivity(Intent.createChooser(sendIntent, null)); waitForIdle(); - onView(withIdFromRuntimeResource("chooser_copy_button")).check(matches(isDisplayed())); - onView(withIdFromRuntimeResource("chooser_copy_button")).perform(click()); + onView(withId(com.android.internal.R.id.chooser_copy_button)).check(matches(isDisplayed())); + onView(withId(com.android.internal.R.id.chooser_copy_button)).perform(click()); verify(mockLogger, atLeastOnce()).write(logMakerCaptor.capture()); @@ -806,52 +818,11 @@ public class UnbundledChooserActivityTest { mActivityRule.launchActivity(Intent.createChooser(sendIntent, null)); waitForIdle(); - onView(withIdFromRuntimeResource("chooser_nearby_button")).check(matches(isDisplayed())); - onView(withIdFromRuntimeResource("chooser_nearby_button")).perform(click()); - - ChooserActivityLoggerFake logger = - (ChooserActivityLoggerFake) activity.getChooserActivityLogger(); + onView(withId(com.android.internal.R.id.chooser_nearby_button)) + .check(matches(isDisplayed())); + onView(withId(com.android.internal.R.id.chooser_nearby_button)).perform(click()); // TODO(b/211669337): Determine the expected SHARESHEET_DIRECT_LOAD_COMPLETE events. - logger.removeCallsForUiEventsOfType( - ChooserActivityLogger.SharesheetStandardEvent - .SHARESHEET_DIRECT_LOAD_COMPLETE.getId()); - - // SHARESHEET_TRIGGERED: - assertThat(logger.event(0).getId(), - is(ChooserActivityLogger.SharesheetStandardEvent.SHARESHEET_TRIGGERED.getId())); - - // SHARESHEET_STARTED: - assertThat(logger.get(1).atomId, is(FrameworkStatsLog.SHARESHEET_STARTED)); - assertThat(logger.get(1).intent, is(Intent.ACTION_SEND)); - assertThat(logger.get(1).mimeType, is("text/plain")); - assertThat(logger.get(1).packageName, is( - InstrumentationRegistry.getInstrumentation().getTargetContext().getPackageName())); - assertThat(logger.get(1).appProvidedApp, is(0)); - assertThat(logger.get(1).appProvidedDirect, is(0)); - assertThat(logger.get(1).isWorkprofile, is(false)); - assertThat(logger.get(1).previewType, is(3)); - - // SHARESHEET_APP_LOAD_COMPLETE: - assertThat(logger.event(2).getId(), - is(ChooserActivityLogger - .SharesheetStandardEvent.SHARESHEET_APP_LOAD_COMPLETE.getId())); - - // Next are just artifacts of test set-up: - assertThat(logger.event(3).getId(), - is(ChooserActivityLogger - .SharesheetStandardEvent.SHARESHEET_EMPTY_DIRECT_SHARE_ROW.getId())); - assertThat(logger.event(4).getId(), - is(ChooserActivityLogger.SharesheetStandardEvent.SHARESHEET_EXPANDED.getId())); - - // SHARESHEET_NEARBY_TARGET_SELECTED: - assertThat(logger.get(5).atomId, is(FrameworkStatsLog.RANKING_SELECTED)); - assertThat(logger.get(5).targetType, - is(ChooserActivityLogger - .SharesheetTargetSelectedEvent.SHARESHEET_NEARBY_TARGET_SELECTED.getId())); - - // No more events. - assertThat(logger.numCalls(), is(6)); } @@ -860,7 +831,7 @@ public class UnbundledChooserActivityTest { public void testEditImageLogs() throws Exception { Intent sendIntent = createSendImageIntent( Uri.parse("android.resource://com.android.frameworks.coretests/" - + com.android.frameworks.coretests.R.drawable.test320x240)); + + R.drawable.test320x240)); ChooserActivityOverrideData.getInstance().previewThumbnail = createBitmap(); ChooserActivityOverrideData.getInstance().isImageType = true; @@ -877,59 +848,17 @@ public class UnbundledChooserActivityTest { mActivityRule.launchActivity(Intent.createChooser(sendIntent, null)); waitForIdle(); - onView(withIdFromRuntimeResource("chooser_edit_button")).check(matches(isDisplayed())); - onView(withIdFromRuntimeResource("chooser_edit_button")).perform(click()); - - ChooserActivityLoggerFake logger = - (ChooserActivityLoggerFake) activity.getChooserActivityLogger(); + onView(withId(com.android.internal.R.id.chooser_edit_button)).check(matches(isDisplayed())); + onView(withId(com.android.internal.R.id.chooser_edit_button)).perform(click()); // TODO(b/211669337): Determine the expected SHARESHEET_DIRECT_LOAD_COMPLETE events. - logger.removeCallsForUiEventsOfType( - ChooserActivityLogger.SharesheetStandardEvent - .SHARESHEET_DIRECT_LOAD_COMPLETE.getId()); - - // SHARESHEET_TRIGGERED: - assertThat(logger.event(0).getId(), - is(ChooserActivityLogger.SharesheetStandardEvent.SHARESHEET_TRIGGERED.getId())); - - // SHARESHEET_STARTED: - assertThat(logger.get(1).atomId, is(FrameworkStatsLog.SHARESHEET_STARTED)); - assertThat(logger.get(1).intent, is(Intent.ACTION_SEND)); - assertThat(logger.get(1).mimeType, is("image/png")); - assertThat(logger.get(1).packageName, is( - InstrumentationRegistry.getInstrumentation().getTargetContext().getPackageName())); - assertThat(logger.get(1).appProvidedApp, is(0)); - assertThat(logger.get(1).appProvidedDirect, is(0)); - assertThat(logger.get(1).isWorkprofile, is(false)); - assertThat(logger.get(1).previewType, is(1)); - - // SHARESHEET_APP_LOAD_COMPLETE: - assertThat(logger.event(2).getId(), - is(ChooserActivityLogger - .SharesheetStandardEvent.SHARESHEET_APP_LOAD_COMPLETE.getId())); - - // Next are just artifacts of test set-up: - assertThat(logger.event(3).getId(), - is(ChooserActivityLogger - .SharesheetStandardEvent.SHARESHEET_EMPTY_DIRECT_SHARE_ROW.getId())); - assertThat(logger.event(4).getId(), - is(ChooserActivityLogger.SharesheetStandardEvent.SHARESHEET_EXPANDED.getId())); - - // SHARESHEET_EDIT_TARGET_SELECTED: - assertThat(logger.get(5).atomId, is(FrameworkStatsLog.RANKING_SELECTED)); - assertThat(logger.get(5).targetType, - is(ChooserActivityLogger - .SharesheetTargetSelectedEvent.SHARESHEET_EDIT_TARGET_SELECTED.getId())); - - // No more events. - assertThat(logger.numCalls(), is(6)); } @Test public void oneVisibleImagePreview() throws InterruptedException { Uri uri = Uri.parse("android.resource://com.android.frameworks.coretests/" - + com.android.frameworks.coretests.R.drawable.test320x240); + + R.drawable.test320x240); ArrayList<Uri> uris = new ArrayList<>(); uris.add(uri); @@ -952,20 +881,20 @@ public class UnbundledChooserActivityTest { .thenReturn(resolvedComponentInfos); mActivityRule.launchActivity(Intent.createChooser(sendIntent, null)); waitForIdle(); - onView(withIdFromRuntimeResource("content_preview_image_1_large")) + onView(withId(com.android.internal.R.id.content_preview_image_1_large)) .check(matches(isDisplayed())); - onView(withIdFromRuntimeResource("content_preview_image_2_large")) + onView(withId(com.android.internal.R.id.content_preview_image_2_large)) .check(matches(not(isDisplayed()))); - onView(withIdFromRuntimeResource("content_preview_image_2_small")) + onView(withId(com.android.internal.R.id.content_preview_image_2_small)) .check(matches(not(isDisplayed()))); - onView(withIdFromRuntimeResource("content_preview_image_3_small")) + onView(withId(com.android.internal.R.id.content_preview_image_3_small)) .check(matches(not(isDisplayed()))); } @Test public void twoVisibleImagePreview() throws InterruptedException { Uri uri = Uri.parse("android.resource://com.android.frameworks.coretests/" - + com.android.frameworks.coretests.R.drawable.test320x240); + + R.drawable.test320x240); ArrayList<Uri> uris = new ArrayList<>(); uris.add(uri); @@ -989,20 +918,20 @@ public class UnbundledChooserActivityTest { .thenReturn(resolvedComponentInfos); mActivityRule.launchActivity(Intent.createChooser(sendIntent, null)); waitForIdle(); - onView(withIdFromRuntimeResource("content_preview_image_1_large")) + onView(withId(com.android.internal.R.id.content_preview_image_1_large)) .check(matches(isDisplayed())); - onView(withIdFromRuntimeResource("content_preview_image_2_large")) + onView(withId(com.android.internal.R.id.content_preview_image_2_large)) .check(matches(isDisplayed())); - onView(withIdFromRuntimeResource("content_preview_image_2_small")) + onView(withId(com.android.internal.R.id.content_preview_image_2_small)) .check(matches(not(isDisplayed()))); - onView(withIdFromRuntimeResource("content_preview_image_3_small")) + onView(withId(com.android.internal.R.id.content_preview_image_3_small)) .check(matches(not(isDisplayed()))); } @Test public void threeOrMoreVisibleImagePreview() throws InterruptedException { Uri uri = Uri.parse("android.resource://com.android.frameworks.coretests/" - + com.android.frameworks.coretests.R.drawable.test320x240); + + R.drawable.test320x240); ArrayList<Uri> uris = new ArrayList<>(); uris.add(uri); @@ -1029,13 +958,13 @@ public class UnbundledChooserActivityTest { .thenReturn(resolvedComponentInfos); mActivityRule.launchActivity(Intent.createChooser(sendIntent, null)); waitForIdle(); - onView(withIdFromRuntimeResource("content_preview_image_1_large")) + onView(withId(com.android.internal.R.id.content_preview_image_1_large)) .check(matches(isDisplayed())); - onView(withIdFromRuntimeResource("content_preview_image_2_large")) + onView(withId(com.android.internal.R.id.content_preview_image_2_large)) .check(matches(not(isDisplayed()))); - onView(withIdFromRuntimeResource("content_preview_image_2_small")) + onView(withId(com.android.internal.R.id.content_preview_image_2_small)) .check(matches(isDisplayed())); - onView(withIdFromRuntimeResource("content_preview_image_3_small")) + onView(withId(com.android.internal.R.id.content_preview_image_3_small)) .check(matches(isDisplayed())); } @@ -1135,7 +1064,7 @@ public class UnbundledChooserActivityTest { @Test public void testImagePreviewLogging() { Uri uri = Uri.parse("android.resource://com.android.frameworks.coretests/" - + com.android.frameworks.coretests.R.drawable.test320x240); + + R.drawable.test320x240); ArrayList<Uri> uris = new ArrayList<>(); uris.add(uri); @@ -1192,10 +1121,11 @@ public class UnbundledChooserActivityTest { .thenReturn(resolvedComponentInfos); mActivityRule.launchActivity(Intent.createChooser(sendIntent, null)); waitForIdle(); - onView(withIdFromRuntimeResource("content_preview_filename")).check(matches(isDisplayed())); - onView(withIdFromRuntimeResource("content_preview_filename")) + onView(withId(com.android.internal.R.id.content_preview_filename)) + .check(matches(isDisplayed())); + onView(withId(com.android.internal.R.id.content_preview_filename)) .check(matches(withText("app.pdf"))); - onView(withIdFromRuntimeResource("content_preview_file_icon")) + onView(withId(com.android.internal.R.id.content_preview_file_icon)) .check(matches(isDisplayed())); } @@ -1225,11 +1155,11 @@ public class UnbundledChooserActivityTest { .thenReturn(resolvedComponentInfos); mActivityRule.launchActivity(Intent.createChooser(sendIntent, null)); waitForIdle(); - onView(withIdFromRuntimeResource("content_preview_filename")) + onView(withId(com.android.internal.R.id.content_preview_filename)) .check(matches(isDisplayed())); - onView(withIdFromRuntimeResource("content_preview_filename")) + onView(withId(com.android.internal.R.id.content_preview_filename)) .check(matches(withText("app.pdf + 2 files"))); - onView(withIdFromRuntimeResource("content_preview_file_icon")) + onView(withId(com.android.internal.R.id.content_preview_file_icon)) .check(matches(isDisplayed())); } @@ -1258,10 +1188,11 @@ public class UnbundledChooserActivityTest { mActivityRule.launchActivity(Intent.createChooser(sendIntent, null)); waitForIdle(); - onView(withIdFromRuntimeResource("content_preview_filename")).check(matches(isDisplayed())); - onView(withIdFromRuntimeResource("content_preview_filename")) + onView(withId(com.android.internal.R.id.content_preview_filename)) + .check(matches(isDisplayed())); + onView(withId(com.android.internal.R.id.content_preview_filename)) .check(matches(withText("app.pdf"))); - onView(withIdFromRuntimeResource("content_preview_file_icon")) + onView(withId(com.android.internal.R.id.content_preview_file_icon)) .check(matches(isDisplayed())); } @@ -1297,10 +1228,11 @@ public class UnbundledChooserActivityTest { mActivityRule.launchActivity(Intent.createChooser(sendIntent, null)); waitForIdle(); - onView(withIdFromRuntimeResource("content_preview_filename")).check(matches(isDisplayed())); - onView(withIdFromRuntimeResource("content_preview_filename")) + onView(withId(com.android.internal.R.id.content_preview_filename)) + .check(matches(isDisplayed())); + onView(withId(com.android.internal.R.id.content_preview_filename)) .check(matches(withText("app.pdf + 1 file"))); - onView(withIdFromRuntimeResource("content_preview_file_icon")) + onView(withId(com.android.internal.R.id.content_preview_file_icon)) .check(matches(isDisplayed())); } @@ -1347,95 +1279,9 @@ public class UnbundledChooserActivityTest { is(testBaseScore * SHORTCUT_TARGET_SCORE_BOOST)); } - @Test - public void testConvertToChooserTarget_predictionService() { - Intent sendIntent = createSendTextIntent(); - List<ResolvedComponentInfo> resolvedComponentInfos = createResolvedComponentsForTest(2); - when( - ChooserActivityOverrideData - .getInstance() - .resolverListController - .getResolversForIntent( - Mockito.anyBoolean(), - Mockito.anyBoolean(), - Mockito.anyBoolean(), - Mockito.isA(List.class))) - .thenReturn(resolvedComponentInfos); - - final ChooserActivity activity = - mActivityRule.launchActivity(Intent.createChooser(sendIntent, null)); - waitForIdle(); - - List<ShareShortcutInfo> shortcuts = createShortcuts(activity); - - int[] expectedOrderAllShortcuts = {0, 1, 2, 3}; - float[] expectedScoreAllShortcuts = {1.0f, 0.99f, 0.98f, 0.97f}; - - List<ChooserTarget> chooserTargets = activity.convertToChooserTarget(shortcuts, shortcuts, - null, TARGET_TYPE_SHORTCUTS_FROM_PREDICTION_SERVICE); - assertCorrectShortcutToChooserTargetConversion(shortcuts, chooserTargets, - expectedOrderAllShortcuts, expectedScoreAllShortcuts); - - List<ShareShortcutInfo> subset = new ArrayList<>(); - subset.add(shortcuts.get(1)); - subset.add(shortcuts.get(2)); - subset.add(shortcuts.get(3)); - - int[] expectedOrderSubset = {1, 2, 3}; - float[] expectedScoreSubset = {0.99f, 0.98f, 0.97f}; - - chooserTargets = activity.convertToChooserTarget(subset, shortcuts, null, - TARGET_TYPE_SHORTCUTS_FROM_PREDICTION_SERVICE); - assertCorrectShortcutToChooserTargetConversion(shortcuts, chooserTargets, - expectedOrderSubset, expectedScoreSubset); - } - - @Test - public void testConvertToChooserTarget_shortcutManager() { - Intent sendIntent = createSendTextIntent(); - List<ResolvedComponentInfo> resolvedComponentInfos = createResolvedComponentsForTest(2); - when( - ChooserActivityOverrideData - .getInstance() - .resolverListController - .getResolversForIntent( - Mockito.anyBoolean(), - Mockito.anyBoolean(), - Mockito.anyBoolean(), - Mockito.isA(List.class))) - .thenReturn(resolvedComponentInfos); - - final ChooserActivity activity = - mActivityRule.launchActivity(Intent.createChooser(sendIntent, null)); - waitForIdle(); - - List<ShareShortcutInfo> shortcuts = createShortcuts(activity); - - int[] expectedOrderAllShortcuts = {2, 0, 3, 1}; - float[] expectedScoreAllShortcuts = {1.0f, 0.99f, 0.99f, 0.98f}; - - List<ChooserTarget> chooserTargets = activity.convertToChooserTarget(shortcuts, shortcuts, - null, TARGET_TYPE_SHORTCUTS_FROM_SHORTCUT_MANAGER); - assertCorrectShortcutToChooserTargetConversion(shortcuts, chooserTargets, - expectedOrderAllShortcuts, expectedScoreAllShortcuts); - - List<ShareShortcutInfo> subset = new ArrayList<>(); - subset.add(shortcuts.get(1)); - subset.add(shortcuts.get(2)); - subset.add(shortcuts.get(3)); - - int[] expectedOrderSubset = {2, 3, 1}; - float[] expectedScoreSubset = {1.0f, 0.99f, 0.98f}; - - chooserTargets = activity.convertToChooserTarget(subset, shortcuts, null, - TARGET_TYPE_SHORTCUTS_FROM_SHORTCUT_MANAGER); - assertCorrectShortcutToChooserTargetConversion(shortcuts, chooserTargets, - expectedOrderSubset, expectedScoreSubset); - } - // This test is too long and too slow and should not be taken as an example for future tests. - @Test @Ignore - public void testDirectTargetSelectionLogging() throws InterruptedException { + @Test + public void testDirectTargetSelectionLogging() { Intent sendIntent = createSendTextIntent(); // We need app targets for direct targets to get displayed List<ResolvedComponentInfo> resolvedComponentInfos = createResolvedComponentsForTest(2); @@ -1453,41 +1299,55 @@ public class UnbundledChooserActivityTest { // Set up resources MetricsLogger mockLogger = ChooserActivityOverrideData.getInstance().metricsLogger; ArgumentCaptor<LogMaker> logMakerCaptor = ArgumentCaptor.forClass(LogMaker.class); - // Create direct share target - List<ChooserTarget> serviceTargets = createDirectShareTargets(1, ""); - ResolveInfo ri = ResolverDataProvider.createResolveInfo(3, 0); + + // create test shortcut loader factory, remember loaders and their callbacks + SparseArray<Pair<ShortcutLoader, Consumer<ShortcutLoader.Result>>> shortcutLoaders = + createShortcutLoaderFactory(); // Start activity final IChooserWrapper activity = (IChooserWrapper) mActivityRule.launchActivity(Intent.createChooser(sendIntent, null)); + waitForIdle(); - // Insert the direct share target - Map<ChooserTarget, ShortcutInfo> directShareToShortcutInfos = new HashMap<>(); - directShareToShortcutInfos.put(serviceTargets.get(0), null); - InstrumentationRegistry.getInstrumentation().runOnMainSync( - () -> activity.getAdapter().addServiceResults( - activity.createTestDisplayResolveInfo(sendIntent, - ri, - "testLabel", - "testInfo", - sendIntent, - /* resolveInfoPresentationGetter */ null), - serviceTargets, - TARGET_TYPE_CHOOSER_TARGET, - directShareToShortcutInfos) - ); + // verify that ShortcutLoader was queried + ArgumentCaptor<DisplayResolveInfo[]> appTargets = + ArgumentCaptor.forClass(DisplayResolveInfo[].class); + verify(shortcutLoaders.get(0).first, times(1)).queryShortcuts(appTargets.capture()); - // Thread.sleep shouldn't be a thing in an integration test but it's - // necessary here because of the way the code is structured - // TODO: restructure the tests b/129870719 - Thread.sleep(((ChooserActivity) activity).mListViewUpdateDelayMs); + // send shortcuts + assertThat( + "Wrong number of app targets", + appTargets.getValue().length, + is(resolvedComponentInfos.size())); + List<ChooserTarget> serviceTargets = createDirectShareTargets(1, ""); + ShortcutLoader.Result result = new ShortcutLoader.Result( + true, + appTargets.getValue(), + new ShortcutLoader.ShortcutResultInfo[] { + new ShortcutLoader.ShortcutResultInfo( + appTargets.getValue()[0], + serviceTargets + ) + }, + new HashMap<>(), + new HashMap<>() + ); + activity.getMainExecutor().execute(() -> shortcutLoaders.get(0).second.accept(result)); + waitForIdle(); - assertThat("Chooser should have 3 targets (2 apps, 1 direct)", - activity.getAdapter().getCount(), is(3)); - assertThat("Chooser should have exactly one selectable direct target", - activity.getAdapter().getSelectableServiceTargetCount(), is(1)); - assertThat("The resolver info must match the resolver info used to create the target", - activity.getAdapter().getItem(0).getResolveInfo(), is(ri)); + final ChooserListAdapter activeAdapter = activity.getAdapter(); + assertThat( + "Chooser should have 3 targets (2 apps, 1 direct)", + activeAdapter.getCount(), + is(3)); + assertThat( + "Chooser should have exactly one selectable direct target", + activeAdapter.getSelectableServiceTargetCount(), + is(1)); + assertThat( + "The resolver info must match the resolver info used to create the target", + activeAdapter.getItem(0).getResolveInfo(), + is(resolvedComponentInfos.get(0).getResolveInfoAt(0))); // Click on the direct target String name = serviceTargets.get(0).getTitle().toString(); @@ -1495,24 +1355,30 @@ public class UnbundledChooserActivityTest { .perform(click()); waitForIdle(); - // Currently we're seeing 3 invocations - // 1. ChooserActivity.onCreate() - // 2. ChooserActivity$ChooserRowAdapter.createContentPreviewView() - // 3. ChooserActivity.startSelected -- which is the one we're after - verify(mockLogger, Mockito.times(3)).write(logMakerCaptor.capture()); - assertThat(logMakerCaptor.getAllValues().get(2).getCategory(), + // Currently we're seeing 4 invocations + // 1. ChooserActivity.logActionShareWithPreview() + // 2. ChooserActivity.onCreate() + // 3. ChooserActivity.logDirectShareTargetReceived() + // 4. ChooserActivity.startSelected -- which is the one we're after + verify(mockLogger, Mockito.times(4)).write(logMakerCaptor.capture()); + LogMaker selectionLog = logMakerCaptor.getAllValues().get(3); + assertThat( + selectionLog.getCategory(), is(MetricsEvent.ACTION_ACTIVITY_CHOOSER_PICKED_SERVICE_TARGET)); - String hashedName = (String) logMakerCaptor - .getAllValues().get(2).getTaggedData(MetricsEvent.FIELD_HASHED_TARGET_NAME); - assertThat("Hash is not predictable but must be obfuscated", + String hashedName = (String) selectionLog.getTaggedData( + MetricsEvent.FIELD_HASHED_TARGET_NAME); + assertThat( + "Hash is not predictable but must be obfuscated", hashedName, is(not(name))); - assertThat("The packages shouldn't match for app target and direct target", logMakerCaptor - .getAllValues().get(2).getTaggedData(MetricsEvent.FIELD_RANKED_POSITION), is(-1)); + assertThat( + "The packages shouldn't match for app target and direct target", + selectionLog.getTaggedData(MetricsEvent.FIELD_RANKED_POSITION), + is(-1)); } // This test is too long and too slow and should not be taken as an example for future tests. - @Test @Ignore - public void testDirectTargetLoggingWithRankedAppTarget() throws InterruptedException { + @Test + public void testDirectTargetLoggingWithRankedAppTarget() { Intent sendIntent = createSendTextIntent(); // We need app targets for direct targets to get displayed List<ResolvedComponentInfo> resolvedComponentInfos = createResolvedComponentsForTest(2); @@ -1530,41 +1396,57 @@ public class UnbundledChooserActivityTest { // Set up resources MetricsLogger mockLogger = ChooserActivityOverrideData.getInstance().metricsLogger; ArgumentCaptor<LogMaker> logMakerCaptor = ArgumentCaptor.forClass(LogMaker.class); - // Create direct share target - List<ChooserTarget> serviceTargets = createDirectShareTargets(1, - resolvedComponentInfos.get(0).getResolveInfoAt(0).activityInfo.packageName); - ResolveInfo ri = ResolverDataProvider.createResolveInfo(3, 0); + + // create test shortcut loader factory, remember loaders and their callbacks + SparseArray<Pair<ShortcutLoader, Consumer<ShortcutLoader.Result>>> shortcutLoaders = + createShortcutLoaderFactory(); // Start activity final IChooserWrapper activity = (IChooserWrapper) mActivityRule.launchActivity(Intent.createChooser(sendIntent, null)); + waitForIdle(); - // Insert the direct share target - Map<ChooserTarget, ShortcutInfo> directShareToShortcutInfos = new HashMap<>(); - directShareToShortcutInfos.put(serviceTargets.get(0), null); - InstrumentationRegistry.getInstrumentation().runOnMainSync( - () -> activity.getAdapter().addServiceResults( - activity.createTestDisplayResolveInfo(sendIntent, - ri, - "testLabel", - "testInfo", - sendIntent, - /* resolveInfoPresentationGetter */ null), - serviceTargets, - TARGET_TYPE_CHOOSER_TARGET, - directShareToShortcutInfos) + // verify that ShortcutLoader was queried + ArgumentCaptor<DisplayResolveInfo[]> appTargets = + ArgumentCaptor.forClass(DisplayResolveInfo[].class); + verify(shortcutLoaders.get(0).first, times(1)).queryShortcuts(appTargets.capture()); + + // send shortcuts + assertThat( + "Wrong number of app targets", + appTargets.getValue().length, + is(resolvedComponentInfos.size())); + List<ChooserTarget> serviceTargets = createDirectShareTargets( + 1, + resolvedComponentInfos.get(0).getResolveInfoAt(0).activityInfo.packageName); + ShortcutLoader.Result result = new ShortcutLoader.Result( + true, + appTargets.getValue(), + new ShortcutLoader.ShortcutResultInfo[] { + new ShortcutLoader.ShortcutResultInfo( + appTargets.getValue()[0], + serviceTargets + ) + }, + new HashMap<>(), + new HashMap<>() ); - // Thread.sleep shouldn't be a thing in an integration test but it's - // necessary here because of the way the code is structured - // TODO: restructure the tests b/129870719 - Thread.sleep(((ChooserActivity) activity).mListViewUpdateDelayMs); + activity.getMainExecutor().execute(() -> shortcutLoaders.get(0).second.accept(result)); + waitForIdle(); - assertThat("Chooser should have 3 targets (2 apps, 1 direct)", - activity.getAdapter().getCount(), is(3)); - assertThat("Chooser should have exactly one selectable direct target", - activity.getAdapter().getSelectableServiceTargetCount(), is(1)); - assertThat("The resolver info must match the resolver info used to create the target", - activity.getAdapter().getItem(0).getResolveInfo(), is(ri)); + final ChooserListAdapter activeAdapter = activity.getAdapter(); + assertThat( + "Chooser should have 3 targets (2 apps, 1 direct)", + activeAdapter.getCount(), + is(3)); + assertThat( + "Chooser should have exactly one selectable direct target", + activeAdapter.getSelectableServiceTargetCount(), + is(1)); + assertThat( + "The resolver info must match the resolver info used to create the target", + activeAdapter.getItem(0).getResolveInfo(), + is(resolvedComponentInfos.get(0).getResolveInfoAt(0))); // Click on the direct target String name = serviceTargets.get(0).getTitle().toString(); @@ -1572,19 +1454,20 @@ public class UnbundledChooserActivityTest { .perform(click()); waitForIdle(); - // Currently we're seeing 3 invocations - // 1. ChooserActivity.onCreate() - // 2. ChooserActivity$ChooserRowAdapter.createContentPreviewView() - // 3. ChooserActivity.startSelected -- which is the one we're after - verify(mockLogger, Mockito.times(3)).write(logMakerCaptor.capture()); - assertThat(logMakerCaptor.getAllValues().get(2).getCategory(), + // Currently we're seeing 4 invocations + // 1. ChooserActivity.logActionShareWithPreview() + // 2. ChooserActivity.onCreate() + // 3. ChooserActivity.logDirectShareTargetReceived() + // 4. ChooserActivity.startSelected -- which is the one we're after + verify(mockLogger, Mockito.times(4)).write(logMakerCaptor.capture()); + assertThat(logMakerCaptor.getAllValues().get(3).getCategory(), is(MetricsEvent.ACTION_ACTIVITY_CHOOSER_PICKED_SERVICE_TARGET)); assertThat("The packages should match for app target and direct target", logMakerCaptor - .getAllValues().get(2).getTaggedData(MetricsEvent.FIELD_RANKED_POSITION), is(0)); + .getAllValues().get(3).getTaggedData(MetricsEvent.FIELD_RANKED_POSITION), is(0)); } - @Test @Ignore - public void testShortcutTargetWithApplyAppLimits() throws InterruptedException { + @Test + public void testShortcutTargetWithApplyAppLimits() { // Set up resources ChooserActivityOverrideData.getInstance().resources = Mockito.spy( InstrumentationRegistry.getInstrumentation().getContext().getResources()); @@ -1592,8 +1475,7 @@ public class UnbundledChooserActivityTest { ChooserActivityOverrideData .getInstance() .resources - .getInteger( - getRuntimeResourceId("config_maxShortcutTargetsPerApp", "integer"))) + .getInteger(R.integer.config_maxShortcutTargetsPerApp)) .thenReturn(1); Intent sendIntent = createSendTextIntent(); // We need app targets for direct targets to get displayed @@ -1608,56 +1490,68 @@ public class UnbundledChooserActivityTest { Mockito.anyBoolean(), Mockito.isA(List.class))) .thenReturn(resolvedComponentInfos); - // Create direct share target - List<ChooserTarget> serviceTargets = createDirectShareTargets(2, - resolvedComponentInfos.get(0).getResolveInfoAt(0).activityInfo.packageName); - ResolveInfo ri = ResolverDataProvider.createResolveInfo(3, 0); + + // create test shortcut loader factory, remember loaders and their callbacks + SparseArray<Pair<ShortcutLoader, Consumer<ShortcutLoader.Result>>> shortcutLoaders = + createShortcutLoaderFactory(); // Start activity - final ChooserActivity activity = - mActivityRule.launchActivity(Intent.createChooser(sendIntent, null)); - final IChooserWrapper wrapper = (IChooserWrapper) activity; + final IChooserWrapper activity = (IChooserWrapper) mActivityRule + .launchActivity(Intent.createChooser(sendIntent, null)); + waitForIdle(); - // Insert the direct share target - Map<ChooserTarget, ShortcutInfo> directShareToShortcutInfos = new HashMap<>(); - List<ShareShortcutInfo> shortcutInfos = createShortcuts(activity); - directShareToShortcutInfos.put(serviceTargets.get(0), - shortcutInfos.get(0).getShortcutInfo()); - directShareToShortcutInfos.put(serviceTargets.get(1), - shortcutInfos.get(1).getShortcutInfo()); - InstrumentationRegistry.getInstrumentation().runOnMainSync( - () -> wrapper.getAdapter().addServiceResults( - wrapper.createTestDisplayResolveInfo(sendIntent, - ri, - "testLabel", - "testInfo", - sendIntent, - /* resolveInfoPresentationGetter */ null), - serviceTargets, - TARGET_TYPE_SHORTCUTS_FROM_PREDICTION_SERVICE, - directShareToShortcutInfos) + // verify that ShortcutLoader was queried + ArgumentCaptor<DisplayResolveInfo[]> appTargets = + ArgumentCaptor.forClass(DisplayResolveInfo[].class); + verify(shortcutLoaders.get(0).first, times(1)).queryShortcuts(appTargets.capture()); + + // send shortcuts + assertThat( + "Wrong number of app targets", + appTargets.getValue().length, + is(resolvedComponentInfos.size())); + List<ChooserTarget> serviceTargets = createDirectShareTargets( + 2, + resolvedComponentInfos.get(0).getResolveInfoAt(0).activityInfo.packageName); + ShortcutLoader.Result result = new ShortcutLoader.Result( + true, + appTargets.getValue(), + new ShortcutLoader.ShortcutResultInfo[] { + new ShortcutLoader.ShortcutResultInfo( + appTargets.getValue()[0], + serviceTargets + ) + }, + new HashMap<>(), + new HashMap<>() ); - // Thread.sleep shouldn't be a thing in an integration test but it's - // necessary here because of the way the code is structured - // TODO: restructure the tests b/129870719 - Thread.sleep(((ChooserActivity) activity).mListViewUpdateDelayMs); + activity.getMainExecutor().execute(() -> shortcutLoaders.get(0).second.accept(result)); + waitForIdle(); - assertThat("Chooser should have 3 targets (2 apps, 1 direct)", - wrapper.getAdapter().getCount(), is(3)); - assertThat("Chooser should have exactly one selectable direct target", - wrapper.getAdapter().getSelectableServiceTargetCount(), is(1)); - assertThat("The resolver info must match the resolver info used to create the target", - wrapper.getAdapter().getItem(0).getResolveInfo(), is(ri)); - assertThat("The display label must match", - wrapper.getAdapter().getItem(0).getDisplayLabel(), is("testTitle0")); + final ChooserListAdapter activeAdapter = activity.getAdapter(); + assertThat( + "Chooser should have 3 targets (2 apps, 1 direct)", + activeAdapter.getCount(), + is(3)); + assertThat( + "Chooser should have exactly one selectable direct target", + activeAdapter.getSelectableServiceTargetCount(), + is(1)); + assertThat( + "The resolver info must match the resolver info used to create the target", + activeAdapter.getItem(0).getResolveInfo(), + is(resolvedComponentInfos.get(0).getResolveInfoAt(0))); + assertThat( + "The display label must match", + activeAdapter.getItem(0).getDisplayLabel(), + is("testTitle0")); } - @Test @Ignore - public void testShortcutTargetWithoutApplyAppLimits() throws InterruptedException { - DeviceConfig.setProperty(DeviceConfig.NAMESPACE_SYSTEMUI, + @Test + public void testShortcutTargetWithoutApplyAppLimits() { + setDeviceConfigProperty( SystemUiDeviceConfigFlags.APPLY_SHARING_APP_LIMITS_IN_SYSUI, - Boolean.toString(false), - true /* makeDefault*/); + Boolean.toString(false)); // Set up resources ChooserActivityOverrideData.getInstance().resources = Mockito.spy( InstrumentationRegistry.getInstrumentation().getContext().getResources()); @@ -1665,8 +1559,7 @@ public class UnbundledChooserActivityTest { ChooserActivityOverrideData .getInstance() .resources - .getInteger( - getRuntimeResourceId("config_maxShortcutTargetsPerApp", "integer"))) + .getInteger(R.integer.config_maxShortcutTargetsPerApp)) .thenReturn(1); Intent sendIntent = createSendTextIntent(); // We need app targets for direct targets to get displayed @@ -1681,50 +1574,65 @@ public class UnbundledChooserActivityTest { Mockito.anyBoolean(), Mockito.isA(List.class))) .thenReturn(resolvedComponentInfos); - // Create direct share target - List<ChooserTarget> serviceTargets = createDirectShareTargets(2, - resolvedComponentInfos.get(0).getResolveInfoAt(0).activityInfo.packageName); - ResolveInfo ri = ResolverDataProvider.createResolveInfo(3, 0); + + // create test shortcut loader factory, remember loaders and their callbacks + SparseArray<Pair<ShortcutLoader, Consumer<ShortcutLoader.Result>>> shortcutLoaders = + createShortcutLoaderFactory(); // Start activity - final ChooserActivity activity = + final IChooserWrapper activity = (IChooserWrapper) mActivityRule.launchActivity(Intent.createChooser(sendIntent, null)); - final IChooserWrapper wrapper = (IChooserWrapper) activity; + waitForIdle(); - // Insert the direct share target - Map<ChooserTarget, ShortcutInfo> directShareToShortcutInfos = new HashMap<>(); - List<ShareShortcutInfo> shortcutInfos = createShortcuts(activity); - directShareToShortcutInfos.put(serviceTargets.get(0), - shortcutInfos.get(0).getShortcutInfo()); - directShareToShortcutInfos.put(serviceTargets.get(1), - shortcutInfos.get(1).getShortcutInfo()); - InstrumentationRegistry.getInstrumentation().runOnMainSync( - () -> wrapper.getAdapter().addServiceResults( - wrapper.createTestDisplayResolveInfo(sendIntent, - ri, - "testLabel", - "testInfo", - sendIntent, - /* resolveInfoPresentationGetter */ null), - serviceTargets, - TARGET_TYPE_SHORTCUTS_FROM_PREDICTION_SERVICE, - directShareToShortcutInfos) + // verify that ShortcutLoader was queried + ArgumentCaptor<DisplayResolveInfo[]> appTargets = + ArgumentCaptor.forClass(DisplayResolveInfo[].class); + verify(shortcutLoaders.get(0).first, times(1)).queryShortcuts(appTargets.capture()); + + // send shortcuts + assertThat( + "Wrong number of app targets", + appTargets.getValue().length, + is(resolvedComponentInfos.size())); + List<ChooserTarget> serviceTargets = createDirectShareTargets( + 2, + resolvedComponentInfos.get(0).getResolveInfoAt(0).activityInfo.packageName); + ShortcutLoader.Result result = new ShortcutLoader.Result( + true, + appTargets.getValue(), + new ShortcutLoader.ShortcutResultInfo[] { + new ShortcutLoader.ShortcutResultInfo( + appTargets.getValue()[0], + serviceTargets + ) + }, + new HashMap<>(), + new HashMap<>() ); - // Thread.sleep shouldn't be a thing in an integration test but it's - // necessary here because of the way the code is structured - // TODO: restructure the tests b/129870719 - Thread.sleep(((ChooserActivity) activity).mListViewUpdateDelayMs); + activity.getMainExecutor().execute(() -> shortcutLoaders.get(0).second.accept(result)); + waitForIdle(); - assertThat("Chooser should have 4 targets (2 apps, 2 direct)", - wrapper.getAdapter().getCount(), is(4)); - assertThat("Chooser should have exactly two selectable direct target", - wrapper.getAdapter().getSelectableServiceTargetCount(), is(2)); - assertThat("The resolver info must match the resolver info used to create the target", - wrapper.getAdapter().getItem(0).getResolveInfo(), is(ri)); - assertThat("The display label must match", - wrapper.getAdapter().getItem(0).getDisplayLabel(), is("testTitle0")); - assertThat("The display label must match", - wrapper.getAdapter().getItem(1).getDisplayLabel(), is("testTitle1")); + final ChooserListAdapter activeAdapter = activity.getAdapter(); + assertThat( + "Chooser should have 4 targets (2 apps, 2 direct)", + activeAdapter.getCount(), + is(4)); + assertThat( + "Chooser should have exactly two selectable direct target", + activeAdapter.getSelectableServiceTargetCount(), + is(2)); + assertThat( + "The resolver info must match the resolver info used to create the target", + activeAdapter.getItem(0).getResolveInfo(), + is(resolvedComponentInfos.get(0).getResolveInfoAt(0))); + assertThat( + "The display label must match", + activeAdapter.getItem(0).getDisplayLabel(), + is("testTitle0")); + assertThat( + "The display label must match", + activeAdapter.getItem(1).getDisplayLabel(), + is("testTitle1")); } @Test @@ -1742,7 +1650,7 @@ public class UnbundledChooserActivityTest { .getContext().getResources().getConfiguration())); waitForIdle(); - onView(withIdFromRuntimeResource("resolver_list")) + onView(withId(com.android.internal.R.id.resolver_list)) .check(matches(withGridColumnCount(6))); } @@ -1760,8 +1668,7 @@ public class UnbundledChooserActivityTest { } private void testDirectTargetLoggingWithAppTargetNotRanked( - int orientation, int appTargetsExpected - ) throws InterruptedException { + int orientation, int appTargetsExpected) { Configuration configuration = new Configuration(InstrumentationRegistry.getInstrumentation().getContext() .getResources().getConfiguration()); @@ -1799,9 +1706,8 @@ public class UnbundledChooserActivityTest { ResolveInfo ri = ResolverDataProvider.createResolveInfo(16, 0); // Start activity - final IChooserWrapper activity = (IChooserWrapper) + final IChooserWrapper wrapper = (IChooserWrapper) mActivityRule.launchActivity(Intent.createChooser(sendIntent, null)); - final IChooserWrapper wrapper = (IChooserWrapper) activity; // Insert the direct share target Map<ChooserTarget, ShortcutInfo> directShareToShortcutInfos = new HashMap<>(); directShareToShortcutInfos.put(serviceTargets.get(0), null); @@ -1815,12 +1721,9 @@ public class UnbundledChooserActivityTest { /* resolveInfoPresentationGetter */ null), serviceTargets, TARGET_TYPE_CHOOSER_TARGET, - directShareToShortcutInfos) + directShareToShortcutInfos, + /* directShareToAppTargets */ null) ); - // Thread.sleep shouldn't be a thing in an integration test but it's - // necessary here because of the way the code is structured - // TODO: restructure the tests b/129870719 - Thread.sleep(((ChooserActivity) activity).mListViewUpdateDelayMs); assertThat( String.format("Chooser should have %d targets (%d apps, 1 direct, 15 A-Z)", @@ -1850,8 +1753,6 @@ public class UnbundledChooserActivityTest { @Test public void testWorkTab_displayedWhenWorkProfileUserAvailable() { - // enable the work tab feature flag - ResolverActivity.ENABLE_TABBED_VIEW = true; Intent sendIntent = createSendTextIntent(); sendIntent.setType(TEST_MIME_TYPE); markWorkProfileUserAvailable(); @@ -1859,26 +1760,22 @@ public class UnbundledChooserActivityTest { mActivityRule.launchActivity(Intent.createChooser(sendIntent, "work tab test")); waitForIdle(); - onView(withIdFromRuntimeResource("tabs")).check(matches(isDisplayed())); + onView(withId(android.R.id.tabs)).check(matches(isDisplayed())); } @Test public void testWorkTab_hiddenWhenWorkProfileUserNotAvailable() { - // enable the work tab feature flag - ResolverActivity.ENABLE_TABBED_VIEW = true; Intent sendIntent = createSendTextIntent(); sendIntent.setType(TEST_MIME_TYPE); mActivityRule.launchActivity(Intent.createChooser(sendIntent, "work tab test")); waitForIdle(); - onView(withIdFromRuntimeResource("tabs")).check(matches(not(isDisplayed()))); + onView(withId(android.R.id.tabs)).check(matches(not(isDisplayed()))); } @Test public void testWorkTab_eachTabUsesExpectedAdapter() { - // enable the work tab feature flag - ResolverActivity.ENABLE_TABBED_VIEW = true; int personalProfileTargets = 3; int otherProfileTargets = 1; List<ResolvedComponentInfo> personalResolvedComponentInfos = @@ -1897,7 +1794,7 @@ public class UnbundledChooserActivityTest { waitForIdle(); assertThat(activity.getCurrentUserHandle().getIdentifier(), is(0)); - onView(withTextFromRuntimeResource("resolver_work_tab")).perform(click()); + onView(withText(R.string.resolver_work_tab)).perform(click()); assertThat(activity.getCurrentUserHandle().getIdentifier(), is(10)); assertThat(activity.getPersonalListAdapter().getCount(), is(personalProfileTargets)); assertThat(activity.getWorkListAdapter().getCount(), is(workProfileTargets)); @@ -1905,8 +1802,6 @@ public class UnbundledChooserActivityTest { @Test public void testWorkTab_workProfileHasExpectedNumberOfTargets() throws InterruptedException { - // enable the work tab feature flag - ResolverActivity.ENABLE_TABBED_VIEW = true; markWorkProfileUserAvailable(); int workProfileTargets = 4; List<ResolvedComponentInfo> personalResolvedComponentInfos = @@ -1920,16 +1815,14 @@ public class UnbundledChooserActivityTest { final IChooserWrapper activity = (IChooserWrapper) mActivityRule.launchActivity(Intent.createChooser(sendIntent, "work tab test")); waitForIdle(); - onView(withTextFromRuntimeResource("resolver_work_tab")).perform(click()); + onView(withText(R.string.resolver_work_tab)).perform(click()); waitForIdle(); assertThat(activity.getWorkListAdapter().getCount(), is(workProfileTargets)); } @Test @Ignore - public void testWorkTab_selectingWorkTabAppOpensAppInWorkProfile() throws InterruptedException { - // enable the work tab feature flag - ResolverActivity.ENABLE_TABBED_VIEW = true; + public void testWorkTab_selectingWorkTabAppOpensAppInWorkProfile() { markWorkProfileUserAvailable(); List<ResolvedComponentInfo> personalResolvedComponentInfos = createResolvedComponentsForTestWithOtherProfile(3, /* userId */ 10); @@ -1945,13 +1838,10 @@ public class UnbundledChooserActivityTest { return true; }; - final IChooserWrapper activity = (IChooserWrapper) - mActivityRule.launchActivity(Intent.createChooser(sendIntent, "work tab test")); + mActivityRule.launchActivity(Intent.createChooser(sendIntent, "work tab test")); waitForIdle(); - onView(withTextFromRuntimeResource("resolver_work_tab")).perform(click()); + onView(withText(R.string.resolver_work_tab)).perform(click()); waitForIdle(); - // wait for the share sheet to expand - Thread.sleep(((ChooserActivity) activity).mListViewUpdateDelayMs); onView(first(allOf( withText(workResolvedComponentInfos.get(0) @@ -1964,8 +1854,6 @@ public class UnbundledChooserActivityTest { @Test public void testWorkTab_crossProfileIntentsDisabled_personalToWork_emptyStateShown() { - // enable the work tab feature flag - ResolverActivity.ENABLE_TABBED_VIEW = true; markWorkProfileUserAvailable(); int workProfileTargets = 4; List<ResolvedComponentInfo> personalResolvedComponentInfos = @@ -1979,18 +1867,17 @@ public class UnbundledChooserActivityTest { mActivityRule.launchActivity(Intent.createChooser(sendIntent, "work tab test")); waitForIdle(); - onView(withTextFromRuntimeResource("resolver_work_tab")).perform(click()); + onView(withText(R.string.resolver_work_tab)).perform(click()); waitForIdle(); - onView(withIdFromRuntimeResource("contentPanel")) + onView(withId(com.android.internal.R.id.contentPanel)) .perform(swipeUp()); - onView(withTextFromRuntimeResource("resolver_cross_profile_blocked")) + onView(withText(R.string.resolver_cross_profile_blocked)) .check(matches(isDisplayed())); } @Test public void testWorkTab_workProfileDisabled_emptyStateShown() { - // enable the work tab feature flag markWorkProfileUserAvailable(); int workProfileTargets = 4; List<ResolvedComponentInfo> personalResolvedComponentInfos = @@ -2002,22 +1889,19 @@ public class UnbundledChooserActivityTest { Intent sendIntent = createSendTextIntent(); sendIntent.setType(TEST_MIME_TYPE); - ResolverActivity.ENABLE_TABBED_VIEW = true; mActivityRule.launchActivity(Intent.createChooser(sendIntent, "work tab test")); waitForIdle(); - onView(withIdFromRuntimeResource("contentPanel")) + onView(withId(com.android.internal.R.id.contentPanel)) .perform(swipeUp()); - onView(withTextFromRuntimeResource("resolver_work_tab")).perform(click()); + onView(withText(R.string.resolver_work_tab)).perform(click()); waitForIdle(); - onView(withTextFromRuntimeResource("resolver_turn_on_work_apps")) + onView(withText(R.string.resolver_turn_on_work_apps)) .check(matches(isDisplayed())); } @Test public void testWorkTab_noWorkAppsAvailable_emptyStateShown() { - // enable the work tab feature flag - ResolverActivity.ENABLE_TABBED_VIEW = true; markWorkProfileUserAvailable(); List<ResolvedComponentInfo> personalResolvedComponentInfos = createResolvedComponentsForTest(3); @@ -2029,20 +1913,18 @@ public class UnbundledChooserActivityTest { mActivityRule.launchActivity(Intent.createChooser(sendIntent, "work tab test")); waitForIdle(); - onView(withIdFromRuntimeResource("contentPanel")) + onView(withId(com.android.internal.R.id.contentPanel)) .perform(swipeUp()); - onView(withTextFromRuntimeResource("resolver_work_tab")).perform(click()); + onView(withText(R.string.resolver_work_tab)).perform(click()); waitForIdle(); - onView(withTextFromRuntimeResource("resolver_no_work_apps_available")) + onView(withText(R.string.resolver_no_work_apps_available)) .check(matches(isDisplayed())); } @Ignore // b/220067877 @Test public void testWorkTab_xProfileOff_noAppsAvailable_workOff_xProfileOffEmptyStateShown() { - // enable the work tab feature flag - ResolverActivity.ENABLE_TABBED_VIEW = true; markWorkProfileUserAvailable(); List<ResolvedComponentInfo> personalResolvedComponentInfos = createResolvedComponentsForTest(3); @@ -2056,19 +1938,17 @@ public class UnbundledChooserActivityTest { mActivityRule.launchActivity(Intent.createChooser(sendIntent, "work tab test")); waitForIdle(); - onView(withIdFromRuntimeResource("contentPanel")) + onView(withId(com.android.internal.R.id.contentPanel)) .perform(swipeUp()); - onView(withTextFromRuntimeResource("resolver_work_tab")).perform(click()); + onView(withText(R.string.resolver_work_tab)).perform(click()); waitForIdle(); - onView(withTextFromRuntimeResource("resolver_cross_profile_blocked")) + onView(withText(R.string.resolver_cross_profile_blocked)) .check(matches(isDisplayed())); } @Test public void testWorkTab_noAppsAvailable_workOff_noAppsAvailableEmptyStateShown() { - // enable the work tab feature flag - ResolverActivity.ENABLE_TABBED_VIEW = true; markWorkProfileUserAvailable(); List<ResolvedComponentInfo> personalResolvedComponentInfos = createResolvedComponentsForTest(3); @@ -2081,12 +1961,12 @@ public class UnbundledChooserActivityTest { mActivityRule.launchActivity(Intent.createChooser(sendIntent, "work tab test")); waitForIdle(); - onView(withIdFromRuntimeResource("contentPanel")) + onView(withId(com.android.internal.R.id.contentPanel)) .perform(swipeUp()); - onView(withTextFromRuntimeResource("resolver_work_tab")).perform(click()); + onView(withText(R.string.resolver_work_tab)).perform(click()); waitForIdle(); - onView(withTextFromRuntimeResource("resolver_no_work_apps_available")) + onView(withText(R.string.resolver_no_work_apps_available)) .check(matches(isDisplayed())); } @@ -2115,7 +1995,7 @@ public class UnbundledChooserActivityTest { // timeout everywhere instead of introducing one to fix this particular test. assertThat(activity.getAdapter().getCount(), is(2)); - onView(withIdFromRuntimeResource("profile_button")).check(doesNotExist()); + onView(withId(com.android.internal.R.id.profile_button)).check(doesNotExist()); ResolveInfo[] chosen = new ResolveInfo[1]; ChooserActivityOverrideData.getInstance().onSafelyStartCallback = targetInfo -> { @@ -2128,53 +2008,11 @@ public class UnbundledChooserActivityTest { .perform(click()); waitForIdle(); - ChooserActivityLoggerFake logger = - (ChooserActivityLoggerFake) activity.getChooserActivityLogger(); - // TODO(b/211669337): Determine the expected SHARESHEET_DIRECT_LOAD_COMPLETE events. - logger.removeCallsForUiEventsOfType( - ChooserActivityLogger.SharesheetStandardEvent - .SHARESHEET_DIRECT_LOAD_COMPLETE.getId()); - - // SHARESHEET_TRIGGERED: - assertThat(logger.event(0).getId(), - is(ChooserActivityLogger.SharesheetStandardEvent.SHARESHEET_TRIGGERED.getId())); - - // SHARESHEET_STARTED: - assertThat(logger.get(1).atomId, is(FrameworkStatsLog.SHARESHEET_STARTED)); - assertThat(logger.get(1).intent, is(Intent.ACTION_SEND)); - assertThat(logger.get(1).mimeType, is("text/plain")); - assertThat(logger.get(1).packageName, is( - InstrumentationRegistry.getInstrumentation().getTargetContext().getPackageName())); - assertThat(logger.get(1).appProvidedApp, is(0)); - assertThat(logger.get(1).appProvidedDirect, is(0)); - assertThat(logger.get(1).isWorkprofile, is(false)); - assertThat(logger.get(1).previewType, is(3)); - - // SHARESHEET_APP_LOAD_COMPLETE: - assertThat(logger.event(2).getId(), - is(ChooserActivityLogger - .SharesheetStandardEvent.SHARESHEET_APP_LOAD_COMPLETE.getId())); - - // Next are just artifacts of test set-up: - assertThat(logger.event(3).getId(), - is(ChooserActivityLogger - .SharesheetStandardEvent.SHARESHEET_EMPTY_DIRECT_SHARE_ROW.getId())); - assertThat(logger.event(4).getId(), - is(ChooserActivityLogger.SharesheetStandardEvent.SHARESHEET_EXPANDED.getId())); - - // SHARESHEET_APP_TARGET_SELECTED: - assertThat(logger.get(5).atomId, is(FrameworkStatsLog.RANKING_SELECTED)); - assertThat(logger.get(5).targetType, - is(ChooserActivityLogger - .SharesheetTargetSelectedEvent.SHARESHEET_APP_TARGET_SELECTED.getId())); - - // No more events. - assertThat(logger.numCalls(), is(6)); } - @Test @Ignore - public void testDirectTargetLogging() throws InterruptedException { + @Test + public void testDirectTargetLogging() { Intent sendIntent = createSendTextIntent(); // We need app targets for direct targets to get displayed List<ResolvedComponentInfo> resolvedComponentInfos = createResolvedComponentsForTest(2); @@ -2189,41 +2027,59 @@ public class UnbundledChooserActivityTest { Mockito.isA(List.class))) .thenReturn(resolvedComponentInfos); - // Create direct share target - List<ChooserTarget> serviceTargets = createDirectShareTargets(1, - resolvedComponentInfos.get(0).getResolveInfoAt(0).activityInfo.packageName); - ResolveInfo ri = ResolverDataProvider.createResolveInfo(3, 0); + // create test shortcut loader factory, remember loaders and their callbacks + SparseArray<Pair<ShortcutLoader, Consumer<ShortcutLoader.Result>>> shortcutLoaders = + new SparseArray<>(); + ChooserActivityOverrideData.getInstance().shortcutLoaderFactory = + (userHandle, callback) -> { + Pair<ShortcutLoader, Consumer<ShortcutLoader.Result>> pair = + new Pair<>(mock(ShortcutLoader.class), callback); + shortcutLoaders.put(userHandle.getIdentifier(), pair); + return pair.first; + }; // Start activity final IChooserWrapper activity = (IChooserWrapper) mActivityRule.launchActivity(Intent.createChooser(sendIntent, null)); + waitForIdle(); - // Insert the direct share target - Map<ChooserTarget, ShortcutInfo> directShareToShortcutInfos = new HashMap<>(); - directShareToShortcutInfos.put(serviceTargets.get(0), null); - InstrumentationRegistry.getInstrumentation().runOnMainSync( - () -> activity.getAdapter().addServiceResults( - activity.createTestDisplayResolveInfo(sendIntent, - ri, - "testLabel", - "testInfo", - sendIntent, - /* resolveInfoPresentationGetter */ null), - serviceTargets, - TARGET_TYPE_CHOOSER_TARGET, - directShareToShortcutInfos) + // verify that ShortcutLoader was queried + ArgumentCaptor<DisplayResolveInfo[]> appTargets = + ArgumentCaptor.forClass(DisplayResolveInfo[].class); + verify(shortcutLoaders.get(0).first, times(1)) + .queryShortcuts(appTargets.capture()); + + // send shortcuts + assertThat( + "Wrong number of app targets", + appTargets.getValue().length, + is(resolvedComponentInfos.size())); + List<ChooserTarget> serviceTargets = createDirectShareTargets(1, + resolvedComponentInfos.get(0).getResolveInfoAt(0).activityInfo.packageName); + ShortcutLoader.Result result = new ShortcutLoader.Result( + // TODO: test another value as well + false, + appTargets.getValue(), + new ShortcutLoader.ShortcutResultInfo[] { + new ShortcutLoader.ShortcutResultInfo( + appTargets.getValue()[0], + serviceTargets + ) + }, + new HashMap<>(), + new HashMap<>() ); - // Thread.sleep shouldn't be a thing in an integration test but it's - // necessary here because of the way the code is structured - // TODO: restructure the tests b/129870719 - Thread.sleep(((ChooserActivity) activity).mListViewUpdateDelayMs); + activity.getMainExecutor().execute(() -> shortcutLoaders.get(0).second.accept(result)); + waitForIdle(); assertThat("Chooser should have 3 targets (2 apps, 1 direct)", activity.getAdapter().getCount(), is(3)); assertThat("Chooser should have exactly one selectable direct target", activity.getAdapter().getSelectableServiceTargetCount(), is(1)); - assertThat("The resolver info must match the resolver info used to create the target", - activity.getAdapter().getItem(0).getResolveInfo(), is(ri)); + assertThat( + "The resolver info must match the resolver info used to create the target", + activity.getAdapter().getItem(0).getResolveInfo(), + is(resolvedComponentInfos.get(0).getResolveInfoAt(0))); // Click on the direct target String name = serviceTargets.get(0).getTitle().toString(); @@ -2231,34 +2087,11 @@ public class UnbundledChooserActivityTest { .perform(click()); waitForIdle(); - ChooserActivityLoggerFake logger = - (ChooserActivityLoggerFake) activity.getChooserActivityLogger(); - assertThat(logger.numCalls(), is(6)); - // first one should be SHARESHEET_TRIGGERED uievent - assertThat(logger.get(0).atomId, is(FrameworkStatsLog.UI_EVENT_REPORTED)); - assertThat(logger.get(0).event.getId(), - is(ChooserActivityLogger.SharesheetStandardEvent.SHARESHEET_TRIGGERED.getId())); - // second one should be SHARESHEET_STARTED event - assertThat(logger.get(1).atomId, is(FrameworkStatsLog.SHARESHEET_STARTED)); - assertThat(logger.get(1).intent, is(Intent.ACTION_SEND)); - assertThat(logger.get(1).mimeType, is("text/plain")); - assertThat(logger.get(1).packageName, is( - InstrumentationRegistry.getInstrumentation().getTargetContext().getPackageName())); - assertThat(logger.get(1).appProvidedApp, is(0)); - assertThat(logger.get(1).appProvidedDirect, is(0)); - assertThat(logger.get(1).isWorkprofile, is(false)); - assertThat(logger.get(1).previewType, is(3)); - // third one should be SHARESHEET_APP_LOAD_COMPLETE uievent - assertThat(logger.get(2).atomId, is(FrameworkStatsLog.UI_EVENT_REPORTED)); - assertThat(logger.get(2).event.getId(), - is(ChooserActivityLogger - .SharesheetStandardEvent.SHARESHEET_APP_LOAD_COMPLETE.getId())); - // fourth and fifth are just artifacts of test set-up - // sixth one should be ranking atom with SHARESHEET_COPY_TARGET_SELECTED event - assertThat(logger.get(5).atomId, is(FrameworkStatsLog.RANKING_SELECTED)); - assertThat(logger.get(5).targetType, - is(ChooserActivityLogger - .SharesheetTargetSelectedEvent.SHARESHEET_SERVICE_TARGET_SELECTED.getId())); + ChooserActivityLogger logger = activity.getChooserActivityLogger(); + ArgumentCaptor<Integer> typeCaptor = ArgumentCaptor.forClass(Integer.class); + Mockito.verify(logger, times(1)) + .logShareTargetSelected(typeCaptor.capture(), any(), anyInt(), anyBoolean()); + assertThat(typeCaptor.getValue(), is(ChooserActivity.SELECTION_TYPE_SERVICE)); } @Test @Ignore @@ -2290,44 +2123,7 @@ public class UnbundledChooserActivityTest { assertThat("Chooser should have no direct targets", activity.getAdapter().getSelectableServiceTargetCount(), is(0)); - ChooserActivityLoggerFake logger = - (ChooserActivityLoggerFake) activity.getChooserActivityLogger(); - // TODO(b/211669337): Determine the expected SHARESHEET_DIRECT_LOAD_COMPLETE events. - logger.removeCallsForUiEventsOfType( - ChooserActivityLogger.SharesheetStandardEvent - .SHARESHEET_DIRECT_LOAD_COMPLETE.getId()); - - // SHARESHEET_TRIGGERED: - assertThat(logger.event(0).getId(), - is(ChooserActivityLogger.SharesheetStandardEvent.SHARESHEET_TRIGGERED.getId())); - - // SHARESHEET_STARTED: - assertThat(logger.get(1).atomId, is(FrameworkStatsLog.SHARESHEET_STARTED)); - assertThat(logger.get(1).intent, is(Intent.ACTION_SEND)); - assertThat(logger.get(1).mimeType, is("text/plain")); - assertThat(logger.get(1).packageName, is( - InstrumentationRegistry.getInstrumentation().getTargetContext().getPackageName())); - assertThat(logger.get(1).appProvidedApp, is(0)); - assertThat(logger.get(1).appProvidedDirect, is(0)); - assertThat(logger.get(1).isWorkprofile, is(false)); - assertThat(logger.get(1).previewType, is(3)); - - // SHARESHEET_APP_LOAD_COMPLETE: - assertThat(logger.event(2).getId(), - is(ChooserActivityLogger - .SharesheetStandardEvent.SHARESHEET_APP_LOAD_COMPLETE.getId())); - - // SHARESHEET_EMPTY_DIRECT_SHARE_ROW: - assertThat(logger.event(3).getId(), - is(ChooserActivityLogger - .SharesheetStandardEvent.SHARESHEET_EMPTY_DIRECT_SHARE_ROW.getId())); - - // Next is just an artifact of test set-up: - assertThat(logger.event(4).getId(), - is(ChooserActivityLogger.SharesheetStandardEvent.SHARESHEET_EXPANDED.getId())); - - assertThat(logger.numCalls(), is(5)); } @Ignore // b/220067877 @@ -2351,58 +2147,14 @@ public class UnbundledChooserActivityTest { mActivityRule.launchActivity(Intent.createChooser(sendIntent, null)); waitForIdle(); - onView(withIdFromRuntimeResource("chooser_copy_button")).check(matches(isDisplayed())); - onView(withIdFromRuntimeResource("chooser_copy_button")).perform(click()); - - ChooserActivityLoggerFake logger = - (ChooserActivityLoggerFake) activity.getChooserActivityLogger(); + onView(withId(com.android.internal.R.id.chooser_copy_button)).check(matches(isDisplayed())); + onView(withId(com.android.internal.R.id.chooser_copy_button)).perform(click()); // TODO(b/211669337): Determine the expected SHARESHEET_DIRECT_LOAD_COMPLETE events. - logger.removeCallsForUiEventsOfType( - ChooserActivityLogger.SharesheetStandardEvent - .SHARESHEET_DIRECT_LOAD_COMPLETE.getId()); - - // SHARESHEET_TRIGGERED: - assertThat(logger.event(0).getId(), - is(ChooserActivityLogger.SharesheetStandardEvent.SHARESHEET_TRIGGERED.getId())); - - // SHARESHEET_STARTED: - assertThat(logger.get(1).atomId, is(FrameworkStatsLog.SHARESHEET_STARTED)); - assertThat(logger.get(1).intent, is(Intent.ACTION_SEND)); - assertThat(logger.get(1).mimeType, is("text/plain")); - assertThat(logger.get(1).packageName, is( - InstrumentationRegistry.getInstrumentation().getTargetContext().getPackageName())); - assertThat(logger.get(1).appProvidedApp, is(0)); - assertThat(logger.get(1).appProvidedDirect, is(0)); - assertThat(logger.get(1).isWorkprofile, is(false)); - assertThat(logger.get(1).previewType, is(3)); - - // SHARESHEET_APP_LOAD_COMPLETE: - assertThat(logger.event(2).getId(), - is(ChooserActivityLogger - .SharesheetStandardEvent.SHARESHEET_APP_LOAD_COMPLETE.getId())); - - // Next are just artifacts of test set-up: - assertThat(logger.event(3).getId(), - is(ChooserActivityLogger - .SharesheetStandardEvent.SHARESHEET_EMPTY_DIRECT_SHARE_ROW.getId())); - assertThat(logger.event(4).getId(), - is(ChooserActivityLogger.SharesheetStandardEvent.SHARESHEET_EXPANDED.getId())); - - // SHARESHEET_COPY_TARGET_SELECTED: - assertThat(logger.get(5).atomId, is(FrameworkStatsLog.RANKING_SELECTED)); - assertThat(logger.get(5).targetType, - is(ChooserActivityLogger - .SharesheetTargetSelectedEvent.SHARESHEET_COPY_TARGET_SELECTED.getId())); - - // No more events. - assertThat(logger.numCalls(), is(6)); } @Test @Ignore("b/222124533") public void testSwitchProfileLogging() throws InterruptedException { - // enable the work tab feature flag - ResolverActivity.ENABLE_TABBED_VIEW = true; markWorkProfileUserAvailable(); int workProfileTargets = 4; List<ResolvedComponentInfo> personalResolvedComponentInfos = @@ -2416,134 +2168,16 @@ public class UnbundledChooserActivityTest { final IChooserWrapper activity = (IChooserWrapper) mActivityRule.launchActivity(Intent.createChooser(sendIntent, "work tab test")); waitForIdle(); - onView(withTextFromRuntimeResource("resolver_work_tab")).perform(click()); + onView(withText(R.string.resolver_work_tab)).perform(click()); waitForIdle(); - onView(withTextFromRuntimeResource("resolver_personal_tab")).perform(click()); + onView(withText(R.string.resolver_personal_tab)).perform(click()); waitForIdle(); - ChooserActivityLoggerFake logger = - (ChooserActivityLoggerFake) activity.getChooserActivityLogger(); - // TODO(b/211669337): Determine the expected SHARESHEET_DIRECT_LOAD_COMPLETE events. - logger.removeCallsForUiEventsOfType( - ChooserActivityLogger.SharesheetStandardEvent - .SHARESHEET_DIRECT_LOAD_COMPLETE.getId()); - - // SHARESHEET_TRIGGERED: - assertThat(logger.event(0).getId(), - is(ChooserActivityLogger.SharesheetStandardEvent.SHARESHEET_TRIGGERED.getId())); - - // SHARESHEET_STARTED: - assertThat(logger.get(1).atomId, is(FrameworkStatsLog.SHARESHEET_STARTED)); - assertThat(logger.get(1).intent, is(Intent.ACTION_SEND)); - assertThat(logger.get(1).mimeType, is(TEST_MIME_TYPE)); - assertThat(logger.get(1).packageName, is( - InstrumentationRegistry.getInstrumentation().getTargetContext().getPackageName())); - assertThat(logger.get(1).appProvidedApp, is(0)); - assertThat(logger.get(1).appProvidedDirect, is(0)); - assertThat(logger.get(1).isWorkprofile, is(false)); - assertThat(logger.get(1).previewType, is(3)); - - // SHARESHEET_APP_LOAD_COMPLETE: - assertThat(logger.event(2).getId(), - is(ChooserActivityLogger - .SharesheetStandardEvent.SHARESHEET_APP_LOAD_COMPLETE.getId())); - - // Next is just an artifact of test set-up: - assertThat(logger.event(3).getId(), - is(ChooserActivityLogger - .SharesheetStandardEvent.SHARESHEET_EMPTY_DIRECT_SHARE_ROW.getId())); - - // SHARESHEET_PROFILE_CHANGED: - assertThat(logger.event(4).getId(), - is(ChooserActivityLogger.SharesheetStandardEvent - .SHARESHEET_PROFILE_CHANGED.getId())); - - // Repeat the loading steps in the new profile: - - // SHARESHEET_APP_LOAD_COMPLETE: - assertThat(logger.event(5).getId(), - is(ChooserActivityLogger - .SharesheetStandardEvent.SHARESHEET_APP_LOAD_COMPLETE.getId())); - - // Next is again an artifact of test set-up: - assertThat(logger.event(6).getId(), - is(ChooserActivityLogger - .SharesheetStandardEvent.SHARESHEET_EMPTY_DIRECT_SHARE_ROW.getId())); - - // SHARESHEET_PROFILE_CHANGED: - assertThat(logger.event(7).getId(), - is(ChooserActivityLogger.SharesheetStandardEvent - .SHARESHEET_PROFILE_CHANGED.getId())); - - // No more events (this profile was already loaded). - assertThat(logger.numCalls(), is(8)); - } - - @Test - public void testAutolaunch_singleTarget_wifthWorkProfileAndTabbedViewOff_noAutolaunch() { - ResolverActivity.ENABLE_TABBED_VIEW = false; - List<ResolvedComponentInfo> personalResolvedComponentInfos = - createResolvedComponentsForTestWithOtherProfile(2, /* userId */ 10); - when( - ChooserActivityOverrideData - .getInstance() - .resolverListController - .getResolversForIntent( - Mockito.anyBoolean(), - Mockito.anyBoolean(), - Mockito.anyBoolean(), - Mockito.isA(List.class))) - .thenReturn(new ArrayList<>(personalResolvedComponentInfos)); - Intent sendIntent = createSendTextIntent(); - sendIntent.setType(TEST_MIME_TYPE); - ResolveInfo[] chosen = new ResolveInfo[1]; - ChooserActivityOverrideData.getInstance().onSafelyStartCallback = targetInfo -> { - chosen[0] = targetInfo.getResolveInfo(); - return true; - }; - waitForIdle(); - - mActivityRule.launchActivity(Intent.createChooser(sendIntent, "work tab test")); - waitForIdle(); - - assertTrue(chosen[0] == null); - } - - @Test - public void testAutolaunch_singleTarget_noWorkProfile_autolaunch() { - ResolverActivity.ENABLE_TABBED_VIEW = false; - List<ResolvedComponentInfo> personalResolvedComponentInfos = - createResolvedComponentsForTest(1); - when( - ChooserActivityOverrideData - .getInstance() - .resolverListController - .getResolversForIntent( - Mockito.anyBoolean(), - Mockito.anyBoolean(), - Mockito.anyBoolean(), - Mockito.isA(List.class))) - .thenReturn(new ArrayList<>(personalResolvedComponentInfos)); - Intent sendIntent = createSendTextIntent(); - sendIntent.setType(TEST_MIME_TYPE); - ResolveInfo[] chosen = new ResolveInfo[1]; - ChooserActivityOverrideData.getInstance().onSafelyStartCallback = targetInfo -> { - chosen[0] = targetInfo.getResolveInfo(); - return true; - }; - waitForIdle(); - - mActivityRule.launchActivity(Intent.createChooser(sendIntent, "work tab test")); - waitForIdle(); - - assertThat(chosen[0], is(personalResolvedComponentInfos.get(0).getResolveInfoAt(0))); } @Test public void testWorkTab_onePersonalTarget_emptyStateOnWorkTarget_autolaunch() { - // enable the work tab feature flag - ResolverActivity.ENABLE_TABBED_VIEW = true; markWorkProfileUserAvailable(); int workProfileTargets = 4; List<ResolvedComponentInfo> personalResolvedComponentInfos = @@ -2559,7 +2193,7 @@ public class UnbundledChooserActivityTest { return true; }; - mActivityRule.launchActivity(sendIntent); + mActivityRule.launchActivity(Intent.createChooser(sendIntent, "Test")); waitForIdle(); assertThat(chosen[0], is(personalResolvedComponentInfos.get(1).getResolveInfoAt(0))); @@ -2591,7 +2225,7 @@ public class UnbundledChooserActivityTest { when( ChooserActivityOverrideData .getInstance().packageManager - .resolveActivity(any(Intent.class), anyInt())) + .resolveActivity(any(Intent.class), any())) .thenReturn(ri); waitForIdle(); @@ -2605,8 +2239,6 @@ public class UnbundledChooserActivityTest { @Test public void testWorkTab_withInitialIntents_workTabDoesNotIncludePersonalInitialIntents() { - // enable the work tab feature flag - ResolverActivity.ENABLE_TABBED_VIEW = true; markWorkProfileUserAvailable(); int workProfileTargets = 1; List<ResolvedComponentInfo> personalResolvedComponentInfos = @@ -2624,7 +2256,7 @@ public class UnbundledChooserActivityTest { ChooserActivityOverrideData .getInstance() .packageManager - .resolveActivity(any(Intent.class), anyInt())) + .resolveActivity(any(Intent.class), any())) .thenReturn(createFakeResolveInfo()); waitForIdle(); @@ -2637,8 +2269,6 @@ public class UnbundledChooserActivityTest { @Test public void testWorkTab_xProfileIntentsDisabled_personalToWork_nonSendIntent_emptyStateShown() { - // enable the work tab feature flag - ResolverActivity.ENABLE_TABBED_VIEW = true; markWorkProfileUserAvailable(); int workProfileTargets = 4; List<ResolvedComponentInfo> personalResolvedComponentInfos = @@ -2657,24 +2287,22 @@ public class UnbundledChooserActivityTest { ChooserActivityOverrideData .getInstance() .packageManager - .resolveActivity(any(Intent.class), anyInt())) + .resolveActivity(any(Intent.class), any())) .thenReturn(createFakeResolveInfo()); mActivityRule.launchActivity(chooserIntent); waitForIdle(); - onView(withTextFromRuntimeResource("resolver_work_tab")).perform(click()); + onView(withText(R.string.resolver_work_tab)).perform(click()); waitForIdle(); - onView(withIdFromRuntimeResource("contentPanel")) + onView(withId(com.android.internal.R.id.contentPanel)) .perform(swipeUp()); - onView(withTextFromRuntimeResource("resolver_cross_profile_blocked")) + onView(withText(R.string.resolver_cross_profile_blocked)) .check(matches(isDisplayed())); } @Test public void testWorkTab_noWorkAppsAvailable_nonSendIntent_emptyStateShown() { - // enable the work tab feature flag - ResolverActivity.ENABLE_TABBED_VIEW = true; markWorkProfileUserAvailable(); List<ResolvedComponentInfo> personalResolvedComponentInfos = createResolvedComponentsForTest(3); @@ -2691,17 +2319,17 @@ public class UnbundledChooserActivityTest { ChooserActivityOverrideData .getInstance() .packageManager - .resolveActivity(any(Intent.class), anyInt())) + .resolveActivity(any(Intent.class), any())) .thenReturn(createFakeResolveInfo()); mActivityRule.launchActivity(chooserIntent); waitForIdle(); - onView(withIdFromRuntimeResource("contentPanel")) + onView(withId(com.android.internal.R.id.contentPanel)) .perform(swipeUp()); - onView(withTextFromRuntimeResource("resolver_work_tab")).perform(click()); + onView(withText(R.string.resolver_work_tab)).perform(click()); waitForIdle(); - onView(withTextFromRuntimeResource("resolver_no_work_apps_available")) + onView(withText(R.string.resolver_no_work_apps_available)) .check(matches(isDisplayed())); } @@ -2726,7 +2354,7 @@ public class UnbundledChooserActivityTest { ChooserActivityOverrideData .getInstance() .packageManager - .resolveActivity(any(Intent.class), anyInt())) + .resolveActivity(any(Intent.class), any())) .thenReturn(ri); waitForIdle(); @@ -2740,150 +2368,35 @@ public class UnbundledChooserActivityTest { } @Test - public void testWorkTab_selectingWorkTabWithPausedWorkProfile_directShareTargetsNotQueried() { - // enable the work tab feature flag - ResolverActivity.ENABLE_TABBED_VIEW = true; - markWorkProfileUserAvailable(); - List<ResolvedComponentInfo> personalResolvedComponentInfos = - createResolvedComponentsForTestWithOtherProfile(3, /* userId */ 10); - List<ResolvedComponentInfo> workResolvedComponentInfos = - createResolvedComponentsForTest(3); - setupResolverControllers(personalResolvedComponentInfos, workResolvedComponentInfos); - ChooserActivityOverrideData.getInstance().isQuietModeEnabled = true; - boolean[] isQueryDirectShareCalledOnWorkProfile = new boolean[] { false }; - ChooserActivityOverrideData.getInstance().onQueryDirectShareTargets = - chooserListAdapter -> { - isQueryDirectShareCalledOnWorkProfile[0] = - (chooserListAdapter.getUserHandle().getIdentifier() == 10); - return null; - }; - Intent sendIntent = createSendTextIntent(); - sendIntent.setType(TEST_MIME_TYPE); - - mActivityRule.launchActivity(Intent.createChooser(sendIntent, "work tab test")); - waitForIdle(); - onView(withIdFromRuntimeResource("contentPanel")) - .perform(swipeUp()); - onView(withTextFromRuntimeResource("resolver_work_tab")).perform(click()); - waitForIdle(); - - assertFalse("Direct share targets were queried on a paused work profile", - isQueryDirectShareCalledOnWorkProfile[0]); - } - - @Test - public void testWorkTab_selectingWorkTabWithNotRunningWorkUser_directShareTargetsNotQueried() { - // enable the work tab feature flag - ResolverActivity.ENABLE_TABBED_VIEW = true; - markWorkProfileUserAvailable(); - List<ResolvedComponentInfo> personalResolvedComponentInfos = - createResolvedComponentsForTestWithOtherProfile(3, /* userId */ 10); - List<ResolvedComponentInfo> workResolvedComponentInfos = - createResolvedComponentsForTest(3); - setupResolverControllers(personalResolvedComponentInfos, workResolvedComponentInfos); - ChooserActivityOverrideData.getInstance().isWorkProfileUserRunning = false; - boolean[] isQueryDirectShareCalledOnWorkProfile = new boolean[] { false }; - ChooserActivityOverrideData.getInstance().onQueryDirectShareTargets = - chooserListAdapter -> { - isQueryDirectShareCalledOnWorkProfile[0] = - (chooserListAdapter.getUserHandle().getIdentifier() == 10); - return null; - }; - Intent sendIntent = createSendTextIntent(); - sendIntent.setType(TEST_MIME_TYPE); - - mActivityRule.launchActivity(Intent.createChooser(sendIntent, "work tab test")); - waitForIdle(); - onView(withIdFromRuntimeResource("contentPanel")) - .perform(swipeUp()); - onView(withTextFromRuntimeResource("resolver_work_tab")).perform(click()); - waitForIdle(); - - assertFalse("Direct share targets were queried on a locked work profile user", - isQueryDirectShareCalledOnWorkProfile[0]); - } - - @Test - public void testWorkTab_workUserNotRunning_workTargetsShown() { - // enable the work tab feature flag - ResolverActivity.ENABLE_TABBED_VIEW = true; + public void test_query_shortcut_loader_for_the_selected_tab() { markWorkProfileUserAvailable(); List<ResolvedComponentInfo> personalResolvedComponentInfos = createResolvedComponentsForTestWithOtherProfile(3, /* userId */ 10); List<ResolvedComponentInfo> workResolvedComponentInfos = createResolvedComponentsForTest(3); setupResolverControllers(personalResolvedComponentInfos, workResolvedComponentInfos); - Intent sendIntent = createSendTextIntent(); - sendIntent.setType(TEST_MIME_TYPE); - ChooserActivityOverrideData.getInstance().isWorkProfileUserRunning = false; - - final ChooserActivity activity = - mActivityRule.launchActivity(Intent.createChooser(sendIntent, "work tab test")); - final IChooserWrapper wrapper = (IChooserWrapper) activity; - waitForIdle(); - onView(withIdFromRuntimeResource("contentPanel")).perform(swipeUp()); - onView(withTextFromRuntimeResource("resolver_work_tab")).perform(click()); - waitForIdle(); - - assertEquals(3, wrapper.getWorkListAdapter().getCount()); - } - - @Test - public void testWorkTab_selectingWorkTabWithLockedWorkUser_directShareTargetsNotQueried() { - // enable the work tab feature flag - ResolverActivity.ENABLE_TABBED_VIEW = true; - markWorkProfileUserAvailable(); - List<ResolvedComponentInfo> personalResolvedComponentInfos = - createResolvedComponentsForTestWithOtherProfile(3, /* userId */ 10); - List<ResolvedComponentInfo> workResolvedComponentInfos = - createResolvedComponentsForTest(3); - setupResolverControllers(personalResolvedComponentInfos, workResolvedComponentInfos); - ChooserActivityOverrideData.getInstance().isWorkProfileUserUnlocked = false; - boolean[] isQueryDirectShareCalledOnWorkProfile = new boolean[] { false }; - ChooserActivityOverrideData.getInstance().onQueryDirectShareTargets = - chooserListAdapter -> { - isQueryDirectShareCalledOnWorkProfile[0] = - (chooserListAdapter.getUserHandle().getIdentifier() == 10); - return null; - }; + ShortcutLoader personalProfileShortcutLoader = mock(ShortcutLoader.class); + ShortcutLoader workProfileShortcutLoader = mock(ShortcutLoader.class); + final SparseArray<ShortcutLoader> shortcutLoaders = new SparseArray<>(); + shortcutLoaders.put(0, personalProfileShortcutLoader); + shortcutLoaders.put(10, workProfileShortcutLoader); + ChooserActivityOverrideData.getInstance().shortcutLoaderFactory = + (userHandle, callback) -> shortcutLoaders.get(userHandle.getIdentifier(), null); Intent sendIntent = createSendTextIntent(); sendIntent.setType(TEST_MIME_TYPE); mActivityRule.launchActivity(Intent.createChooser(sendIntent, "work tab test")); waitForIdle(); - onView(withIdFromRuntimeResource("contentPanel")) + onView(withId(com.android.internal.R.id.contentPanel)) .perform(swipeUp()); - onView(withTextFromRuntimeResource("resolver_work_tab")).perform(click()); waitForIdle(); - assertFalse("Direct share targets were queried on a locked work profile user", - isQueryDirectShareCalledOnWorkProfile[0]); - } + verify(personalProfileShortcutLoader, times(1)).queryShortcuts(any()); - @Test - public void testWorkTab_workUserLocked_workTargetsShown() { - // enable the work tab feature flag - ResolverActivity.ENABLE_TABBED_VIEW = true; - markWorkProfileUserAvailable(); - List<ResolvedComponentInfo> personalResolvedComponentInfos = - createResolvedComponentsForTestWithOtherProfile(3, /* userId */ 10); - List<ResolvedComponentInfo> workResolvedComponentInfos = - createResolvedComponentsForTest(3); - setupResolverControllers(personalResolvedComponentInfos, workResolvedComponentInfos); - Intent sendIntent = createSendTextIntent(); - sendIntent.setType(TEST_MIME_TYPE); - ChooserActivityOverrideData.getInstance().isWorkProfileUserUnlocked = false; - - final ChooserActivity activity = - mActivityRule.launchActivity(Intent.createChooser(sendIntent, "work tab test")); - final IChooserWrapper wrapper = (IChooserWrapper) activity; - waitForIdle(); - onView(withIdFromRuntimeResource("contentPanel")) - .perform(swipeUp()); - onView(withTextFromRuntimeResource("resolver_work_tab")).perform(click()); + onView(withText(R.string.resolver_work_tab)).perform(click()); waitForIdle(); - assertEquals(3, wrapper.getWorkListAdapter().getCount()); + verify(workProfileShortcutLoader, times(1)).queryShortcuts(any()); } private Intent createChooserIntent(Intent intent, Intent[] initialIntents) { @@ -3092,21 +2605,6 @@ public class UnbundledChooserActivityTest { return shortcuts; } - private void assertCorrectShortcutToChooserTargetConversion(List<ShareShortcutInfo> shortcuts, - List<ChooserTarget> chooserTargets, int[] expectedOrder, float[] expectedScores) { - assertEquals(expectedOrder.length, chooserTargets.size()); - for (int i = 0; i < chooserTargets.size(); i++) { - ChooserTarget ct = chooserTargets.get(i); - ShortcutInfo si = shortcuts.get(expectedOrder[i]).getShortcutInfo(); - ComponentName cn = shortcuts.get(expectedOrder[i]).getTargetComponent(); - - assertEquals(si.getId(), ct.getIntentExtras().getString(Intent.EXTRA_SHORTCUT_ID)); - assertEquals(si.getShortLabel(), ct.getTitle()); - assertThat(Math.abs(expectedScores[i] - ct.getScore()) < 0.000001, is(true)); - assertEquals(cn.flattenToString(), ct.getComponentName().flattenToString()); - } - } - private void markWorkProfileUserAvailable() { ChooserActivityOverrideData.getInstance().workProfileUserHandle = UserHandle.of(10); } @@ -3147,14 +2645,6 @@ public class UnbundledChooserActivityTest { .thenReturn(new ArrayList<>(personalResolvedComponentInfos)); } - private Matcher<View> withIdFromRuntimeResource(String id) { - return withId(getRuntimeResourceId(id, "id")); - } - - private Matcher<View> withTextFromRuntimeResource(String id) { - return withText(getRuntimeResourceId(id, "string")); - } - private static GridRecyclerSpanCountMatcher withGridColumnCount(int columnCount) { return new GridRecyclerSpanCountMatcher(Matchers.is(columnCount)); } @@ -3214,25 +2704,17 @@ public class UnbundledChooserActivityTest { .thenReturn(targetsPerRow); } - // ChooserWrapperActivity inherits from the framework ChooserActivity, so if the framework - // resources have been updated since the framework was last built/pushed, the inherited behavior - // (which is the focus of our testing) will still be implemented in terms of the old resource - // IDs; then when we try to assert those IDs in tests (e.g. `onView(withText(R.string.foo))`), - // the expected values won't match. The tests can instead call this method (with the same - // general semantics as Resources#getIdentifier() e.g. `getRuntimeResourceId("foo", "string")`) - // to refer to the resource by that name in the runtime chooser, regardless of whether the - // framework code on the device is up-to-date. - // TODO: is there a better way to do this? (Other than abandoning inheritance-based DI wrapper?) - private int getRuntimeResourceId(String name, String defType) { - int id = -1; - if (ChooserActivityOverrideData.getInstance().resources != null) { - id = ChooserActivityOverrideData.getInstance().resources.getIdentifier( - name, defType, "android"); - } else { - id = mActivityRule.getActivity().getResources().getIdentifier(name, defType, "android"); - } - assertThat(id, greaterThan(0)); - - return id; + private SparseArray<Pair<ShortcutLoader, Consumer<ShortcutLoader.Result>>> + createShortcutLoaderFactory() { + SparseArray<Pair<ShortcutLoader, Consumer<ShortcutLoader.Result>>> shortcutLoaders = + new SparseArray<>(); + ChooserActivityOverrideData.getInstance().shortcutLoaderFactory = + (userHandle, callback) -> { + Pair<ShortcutLoader, Consumer<ShortcutLoader.Result>> pair = + new Pair<>(mock(ShortcutLoader.class), callback); + shortcutLoaders.put(userHandle.getIdentifier(), pair); + return pair.first; + }; + return shortcutLoaders; } } diff --git a/java/tests/src/com/android/intentresolver/UnbundledChooserActivityWorkProfileTest.java b/java/tests/src/com/android/intentresolver/UnbundledChooserActivityWorkProfileTest.java new file mode 100644 index 00000000..b7eecb3f --- /dev/null +++ b/java/tests/src/com/android/intentresolver/UnbundledChooserActivityWorkProfileTest.java @@ -0,0 +1,455 @@ +/* + * 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 androidx.test.espresso.Espresso.onView; +import static androidx.test.espresso.action.ViewActions.click; +import static androidx.test.espresso.action.ViewActions.swipeUp; +import static androidx.test.espresso.assertion.ViewAssertions.matches; +import static androidx.test.espresso.matcher.ViewMatchers.isDisplayed; +import static androidx.test.espresso.matcher.ViewMatchers.withId; +import static androidx.test.espresso.matcher.ViewMatchers.withText; + +import static com.android.intentresolver.UnbundledChooserActivityWorkProfileTest.TestCase.ExpectedBlocker.NO_BLOCKER; +import static com.android.intentresolver.UnbundledChooserActivityWorkProfileTest.TestCase.ExpectedBlocker.PERSONAL_PROFILE_ACCESS_BLOCKER; +import static com.android.intentresolver.UnbundledChooserActivityWorkProfileTest.TestCase.ExpectedBlocker.PERSONAL_PROFILE_SHARE_BLOCKER; +import static com.android.intentresolver.UnbundledChooserActivityWorkProfileTest.TestCase.ExpectedBlocker.WORK_PROFILE_ACCESS_BLOCKER; +import static com.android.intentresolver.UnbundledChooserActivityWorkProfileTest.TestCase.ExpectedBlocker.WORK_PROFILE_SHARE_BLOCKER; +import static com.android.intentresolver.UnbundledChooserActivityWorkProfileTest.TestCase.Tab.PERSONAL; +import static com.android.intentresolver.UnbundledChooserActivityWorkProfileTest.TestCase.Tab.WORK; +import static com.android.intentresolver.ChooserWrapperActivity.sOverrides; + +import static org.hamcrest.CoreMatchers.not; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.when; + +import android.companion.DeviceFilter; +import android.content.Intent; +import android.os.UserHandle; + +import androidx.test.InstrumentationRegistry; +import androidx.test.espresso.NoMatchingViewException; +import androidx.test.rule.ActivityTestRule; + +import com.android.internal.R; +import com.android.intentresolver.ResolverActivity.ResolvedComponentInfo; +import com.android.intentresolver.UnbundledChooserActivityWorkProfileTest.TestCase.Tab; + +import org.junit.Before; +import org.junit.Rule; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.junit.runners.Parameterized; +import org.mockito.Mockito; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collection; +import java.util.List; + +@DeviceFilter.MediumType +@RunWith(Parameterized.class) +public class UnbundledChooserActivityWorkProfileTest { + + private static final UserHandle PERSONAL_USER_HANDLE = InstrumentationRegistry + .getInstrumentation().getTargetContext().getUser(); + private static final UserHandle WORK_USER_HANDLE = UserHandle.of(10); + + @Rule + public ActivityTestRule<ChooserWrapperActivity> mActivityRule = + new ActivityTestRule<>(ChooserWrapperActivity.class, false, + false); + private final TestCase mTestCase; + + public UnbundledChooserActivityWorkProfileTest(TestCase testCase) { + mTestCase = testCase; + } + + @Before + public void cleanOverrideData() { + // TODO: use the other form of `adoptShellPermissionIdentity()` where we explicitly list the + // permissions we require (which we'll read from the manifest at runtime). + InstrumentationRegistry + .getInstrumentation() + .getUiAutomation() + .adoptShellPermissionIdentity(); + + sOverrides.reset(); + } + + @Test + public void testBlocker() { + setUpPersonalAndWorkComponentInfos(); + sOverrides.hasCrossProfileIntents = mTestCase.hasCrossProfileIntents(); + sOverrides.myUserId = mTestCase.getMyUserHandle().getIdentifier(); + + launchActivity(mTestCase.getIsSendAction()); + switchToTab(mTestCase.getTab()); + + switch (mTestCase.getExpectedBlocker()) { + case NO_BLOCKER: + assertNoBlockerDisplayed(); + break; + case PERSONAL_PROFILE_SHARE_BLOCKER: + assertCantSharePersonalAppsBlockerDisplayed(); + break; + case WORK_PROFILE_SHARE_BLOCKER: + assertCantShareWorkAppsBlockerDisplayed(); + break; + case PERSONAL_PROFILE_ACCESS_BLOCKER: + assertCantAccessPersonalAppsBlockerDisplayed(); + break; + case WORK_PROFILE_ACCESS_BLOCKER: + assertCantAccessWorkAppsBlockerDisplayed(); + break; + } + } + + @Parameterized.Parameters(name = "{0}") + public static Collection tests() { + return Arrays.asList( + new TestCase( + /* isSendAction= */ true, + /* hasCrossProfileIntents= */ true, + /* myUserHandle= */ WORK_USER_HANDLE, + /* tab= */ WORK, + /* expectedBlocker= */ NO_BLOCKER + ), +// TODO(b/256869196) ChooserActivity goes into requestLayout loop +// new TestCase( +// /* isSendAction= */ true, +// /* hasCrossProfileIntents= */ false, +// /* myUserHandle= */ WORK_USER_HANDLE, +// /* tab= */ WORK, +// /* expectedBlocker= */ NO_BLOCKER +// ), + new TestCase( + /* isSendAction= */ true, + /* hasCrossProfileIntents= */ true, + /* myUserHandle= */ PERSONAL_USER_HANDLE, + /* tab= */ WORK, + /* expectedBlocker= */ NO_BLOCKER + ), + new TestCase( + /* isSendAction= */ true, + /* hasCrossProfileIntents= */ false, + /* myUserHandle= */ PERSONAL_USER_HANDLE, + /* tab= */ WORK, + /* expectedBlocker= */ WORK_PROFILE_SHARE_BLOCKER + ), + new TestCase( + /* isSendAction= */ true, + /* hasCrossProfileIntents= */ true, + /* myUserHandle= */ WORK_USER_HANDLE, + /* tab= */ PERSONAL, + /* expectedBlocker= */ NO_BLOCKER + ), +// TODO(b/256869196) ChooserActivity goes into requestLayout loop +// new TestCase( +// /* isSendAction= */ true, +// /* hasCrossProfileIntents= */ false, +// /* myUserHandle= */ WORK_USER_HANDLE, +// /* tab= */ PERSONAL, +// /* expectedBlocker= */ PERSONAL_PROFILE_SHARE_BLOCKER +// ), + new TestCase( + /* isSendAction= */ true, + /* hasCrossProfileIntents= */ true, + /* myUserHandle= */ PERSONAL_USER_HANDLE, + /* tab= */ PERSONAL, + /* expectedBlocker= */ NO_BLOCKER + ), + new TestCase( + /* isSendAction= */ true, + /* hasCrossProfileIntents= */ false, + /* myUserHandle= */ PERSONAL_USER_HANDLE, + /* tab= */ PERSONAL, + /* expectedBlocker= */ NO_BLOCKER + ), + new TestCase( + /* isSendAction= */ false, + /* hasCrossProfileIntents= */ true, + /* myUserHandle= */ WORK_USER_HANDLE, + /* tab= */ WORK, + /* expectedBlocker= */ NO_BLOCKER + ), + new TestCase( + /* isSendAction= */ false, + /* hasCrossProfileIntents= */ false, + /* myUserHandle= */ WORK_USER_HANDLE, + /* tab= */ WORK, + /* expectedBlocker= */ NO_BLOCKER + ), + new TestCase( + /* isSendAction= */ false, + /* hasCrossProfileIntents= */ true, + /* myUserHandle= */ PERSONAL_USER_HANDLE, + /* tab= */ WORK, + /* expectedBlocker= */ NO_BLOCKER + ), + new TestCase( + /* isSendAction= */ false, + /* hasCrossProfileIntents= */ false, + /* myUserHandle= */ PERSONAL_USER_HANDLE, + /* tab= */ WORK, + /* expectedBlocker= */ WORK_PROFILE_ACCESS_BLOCKER + ), + new TestCase( + /* isSendAction= */ false, + /* hasCrossProfileIntents= */ true, + /* myUserHandle= */ WORK_USER_HANDLE, + /* tab= */ PERSONAL, + /* expectedBlocker= */ NO_BLOCKER + ), + new TestCase( + /* isSendAction= */ false, + /* hasCrossProfileIntents= */ false, + /* myUserHandle= */ WORK_USER_HANDLE, + /* tab= */ PERSONAL, + /* expectedBlocker= */ PERSONAL_PROFILE_ACCESS_BLOCKER + ), + new TestCase( + /* isSendAction= */ false, + /* hasCrossProfileIntents= */ true, + /* myUserHandle= */ PERSONAL_USER_HANDLE, + /* tab= */ PERSONAL, + /* expectedBlocker= */ NO_BLOCKER + ), + new TestCase( + /* isSendAction= */ false, + /* hasCrossProfileIntents= */ false, + /* myUserHandle= */ PERSONAL_USER_HANDLE, + /* tab= */ PERSONAL, + /* expectedBlocker= */ NO_BLOCKER + ) + ); + } + + private List<ResolvedComponentInfo> createResolvedComponentsForTestWithOtherProfile( + int numberOfResults, int userId) { + List<ResolvedComponentInfo> infoList = new ArrayList<>(numberOfResults); + for (int i = 0; i < numberOfResults; i++) { + infoList.add( + ResolverDataProvider.createResolvedComponentInfoWithOtherId(i, userId)); + } + return infoList; + } + + private List<ResolvedComponentInfo> createResolvedComponentsForTest(int numberOfResults) { + List<ResolvedComponentInfo> infoList = new ArrayList<>(numberOfResults); + for (int i = 0; i < numberOfResults; i++) { + infoList.add(ResolverDataProvider.createResolvedComponentInfo(i)); + } + return infoList; + } + + private void setUpPersonalAndWorkComponentInfos() { + markWorkProfileUserAvailable(); + int workProfileTargets = 4; + List<ResolvedComponentInfo> personalResolvedComponentInfos = + createResolvedComponentsForTestWithOtherProfile(3, + /* userId */ WORK_USER_HANDLE.getIdentifier()); + List<ResolvedComponentInfo> workResolvedComponentInfos = + createResolvedComponentsForTest(workProfileTargets); + setupResolverControllers(personalResolvedComponentInfos, workResolvedComponentInfos); + } + + private void setupResolverControllers( + List<ResolvedComponentInfo> personalResolvedComponentInfos, + List<ResolvedComponentInfo> workResolvedComponentInfos) { + when(sOverrides.resolverListController.getResolversForIntent(Mockito.anyBoolean(), + Mockito.anyBoolean(), + Mockito.anyBoolean(), + Mockito.isA(List.class))) + .thenReturn(new ArrayList<>(personalResolvedComponentInfos)); + when(sOverrides.workResolverListController.getResolversForIntent(Mockito.anyBoolean(), + Mockito.anyBoolean(), + Mockito.anyBoolean(), + Mockito.isA(List.class))).thenReturn(workResolvedComponentInfos); + when(sOverrides.workResolverListController.getResolversForIntentAsUser(Mockito.anyBoolean(), + Mockito.anyBoolean(), + Mockito.anyBoolean(), + Mockito.isA(List.class), + eq(UserHandle.SYSTEM))) + .thenReturn(new ArrayList<>(personalResolvedComponentInfos)); + } + + private void waitForIdle() { + InstrumentationRegistry.getInstrumentation().waitForIdleSync(); + } + + private void markWorkProfileUserAvailable() { + ChooserWrapperActivity.sOverrides.workProfileUserHandle = WORK_USER_HANDLE; + } + + private void assertCantAccessWorkAppsBlockerDisplayed() { + onView(withText(R.string.resolver_cross_profile_blocked)) + .check(matches(isDisplayed())); + onView(withText(R.string.resolver_cant_access_work_apps_explanation)) + .check(matches(isDisplayed())); + } + + private void assertCantAccessPersonalAppsBlockerDisplayed() { + onView(withText(R.string.resolver_cross_profile_blocked)) + .check(matches(isDisplayed())); + onView(withText(R.string.resolver_cant_access_personal_apps_explanation)) + .check(matches(isDisplayed())); + } + + private void assertCantShareWorkAppsBlockerDisplayed() { + onView(withText(R.string.resolver_cross_profile_blocked)) + .check(matches(isDisplayed())); + onView(withText(R.string.resolver_cant_share_with_work_apps_explanation)) + .check(matches(isDisplayed())); + } + + private void assertCantSharePersonalAppsBlockerDisplayed() { + onView(withText(R.string.resolver_cross_profile_blocked)) + .check(matches(isDisplayed())); + onView(withText(R.string.resolver_cant_share_with_personal_apps_explanation)) + .check(matches(isDisplayed())); + } + + private void assertNoBlockerDisplayed() { + try { + onView(withText(R.string.resolver_cross_profile_blocked)) + .check(matches(not(isDisplayed()))); + } catch (NoMatchingViewException ignored) { + } + } + + private void switchToTab(Tab tab) { + final int stringId = tab == Tab.WORK ? R.string.resolver_work_tab + : R.string.resolver_personal_tab; + + onView(withText(stringId)).perform(click()); + waitForIdle(); + + onView(withId(R.id.contentPanel)) + .perform(swipeUp()); + waitForIdle(); + } + + private Intent createTextIntent(boolean isSendAction) { + Intent sendIntent = new Intent(); + if (isSendAction) { + sendIntent.setAction(Intent.ACTION_SEND); + } + sendIntent.putExtra(Intent.EXTRA_TEXT, "testing intent sending"); + sendIntent.setType("text/plain"); + return sendIntent; + } + + private void launchActivity(boolean isSendAction) { + Intent sendIntent = createTextIntent(isSendAction); + mActivityRule.launchActivity(Intent.createChooser(sendIntent, "Test")); + waitForIdle(); + } + + public static class TestCase { + private final boolean mIsSendAction; + private final boolean mHasCrossProfileIntents; + private final UserHandle mMyUserHandle; + private final Tab mTab; + private final ExpectedBlocker mExpectedBlocker; + + public enum ExpectedBlocker { + NO_BLOCKER, + PERSONAL_PROFILE_SHARE_BLOCKER, + WORK_PROFILE_SHARE_BLOCKER, + PERSONAL_PROFILE_ACCESS_BLOCKER, + WORK_PROFILE_ACCESS_BLOCKER + } + + public enum Tab { + WORK, + PERSONAL + } + + public TestCase(boolean isSendAction, boolean hasCrossProfileIntents, + UserHandle myUserHandle, Tab tab, ExpectedBlocker expectedBlocker) { + mIsSendAction = isSendAction; + mHasCrossProfileIntents = hasCrossProfileIntents; + mMyUserHandle = myUserHandle; + mTab = tab; + mExpectedBlocker = expectedBlocker; + } + + public boolean getIsSendAction() { + return mIsSendAction; + } + + public boolean hasCrossProfileIntents() { + return mHasCrossProfileIntents; + } + + public UserHandle getMyUserHandle() { + return mMyUserHandle; + } + + public Tab getTab() { + return mTab; + } + + public ExpectedBlocker getExpectedBlocker() { + return mExpectedBlocker; + } + + @Override + public String toString() { + StringBuilder result = new StringBuilder("test"); + + if (mTab == WORK) { + result.append("WorkTab_"); + } else { + result.append("PersonalTab_"); + } + + if (mIsSendAction) { + result.append("sendAction_"); + } else { + result.append("notSendAction_"); + } + + if (mHasCrossProfileIntents) { + result.append("hasCrossProfileIntents_"); + } else { + result.append("doesNotHaveCrossProfileIntents_"); + } + + if (mMyUserHandle.equals(PERSONAL_USER_HANDLE)) { + result.append("myUserIsPersonal_"); + } else { + result.append("myUserIsWork_"); + } + + if (mExpectedBlocker == ExpectedBlocker.NO_BLOCKER) { + result.append("thenNoBlocker"); + } else if (mExpectedBlocker == PERSONAL_PROFILE_ACCESS_BLOCKER) { + result.append("thenAccessBlockerOnPersonalProfile"); + } else if (mExpectedBlocker == PERSONAL_PROFILE_SHARE_BLOCKER) { + result.append("thenShareBlockerOnPersonalProfile"); + } else if (mExpectedBlocker == WORK_PROFILE_ACCESS_BLOCKER) { + result.append("thenAccessBlockerOnWorkProfile"); + } else if (mExpectedBlocker == WORK_PROFILE_SHARE_BLOCKER) { + result.append("thenShareBlockerOnWorkProfile"); + } + + return result.toString(); + } + } +} diff --git a/java/tests/src/com/android/intentresolver/chooser/TargetInfoTest.kt b/java/tests/src/com/android/intentresolver/chooser/TargetInfoTest.kt new file mode 100644 index 00000000..11837e08 --- /dev/null +++ b/java/tests/src/com/android/intentresolver/chooser/TargetInfoTest.kt @@ -0,0 +1,186 @@ +/* + * 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.chooser + +import android.app.prediction.AppTarget +import android.app.prediction.AppTargetId +import android.content.ComponentName +import android.content.Intent +import android.content.pm.ActivityInfo +import android.content.pm.ResolveInfo +import android.os.UserHandle +import androidx.test.platform.app.InstrumentationRegistry +import com.android.intentresolver.createChooserTarget +import com.android.intentresolver.createShortcutInfo +import com.android.intentresolver.mock +import com.android.intentresolver.ResolverDataProvider +import com.google.common.truth.Truth.assertThat +import org.junit.Test + +class TargetInfoTest { + private val context = InstrumentationRegistry.getInstrumentation().getContext() + + @Test + fun testNewEmptyTargetInfo() { + val info = NotSelectableTargetInfo.newEmptyTargetInfo() + assertThat(info.isEmptyTargetInfo()).isTrue() + assertThat(info.isChooserTargetInfo()).isTrue() // From legacy inheritance model. + assertThat(info.hasDisplayIcon()).isFalse() + assertThat(info.getDisplayIcon()).isNull() + } + + @Test + fun testNewPlaceholderTargetInfo() { + val info = NotSelectableTargetInfo.newPlaceHolderTargetInfo(context) + assertThat(info.isPlaceHolderTargetInfo()).isTrue() + assertThat(info.isChooserTargetInfo()).isTrue() // From legacy inheritance model. + assertThat(info.hasDisplayIcon()).isTrue() + // TODO: test infrastructure isn't set up to assert anything about the icon itself. + } + + @Test + fun testNewSelectableTargetInfo() { + val displayInfo: DisplayResolveInfo = mock() + val chooserTarget = createChooserTarget( + "title", 0.3f, ResolverDataProvider.createComponentName(1), "test_shortcut_id") + val shortcutInfo = createShortcutInfo("id", ResolverDataProvider.createComponentName(2), 3) + val appTarget = AppTarget( + AppTargetId("id"), + chooserTarget.componentName.packageName, + chooserTarget.componentName.className, + UserHandle.CURRENT) + val resolvedIntent = mock<Intent>() + + val targetInfo = SelectableTargetInfo.newSelectableTargetInfo( + displayInfo, + mock(), + resolvedIntent, + chooserTarget, + 0.1f, + shortcutInfo, + appTarget, + mock(), + ) + assertThat(targetInfo.isSelectableTargetInfo).isTrue() + assertThat(targetInfo.isChooserTargetInfo).isTrue() // From legacy inheritance model. + assertThat(targetInfo.displayResolveInfo).isSameInstanceAs(displayInfo) + assertThat(targetInfo.chooserTargetComponentName).isEqualTo(chooserTarget.componentName) + assertThat(targetInfo.directShareShortcutId).isEqualTo(shortcutInfo.id) + assertThat(targetInfo.directShareShortcutInfo).isSameInstanceAs(shortcutInfo) + assertThat(targetInfo.directShareAppTarget).isSameInstanceAs(appTarget) + assertThat(targetInfo.resolvedIntent).isSameInstanceAs(resolvedIntent) + // TODO: make more meaningful assertions about the behavior of a selectable target. + } + + @Test + fun test_SelectableTargetInfo_componentName_no_source_info() { + val chooserTarget = createChooserTarget( + "title", 0.3f, ResolverDataProvider.createComponentName(1), "test_shortcut_id") + val shortcutInfo = createShortcutInfo("id", ResolverDataProvider.createComponentName(2), 3) + val appTarget = AppTarget( + AppTargetId("id"), + chooserTarget.componentName.packageName, + chooserTarget.componentName.className, + UserHandle.CURRENT) + val pkgName = "org.package" + val className = "MainActivity" + val backupResolveInfo = ResolveInfo().apply { + activityInfo = ActivityInfo().apply { + packageName = pkgName + name = className + } + } + + val targetInfo = SelectableTargetInfo.newSelectableTargetInfo( + null, + backupResolveInfo, + mock(), + chooserTarget, + 0.1f, + shortcutInfo, + appTarget, + mock(), + ) + assertThat(targetInfo.resolvedComponentName).isEqualTo(ComponentName(pkgName, className)) + } + + @Test + fun testNewDisplayResolveInfo() { + val intent = Intent(Intent.ACTION_SEND) + intent.putExtra(Intent.EXTRA_TEXT, "testing intent sending") + intent.setType("text/plain") + + val resolveInfo = ResolverDataProvider.createResolveInfo(3, 0) + + val targetInfo = DisplayResolveInfo.newDisplayResolveInfo( + intent, + resolveInfo, + "label", + "extended info", + intent, + /* resolveInfoPresentationGetter= */ null) + assertThat(targetInfo.isDisplayResolveInfo()).isTrue() + assertThat(targetInfo.isMultiDisplayResolveInfo()).isFalse() + assertThat(targetInfo.isChooserTargetInfo()).isFalse() + } + + @Test + fun testNewMultiDisplayResolveInfo() { + val intent = Intent(Intent.ACTION_SEND) + intent.putExtra(Intent.EXTRA_TEXT, "testing intent sending") + intent.setType("text/plain") + + val resolveInfo = ResolverDataProvider.createResolveInfo(3, 0) + val firstTargetInfo = DisplayResolveInfo.newDisplayResolveInfo( + intent, + resolveInfo, + "label 1", + "extended info 1", + intent, + /* resolveInfoPresentationGetter= */ null) + val secondTargetInfo = DisplayResolveInfo.newDisplayResolveInfo( + intent, + resolveInfo, + "label 2", + "extended info 2", + intent, + /* resolveInfoPresentationGetter= */ null) + + val multiTargetInfo = MultiDisplayResolveInfo.newMultiDisplayResolveInfo( + listOf(firstTargetInfo, secondTargetInfo)) + + assertThat(multiTargetInfo.isMultiDisplayResolveInfo()).isTrue() + assertThat(multiTargetInfo.isDisplayResolveInfo()).isTrue() // From legacy inheritance. + assertThat(multiTargetInfo.isChooserTargetInfo()).isFalse() + + assertThat(multiTargetInfo.getExtendedInfo()).isNull() + + assertThat(multiTargetInfo.getAllDisplayTargets()) + .containsExactly(firstTargetInfo, secondTargetInfo) + + assertThat(multiTargetInfo.hasSelected()).isFalse() + assertThat(multiTargetInfo.getSelectedTarget()).isNull() + + multiTargetInfo.setSelected(1) + + assertThat(multiTargetInfo.hasSelected()).isTrue() + assertThat(multiTargetInfo.getSelectedTarget()).isEqualTo(secondTargetInfo) + + // TODO: consider exercising activity-start behavior. + // TODO: consider exercising DisplayResolveInfo base class behavior. + } +} diff --git a/java/tests/src/com/android/intentresolver/model/AbstractResolverComparatorTest.java b/java/tests/src/com/android/intentresolver/model/AbstractResolverComparatorTest.java new file mode 100644 index 00000000..448718cd --- /dev/null +++ b/java/tests/src/com/android/intentresolver/model/AbstractResolverComparatorTest.java @@ -0,0 +1,107 @@ +/* + * Copyright (C) 2019 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.model; + +import static junit.framework.Assert.assertEquals; + +import android.content.ComponentName; +import android.content.Context; +import android.content.Intent; +import android.content.pm.ActivityInfo; +import android.content.pm.ResolveInfo; +import android.os.Message; + +import androidx.test.InstrumentationRegistry; + +import com.android.intentresolver.ResolverActivity; + +import org.junit.Test; + +import java.util.List; + +public class AbstractResolverComparatorTest { + + @Test + public void testPinned() { + ResolverActivity.ResolvedComponentInfo r1 = new ResolverActivity.ResolvedComponentInfo( + new ComponentName("package", "class"), new Intent(), new ResolveInfo() + ); + r1.setPinned(true); + + ResolverActivity.ResolvedComponentInfo r2 = new ResolverActivity.ResolvedComponentInfo( + new ComponentName("zackage", "zlass"), new Intent(), new ResolveInfo() + ); + + Context context = InstrumentationRegistry.getTargetContext(); + AbstractResolverComparator comparator = getTestComparator(context); + + assertEquals("Pinned ranks over unpinned", -1, comparator.compare(r1, r2)); + assertEquals("Unpinned ranks under pinned", 1, comparator.compare(r2, r1)); + } + + + @Test + public void testBothPinned() { + ResolveInfo pmInfo1 = new ResolveInfo(); + pmInfo1.activityInfo = new ActivityInfo(); + pmInfo1.activityInfo.packageName = "aaa"; + + ResolverActivity.ResolvedComponentInfo r1 = new ResolverActivity.ResolvedComponentInfo( + new ComponentName("package", "class"), new Intent(), pmInfo1); + r1.setPinned(true); + + ResolveInfo pmInfo2 = new ResolveInfo(); + pmInfo2.activityInfo = new ActivityInfo(); + pmInfo2.activityInfo.packageName = "zzz"; + ResolverActivity.ResolvedComponentInfo r2 = new ResolverActivity.ResolvedComponentInfo( + new ComponentName("zackage", "zlass"), new Intent(), pmInfo2); + r2.setPinned(true); + + Context context = InstrumentationRegistry.getTargetContext(); + AbstractResolverComparator comparator = getTestComparator(context); + + assertEquals("Both pinned should rank alphabetically", -1, comparator.compare(r1, r2)); + } + + private AbstractResolverComparator getTestComparator(Context context) { + Intent intent = new Intent(); + + AbstractResolverComparator testComparator = + new AbstractResolverComparator(context, intent) { + + @Override + int compare(ResolveInfo lhs, ResolveInfo rhs) { + // Used for testing pinning, so we should never get here --- the overrides + // should determine the result instead. + return 1; + } + + @Override + void doCompute(List<ResolverActivity.ResolvedComponentInfo> targets) {} + + @Override + public float getScore(ComponentName name) { + return 0; + } + + @Override + void handleResultMessage(Message message) {} + }; + return testComparator; + } + +} diff --git a/java/tests/src/com/android/intentresolver/shortcuts/ShortcutLoaderTest.kt b/java/tests/src/com/android/intentresolver/shortcuts/ShortcutLoaderTest.kt new file mode 100644 index 00000000..5756a0cd --- /dev/null +++ b/java/tests/src/com/android/intentresolver/shortcuts/ShortcutLoaderTest.kt @@ -0,0 +1,329 @@ +/* + * 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.AppPredictor +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.ShortcutManager +import android.os.UserHandle +import android.os.UserManager +import androidx.test.filters.SmallTest +import com.android.intentresolver.any +import com.android.intentresolver.chooser.DisplayResolveInfo +import com.android.intentresolver.createAppTarget +import com.android.intentresolver.createShareShortcutInfo +import com.android.intentresolver.createShortcutInfo +import com.android.intentresolver.mock +import com.android.intentresolver.whenever +import org.junit.Assert.assertArrayEquals +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test +import org.mockito.ArgumentCaptor +import org.mockito.Mockito.anyInt +import org.mockito.Mockito.never +import org.mockito.Mockito.times +import org.mockito.Mockito.verify +import java.util.concurrent.Executor +import java.util.function.Consumer + +@SmallTest +class ShortcutLoaderTest { + private val appInfo = ApplicationInfo().apply { + enabled = true + flags = 0 + } + private val pm = mock<PackageManager> { + whenever(getApplicationInfo(any(), any<ApplicationInfoFlags>())).thenReturn(appInfo) + } + private val context = mock<Context> { + whenever(packageManager).thenReturn(pm) + whenever(createContextAsUser(any(), anyInt())).thenReturn(this) + } + private val executor = ImmediateExecutor() + private val intentFilter = mock<IntentFilter>() + private val appPredictor = mock<ShortcutLoader.AppPredictorProxy>() + private val callback = mock<Consumer<ShortcutLoader.Result>>() + + @Test + fun test_app_predictor_result() { + val componentName = ComponentName("pkg", "Class") + val appTarget = mock<DisplayResolveInfo> { + whenever(resolvedComponentName).thenReturn(componentName) + } + val appTargets = arrayOf(appTarget) + val testSubject = ShortcutLoader( + context, + appPredictor, + UserHandle.of(0), + true, + intentFilter, + executor, + executor, + callback + ) + + testSubject.queryShortcuts(appTargets) + + verify(appPredictor, times(1)).requestPredictionUpdate() + val appPredictorCallbackCaptor = ArgumentCaptor.forClass(AppPredictor.Callback::class.java) + verify(appPredictor, times(1)) + .registerPredictionUpdates(any(), appPredictorCallbackCaptor.capture()) + + val matchingShortcutInfo = createShortcutInfo("id-0", componentName, 1) + val matchingAppTarget = createAppTarget(matchingShortcutInfo) + val shortcuts = listOf( + matchingAppTarget, + // mismatching shortcut + createAppTarget( + createShortcutInfo("id-1", ComponentName("mismatching.pkg", "Class"), 1) + ) + ) + appPredictorCallbackCaptor.value.onTargetsAvailable(shortcuts) + + val resultCaptor = ArgumentCaptor.forClass(ShortcutLoader.Result::class.java) + verify(callback, times(1)).accept(resultCaptor.capture()) + + val result = resultCaptor.value + assertTrue("An app predictor result is expected", result.isFromAppPredictor) + assertArrayEquals("Wrong input app targets in the result", appTargets, result.appTargets) + assertEquals("Wrong shortcut count", 1, result.shortcutsByApp.size) + assertEquals("Wrong app target", appTarget, result.shortcutsByApp[0].appTarget) + for (shortcut in result.shortcutsByApp[0].shortcuts) { + assertEquals( + "Wrong AppTarget in the cache", + matchingAppTarget, + result.directShareAppTargetCache[shortcut] + ) + assertEquals( + "Wrong ShortcutInfo in the cache", + matchingShortcutInfo, + result.directShareShortcutInfoCache[shortcut] + ) + } + } + + @Test + fun test_shortcut_manager_result() { + val componentName = ComponentName("pkg", "Class") + val appTarget = mock<DisplayResolveInfo> { + whenever(resolvedComponentName).thenReturn(componentName) + } + val appTargets = arrayOf(appTarget) + val matchingShortcutInfo = createShortcutInfo("id-0", componentName, 1) + val shortcutManagerResult = listOf( + ShortcutManager.ShareShortcutInfo(matchingShortcutInfo, componentName), + // mismatching shortcut + createShareShortcutInfo("id-1", ComponentName("mismatching.pkg", "Class"), 1) + ) + val shortcutManager = mock<ShortcutManager> { + whenever(getShareTargets(intentFilter)).thenReturn(shortcutManagerResult) + } + whenever(context.getSystemService(Context.SHORTCUT_SERVICE)).thenReturn(shortcutManager) + val testSubject = ShortcutLoader( + context, + null, + UserHandle.of(0), + true, + intentFilter, + executor, + executor, + callback + ) + + testSubject.queryShortcuts(appTargets) + + val resultCaptor = ArgumentCaptor.forClass(ShortcutLoader.Result::class.java) + verify(callback, times(1)).accept(resultCaptor.capture()) + + val result = resultCaptor.value + assertFalse("An ShortcutManager result is expected", result.isFromAppPredictor) + assertArrayEquals("Wrong input app targets in the result", appTargets, result.appTargets) + assertEquals("Wrong shortcut count", 1, result.shortcutsByApp.size) + assertEquals("Wrong app target", appTarget, result.shortcutsByApp[0].appTarget) + for (shortcut in result.shortcutsByApp[0].shortcuts) { + assertTrue( + "AppTargets are not expected the cache of a ShortcutManager result", + result.directShareAppTargetCache.isEmpty() + ) + assertEquals( + "Wrong ShortcutInfo in the cache", + matchingShortcutInfo, + result.directShareShortcutInfoCache[shortcut] + ) + } + } + + @Test + fun test_fallback_to_shortcut_manager() { + val componentName = ComponentName("pkg", "Class") + val appTarget = mock<DisplayResolveInfo> { + whenever(resolvedComponentName).thenReturn(componentName) + } + val appTargets = arrayOf(appTarget) + val matchingShortcutInfo = createShortcutInfo("id-0", componentName, 1) + val shortcutManagerResult = listOf( + ShortcutManager.ShareShortcutInfo(matchingShortcutInfo, componentName), + // mismatching shortcut + createShareShortcutInfo("id-1", ComponentName("mismatching.pkg", "Class"), 1) + ) + val shortcutManager = mock<ShortcutManager> { + whenever(getShareTargets(intentFilter)).thenReturn(shortcutManagerResult) + } + whenever(context.getSystemService(Context.SHORTCUT_SERVICE)).thenReturn(shortcutManager) + val testSubject = ShortcutLoader( + context, + appPredictor, + UserHandle.of(0), + true, + intentFilter, + executor, + executor, + callback + ) + + testSubject.queryShortcuts(appTargets) + + verify(appPredictor, times(1)).requestPredictionUpdate() + val appPredictorCallbackCaptor = ArgumentCaptor.forClass(AppPredictor.Callback::class.java) + verify(appPredictor, times(1)) + .registerPredictionUpdates(any(), appPredictorCallbackCaptor.capture()) + appPredictorCallbackCaptor.value.onTargetsAvailable(emptyList()) + + val resultCaptor = ArgumentCaptor.forClass(ShortcutLoader.Result::class.java) + verify(callback, times(1)).accept(resultCaptor.capture()) + + val result = resultCaptor.value + assertFalse("An ShortcutManager result is expected", result.isFromAppPredictor) + assertArrayEquals("Wrong input app targets in the result", appTargets, result.appTargets) + assertEquals("Wrong shortcut count", 1, result.shortcutsByApp.size) + assertEquals("Wrong app target", appTarget, result.shortcutsByApp[0].appTarget) + for (shortcut in result.shortcutsByApp[0].shortcuts) { + assertTrue( + "AppTargets are not expected the cache of a ShortcutManager result", + result.directShareAppTargetCache.isEmpty() + ) + assertEquals( + "Wrong ShortcutInfo in the cache", + matchingShortcutInfo, + result.directShareShortcutInfoCache[shortcut] + ) + } + } + + @Test + fun test_do_not_call_services_for_not_running_work_profile() { + testDisabledWorkProfileDoNotCallSystem(isUserRunning = false) + } + + @Test + fun test_do_not_call_services_for_locked_work_profile() { + testDisabledWorkProfileDoNotCallSystem(isUserUnlocked = false) + } + + @Test + fun test_do_not_call_services_if_quite_mode_is_enabled_for_work_profile() { + testDisabledWorkProfileDoNotCallSystem(isQuietModeEnabled = true) + } + + @Test + fun test_call_services_for_not_running_main_profile() { + testAlwaysCallSystemForMainProfile(isUserRunning = false) + } + + @Test + fun test_call_services_for_locked_main_profile() { + testAlwaysCallSystemForMainProfile(isUserUnlocked = false) + } + + @Test + fun test_call_services_if_quite_mode_is_enabled_for_main_profile() { + testAlwaysCallSystemForMainProfile(isQuietModeEnabled = true) + } + + private fun testDisabledWorkProfileDoNotCallSystem( + isUserRunning: Boolean = true, + isUserUnlocked: Boolean = true, + isQuietModeEnabled: Boolean = false + ) { + val userHandle = UserHandle.of(10) + val userManager = mock<UserManager> { + whenever(isUserRunning(userHandle)).thenReturn(isUserRunning) + whenever(isUserUnlocked(userHandle)).thenReturn(isUserUnlocked) + whenever(isQuietModeEnabled(userHandle)).thenReturn(isQuietModeEnabled) + } + whenever(context.getSystemService(Context.USER_SERVICE)).thenReturn(userManager); + val appPredictor = mock<ShortcutLoader.AppPredictorProxy>() + val callback = mock<Consumer<ShortcutLoader.Result>>() + val testSubject = ShortcutLoader( + context, + appPredictor, + userHandle, + false, + intentFilter, + executor, + executor, + callback + ) + + testSubject.queryShortcuts(arrayOf<DisplayResolveInfo>(mock())) + + verify(appPredictor, never()).requestPredictionUpdate() + } + + private fun testAlwaysCallSystemForMainProfile( + isUserRunning: Boolean = true, + isUserUnlocked: Boolean = true, + isQuietModeEnabled: Boolean = false + ) { + val userHandle = UserHandle.of(10) + val userManager = mock<UserManager> { + whenever(isUserRunning(userHandle)).thenReturn(isUserRunning) + whenever(isUserUnlocked(userHandle)).thenReturn(isUserUnlocked) + whenever(isQuietModeEnabled(userHandle)).thenReturn(isQuietModeEnabled) + } + whenever(context.getSystemService(Context.USER_SERVICE)).thenReturn(userManager); + val appPredictor = mock<ShortcutLoader.AppPredictorProxy>() + val callback = mock<Consumer<ShortcutLoader.Result>>() + val testSubject = ShortcutLoader( + context, + appPredictor, + userHandle, + true, + intentFilter, + executor, + executor, + callback + ) + + testSubject.queryShortcuts(arrayOf<DisplayResolveInfo>(mock())) + + verify(appPredictor, times(1)).requestPredictionUpdate() + } +} + +private class ImmediateExecutor : Executor { + override fun execute(r: Runnable) { + r.run() + } +} diff --git a/java/tests/src/com/android/intentresolver/shortcuts/ShortcutToChooserTargetConverterTest.kt b/java/tests/src/com/android/intentresolver/shortcuts/ShortcutToChooserTargetConverterTest.kt new file mode 100644 index 00000000..e0de005d --- /dev/null +++ b/java/tests/src/com/android/intentresolver/shortcuts/ShortcutToChooserTargetConverterTest.kt @@ -0,0 +1,177 @@ +/* + * 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.AppTarget +import android.content.ComponentName +import android.content.Intent +import android.content.pm.ShortcutInfo +import android.content.pm.ShortcutManager.ShareShortcutInfo +import android.service.chooser.ChooserTarget +import com.android.intentresolver.createAppTarget +import com.android.intentresolver.createShareShortcutInfo +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Test + +private const val PACKAGE = "org.package" + +class ShortcutToChooserTargetConverterTest { + private val testSubject = ShortcutToChooserTargetConverter() + private val ranks = arrayOf(3 ,7, 1 ,3) + private val shortcuts = ranks + .foldIndexed(ArrayList<ShareShortcutInfo>(ranks.size)) { i, acc, rank -> + val id = i + 1 + acc.add( + createShareShortcutInfo( + id = "id-$i", + componentName = ComponentName(PACKAGE, "Class$id"), + rank, + ) + ) + acc + } + + @Test + fun testConvertToChooserTarget_predictionService() { + val appTargets = shortcuts.map { createAppTarget(it.shortcutInfo) } + val expectedOrderAllShortcuts = intArrayOf(0, 1, 2, 3) + val expectedScoreAllShortcuts = floatArrayOf(1.0f, 0.99f, 0.98f, 0.97f) + val appTargetCache = HashMap<ChooserTarget, AppTarget>() + val shortcutInfoCache = HashMap<ChooserTarget, ShortcutInfo>() + + var chooserTargets = testSubject.convertToChooserTarget( + shortcuts, + shortcuts, + appTargets, + appTargetCache, + shortcutInfoCache, + ) + + assertCorrectShortcutToChooserTargetConversion( + shortcuts, + chooserTargets, + expectedOrderAllShortcuts, + expectedScoreAllShortcuts, + ) + assertAppTargetCache(chooserTargets, appTargetCache) + assertShortcutInfoCache(chooserTargets, shortcutInfoCache) + + val subset = shortcuts.subList(1, shortcuts.size) + val expectedOrderSubset = intArrayOf(1, 2, 3) + val expectedScoreSubset = floatArrayOf(0.99f, 0.98f, 0.97f) + appTargetCache.clear() + shortcutInfoCache.clear() + + chooserTargets = testSubject.convertToChooserTarget( + subset, + shortcuts, + appTargets, + appTargetCache, + shortcutInfoCache, + ) + + assertCorrectShortcutToChooserTargetConversion( + shortcuts, + chooserTargets, + expectedOrderSubset, + expectedScoreSubset, + ) + assertAppTargetCache(chooserTargets, appTargetCache) + assertShortcutInfoCache(chooserTargets, shortcutInfoCache) + } + + @Test + fun testConvertToChooserTarget_shortcutManager() { + val testSubject = ShortcutToChooserTargetConverter() + val expectedOrderAllShortcuts = intArrayOf(2, 0, 3, 1) + val expectedScoreAllShortcuts = floatArrayOf(1.0f, 0.99f, 0.99f, 0.98f) + val shortcutInfoCache = HashMap<ChooserTarget, ShortcutInfo>() + + var chooserTargets = testSubject.convertToChooserTarget( + shortcuts, + shortcuts, + null, + null, + shortcutInfoCache, + ) + + assertCorrectShortcutToChooserTargetConversion( + shortcuts, chooserTargets, + expectedOrderAllShortcuts, expectedScoreAllShortcuts + ) + assertShortcutInfoCache(chooserTargets, shortcutInfoCache) + + val subset: MutableList<ShareShortcutInfo> = java.util.ArrayList() + subset.add(shortcuts[1]) + subset.add(shortcuts[2]) + subset.add(shortcuts[3]) + val expectedOrderSubset = intArrayOf(2, 3, 1) + val expectedScoreSubset = floatArrayOf(1.0f, 0.99f, 0.98f) + shortcutInfoCache.clear() + + chooserTargets = testSubject.convertToChooserTarget( + subset, + shortcuts, + null, + null, + shortcutInfoCache, + ) + + assertCorrectShortcutToChooserTargetConversion( + shortcuts, chooserTargets, + expectedOrderSubset, expectedScoreSubset + ) + assertShortcutInfoCache(chooserTargets, shortcutInfoCache) + } + + private fun assertCorrectShortcutToChooserTargetConversion( + shortcuts: List<ShareShortcutInfo>, + chooserTargets: List<ChooserTarget>, + expectedOrder: IntArray, + expectedScores: FloatArray, + ) { + assertEquals("Unexpected ChooserTarget count", expectedOrder.size, chooserTargets.size) + for (i in chooserTargets.indices) { + val ct = chooserTargets[i] + val si = shortcuts[expectedOrder[i]].shortcutInfo + val cn = shortcuts[expectedOrder[i]].targetComponent + assertEquals(si.id, ct.intentExtras.getString(Intent.EXTRA_SHORTCUT_ID)) + assertEquals(si.label, ct.title) + assertEquals(expectedScores[i], ct.score) + assertEquals(cn, ct.componentName) + } + } + + private fun assertAppTargetCache( + chooserTargets: List<ChooserTarget>, cache: Map<ChooserTarget, AppTarget> + ) { + for (ct in chooserTargets) { + val target = cache[ct] + assertNotNull("AppTarget is missing", target) + } + } + + private fun assertShortcutInfoCache( + chooserTargets: List<ChooserTarget>, cache: Map<ChooserTarget, ShortcutInfo> + ) { + for (ct in chooserTargets) { + val si = cache[ct] + assertNotNull("AppTarget is missing", si) + } + } +} |