diff options
25 files changed, 9837 insertions, 43 deletions
diff --git a/AndroidManifest-app.xml b/AndroidManifest-app.xml index 9efc7ab1..ec4fec85 100644 --- a/AndroidManifest-app.xml +++ b/AndroidManifest-app.xml @@ -60,6 +60,36 @@ android:visibleToInstantApps="true" android:exported="false"/> + <receiver android:name="com.android.intentresolver.v2.ChooserSelector" + android:exported="true"> + <intent-filter> + <action android:name="android.intent.action.BOOT_COMPLETED" /> + </intent-filter> + </receiver> + + <activity android:name="com.android.intentresolver.v2.ChooserActivity" + android:enabled="false" + android:theme="@style/Theme.DeviceDefault.Chooser" + android:finishOnCloseSystemDialogs="true" + android:excludeFromRecents="true" + android:documentLaunchMode="never" + android:relinquishTaskIdentity="true" + android:configChanges="screenSize|smallestScreenSize|screenLayout|keyboard|keyboardHidden" + android:visibleToInstantApps="true" + android:exported="true"> + + <!-- This intent filter is assigned a priority greater than 500 so + that it will take precedence over the ChooserActivity + in the process of resolving implicit action.CHOOSER intents + whenever this activity is enabled by the experiment flag. --> + <intent-filter android:priority="501"> + <action android:name="android.intent.action.CHOOSER" /> + <category android:name="android.intent.category.DEFAULT" /> + <category android:name="android.intent.category.VOICE" /> + </intent-filter> + + </activity> + <provider android:name="androidx.startup.InitializationProvider" android:authorities="${applicationId}.androidx-startup" tools:replace="android:authorities" diff --git a/aconfig/FeatureFlags.aconfig b/aconfig/FeatureFlags.aconfig index 7b0ab052..ae83ca79 100644 --- a/aconfig/FeatureFlags.aconfig +++ b/aconfig/FeatureFlags.aconfig @@ -24,3 +24,10 @@ flag { description: "Enables caching target icons and labels in a local DB" bug: "285314844" } + +flag { + name: "modular_framework" + namespace: "intentresolver" + description: "Enables the new modular framework" + bug: "302113519" +} diff --git a/java/src/com/android/intentresolver/AnnotatedUserHandles.java b/java/src/com/android/intentresolver/AnnotatedUserHandles.java index 168f36d6..5d559f5b 100644 --- a/java/src/com/android/intentresolver/AnnotatedUserHandles.java +++ b/java/src/com/android/intentresolver/AnnotatedUserHandles.java @@ -105,7 +105,7 @@ public final class AnnotatedUserHandles { .build(); } - @VisibleForTesting static Builder newBuilder() { + @VisibleForTesting public static Builder newBuilder() { return new Builder(); } @@ -173,7 +173,7 @@ public final class AnnotatedUserHandles { } @VisibleForTesting - static class Builder { + public static class Builder { private int mUserIdOfCallingApp; private UserHandle mUserHandleSharesheetLaunchedAs; private UserHandle mPersonalProfileUserHandle; diff --git a/java/src/com/android/intentresolver/ChooserActivity.java b/java/src/com/android/intentresolver/ChooserActivity.java index 3f9e2154..3a11bee2 100644 --- a/java/src/com/android/intentresolver/ChooserActivity.java +++ b/java/src/com/android/intentresolver/ChooserActivity.java @@ -1143,7 +1143,7 @@ public class ChooserActivity extends Hilt_ChooserActivity implements } @Override - boolean isComponentFiltered(ComponentName name) { + public boolean isComponentFiltered(ComponentName name) { return mChooserRequest.getFilteredComponentNames().contains(name); } diff --git a/java/src/com/android/intentresolver/ChooserGridLayoutManager.java b/java/src/com/android/intentresolver/ChooserGridLayoutManager.java index 5f373525..aaa7554c 100644 --- a/java/src/com/android/intentresolver/ChooserGridLayoutManager.java +++ b/java/src/com/android/intentresolver/ChooserGridLayoutManager.java @@ -70,7 +70,7 @@ public class ChooserGridLayoutManager extends GridLayoutManager { return super.getRowCountForAccessibility(recycler, state) - 1; } - void setVerticalScrollEnabled(boolean verticalScrollEnabled) { + public void setVerticalScrollEnabled(boolean verticalScrollEnabled) { mVerticalScrollEnabled = verticalScrollEnabled; } diff --git a/java/src/com/android/intentresolver/ChooserIntegratedDeviceComponents.java b/java/src/com/android/intentresolver/ChooserIntegratedDeviceComponents.java index 5fbf03a0..df5a8dc8 100644 --- a/java/src/com/android/intentresolver/ChooserIntegratedDeviceComponents.java +++ b/java/src/com/android/intentresolver/ChooserIntegratedDeviceComponents.java @@ -49,7 +49,7 @@ public class ChooserIntegratedDeviceComponents { } @VisibleForTesting - ChooserIntegratedDeviceComponents( + public ChooserIntegratedDeviceComponents( ComponentName editSharingComponent, ComponentName nearbySharingComponent) { mEditSharingComponent = editSharingComponent; mNearbySharingComponent = nearbySharingComponent; diff --git a/java/src/com/android/intentresolver/ChooserListAdapter.java b/java/src/com/android/intentresolver/ChooserListAdapter.java index 230c18b2..ec8800b8 100644 --- a/java/src/com/android/intentresolver/ChooserListAdapter.java +++ b/java/src/com/android/intentresolver/ChooserListAdapter.java @@ -413,7 +413,7 @@ public class ChooserListAdapter extends ResolverListAdapter { } } - void updateAlphabeticalList() { + public void updateAlphabeticalList() { final ChooserActivity.AzInfoComparator comparator = new ChooserActivity.AzInfoComparator(mContext); final List<DisplayResolveInfo> allTargets = new ArrayList<>(); diff --git a/java/src/com/android/intentresolver/ChooserMultiProfilePagerAdapter.java b/java/src/com/android/intentresolver/ChooserMultiProfilePagerAdapter.java index 23a081d2..080f9d24 100644 --- a/java/src/com/android/intentresolver/ChooserMultiProfilePagerAdapter.java +++ b/java/src/com/android/intentresolver/ChooserMultiProfilePagerAdapter.java @@ -46,7 +46,7 @@ public class ChooserMultiProfilePagerAdapter extends MultiProfilePagerAdapter< private final ChooserProfileAdapterBinder mAdapterBinder; private final BottomPaddingOverrideSupplier mBottomPaddingOverrideSupplier; - ChooserMultiProfilePagerAdapter( + public ChooserMultiProfilePagerAdapter( Context context, ChooserGridAdapter adapter, EmptyStateProvider emptyStateProvider, @@ -68,7 +68,7 @@ public class ChooserMultiProfilePagerAdapter extends MultiProfilePagerAdapter< featureFlags); } - ChooserMultiProfilePagerAdapter( + public ChooserMultiProfilePagerAdapter( Context context, ChooserGridAdapter personalAdapter, ChooserGridAdapter workAdapter, diff --git a/java/src/com/android/intentresolver/MultiProfilePagerAdapter.java b/java/src/com/android/intentresolver/MultiProfilePagerAdapter.java index 8c640dd3..8ce42b28 100644 --- a/java/src/com/android/intentresolver/MultiProfilePagerAdapter.java +++ b/java/src/com/android/intentresolver/MultiProfilePagerAdapter.java @@ -79,11 +79,11 @@ public class MultiProfilePagerAdapter< void bind(PageViewT view, SinglePageAdapterT adapter); } - static final int PROFILE_PERSONAL = 0; - static final int PROFILE_WORK = 1; + public static final int PROFILE_PERSONAL = 0; + public static final int PROFILE_WORK = 1; @IntDef({PROFILE_PERSONAL, PROFILE_WORK}) - @interface Profile {} + public @interface Profile {} private final Function<SinglePageAdapterT, ListAdapterT> mListAdapterExtractor; private final AdapterBinder<PageViewT, SinglePageAdapterT> mAdapterBinder; @@ -197,7 +197,7 @@ public class MultiProfilePagerAdapter< return getItemCount(); } - protected int getCurrentPage() { + public int getCurrentPage() { return mCurrentPage; } @@ -234,7 +234,7 @@ public class MultiProfilePagerAdapter< return mItems.get(pageIndex); } - protected ViewGroup getEmptyStateView(int pageIndex) { + public ViewGroup getEmptyStateView(int pageIndex) { return getItem(pageIndex).getEmptyStateView(); } @@ -266,7 +266,7 @@ public class MultiProfilePagerAdapter< * Performs view-related initialization procedures for the adapter specified * by <code>pageIndex</code>. */ - protected final void setupListAdapter(int pageIndex) { + public final void setupListAdapter(int pageIndex) { mAdapterBinder.bind(getListViewForIndex(pageIndex), getAdapterForIndex(pageIndex)); } @@ -278,7 +278,7 @@ public class MultiProfilePagerAdapter< * with <code>UserHandle.of(10)</code> returns the work profile {@link ListAdapterT}. */ @Nullable - protected final ListAdapterT getListAdapterForUserHandle(UserHandle userHandle) { + public final ListAdapterT getListAdapterForUserHandle(UserHandle userHandle) { if (getPersonalListAdapter().getUserHandle().equals(userHandle) || userHandle.equals(getCloneUserHandle())) { return getPersonalListAdapter(); @@ -297,7 +297,7 @@ public class MultiProfilePagerAdapter< * @see #getInactiveListAdapter() */ @VisibleForTesting - protected final ListAdapterT getActiveListAdapter() { + public final ListAdapterT getActiveListAdapter() { return mListAdapterExtractor.apply(getAdapterForIndex(getCurrentPage())); } @@ -311,7 +311,7 @@ public class MultiProfilePagerAdapter< */ @VisibleForTesting @Nullable - protected final ListAdapterT getInactiveListAdapter() { + public final ListAdapterT getInactiveListAdapter() { if (getCount() < 2) { return null; } @@ -330,16 +330,16 @@ public class MultiProfilePagerAdapter< return mListAdapterExtractor.apply(getAdapterForIndex(PROFILE_WORK)); } - protected final SinglePageAdapterT getCurrentRootAdapter() { + public final SinglePageAdapterT getCurrentRootAdapter() { return getAdapterForIndex(getCurrentPage()); } - protected final PageViewT getActiveAdapterView() { + public final PageViewT getActiveAdapterView() { return getListViewForIndex(getCurrentPage()); } @Nullable - protected final PageViewT getInactiveAdapterView() { + public final PageViewT getInactiveAdapterView() { if (getCount() < 2) { return null; } @@ -505,7 +505,7 @@ public class MultiProfilePagerAdapter< paddingBottom)); } - protected void showListView(ListAdapterT activeListAdapter) { + public void showListView(ListAdapterT activeListAdapter) { ProfileDescriptor<PageViewT, SinglePageAdapterT> descriptor = getItem( userHandleToPageIndex(activeListAdapter.getUserHandle())); descriptor.mRootView.findViewById( diff --git a/java/src/com/android/intentresolver/ResolverListAdapter.java b/java/src/com/android/intentresolver/ResolverListAdapter.java index 95ed0d5c..8c0d414c 100644 --- a/java/src/com/android/intentresolver/ResolverListAdapter.java +++ b/java/src/com/android/intentresolver/ResolverListAdapter.java @@ -68,7 +68,7 @@ public class ResolverListAdapter extends BaseAdapter { protected final Context mContext; protected final LayoutInflater mInflater; protected final ResolverListCommunicator mResolverListCommunicator; - protected final ResolverListController mResolverListController; + public final ResolverListController mResolverListController; private final List<Intent> mIntents; private final Intent[] mInitialIntents; @@ -229,7 +229,7 @@ public class ResolverListAdapter extends BaseAdapter { packageName, userHandle, action); } - List<ResolvedComponentInfo> getUnfilteredResolveList() { + public List<ResolvedComponentInfo> getUnfilteredResolveList() { return mUnfilteredResolveList; } @@ -808,7 +808,7 @@ public class ResolverListAdapter extends BaseAdapter { return mContext.getDrawable(R.drawable.resolver_icon_placeholder); } - void loadFilteredItemIconTaskAsync(@NonNull ImageView iconView) { + public void loadFilteredItemIconTaskAsync(@NonNull ImageView iconView) { final DisplayResolveInfo iconInfo = getFilteredItem(); if (iconInfo != null) { mTargetDataLoader.loadAppTargetIcon( @@ -834,7 +834,7 @@ public class ResolverListAdapter extends BaseAdapter { return mIntents; } - protected boolean isTabLoaded() { + public boolean isTabLoaded() { return mIsTabLoaded; } @@ -893,7 +893,7 @@ public class ResolverListAdapter extends BaseAdapter { * Necessary methods to communicate between {@link ResolverListAdapter} * and {@link ResolverActivity}. */ - interface ResolverListCommunicator { + public interface ResolverListCommunicator { Intent getReplacementIntent(ActivityInfo activityInfo, Intent defIntent); diff --git a/java/src/com/android/intentresolver/ResolverListController.java b/java/src/com/android/intentresolver/ResolverListController.java index cb56ab30..05121576 100644 --- a/java/src/com/android/intentresolver/ResolverListController.java +++ b/java/src/com/android/intentresolver/ResolverListController.java @@ -333,7 +333,7 @@ public class ResolverListController { && ai.name.equals(b.name.getClassName()); } - boolean isComponentFiltered(ComponentName componentName) { + public boolean isComponentFiltered(ComponentName componentName) { return false; } diff --git a/java/src/com/android/intentresolver/ResolverMultiProfilePagerAdapter.java b/java/src/com/android/intentresolver/ResolverMultiProfilePagerAdapter.java index e0c5380f..591c23b7 100644 --- a/java/src/com/android/intentresolver/ResolverMultiProfilePagerAdapter.java +++ b/java/src/com/android/intentresolver/ResolverMultiProfilePagerAdapter.java @@ -40,7 +40,7 @@ public class ResolverMultiProfilePagerAdapter extends MultiProfilePagerAdapter<ListView, ResolverListAdapter, ResolverListAdapter> { private final BottomPaddingOverrideSupplier mBottomPaddingOverrideSupplier; - ResolverMultiProfilePagerAdapter( + public ResolverMultiProfilePagerAdapter( Context context, ResolverListAdapter adapter, EmptyStateProvider emptyStateProvider, @@ -58,14 +58,14 @@ public class ResolverMultiProfilePagerAdapter extends new BottomPaddingOverrideSupplier()); } - ResolverMultiProfilePagerAdapter(Context context, - ResolverListAdapter personalAdapter, - ResolverListAdapter workAdapter, - EmptyStateProvider emptyStateProvider, - Supplier<Boolean> workProfileQuietModeChecker, - @Profile int defaultProfile, - UserHandle workProfileUserHandle, - UserHandle cloneProfileUserHandle) { + public ResolverMultiProfilePagerAdapter(Context context, + ResolverListAdapter personalAdapter, + ResolverListAdapter workAdapter, + EmptyStateProvider emptyStateProvider, + Supplier<Boolean> workProfileQuietModeChecker, + @Profile int defaultProfile, + UserHandle workProfileUserHandle, + UserHandle cloneProfileUserHandle) { this( context, ImmutableList.of(personalAdapter, workAdapter), diff --git a/java/src/com/android/intentresolver/ResolverViewPager.java b/java/src/com/android/intentresolver/ResolverViewPager.java index 0804a2b8..0496579d 100644 --- a/java/src/com/android/intentresolver/ResolverViewPager.java +++ b/java/src/com/android/intentresolver/ResolverViewPager.java @@ -69,7 +69,7 @@ public class ResolverViewPager extends ViewPager { * Sets whether swiping sideways should happen. * <p>Note that swiping is always disabled for RTL layouts (b/159110029 for context). */ - void setSwipingEnabled(boolean swipingEnabled) { + public void setSwipingEnabled(boolean swipingEnabled) { mSwipingEnabled = swipingEnabled; } diff --git a/java/src/com/android/intentresolver/v2/ChooserActivity.java b/java/src/com/android/intentresolver/v2/ChooserActivity.java new file mode 100644 index 00000000..9e437010 --- /dev/null +++ b/java/src/com/android/intentresolver/v2/ChooserActivity.java @@ -0,0 +1,1851 @@ +/* + * 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.v2; + +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 androidx.lifecycle.LifecycleKt.getCoroutineScope; +import static com.android.intentresolver.v2.ResolverActivity.PROFILE_PERSONAL; +import static com.android.intentresolver.v2.ResolverActivity.PROFILE_WORK; +import static com.android.internal.util.LatencyTracker.ACTION_LOAD_SHARE_SHEET; + +import android.annotation.IntDef; +import android.annotation.Nullable; +import android.app.Activity; +import android.app.ActivityManager; +import android.app.ActivityOptions; +import android.app.prediction.AppPredictor; +import android.app.prediction.AppTarget; +import android.app.prediction.AppTargetEvent; +import android.app.prediction.AppTargetId; +import android.content.ComponentName; +import android.content.ContentResolver; +import android.content.Context; +import android.content.Intent; +import android.content.IntentFilter; +import android.content.IntentSender; +import android.content.SharedPreferences; +import android.content.pm.ActivityInfo; +import android.content.pm.PackageManager; +import android.content.pm.ResolveInfo; +import android.content.pm.ShortcutInfo; +import android.content.res.Configuration; +import android.database.Cursor; +import android.graphics.Insets; +import android.net.Uri; +import android.os.Bundle; +import android.os.SystemClock; +import android.os.UserHandle; +import android.os.UserManager; +import android.service.chooser.ChooserTarget; +import android.util.Log; +import android.util.Slog; +import android.util.SparseArray; +import android.view.View; +import android.view.ViewGroup; +import android.view.ViewGroup.LayoutParams; +import android.view.ViewTreeObserver; +import android.view.WindowInsets; +import android.widget.TextView; + +import androidx.annotation.MainThread; +import androidx.annotation.NonNull; +import androidx.lifecycle.ViewModelProvider; +import androidx.recyclerview.widget.GridLayoutManager; +import androidx.recyclerview.widget.RecyclerView; +import androidx.viewpager.widget.ViewPager; + +import com.android.intentresolver.ChooserActionFactory; +import com.android.intentresolver.ChooserGridLayoutManager; +import com.android.intentresolver.ChooserIntegratedDeviceComponents; +import com.android.intentresolver.ChooserListAdapter; +import com.android.intentresolver.ChooserMultiProfilePagerAdapter; +import com.android.intentresolver.ChooserRefinementManager; +import com.android.intentresolver.ChooserRequestParameters; +import com.android.intentresolver.ChooserStackedAppDialogFragment; +import com.android.intentresolver.ChooserTargetActionsDialogFragment; +import com.android.intentresolver.EnterTransitionAnimationDelegate; +import com.android.intentresolver.FeatureFlags; +import com.android.intentresolver.IntentForwarderActivity; +import com.android.intentresolver.R; +import com.android.intentresolver.ResolverListAdapter; +import com.android.intentresolver.ResolverListController; +import com.android.intentresolver.ResolverViewPager; +import com.android.intentresolver.SecureSettings; +import com.android.intentresolver.chooser.DisplayResolveInfo; +import com.android.intentresolver.chooser.MultiDisplayResolveInfo; +import com.android.intentresolver.chooser.TargetInfo; +import com.android.intentresolver.contentpreview.BasePreviewViewModel; +import com.android.intentresolver.contentpreview.ChooserContentPreviewUi; +import com.android.intentresolver.contentpreview.HeadlineGeneratorImpl; +import com.android.intentresolver.contentpreview.PreviewViewModel; +import com.android.intentresolver.emptystate.EmptyState; +import com.android.intentresolver.emptystate.EmptyStateProvider; +import com.android.intentresolver.emptystate.NoCrossProfileEmptyStateProvider; +import com.android.intentresolver.emptystate.NoCrossProfileEmptyStateProvider.DevicePolicyBlockerEmptyState; +import com.android.intentresolver.grid.ChooserGridAdapter; +import com.android.intentresolver.icons.DefaultTargetDataLoader; +import com.android.intentresolver.icons.TargetDataLoader; +import com.android.intentresolver.logging.EventLog; +import com.android.intentresolver.measurements.Tracer; +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.ImagePreviewView; +import com.android.intentresolver.v2.Hilt_ChooserActivity; +import com.android.internal.annotations.VisibleForTesting; +import com.android.internal.content.PackageMonitor; +import com.android.internal.logging.nano.MetricsProto.MetricsEvent; + +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.text.Collator; +import java.util.ArrayList; +import java.util.Collections; +import java.util.Comparator; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.function.Consumer; + +import javax.inject.Inject; + +import dagger.hilt.android.AndroidEntryPoint; + +/** + * The Chooser Activity handles intent resolution specifically for sharing intents - + * for example, as generated by {@see android.content.Intent#createChooser(Intent, CharSequence)}. + * + */ +@AndroidEntryPoint(ResolverActivity.class) +public class ChooserActivity extends Hilt_ChooserActivity implements + ResolverListAdapter.ResolverListCommunicator { + private static final String TAG = "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 + * ourselves when onStop gets called. + */ + 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. + * @hide + */ + public static final String FIRST_IMAGE_PREVIEW_TRANSITION_NAME = "screenshot_preview_image"; + + private static final boolean DEBUG = true; + + public static final String LAUNCH_LOCATION_DIRECT_SHARE = "direct_share"; + private static final String SHORTCUT_TARGET = "shortcut_target"; + + // TODO: these data structures are for one-time use in shuttling data from where they're + // populated in `ShortcutToChooserTargetConverter` to where they're consumed in + // `ShortcutSelectionLogic` which packs the appropriate elements into the final `TargetInfo`. + // That flow should be refactored so that `ChooserActivity` isn't responsible for holding their + // intermediate data, and then these members can be removed. + private final Map<ChooserTarget, AppTarget> mDirectShareAppTargetCache = new HashMap<>(); + private final Map<ChooserTarget, ShortcutInfo> mDirectShareShortcutInfoCache = new HashMap<>(); + + public static final int TARGET_TYPE_DEFAULT = 0; + public static final int TARGET_TYPE_CHOOSER_TARGET = 1; + public static final int TARGET_TYPE_SHORTCUTS_FROM_SHORTCUT_MANAGER = 2; + public static final int TARGET_TYPE_SHORTCUTS_FROM_PREDICTION_SERVICE = 3; + + private static final int SCROLL_STATUS_IDLE = 0; + private static final int SCROLL_STATUS_SCROLLING_VERTICAL = 1; + private static final int SCROLL_STATUS_SCROLLING_HORIZONTAL = 2; + + @IntDef(flag = false, prefix = { "TARGET_TYPE_" }, value = { + TARGET_TYPE_DEFAULT, + TARGET_TYPE_CHOOSER_TARGET, + TARGET_TYPE_SHORTCUTS_FROM_SHORTCUT_MANAGER, + TARGET_TYPE_SHORTCUTS_FROM_PREDICTION_SERVICE + }) + @Retention(RetentionPolicy.SOURCE) + public @interface ShareTargetType {} + + @Inject public FeatureFlags mFeatureFlags; + @Inject public EventLog mEventLog; + + private ChooserIntegratedDeviceComponents mIntegratedDeviceComponents; + + /* TODO: this is `nullable` because we have to defer the assignment til onCreate(). We make the + * only assignment there, and expect it to be ready by the time we ever use it -- + * someday if we move all the usage to a component with a narrower lifecycle (something that + * matches our Activity's create/destroy lifecycle, not its Java object lifecycle) then we + * should be able to make this assignment as "final." + */ + @Nullable + private ChooserRequestParameters mChooserRequest; + + private ChooserRefinementManager mRefinementManager; + + private ChooserContentPreviewUi mChooserContentPreviewUi; + + private boolean mShouldDisplayLandscape; + private long mChooserShownTime; + protected boolean mIsSuccessfullySelected; + + private int mCurrAvailableWidth = 0; + private Insets mLastAppliedInsets = null; + private int mLastNumberOfChildren = -1; + private int mMaxTargetsPerRow = 1; + + 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"; + + private final ExecutorService mBackgroundThreadPoolExecutor = Executors.newFixedThreadPool(5); + + private int mScrollStatus = SCROLL_STATUS_IDLE; + + @VisibleForTesting + protected ChooserMultiProfilePagerAdapter mChooserMultiProfilePagerAdapter; + private final EnterTransitionAnimationDelegate mEnterTransitionAnimationDelegate = + new EnterTransitionAnimationDelegate(this, () -> mResolverDrawerLayout); + + private View mContentView = null; + + private final SparseArray<ProfileRecord> mProfileRecords = new SparseArray<>(); + + private boolean mExcludeSharedText = false; + /** + * When we intend to finish the activity with a shared element transition, we can't immediately + * finish() when the transition is invoked, as the receiving end may not be able to start the + * animation and the UI breaks if this takes too long. Instead we defer finishing until onStop + * in order to wait for the transition to begin. + */ + private boolean mFinishWhenStopped = false; + + @Override + protected void onCreate(Bundle savedInstanceState) { + Tracer.INSTANCE.markLaunched(); + final long intentReceivedTime = System.currentTimeMillis(); + mLatencyTracker.onActionStart(ACTION_LOAD_SHARE_SHEET); + + try { + mChooserRequest = new ChooserRequestParameters( + getIntent(), + getReferrerPackageName(), + getReferrer()); + } catch (IllegalArgumentException e) { + Log.e(TAG, "Caller provided invalid Chooser request parameters", e); + finish(); + super_onCreate(null); + return; + } + mPinnedSharedPrefs = getPinnedSharedPrefs(this); + mMaxTargetsPerRow = getResources().getInteger(R.integer.config_chooser_max_targets_per_row); + mShouldDisplayLandscape = + shouldDisplayLandscape(getResources().getConfiguration().orientation); + setRetainInOnStop(mChooserRequest.shouldRetainInOnStop()); + + createProfileRecords( + new AppPredictorFactory( + this, + mChooserRequest.getSharedText(), + mChooserRequest.getTargetIntentFilter()), + mChooserRequest.getTargetIntentFilter()); + + + super.onCreate( + savedInstanceState, + mChooserRequest.getTargetIntent(), + mChooserRequest.getAdditionalTargets(), + mChooserRequest.getTitle(), + mChooserRequest.getDefaultTitleResource(), + mChooserRequest.getInitialIntents(), + /* resolutionList= */ null, + /* supportsAlwaysUseOption= */ false, + new DefaultTargetDataLoader(this, getLifecycle(), false), + /* safeForwardingMode= */ true); + + getEventLog().logSharesheetTriggered(); + + mIntegratedDeviceComponents = getIntegratedDeviceComponents(); + + mRefinementManager = new ViewModelProvider(this).get(ChooserRefinementManager.class); + + mRefinementManager.getRefinementCompletion().observe(this, completion -> { + if (completion.consume()) { + TargetInfo targetInfo = completion.getTargetInfo(); + // targetInfo is non-null if the refinement process was successful. + if (targetInfo != null) { + maybeRemoveSharedText(targetInfo); + + // We already block suspended targets from going to refinement, and we probably + // can't recover a Chooser session if that's the reason the refined target fails + // to launch now. Fire-and-forget the refined launch; ignore the return value + // and just make sure the Sharesheet session gets cleaned up regardless. + ChooserActivity.super.onTargetSelected(targetInfo, false); + } + + finish(); + } + }); + + BasePreviewViewModel previewViewModel = + new ViewModelProvider(this, createPreviewViewModelFactory()) + .get(BasePreviewViewModel.class); + mChooserContentPreviewUi = new ChooserContentPreviewUi( + getLifecycle(), + previewViewModel.createOrReuseProvider(mChooserRequest), + mChooserRequest.getTargetIntent(), + previewViewModel.createOrReuseImageLoader(), + createChooserActionFactory(), + mEnterTransitionAnimationDelegate, + new HeadlineGeneratorImpl(this)); + + updateStickyContentPreview(); + if (shouldShowStickyContentPreview() + || mChooserMultiProfilePagerAdapter + .getCurrentRootAdapter().getSystemRowCount() != 0) { + getEventLog().logActionShareWithPreview( + mChooserContentPreviewUi.getPreferredContentPreview()); + } + + mChooserShownTime = System.currentTimeMillis(); + final long systemCost = mChooserShownTime - intentReceivedTime; + getEventLog().logChooserActivityShown( + isWorkProfile(), mChooserRequest.getTargetType(), systemCost); + + if (mResolverDrawerLayout != null) { + mResolverDrawerLayout.addOnLayoutChangeListener(this::handleLayoutChange); + + mResolverDrawerLayout.setOnCollapsedChangedListener( + isCollapsed -> { + mChooserMultiProfilePagerAdapter.setIsCollapsed(isCollapsed); + getEventLog().logSharesheetExpansionChanged(isCollapsed); + }); + } + + if (DEBUG) { + Log.d(TAG, "System Time Cost is " + systemCost); + } + + getEventLog().logShareStarted( + getReferrerPackageName(), + mChooserRequest.getTargetType(), + mChooserRequest.getCallerChooserTargets().size(), + (mChooserRequest.getInitialIntents() == null) + ? 0 : mChooserRequest.getInitialIntents().length, + isWorkProfile(), + mChooserContentPreviewUi.getPreferredContentPreview(), + mChooserRequest.getTargetAction(), + mChooserRequest.getChooserActions().size(), + mChooserRequest.getModifyShareAction() != null + ); + + mEnterTransitionAnimationDelegate.postponeTransition(); + } + + @VisibleForTesting + protected ChooserIntegratedDeviceComponents getIntegratedDeviceComponents() { + return ChooserIntegratedDeviceComponents.get(this, new SecureSettings()); + } + + @Override + protected int appliedThemeResId() { + return R.style.Theme_DeviceDefault_Chooser; + } + + private void createProfileRecords( + AppPredictorFactory factory, IntentFilter targetIntentFilter) { + UserHandle mainUserHandle = getAnnotatedUserHandles().personalProfileUserHandle; + ProfileRecord record = createProfileRecord(mainUserHandle, targetIntentFilter, factory); + if (record.shortcutLoader == null) { + Tracer.INSTANCE.endLaunchToShortcutTrace(); + } + + UserHandle workUserHandle = getAnnotatedUserHandles().workProfileUserHandle; + if (workUserHandle != null) { + createProfileRecord(workUserHandle, targetIntentFilter, factory); + } + } + + private ProfileRecord createProfileRecord( + UserHandle userHandle, IntentFilter targetIntentFilter, AppPredictorFactory factory) { + AppPredictor appPredictor = factory.create(userHandle); + ShortcutLoader shortcutLoader = ActivityManager.isLowRamDeviceStatic() + ? null + : createShortcutLoader( + this, + appPredictor, + userHandle, + targetIntentFilter, + shortcutsResult -> onShortcutsLoaded(userHandle, shortcutsResult)); + ProfileRecord record = new ProfileRecord(appPredictor, shortcutLoader); + mProfileRecords.put(userHandle.getIdentifier(), record); + return record; + } + + @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, + getCoroutineScope(getLifecycle()), + appPredictor, + userHandle, + targetIntentFilter, + callback); + } + + static SharedPreferences getPinnedSharedPrefs(Context context) { + return context.getSharedPreferences(PINNED_SHARED_PREFS_NAME, MODE_PRIVATE); + } + + @Override + protected ChooserMultiProfilePagerAdapter createMultiProfilePagerAdapter( + Intent[] initialIntents, + List<ResolveInfo> rList, + boolean filterLastUsed, + TargetDataLoader targetDataLoader) { + if (shouldShowTabs()) { + mChooserMultiProfilePagerAdapter = createChooserMultiProfilePagerAdapterForTwoProfiles( + initialIntents, rList, filterLastUsed, targetDataLoader); + } else { + mChooserMultiProfilePagerAdapter = createChooserMultiProfilePagerAdapterForOneProfile( + initialIntents, rList, filterLastUsed, targetDataLoader); + } + 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( + getAnnotatedUserHandles().personalProfileUserHandle, + noWorkToPersonalEmptyState, + noPersonalToWorkEmptyState, + createCrossProfileIntentsChecker(), + getAnnotatedUserHandles().tabOwnerUserHandleForLaunch); + } + + private ChooserMultiProfilePagerAdapter createChooserMultiProfilePagerAdapterForOneProfile( + Intent[] initialIntents, + List<ResolveInfo> rList, + boolean filterLastUsed, + TargetDataLoader targetDataLoader) { + ChooserGridAdapter adapter = createChooserGridAdapter( + /* context */ this, + /* payloadIntents */ mIntents, + initialIntents, + rList, + filterLastUsed, + /* userHandle */ getAnnotatedUserHandles().personalProfileUserHandle, + targetDataLoader); + return new ChooserMultiProfilePagerAdapter( + /* context */ this, + adapter, + createEmptyStateProvider(/* workProfileUserHandle= */ null), + /* workProfileQuietModeChecker= */ () -> false, + /* workProfileUserHandle= */ null, + getAnnotatedUserHandles().cloneProfileUserHandle, + mMaxTargetsPerRow, + mFeatureFlags); + } + + private ChooserMultiProfilePagerAdapter createChooserMultiProfilePagerAdapterForTwoProfiles( + Intent[] initialIntents, + List<ResolveInfo> rList, + boolean filterLastUsed, + TargetDataLoader targetDataLoader) { + int selectedProfile = findSelectedProfile(); + ChooserGridAdapter personalAdapter = createChooserGridAdapter( + /* context */ this, + /* payloadIntents */ mIntents, + selectedProfile == PROFILE_PERSONAL ? initialIntents : null, + rList, + filterLastUsed, + /* userHandle */ getAnnotatedUserHandles().personalProfileUserHandle, + targetDataLoader); + ChooserGridAdapter workAdapter = createChooserGridAdapter( + /* context */ this, + /* payloadIntents */ mIntents, + selectedProfile == PROFILE_WORK ? initialIntents : null, + rList, + filterLastUsed, + /* userHandle */ getAnnotatedUserHandles().workProfileUserHandle, + targetDataLoader); + return new ChooserMultiProfilePagerAdapter( + /* context */ this, + personalAdapter, + workAdapter, + createEmptyStateProvider(getAnnotatedUserHandles().workProfileUserHandle), + () -> mWorkProfileAvailability.isQuietModeEnabled(), + selectedProfile, + getAnnotatedUserHandles().workProfileUserHandle, + getAnnotatedUserHandles().cloneProfileUserHandle, + mMaxTargetsPerRow, + mFeatureFlags); + } + + private int findSelectedProfile() { + int selectedProfile = getSelectedProfileExtra(); + if (selectedProfile == -1) { + selectedProfile = getProfileForUser( + getAnnotatedUserHandles().tabOwnerUserHandleForLaunch); + } + return selectedProfile; + } + + /** + * 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) + */ + protected boolean isWorkProfile() { + return getSystemService(UserManager.class) + .getUserInfo(UserHandle.myUserId()).isManagedProfile(); + } + + @Override + protected PackageMonitor createPackageMonitor(ResolverListAdapter listAdapter) { + return new PackageMonitor() { + @Override + public void onSomePackagesChanged() { + handlePackagesChanged(listAdapter); + } + }; + } + + /** + * Update UI to reflect changes in data. + */ + public void handlePackagesChanged() { + handlePackagesChanged(/* listAdapter */ null); + } + + /** + * Update UI to reflect changes in data. + * <p>If {@code listAdapter} is {@code null}, both profile list adapters are updated if + * available. + */ + private void handlePackagesChanged(@Nullable ResolverListAdapter listAdapter) { + // Refresh pinned items + mPinnedSharedPrefs = getPinnedSharedPrefs(this); + if (listAdapter == null) { + handlePackageChangePerProfile(mChooserMultiProfilePagerAdapter.getActiveListAdapter()); + if (mChooserMultiProfilePagerAdapter.getCount() > 1) { + handlePackageChangePerProfile( + mChooserMultiProfilePagerAdapter.getInactiveListAdapter()); + } + } else { + handlePackageChangePerProfile(listAdapter); + } + updateProfileViewButton(); + } + + private void handlePackageChangePerProfile(ResolverListAdapter adapter) { + ProfileRecord record = getProfileRecord(adapter.getUserHandle()); + if (record != null && record.shortcutLoader != null) { + record.shortcutLoader.reset(); + } + adapter.handlePackagesChanged(); + } + + @Override + protected void onResume() { + super.onResume(); + Log.d(TAG, "onResume: " + getComponentName().flattenToShortString()); + mFinishWhenStopped = false; + mRefinementManager.onActivityResume(); + } + + @Override + public void onConfigurationChanged(Configuration newConfig) { + super.onConfigurationChanged(newConfig); + ViewPager viewPager = findViewById(com.android.internal.R.id.profile_pager); + if (viewPager.isLayoutRtl()) { + mMultiProfilePagerAdapter.setupViewPager(viewPager); + } + + mShouldDisplayLandscape = shouldDisplayLandscape(newConfig.orientation); + mMaxTargetsPerRow = getResources().getInteger(R.integer.config_chooser_max_targets_per_row); + mChooserMultiProfilePagerAdapter.setMaxTargetsPerRow(mMaxTargetsPerRow); + adjustPreviewWidth(newConfig.orientation, null); + updateStickyContentPreview(); + updateTabPadding(); + } + + private boolean shouldDisplayLandscape(int orientation) { + // Sharesheet fixes the # of items per row and therefore can not correctly lay out + // when in the restricted size of multi-window mode. In the future, would be nice + // to use minimum dp size requirements instead + return orientation == Configuration.ORIENTATION_LANDSCAPE && !isInMultiWindowMode(); + } + + private void adjustPreviewWidth(int orientation, View parent) { + int width = -1; + if (mShouldDisplayLandscape) { + width = getResources().getDimensionPixelSize(R.dimen.chooser_preview_width); + } + + parent = parent == null ? getWindow().getDecorView() : parent; + + updateLayoutWidth(com.android.internal.R.id.content_preview_file_layout, width, parent); + } + + private void updateTabPadding() { + if (shouldShowTabs()) { + View tabs = findViewById(com.android.internal.R.id.tabs); + float iconSize = getResources().getDimension(R.dimen.chooser_icon_size); + // The entire width consists of icons or padding. Divide the item padding in half to get + // paddingHorizontal. + float padding = (tabs.getWidth() - mMaxTargetsPerRow * iconSize) + / mMaxTargetsPerRow / 2; + // Subtract the margin the buttons already have. + padding -= getResources().getDimension(R.dimen.resolver_profile_tab_margin); + tabs.setPadding((int) padding, 0, (int) padding, 0); + } + } + + private void updateLayoutWidth(int layoutResourceId, int width, View parent) { + View view = parent.findViewById(layoutResourceId); + if (view != null && view.getLayoutParams() != null) { + LayoutParams params = view.getLayoutParams(); + params.width = width; + view.setLayoutParams(params); + } + } + + /** + * 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) { + ViewGroup layout = mChooserContentPreviewUi.displayContentPreview( + getResources(), + getLayoutInflater(), + parent, + mFeatureFlags.scrollablePreview() + ? findViewById(R.id.chooser_headline_row_container) + : null); + + if (layout != null) { + adjustPreviewWidth(getResources().getConfiguration().orientation, layout); + } + + return layout; + } + + @Nullable + private View getFirstVisibleImgPreviewView() { + View imagePreview = findViewById(R.id.scrollable_image_preview); + return imagePreview instanceof ImagePreviewView + ? ((ImagePreviewView) imagePreview).getTransitionView() + : null; + } + + /** + * Wrapping the ContentResolver call to expose for easier mocking, + * and to avoid mocking Android core classes. + */ + @VisibleForTesting + public Cursor queryResolver(ContentResolver resolver, Uri uri) { + return resolver.query(uri, null, null, null, null); + } + + @Override + protected void onStop() { + super.onStop(); + mRefinementManager.onActivityStop(isChangingConfigurations()); + + if (mFinishWhenStopped) { + mFinishWhenStopped = false; + finish(); + } + } + + @Override + protected void onDestroy() { + super.onDestroy(); + + if (isFinishing()) { + mLatencyTracker.onActionCancel(ACTION_LOAD_SHARE_SHEET); + } + + mBackgroundThreadPoolExecutor.shutdownNow(); + + destroyProfileRecords(); + } + + private void destroyProfileRecords() { + for (int i = 0; i < mProfileRecords.size(); ++i) { + mProfileRecords.valueAt(i).destroy(); + } + mProfileRecords.clear(); + } + + @Override // ResolverListCommunicator + public Intent getReplacementIntent(ActivityInfo aInfo, Intent defIntent) { + if (mChooserRequest == null) { + return defIntent; + } + + Intent result = defIntent; + if (mChooserRequest.getReplacementExtras() != null) { + final Bundle replExtras = + mChooserRequest.getReplacementExtras().getBundle(aInfo.packageName); + if (replExtras != null) { + result = new Intent(defIntent); + result.putExtras(replExtras); + } + } + if (aInfo.name.equals(IntentForwarderActivity.FORWARD_INTENT_TO_PARENT) + || aInfo.name.equals(IntentForwarderActivity.FORWARD_INTENT_TO_MANAGED_PROFILE)) { + result = Intent.createChooser(result, + getIntent().getCharSequenceExtra(Intent.EXTRA_TITLE)); + + // Don't auto-launch single intents if the intent is being forwarded. This is done + // because automatically launching a resolving application as a response to the user + // action of switching accounts is pretty unexpected. + result.putExtra(Intent.EXTRA_AUTO_LAUNCH_SINGLE_CHOICE, false); + } + return result; + } + + @Override + public void onActivityStarted(TargetInfo cti) { + if (mChooserRequest.getChosenComponentSender() != null) { + final ComponentName target = cti.getResolvedComponentName(); + if (target != null) { + final Intent fillIn = new Intent().putExtra(Intent.EXTRA_CHOSEN_COMPONENT, target); + try { + 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); + } + } + } + } + + private void addCallerChooserTargets() { + if (!mChooserRequest.getCallerChooserTargets().isEmpty()) { + // Send the caller's chooser targets only to the default profile. + UserHandle defaultUser = (findSelectedProfile() == PROFILE_WORK) + ? getAnnotatedUserHandles().workProfileUserHandle + : getAnnotatedUserHandles().personalProfileUserHandle; + if (mChooserMultiProfilePagerAdapter.getCurrentUserHandle() == defaultUser) { + mChooserMultiProfilePagerAdapter.getActiveListAdapter().addServiceResults( + /* origTarget */ null, + new ArrayList<>(mChooserRequest.getCallerChooserTargets()), + TARGET_TYPE_DEFAULT, + /* directShareShortcutInfoCache */ Collections.emptyMap(), + /* directShareAppTargetCache */ Collections.emptyMap()); + } + } + } + + @Override + public int getLayoutResource() { + return mFeatureFlags.scrollablePreview() + ? R.layout.chooser_grid_scrollable_preview + : R.layout.chooser_grid; + } + + @Override // ResolverListCommunicator + public boolean shouldGetActivityMetadata() { + return true; + } + + @Override + public boolean shouldAutoLaunchSingleChoice(TargetInfo target) { + // Note that this is only safe because the Intent handled by the ChooserActivity is + // guaranteed to contain no extras unknown to the local ClassLoader. That is why this + // method can not be replaced in the ResolverActivity whole hog. + if (!super.shouldAutoLaunchSingleChoice(target)) { + return false; + } + + return getIntent().getBooleanExtra(Intent.EXTRA_AUTO_LAUNCH_SINGLE_CHOICE, true); + } + + private void showTargetDetails(TargetInfo targetInfo) { + if (targetInfo == null) return; + + List<DisplayResolveInfo> targetList = targetInfo.getAllDisplayTargets(); + if (targetList.isEmpty()) { + Log.e(TAG, "No displayable data to show target details"); + return; + } + + // 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(); + + ChooserTargetActionsDialogFragment.show( + getSupportFragmentManager(), + targetList, + // Adding userHandle from ResolveInfo allows the app icon in Dialog Box to be + // resolved correctly within the same tab. + targetInfo.getResolveInfo().userHandle, + shortcutIdKey, + shortcutTitle, + isShortcutPinned, + intentFilter); + } + + @Override + protected boolean onTargetSelected(TargetInfo target, boolean alwaysCheck) { + if (mRefinementManager.maybeHandleSelection( + target, + mChooserRequest.getRefinementIntentSender(), + getApplication(), + getMainThreadHandler())) { + return false; + } + updateModelAndChooserCounts(target); + maybeRemoveSharedText(target); + return super.onTargetSelected(target, alwaysCheck); + } + + @Override + public void startSelected(int which, boolean always, boolean filtered) { + ChooserListAdapter currentListAdapter = + mChooserMultiProfilePagerAdapter.getActiveListAdapter(); + TargetInfo targetInfo = currentListAdapter + .targetInfoForPosition(which, filtered); + if (targetInfo != null && targetInfo.isNotSelectableTargetInfo()) { + return; + } + + final long selectionCost = System.currentTimeMillis() - mChooserShownTime; + + if ((targetInfo != null) && targetInfo.isMultiDisplayResolveInfo()) { + MultiDisplayResolveInfo mti = (MultiDisplayResolveInfo) targetInfo; + if (!mti.hasSelected()) { + // Add userHandle based badge to the stackedAppDialogBox. + ChooserStackedAppDialogFragment.show( + getSupportFragmentManager(), + mti, + which, + targetInfo.getResolveInfo().userHandle); + return; + } + } + + super.startSelected(which, always, filtered); + + // TODO: both of the conditions around this switch logic *should* be redundant, and + // can be removed if certain invariants can be guaranteed. In particular, it seems + // like targetInfo (from `ChooserListAdapter.targetInfoForPosition()`) is *probably* + // expected to be null only at out-of-bounds indexes where `getPositionTargetType()` + // returns TARGET_BAD; then the switch falls through to a default no-op, and we don't + // need to null-check targetInfo. We only need the null check if it's possible that + // the ChooserListAdapter contains null elements "in the middle" of its list data, + // such that they're classified as belonging to one of the real target types. That + // should probably never happen. But why would this method ever be invoked with a + // null target at all? Even an out-of-bounds index should never be "selected"... + if ((currentListAdapter.getCount() > 0) && (targetInfo != null)) { + switch (currentListAdapter.getPositionTargetType(which)) { + case ChooserListAdapter.TARGET_SERVICE: + getEventLog().logShareTargetSelected( + EventLog.SELECTION_TYPE_SERVICE, + targetInfo.getResolveInfo().activityInfo.processName, + which, + /* directTargetAlsoRanked= */ getRankedPosition(targetInfo), + mChooserRequest.getCallerChooserTargets().size(), + targetInfo.getHashedTargetIdForMetrics(this), + targetInfo.isPinned(), + mIsSuccessfullySelected, + selectionCost + ); + return; + case ChooserListAdapter.TARGET_CALLER: + case ChooserListAdapter.TARGET_STANDARD: + getEventLog().logShareTargetSelected( + EventLog.SELECTION_TYPE_APP, + targetInfo.getResolveInfo().activityInfo.processName, + (which - currentListAdapter.getSurfacedTargetInfo().size()), + /* directTargetAlsoRanked= */ -1, + currentListAdapter.getCallerTargetCount(), + /* directTargetHashed= */ null, + targetInfo.isPinned(), + mIsSuccessfullySelected, + selectionCost + ); + return; + case ChooserListAdapter.TARGET_STANDARD_AZ: + // A-Z targets are unranked standard targets; we use a value of -1 to mark that + // they are from the alphabetical pool. + // TODO: why do we log a different selection type if the -1 value already + // designates the same condition? + getEventLog().logShareTargetSelected( + EventLog.SELECTION_TYPE_STANDARD, + targetInfo.getResolveInfo().activityInfo.processName, + /* value= */ -1, + /* directTargetAlsoRanked= */ -1, + /* numCallerProvided= */ 0, + /* directTargetHashed= */ null, + /* isPinned= */ false, + mIsSuccessfullySelected, + selectionCost + ); + return; + } + } + } + + private int getRankedPosition(TargetInfo targetInfo) { + String targetPackageName = + targetInfo.getChooserTargetComponentName().getPackageName(); + ChooserListAdapter currentListAdapter = + mChooserMultiProfilePagerAdapter.getActiveListAdapter(); + int maxRankedResults = Math.min( + currentListAdapter.getDisplayResolveInfoCount(), MAX_LOG_RANK_POSITION); + + for (int i = 0; i < maxRankedResults; i++) { + if (currentListAdapter.getDisplayResolveInfo(i) + .getResolveInfo().activityInfo.packageName.equals(targetPackageName)) { + return i; + } + } + return -1; + } + + @Override + protected boolean shouldAddFooterView() { + // To accommodate for window insets + return true; + } + + @Override + protected void applyFooterView(int height) { + int count = mChooserMultiProfilePagerAdapter.getItemCount(); + + for (int i = 0; i < count; i++) { + mChooserMultiProfilePagerAdapter.getAdapterForIndex(i).setFooterHeight(height); + } + } + + private void logDirectShareTargetReceived(UserHandle forUser) { + ProfileRecord profileRecord = getProfileRecord(forUser); + if (profileRecord == null) { + return; + } + getEventLog().logDirectShareTargetReceived( + MetricsEvent.ACTION_DIRECT_SHARE_TARGETS_LOADED_SHORTCUT_MANAGER, + (int) (SystemClock.elapsedRealtime() - profileRecord.loadingStartTime)); + } + + void updateModelAndChooserCounts(TargetInfo info) { + if (info != null && info.isMultiDisplayResolveInfo()) { + info = ((MultiDisplayResolveInfo) info).getSelectedTarget(); + } + if (info != null) { + sendClickToAppPredictor(info); + final ResolveInfo ri = info.getResolveInfo(); + Intent targetIntent = getTargetIntent(); + if (ri != null && ri.activityInfo != null && targetIntent != null) { + ChooserListAdapter currentListAdapter = + mChooserMultiProfilePagerAdapter.getActiveListAdapter(); + if (currentListAdapter != null) { + sendImpressionToAppPredictor(info, currentListAdapter); + currentListAdapter.updateModel(info); + currentListAdapter.updateChooserCounts( + ri.activityInfo.packageName, + targetIntent.getAction(), + ri.userHandle); + } + if (DEBUG) { + Log.d(TAG, "ResolveInfo Package is " + ri.activityInfo.packageName); + Log.d(TAG, "Action to be updated is " + targetIntent.getAction()); + } + } else if (DEBUG) { + Log.d(TAG, "Can not log Chooser Counts of null ResolveInfo"); + } + } + mIsSuccessfullySelected = true; + } + + private void maybeRemoveSharedText(@NonNull TargetInfo targetInfo) { + Intent targetIntent = targetInfo.getTargetIntent(); + if (targetIntent == null) { + return; + } + Intent originalTargetIntent = new Intent(mChooserRequest.getTargetIntent()); + // Our TargetInfo implementations add associated component to the intent, let's do the same + // for the sake of the comparison below. + if (targetIntent.getComponent() != null) { + originalTargetIntent.setComponent(targetIntent.getComponent()); + } + // Use filterEquals as a way to check that the primary intent is in use (and not an + // alternative one). For example, an app is sharing an image and a link with mime type + // "image/png" and provides an alternative intent to share only the link with mime type + // "text/uri". Should there be a target that accepts only the latter, the alternative intent + // will be used and we don't want to exclude the link from it. + if (mExcludeSharedText && originalTargetIntent.filterEquals(targetIntent)) { + targetIntent.removeExtra(Intent.EXTRA_TEXT); + } + } + + private void sendImpressionToAppPredictor(TargetInfo targetInfo, ChooserListAdapter adapter) { + // Send DS target impression info to AppPredictor, only when user chooses app share. + if (targetInfo.isChooserTargetInfo()) { + return; + } + + AppPredictor directShareAppPredictor = getAppPredictor( + mChooserMultiProfilePagerAdapter.getCurrentUserHandle()); + if (directShareAppPredictor == null) { + return; + } + List<TargetInfo> surfacedTargetInfo = adapter.getSurfacedTargetInfo(); + List<AppTargetId> targetIds = new ArrayList<>(); + for (TargetInfo chooserTargetInfo : surfacedTargetInfo) { + ShortcutInfo shortcutInfo = chooserTargetInfo.getDirectShareShortcutInfo(); + if (shortcutInfo != null) { + ComponentName componentName = + chooserTargetInfo.getChooserTargetComponentName(); + targetIds.add(new AppTargetId( + String.format( + "%s/%s/%s", + shortcutInfo.getId(), + componentName.flattenToString(), + SHORTCUT_TARGET))); + } + } + directShareAppPredictor.notifyLaunchLocationShown(LAUNCH_LOCATION_DIRECT_SHARE, targetIds); + } + + private void sendClickToAppPredictor(TargetInfo targetInfo) { + if (!targetInfo.isChooserTargetInfo()) { + return; + } + + AppPredictor directShareAppPredictor = getAppPredictor( + mChooserMultiProfilePagerAdapter.getCurrentUserHandle()); + if (directShareAppPredictor == null) { + return; + } + 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) + .build()); + } + } + + @Nullable + private AppPredictor getAppPredictor(UserHandle userHandle) { + ProfileRecord record = getProfileRecord(userHandle); + // We cannot use APS service when clone profile is present as APS service cannot sort + // cross profile targets as of now. + return ((record == null) || (getAnnotatedUserHandles().cloneProfileUserHandle != null)) + ? null : record.appPredictor; + } + + /** + * Sort intents alphabetically based on display label. + */ + static class AzInfoComparator implements Comparator<DisplayResolveInfo> { + Comparator<DisplayResolveInfo> mComparator; + AzInfoComparator(Context context) { + Collator collator = Collator + .getInstance(context.getResources().getConfiguration().locale); + // Adding two stage comparator, first stage compares using displayLabel, next stage + // compares using resolveInfo.userHandle + mComparator = Comparator.comparing(DisplayResolveInfo::getDisplayLabel, collator) + .thenComparingInt(target -> target.getResolveInfo().userHandle.getIdentifier()); + } + + @Override + public int compare( + DisplayResolveInfo lhsp, DisplayResolveInfo rhsp) { + return mComparator.compare(lhsp, rhsp); + } + } + + protected EventLog getEventLog() { + return mEventLog; + } + + public class ChooserListController extends ResolverListController { + public ChooserListController( + Context context, + PackageManager pm, + Intent targetIntent, + String referrerPackageName, + int launchedFromUid, + AbstractResolverComparator resolverComparator, + UserHandle queryIntentsAsUser) { + super( + context, + pm, + targetIntent, + referrerPackageName, + launchedFromUid, + resolverComparator, + queryIntentsAsUser); + } + + @Override + public boolean isComponentFiltered(ComponentName name) { + return mChooserRequest.getFilteredComponentNames().contains(name); + } + + @Override + public boolean isComponentPinned(ComponentName name) { + return mPinnedSharedPrefs.getBoolean(name.flattenToString(), false); + } + } + + @VisibleForTesting + public ChooserGridAdapter createChooserGridAdapter( + Context context, + List<Intent> payloadIntents, + Intent[] initialIntents, + List<ResolveInfo> rList, + boolean filterLastUsed, + UserHandle userHandle, + TargetDataLoader targetDataLoader) { + ChooserListAdapter chooserListAdapter = createChooserListAdapter( + context, + payloadIntents, + initialIntents, + rList, + filterLastUsed, + createListController(userHandle), + userHandle, + getTargetIntent(), + mChooserRequest, + mMaxTargetsPerRow, + targetDataLoader); + + return new ChooserGridAdapter( + context, + new ChooserGridAdapter.ChooserActivityDelegate() { + @Override + public boolean shouldShowTabs() { + return ChooserActivity.this.shouldShowTabs(); + } + + @Override + public View buildContentPreview(ViewGroup parent) { + return createContentPreviewView(parent); + } + + @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); + // Only a direct share target or an app target is expected + if (longPressedTargetInfo.isDisplayResolveInfo() + || longPressedTargetInfo.isSelectableTargetInfo()) { + showTargetDetails(longPressedTargetInfo); + } + } + + @Override + public void updateProfileViewButton(View newButtonFromProfileRow) { + mProfileView = newButtonFromProfileRow; + mProfileView.setOnClickListener(ChooserActivity.this::onProfileClick); + ChooserActivity.this.updateProfileViewButton(); + } + }, + chooserListAdapter, + shouldShowContentPreview(), + mMaxTargetsPerRow, + mFeatureFlags); + } + + @VisibleForTesting + 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, + TargetDataLoader targetDataLoader) { + UserHandle initialIntentsUserSpace = isLaunchedAsCloneProfile() + && userHandle.equals(getAnnotatedUserHandles().personalProfileUserHandle) + ? getAnnotatedUserHandles().cloneProfileUserHandle : userHandle; + return new ChooserListAdapter( + context, + payloadIntents, + initialIntents, + rList, + filterLastUsed, + createListController(userHandle), + userHandle, + targetIntent, + this, + context.getPackageManager(), + getEventLog(), + chooserRequest, + maxTargetsPerRow, + initialIntentsUserSpace, + targetDataLoader); + } + + @Override + protected void onWorkProfileStatusUpdated() { + UserHandle workUser = getAnnotatedUserHandles().workProfileUserHandle; + ProfileRecord record = workUser == null ? null : getProfileRecord(workUser); + if (record != null && record.shortcutLoader != null) { + record.shortcutLoader.reset(); + } + super.onWorkProfileStatusUpdated(); + } + + @Override + @VisibleForTesting + protected ChooserListController createListController(UserHandle userHandle) { + AppPredictor appPredictor = getAppPredictor(userHandle); + AbstractResolverComparator resolverComparator; + if (appPredictor != null) { + resolverComparator = new AppPredictionServiceResolverComparator(this, getTargetIntent(), + getReferrerPackageName(), appPredictor, userHandle, getEventLog(), + getIntegratedDeviceComponents().getNearbySharingComponent()); + } else { + resolverComparator = + new ResolverRankerServiceResolverComparator( + this, + getTargetIntent(), + getReferrerPackageName(), + null, + getEventLog(), + getResolverRankerServiceUserHandleList(userHandle), + getIntegratedDeviceComponents().getNearbySharingComponent()); + } + + return new ChooserListController( + this, + mPm, + getTargetIntent(), + getReferrerPackageName(), + getAnnotatedUserHandles().userIdOfCallingApp, + resolverComparator, + getQueryIntentsUser(userHandle)); + } + + @VisibleForTesting + protected ViewModelProvider.Factory createPreviewViewModelFactory() { + return PreviewViewModel.Companion.getFactory(); + } + + private ChooserActionFactory createChooserActionFactory() { + return new ChooserActionFactory( + this, + mChooserRequest, + mIntegratedDeviceComponents, + getEventLog(), + (isExcluded) -> mExcludeSharedText = isExcluded, + this::getFirstVisibleImgPreviewView, + new ChooserActionFactory.ActionActivityStarter() { + @Override + public void safelyStartActivityAsPersonalProfileUser(TargetInfo targetInfo) { + safelyStartActivityAsUser( + targetInfo, getAnnotatedUserHandles().personalProfileUserHandle); + finish(); + } + + @Override + public void safelyStartActivityAsPersonalProfileUserWithSharedElementTransition( + TargetInfo targetInfo, View sharedElement, String sharedElementName) { + ActivityOptions options = ActivityOptions.makeSceneTransitionAnimation( + ChooserActivity.this, sharedElement, sharedElementName); + safelyStartActivityAsUser( + targetInfo, + getAnnotatedUserHandles().personalProfileUserHandle, + options.toBundle()); + // Can't finish right away because the shared element transition may not + // be ready to start. + mFinishWhenStopped = true; + } + }, + (status) -> { + if (status != null) { + setResult(status); + } + finish(); + }); + } + + /* + * Need to dynamically adjust how many icons can fit per row before we add them, + * which also means setting the correct offset to initially show the content + * preview area + 2 rows of targets + */ + private void handleLayoutChange(View v, int left, int top, int right, int bottom, int oldLeft, + int oldTop, int oldRight, int oldBottom) { + if (mChooserMultiProfilePagerAdapter == null) { + return; + } + RecyclerView recyclerView = mChooserMultiProfilePagerAdapter.getActiveAdapterView(); + ChooserGridAdapter gridAdapter = mChooserMultiProfilePagerAdapter.getCurrentRootAdapter(); + // Skip height calculation if recycler view was scrolled to prevent it inaccurately + // calculating the height, as the logic below does not account for the scrolled offset. + if (gridAdapter == null || recyclerView == null + || recyclerView.computeVerticalScrollOffset() != 0) { + return; + } + + final int availableWidth = right - left - v.getPaddingLeft() - v.getPaddingRight(); + boolean isLayoutUpdated = + gridAdapter.calculateChooserTargetWidth(availableWidth) + || recyclerView.getAdapter() == null + || availableWidth != mCurrAvailableWidth; + + boolean insetsChanged = !Objects.equals(mLastAppliedInsets, mSystemWindowInsets); + + if (isLayoutUpdated + || insetsChanged + || mLastNumberOfChildren != recyclerView.getChildCount()) { + mCurrAvailableWidth = availableWidth; + if (isLayoutUpdated) { + // It is very important we call setAdapter from here. Otherwise in some cases + // the resolver list doesn't get populated, such as b/150922090, b/150918223 + // and b/150936654 + recyclerView.setAdapter(gridAdapter); + ((GridLayoutManager) recyclerView.getLayoutManager()).setSpanCount( + mMaxTargetsPerRow); + + updateTabPadding(); + } + + UserHandle currentUserHandle = mChooserMultiProfilePagerAdapter.getCurrentUserHandle(); + int currentProfile = getProfileForUser(currentUserHandle); + int initialProfile = findSelectedProfile(); + if (currentProfile != initialProfile) { + return; + } + + if (mLastNumberOfChildren == recyclerView.getChildCount() && !insetsChanged) { + return; + } + + getMainThreadHandler().post(() -> { + if (mResolverDrawerLayout == null || gridAdapter == null) { + return; + } + int offset = calculateDrawerOffset(top, bottom, recyclerView, gridAdapter); + mResolverDrawerLayout.setCollapsibleHeightReserved(offset); + mEnterTransitionAnimationDelegate.markOffsetCalculated(); + mLastAppliedInsets = mSystemWindowInsets; + }); + } + } + + private int calculateDrawerOffset( + int top, int bottom, RecyclerView recyclerView, ChooserGridAdapter gridAdapter) { + + int offset = mSystemWindowInsets != null ? mSystemWindowInsets.bottom : 0; + int rowsToShow = gridAdapter.getSystemRowCount() + + gridAdapter.getProfileRowCount() + + gridAdapter.getServiceTargetRowCount() + + gridAdapter.getCallerAndRankedTargetRowCount(); + + // then this is most likely not a SEND_* action, so check + // the app target count + if (rowsToShow == 0) { + rowsToShow = gridAdapter.getRowCount(); + } + + // still zero? then use a default height and leave, which + // can happen when there are no targets to show + if (rowsToShow == 0 && !shouldShowStickyContentPreview()) { + offset += getResources().getDimensionPixelSize( + R.dimen.chooser_max_collapsed_height); + return offset; + } + + View stickyContentPreview = findViewById(com.android.internal.R.id.content_preview_container); + if (shouldShowStickyContentPreview() && isStickyContentPreviewShowing()) { + offset += stickyContentPreview.getHeight(); + } + + if (shouldShowTabs()) { + offset += findViewById(com.android.internal.R.id.tabs).getHeight(); + } + + if (recyclerView.getVisibility() == View.VISIBLE) { + rowsToShow = Math.min(4, rowsToShow); + boolean shouldShowExtraRow = shouldShowExtraRow(rowsToShow); + mLastNumberOfChildren = recyclerView.getChildCount(); + for (int i = 0, childCount = recyclerView.getChildCount(); + i < childCount && rowsToShow > 0; i++) { + View child = recyclerView.getChildAt(i); + if (((GridLayoutManager.LayoutParams) + child.getLayoutParams()).getSpanIndex() != 0) { + continue; + } + int height = child.getHeight(); + offset += height; + if (shouldShowExtraRow) { + offset += height; + } + rowsToShow--; + } + } else { + ViewGroup currentEmptyStateView = getActiveEmptyStateView(); + if (currentEmptyStateView.getVisibility() == View.VISIBLE) { + offset += currentEmptyStateView.getHeight(); + } + } + + return Math.min(offset, bottom - top); + } + + /** + * If we have a tabbed view and are showing 1 row in the current profile and an empty + * state screen in the other profile, to prevent cropping of the empty state screen we show + * a second row in the current profile. + */ + private boolean shouldShowExtraRow(int rowsToShow) { + return shouldShowTabs() + && rowsToShow == 1 + && mChooserMultiProfilePagerAdapter.shouldShowEmptyStateScreen( + mChooserMultiProfilePagerAdapter.getInactiveListAdapter()); + } + + /** + * Returns {@link #PROFILE_WORK}, if the given user handle matches work user handle. + * Returns {@link #PROFILE_PERSONAL}, otherwise. + **/ + private int getProfileForUser(UserHandle currentUserHandle) { + if (currentUserHandle.equals(getAnnotatedUserHandles().workProfileUserHandle)) { + return PROFILE_WORK; + } + // We return personal profile, as it is the default when there is no work profile, personal + // profile represents rootUser, clonedUser & secondaryUser, covering all use cases. + return PROFILE_PERSONAL; + } + + private ViewGroup getActiveEmptyStateView() { + int currentPage = mChooserMultiProfilePagerAdapter.getCurrentPage(); + return mChooserMultiProfilePagerAdapter.getEmptyStateView(currentPage); + } + + @Override // ResolverListCommunicator + public void onHandlePackagesChanged(ResolverListAdapter listAdapter) { + mChooserMultiProfilePagerAdapter.getActiveListAdapter().notifyDataSetChanged(); + super.onHandlePackagesChanged(listAdapter); + } + + @Override + protected void onListRebuilt(ResolverListAdapter listAdapter, boolean rebuildComplete) { + setupScrollListener(); + maybeSetupGlobalLayoutListener(); + + ChooserListAdapter chooserListAdapter = (ChooserListAdapter) listAdapter; + UserHandle listProfileUserHandle = chooserListAdapter.getUserHandle(); + if (listProfileUserHandle.equals(mChooserMultiProfilePagerAdapter.getCurrentUserHandle())) { + mChooserMultiProfilePagerAdapter.getActiveAdapterView() + .setAdapter(mChooserMultiProfilePagerAdapter.getCurrentRootAdapter()); + mChooserMultiProfilePagerAdapter + .setupListAdapter(mChooserMultiProfilePagerAdapter.getCurrentPage()); + } + + //TODO: move this block inside ChooserListAdapter (should be called when + // ResolverListAdapter#mPostListReadyRunnable is executed. + if (chooserListAdapter.getDisplayResolveInfoCount() == 0) { + chooserListAdapter.notifyDataSetChanged(); + } else { + chooserListAdapter.updateAlphabeticalList(); + } + + if (rebuildComplete) { + long duration = Tracer.INSTANCE.endAppTargetLoadingSection(listProfileUserHandle); + if (duration >= 0) { + Log.d(TAG, "app target loading time " + duration + " ms"); + } + addCallerChooserTargets(); + getEventLog().logSharesheetAppLoadComplete(); + maybeQueryAdditionalPostProcessingTargets( + listProfileUserHandle, + chooserListAdapter.getDisplayResolveInfos()); + mLatencyTracker.onActionEnd(ACTION_LOAD_SHARE_SHEET); + } + } + + private void maybeQueryAdditionalPostProcessingTargets( + UserHandle userHandle, + DisplayResolveInfo[] displayResolveInfos) { + ProfileRecord record = getProfileRecord(userHandle); + if (record == null || record.shortcutLoader == null) { + return; + } + record.loadingStartTime = SystemClock.elapsedRealtime(); + record.shortcutLoader.updateAppTargets(displayResolveInfos); + } + + @MainThread + private void onShortcutsLoaded(UserHandle userHandle, ShortcutLoader.Result result) { + if (DEBUG) { + Log.d(TAG, "onShortcutsLoaded for user: " + userHandle); + } + mDirectShareShortcutInfoCache.putAll(result.getDirectShareShortcutInfoCache()); + mDirectShareAppTargetCache.putAll(result.getDirectShareAppTargetCache()); + ChooserListAdapter adapter = + mChooserMultiProfilePagerAdapter.getListAdapterForUserHandle(userHandle); + if (adapter != null) { + for (ShortcutLoader.ShortcutResultInfo resultInfo : result.getShortcutsByApp()) { + adapter.addServiceResults( + resultInfo.getAppTarget(), + resultInfo.getShortcuts(), + result.isFromAppPredictor() + ? TARGET_TYPE_SHORTCUTS_FROM_PREDICTION_SERVICE + : TARGET_TYPE_SHORTCUTS_FROM_SHORTCUT_MANAGER, + mDirectShareShortcutInfoCache, + mDirectShareAppTargetCache); + } + adapter.completeServiceTargetLoading(); + } + + if (mMultiProfilePagerAdapter.getActiveListAdapter() == adapter) { + long duration = Tracer.INSTANCE.endLaunchToShortcutTrace(); + if (duration >= 0) { + Log.d(TAG, "stat to first shortcut time: " + duration + " ms"); + } + } + logDirectShareTargetReceived(userHandle); + sendVoiceChoicesIfNeeded(); + getEventLog().logSharesheetDirectLoadComplete(); + } + + private void setupScrollListener() { + if (mResolverDrawerLayout == null) { + return; + } + int elevatedViewResId = shouldShowTabs() ? com.android.internal.R.id.tabs : com.android.internal.R.id.chooser_header; + final View elevatedView = mResolverDrawerLayout.findViewById(elevatedViewResId); + final float defaultElevation = elevatedView.getElevation(); + final float chooserHeaderScrollElevation = + getResources().getDimensionPixelSize(R.dimen.chooser_header_scroll_elevation); + mChooserMultiProfilePagerAdapter.getActiveAdapterView().addOnScrollListener( + new RecyclerView.OnScrollListener() { + @Override + public void onScrollStateChanged(RecyclerView view, int scrollState) { + if (scrollState == RecyclerView.SCROLL_STATE_IDLE) { + if (mScrollStatus == SCROLL_STATUS_SCROLLING_VERTICAL) { + mScrollStatus = SCROLL_STATUS_IDLE; + setHorizontalScrollingEnabled(true); + } + } else if (scrollState == RecyclerView.SCROLL_STATE_DRAGGING) { + if (mScrollStatus == SCROLL_STATUS_IDLE) { + mScrollStatus = SCROLL_STATUS_SCROLLING_VERTICAL; + setHorizontalScrollingEnabled(false); + } + } + } + + @Override + public void onScrolled(RecyclerView view, int dx, int dy) { + if (view.getChildCount() > 0) { + View child = view.getLayoutManager().findViewByPosition(0); + if (child == null || child.getTop() < 0) { + elevatedView.setElevation(chooserHeaderScrollElevation); + return; + } + } + + elevatedView.setElevation(defaultElevation); + } + }); + } + + private void maybeSetupGlobalLayoutListener() { + if (shouldShowTabs()) { + return; + } + final View recyclerView = mChooserMultiProfilePagerAdapter.getActiveAdapterView(); + recyclerView.getViewTreeObserver() + .addOnGlobalLayoutListener(new ViewTreeObserver.OnGlobalLayoutListener() { + @Override + public void onGlobalLayout() { + // Fixes an issue were the accessibility border disappears on list creation. + recyclerView.getViewTreeObserver().removeOnGlobalLayoutListener(this); + final TextView titleView = findViewById(com.android.internal.R.id.title); + if (titleView != null) { + titleView.setFocusable(true); + titleView.setFocusableInTouchMode(true); + titleView.requestFocus(); + titleView.requestAccessibilityFocus(); + } + } + }); + } + + /** + * 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, + * we instead show the content preview as a regular list item. + */ + private boolean shouldShowStickyContentPreview() { + return shouldShowStickyContentPreviewNoOrientationCheck(); + } + + private boolean shouldShowStickyContentPreviewNoOrientationCheck() { + if (!shouldShowContentPreview()) { + return false; + } + boolean isEmpty = mMultiProfilePagerAdapter.getListAdapterForUserHandle( + UserHandle.of(UserHandle.myUserId())).getCount() == 0; + return (mFeatureFlags.scrollablePreview() || shouldShowTabs()) + && (!isEmpty || shouldShowContentPreviewWhenEmpty()); + } + + /** + * This method could be used to override the default behavior when we hide the preview area + * when the current tab doesn't have any items. + * + * @return true if we want to show the content preview area even if the tab for the current + * user is empty + */ + protected boolean shouldShowContentPreviewWhenEmpty() { + return false; + } + + /** + * @return true if we want to show the content preview area + */ + protected boolean shouldShowContentPreview() { + return (mChooserRequest != null) && mChooserRequest.isSendActionTarget(); + } + + private void updateStickyContentPreview() { + if (shouldShowStickyContentPreviewNoOrientationCheck()) { + // The sticky content preview is only shown when we show the work and personal tabs. + // We don't show it in landscape as otherwise there is no room for scrolling. + // If the sticky content preview will be shown at some point with orientation change, + // then always preload it to avoid subsequent resizing of the share sheet. + ViewGroup contentPreviewContainer = + findViewById(com.android.internal.R.id.content_preview_container); + if (contentPreviewContainer.getChildCount() == 0) { + ViewGroup contentPreviewView = createContentPreviewView(contentPreviewContainer); + contentPreviewContainer.addView(contentPreviewView); + } + } + if (shouldShowStickyContentPreview()) { + showStickyContentPreview(); + } else { + hideStickyContentPreview(); + } + } + + private void showStickyContentPreview() { + if (isStickyContentPreviewShowing()) { + return; + } + ViewGroup contentPreviewContainer = findViewById(com.android.internal.R.id.content_preview_container); + contentPreviewContainer.setVisibility(View.VISIBLE); + } + + private boolean isStickyContentPreviewShowing() { + ViewGroup contentPreviewContainer = findViewById(com.android.internal.R.id.content_preview_container); + return contentPreviewContainer.getVisibility() == View.VISIBLE; + } + + private void hideStickyContentPreview() { + if (!isStickyContentPreviewShowing()) { + return; + } + ViewGroup contentPreviewContainer = findViewById(com.android.internal.R.id.content_preview_container); + contentPreviewContainer.setVisibility(View.GONE); + } + + private View findRootView() { + if (mContentView == null) { + mContentView = findViewById(android.R.id.content); + } + return mContentView; + } + + /** + * Intentionally override the {@link ResolverActivity} implementation as we only need that + * implementation for the intent resolver case. + */ + @Override + public void onButtonClick(View v) {} + + /** + * Intentionally override the {@link ResolverActivity} implementation as we only need that + * implementation for the intent resolver case. + */ + @Override + protected void resetButtonBar() {} + + @Override + protected String getMetricsCategory() { + return METRICS_CATEGORY_CHOOSER; + } + + @Override + protected void onProfileTabSelected() { + // This fixes an edge case where after performing a variety of gestures, vertical scrolling + // ends up disabled. That's because at some point the old tab's vertical scrolling is + // disabled and the new tab's is enabled. For context, see b/159997845 + setVerticalScrollEnabled(true); + if (mResolverDrawerLayout != null) { + mResolverDrawerLayout.scrollNestedScrollableChildBackToTop(); + } + } + + @Override + protected WindowInsets onApplyWindowInsets(View v, WindowInsets insets) { + if (shouldShowTabs()) { + mChooserMultiProfilePagerAdapter + .setEmptyStateBottomOffset(insets.getSystemWindowInsetBottom()); + mChooserMultiProfilePagerAdapter.setupContainerPadding( + getActiveEmptyStateView().findViewById(com.android.internal.R.id.resolver_empty_state_container)); + } + + WindowInsets result = super.onApplyWindowInsets(v, insets); + if (mResolverDrawerLayout != null) { + mResolverDrawerLayout.requestLayout(); + } + return result; + } + + private void setHorizontalScrollingEnabled(boolean enabled) { + ResolverViewPager viewPager = findViewById(com.android.internal.R.id.profile_pager); + viewPager.setSwipingEnabled(enabled); + } + + private void setVerticalScrollEnabled(boolean enabled) { + ChooserGridLayoutManager layoutManager = + (ChooserGridLayoutManager) mChooserMultiProfilePagerAdapter.getActiveAdapterView() + .getLayoutManager(); + layoutManager.setVerticalScrollEnabled(enabled); + } + + @Override + void onHorizontalSwipeStateChanged(int state) { + if (state == ViewPager.SCROLL_STATE_DRAGGING) { + if (mScrollStatus == SCROLL_STATUS_IDLE) { + mScrollStatus = SCROLL_STATUS_SCROLLING_HORIZONTAL; + setVerticalScrollEnabled(false); + } + } else if (state == ViewPager.SCROLL_STATE_IDLE) { + if (mScrollStatus == SCROLL_STATUS_SCROLLING_HORIZONTAL) { + mScrollStatus = SCROLL_STATUS_IDLE; + setVerticalScrollEnabled(true); + } + } + } + + @Override + protected void maybeLogProfileChange() { + getEventLog().logSharesheetProfileChanged(); + } + + 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; + } + + public void destroy() { + if (appPredictor != null) { + appPredictor.destroy(); + } + } + } +} diff --git a/java/src/com/android/intentresolver/v2/ChooserSelector.kt b/java/src/com/android/intentresolver/v2/ChooserSelector.kt new file mode 100644 index 00000000..378bc06c --- /dev/null +++ b/java/src/com/android/intentresolver/v2/ChooserSelector.kt @@ -0,0 +1,36 @@ +package com.android.intentresolver.v2 + +import android.content.BroadcastReceiver +import android.content.ComponentName +import android.content.Context +import android.content.Intent +import android.content.pm.PackageManager +import com.android.intentresolver.FeatureFlags +import dagger.hilt.android.AndroidEntryPoint +import javax.inject.Inject + +@AndroidEntryPoint(BroadcastReceiver::class) +class ChooserSelector : Hilt_ChooserSelector() { + + @Inject lateinit var featureFlags: FeatureFlags + + override fun onReceive(context: Context, intent: Intent) { + super.onReceive(context, intent) + if (intent.action == Intent.ACTION_BOOT_COMPLETED) { + context.packageManager.setComponentEnabledSetting( + ComponentName(CHOOSER_PACKAGE, CHOOSER_PACKAGE + CHOOSER_CLASS), + if (featureFlags.modularFramework()) { + PackageManager.COMPONENT_ENABLED_STATE_ENABLED + } else { + PackageManager.COMPONENT_ENABLED_STATE_DEFAULT + }, + /* flags = */ 0, + ) + } + } + + companion object { + private const val CHOOSER_PACKAGE = "com.android.intentresolver" + private const val CHOOSER_CLASS = ".v2.ChooserActivity" + } +} diff --git a/java/src/com/android/intentresolver/v2/ResolverActivity.java b/java/src/com/android/intentresolver/v2/ResolverActivity.java new file mode 100644 index 00000000..dd6842aa --- /dev/null +++ b/java/src/com/android/intentresolver/v2/ResolverActivity.java @@ -0,0 +1,2426 @@ +/* + * 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.v2; + +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; +import static android.app.admin.DevicePolicyResources.Strings.Core.RESOLVER_WORK_TAB; +import static android.app.admin.DevicePolicyResources.Strings.Core.RESOLVER_WORK_TAB_ACCESSIBILITY; +import static android.content.Intent.FLAG_ACTIVITY_LAUNCH_ADJACENT; +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 static com.android.internal.annotations.VisibleForTesting.Visibility.PROTECTED; + +import android.annotation.Nullable; +import android.annotation.StringRes; +import android.annotation.UiThread; +import android.app.Activity; +import android.app.ActivityManager; +import android.app.ActivityThread; +import android.app.VoiceInteractor.PickOptionRequest; +import android.app.VoiceInteractor.PickOptionRequest.Option; +import android.app.VoiceInteractor.Prompt; +import android.app.admin.DevicePolicyEventLogger; +import android.app.admin.DevicePolicyManager; +import android.content.ComponentName; +import android.content.Context; +import android.content.Intent; +import android.content.IntentFilter; +import android.content.PermissionChecker; +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.UserInfo; +import android.content.res.Configuration; +import android.content.res.TypedArray; +import android.graphics.Insets; +import android.net.Uri; +import android.os.Build; +import android.os.Bundle; +import android.os.PatternMatcher; +import android.os.RemoteException; +import android.os.StrictMode; +import android.os.Trace; +import android.os.UserHandle; +import android.os.UserManager; +import android.provider.MediaStore; +import android.provider.Settings; +import android.stats.devicepolicy.DevicePolicyEnums; +import android.text.TextUtils; +import android.util.Log; +import android.util.Slog; +import android.view.Gravity; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.view.ViewGroup.LayoutParams; +import android.view.Window; +import android.view.WindowInsets; +import android.view.WindowManager; +import android.widget.AbsListView; +import android.widget.AdapterView; +import android.widget.Button; +import android.widget.FrameLayout; +import android.widget.ImageView; +import android.widget.ListView; +import android.widget.Space; +import android.widget.TabHost; +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.AnnotatedUserHandles; +import com.android.intentresolver.MultiProfilePagerAdapter; +import com.android.intentresolver.MultiProfilePagerAdapter.MyUserIdProvider; +import com.android.intentresolver.MultiProfilePagerAdapter.OnSwitchOnWorkSelectedListener; +import com.android.intentresolver.MultiProfilePagerAdapter.Profile; +import com.android.intentresolver.R; +import com.android.intentresolver.ResolverListAdapter; +import com.android.intentresolver.ResolverListController; +import com.android.intentresolver.ResolverMultiProfilePagerAdapter; +import com.android.intentresolver.WorkProfileAvailabilityManager; +import com.android.intentresolver.chooser.DisplayResolveInfo; +import com.android.intentresolver.chooser.TargetInfo; +import com.android.intentresolver.emptystate.CompositeEmptyStateProvider; +import com.android.intentresolver.emptystate.CrossProfileIntentsChecker; +import com.android.intentresolver.emptystate.EmptyState; +import com.android.intentresolver.emptystate.EmptyStateProvider; +import com.android.intentresolver.emptystate.NoAppsAvailableEmptyStateProvider; +import com.android.intentresolver.emptystate.NoCrossProfileEmptyStateProvider; +import com.android.intentresolver.emptystate.NoCrossProfileEmptyStateProvider.DevicePolicyBlockerEmptyState; +import com.android.intentresolver.emptystate.WorkProfilePausedEmptyStateProvider; +import com.android.intentresolver.icons.DefaultTargetDataLoader; +import com.android.intentresolver.icons.TargetDataLoader; +import com.android.intentresolver.model.ResolverRankerServiceResolverComparator; +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 java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.Iterator; +import java.util.List; +import java.util.Objects; +import java.util.Set; +import java.util.function.Supplier; + +/** + * This is a copy of ResolverActivity to support IntentResolver's ChooserActivity. This code is + * *not* the resolver that is actually triggered by the system right now (you want + * frameworks/base/core/java/com/android/internal/app/ResolverActivity.java for that), the full + * migration is not complete. + */ +@UiThread +public class ResolverActivity extends FragmentActivity implements + ResolverListAdapter.ResolverListCommunicator { + + public ResolverActivity() { + mIsIntentPicker = getClass().equals(ResolverActivity.class); + } + + protected ResolverActivity(boolean isIntentPicker) { + mIsIntentPicker = isIntentPicker; + } + + /** + * Whether to enable a launch mode that is safe to use when forwarding intents received from + * applications and running in system processes. This mode uses Activity.startActivityAsCaller + * instead of the normal Activity.startActivity for launching the activity selected + * by the user. + */ + private boolean mSafeForwardingMode; + + private Button mAlwaysButton; + private Button mOnceButton; + protected View mProfileView; + private int mLastSelected = AbsListView.INVALID_POSITION; + private boolean mResolvingHome = false; + private String mProfileSwitchMessage; + private int mLayoutId; + @VisibleForTesting + protected final ArrayList<Intent> mIntents = new ArrayList<>(); + private PickTargetOptionRequest mPickOptionRequest; + private String mReferrerPackage; + private CharSequence mTitle; + private int mDefaultTitleResId; + // Expected to be true if this object is ResolverActivity or is ResolverWrapperActivity. + private final boolean mIsIntentPicker; + + // Whether or not this activity supports choosing a default handler for the intent. + @VisibleForTesting + protected boolean mSupportsAlwaysUseOption; + protected ResolverDrawerLayout mResolverDrawerLayout; + protected PackageManager mPm; + + private static final String TAG = "ResolverActivity"; + private static final boolean DEBUG = false; + private static final String LAST_SHOWN_TAB_KEY = "last_shown_tab_key"; + + private boolean mRegistered; + + protected Insets mSystemWindowInsets = null; + private Space mFooterSpacer = null; + + /** See {@link #setRetainInOnStop}. */ + private boolean mRetainInOnStop; + + 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; + + private static final String TAB_TAG_PERSONAL = "personal"; + private static final String TAB_TAG_WORK = "work"; + + private PackageMonitor mPersonalPackageMonitor; + private PackageMonitor mWorkPackageMonitor; + + private TargetDataLoader mTargetDataLoader; + + @VisibleForTesting + protected MultiProfilePagerAdapter mMultiProfilePagerAdapter; + + protected WorkProfileAvailabilityManager mWorkProfileAvailability; + + // Intent extra for connected audio devices + public static final String EXTRA_IS_AUDIO_CAPTURE_DEVICE = "is_audio_capture_device"; + + /** + * Integer extra to indicate which profile should be automatically selected. + * <p>Can only be used if there is a work profile. + * <p>Possible values can be either {@link #PROFILE_PERSONAL} or {@link #PROFILE_WORK}. + */ + protected static final String EXTRA_SELECTED_PROFILE = + "com.android.internal.app.ResolverActivity.EXTRA_SELECTED_PROFILE"; + + /** + * {@link UserHandle} extra to indicate the user of the user that the starting intent + * originated from. + * <p>This is not necessarily the same as {@link #getUserId()} or {@link UserHandle#myUserId()}, + * as there are edge cases when the intent resolver is launched in the other profile. + * For example, when we have 0 resolved apps in current profile and multiple resolved + * apps in the other profile, opening a link from the current profile launches the intent + * resolver in the other one. b/148536209 for more info. + */ + static final String EXTRA_CALLING_USER = + "com.android.internal.app.ResolverActivity.EXTRA_CALLING_USER"; + + protected static final int PROFILE_PERSONAL = MultiProfilePagerAdapter.PROFILE_PERSONAL; + protected static final int PROFILE_WORK = MultiProfilePagerAdapter.PROFILE_WORK; + + private UserHandle mHeaderCreatorUser; + + // User handle annotations are lazy-initialized to ensure that they're computed exactly once + // (even though they can't be computed prior to activity creation). + // TODO: use a less ad-hoc pattern for lazy initialization (by switching to Dagger or + // introducing a common `LazySingletonSupplier` API, etc), and/or migrate all dependents to a + // new component whose lifecycle is limited to the "created" Activity (so that we can just hold + // the annotations as a `final` ivar, which is a better way to show immutability). + private Supplier<AnnotatedUserHandles> mLazyAnnotatedUserHandles = () -> { + final AnnotatedUserHandles result = computeAnnotatedUserHandles(); + mLazyAnnotatedUserHandles = () -> result; + return result; + }; + + // This method is called exactly once during creation to compute the immutable annotations + // accessible through the lazy supplier {@link mLazyAnnotatedUserHandles}. + // TODO: this is only defined so that tests can provide an override that injects fake + // annotations. Dagger could provide a cleaner model for our testing/injection requirements. + @VisibleForTesting(visibility = VisibleForTesting.Visibility.PRIVATE) + protected AnnotatedUserHandles computeAnnotatedUserHandles() { + return AnnotatedUserHandles.forShareActivity(this); + } + + @Nullable + private OnSwitchOnWorkSelectedListener mOnSwitchOnWorkSelectedListener; + + protected final LatencyTracker mLatencyTracker = getLatencyTracker(); + + private enum ActionTitle { + VIEW(Intent.ACTION_VIEW, + R.string.whichViewApplication, + R.string.whichViewApplicationNamed, + R.string.whichViewApplicationLabel), + EDIT(Intent.ACTION_EDIT, + R.string.whichEditApplication, + R.string.whichEditApplicationNamed, + R.string.whichEditApplicationLabel), + SEND(Intent.ACTION_SEND, + R.string.whichSendApplication, + R.string.whichSendApplicationNamed, + R.string.whichSendApplicationLabel), + SENDTO(Intent.ACTION_SENDTO, + R.string.whichSendToApplication, + R.string.whichSendToApplicationNamed, + R.string.whichSendToApplicationLabel), + SEND_MULTIPLE(Intent.ACTION_SEND_MULTIPLE, + R.string.whichSendApplication, + R.string.whichSendApplicationNamed, + R.string.whichSendApplicationLabel), + CAPTURE_IMAGE(MediaStore.ACTION_IMAGE_CAPTURE, + R.string.whichImageCaptureApplication, + R.string.whichImageCaptureApplicationNamed, + R.string.whichImageCaptureApplicationLabel), + DEFAULT(null, + R.string.whichApplication, + R.string.whichApplicationNamed, + R.string.whichApplicationLabel), + HOME(Intent.ACTION_MAIN, + R.string.whichHomeApplication, + R.string.whichHomeApplicationNamed, + R.string.whichHomeApplicationLabel); + + // titles for layout that deals with http(s) intents + public static final int BROWSABLE_TITLE_RES = R.string.whichOpenLinksWith; + public static final int BROWSABLE_HOST_TITLE_RES = R.string.whichOpenHostLinksWith; + public static final int BROWSABLE_HOST_APP_TITLE_RES = R.string.whichOpenHostLinksWithApp; + public static final int BROWSABLE_APP_TITLE_RES = R.string.whichOpenLinksWithApp; + + public final String action; + public final int titleRes; + public final int namedTitleRes; + public final @StringRes int labelRes; + + ActionTitle(String action, int titleRes, int namedTitleRes, @StringRes int labelRes) { + this.action = action; + this.titleRes = titleRes; + this.namedTitleRes = namedTitleRes; + this.labelRes = labelRes; + } + + public static ActionTitle forAction(String action) { + for (ActionTitle title : values()) { + if (title != HOME && action != null && action.equals(title.action)) { + return title; + } + } + return DEFAULT; + } + } + + protected PackageMonitor createPackageMonitor(ResolverListAdapter listAdapter) { + return new PackageMonitor() { + @Override + public void onSomePackagesChanged() { + listAdapter.handlePackagesChanged(); + updateProfileViewButton(); + } + + @Override + public boolean onPackageChanged(String packageName, int uid, String[] components) { + // We care about all package changes, not just the whole package itself which is + // default behavior. + return true; + } + }; + } + + @Override + protected void onCreate(Bundle savedInstanceState) { + // Use a specialized prompt when we're handling the 'Home' app startActivity() + final Intent intent = makeMyIntent(); + final Set<String> categories = intent.getCategories(); + if (Intent.ACTION_MAIN.equals(intent.getAction()) + && categories != null + && categories.size() == 1 + && categories.contains(Intent.CATEGORY_HOME)) { + // Note: this field is not set to true in the compatibility version. + mResolvingHome = true; + } + + onCreate( + savedInstanceState, + intent, + /* additionalTargets= */ null, + /* title= */ null, + /* defaultTitleRes= */ 0, + /* initialIntents= */ null, + /* resolutionList= */ null, + /* supportsAlwaysUseOption= */ true, + createIconLoader(), + /* safeForwardingMode= */ true); + } + + /** + * Compatibility version for other bundled services that use this overload without + * a default title resource + */ + protected void onCreate( + Bundle savedInstanceState, + Intent intent, + CharSequence title, + Intent[] initialIntents, + List<ResolveInfo> resolutionList, + boolean supportsAlwaysUseOption, + boolean safeForwardingMode) { + onCreate( + savedInstanceState, + intent, + null, + title, + 0, + initialIntents, + resolutionList, + supportsAlwaysUseOption, + createIconLoader(), + safeForwardingMode); + } + + protected void onCreate( + Bundle savedInstanceState, + Intent intent, + Intent[] additionalTargets, + CharSequence title, + int defaultTitleRes, + Intent[] initialIntents, + List<ResolveInfo> resolutionList, + boolean supportsAlwaysUseOption, + TargetDataLoader targetDataLoader, + boolean safeForwardingMode) { + setTheme(appliedThemeResId()); + super.onCreate(savedInstanceState); + + // Determine whether we should show that intent is forwarded + // from managed profile to owner or other way around. + setProfileSwitchMessage(intent.getContentUserHint()); + + // Force computation of user handle annotations in order to validate the caller ID. (See the + // associated TODO comment to explain why this is structured as a lazy computation.) + AnnotatedUserHandles unusedReferenceToHandles = mLazyAnnotatedUserHandles.get(); + + mWorkProfileAvailability = createWorkProfileAvailabilityManager(); + + mPm = getPackageManager(); + + mReferrerPackage = getReferrerPackageName(); + + // The initial intent must come before any other targets that are to be added. + mIntents.add(0, new Intent(intent)); + if (additionalTargets != null) { + Collections.addAll(mIntents, additionalTargets); + } + + mTitle = title; + mDefaultTitleResId = defaultTitleRes; + + mSupportsAlwaysUseOption = supportsAlwaysUseOption; + mSafeForwardingMode = safeForwardingMode; + mTargetDataLoader = targetDataLoader; + + // The last argument of createResolverListAdapter is whether to do special handling + // of the last used choice to highlight it in the list. We need to always + // turn this off when running under voice interaction, since it results in + // a more complicated UI that the current voice interaction flow is not able + // to handle. We also turn it off when the work tab is shown to simplify the UX. + // We also turn it off when clonedProfile is present on the device, because we might have + // different "last chosen" activities in the different profiles, and PackageManager doesn't + // provide any more information to help us select between them. + boolean filterLastUsed = mSupportsAlwaysUseOption && !isVoiceInteraction() + && !shouldShowTabs() && !hasCloneProfile(); + mMultiProfilePagerAdapter = createMultiProfilePagerAdapter( + initialIntents, resolutionList, filterLastUsed, targetDataLoader); + if (configureContentView(targetDataLoader)) { + return; + } + + mPersonalPackageMonitor = createPackageMonitor( + mMultiProfilePagerAdapter.getPersonalListAdapter()); + mPersonalPackageMonitor.register( + this, getMainLooper(), getAnnotatedUserHandles().personalProfileUserHandle, false); + if (shouldShowTabs()) { + mWorkPackageMonitor = createPackageMonitor( + mMultiProfilePagerAdapter.getWorkListAdapter()); + mWorkPackageMonitor.register( + this, getMainLooper(), getAnnotatedUserHandles().workProfileUserHandle, false); + } + + mRegistered = true; + + final ResolverDrawerLayout rdl = findViewById(com.android.internal.R.id.contentPanel); + if (rdl != null) { + rdl.setOnDismissedListener(new ResolverDrawerLayout.OnDismissedListener() { + @Override + public void onDismissed() { + finish(); + } + }); + + boolean hasTouchScreen = getPackageManager() + .hasSystemFeature(PackageManager.FEATURE_TOUCHSCREEN); + + if (isVoiceInteraction() || !hasTouchScreen) { + rdl.setCollapsed(false); + } + + rdl.setSystemUiVisibility(View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION + | View.SYSTEM_UI_FLAG_LAYOUT_STABLE); + rdl.setOnApplyWindowInsetsListener(this::onApplyWindowInsets); + + mResolverDrawerLayout = rdl; + } + + mProfileView = findViewById(com.android.internal.R.id.profile_button); + if (mProfileView != null) { + mProfileView.setOnClickListener(this::onProfileClick); + updateProfileViewButton(); + } + + final Set<String> categories = intent.getCategories(); + MetricsLogger.action(this, mMultiProfilePagerAdapter.getActiveListAdapter().hasFilteredItem() + ? MetricsProto.MetricsEvent.ACTION_SHOW_APP_DISAMBIG_APP_FEATURED + : MetricsProto.MetricsEvent.ACTION_SHOW_APP_DISAMBIG_NONE_FEATURED, + intent.getAction() + ":" + intent.getType() + ":" + + (categories != null ? Arrays.toString(categories.toArray()) : "")); + } + + protected MultiProfilePagerAdapter createMultiProfilePagerAdapter( + Intent[] initialIntents, + List<ResolveInfo> resolutionList, + boolean filterLastUsed, + TargetDataLoader targetDataLoader) { + MultiProfilePagerAdapter resolverMultiProfilePagerAdapter = null; + if (shouldShowTabs()) { + resolverMultiProfilePagerAdapter = + createResolverMultiProfilePagerAdapterForTwoProfiles( + initialIntents, resolutionList, filterLastUsed, targetDataLoader); + } else { + resolverMultiProfilePagerAdapter = createResolverMultiProfilePagerAdapterForOneProfile( + initialIntents, resolutionList, filterLastUsed, targetDataLoader); + } + return resolverMultiProfilePagerAdapter; + } + + protected EmptyStateProvider createBlockerEmptyStateProvider() { + final boolean shouldShowNoCrossProfileIntentsEmptyState = getUser().equals(getIntentUser()); + + if (!shouldShowNoCrossProfileIntentsEmptyState) { + // Implementation that doesn't show any blockers + return new EmptyStateProvider() {}; + } + + final 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 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( + getAnnotatedUserHandles().personalProfileUserHandle, + noWorkToPersonalEmptyState, + noPersonalToWorkEmptyState, + createCrossProfileIntentsChecker(), + getAnnotatedUserHandles().tabOwnerUserHandleForLaunch); + } + + protected int appliedThemeResId() { + return R.style.Theme_DeviceDefault_Resolver; + } + + /** + * Numerous layouts are supported, each with optional ViewGroups. + * Make sure the inset gets added to the correct View, using + * a footer for Lists so it can properly scroll under the navbar. + */ + protected boolean shouldAddFooterView() { + if (useLayoutWithDefault()) return true; + + View buttonBar = findViewById(com.android.internal.R.id.button_bar); + if (buttonBar == null || buttonBar.getVisibility() == View.GONE) return true; + + return false; + } + + protected void applyFooterView(int height) { + if (mFooterSpacer == null) { + mFooterSpacer = new Space(getApplicationContext()); + } else { + ((ResolverMultiProfilePagerAdapter) mMultiProfilePagerAdapter) + .getActiveAdapterView().removeFooterView(mFooterSpacer); + } + mFooterSpacer.setLayoutParams(new AbsListView.LayoutParams(LayoutParams.MATCH_PARENT, + mSystemWindowInsets.bottom)); + ((ResolverMultiProfilePagerAdapter) mMultiProfilePagerAdapter) + .getActiveAdapterView().addFooterView(mFooterSpacer); + } + + protected WindowInsets onApplyWindowInsets(View v, WindowInsets insets) { + mSystemWindowInsets = insets.getSystemWindowInsets(); + + mResolverDrawerLayout.setPadding(mSystemWindowInsets.left, mSystemWindowInsets.top, + mSystemWindowInsets.right, 0); + + resetButtonBar(); + + if (shouldUseMiniResolver()) { + View buttonContainer = findViewById(com.android.internal.R.id.button_bar_container); + buttonContainer.setPadding(0, 0, 0, mSystemWindowInsets.bottom + + getResources().getDimensionPixelOffset(R.dimen.resolver_button_bar_spacing)); + } + + // Need extra padding so the list can fully scroll up + if (shouldAddFooterView()) { + applyFooterView(mSystemWindowInsets.bottom); + } + + return insets.consumeSystemWindowInsets(); + } + + @Override + public void onConfigurationChanged(Configuration newConfig) { + super.onConfigurationChanged(newConfig); + mMultiProfilePagerAdapter.getActiveListAdapter().handlePackagesChanged(); + if (mIsIntentPicker && shouldShowTabs() && !useLayoutWithDefault() + && !shouldUseMiniResolver()) { + updateIntentPickerPaddings(); + } + + if (mSystemWindowInsets != null) { + mResolverDrawerLayout.setPadding(mSystemWindowInsets.left, mSystemWindowInsets.top, + mSystemWindowInsets.right, 0); + } + } + + public int getLayoutResource() { + return R.layout.resolver_list; + } + + @Override + protected void onStop() { + super.onStop(); + + final Window window = this.getWindow(); + final WindowManager.LayoutParams attrs = window.getAttributes(); + attrs.privateFlags &= ~SYSTEM_FLAG_HIDE_NON_SYSTEM_OVERLAY_WINDOWS; + window.setAttributes(attrs); + + if (mRegistered) { + mPersonalPackageMonitor.unregister(); + if (mWorkPackageMonitor != null) { + mWorkPackageMonitor.unregister(); + } + mRegistered = false; + } + final Intent intent = getIntent(); + if ((intent.getFlags() & FLAG_ACTIVITY_NEW_TASK) != 0 && !isVoiceInteraction() + && !mResolvingHome && !mRetainInOnStop) { + // This resolver is in the unusual situation where it has been + // launched at the top of a new task. We don't let it be added + // to the recent tasks shown to the user, and we need to make sure + // that each time we are launched we get the correct launching + // uid (not re-using the same resolver from an old launching uid), + // so we will now finish ourself since being no longer visible, + // the user probably can't get back to us. + if (!isChangingConfigurations()) { + finish(); + } + } + // TODO: should we clean up the work-profile manager before we potentially finish() above? + mWorkProfileAvailability.unregisterWorkProfileStateReceiver(this); + } + + @Override + protected void onDestroy() { + super.onDestroy(); + if (!isChangingConfigurations() && mPickOptionRequest != null) { + mPickOptionRequest.cancel(); + } + if (mMultiProfilePagerAdapter != null + && mMultiProfilePagerAdapter.getActiveListAdapter() != null) { + mMultiProfilePagerAdapter.getActiveListAdapter().onDestroy(); + } + } + + public void onButtonClick(View v) { + final int id = v.getId(); + ListView listView = (ListView) mMultiProfilePagerAdapter.getActiveAdapterView(); + ResolverListAdapter currentListAdapter = mMultiProfilePagerAdapter.getActiveListAdapter(); + int which = currentListAdapter.hasFilteredItem() + ? currentListAdapter.getFilteredPosition() + : listView.getCheckedItemPosition(); + boolean hasIndexBeenFiltered = !currentListAdapter.hasFilteredItem(); + startSelected(which, id == com.android.internal.R.id.button_always, hasIndexBeenFiltered); + } + + public void startSelected(int which, boolean always, boolean hasIndexBeenFiltered) { + if (isFinishing()) { + return; + } + ResolveInfo ri = mMultiProfilePagerAdapter.getActiveListAdapter() + .resolveInfoForPosition(which, hasIndexBeenFiltered); + if (mResolvingHome && hasManagedProfile() && !supportsManagedProfiles(ri)) { + Toast.makeText(this, + getWorkProfileNotSupportedMsg( + ri.activityInfo.loadLabel(getPackageManager()).toString()), + Toast.LENGTH_LONG).show(); + return; + } + + TargetInfo target = mMultiProfilePagerAdapter.getActiveListAdapter() + .targetInfoForPosition(which, hasIndexBeenFiltered); + if (target == null) { + return; + } + if (onTargetSelected(target, always)) { + if (always && mSupportsAlwaysUseOption) { + MetricsLogger.action( + this, MetricsProto.MetricsEvent.ACTION_APP_DISAMBIG_ALWAYS); + } else if (mSupportsAlwaysUseOption) { + MetricsLogger.action( + this, MetricsProto.MetricsEvent.ACTION_APP_DISAMBIG_JUST_ONCE); + } else { + MetricsLogger.action( + this, MetricsProto.MetricsEvent.ACTION_APP_DISAMBIG_TAP); + } + MetricsLogger.action(this, + mMultiProfilePagerAdapter.getActiveListAdapter().hasFilteredItem() + ? MetricsProto.MetricsEvent.ACTION_HIDE_APP_DISAMBIG_APP_FEATURED + : MetricsProto.MetricsEvent.ACTION_HIDE_APP_DISAMBIG_NONE_FEATURED); + finish(); + } + } + + /** + * Replace me in subclasses! + */ + @Override // ResolverListCommunicator + public Intent getReplacementIntent(ActivityInfo aInfo, Intent defIntent) { + return defIntent; + } + + protected void onListRebuilt(ResolverListAdapter listAdapter, boolean rebuildCompleted) { + final ItemClickListener listener = new ItemClickListener(); + setupAdapterListView((ListView) mMultiProfilePagerAdapter.getActiveAdapterView(), listener); + if (shouldShowTabs() && mIsIntentPicker) { + final ResolverDrawerLayout rdl = findViewById(com.android.internal.R.id.contentPanel); + if (rdl != null) { + rdl.setMaxCollapsedHeight(getResources() + .getDimensionPixelSize(useLayoutWithDefault() + ? R.dimen.resolver_max_collapsed_height_with_default_with_tabs + : R.dimen.resolver_max_collapsed_height_with_tabs)); + } + } + } + + protected boolean onTargetSelected(TargetInfo target, boolean always) { + final ResolveInfo ri = target.getResolveInfo(); + final Intent intent = target != null ? target.getResolvedIntent() : null; + + if (intent != null && (mSupportsAlwaysUseOption + || mMultiProfilePagerAdapter.getActiveListAdapter().hasFilteredItem()) + && mMultiProfilePagerAdapter.getActiveListAdapter().getUnfilteredResolveList() != null) { + // Build a reasonable intent filter, based on what matched. + IntentFilter filter = new IntentFilter(); + Intent filterIntent; + + if (intent.getSelector() != null) { + filterIntent = intent.getSelector(); + } else { + filterIntent = intent; + } + + String action = filterIntent.getAction(); + if (action != null) { + filter.addAction(action); + } + Set<String> categories = filterIntent.getCategories(); + if (categories != null) { + for (String cat : categories) { + filter.addCategory(cat); + } + } + filter.addCategory(Intent.CATEGORY_DEFAULT); + + int cat = ri.match & IntentFilter.MATCH_CATEGORY_MASK; + Uri data = filterIntent.getData(); + if (cat == IntentFilter.MATCH_CATEGORY_TYPE) { + String mimeType = filterIntent.resolveType(this); + if (mimeType != null) { + try { + filter.addDataType(mimeType); + } catch (IntentFilter.MalformedMimeTypeException e) { + Log.w("ResolverActivity", e); + filter = null; + } + } + } + if (data != null && data.getScheme() != null) { + // We need the data specification if there was no type, + // OR if the scheme is not one of our magical "file:" + // or "content:" schemes (see IntentFilter for the reason). + if (cat != IntentFilter.MATCH_CATEGORY_TYPE + || (!"file".equals(data.getScheme()) + && !"content".equals(data.getScheme()))) { + filter.addDataScheme(data.getScheme()); + + // Look through the resolved filter to determine which part + // of it matched the original Intent. + Iterator<PatternMatcher> pIt = ri.filter.schemeSpecificPartsIterator(); + if (pIt != null) { + String ssp = data.getSchemeSpecificPart(); + while (ssp != null && pIt.hasNext()) { + PatternMatcher p = pIt.next(); + if (p.match(ssp)) { + filter.addDataSchemeSpecificPart(p.getPath(), p.getType()); + break; + } + } + } + Iterator<IntentFilter.AuthorityEntry> aIt = ri.filter.authoritiesIterator(); + if (aIt != null) { + while (aIt.hasNext()) { + IntentFilter.AuthorityEntry a = aIt.next(); + if (a.match(data) >= 0) { + int port = a.getPort(); + filter.addDataAuthority(a.getHost(), + port >= 0 ? Integer.toString(port) : null); + break; + } + } + } + pIt = ri.filter.pathsIterator(); + if (pIt != null) { + String path = data.getPath(); + while (path != null && pIt.hasNext()) { + PatternMatcher p = pIt.next(); + if (p.match(path)) { + filter.addDataPath(p.getPath(), p.getType()); + break; + } + } + } + } + } + + if (filter != null) { + final int N = mMultiProfilePagerAdapter.getActiveListAdapter() + .getUnfilteredResolveList().size(); + ComponentName[] set; + // If we don't add back in the component for forwarding the intent to a managed + // profile, the preferred activity may not be updated correctly (as the set of + // components we tell it we knew about will have changed). + final boolean needToAddBackProfileForwardingComponent = + mMultiProfilePagerAdapter.getActiveListAdapter().getOtherProfile() != null; + if (!needToAddBackProfileForwardingComponent) { + set = new ComponentName[N]; + } else { + set = new ComponentName[N + 1]; + } + + int bestMatch = 0; + for (int i=0; i<N; i++) { + ResolveInfo r = mMultiProfilePagerAdapter.getActiveListAdapter() + .getUnfilteredResolveList().get(i).getResolveInfoAt(0); + set[i] = new ComponentName(r.activityInfo.packageName, + r.activityInfo.name); + if (r.match > bestMatch) bestMatch = r.match; + } + + if (needToAddBackProfileForwardingComponent) { + set[N] = mMultiProfilePagerAdapter.getActiveListAdapter() + .getOtherProfile().getResolvedComponentName(); + final int otherProfileMatch = mMultiProfilePagerAdapter.getActiveListAdapter() + .getOtherProfile().getResolveInfo().match; + if (otherProfileMatch > bestMatch) bestMatch = otherProfileMatch; + } + + if (always) { + final int userId = getUserId(); + final PackageManager pm = getPackageManager(); + + // Set the preferred Activity + pm.addUniquePreferredActivity(filter, bestMatch, set, intent.getComponent()); + + if (ri.handleAllWebDataURI) { + // Set default Browser if needed + final String packageName = pm.getDefaultBrowserPackageNameAsUser(userId); + if (TextUtils.isEmpty(packageName)) { + pm.setDefaultBrowserPackageNameAsUser(ri.activityInfo.packageName, userId); + } + } + } else { + try { + mMultiProfilePagerAdapter.getActiveListAdapter() + .mResolverListController.setLastChosen(intent, filter, bestMatch); + } catch (RemoteException re) { + Log.d(TAG, "Error calling setLastChosenActivity\n" + re); + } + } + } + } + + if (target != null) { + safelyStartActivity(target); + + // Rely on the ActivityManager to pop up a dialog regarding app suspension + // and return false + if (target.isSuspended()) { + return false; + } + } + + return true; + } + + public void onActivityStarted(TargetInfo cti) { + // Do nothing + } + + @Override // ResolverListCommunicator + public boolean shouldGetActivityMetadata() { + return false; + } + + public boolean shouldAutoLaunchSingleChoice(TargetInfo target) { + return !target.isSuspended(); + } + + // TODO: this method takes an unused `UserHandle` because the override in `ChooserActivity` uses + // that data to set up other components as dependencies of the controller. In reality, these + // methods don't require polymorphism, because they're only invoked from within their respective + // concrete class; `ResolverActivity` will never call this method expecting to get a + // `ChooserListController` (subclass) result, because `ResolverActivity` only invokes this + // method as part of handling `createMultiProfilePagerAdapter()`, which is itself overridden in + // `ChooserActivity`. A future refactoring could better express the coupling between the adapter + // and controller types; in the meantime, structuring as an override (with matching signatures) + // shows that these methods are *structurally* related, and helps to prevent any regressions in + // the future if resolver *were* to make any (non-overridden) calls to a version that used a + // different signature (and thus didn't return the subclass type). + @VisibleForTesting + protected ResolverListController createListController(UserHandle userHandle) { + ResolverRankerServiceResolverComparator resolverComparator = + new ResolverRankerServiceResolverComparator( + this, + getTargetIntent(), + getReferrerPackageName(), + null, + null, + getResolverRankerServiceUserHandleList(userHandle), + null); + return new ResolverListController( + this, + mPm, + getTargetIntent(), + getReferrerPackageName(), + getAnnotatedUserHandles().userIdOfCallingApp, + resolverComparator, + getQueryIntentsUser(userHandle)); + } + + /** + * Finishing procedures to be performed after the list has been rebuilt. + * </p>Subclasses must call postRebuildListInternal at the end of postRebuildList. + * @param rebuildCompleted + * @return <code>true</code> if the activity is finishing and creation should halt. + */ + protected boolean postRebuildList(boolean rebuildCompleted) { + return postRebuildListInternal(rebuildCompleted); + } + + void onHorizontalSwipeStateChanged(int state) {} + + /** + * Callback called when user changes the profile tab. + * <p>This method is intended to be overridden by subclasses. + */ + protected void onProfileTabSelected() { } + + /** + * Add a label to signify that the user can pick a different app. + * @param adapter The adapter used to provide data to item views. + */ + public void addUseDifferentAppLabelIfNecessary(ResolverListAdapter adapter) { + final boolean useHeader = adapter.hasFilteredItem(); + if (useHeader) { + FrameLayout stub = findViewById(com.android.internal.R.id.stub); + stub.setVisibility(View.VISIBLE); + TextView textView = (TextView) LayoutInflater.from(this).inflate( + R.layout.resolver_different_item_header, null, false); + if (shouldShowTabs()) { + textView.setGravity(Gravity.CENTER); + } + stub.addView(textView); + } + } + + protected void resetButtonBar() { + if (!mSupportsAlwaysUseOption) { + return; + } + final ViewGroup buttonLayout = findViewById(com.android.internal.R.id.button_bar); + if (buttonLayout == null) { + Log.e(TAG, "Layout unexpectedly does not have a button bar"); + return; + } + ResolverListAdapter activeListAdapter = + mMultiProfilePagerAdapter.getActiveListAdapter(); + View buttonBarDivider = findViewById(com.android.internal.R.id.resolver_button_bar_divider); + if (!useLayoutWithDefault()) { + int inset = mSystemWindowInsets != null ? mSystemWindowInsets.bottom : 0; + buttonLayout.setPadding(buttonLayout.getPaddingLeft(), buttonLayout.getPaddingTop(), + buttonLayout.getPaddingRight(), getResources().getDimensionPixelSize( + R.dimen.resolver_button_bar_spacing) + inset); + } + if (activeListAdapter.isTabLoaded() + && mMultiProfilePagerAdapter.shouldShowEmptyStateScreen(activeListAdapter) + && !useLayoutWithDefault()) { + buttonLayout.setVisibility(View.INVISIBLE); + if (buttonBarDivider != null) { + buttonBarDivider.setVisibility(View.INVISIBLE); + } + setButtonBarIgnoreOffset(/* ignoreOffset */ false); + return; + } + if (buttonBarDivider != null) { + buttonBarDivider.setVisibility(View.VISIBLE); + } + buttonLayout.setVisibility(View.VISIBLE); + setButtonBarIgnoreOffset(/* ignoreOffset */ true); + + mOnceButton = (Button) buttonLayout.findViewById(com.android.internal.R.id.button_once); + mAlwaysButton = (Button) buttonLayout.findViewById(com.android.internal.R.id.button_always); + + resetAlwaysOrOnceButtonBar(); + } + + protected String getMetricsCategory() { + return METRICS_CATEGORY_RESOLVER; + } + + @Override // ResolverListCommunicator + public void onHandlePackagesChanged(ResolverListAdapter listAdapter) { + if (listAdapter == mMultiProfilePagerAdapter.getActiveListAdapter()) { + if (listAdapter.getUserHandle().equals(getAnnotatedUserHandles().workProfileUserHandle) + && mWorkProfileAvailability.isWaitingToEnableWorkProfile()) { + // We have just turned on the work profile and entered the pass code to start it, + // now we are waiting to receive the ACTION_USER_UNLOCKED broadcast. There is no + // point in reloading the list now, since the work profile user is still + // turning on. + return; + } + boolean listRebuilt = mMultiProfilePagerAdapter.rebuildActiveTab(true); + if (listRebuilt) { + ResolverListAdapter activeListAdapter = + mMultiProfilePagerAdapter.getActiveListAdapter(); + activeListAdapter.notifyDataSetChanged(); + if (activeListAdapter.getCount() == 0 && !inactiveListAdapterHasItems()) { + // We no longer have any items... just finish the activity. + finish(); + } + } + } else { + mMultiProfilePagerAdapter.clearInactiveProfileCache(); + } + } + + protected void maybeLogProfileChange() {} + + // @NonFinalForTesting + @VisibleForTesting + protected MyUserIdProvider createMyUserIdProvider() { + return new MyUserIdProvider(); + } + + // @NonFinalForTesting + @VisibleForTesting + protected CrossProfileIntentsChecker createCrossProfileIntentsChecker() { + return new CrossProfileIntentsChecker(getContentResolver()); + } + + protected WorkProfileAvailabilityManager createWorkProfileAvailabilityManager() { + return new WorkProfileAvailabilityManager( + getSystemService(UserManager.class), + getAnnotatedUserHandles().workProfileUserHandle, + this::onWorkProfileStatusUpdated); + } + + protected void onWorkProfileStatusUpdated() { + if (mMultiProfilePagerAdapter.getCurrentUserHandle().equals( + getAnnotatedUserHandles().workProfileUserHandle)) { + mMultiProfilePagerAdapter.rebuildActiveTab(true); + } else { + mMultiProfilePagerAdapter.clearInactiveProfileCache(); + } + } + + // @NonFinalForTesting + @VisibleForTesting + protected ResolverListAdapter createResolverListAdapter( + Context context, + List<Intent> payloadIntents, + Intent[] initialIntents, + List<ResolveInfo> resolutionList, + boolean filterLastUsed, + UserHandle userHandle, + TargetDataLoader targetDataLoader) { + UserHandle initialIntentsUserSpace = isLaunchedAsCloneProfile() + && userHandle.equals(getAnnotatedUserHandles().personalProfileUserHandle) + ? getAnnotatedUserHandles().cloneProfileUserHandle : userHandle; + return new ResolverListAdapter( + context, + payloadIntents, + initialIntents, + resolutionList, + filterLastUsed, + createListController(userHandle), + userHandle, + getTargetIntent(), + this, + initialIntentsUserSpace, + targetDataLoader); + } + + private TargetDataLoader createIconLoader() { + Intent startIntent = getIntent(); + boolean isAudioCaptureDevice = + startIntent.getBooleanExtra(EXTRA_IS_AUDIO_CAPTURE_DEVICE, false); + return new DefaultTargetDataLoader(this, getLifecycle(), isAudioCaptureDevice); + } + + private LatencyTracker getLatencyTracker() { + return LatencyTracker.getInstance(this); + } + + /** + * Get the string resource to be used as a label for the link to the resolver activity for an + * action. + * + * @param action The action to resolve + * + * @return The string resource to be used as a label + */ + public static @StringRes int getLabelRes(String action) { + return ActionTitle.forAction(action).labelRes; + } + + protected final EmptyStateProvider createEmptyStateProvider( + @Nullable UserHandle workProfileUserHandle) { + final EmptyStateProvider blockerEmptyStateProvider = createBlockerEmptyStateProvider(); + + final EmptyStateProvider workProfileOffEmptyStateProvider = + new WorkProfilePausedEmptyStateProvider(this, workProfileUserHandle, + mWorkProfileAvailability, + /* onSwitchOnWorkSelectedListener= */ + () -> { + if (mOnSwitchOnWorkSelectedListener != null) { + mOnSwitchOnWorkSelectedListener.onSwitchOnWorkSelected(); + } + }, + getMetricsCategory()); + + final EmptyStateProvider noAppsEmptyStateProvider = new NoAppsAvailableEmptyStateProvider( + this, + workProfileUserHandle, + getAnnotatedUserHandles().personalProfileUserHandle, + getMetricsCategory(), + getAnnotatedUserHandles().tabOwnerUserHandleForLaunch + ); + + // Return composite provider, the order matters (the higher, the more priority) + return new CompositeEmptyStateProvider( + blockerEmptyStateProvider, + workProfileOffEmptyStateProvider, + noAppsEmptyStateProvider + ); + } + + private Intent makeMyIntent() { + Intent intent = new Intent(getIntent()); + intent.setComponent(null); + // The resolver activity is set to be hidden from recent tasks. + // we don't want this attribute to be propagated to the next activity + // being launched. Note that if the original Intent also had this + // flag set, we are now losing it. That should be a very rare case + // and we can live with this. + intent.setFlags(intent.getFlags() & ~Intent.FLAG_ACTIVITY_EXCLUDE_FROM_RECENTS); + + // If FLAG_ACTIVITY_LAUNCH_ADJACENT was set, ResolverActivity was opened in the alternate + // side, which means we want to open the target app on the same side as ResolverActivity. + if ((intent.getFlags() & FLAG_ACTIVITY_LAUNCH_ADJACENT) != 0) { + intent.setFlags(intent.getFlags() & ~FLAG_ACTIVITY_LAUNCH_ADJACENT); + } + return intent; + } + + /** + * Call {@link Activity#onCreate} without initializing anything further. This should + * only be used when the activity is about to be immediately finished to avoid wasting + * initializing steps and leaking resources. + */ + protected final void super_onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + } + + private ResolverMultiProfilePagerAdapter + createResolverMultiProfilePagerAdapterForOneProfile( + Intent[] initialIntents, + List<ResolveInfo> resolutionList, + boolean filterLastUsed, + TargetDataLoader targetDataLoader) { + ResolverListAdapter adapter = createResolverListAdapter( + /* context */ this, + /* payloadIntents */ mIntents, + initialIntents, + resolutionList, + filterLastUsed, + /* userHandle */ getAnnotatedUserHandles().personalProfileUserHandle, + targetDataLoader); + return new ResolverMultiProfilePagerAdapter( + /* context */ this, + adapter, + createEmptyStateProvider(/* workProfileUserHandle= */ null), + /* workProfileQuietModeChecker= */ () -> false, + /* workProfileUserHandle= */ null, + getAnnotatedUserHandles().cloneProfileUserHandle); + } + + private UserHandle getIntentUser() { + return getIntent().hasExtra(EXTRA_CALLING_USER) + ? getIntent().getParcelableExtra(EXTRA_CALLING_USER) + : getAnnotatedUserHandles().tabOwnerUserHandleForLaunch; + } + + private ResolverMultiProfilePagerAdapter createResolverMultiProfilePagerAdapterForTwoProfiles( + Intent[] initialIntents, + List<ResolveInfo> resolutionList, + boolean filterLastUsed, + TargetDataLoader targetDataLoader) { + // In the edge case when we have 0 apps in the current profile and >1 apps in the other, + // 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 = getIntentUser(); + if (!getAnnotatedUserHandles().tabOwnerUserHandleForLaunch.equals(intentUser)) { + if (getAnnotatedUserHandles().personalProfileUserHandle.equals(intentUser)) { + selectedProfile = PROFILE_PERSONAL; + } else if (getAnnotatedUserHandles().workProfileUserHandle.equals(intentUser)) { + selectedProfile = PROFILE_WORK; + } + } else { + int selectedProfileExtra = getSelectedProfileExtra(); + if (selectedProfileExtra != -1) { + selectedProfile = selectedProfileExtra; + } + } + // We only show the default app for the profile of the current user. The filterLastUsed + // flag determines whether to show a default app and that app is not shown in the + // resolver list. So filterLastUsed should be false for the other profile. + ResolverListAdapter personalAdapter = createResolverListAdapter( + /* context */ this, + /* payloadIntents */ mIntents, + selectedProfile == PROFILE_PERSONAL ? initialIntents : null, + resolutionList, + (filterLastUsed && UserHandle.myUserId() + == getAnnotatedUserHandles().personalProfileUserHandle.getIdentifier()), + /* userHandle */ getAnnotatedUserHandles().personalProfileUserHandle, + targetDataLoader); + UserHandle workProfileUserHandle = getAnnotatedUserHandles().workProfileUserHandle; + ResolverListAdapter workAdapter = createResolverListAdapter( + /* context */ this, + /* payloadIntents */ mIntents, + selectedProfile == PROFILE_WORK ? initialIntents : null, + resolutionList, + (filterLastUsed && UserHandle.myUserId() + == workProfileUserHandle.getIdentifier()), + /* userHandle */ workProfileUserHandle, + targetDataLoader); + return new ResolverMultiProfilePagerAdapter( + /* context */ this, + personalAdapter, + workAdapter, + createEmptyStateProvider(workProfileUserHandle), + () -> mWorkProfileAvailability.isQuietModeEnabled(), + selectedProfile, + workProfileUserHandle, + getAnnotatedUserHandles().cloneProfileUserHandle); + } + + /** + * Returns {@link #PROFILE_PERSONAL} or {@link #PROFILE_WORK} if the {@link + * #EXTRA_SELECTED_PROFILE} extra was supplied, or {@code -1} if no extra was supplied. + * @throws IllegalArgumentException if the value passed to the {@link #EXTRA_SELECTED_PROFILE} + * extra is not {@link #PROFILE_PERSONAL} or {@link #PROFILE_WORK} + */ + final int getSelectedProfileExtra() { + int selectedProfile = -1; + if (getIntent().hasExtra(EXTRA_SELECTED_PROFILE)) { + selectedProfile = getIntent().getIntExtra(EXTRA_SELECTED_PROFILE, /* defValue = */ -1); + if (selectedProfile != PROFILE_PERSONAL && selectedProfile != PROFILE_WORK) { + throw new IllegalArgumentException(EXTRA_SELECTED_PROFILE + " has invalid value " + + selectedProfile + ". Must be either ResolverActivity.PROFILE_PERSONAL or " + + "ResolverActivity.PROFILE_WORK."); + } + } + return selectedProfile; + } + + protected final @Profile int getCurrentProfile() { + UserHandle launchUser = getAnnotatedUserHandles().tabOwnerUserHandleForLaunch; + UserHandle personalUser = getAnnotatedUserHandles().personalProfileUserHandle; + return launchUser.equals(personalUser) ? PROFILE_PERSONAL : PROFILE_WORK; + } + + protected final AnnotatedUserHandles getAnnotatedUserHandles() { + return mLazyAnnotatedUserHandles.get(); + } + + private boolean hasWorkProfile() { + return getAnnotatedUserHandles().workProfileUserHandle != null; + } + + private boolean hasCloneProfile() { + return getAnnotatedUserHandles().cloneProfileUserHandle != null; + } + + protected final boolean isLaunchedAsCloneProfile() { + UserHandle launchUser = getAnnotatedUserHandles().userHandleSharesheetLaunchedAs; + UserHandle cloneUser = getAnnotatedUserHandles().cloneProfileUserHandle; + return hasCloneProfile() && launchUser.equals(cloneUser); + } + + protected final boolean shouldShowTabs() { + return hasWorkProfile(); + } + + protected final void onProfileClick(View v) { + final DisplayResolveInfo dri = + mMultiProfilePagerAdapter.getActiveListAdapter().getOtherProfile(); + if (dri == null) { + return; + } + + // Do not show the profile switch message anymore. + mProfileSwitchMessage = null; + + onTargetSelected(dri, false); + finish(); + } + + private void updateIntentPickerPaddings() { + View titleCont = findViewById(com.android.internal.R.id.title_container); + titleCont.setPadding( + titleCont.getPaddingLeft(), + titleCont.getPaddingTop(), + titleCont.getPaddingRight(), + getResources().getDimensionPixelSize(R.dimen.resolver_title_padding_bottom)); + View buttonBar = findViewById(com.android.internal.R.id.button_bar); + buttonBar.setPadding( + buttonBar.getPaddingLeft(), + getResources().getDimensionPixelSize(R.dimen.resolver_button_bar_spacing), + buttonBar.getPaddingRight(), + getResources().getDimensionPixelSize(R.dimen.resolver_button_bar_spacing)); + } + + private void maybeLogCrossProfileTargetLaunch(TargetInfo cti, UserHandle currentUserHandle) { + if (!hasWorkProfile() || currentUserHandle.equals(getUser())) { + return; + } + DevicePolicyEventLogger + .createEvent(DevicePolicyEnums.RESOLVER_CROSS_PROFILE_TARGET_OPENED) + .setBoolean( + currentUserHandle.equals( + getAnnotatedUserHandles().personalProfileUserHandle)) + .setStrings(getMetricsCategory(), + cti.isInDirectShareMetricsCategory() ? "direct_share" : "other_target") + .write(); + } + + @Override // ResolverListCommunicator + public final void sendVoiceChoicesIfNeeded() { + if (!isVoiceInteraction()) { + // Clearly not needed. + return; + } + + int count = mMultiProfilePagerAdapter.getActiveListAdapter().getCount(); + final Option[] options = new Option[count]; + for (int i = 0; i < options.length; i++) { + TargetInfo target = mMultiProfilePagerAdapter.getActiveListAdapter().getItem(i); + if (target == null) { + // If this occurs, a new set of targets is being loaded. Let that complete, + // and have the next call to send voice choices proceed instead. + return; + } + options[i] = optionForChooserTarget(target, i); + } + + mPickOptionRequest = new PickTargetOptionRequest( + new Prompt(getTitle()), options, null); + getVoiceInteractor().submitRequest(mPickOptionRequest); + } + + final Option optionForChooserTarget(TargetInfo target, int index) { + return new Option(getOrLoadDisplayLabel(target), index); + } + + public final Intent getTargetIntent() { + return mIntents.isEmpty() ? null : mIntents.get(0); + } + + protected final String getReferrerPackageName() { + final Uri referrer = getReferrer(); + if (referrer != null && "android-app".equals(referrer.getScheme())) { + return referrer.getHost(); + } + return null; + } + + @Override // ResolverListCommunicator + public final void updateProfileViewButton() { + if (mProfileView == null) { + return; + } + + final DisplayResolveInfo dri = + mMultiProfilePagerAdapter.getActiveListAdapter().getOtherProfile(); + if (dri != null && !shouldShowTabs()) { + mProfileView.setVisibility(View.VISIBLE); + View text = mProfileView.findViewById(com.android.internal.R.id.profile_button); + if (!(text instanceof TextView)) { + text = mProfileView.findViewById(com.android.internal.R.id.text1); + } + ((TextView) text).setText(dri.getDisplayLabel()); + } else { + mProfileView.setVisibility(View.GONE); + } + } + + private void setProfileSwitchMessage(int contentUserHint) { + if ((contentUserHint != UserHandle.USER_CURRENT) + && (contentUserHint != UserHandle.myUserId())) { + UserManager userManager = (UserManager) getSystemService(Context.USER_SERVICE); + UserInfo originUserInfo = userManager.getUserInfo(contentUserHint); + boolean originIsManaged = originUserInfo != null ? originUserInfo.isManagedProfile() + : false; + boolean targetIsManaged = userManager.isManagedProfile(); + if (originIsManaged && !targetIsManaged) { + mProfileSwitchMessage = getForwardToPersonalMsg(); + } else if (!originIsManaged && targetIsManaged) { + mProfileSwitchMessage = getForwardToWorkMsg(); + } + } + } + + private String getForwardToPersonalMsg() { + return getSystemService(DevicePolicyManager.class).getResources().getString( + FORWARD_INTENT_TO_PERSONAL, + () -> getString(R.string.forward_intent_to_owner)); + } + + private String getForwardToWorkMsg() { + return getSystemService(DevicePolicyManager.class).getResources().getString( + FORWARD_INTENT_TO_WORK, + () -> getString(R.string.forward_intent_to_work)); + } + + protected final CharSequence getTitleForAction(Intent intent, int defaultTitleRes) { + final ActionTitle title = mResolvingHome + ? ActionTitle.HOME + : ActionTitle.forAction(intent.getAction()); + + // While there may already be a filtered item, we can only use it in the title if the list + // is already sorted and all information relevant to it is already in the list. + final boolean named = + mMultiProfilePagerAdapter.getActiveListAdapter().getFilteredPosition() >= 0; + if (title == ActionTitle.DEFAULT && defaultTitleRes != 0) { + return getString(defaultTitleRes); + } else { + return named + ? getString( + title.namedTitleRes, + getOrLoadDisplayLabel( + mMultiProfilePagerAdapter + .getActiveListAdapter().getFilteredItem())) + : getString(title.titleRes); + } + } + + final void dismiss() { + if (!isFinishing()) { + finish(); + } + } + + @Override + protected final void onRestart() { + super.onRestart(); + if (!mRegistered) { + mPersonalPackageMonitor.register( + this, + getMainLooper(), + getAnnotatedUserHandles().personalProfileUserHandle, + false); + if (shouldShowTabs()) { + if (mWorkPackageMonitor == null) { + mWorkPackageMonitor = createPackageMonitor( + mMultiProfilePagerAdapter.getWorkListAdapter()); + } + mWorkPackageMonitor.register( + this, + getMainLooper(), + getAnnotatedUserHandles().workProfileUserHandle, + false); + } + mRegistered = true; + } + if (shouldShowTabs() && mWorkProfileAvailability.isWaitingToEnableWorkProfile()) { + if (mWorkProfileAvailability.isQuietModeEnabled()) { + mWorkProfileAvailability.markWorkProfileEnabledBroadcastReceived(); + } + } + mMultiProfilePagerAdapter.getActiveListAdapter().handlePackagesChanged(); + updateProfileViewButton(); + } + + @Override + protected final void onStart() { + super.onStart(); + + this.getWindow().addSystemFlags(SYSTEM_FLAG_HIDE_NON_SYSTEM_OVERLAY_WINDOWS); + if (shouldShowTabs()) { + mWorkProfileAvailability.registerWorkProfileStateReceiver(this); + } + } + + @Override + protected final void onSaveInstanceState(Bundle outState) { + super.onSaveInstanceState(outState); + ViewPager viewPager = findViewById(com.android.internal.R.id.profile_pager); + if (viewPager != null) { + outState.putInt(LAST_SHOWN_TAB_KEY, viewPager.getCurrentItem()); + } + } + + @Override + protected final void onRestoreInstanceState(Bundle savedInstanceState) { + super.onRestoreInstanceState(savedInstanceState); + resetButtonBar(); + ViewPager viewPager = findViewById(com.android.internal.R.id.profile_pager); + if (viewPager != null) { + viewPager.setCurrentItem(savedInstanceState.getInt(LAST_SHOWN_TAB_KEY)); + } + mMultiProfilePagerAdapter.clearInactiveProfileCache(); + } + + private boolean hasManagedProfile() { + UserManager userManager = (UserManager) getSystemService(Context.USER_SERVICE); + if (userManager == null) { + return false; + } + + try { + List<UserInfo> profiles = userManager.getProfiles(getUserId()); + for (UserInfo userInfo : profiles) { + if (userInfo != null && userInfo.isManagedProfile()) { + return true; + } + } + } catch (SecurityException e) { + return false; + } + return false; + } + + private boolean supportsManagedProfiles(ResolveInfo resolveInfo) { + try { + ApplicationInfo appInfo = getPackageManager().getApplicationInfo( + resolveInfo.activityInfo.packageName, 0 /* default flags */); + return appInfo.targetSdkVersion >= Build.VERSION_CODES.LOLLIPOP; + } catch (NameNotFoundException e) { + return false; + } + } + + private void setAlwaysButtonEnabled(boolean hasValidSelection, int checkedPos, + boolean filtered) { + if (!mMultiProfilePagerAdapter.getCurrentUserHandle().equals(getUser())) { + // Never allow the inactive profile to always open an app. + mAlwaysButton.setEnabled(false); + return; + } + // In case of clonedProfile being active, we do not allow the 'Always' option in the + // disambiguation dialog of Personal Profile as the package manager cannot distinguish + // between cross-profile preferred activities. + if (hasCloneProfile() && (mMultiProfilePagerAdapter.getCurrentPage() == PROFILE_PERSONAL)) { + mAlwaysButton.setEnabled(false); + return; + } + boolean enabled = false; + ResolveInfo ri = null; + if (hasValidSelection) { + ri = mMultiProfilePagerAdapter.getActiveListAdapter() + .resolveInfoForPosition(checkedPos, filtered); + if (ri == null) { + Log.e(TAG, "Invalid position supplied to setAlwaysButtonEnabled"); + return; + } else if (ri.targetUserId != UserHandle.USER_CURRENT) { + Log.e(TAG, "Attempted to set selection to resolve info for another user"); + return; + } else { + enabled = true; + } + + mAlwaysButton.setText(getResources() + .getString(R.string.activity_resolver_use_always)); + } + + if (ri != null) { + ActivityInfo activityInfo = ri.activityInfo; + + boolean hasRecordPermission = + mPm.checkPermission(android.Manifest.permission.RECORD_AUDIO, + activityInfo.packageName) + == PackageManager.PERMISSION_GRANTED; + + if (!hasRecordPermission) { + // OK, we know the record permission, is this a capture device + boolean hasAudioCapture = + getIntent().getBooleanExtra( + ResolverActivity.EXTRA_IS_AUDIO_CAPTURE_DEVICE, false); + enabled = !hasAudioCapture; + } + } + mAlwaysButton.setEnabled(enabled); + } + + private String getWorkProfileNotSupportedMsg(String launcherName) { + return getSystemService(DevicePolicyManager.class).getResources().getString( + RESOLVER_WORK_PROFILE_NOT_SUPPORTED, + () -> getString( + R.string.activity_resolver_work_profiles_support, + launcherName), + launcherName); + } + + @Override // ResolverListCommunicator + public final void onPostListReady(ResolverListAdapter listAdapter, boolean doPostProcessing, + boolean rebuildCompleted) { + if (isAutolaunching()) { + return; + } + if (mIsIntentPicker) { + ((ResolverMultiProfilePagerAdapter) mMultiProfilePagerAdapter) + .setUseLayoutWithDefault(useLayoutWithDefault()); + } + if (mMultiProfilePagerAdapter.shouldShowEmptyStateScreen(listAdapter)) { + mMultiProfilePagerAdapter.showEmptyResolverListEmptyState(listAdapter); + } else { + mMultiProfilePagerAdapter.showListView(listAdapter); + } + // showEmptyResolverListEmptyState can mark the tab as loaded, + // which is a precondition for auto launching + if (rebuildCompleted && maybeAutolaunchActivity()) { + return; + } + if (doPostProcessing) { + maybeCreateHeader(listAdapter); + resetButtonBar(); + onListRebuilt(listAdapter, rebuildCompleted); + } + } + + /** Start the activity specified by the {@link TargetInfo}.*/ + public final void safelyStartActivity(TargetInfo cti) { + // In case cloned apps are present, we would want to start those apps in cloned user + // space, which will not be same as the adapter's userHandle. resolveInfo.userHandle + // identifies the correct user space in such cases. + UserHandle activityUserHandle = cti.getResolveInfo().userHandle; + safelyStartActivityAsUser(cti, activityUserHandle, null); + } + + /** + * Start activity as a fixed user handle. + * @param cti TargetInfo to be launched. + * @param user User to launch this activity as. + */ + @VisibleForTesting(visibility = VisibleForTesting.Visibility.PROTECTED) + public final void safelyStartActivityAsUser(TargetInfo cti, UserHandle user) { + safelyStartActivityAsUser(cti, user, null); + } + + protected final void safelyStartActivityAsUser( + TargetInfo cti, UserHandle user, @Nullable Bundle options) { + // We're dispatching intents that might be coming from legacy apps, so + // don't kill ourselves. + StrictMode.disableDeathOnFileUriExposure(); + try { + safelyStartActivityInternal(cti, user, options); + } finally { + StrictMode.enableDeathOnFileUriExposure(); + } + } + + @VisibleForTesting + protected void safelyStartActivityInternal( + TargetInfo cti, UserHandle user, @Nullable Bundle options) { + // If the target is suspended, the activity will not be successfully launched. + // Do not unregister from package manager updates in this case + if (!cti.isSuspended() && mRegistered) { + if (mPersonalPackageMonitor != null) { + mPersonalPackageMonitor.unregister(); + } + if (mWorkPackageMonitor != null) { + mWorkPackageMonitor.unregister(); + } + mRegistered = false; + } + // If needed, show that intent is forwarded + // from managed profile to owner or other way around. + if (mProfileSwitchMessage != null) { + Toast.makeText(this, mProfileSwitchMessage, Toast.LENGTH_LONG).show(); + } + if (!mSafeForwardingMode) { + if (cti.startAsUser(this, options, user)) { + onActivityStarted(cti); + maybeLogCrossProfileTargetLaunch(cti, user); + } + return; + } + try { + if (cti.startAsCaller(this, options, user.getIdentifier())) { + onActivityStarted(cti); + maybeLogCrossProfileTargetLaunch(cti, user); + } + } catch (RuntimeException e) { + Slog.wtf(TAG, + "Unable to launch as uid " + getAnnotatedUserHandles().userIdOfCallingApp + + " package " + getLaunchedFromPackage() + ", while running in " + + ActivityThread.currentProcessName(), e); + } + } + + final void showTargetDetails(ResolveInfo ri) { + Intent in = new Intent().setAction(Settings.ACTION_APPLICATION_DETAILS_SETTINGS) + .setData(Uri.fromParts("package", ri.activityInfo.packageName, null)) + .addFlags(Intent.FLAG_ACTIVITY_NEW_DOCUMENT); + startActivityAsUser(in, mMultiProfilePagerAdapter.getCurrentUserHandle()); + } + + /** + * Sets up the content view. + * @return <code>true</code> if the activity is finishing and creation should halt. + */ + private boolean configureContentView(TargetDataLoader targetDataLoader) { + if (mMultiProfilePagerAdapter.getActiveListAdapter() == null) { + throw new IllegalStateException("mMultiProfilePagerAdapter.getCurrentListAdapter() " + + "cannot be null."); + } + Trace.beginSection("configureContentView"); + // We partially rebuild the inactive adapter to determine if we should auto launch + // isTabLoaded will be true here if the empty state screen is shown instead of the list. + boolean rebuildCompleted = mMultiProfilePagerAdapter.rebuildActiveTab(true) + || mMultiProfilePagerAdapter.getActiveListAdapter().isTabLoaded(); + if (shouldShowTabs()) { + boolean rebuildInactiveCompleted = mMultiProfilePagerAdapter.rebuildInactiveTab(false) + || mMultiProfilePagerAdapter.getInactiveListAdapter().isTabLoaded(); + rebuildCompleted = rebuildCompleted && rebuildInactiveCompleted; + } + + if (shouldUseMiniResolver()) { + configureMiniResolverContent(targetDataLoader); + Trace.endSection(); + return false; + } + + if (useLayoutWithDefault()) { + mLayoutId = R.layout.resolver_list_with_default; + } else { + mLayoutId = getLayoutResource(); + } + setContentView(mLayoutId); + mMultiProfilePagerAdapter.setupViewPager(findViewById(com.android.internal.R.id.profile_pager)); + boolean result = postRebuildList(rebuildCompleted); + Trace.endSection(); + return result; + } + + /** + * Mini resolver is shown when the user is choosing between browser[s] in this profile and a + * single app in the other profile (see shouldUseMiniResolver()). It shows the single app icon + * and asks the user if they'd like to open that cross-profile app or use the in-profile + * browser. + */ + private void configureMiniResolverContent(TargetDataLoader targetDataLoader) { + mLayoutId = R.layout.miniresolver; + setContentView(mLayoutId); + + DisplayResolveInfo sameProfileResolveInfo = + mMultiProfilePagerAdapter.getActiveListAdapter().getFirstDisplayResolveInfo(); + boolean inWorkProfile = getCurrentProfile() == PROFILE_WORK; + + final ResolverListAdapter inactiveAdapter = + mMultiProfilePagerAdapter.getInactiveListAdapter(); + final DisplayResolveInfo otherProfileResolveInfo = + inactiveAdapter.getFirstDisplayResolveInfo(); + + // Load the icon asynchronously + ImageView icon = findViewById(com.android.internal.R.id.icon); + targetDataLoader.loadAppTargetIcon( + otherProfileResolveInfo, + inactiveAdapter.getUserHandle(), + (drawable) -> { + if (!isDestroyed()) { + otherProfileResolveInfo.getDisplayIconHolder().setDisplayIcon(drawable); + new ResolverListAdapter.ViewHolder(icon).bindIcon(otherProfileResolveInfo); + } + }); + + ((TextView) findViewById(com.android.internal.R.id.open_cross_profile)).setText( + getResources().getString( + inWorkProfile + ? R.string.miniresolver_open_in_personal + : R.string.miniresolver_open_in_work, + getOrLoadDisplayLabel(otherProfileResolveInfo))); + ((Button) findViewById(com.android.internal.R.id.use_same_profile_browser)).setText( + inWorkProfile ? R.string.miniresolver_use_work_browser + : R.string.miniresolver_use_personal_browser); + + findViewById(com.android.internal.R.id.use_same_profile_browser).setOnClickListener( + v -> { + safelyStartActivity(sameProfileResolveInfo); + finish(); + }); + + findViewById(com.android.internal.R.id.button_open).setOnClickListener(v -> { + Intent intent = otherProfileResolveInfo.getResolvedIntent(); + safelyStartActivityAsUser(otherProfileResolveInfo, inactiveAdapter.getUserHandle()); + finish(); + }); + } + + /** + * Mini resolver should be used when all of the following are true: + * 1. This is the intent picker (ResolverActivity). + * 2. This profile only has web browser matches. + * 3. The other profile has a single non-browser match. + */ + private boolean shouldUseMiniResolver() { + if (!mIsIntentPicker) { + return false; + } + if (mMultiProfilePagerAdapter.getActiveListAdapter() == null + || mMultiProfilePagerAdapter.getInactiveListAdapter() == null) { + return false; + } + ResolverListAdapter sameProfileAdapter = + mMultiProfilePagerAdapter.getActiveListAdapter(); + ResolverListAdapter otherProfileAdapter = + mMultiProfilePagerAdapter.getInactiveListAdapter(); + + if (sameProfileAdapter.getDisplayResolveInfoCount() == 0) { + Log.d(TAG, "No targets in the current profile"); + return false; + } + + if (otherProfileAdapter.getDisplayResolveInfoCount() != 1) { + Log.d(TAG, "Other-profile count: " + otherProfileAdapter.getDisplayResolveInfoCount()); + return false; + } + + if (otherProfileAdapter.allResolveInfosHandleAllWebDataUri()) { + Log.d(TAG, "Other profile is a web browser"); + return false; + } + + if (!sameProfileAdapter.allResolveInfosHandleAllWebDataUri()) { + Log.d(TAG, "Non-browser found in this profile"); + return false; + } + + return true; + } + + /** + * Finishing procedures to be performed after the list has been rebuilt. + * @param rebuildCompleted + * @return <code>true</code> if the activity is finishing and creation should halt. + */ + final boolean postRebuildListInternal(boolean rebuildCompleted) { + int count = mMultiProfilePagerAdapter.getActiveListAdapter().getUnfilteredCount(); + + // We only rebuild asynchronously when we have multiple elements to sort. In the case where + // we're already done, we can check if we should auto-launch immediately. + if (rebuildCompleted && maybeAutolaunchActivity()) { + return true; + } + + setupViewVisibilities(); + + if (shouldShowTabs()) { + setupProfileTabs(); + } + + return false; + } + + private int isPermissionGranted(String permission, int uid) { + return ActivityManager.checkComponentPermission(permission, uid, + /* owningUid= */-1, /* exported= */ true); + } + + /** + * @return {@code true} if a resolved target is autolaunched, otherwise {@code false} + */ + private boolean maybeAutolaunchActivity() { + int numberOfProfiles = mMultiProfilePagerAdapter.getItemCount(); + if (numberOfProfiles == 1 && maybeAutolaunchIfSingleTarget()) { + return true; + } else if (numberOfProfiles == 2 + && mMultiProfilePagerAdapter.getActiveListAdapter().isTabLoaded() + && mMultiProfilePagerAdapter.getInactiveListAdapter().isTabLoaded() + && maybeAutolaunchIfCrossProfileSupported()) { + // TODO(b/280988288): If the ChooserActivity is shown we should consider showing the + // correct intent-picker UIs (e.g., mini-resolver) if it was launched without + // ACTION_SEND. + return true; + } + return false; + } + + private boolean maybeAutolaunchIfSingleTarget() { + int count = mMultiProfilePagerAdapter.getActiveListAdapter().getUnfilteredCount(); + if (count != 1) { + return false; + } + + if (mMultiProfilePagerAdapter.getActiveListAdapter().getOtherProfile() != null) { + return false; + } + + // Only one target, so we're a candidate to auto-launch! + final TargetInfo target = mMultiProfilePagerAdapter.getActiveListAdapter() + .targetInfoForPosition(0, false); + if (shouldAutoLaunchSingleChoice(target)) { + safelyStartActivity(target); + finish(); + return true; + } + return false; + } + + /** + * When we have a personal and a work profile, we auto launch in the following scenario: + * - There is 1 resolved target on each profile + * - That target is the same app on both profiles + * - The target app has permission to communicate cross profiles + * - The target app has declared it supports cross-profile communication via manifest metadata + */ + private boolean maybeAutolaunchIfCrossProfileSupported() { + ResolverListAdapter activeListAdapter = mMultiProfilePagerAdapter.getActiveListAdapter(); + int count = activeListAdapter.getUnfilteredCount(); + if (count != 1) { + return false; + } + ResolverListAdapter inactiveListAdapter = + mMultiProfilePagerAdapter.getInactiveListAdapter(); + if (inactiveListAdapter.getUnfilteredCount() != 1) { + return false; + } + TargetInfo activeProfileTarget = activeListAdapter + .targetInfoForPosition(0, false); + TargetInfo inactiveProfileTarget = inactiveListAdapter.targetInfoForPosition(0, false); + if (!Objects.equals(activeProfileTarget.getResolvedComponentName(), + inactiveProfileTarget.getResolvedComponentName())) { + return false; + } + if (!shouldAutoLaunchSingleChoice(activeProfileTarget)) { + return false; + } + String packageName = activeProfileTarget.getResolvedComponentName().getPackageName(); + if (!canAppInteractCrossProfiles(packageName)) { + return false; + } + + DevicePolicyEventLogger + .createEvent(DevicePolicyEnums.RESOLVER_AUTOLAUNCH_CROSS_PROFILE_TARGET) + .setBoolean(activeListAdapter.getUserHandle() + .equals(getAnnotatedUserHandles().personalProfileUserHandle)) + .setStrings(getMetricsCategory()) + .write(); + safelyStartActivity(activeProfileTarget); + finish(); + return true; + } + + /** + * Returns whether the package has the necessary permissions to interact across profiles on + * behalf of a given user. + * + * <p>This means meeting the following condition: + * <ul> + * <li>The app's {@link ApplicationInfo#crossProfile} flag must be true, and at least + * one of the following conditions must be fulfilled</li> + * <li>{@code Manifest.permission.INTERACT_ACROSS_USERS_FULL} granted.</li> + * <li>{@code Manifest.permission.INTERACT_ACROSS_USERS} granted.</li> + * <li>{@code Manifest.permission.INTERACT_ACROSS_PROFILES} granted, or the corresponding + * AppOps {@code android:interact_across_profiles} is set to "allow".</li> + * </ul> + * + */ + private boolean canAppInteractCrossProfiles(String packageName) { + ApplicationInfo applicationInfo; + try { + applicationInfo = getPackageManager().getApplicationInfo(packageName, 0); + } catch (NameNotFoundException e) { + Log.e(TAG, "Package " + packageName + " does not exist on current user."); + return false; + } + if (!applicationInfo.crossProfile) { + return false; + } + + int packageUid = applicationInfo.uid; + + if (isPermissionGranted(android.Manifest.permission.INTERACT_ACROSS_USERS_FULL, + packageUid) == PackageManager.PERMISSION_GRANTED) { + return true; + } + if (isPermissionGranted(android.Manifest.permission.INTERACT_ACROSS_USERS, packageUid) + == PackageManager.PERMISSION_GRANTED) { + return true; + } + if (PermissionChecker.checkPermissionForPreflight(this, INTERACT_ACROSS_PROFILES, + PID_UNKNOWN, packageUid, packageName) == PackageManager.PERMISSION_GRANTED) { + return true; + } + return false; + } + + private boolean isAutolaunching() { + return !mRegistered && isFinishing(); + } + + private void setupProfileTabs() { + maybeHideDivider(); + TabHost tabHost = findViewById(com.android.internal.R.id.profile_tabhost); + tabHost.setup(); + ViewPager viewPager = findViewById(com.android.internal.R.id.profile_pager); + viewPager.setSaveEnabled(false); + + Button personalButton = (Button) getLayoutInflater().inflate( + R.layout.resolver_profile_tab_button, tabHost.getTabWidget(), false); + personalButton.setText(getPersonalTabLabel()); + personalButton.setContentDescription(getPersonalTabAccessibilityLabel()); + + TabHost.TabSpec tabSpec = tabHost.newTabSpec(TAB_TAG_PERSONAL) + .setContent(com.android.internal.R.id.profile_pager) + .setIndicator(personalButton); + tabHost.addTab(tabSpec); + + Button workButton = (Button) getLayoutInflater().inflate( + R.layout.resolver_profile_tab_button, tabHost.getTabWidget(), false); + workButton.setText(getWorkTabLabel()); + workButton.setContentDescription(getWorkTabAccessibilityLabel()); + + tabSpec = tabHost.newTabSpec(TAB_TAG_WORK) + .setContent(com.android.internal.R.id.profile_pager) + .setIndicator(workButton); + tabHost.addTab(tabSpec); + + TabWidget tabWidget = tabHost.getTabWidget(); + tabWidget.setVisibility(View.VISIBLE); + updateActiveTabStyle(tabHost); + + tabHost.setOnTabChangedListener(tabId -> { + updateActiveTabStyle(tabHost); + if (TAB_TAG_PERSONAL.equals(tabId)) { + viewPager.setCurrentItem(0); + } else { + viewPager.setCurrentItem(1); + } + setupViewVisibilities(); + maybeLogProfileChange(); + onProfileTabSelected(); + DevicePolicyEventLogger + .createEvent(DevicePolicyEnums.RESOLVER_SWITCH_TABS) + .setInt(viewPager.getCurrentItem()) + .setStrings(getMetricsCategory()) + .write(); + }); + + viewPager.setVisibility(View.VISIBLE); + tabHost.setCurrentTab(mMultiProfilePagerAdapter.getCurrentPage()); + mMultiProfilePagerAdapter.setOnProfileSelectedListener( + new MultiProfilePagerAdapter.OnProfileSelectedListener() { + @Override + public void onProfileSelected(int index) { + tabHost.setCurrentTab(index); + resetButtonBar(); + resetCheckedItem(); + } + + @Override + public void onProfilePageStateChanged(int state) { + onHorizontalSwipeStateChanged(state); + } + }); + mOnSwitchOnWorkSelectedListener = () -> { + final View workTab = tabHost.getTabWidget().getChildAt(1); + workTab.setFocusable(true); + workTab.setFocusableInTouchMode(true); + workTab.requestFocus(); + }; + } + + private String getPersonalTabLabel() { + return getSystemService(DevicePolicyManager.class).getResources().getString( + RESOLVER_PERSONAL_TAB, () -> getString(R.string.resolver_personal_tab)); + } + + private String getWorkTabLabel() { + return getSystemService(DevicePolicyManager.class).getResources().getString( + RESOLVER_WORK_TAB, () -> getString(R.string.resolver_work_tab)); + } + + private void maybeHideDivider() { + if (!mIsIntentPicker) { + return; + } + final View divider = findViewById(com.android.internal.R.id.divider); + if (divider == null) { + return; + } + divider.setVisibility(View.GONE); + } + + private void resetCheckedItem() { + if (!mIsIntentPicker) { + return; + } + mLastSelected = ListView.INVALID_POSITION; + ListView inactiveListView = (ListView) mMultiProfilePagerAdapter.getInactiveAdapterView(); + if (inactiveListView.getCheckedItemCount() > 0) { + inactiveListView.setItemChecked(inactiveListView.getCheckedItemPosition(), false); + } + } + + private String getPersonalTabAccessibilityLabel() { + return getSystemService(DevicePolicyManager.class).getResources().getString( + RESOLVER_PERSONAL_TAB_ACCESSIBILITY, + () -> getString(R.string.resolver_personal_tab_accessibility)); + } + + private String getWorkTabAccessibilityLabel() { + return getSystemService(DevicePolicyManager.class).getResources().getString( + RESOLVER_WORK_TAB_ACCESSIBILITY, + () -> getString(R.string.resolver_work_tab_accessibility)); + } + + private static int getAttrColor(Context context, int attr) { + TypedArray ta = context.obtainStyledAttributes(new int[]{attr}); + int colorAccent = ta.getColor(0, 0); + ta.recycle(); + return colorAccent; + } + + private void updateActiveTabStyle(TabHost tabHost) { + int currentTab = tabHost.getCurrentTab(); + TextView selected = (TextView) tabHost.getTabWidget().getChildAt(currentTab); + TextView unselected = (TextView) tabHost.getTabWidget().getChildAt(1 - currentTab); + selected.setSelected(true); + unselected.setSelected(false); + } + + private void setupViewVisibilities() { + ResolverListAdapter activeListAdapter = mMultiProfilePagerAdapter.getActiveListAdapter(); + if (!mMultiProfilePagerAdapter.shouldShowEmptyStateScreen(activeListAdapter)) { + addUseDifferentAppLabelIfNecessary(activeListAdapter); + } + } + + /** + * Updates the button bar container {@code ignoreOffset} layout param. + * <p>Setting this to {@code true} means that the button bar will be glued to the bottom of + * the screen. + */ + private void setButtonBarIgnoreOffset(boolean ignoreOffset) { + View buttonBarContainer = findViewById(com.android.internal.R.id.button_bar_container); + if (buttonBarContainer != null) { + ResolverDrawerLayout.LayoutParams layoutParams = + (ResolverDrawerLayout.LayoutParams) buttonBarContainer.getLayoutParams(); + layoutParams.ignoreOffset = ignoreOffset; + buttonBarContainer.setLayoutParams(layoutParams); + } + } + + private void setupAdapterListView(ListView listView, ItemClickListener listener) { + listView.setOnItemClickListener(listener); + listView.setOnItemLongClickListener(listener); + + if (mSupportsAlwaysUseOption) { + listView.setChoiceMode(AbsListView.CHOICE_MODE_SINGLE); + } + } + + /** + * Configure the area above the app selection list (title, content preview, etc). + */ + private void maybeCreateHeader(ResolverListAdapter listAdapter) { + if (mHeaderCreatorUser != null + && !listAdapter.getUserHandle().equals(mHeaderCreatorUser)) { + return; + } + if (!shouldShowTabs() + && listAdapter.getCount() == 0 && listAdapter.getPlaceholderCount() == 0) { + final TextView titleView = findViewById(com.android.internal.R.id.title); + if (titleView != null) { + titleView.setVisibility(View.GONE); + } + } + + CharSequence title = mTitle != null + ? mTitle + : getTitleForAction(getTargetIntent(), mDefaultTitleResId); + + if (!TextUtils.isEmpty(title)) { + final TextView titleView = findViewById(com.android.internal.R.id.title); + if (titleView != null) { + titleView.setText(title); + } + setTitle(title); + } + + final ImageView iconView = findViewById(com.android.internal.R.id.icon); + if (iconView != null) { + listAdapter.loadFilteredItemIconTaskAsync(iconView); + } + mHeaderCreatorUser = listAdapter.getUserHandle(); + } + + private void resetAlwaysOrOnceButtonBar() { + // Disable both buttons initially + setAlwaysButtonEnabled(false, ListView.INVALID_POSITION, false); + mOnceButton.setEnabled(false); + + int filteredPosition = mMultiProfilePagerAdapter.getActiveListAdapter() + .getFilteredPosition(); + if (useLayoutWithDefault() && filteredPosition != ListView.INVALID_POSITION) { + setAlwaysButtonEnabled(true, filteredPosition, false); + mOnceButton.setEnabled(true); + // Focus the button if we already have the default option + mOnceButton.requestFocus(); + return; + } + + // When the items load in, if an item was already selected, enable the buttons + ListView currentAdapterView = (ListView) mMultiProfilePagerAdapter.getActiveAdapterView(); + if (currentAdapterView != null + && currentAdapterView.getCheckedItemPosition() != ListView.INVALID_POSITION) { + setAlwaysButtonEnabled(true, currentAdapterView.getCheckedItemPosition(), true); + mOnceButton.setEnabled(true); + } + } + + @Override // ResolverListCommunicator + public final boolean useLayoutWithDefault() { + // We only use the default app layout when the profile of the active user has a + // filtered item. We always show the same default app even in the inactive user profile. + boolean adapterForCurrentUserHasFilteredItem = + mMultiProfilePagerAdapter.getListAdapterForUserHandle( + getAnnotatedUserHandles().tabOwnerUserHandleForLaunch).hasFilteredItem(); + return mSupportsAlwaysUseOption && adapterForCurrentUserHasFilteredItem; + } + + /** + * If {@code retainInOnStop} is set to true, we will not finish ourselves when onStop gets + * called and we are launched in a new task. + */ + protected final void setRetainInOnStop(boolean retainInOnStop) { + mRetainInOnStop = retainInOnStop; + } + + private boolean inactiveListAdapterHasItems() { + if (!shouldShowTabs()) { + return false; + } + return mMultiProfilePagerAdapter.getInactiveListAdapter().getCount() > 0; + } + + final class ItemClickListener implements AdapterView.OnItemClickListener, + AdapterView.OnItemLongClickListener { + @Override + public void onItemClick(AdapterView<?> parent, View view, int position, long id) { + final ListView listView = parent instanceof ListView ? (ListView) parent : null; + if (listView != null) { + position -= listView.getHeaderViewsCount(); + } + if (position < 0) { + // Header views don't count. + return; + } + // If we're still loading, we can't yet enable the buttons. + if (mMultiProfilePagerAdapter.getActiveListAdapter() + .resolveInfoForPosition(position, true) == null) { + return; + } + ListView currentAdapterView = + (ListView) mMultiProfilePagerAdapter.getActiveAdapterView(); + final int checkedPos = currentAdapterView.getCheckedItemPosition(); + final boolean hasValidSelection = checkedPos != ListView.INVALID_POSITION; + if (!useLayoutWithDefault() + && (!hasValidSelection || mLastSelected != checkedPos) + && mAlwaysButton != null) { + setAlwaysButtonEnabled(hasValidSelection, checkedPos, true); + mOnceButton.setEnabled(hasValidSelection); + if (hasValidSelection) { + currentAdapterView.smoothScrollToPosition(checkedPos); + mOnceButton.requestFocus(); + } + mLastSelected = checkedPos; + } else { + startSelected(position, false, true); + } + } + + @Override + public boolean onItemLongClick(AdapterView<?> parent, View view, int position, long id) { + final ListView listView = parent instanceof ListView ? (ListView) parent : null; + if (listView != null) { + position -= listView.getHeaderViewsCount(); + } + if (position < 0) { + // Header views don't count. + return false; + } + ResolveInfo ri = mMultiProfilePagerAdapter.getActiveListAdapter() + .resolveInfoForPosition(position, true); + showTargetDetails(ri); + return true; + } + + } + + /** 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; + } + + static final class PickTargetOptionRequest extends PickOptionRequest { + public PickTargetOptionRequest(@Nullable Prompt prompt, Option[] options, + @Nullable Bundle extras) { + super(prompt, options, extras); + } + + @Override + public void onCancel() { + super.onCancel(); + final ResolverActivity ra = (ResolverActivity) getActivity(); + if (ra != null) { + ra.mPickOptionRequest = null; + ra.finish(); + } + } + + @Override + public void onPickOptionResult(boolean finished, Option[] selections, Bundle result) { + super.onPickOptionResult(finished, selections, result); + if (selections.length != 1) { + // TODO In a better world we would filter the UI presented here and let the + // user refine. Maybe later. + return; + } + + final ResolverActivity ra = (ResolverActivity) getActivity(); + if (ra != null) { + final TargetInfo ti = ra.mMultiProfilePagerAdapter.getActiveListAdapter() + .getItem(selections[0].getIndex()); + if (ra.onTargetSelected(ti, false)) { + ra.mPickOptionRequest = null; + ra.finish(); + } + } + } + } + /** + * Returns the {@link UserHandle} to use when querying resolutions for intents in a + * {@link ResolverListController} configured for the provided {@code userHandle}. + */ + protected final UserHandle getQueryIntentsUser(UserHandle userHandle) { + return getAnnotatedUserHandles().getQueryIntentsUser(userHandle); + } + + /** + * Returns the {@link List} of {@link UserHandle} to pass on to the + * {@link ResolverRankerServiceResolverComparator} as per the provided {@code userHandle}. + */ + @VisibleForTesting(visibility = PROTECTED) + public final List<UserHandle> getResolverRankerServiceUserHandleList(UserHandle userHandle) { + return getResolverRankerServiceUserHandleListInternal(userHandle); + } + + @VisibleForTesting + protected List<UserHandle> getResolverRankerServiceUserHandleListInternal( + UserHandle userHandle) { + List<UserHandle> userList = new ArrayList<>(); + userList.add(userHandle); + // Add clonedProfileUserHandle to the list only if we are: + // a. Building the Personal Tab. + // b. CloneProfile exists on the device. + if (userHandle.equals(getAnnotatedUserHandles().personalProfileUserHandle) + && hasCloneProfile()) { + userList.add(getAnnotatedUserHandles().cloneProfileUserHandle); + } + return userList; + } + + private CharSequence getOrLoadDisplayLabel(TargetInfo info) { + if (info.isDisplayResolveInfo()) { + mTargetDataLoader.getOrLoadLabel((DisplayResolveInfo) info); + } + CharSequence displayLabel = info.getDisplayLabel(); + return displayLabel == null ? "" : displayLabel; + } +} diff --git a/java/tests/AndroidManifest.xml b/java/tests/AndroidManifest.xml index 35dc2ee6..03e32c65 100644 --- a/java/tests/AndroidManifest.xml +++ b/java/tests/AndroidManifest.xml @@ -27,6 +27,8 @@ <uses-library android:name="android.test.runner" /> <activity android:name="com.android.intentresolver.ChooserWrapperActivity" /> <activity android:name="com.android.intentresolver.ResolverWrapperActivity" /> + <activity android:name="com.android.intentresolver.v2.ChooserWrapperActivity" /> + <activity android:name="com.android.intentresolver.v2.ResolverWrapperActivity" /> <provider android:authorities="com.android.intentresolver.tests" android:name="com.android.intentresolver.TestContentProvider" diff --git a/java/tests/src/com/android/intentresolver/MatcherUtils.java b/java/tests/src/com/android/intentresolver/MatcherUtils.java index 6168968b..97cc6984 100644 --- a/java/tests/src/com/android/intentresolver/MatcherUtils.java +++ b/java/tests/src/com/android/intentresolver/MatcherUtils.java @@ -29,7 +29,7 @@ public class MatcherUtils { /** * Returns a {@link Matcher} which only matches the first occurrence of a set criteria. */ - static <T> Matcher<T> first(final Matcher<T> matcher) { + public static <T> Matcher<T> first(final Matcher<T> matcher) { return new BaseMatcher<T>() { boolean isFirstMatch = true; diff --git a/java/tests/src/com/android/intentresolver/ResolverDataProvider.java b/java/tests/src/com/android/intentresolver/ResolverDataProvider.java index 1f8d9bee..4eb350fc 100644 --- a/java/tests/src/com/android/intentresolver/ResolverDataProvider.java +++ b/java/tests/src/com/android/intentresolver/ResolverDataProvider.java @@ -43,7 +43,7 @@ public class ResolverDataProvider { createResolveInfo(i, UserHandle.USER_CURRENT)); } - static ResolvedComponentInfo createResolvedComponentInfo(int i, + public static ResolvedComponentInfo createResolvedComponentInfo(int i, UserHandle resolvedForUser) { return new ResolvedComponentInfo( createComponentName(i), @@ -59,7 +59,7 @@ public class ResolverDataProvider { createResolveInfo(componentName, UserHandle.USER_CURRENT)); } - static ResolvedComponentInfo createResolvedComponentInfo( + public static ResolvedComponentInfo createResolvedComponentInfo( ComponentName componentName, Intent intent, UserHandle resolvedForUser) { return new ResolvedComponentInfo( componentName, @@ -74,8 +74,8 @@ public class ResolverDataProvider { createResolveInfo(i, USER_SOMEONE_ELSE)); } - static ResolvedComponentInfo createResolvedComponentInfoWithOtherId(int i, - UserHandle resolvedForUser) { + public static ResolvedComponentInfo createResolvedComponentInfoWithOtherId(int i, + UserHandle resolvedForUser) { return new ResolvedComponentInfo( createComponentName(i), createResolverIntent(i), @@ -89,7 +89,7 @@ public class ResolverDataProvider { createResolveInfo(i, userId)); } - static ResolvedComponentInfo createResolvedComponentInfoWithOtherId(int i, + public static ResolvedComponentInfo createResolvedComponentInfoWithOtherId(int i, int userId, UserHandle resolvedForUser) { return new ResolvedComponentInfo( createComponentName(i), diff --git a/java/tests/src/com/android/intentresolver/v2/ChooserActivityOverrideData.java b/java/tests/src/com/android/intentresolver/v2/ChooserActivityOverrideData.java new file mode 100644 index 00000000..32eabbed --- /dev/null +++ b/java/tests/src/com/android/intentresolver/v2/ChooserActivityOverrideData.java @@ -0,0 +1,131 @@ +/* + * Copyright (C) 2021 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.v2; + +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.pm.PackageManager; +import android.content.res.Resources; +import android.database.Cursor; +import android.os.UserHandle; + +import com.android.intentresolver.AnnotatedUserHandles; +import com.android.intentresolver.WorkProfileAvailabilityManager; +import com.android.intentresolver.chooser.TargetInfo; +import com.android.intentresolver.contentpreview.ImageLoader; +import com.android.intentresolver.emptystate.CrossProfileIntentsChecker; +import com.android.intentresolver.shortcuts.ShortcutLoader; + +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 + * this singleton to modify behavior. + */ +public class ChooserActivityOverrideData { + private static ChooserActivityOverrideData sInstance = null; + + public static ChooserActivityOverrideData getInstance() { + if (sInstance == null) { + sInstance = new ChooserActivityOverrideData(); + } + return sInstance; + } + + @SuppressWarnings("Since15") + public Function<PackageManager, PackageManager> createPackageManager; + public Function<TargetInfo, Boolean> onSafelyStartInternalCallback; + public Function<TargetInfo, Boolean> onSafelyStartCallback; + public Function2<UserHandle, Consumer<ShortcutLoader.Result>, ShortcutLoader> + shortcutLoaderFactory = (userHandle, callback) -> null; + public ChooserActivity.ChooserListController resolverListController; + public ChooserActivity.ChooserListController workResolverListController; + public Boolean isVoiceInteraction; + public Cursor resolverCursor; + public boolean resolverForceException; + public ImageLoader imageLoader; + public int alternateProfileSetting; + public Resources resources; + public AnnotatedUserHandles annotatedUserHandles; + public boolean hasCrossProfileIntents; + public boolean isQuietModeEnabled; + public Integer myUserId; + public WorkProfileAvailabilityManager mWorkProfileAvailability; + public CrossProfileIntentsChecker mCrossProfileIntentsChecker; + public PackageManager packageManager; + + public void reset() { + onSafelyStartInternalCallback = null; + isVoiceInteraction = null; + createPackageManager = null; + imageLoader = null; + resolverCursor = null; + resolverForceException = false; + resolverListController = mock(ChooserActivity.ChooserListController.class); + workResolverListController = mock(ChooserActivity.ChooserListController.class); + alternateProfileSetting = 0; + resources = null; + annotatedUserHandles = AnnotatedUserHandles.newBuilder() + .setUserIdOfCallingApp(1234) // Must be non-negative. + .setUserHandleSharesheetLaunchedAs(UserHandle.SYSTEM) + .setPersonalProfileUserHandle(UserHandle.SYSTEM) + .build(); + hasCrossProfileIntents = true; + isQuietModeEnabled = false; + myUserId = null; + packageManager = null; + mWorkProfileAvailability = new WorkProfileAvailabilityManager(null, null, null) { + @Override + public boolean isQuietModeEnabled() { + return isQuietModeEnabled; + } + + @Override + public boolean isWorkProfileUserUnlocked() { + return true; + } + + @Override + public void requestQuietModeEnabled(boolean enabled) { + isQuietModeEnabled = enabled; + } + + @Override + public void markWorkProfileEnabledBroadcastReceived() {} + + @Override + public boolean isWaitingToEnableWorkProfile() { + return false; + } + }; + shortcutLoaderFactory = ((userHandle, resultConsumer) -> null); + + mCrossProfileIntentsChecker = mock(CrossProfileIntentsChecker.class); + when(mCrossProfileIntentsChecker.hasCrossProfileIntents(any(), anyInt(), anyInt())) + .thenAnswer(invocation -> hasCrossProfileIntents); + } + + private ChooserActivityOverrideData() {} +} + diff --git a/java/tests/src/com/android/intentresolver/v2/ChooserWrapperActivity.java b/java/tests/src/com/android/intentresolver/v2/ChooserWrapperActivity.java new file mode 100644 index 00000000..41b31d01 --- /dev/null +++ b/java/tests/src/com/android/intentresolver/v2/ChooserWrapperActivity.java @@ -0,0 +1,280 @@ +/* + * 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.v2; + +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; +import android.database.Cursor; +import android.net.Uri; +import android.os.Bundle; +import android.os.UserHandle; + +import androidx.lifecycle.ViewModelProvider; + +import com.android.intentresolver.AnnotatedUserHandles; +import com.android.intentresolver.ChooserIntegratedDeviceComponents; +import com.android.intentresolver.ChooserListAdapter; +import com.android.intentresolver.ChooserRequestParameters; +import com.android.intentresolver.IChooserWrapper; +import com.android.intentresolver.ResolverListController; +import com.android.intentresolver.TestContentPreviewViewModel; +import com.android.intentresolver.WorkProfileAvailabilityManager; +import com.android.intentresolver.chooser.DisplayResolveInfo; +import com.android.intentresolver.chooser.TargetInfo; +import com.android.intentresolver.emptystate.CrossProfileIntentsChecker; +import com.android.intentresolver.grid.ChooserGridAdapter; +import com.android.intentresolver.icons.TargetDataLoader; +import com.android.intentresolver.shortcuts.ShortcutLoader; +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 + * information, see {@code com.android.internal.app.ChooserWrapperActivity}. + */ +public class ChooserWrapperActivity extends ChooserActivity implements IChooserWrapper { + static final ChooserActivityOverrideData sOverrides = ChooserActivityOverrideData.getInstance(); + private UsageStatsManager mUsm; + + // ResolverActivity (the base class of ChooserActivity) 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 ChooserListAdapter createChooserListAdapter( + Context context, + List<Intent> payloadIntents, + Intent[] initialIntents, + List<ResolveInfo> rList, + boolean filterLastUsed, + ResolverListController resolverListController, + UserHandle userHandle, + Intent targetIntent, + ChooserRequestParameters chooserRequest, + int maxTargetsPerRow, + TargetDataLoader targetDataLoader) { + PackageManager packageManager = + sOverrides.packageManager == null ? context.getPackageManager() + : sOverrides.packageManager; + return new ChooserListAdapter( + context, + payloadIntents, + initialIntents, + rList, + filterLastUsed, + createListController(userHandle), + userHandle, + targetIntent, + this, + packageManager, + getEventLog(), + chooserRequest, + maxTargetsPerRow, + userHandle, + targetDataLoader); + } + + @Override + public ChooserListAdapter getAdapter() { + return mChooserMultiProfilePagerAdapter.getActiveListAdapter(); + } + + @Override + public ChooserListAdapter getPersonalListAdapter() { + return ((ChooserGridAdapter) mMultiProfilePagerAdapter.getAdapterForIndex(0)) + .getListAdapter(); + } + + @Override + public ChooserListAdapter getWorkListAdapter() { + if (mMultiProfilePagerAdapter.getInactiveListAdapter() == null) { + return null; + } + return ((ChooserGridAdapter) mMultiProfilePagerAdapter.getAdapterForIndex(1)) + .getListAdapter(); + } + + @Override + public boolean getIsSelected() { + return mIsSuccessfullySelected; + } + + @Override + protected ChooserIntegratedDeviceComponents getIntegratedDeviceComponents() { + return new ChooserIntegratedDeviceComponents( + /* editSharingComponent=*/ null, + // An arbitrary pre-installed activity that handles this type of intent: + /* nearbySharingComponent=*/ new ComponentName( + "com.google.android.apps.messaging", + ".ui.conversationlist.ShareIntentActivity")); + } + + @Override + public UsageStatsManager getUsageStatsManager() { + if (mUsm == null) { + mUsm = getSystemService(UsageStatsManager.class); + } + return mUsm; + } + + @Override + public boolean isVoiceInteraction() { + if (sOverrides.isVoiceInteraction != null) { + return sOverrides.isVoiceInteraction; + } + return super.isVoiceInteraction(); + } + + @Override + protected CrossProfileIntentsChecker createCrossProfileIntentsChecker() { + if (sOverrides.mCrossProfileIntentsChecker != null) { + return sOverrides.mCrossProfileIntentsChecker; + } + return super.createCrossProfileIntentsChecker(); + } + + @Override + protected WorkProfileAvailabilityManager createWorkProfileAvailabilityManager() { + if (sOverrides.mWorkProfileAvailability != null) { + return sOverrides.mWorkProfileAvailability; + } + return super.createWorkProfileAvailabilityManager(); + } + + @Override + public void safelyStartActivityInternal(TargetInfo cti, UserHandle user, + @Nullable Bundle options) { + if (sOverrides.onSafelyStartInternalCallback != null + && sOverrides.onSafelyStartInternalCallback.apply(cti)) { + return; + } + super.safelyStartActivityInternal(cti, user, options); + } + + @Override + protected ChooserListController createListController(UserHandle userHandle) { + if (userHandle == UserHandle.SYSTEM) { + return sOverrides.resolverListController; + } + return sOverrides.workResolverListController; + } + + @Override + public PackageManager getPackageManager() { + if (sOverrides.createPackageManager != null) { + return sOverrides.createPackageManager.apply(super.getPackageManager()); + } + return super.getPackageManager(); + } + + @Override + public Resources getResources() { + if (sOverrides.resources != null) { + return sOverrides.resources; + } + return super.getResources(); + } + + @Override + protected ViewModelProvider.Factory createPreviewViewModelFactory() { + return TestContentPreviewViewModel.Companion.wrap( + super.createPreviewViewModelFactory(), + sOverrides.imageLoader); + } + + @Override + public Cursor queryResolver(ContentResolver resolver, Uri uri) { + if (sOverrides.resolverCursor != null) { + return sOverrides.resolverCursor; + } + + if (sOverrides.resolverForceException) { + throw new SecurityException("Test exception handling"); + } + + return super.queryResolver(resolver, uri); + } + + @Override + protected boolean isWorkProfile() { + if (sOverrides.alternateProfileSetting != 0) { + return sOverrides.alternateProfileSetting == MetricsEvent.MANAGED_PROFILE; + } + return super.isWorkProfile(); + } + + @Override + public DisplayResolveInfo createTestDisplayResolveInfo( + Intent originalIntent, + ResolveInfo pri, + CharSequence pLabel, + CharSequence pInfo, + Intent replacementIntent) { + return DisplayResolveInfo.newDisplayResolveInfo( + originalIntent, + pri, + pLabel, + pInfo, + replacementIntent); + } + + @Override + protected AnnotatedUserHandles computeAnnotatedUserHandles() { + return sOverrides.annotatedUserHandles; + } + + @Override + public UserHandle getCurrentUserHandle() { + return mMultiProfilePagerAdapter.getCurrentUserHandle(); + } + + @Override + public Context createContextAsUser(UserHandle user, int flags) { + // return the current context as a work profile doesn't really exist in these tests + return this; + } + + @Override + 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 super.createShortcutLoader( + context, appPredictor, userHandle, targetIntentFilter, callback); + } +} diff --git a/java/tests/src/com/android/intentresolver/v2/ResolverActivityTest.java b/java/tests/src/com/android/intentresolver/v2/ResolverActivityTest.java new file mode 100644 index 00000000..f0911833 --- /dev/null +++ b/java/tests/src/com/android/intentresolver/v2/ResolverActivityTest.java @@ -0,0 +1,1105 @@ +/* + * 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.v2; + +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.v2.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.junit.Assert.assertFalse; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.fail; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.when; + +import android.content.Intent; +import android.content.pm.ResolveInfo; +import android.net.Uri; +import android.os.RemoteException; +import android.os.UserHandle; +import android.text.TextUtils; +import android.view.View; +import android.widget.RelativeLayout; +import android.widget.TextView; + +import androidx.test.InstrumentationRegistry; +import androidx.test.espresso.Espresso; +import androidx.test.espresso.NoMatchingViewException; +import androidx.test.rule.ActivityTestRule; +import androidx.test.runner.AndroidJUnit4; + +import com.android.intentresolver.AnnotatedUserHandles; +import com.android.intentresolver.R; +import com.android.intentresolver.ResolvedComponentInfo; +import com.android.intentresolver.ResolverDataProvider; +import com.android.intentresolver.widget.ResolverDrawerLayout; +import com.google.android.collect.Lists; + +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 { + + private static final UserHandle PERSONAL_USER_HANDLE = androidx.test.platform.app + .InstrumentationRegistry.getInstrumentation().getTargetContext().getUser(); + private static final UserHandle WORK_PROFILE_USER_HANDLE = UserHandle.of(10); + private static final UserHandle CLONE_PROFILE_USER_HANDLE = UserHandle.of(11); + + 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, + PERSONAL_USER_HANDLE); + + setupResolverControllers(resolvedComponentInfos); + + final ResolverWrapperActivity activity = mActivityRule.launchActivity(sendIntent); + Espresso.registerIdlingResources(activity.getLabelIdlingResource()); + waitForIdle(); + + assertThat(activity.getAdapter().getCount(), is(2)); + + ResolveInfo[] chosen = new ResolveInfo[1]; + sOverrides.onSafelyStartInternalCallback = result -> { + chosen[0] = result.first.getResolveInfo(); + return true; + }; + + ResolveInfo toChoose = resolvedComponentInfos.get(0).getResolveInfoAt(0); + onView(withText(toChoose.activityInfo.name)) + .perform(click()); + onView(withId(com.android.internal.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, + PERSONAL_USER_HANDLE); + + setupResolverControllers(resolvedComponentInfos); + waitForIdle(); + + final ResolverWrapperActivity activity = mActivityRule.launchActivity(sendIntent); + final View viewPager = activity.findViewById(com.android.internal.R.id.profile_pager); + final int initialResolverHeight = viewPager.getHeight(); + + activity.runOnUiThread(() -> { + ResolverDrawerLayout layout = (ResolverDrawerLayout) + activity.findViewById( + com.android.internal.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( + com.android.internal.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, + PERSONAL_USER_HANDLE); + + setupResolverControllers(resolvedComponentInfos); + waitForIdle(); + + final ResolverWrapperActivity activity = mActivityRule.launchActivity(sendIntent); + final View viewPager = activity.findViewById(com.android.internal.R.id.profile_pager); + final View divider = activity.findViewById(com.android.internal.R.id.divider); + final RelativeLayout profileView = + (RelativeLayout) activity.findViewById(com.android.internal.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( + com.android.internal.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, + PERSONAL_USER_HANDLE); + ResolveInfo toChoose = resolvedComponentInfos.get(0).getResolveInfoAt(0); + + setupResolverControllers(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.onSafelyStartInternalCallback = result -> { + chosen[0] = result.first.getResolveInfo(); + return true; + }; + + onView(withId(com.android.internal.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, + PERSONAL_USER_HANDLE); + markOtherProfileAvailability(/* workAvailable= */ true, /* cloneAvailable= */ false); + List<ResolvedComponentInfo> workResolvedComponentInfos = createResolvedComponentsForTest(4, + WORK_PROFILE_USER_HANDLE); + setupResolverControllers(personalResolvedComponentInfos, workResolvedComponentInfos); + + ResolveInfo toChoose = personalResolvedComponentInfos.get(1).getResolveInfoAt(0); + Intent sendIntent = createSendImageIntent(); + final ResolverWrapperActivity activity = mActivityRule.launchActivity(sendIntent); + Espresso.registerIdlingResources(activity.getLabelIdlingResource()); + waitForIdle(); + + // The other entry is filtered to the last used slot + assertThat(activity.getAdapter().getCount(), is(1)); + + ResolveInfo[] chosen = new ResolveInfo[1]; + sOverrides.onSafelyStartInternalCallback = result -> { + chosen[0] = result.first.getResolveInfo(); + return true; + }; + // Make a stable copy of the components as the original list may be modified + List<ResolvedComponentInfo> stableCopy = + createResolvedComponentsForTestWithOtherProfile(2, /* userId= */ 10, + PERSONAL_USER_HANDLE); + // 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(com.android.internal.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, PERSONAL_USER_HANDLE); + ResolveInfo toChoose = resolvedComponentInfos.get(1).getResolveInfoAt(0); + + setupResolverControllers(resolvedComponentInfos); + + final ResolverWrapperActivity activity = mActivityRule.launchActivity(sendIntent); + Espresso.registerIdlingResources(activity.getLabelIdlingResource()); + waitForIdle(); + + // The other entry is filtered to the other profile slot + assertThat(activity.getAdapter().getCount(), is(2)); + + ResolveInfo[] chosen = new ResolveInfo[1]; + sOverrides.onSafelyStartInternalCallback = result -> { + chosen[0] = result.first.getResolveInfo(); + return true; + }; + + // Confirm that the button bar is disabled by default + onView(withId(com.android.internal.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, PERSONAL_USER_HANDLE); + + onView(withText(stableCopy.get(1).getResolveInfoAt(0).activityInfo.name)) + .perform(click()); + onView(withId(com.android.internal.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, PERSONAL_USER_HANDLE); + ResolveInfo toChoose = resolvedComponentInfos.get(1).getResolveInfoAt(0); + + setupResolverControllers(resolvedComponentInfos); + when(sOverrides.resolverListController.getLastChosen()) + .thenReturn(resolvedComponentInfos.get(1).getResolveInfoAt(0)); + + final ResolverWrapperActivity activity = mActivityRule.launchActivity(sendIntent); + Espresso.registerIdlingResources(activity.getLabelIdlingResource()); + waitForIdle(); + + // The other entry is filtered to the other profile slot + assertThat(activity.getAdapter().getCount(), is(2)); + + ResolveInfo[] chosen = new ResolveInfo[1]; + sOverrides.onSafelyStartInternalCallback = result -> { + chosen[0] = result.first.getResolveInfo(); + return true; + }; + + // Confirm that the button bar is disabled by default + onView(withId(com.android.internal.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, PERSONAL_USER_HANDLE); + + onView(withText(stableCopy.get(1).getResolveInfoAt(0).activityInfo.name)) + .perform(click()); + onView(withId(com.android.internal.R.id.button_once)).perform(click()); + waitForIdle(); + assertThat(chosen[0], is(toChoose)); + } + + @Test + public void testWorkTab_displayedWhenWorkProfileUserAvailable() { + Intent sendIntent = createSendImageIntent(); + markOtherProfileAvailability(/* workAvailable= */ true, /* cloneAvailable= */ false); + + mActivityRule.launchActivity(sendIntent); + waitForIdle(); + + onView(withId(com.android.internal.R.id.tabs)).check(matches(isDisplayed())); + } + + @Test + public void testWorkTab_hiddenWhenWorkProfileUserNotAvailable() { + Intent sendIntent = createSendImageIntent(); + + mActivityRule.launchActivity(sendIntent); + waitForIdle(); + + onView(withId(com.android.internal.R.id.tabs)).check(matches(not(isDisplayed()))); + } + + @Test + public void testWorkTab_workTabListPopulatedBeforeGoingToTab() throws InterruptedException { + List<ResolvedComponentInfo> personalResolvedComponentInfos = + createResolvedComponentsForTestWithOtherProfile(3, /* userId = */ 10, + PERSONAL_USER_HANDLE); + markOtherProfileAvailability(/* workAvailable= */ true, /* cloneAvailable= */ false); + List<ResolvedComponentInfo> workResolvedComponentInfos = createResolvedComponentsForTest(4, + WORK_PROFILE_USER_HANDLE); + setupResolverControllers(personalResolvedComponentInfos, + new ArrayList<>(workResolvedComponentInfos)); + Intent sendIntent = createSendImageIntent(); + + 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, + PERSONAL_USER_HANDLE); + markOtherProfileAvailability(/* workAvailable= */ true, /* cloneAvailable= */ false); + List<ResolvedComponentInfo> workResolvedComponentInfos = createResolvedComponentsForTest(4, + WORK_PROFILE_USER_HANDLE); + setupResolverControllers(personalResolvedComponentInfos, workResolvedComponentInfos); + Intent sendIntent = createSendImageIntent(); + markOtherProfileAvailability(/* workAvailable= */ true, /* cloneAvailable= */ false); + + 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, PERSONAL_USER_HANDLE); + markOtherProfileAvailability(/* workAvailable= */ true, /* cloneAvailable= */ false); + List<ResolvedComponentInfo> workResolvedComponentInfos = createResolvedComponentsForTest(4, + WORK_PROFILE_USER_HANDLE); + setupResolverControllers(personalResolvedComponentInfos, workResolvedComponentInfos); + Intent sendIntent = createSendImageIntent(); + + 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 { + markOtherProfileAvailability(/* workAvailable= */ true, /* cloneAvailable= */ false); + List<ResolvedComponentInfo> personalResolvedComponentInfos = + createResolvedComponentsForTestWithOtherProfile(3, /* userId */ 10, + PERSONAL_USER_HANDLE); + List<ResolvedComponentInfo> workResolvedComponentInfos = createResolvedComponentsForTest(4, + WORK_PROFILE_USER_HANDLE); + 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 { + markOtherProfileAvailability(/* workAvailable= */ true, /* cloneAvailable= */ false); + List<ResolvedComponentInfo> personalResolvedComponentInfos = + createResolvedComponentsForTestWithOtherProfile(3, /* userId */ 10, + PERSONAL_USER_HANDLE); + List<ResolvedComponentInfo> workResolvedComponentInfos = createResolvedComponentsForTest(4, + WORK_PROFILE_USER_HANDLE); + setupResolverControllers(personalResolvedComponentInfos, workResolvedComponentInfos); + Intent sendIntent = createSendImageIntent(); + ResolveInfo[] chosen = new ResolveInfo[1]; + sOverrides.onSafelyStartInternalCallback = result -> { + chosen[0] = result.first.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(com.android.internal.R.id.button_once)) + .perform(click()); + + waitForIdle(); + assertThat(chosen[0], is(workResolvedComponentInfos.get(0).getResolveInfoAt(0))); + } + + @Test + public void testWorkTab_noPersonalApps_workTabHasExpectedNumberOfTargets() + throws InterruptedException { + markOtherProfileAvailability(/* workAvailable= */ true, /* cloneAvailable= */ false); + List<ResolvedComponentInfo> personalResolvedComponentInfos = + createResolvedComponentsForTestWithOtherProfile(1, PERSONAL_USER_HANDLE); + List<ResolvedComponentInfo> workResolvedComponentInfos = createResolvedComponentsForTest(4, + WORK_PROFILE_USER_HANDLE); + 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() { + markOtherProfileAvailability(/* workAvailable= */ true, /* cloneAvailable= */ false); + List<ResolvedComponentInfo> personalResolvedComponentInfos = + createResolvedComponentsForTestWithOtherProfile(1, PERSONAL_USER_HANDLE); + List<ResolvedComponentInfo> workResolvedComponentInfos = createResolvedComponentsForTest(4, + WORK_PROFILE_USER_HANDLE); + setupResolverControllers(personalResolvedComponentInfos, workResolvedComponentInfos); + Intent sendIntent = createOpenWebsiteIntent(); + + final ResolverWrapperActivity activity = mActivityRule.launchActivity(sendIntent); + waitForIdle(); + TextView headerText = activity.findViewById(com.android.internal.R.id.title); + String initialText = headerText.getText().toString(); + assertFalse("Header text is empty.", initialText.isEmpty()); + assertThat(headerText.getVisibility(), is(View.VISIBLE)); + } + + @Test + public void testWorkTab_switchTabs_headerStaysSame() { + markOtherProfileAvailability(/* workAvailable= */ true, /* cloneAvailable= */ false); + List<ResolvedComponentInfo> personalResolvedComponentInfos = + createResolvedComponentsForTestWithOtherProfile(1, PERSONAL_USER_HANDLE); + List<ResolvedComponentInfo> workResolvedComponentInfos = createResolvedComponentsForTest(4, + WORK_PROFILE_USER_HANDLE); + setupResolverControllers(personalResolvedComponentInfos, workResolvedComponentInfos); + Intent sendIntent = createOpenWebsiteIntent(); + + final ResolverWrapperActivity activity = mActivityRule.launchActivity(sendIntent); + waitForIdle(); + TextView headerText = activity.findViewById(com.android.internal.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 { + markOtherProfileAvailability(/* workAvailable= */ true, /* cloneAvailable= */ false); + List<ResolvedComponentInfo> personalResolvedComponentInfos = + createResolvedComponentsForTestWithOtherProfile(3, /* userId= */ 10, + PERSONAL_USER_HANDLE); + List<ResolvedComponentInfo> workResolvedComponentInfos = createResolvedComponentsForTest(4, + WORK_PROFILE_USER_HANDLE); + setupResolverControllers(personalResolvedComponentInfos, workResolvedComponentInfos); + Intent sendIntent = createSendImageIntent(); + ResolveInfo[] chosen = new ResolveInfo[1]; + sOverrides.onSafelyStartInternalCallback = result -> { + chosen[0] = result.first.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(com.android.internal.R.id.button_once)) + .perform(click()); + waitForIdle(); + + assertThat(chosen[0], is(workResolvedComponentInfos.get(0).getResolveInfoAt(0))); + } + + @Test + public void testWorkTab_crossProfileIntentsDisabled_personalToWork_emptyStateShown() { + markOtherProfileAvailability(/* workAvailable= */ true, /* cloneAvailable= */ false); + int workProfileTargets = 4; + List<ResolvedComponentInfo> personalResolvedComponentInfos = + createResolvedComponentsForTestWithOtherProfile(3, /* userId */ 10, + PERSONAL_USER_HANDLE); + List<ResolvedComponentInfo> workResolvedComponentInfos = + createResolvedComponentsForTest(workProfileTargets, WORK_PROFILE_USER_HANDLE); + 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(com.android.internal.R.id.contentPanel)) + .perform(swipeUp()); + + onView(withText(R.string.resolver_cross_profile_blocked)) + .check(matches(isDisplayed())); + } + + @Test + public void testWorkTab_workProfileDisabled_emptyStateShown() { + markOtherProfileAvailability(/* workAvailable= */ true, /* cloneAvailable= */ false); + int workProfileTargets = 4; + List<ResolvedComponentInfo> personalResolvedComponentInfos = + createResolvedComponentsForTestWithOtherProfile(3, /* userId */ 10, + PERSONAL_USER_HANDLE); + List<ResolvedComponentInfo> workResolvedComponentInfos = + createResolvedComponentsForTest(workProfileTargets, WORK_PROFILE_USER_HANDLE); + sOverrides.isQuietModeEnabled = true; + setupResolverControllers(personalResolvedComponentInfos, workResolvedComponentInfos); + Intent sendIntent = createSendImageIntent(); + sendIntent.setType("TestType"); + + mActivityRule.launchActivity(sendIntent); + waitForIdle(); + onView(withId(com.android.internal.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() { + markOtherProfileAvailability(/* workAvailable= */ true, /* cloneAvailable= */ false); + List<ResolvedComponentInfo> personalResolvedComponentInfos = + createResolvedComponentsForTest(3, PERSONAL_USER_HANDLE); + List<ResolvedComponentInfo> workResolvedComponentInfos = + createResolvedComponentsForTest(0, WORK_PROFILE_USER_HANDLE); + setupResolverControllers(personalResolvedComponentInfos, workResolvedComponentInfos); + Intent sendIntent = createSendImageIntent(); + sendIntent.setType("TestType"); + + mActivityRule.launchActivity(sendIntent); + waitForIdle(); + onView(withId(com.android.internal.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() { + markOtherProfileAvailability(/* workAvailable= */ true, /* cloneAvailable= */ false); + List<ResolvedComponentInfo> personalResolvedComponentInfos = + createResolvedComponentsForTest(3, PERSONAL_USER_HANDLE); + List<ResolvedComponentInfo> workResolvedComponentInfos = + createResolvedComponentsForTest(0, WORK_PROFILE_USER_HANDLE); + setupResolverControllers(personalResolvedComponentInfos, workResolvedComponentInfos); + Intent sendIntent = createSendImageIntent(); + sendIntent.setType("TestType"); + sOverrides.isQuietModeEnabled = true; + sOverrides.hasCrossProfileIntents = false; + + mActivityRule.launchActivity(sendIntent); + waitForIdle(); + onView(withId(com.android.internal.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() { + markOtherProfileAvailability(/* workAvailable= */ true, /* cloneAvailable= */ false); + List<ResolvedComponentInfo> personalResolvedComponentInfos = + createResolvedComponentsForTest(1, PERSONAL_USER_HANDLE); + List<ResolvedComponentInfo> workResolvedComponentInfos = + createResolvedComponentsForTest(1, WORK_PROFILE_USER_HANDLE); + // 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(com.android.internal.R.id.open_cross_profile)).check(matches(isDisplayed())); + } + + @Test + public void testMiniResolver_noCurrentProfileTarget() { + markOtherProfileAvailability(/* workAvailable= */ true, /* cloneAvailable= */ false); + List<ResolvedComponentInfo> personalResolvedComponentInfos = + createResolvedComponentsForTest(0, PERSONAL_USER_HANDLE); + List<ResolvedComponentInfo> workResolvedComponentInfos = + createResolvedComponentsForTest(1, WORK_PROFILE_USER_HANDLE); + 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(com.android.internal.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() { + markOtherProfileAvailability(/* workAvailable= */ true, /* cloneAvailable= */ false); + List<ResolvedComponentInfo> personalResolvedComponentInfos = + createResolvedComponentsForTest(3, PERSONAL_USER_HANDLE); + List<ResolvedComponentInfo> workResolvedComponentInfos = + createResolvedComponentsForTest(0, WORK_PROFILE_USER_HANDLE); + setupResolverControllers(personalResolvedComponentInfos, workResolvedComponentInfos); + Intent sendIntent = createSendImageIntent(); + sendIntent.setType("TestType"); + sOverrides.isQuietModeEnabled = true; + + mActivityRule.launchActivity(sendIntent); + waitForIdle(); + onView(withId(com.android.internal.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_doesNotAutoLaunch() { + markOtherProfileAvailability(/* workAvailable= */ true, /* cloneAvailable= */ false); + int workProfileTargets = 4; + List<ResolvedComponentInfo> personalResolvedComponentInfos = + createResolvedComponentsForTestWithOtherProfile(2, /* userId */ 10, + PERSONAL_USER_HANDLE); + List<ResolvedComponentInfo> workResolvedComponentInfos = + createResolvedComponentsForTest(workProfileTargets, WORK_PROFILE_USER_HANDLE); + sOverrides.hasCrossProfileIntents = false; + setupResolverControllers(personalResolvedComponentInfos, workResolvedComponentInfos); + Intent sendIntent = createSendImageIntent(); + sendIntent.setType("TestType"); + ResolveInfo[] chosen = new ResolveInfo[1]; + sOverrides.onSafelyStartInternalCallback = result -> { + chosen[0] = result.first.getResolveInfo(); + return true; + }; + + mActivityRule.launchActivity(sendIntent); + waitForIdle(); + + assertNull(chosen[0]); + } + + @Test + public void testLayoutWithDefault_withWorkTab_neverShown() throws RemoteException { + markOtherProfileAvailability(/* workAvailable= */ true, /* cloneAvailable= */ false); + + // 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, PERSONAL_USER_HANDLE); + + setupResolverControllers(resolvedComponentInfos); + when(sOverrides.resolverListController.getLastChosen()) + .thenReturn(resolvedComponentInfos.get(1).getResolveInfoAt(0)); + + final ResolverWrapperActivity activity = mActivityRule.launchActivity(sendIntent); + Espresso.registerIdlingResources(activity.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)); + } + + @Test + public void testClonedProfilePresent_personalAdapterIsSetWithPersonalProfile() { + // enable cloneProfile + markOtherProfileAvailability(/* workAvailable= */ false, /* cloneAvailable= */ true); + List<ResolvedComponentInfo> resolvedComponentInfos = + createResolvedComponentsWithCloneProfileForTest( + 3, + PERSONAL_USER_HANDLE, + CLONE_PROFILE_USER_HANDLE); + setupResolverControllers(resolvedComponentInfos); + Intent sendIntent = createSendImageIntent(); + + final ResolverWrapperActivity activity = mActivityRule.launchActivity(sendIntent); + waitForIdle(); + + assertThat(activity.getCurrentUserHandle(), is(PERSONAL_USER_HANDLE)); + assertThat(activity.getAdapter().getCount(), is(3)); + } + + @Test + public void testClonedProfilePresent_personalTabUsesExpectedAdapter() { + // enable cloneProfile + markOtherProfileAvailability(/* workAvailable= */ true, /* cloneAvailable= */ true); + List<ResolvedComponentInfo> personalResolvedComponentInfos = + createResolvedComponentsWithCloneProfileForTest( + 3, + PERSONAL_USER_HANDLE, + CLONE_PROFILE_USER_HANDLE); + List<ResolvedComponentInfo> workResolvedComponentInfos = createResolvedComponentsForTest(4, + WORK_PROFILE_USER_HANDLE); + setupResolverControllers(personalResolvedComponentInfos, workResolvedComponentInfos); + Intent sendIntent = createSendImageIntent(); + + final ResolverWrapperActivity activity = mActivityRule.launchActivity(sendIntent); + waitForIdle(); + + assertThat(activity.getCurrentUserHandle(), is(PERSONAL_USER_HANDLE)); + assertThat(activity.getAdapter().getCount(), is(3)); + } + + @Test + public void testClonedProfilePresent_layoutWithDefault_neverShown() throws Exception { + // enable cloneProfile + markOtherProfileAvailability(/* workAvailable= */ false, /* cloneAvailable= */ true); + Intent sendIntent = createSendImageIntent(); + List<ResolvedComponentInfo> resolvedComponentInfos = + createResolvedComponentsWithCloneProfileForTest( + 2, + PERSONAL_USER_HANDLE, + CLONE_PROFILE_USER_HANDLE); + + setupResolverControllers(resolvedComponentInfos); + when(sOverrides.resolverListController.getLastChosen()) + .thenReturn(resolvedComponentInfos.get(0).getResolveInfoAt(0)); + + final ResolverWrapperActivity activity = mActivityRule.launchActivity(sendIntent); + Espresso.registerIdlingResources(activity.getLabelIdlingResource()); + waitForIdle(); + + assertThat(activity.getAdapter().hasFilteredItem(), is(false)); + assertThat(activity.getAdapter().getCount(), is(2)); + assertThat(activity.getAdapter().getPlaceholderCount(), is(2)); + } + + @Test + public void testClonedProfilePresent_alwaysButtonDisabled() throws Exception { + // enable cloneProfile + markOtherProfileAvailability(/* workAvailable= */ false, /* cloneAvailable= */ true); + Intent sendIntent = createSendImageIntent(); + List<ResolvedComponentInfo> resolvedComponentInfos = + createResolvedComponentsWithCloneProfileForTest( + 3, + PERSONAL_USER_HANDLE, + CLONE_PROFILE_USER_HANDLE); + + setupResolverControllers(resolvedComponentInfos); + when(sOverrides.resolverListController.getLastChosen()) + .thenReturn(resolvedComponentInfos.get(0).getResolveInfoAt(0)); + + final ResolverWrapperActivity activity = mActivityRule.launchActivity(sendIntent); + waitForIdle(); + + // Confirm that the button bar is disabled by default + onView(withId(com.android.internal.R.id.button_once)).check(matches(not(isEnabled()))); + onView(withId(com.android.internal.R.id.button_always)).check(matches(not(isEnabled()))); + + // Make a stable copy of the components as the original list may be modified + List<ResolvedComponentInfo> stableCopy = + createResolvedComponentsForTestWithOtherProfile(2, PERSONAL_USER_HANDLE); + + onView(withText(stableCopy.get(1).getResolveInfoAt(0).activityInfo.name)) + .perform(click()); + + onView(withId(com.android.internal.R.id.button_once)).check(matches(isEnabled())); + onView(withId(com.android.internal.R.id.button_always)).check(matches(not(isEnabled()))); + } + + @Test + public void testClonedProfilePresent_personalProfileActivityIsStartedInCorrectUser() + throws Exception { + // enable cloneProfile + markOtherProfileAvailability(/* workAvailable= */ true, /* cloneAvailable= */ true); + + List<ResolvedComponentInfo> personalResolvedComponentInfos = + createResolvedComponentsWithCloneProfileForTest( + 3, + PERSONAL_USER_HANDLE, + CLONE_PROFILE_USER_HANDLE); + List<ResolvedComponentInfo> workResolvedComponentInfos = + createResolvedComponentsForTest(3, WORK_PROFILE_USER_HANDLE); + sOverrides.hasCrossProfileIntents = false; + setupResolverControllers(personalResolvedComponentInfos, workResolvedComponentInfos); + Intent sendIntent = createSendImageIntent(); + sendIntent.setType("TestType"); + final UserHandle[] selectedActivityUserHandle = new UserHandle[1]; + sOverrides.onSafelyStartInternalCallback = result -> { + selectedActivityUserHandle[0] = result.second; + return true; + }; + + final ResolverWrapperActivity activity = mActivityRule.launchActivity(sendIntent); + waitForIdle(); + onView(first(allOf(withText(personalResolvedComponentInfos.get(0) + .getResolveInfoAt(0).activityInfo.applicationInfo.name), isCompletelyDisplayed()))) + .perform(click()); + onView(withId(com.android.internal.R.id.button_once)) + .perform(click()); + waitForIdle(); + + assertThat(selectedActivityUserHandle[0], is(activity.getAdapter().getUserHandle())); + } + + @Test + public void testClonedProfilePresent_workProfileActivityIsStartedInCorrectUser() + throws Exception { + // enable cloneProfile + markOtherProfileAvailability(/* workAvailable= */ true, /* cloneAvailable= */ true); + + List<ResolvedComponentInfo> personalResolvedComponentInfos = + createResolvedComponentsWithCloneProfileForTest( + 3, + PERSONAL_USER_HANDLE, + CLONE_PROFILE_USER_HANDLE); + List<ResolvedComponentInfo> workResolvedComponentInfos = + createResolvedComponentsForTest(3, WORK_PROFILE_USER_HANDLE); + setupResolverControllers(personalResolvedComponentInfos, workResolvedComponentInfos); + Intent sendIntent = createSendImageIntent(); + sendIntent.setType("TestType"); + final UserHandle[] selectedActivityUserHandle = new UserHandle[1]; + sOverrides.onSafelyStartInternalCallback = result -> { + selectedActivityUserHandle[0] = result.second; + return true; + }; + + final ResolverWrapperActivity activity = 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(com.android.internal.R.id.button_once)) + .perform(click()); + waitForIdle(); + + assertThat(selectedActivityUserHandle[0], is(activity.getAdapter().getUserHandle())); + } + + @Test + public void testClonedProfilePresent_personalProfileResolverComparatorHasCorrectUsers() + throws Exception { + // enable cloneProfile + markOtherProfileAvailability(/* workAvailable= */ false, /* cloneAvailable= */ true); + List<ResolvedComponentInfo> resolvedComponentInfos = + createResolvedComponentsWithCloneProfileForTest( + 3, + PERSONAL_USER_HANDLE, + CLONE_PROFILE_USER_HANDLE); + setupResolverControllers(resolvedComponentInfos); + Intent sendIntent = createSendImageIntent(); + + final ResolverWrapperActivity activity = mActivityRule.launchActivity(sendIntent); + waitForIdle(); + List<UserHandle> result = activity + .getResolverRankerServiceUserHandleList(PERSONAL_USER_HANDLE); + + assertThat(result.containsAll( + Lists.newArrayList(PERSONAL_USER_HANDLE, CLONE_PROFILE_USER_HANDLE)), is(true)); + } + + 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, + UserHandle resolvedForUser) { + List<ResolvedComponentInfo> infoList = new ArrayList<>(numberOfResults); + for (int i = 0; i < numberOfResults; i++) { + infoList.add(ResolverDataProvider.createResolvedComponentInfo(i, resolvedForUser)); + } + return infoList; + } + + private List<ResolvedComponentInfo> createResolvedComponentsWithCloneProfileForTest( + int numberOfResults, + UserHandle resolvedForPersonalUser, + UserHandle resolvedForClonedUser) { + List<ResolvedComponentInfo> infoList = new ArrayList<>(numberOfResults); + for (int i = 0; i < 1; i++) { + infoList.add(ResolverDataProvider.createResolvedComponentInfo(i, + resolvedForPersonalUser)); + } + for (int i = 1; i < numberOfResults; i++) { + infoList.add(ResolverDataProvider.createResolvedComponentInfo(i, + resolvedForClonedUser)); + } + return infoList; + } + + private List<ResolvedComponentInfo> createResolvedComponentsForTestWithOtherProfile( + int numberOfResults, + UserHandle resolvedForUser) { + List<ResolvedComponentInfo> infoList = new ArrayList<>(numberOfResults); + for (int i = 0; i < numberOfResults; i++) { + if (i == 0) { + infoList.add(ResolverDataProvider.createResolvedComponentInfoWithOtherId(i, + resolvedForUser)); + } else { + infoList.add(ResolverDataProvider.createResolvedComponentInfo(i, resolvedForUser)); + } + } + return infoList; + } + + private List<ResolvedComponentInfo> createResolvedComponentsForTestWithOtherProfile( + int numberOfResults, int userId, UserHandle resolvedForUser) { + List<ResolvedComponentInfo> infoList = new ArrayList<>(numberOfResults); + for (int i = 0; i < numberOfResults; i++) { + if (i == 0) { + infoList.add( + ResolverDataProvider.createResolvedComponentInfoWithOtherId(i, userId, + resolvedForUser)); + } else { + infoList.add(ResolverDataProvider.createResolvedComponentInfo(i, resolvedForUser)); + } + } + return infoList; + } + + private void waitForIdle() { + InstrumentationRegistry.getInstrumentation().waitForIdleSync(); + } + + private void markOtherProfileAvailability(boolean workAvailable, boolean cloneAvailable) { + AnnotatedUserHandles.Builder handles = AnnotatedUserHandles.newBuilder(); + handles + .setUserIdOfCallingApp(1234) // Must be non-negative. + .setUserHandleSharesheetLaunchedAs(PERSONAL_USER_HANDLE) + .setPersonalProfileUserHandle(PERSONAL_USER_HANDLE); + if (workAvailable) { + handles.setWorkProfileUserHandle(WORK_PROFILE_USER_HANDLE); + } + if (cloneAvailable) { + handles.setCloneProfileUserHandle(CLONE_PROFILE_USER_HANDLE); + } + sOverrides.annotatedUserHandles = handles.build(); + } + + private void setupResolverControllers( + List<ResolvedComponentInfo> personalResolvedComponentInfos) { + setupResolverControllers(personalResolvedComponentInfos, new ArrayList<>()); + } + + private void setupResolverControllers( + List<ResolvedComponentInfo> personalResolvedComponentInfos, + List<ResolvedComponentInfo> workResolvedComponentInfos) { + when(sOverrides.resolverListController.getResolversForIntentAsUser( + Mockito.anyBoolean(), + Mockito.anyBoolean(), + Mockito.anyBoolean(), + Mockito.isA(List.class), + eq(UserHandle.SYSTEM))) + .thenReturn(new ArrayList<>(personalResolvedComponentInfos)); + when(sOverrides.workResolverListController.getResolversForIntentAsUser( + Mockito.anyBoolean(), + Mockito.anyBoolean(), + Mockito.anyBoolean(), + Mockito.isA(List.class), + eq(UserHandle.SYSTEM))) + .thenReturn(new ArrayList<>(personalResolvedComponentInfos)); + when(sOverrides.workResolverListController.getResolversForIntentAsUser( + Mockito.anyBoolean(), + Mockito.anyBoolean(), + Mockito.anyBoolean(), + Mockito.isA(List.class), + eq(UserHandle.of(10)))) + .thenReturn(new ArrayList<>(workResolvedComponentInfos)); + } +} diff --git a/java/tests/src/com/android/intentresolver/v2/ResolverWrapperActivity.java b/java/tests/src/com/android/intentresolver/v2/ResolverWrapperActivity.java new file mode 100644 index 00000000..610d031e --- /dev/null +++ b/java/tests/src/com/android/intentresolver/v2/ResolverWrapperActivity.java @@ -0,0 +1,285 @@ +/* + * 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.v2; + +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.annotation.Nullable; +import android.content.Context; +import android.content.Intent; +import android.content.pm.PackageManager; +import android.content.pm.ResolveInfo; +import android.graphics.drawable.Drawable; +import android.os.Bundle; +import android.os.UserHandle; +import android.util.Pair; + +import androidx.annotation.NonNull; +import androidx.test.espresso.idling.CountingIdlingResource; + +import com.android.intentresolver.AnnotatedUserHandles; +import com.android.intentresolver.ResolverListAdapter; +import com.android.intentresolver.ResolverListController; +import com.android.intentresolver.WorkProfileAvailabilityManager; +import com.android.intentresolver.chooser.DisplayResolveInfo; +import com.android.intentresolver.chooser.SelectableTargetInfo; +import com.android.intentresolver.chooser.TargetInfo; +import com.android.intentresolver.emptystate.CrossProfileIntentsChecker; +import com.android.intentresolver.icons.TargetDataLoader; + +import java.util.List; +import java.util.function.Consumer; +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 final CountingIdlingResource mLabelIdlingResource = + new CountingIdlingResource("LoadLabelTask"); + + public ResolverWrapperActivity() { + super(/* isIntentPicker= */ true); + } + + public CountingIdlingResource getLabelIdlingResource() { + return mLabelIdlingResource; + } + + @Override + public ResolverListAdapter createResolverListAdapter( + Context context, + List<Intent> payloadIntents, + Intent[] initialIntents, + List<ResolveInfo> rList, + boolean filterLastUsed, + UserHandle userHandle, + TargetDataLoader targetDataLoader) { + return new ResolverListAdapter( + context, + payloadIntents, + initialIntents, + rList, + filterLastUsed, + createListController(userHandle), + userHandle, + payloadIntents.get(0), // TODO: extract upstream + this, + userHandle, + new TargetDataLoaderWrapper(targetDataLoader, mLabelIdlingResource)); + } + + @Override + protected CrossProfileIntentsChecker createCrossProfileIntentsChecker() { + if (sOverrides.mCrossProfileIntentsChecker != null) { + return sOverrides.mCrossProfileIntentsChecker; + } + return super.createCrossProfileIntentsChecker(); + } + + @Override + protected WorkProfileAvailabilityManager createWorkProfileAvailabilityManager() { + if (sOverrides.mWorkProfileAvailability != null) { + return sOverrides.mWorkProfileAvailability; + } + return super.createWorkProfileAvailabilityManager(); + } + + ResolverListAdapter getAdapter() { + return 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 safelyStartActivityInternal(TargetInfo cti, UserHandle user, + @Nullable Bundle options) { + if (sOverrides.onSafelyStartInternalCallback != null + && sOverrides.onSafelyStartInternalCallback.apply(new Pair<>(cti, user))) { + return; + } + super.safelyStartActivityInternal(cti, user, options); + } + + @Override + protected ResolverListController createListController(UserHandle userHandle) { + if (userHandle == UserHandle.SYSTEM) { + return sOverrides.resolverListController; + } + 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 AnnotatedUserHandles computeAnnotatedUserHandles() { + return sOverrides.annotatedUserHandles; + } + + @Override + public void startActivityAsUser(Intent intent, Bundle options, UserHandle user) { + super.startActivityAsUser(intent, options, user); + } + + @Override + protected List<UserHandle> getResolverRankerServiceUserHandleListInternal(UserHandle + userHandle) { + return super.getResolverRankerServiceUserHandleListInternal(userHandle); + } + + /** + * 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<Pair<TargetInfo, UserHandle>, Boolean> onSafelyStartInternalCallback; + public ResolverListController resolverListController; + public ResolverListController workResolverListController; + public Boolean isVoiceInteraction; + public AnnotatedUserHandles annotatedUserHandles; + public Integer myUserId; + public boolean hasCrossProfileIntents; + public boolean isQuietModeEnabled; + public WorkProfileAvailabilityManager mWorkProfileAvailability; + public CrossProfileIntentsChecker mCrossProfileIntentsChecker; + + public void reset() { + onSafelyStartInternalCallback = null; + isVoiceInteraction = null; + createPackageManager = null; + resolverListController = mock(ResolverListController.class); + workResolverListController = mock(ResolverListController.class); + annotatedUserHandles = AnnotatedUserHandles.newBuilder() + .setUserIdOfCallingApp(1234) // Must be non-negative. + .setUserHandleSharesheetLaunchedAs(UserHandle.SYSTEM) + .setPersonalProfileUserHandle(UserHandle.SYSTEM) + .build(); + myUserId = null; + hasCrossProfileIntents = true; + isQuietModeEnabled = false; + + mWorkProfileAvailability = new WorkProfileAvailabilityManager(null, null, null) { + @Override + public boolean isQuietModeEnabled() { + return isQuietModeEnabled; + } + + @Override + public boolean isWorkProfileUserUnlocked() { + return true; + } + + @Override + public void requestQuietModeEnabled(boolean enabled) { + isQuietModeEnabled = enabled; + } + + @Override + public void markWorkProfileEnabledBroadcastReceived() {} + + @Override + public boolean isWaitingToEnableWorkProfile() { + return false; + } + }; + + mCrossProfileIntentsChecker = mock(CrossProfileIntentsChecker.class); + when(mCrossProfileIntentsChecker.hasCrossProfileIntents(any(), anyInt(), anyInt())) + .thenAnswer(invocation -> hasCrossProfileIntents); + } + } + + private static class TargetDataLoaderWrapper extends TargetDataLoader { + private final TargetDataLoader mTargetDataLoader; + private final CountingIdlingResource mLabelIdlingResource; + + private TargetDataLoaderWrapper( + TargetDataLoader targetDataLoader, CountingIdlingResource labelIdlingResource) { + mTargetDataLoader = targetDataLoader; + mLabelIdlingResource = labelIdlingResource; + } + + @Override + public void loadAppTargetIcon( + @NonNull DisplayResolveInfo info, + @NonNull UserHandle userHandle, + @NonNull Consumer<Drawable> callback) { + mTargetDataLoader.loadAppTargetIcon(info, userHandle, callback); + } + + @Override + public void loadDirectShareIcon( + @NonNull SelectableTargetInfo info, + @NonNull UserHandle userHandle, + @NonNull Consumer<Drawable> callback) { + mTargetDataLoader.loadDirectShareIcon(info, userHandle, callback); + } + + @Override + public void loadLabel( + @NonNull DisplayResolveInfo info, + @NonNull Consumer<CharSequence[]> callback) { + mLabelIdlingResource.increment(); + mTargetDataLoader.loadLabel( + info, + (result) -> { + mLabelIdlingResource.decrement(); + callback.accept(result); + }); + } + + @Override + public void getOrLoadLabel(@NonNull DisplayResolveInfo info) { + mTargetDataLoader.getOrLoadLabel(info); + } + } +} diff --git a/java/tests/src/com/android/intentresolver/v2/UnbundledChooserActivityTest.java b/java/tests/src/com/android/intentresolver/v2/UnbundledChooserActivityTest.java new file mode 100644 index 00000000..1e74c7a5 --- /dev/null +++ b/java/tests/src/com/android/intentresolver/v2/UnbundledChooserActivityTest.java @@ -0,0 +1,3160 @@ +/* + * 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.v2; + +import static android.app.Activity.RESULT_OK; +import static androidx.test.espresso.Espresso.onView; +import static androidx.test.espresso.action.ViewActions.click; +import static androidx.test.espresso.action.ViewActions.longClick; +import static androidx.test.espresso.action.ViewActions.swipeUp; +import static androidx.test.espresso.assertion.ViewAssertions.doesNotExist; +import static androidx.test.espresso.assertion.ViewAssertions.matches; +import static androidx.test.espresso.matcher.ViewMatchers.hasSibling; +import static androidx.test.espresso.matcher.ViewMatchers.isCompletelyDisplayed; +import static androidx.test.espresso.matcher.ViewMatchers.isDisplayed; +import static androidx.test.espresso.matcher.ViewMatchers.withEffectiveVisibility; +import static androidx.test.espresso.matcher.ViewMatchers.withId; +import static androidx.test.espresso.matcher.ViewMatchers.withText; +import static com.android.intentresolver.ChooserListAdapter.CALLER_TARGET_SCORE_BOOST; +import static com.android.intentresolver.ChooserListAdapter.SHORTCUT_TARGET_SCORE_BOOST; +import static com.android.intentresolver.MatcherUtils.first; +import static com.android.intentresolver.v2.ChooserActivity.TARGET_TYPE_CHOOSER_TARGET; +import static com.android.intentresolver.v2.ChooserActivity.TARGET_TYPE_DEFAULT; +import static com.android.intentresolver.v2.ChooserActivity.TARGET_TYPE_SHORTCUTS_FROM_PREDICTION_SERVICE; +import static com.android.intentresolver.v2.ChooserActivity.TARGET_TYPE_SHORTCUTS_FROM_SHORTCUT_MANAGER; +import static com.google.common.truth.Truth.assertThat; +import static com.google.common.truth.Truth.assertWithMessage; +import static junit.framework.Assert.assertNull; +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.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.doReturn; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import android.app.PendingIntent; +import android.app.usage.UsageStatsManager; +import android.content.BroadcastReceiver; +import android.content.ClipData; +import android.content.ClipDescription; +import android.content.ClipboardManager; +import android.content.ComponentName; +import android.content.Context; +import android.content.Intent; +import android.content.IntentFilter; +import android.content.pm.ActivityInfo; +import android.content.pm.ApplicationInfo; +import android.content.pm.PackageManager; +import android.content.pm.ResolveInfo; +import android.content.pm.ShortcutInfo; +import android.content.pm.ShortcutManager.ShareShortcutInfo; +import android.content.res.Configuration; +import android.content.res.Resources; +import android.database.Cursor; +import android.graphics.Bitmap; +import android.graphics.Canvas; +import android.graphics.Color; +import android.graphics.Paint; +import android.graphics.Rect; +import android.graphics.Typeface; +import android.graphics.drawable.Icon; +import android.net.Uri; +import android.os.Bundle; +import android.os.UserHandle; +import android.platform.test.annotations.RequiresFlagsEnabled; +import android.platform.test.flag.junit.CheckFlagsRule; +import android.platform.test.flag.junit.DeviceFlagsValueProvider; +import android.provider.DeviceConfig; +import android.service.chooser.ChooserAction; +import android.service.chooser.ChooserTarget; +import android.text.Spannable; +import android.text.SpannableStringBuilder; +import android.text.Spanned; +import android.text.style.BackgroundColorSpan; +import android.text.style.ForegroundColorSpan; +import android.text.style.StyleSpan; +import android.text.style.UnderlineSpan; +import android.util.Pair; +import android.util.SparseArray; +import android.view.View; +import android.view.WindowManager; +import android.widget.TextView; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.recyclerview.widget.GridLayoutManager; +import androidx.recyclerview.widget.RecyclerView; +import androidx.test.espresso.contrib.RecyclerViewActions; +import androidx.test.espresso.matcher.BoundedDiagnosingMatcher; +import androidx.test.espresso.matcher.ViewMatchers; +import androidx.test.platform.app.InstrumentationRegistry; +import androidx.test.rule.ActivityTestRule; + +import com.android.intentresolver.AnnotatedUserHandles; +import com.android.intentresolver.ChooserListAdapter; +import com.android.intentresolver.Flags; +import com.android.intentresolver.IChooserWrapper; +import com.android.intentresolver.R; +import com.android.intentresolver.ResolvedComponentInfo; +import com.android.intentresolver.ResolverDataProvider; +import com.android.intentresolver.TestContentProvider; +import com.android.intentresolver.TestPreviewImageLoader; +import com.android.intentresolver.chooser.DisplayResolveInfo; +import com.android.intentresolver.contentpreview.ImageLoader; +import com.android.intentresolver.logging.EventLog; +import com.android.intentresolver.logging.FakeEventLog; +import com.android.intentresolver.shortcuts.ShortcutLoader; +import com.android.internal.config.sysui.SystemUiDeviceConfigFlags; +import com.android.internal.logging.nano.MetricsProto.MetricsEvent; + +import org.hamcrest.Description; +import org.hamcrest.Matcher; +import org.hamcrest.Matchers; +import org.junit.Before; +import org.junit.Ignore; +import org.junit.Rule; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.junit.runners.Parameterized; +import org.mockito.ArgumentCaptor; +import org.mockito.Mockito; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collection; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.Executors; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicReference; +import java.util.function.Consumer; +import java.util.function.Function; + +import dagger.hilt.android.testing.HiltAndroidRule; +import dagger.hilt.android.testing.HiltAndroidTest; + +/** + * Instrumentation tests for ChooserActivity. + * <p> + * Legacy test suite migrated from framework CoreTests. + * <p> + */ +@RunWith(Parameterized.class) +@HiltAndroidTest +public class UnbundledChooserActivityTest { + + private static FakeEventLog getEventLog(ChooserWrapperActivity activity) { + return (FakeEventLog) activity.mEventLog; + } + + private static final UserHandle PERSONAL_USER_HANDLE = InstrumentationRegistry + .getInstrumentation().getTargetContext().getUser(); + private static final UserHandle WORK_PROFILE_USER_HANDLE = UserHandle.of(10); + private static final UserHandle CLONE_PROFILE_USER_HANDLE = UserHandle.of(11); + + private static final Function<PackageManager, PackageManager> DEFAULT_PM = pm -> pm; + private static final Function<PackageManager, PackageManager> NO_APP_PREDICTION_SERVICE_PM = + pm -> { + PackageManager mock = Mockito.spy(pm); + when(mock.getAppPredictionServicePackageName()).thenReturn(null); + return mock; + }; + + @Parameterized.Parameters + public static Collection packageManagers() { + return Arrays.asList(new Object[][] { + // Default PackageManager + { DEFAULT_PM }, + // No App Prediction Service + { NO_APP_PREDICTION_SERVICE_PM} + }); + } + + private static final String TEST_MIME_TYPE = "application/TestType"; + + private static final int CONTENT_PREVIEW_IMAGE = 1; + private static final int CONTENT_PREVIEW_FILE = 2; + private static final int CONTENT_PREVIEW_TEXT = 3; + + @Rule(order = 0) + public CheckFlagsRule mCheckFlagsRule = DeviceFlagsValueProvider.createCheckFlagsRule(); + + @Rule(order = 1) + public HiltAndroidRule mHiltAndroidRule = new HiltAndroidRule(this); + + @Rule(order = 2) + public ActivityTestRule<ChooserWrapperActivity> mActivityRule = + new ActivityTestRule<>(ChooserWrapperActivity.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). + InstrumentationRegistry + .getInstrumentation() + .getUiAutomation() + .adoptShellPermissionIdentity(); + + cleanOverrideData(); + mHiltAndroidRule.inject(); + } + + private final Function<PackageManager, PackageManager> mPackageManagerOverride; + + public UnbundledChooserActivityTest( + Function<PackageManager, PackageManager> packageManagerOverride) { + mPackageManagerOverride = packageManagerOverride; + } + + 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; + + setDeviceConfigProperty( + SystemUiDeviceConfigFlags.APPLY_SHARING_APP_LIMITS_IN_SYSUI, + Boolean.toString(true)); + } + + @Test + public void customTitle() throws InterruptedException { + Intent viewIntent = createViewTextIntent(); + List<ResolvedComponentInfo> resolvedComponentInfos = createResolvedComponentsForTest(2); + + setupResolverControllers(resolvedComponentInfos); + final IChooserWrapper activity = (IChooserWrapper) mActivityRule.launchActivity( + Intent.createChooser(viewIntent, "chooser test")); + + waitForIdle(); + assertThat(activity.getAdapter().getCount(), is(2)); + assertThat(activity.getAdapter().getServiceTargetCount(), is(0)); + onView(withId(android.R.id.title)).check(matches(withText("chooser test"))); + } + + @Test + public void customTitleIgnoredForSendIntents() throws InterruptedException { + Intent sendIntent = createSendTextIntent(); + List<ResolvedComponentInfo> resolvedComponentInfos = createResolvedComponentsForTest(2); + + setupResolverControllers(resolvedComponentInfos); + mActivityRule.launchActivity(Intent.createChooser(sendIntent, "chooser test")); + waitForIdle(); + onView(withId(android.R.id.title)) + .check(matches(withText(R.string.whichSendApplication))); + } + + @Test + public void emptyTitle() throws InterruptedException { + Intent sendIntent = createSendTextIntent(); + List<ResolvedComponentInfo> resolvedComponentInfos = createResolvedComponentsForTest(2); + + setupResolverControllers(resolvedComponentInfos); + mActivityRule.launchActivity(Intent.createChooser(sendIntent, null)); + waitForIdle(); + onView(withId(android.R.id.title)) + .check(matches(withText(R.string.whichSendApplication))); + } + + @Test + public void test_shareRichTextWithRichTitle_richTextAndRichTitleDisplayed() { + CharSequence title = new SpannableStringBuilder() + .append("Rich", new UnderlineSpan(), Spannable.SPAN_INCLUSIVE_EXCLUSIVE) + .append( + "Title", + new ForegroundColorSpan(Color.RED), + Spannable.SPAN_INCLUSIVE_EXCLUSIVE); + CharSequence sharedText = new SpannableStringBuilder() + .append( + "Rich", + new BackgroundColorSpan(Color.YELLOW), + Spanned.SPAN_INCLUSIVE_EXCLUSIVE) + .append( + "Text", + new StyleSpan(Typeface.ITALIC), + Spanned.SPAN_INCLUSIVE_EXCLUSIVE); + Intent sendIntent = createSendTextIntent(); + sendIntent.putExtra(Intent.EXTRA_TEXT, sharedText); + sendIntent.putExtra(Intent.EXTRA_TITLE, title); + List<ResolvedComponentInfo> resolvedComponentInfos = createResolvedComponentsForTest(2); + setupResolverControllers(resolvedComponentInfos); + + mActivityRule.launchActivity(Intent.createChooser(sendIntent, null)); + waitForIdle(); + + onView(withId(com.android.internal.R.id.content_preview_title)) + .check((view, e) -> { + assertThat(view).isInstanceOf(TextView.class); + CharSequence text = ((TextView) view).getText(); + assertThat(text).isInstanceOf(Spanned.class); + Spanned spanned = (Spanned) text; + assertThat(spanned.getSpans(0, spanned.length(), Object.class)) + .hasLength(2); + assertThat(spanned.getSpans(0, 4, UnderlineSpan.class)).hasLength(1); + assertThat(spanned.getSpans(4, spanned.length(), ForegroundColorSpan.class)) + .hasLength(1); + }); + + onView(withId(com.android.internal.R.id.content_preview_text)) + .check((view, e) -> { + assertThat(view).isInstanceOf(TextView.class); + CharSequence text = ((TextView) view).getText(); + assertThat(text).isInstanceOf(Spanned.class); + Spanned spanned = (Spanned) text; + assertThat(spanned.getSpans(0, spanned.length(), Object.class)) + .hasLength(2); + assertThat(spanned.getSpans(0, 4, BackgroundColorSpan.class)).hasLength(1); + assertThat(spanned.getSpans(4, spanned.length(), StyleSpan.class)).hasLength(1); + }); + } + + @Test + public void emptyPreviewTitleAndThumbnail() throws InterruptedException { + Intent sendIntent = createSendTextIntentWithPreview(null, null); + List<ResolvedComponentInfo> resolvedComponentInfos = createResolvedComponentsForTest(2); + + setupResolverControllers(resolvedComponentInfos); + mActivityRule.launchActivity(Intent.createChooser(sendIntent, null)); + waitForIdle(); + onView(withId(com.android.internal.R.id.content_preview_title)) + .check(matches(not(isDisplayed()))); + onView(withId(com.android.internal.R.id.content_preview_thumbnail)) + .check(matches(not(isDisplayed()))); + } + + @Test + public void visiblePreviewTitleWithoutThumbnail() throws InterruptedException { + String previewTitle = "My Content Preview Title"; + Intent sendIntent = createSendTextIntentWithPreview(previewTitle, null); + List<ResolvedComponentInfo> resolvedComponentInfos = createResolvedComponentsForTest(2); + + setupResolverControllers(resolvedComponentInfos); + mActivityRule.launchActivity(Intent.createChooser(sendIntent, null)); + waitForIdle(); + onView(withId(com.android.internal.R.id.content_preview_title)) + .check(matches(isDisplayed())); + onView(withId(com.android.internal.R.id.content_preview_title)) + .check(matches(withText(previewTitle))); + onView(withId(com.android.internal.R.id.content_preview_thumbnail)) + .check(matches(not(isDisplayed()))); + } + + @Test + public void visiblePreviewTitleWithInvalidThumbnail() throws InterruptedException { + String previewTitle = "My Content Preview Title"; + Intent sendIntent = createSendTextIntentWithPreview(previewTitle, + Uri.parse("tel:(+49)12345789")); + List<ResolvedComponentInfo> resolvedComponentInfos = createResolvedComponentsForTest(2); + + setupResolverControllers(resolvedComponentInfos); + mActivityRule.launchActivity(Intent.createChooser(sendIntent, null)); + waitForIdle(); + 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()))); + } + + @Test + public void visiblePreviewTitleAndThumbnail() throws InterruptedException { + String previewTitle = "My Content Preview Title"; + Uri uri = Uri.parse( + "android.resource://com.android.frameworks.coretests/" + + com.android.intentresolver.tests.R.drawable.test320x240); + Intent sendIntent = createSendTextIntentWithPreview(previewTitle, uri); + ChooserActivityOverrideData.getInstance().imageLoader = + createImageLoader(uri, createBitmap()); + List<ResolvedComponentInfo> resolvedComponentInfos = createResolvedComponentsForTest(2); + + setupResolverControllers(resolvedComponentInfos); + mActivityRule.launchActivity(Intent.createChooser(sendIntent, null)); + waitForIdle(); + 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())); + } + + @Test @Ignore + public void twoOptionsAndUserSelectsOne() throws InterruptedException { + Intent sendIntent = createSendTextIntent(); + List<ResolvedComponentInfo> resolvedComponentInfos = createResolvedComponentsForTest(2); + + setupResolverControllers(resolvedComponentInfos); + + final IChooserWrapper activity = (IChooserWrapper) + mActivityRule.launchActivity(Intent.createChooser(sendIntent, null)); + waitForIdle(); + + assertThat(activity.getAdapter().getCount(), is(2)); + onView(withId(com.android.internal.R.id.profile_button)).check(doesNotExist()); + + ResolveInfo[] chosen = new ResolveInfo[1]; + ChooserActivityOverrideData.getInstance().onSafelyStartInternalCallback = targetInfo -> { + chosen[0] = targetInfo.getResolveInfo(); + return true; + }; + + ResolveInfo toChoose = resolvedComponentInfos.get(0).getResolveInfoAt(0); + onView(withText(toChoose.activityInfo.name)) + .perform(click()); + waitForIdle(); + assertThat(chosen[0], is(toChoose)); + } + + @Test @Ignore + public void fourOptionsStackedIntoOneTarget() throws InterruptedException { + Intent sendIntent = createSendTextIntent(); + + // create just enough targets to ensure the a-z list should be shown + List<ResolvedComponentInfo> resolvedComponentInfos = createResolvedComponentsForTest(1); + + // next create 4 targets in a single app that should be stacked into a single target + String packageName = "xxx.yyy"; + String appName = "aaa"; + ComponentName cn = new ComponentName(packageName, appName); + Intent intent = new Intent("fakeIntent"); + List<ResolvedComponentInfo> infosToStack = new ArrayList<>(); + for (int i = 0; i < 4; i++) { + ResolveInfo resolveInfo = ResolverDataProvider.createResolveInfo(i, + UserHandle.USER_CURRENT, PERSONAL_USER_HANDLE); + resolveInfo.activityInfo.applicationInfo.name = appName; + resolveInfo.activityInfo.applicationInfo.packageName = packageName; + resolveInfo.activityInfo.packageName = packageName; + resolveInfo.activityInfo.name = "ccc" + i; + infosToStack.add(new ResolvedComponentInfo(cn, intent, resolveInfo)); + } + resolvedComponentInfos.addAll(infosToStack); + + setupResolverControllers(resolvedComponentInfos); + + final IChooserWrapper activity = (IChooserWrapper) + mActivityRule.launchActivity(Intent.createChooser(sendIntent, null)); + waitForIdle(); + + // expect 1 unique targets + 1 group + 4 ranked app targets + assertThat(activity.getAdapter().getCount(), is(6)); + + ResolveInfo[] chosen = new ResolveInfo[1]; + ChooserActivityOverrideData.getInstance().onSafelyStartInternalCallback = targetInfo -> { + chosen[0] = targetInfo.getResolveInfo(); + return true; + }; + + onView(allOf(withText(appName), hasSibling(withText("")))).perform(click()); + waitForIdle(); + + // clicking will launch a dialog to choose the activity within the app + onView(withText(appName)).check(matches(isDisplayed())); + int i = 0; + for (ResolvedComponentInfo rci: infosToStack) { + onView(withText("ccc" + i)).check(matches(isDisplayed())); + ++i; + } + } + + @Test @Ignore + public void updateChooserCountsAndModelAfterUserSelection() throws InterruptedException { + Intent sendIntent = createSendTextIntent(); + List<ResolvedComponentInfo> resolvedComponentInfos = createResolvedComponentsForTest(2); + + setupResolverControllers(resolvedComponentInfos); + + final IChooserWrapper activity = (IChooserWrapper) + mActivityRule.launchActivity(Intent.createChooser(sendIntent, null)); + waitForIdle(); + UsageStatsManager usm = activity.getUsageStatsManager(); + verify(ChooserActivityOverrideData.getInstance().resolverListController, times(1)) + .topK(any(List.class), anyInt()); + assertThat(activity.getIsSelected(), is(false)); + ChooserActivityOverrideData.getInstance().onSafelyStartInternalCallback = targetInfo -> { + return true; + }; + ResolveInfo toChoose = resolvedComponentInfos.get(0).getResolveInfoAt(0); + DisplayResolveInfo testDri = + activity.createTestDisplayResolveInfo( + sendIntent, toChoose, "testLabel", "testInfo", sendIntent); + onView(withText(toChoose.activityInfo.name)) + .perform(click()); + waitForIdle(); + verify(ChooserActivityOverrideData.getInstance().resolverListController, times(1)) + .updateChooserCounts(Mockito.anyString(), any(UserHandle.class), + Mockito.anyString()); + verify(ChooserActivityOverrideData.getInstance().resolverListController, times(1)) + .updateModel(testDri); + assertThat(activity.getIsSelected(), is(true)); + } + + @Ignore // b/148158199 + @Test + public void noResultsFromPackageManager() { + setupResolverControllers(null); + Intent sendIntent = createSendTextIntent(); + final ChooserActivity activity = + mActivityRule.launchActivity(Intent.createChooser(sendIntent, null)); + final IChooserWrapper wrapper = (IChooserWrapper) activity; + + waitForIdle(); + assertThat(activity.isFinishing(), is(false)); + + 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() + ); + // backward compatibility. looks like we finish when data is empty after package change + assertThat(activity.isFinishing(), is(true)); + } + + @Test + public void autoLaunchSingleResult() throws InterruptedException { + ResolveInfo[] chosen = new ResolveInfo[1]; + ChooserActivityOverrideData.getInstance().onSafelyStartInternalCallback = targetInfo -> { + chosen[0] = targetInfo.getResolveInfo(); + return true; + }; + + List<ResolvedComponentInfo> resolvedComponentInfos = createResolvedComponentsForTest(1); + setupResolverControllers(resolvedComponentInfos); + + Intent sendIntent = createSendTextIntent(); + final ChooserActivity activity = + mActivityRule.launchActivity(Intent.createChooser(sendIntent, null)); + waitForIdle(); + + assertThat(chosen[0], is(resolvedComponentInfos.get(0).getResolveInfoAt(0))); + assertThat(activity.isFinishing(), is(true)); + } + + @Test @Ignore + public void hasOtherProfileOneOption() { + List<ResolvedComponentInfo> personalResolvedComponentInfos = + createResolvedComponentsForTestWithOtherProfile(2, /* userId */ 10); + List<ResolvedComponentInfo> workResolvedComponentInfos = createResolvedComponentsForTest(4); + setupResolverControllers(personalResolvedComponentInfos, workResolvedComponentInfos); + markOtherProfileAvailability(/* workAvailable= */ true, /* cloneAvailable= */ false); + + ResolveInfo toChoose = personalResolvedComponentInfos.get(1).getResolveInfoAt(0); + Intent sendIntent = createSendTextIntent(); + final IChooserWrapper activity = (IChooserWrapper) + mActivityRule.launchActivity(Intent.createChooser(sendIntent, null)); + waitForIdle(); + + // The other entry is filtered to the other profile slot + assertThat(activity.getAdapter().getCount(), is(1)); + + ResolveInfo[] chosen = new ResolveInfo[1]; + ChooserActivityOverrideData.getInstance().onSafelyStartInternalCallback = 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); + waitForIdle(); + + onView(first(withText(stableCopy.get(1).getResolveInfoAt(0).activityInfo.name))) + .perform(click()); + waitForIdle(); + assertThat(chosen[0], is(toChoose)); + } + + @Test @Ignore + public void hasOtherProfileTwoOptionsAndUserSelectsOne() throws Exception { + Intent sendIntent = createSendTextIntent(); + List<ResolvedComponentInfo> resolvedComponentInfos = + createResolvedComponentsForTestWithOtherProfile(3); + ResolveInfo toChoose = resolvedComponentInfos.get(1).getResolveInfoAt(0); + + setupResolverControllers(resolvedComponentInfos); + when(ChooserActivityOverrideData.getInstance().resolverListController.getLastChosen()) + .thenReturn(resolvedComponentInfos.get(0).getResolveInfoAt(0)); + + final IChooserWrapper activity = (IChooserWrapper) + mActivityRule.launchActivity(Intent.createChooser(sendIntent, null)); + waitForIdle(); + + // The other entry is filtered to the other profile slot + assertThat(activity.getAdapter().getCount(), is(2)); + + ResolveInfo[] chosen = new ResolveInfo[1]; + ChooserActivityOverrideData.getInstance().onSafelyStartInternalCallback = 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(3); + onView(withText(stableCopy.get(1).getResolveInfoAt(0).activityInfo.name)) + .perform(click()); + waitForIdle(); + assertThat(chosen[0], is(toChoose)); + } + + @Test @Ignore + public void hasLastChosenActivityAndOtherProfile() throws Exception { + Intent sendIntent = createSendTextIntent(); + List<ResolvedComponentInfo> resolvedComponentInfos = + createResolvedComponentsForTestWithOtherProfile(3); + ResolveInfo toChoose = resolvedComponentInfos.get(1).getResolveInfoAt(0); + + setupResolverControllers(resolvedComponentInfos); + + final IChooserWrapper activity = (IChooserWrapper) + mActivityRule.launchActivity(Intent.createChooser(sendIntent, null)); + waitForIdle(); + + // The other entry is filtered to the last used slot + assertThat(activity.getAdapter().getCount(), is(2)); + + ResolveInfo[] chosen = new ResolveInfo[1]; + ChooserActivityOverrideData.getInstance().onSafelyStartInternalCallback = 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(3); + onView(withText(stableCopy.get(1).getResolveInfoAt(0).activityInfo.name)) + .perform(click()); + waitForIdle(); + assertThat(chosen[0], is(toChoose)); + } + + @Test + @Ignore("b/285309527") + public void testFilePlusTextSharing_ExcludeText() { + Uri uri = createTestContentProviderUri(null, "image/png"); + Intent sendIntent = createSendImageIntent(uri); + ChooserActivityOverrideData.getInstance().imageLoader = + createImageLoader(uri, createBitmap()); + sendIntent.putExtra(Intent.EXTRA_TEXT, "https://google.com/search?q=google"); + + List<ResolvedComponentInfo> resolvedComponentInfos = Arrays.asList( + ResolverDataProvider.createResolvedComponentInfo( + new ComponentName("org.imageviewer", "ImageTarget"), + sendIntent, PERSONAL_USER_HANDLE), + ResolverDataProvider.createResolvedComponentInfo( + new ComponentName("org.textviewer", "UriTarget"), + new Intent("VIEW_TEXT"), PERSONAL_USER_HANDLE) + ); + + setupResolverControllers(resolvedComponentInfos); + + mActivityRule.launchActivity(Intent.createChooser(sendIntent, null)); + waitForIdle(); + + onView(withId(R.id.include_text_action)) + .check(matches(isDisplayed())) + .perform(click()); + waitForIdle(); + + onView(withId(R.id.content_preview_text)).check(matches(withText("File only"))); + + AtomicReference<Intent> launchedIntentRef = new AtomicReference<>(); + ChooserActivityOverrideData.getInstance().onSafelyStartInternalCallback = targetInfo -> { + launchedIntentRef.set(targetInfo.getTargetIntent()); + return true; + }; + + onView(withText(resolvedComponentInfos.get(0).getResolveInfoAt(0).activityInfo.name)) + .perform(click()); + waitForIdle(); + assertThat(launchedIntentRef.get().hasExtra(Intent.EXTRA_TEXT)).isFalse(); + } + + @Test + @Ignore("b/285309527") + public void testFilePlusTextSharing_RemoveAndAddBackText() { + Uri uri = createTestContentProviderUri("application/pdf", "image/png"); + Intent sendIntent = createSendImageIntent(uri); + ChooserActivityOverrideData.getInstance().imageLoader = + createImageLoader(uri, createBitmap()); + final String text = "https://google.com/search?q=google"; + sendIntent.putExtra(Intent.EXTRA_TEXT, text); + + List<ResolvedComponentInfo> resolvedComponentInfos = Arrays.asList( + ResolverDataProvider.createResolvedComponentInfo( + new ComponentName("org.imageviewer", "ImageTarget"), + sendIntent, PERSONAL_USER_HANDLE), + ResolverDataProvider.createResolvedComponentInfo( + new ComponentName("org.textviewer", "UriTarget"), + new Intent("VIEW_TEXT"), PERSONAL_USER_HANDLE) + ); + + setupResolverControllers(resolvedComponentInfos); + + mActivityRule.launchActivity(Intent.createChooser(sendIntent, null)); + waitForIdle(); + + onView(withId(R.id.include_text_action)) + .check(matches(isDisplayed())) + .perform(click()); + waitForIdle(); + onView(withId(R.id.content_preview_text)).check(matches(withText("File only"))); + + onView(withId(R.id.include_text_action)) + .perform(click()); + waitForIdle(); + + onView(withId(R.id.content_preview_text)).check(matches(withText(text))); + + AtomicReference<Intent> launchedIntentRef = new AtomicReference<>(); + ChooserActivityOverrideData.getInstance().onSafelyStartInternalCallback = targetInfo -> { + launchedIntentRef.set(targetInfo.getTargetIntent()); + return true; + }; + + onView(withText(resolvedComponentInfos.get(0).getResolveInfoAt(0).activityInfo.name)) + .perform(click()); + waitForIdle(); + assertThat(launchedIntentRef.get().getStringExtra(Intent.EXTRA_TEXT)).isEqualTo(text); + } + + @Test + @Ignore("b/285309527") + public void testFilePlusTextSharing_TextExclusionDoesNotAffectAlternativeIntent() { + Uri uri = createTestContentProviderUri("image/png", null); + Intent sendIntent = createSendImageIntent(uri); + ChooserActivityOverrideData.getInstance().imageLoader = + createImageLoader(uri, createBitmap()); + sendIntent.putExtra(Intent.EXTRA_TEXT, "https://google.com/search?q=google"); + + Intent alternativeIntent = createSendTextIntent(); + final String text = "alternative intent"; + alternativeIntent.putExtra(Intent.EXTRA_TEXT, text); + + List<ResolvedComponentInfo> resolvedComponentInfos = Arrays.asList( + ResolverDataProvider.createResolvedComponentInfo( + new ComponentName("org.imageviewer", "ImageTarget"), + sendIntent, PERSONAL_USER_HANDLE), + ResolverDataProvider.createResolvedComponentInfo( + new ComponentName("org.textviewer", "UriTarget"), + alternativeIntent, PERSONAL_USER_HANDLE) + ); + + setupResolverControllers(resolvedComponentInfos); + + mActivityRule.launchActivity(Intent.createChooser(sendIntent, null)); + waitForIdle(); + + onView(withId(R.id.include_text_action)) + .check(matches(isDisplayed())) + .perform(click()); + waitForIdle(); + + AtomicReference<Intent> launchedIntentRef = new AtomicReference<>(); + ChooserActivityOverrideData.getInstance().onSafelyStartInternalCallback = targetInfo -> { + launchedIntentRef.set(targetInfo.getTargetIntent()); + return true; + }; + + onView(withText(resolvedComponentInfos.get(1).getResolveInfoAt(0).activityInfo.name)) + .perform(click()); + waitForIdle(); + assertThat(launchedIntentRef.get().getStringExtra(Intent.EXTRA_TEXT)).isEqualTo(text); + } + + @Test + @Ignore("b/285309527") + public void testImagePlusTextSharing_failedThumbnailAndExcludedText_textChanges() { + Uri uri = createTestContentProviderUri("image/png", null); + Intent sendIntent = createSendImageIntent(uri); + ChooserActivityOverrideData.getInstance().imageLoader = + new TestPreviewImageLoader(Collections.emptyMap()); + sendIntent.putExtra(Intent.EXTRA_TEXT, "https://google.com/search?q=google"); + + List<ResolvedComponentInfo> resolvedComponentInfos = Arrays.asList( + ResolverDataProvider.createResolvedComponentInfo( + new ComponentName("org.imageviewer", "ImageTarget"), + sendIntent, PERSONAL_USER_HANDLE), + ResolverDataProvider.createResolvedComponentInfo( + new ComponentName("org.textviewer", "UriTarget"), + new Intent("VIEW_TEXT"), PERSONAL_USER_HANDLE) + ); + + setupResolverControllers(resolvedComponentInfos); + + mActivityRule.launchActivity(Intent.createChooser(sendIntent, null)); + waitForIdle(); + + onView(withId(R.id.include_text_action)) + .check(matches(isDisplayed())) + .perform(click()); + waitForIdle(); + + onView(withId(R.id.image_view)) + .check(matches(withEffectiveVisibility(ViewMatchers.Visibility.GONE))); + onView(withId(R.id.content_preview_text)) + .check(matches(allOf(isDisplayed(), withText("Image only")))); + } + + @Test + public void copyTextToClipboard() { + Intent sendIntent = createSendTextIntent(); + List<ResolvedComponentInfo> resolvedComponentInfos = createResolvedComponentsForTest(2); + + setupResolverControllers(resolvedComponentInfos); + + final ChooserActivity activity = + mActivityRule.launchActivity(Intent.createChooser(sendIntent, null)); + waitForIdle(); + + onView(withId(R.id.copy)).check(matches(isDisplayed())); + onView(withId(R.id.copy)).perform(click()); + ClipboardManager clipboard = (ClipboardManager) activity.getSystemService( + Context.CLIPBOARD_SERVICE); + ClipData clipData = clipboard.getPrimaryClip(); + assertThat(clipData).isNotNull(); + assertThat(clipData.getItemAt(0).getText()).isEqualTo("testing intent sending"); + + ClipDescription clipDescription = clipData.getDescription(); + assertThat("text/plain", is(clipDescription.getMimeType(0))); + + assertEquals(mActivityRule.getActivityResult().getResultCode(), RESULT_OK); + } + + @Test + public void copyTextToClipboardLogging() { + Intent sendIntent = createSendTextIntent(); + List<ResolvedComponentInfo> resolvedComponentInfos = createResolvedComponentsForTest(2); + + setupResolverControllers(resolvedComponentInfos); + + ChooserWrapperActivity activity = + mActivityRule.launchActivity(Intent.createChooser(sendIntent, null)); + waitForIdle(); + + onView(withId(R.id.copy)).check(matches(isDisplayed())); + onView(withId(R.id.copy)).perform(click()); + FakeEventLog eventLog = getEventLog(activity); + assertThat(eventLog.getActionSelected()) + .isEqualTo(new FakeEventLog.ActionSelected( + /* targetType = */ EventLog.SELECTION_TYPE_COPY)); + } + + @Test + @Ignore + public void testNearbyShareLogging() throws Exception { + Intent sendIntent = createSendTextIntent(); + List<ResolvedComponentInfo> resolvedComponentInfos = createResolvedComponentsForTest(2); + + setupResolverControllers(resolvedComponentInfos); + + mActivityRule.launchActivity(Intent.createChooser(sendIntent, null)); + waitForIdle(); + + 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. + } + + + + @Test @Ignore + public void testEditImageLogs() { + Uri uri = createTestContentProviderUri("image/png", null); + Intent sendIntent = createSendImageIntent(uri); + ChooserActivityOverrideData.getInstance().imageLoader = + createImageLoader(uri, createBitmap()); + + List<ResolvedComponentInfo> resolvedComponentInfos = createResolvedComponentsForTest(2); + + setupResolverControllers(resolvedComponentInfos); + + mActivityRule.launchActivity(Intent.createChooser(sendIntent, null)); + waitForIdle(); + + 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. + } + + + @Test + public void oneVisibleImagePreview() { + Uri uri = createTestContentProviderUri("image/png", null); + + ArrayList<Uri> uris = new ArrayList<>(); + uris.add(uri); + + Intent sendIntent = createSendUriIntentWithPreview(uris); + ChooserActivityOverrideData.getInstance().imageLoader = + createImageLoader(uri, createWideBitmap()); + + List<ResolvedComponentInfo> resolvedComponentInfos = createResolvedComponentsForTest(2); + + setupResolverControllers(resolvedComponentInfos); + mActivityRule.launchActivity(Intent.createChooser(sendIntent, null)); + waitForIdle(); + onView(withId(R.id.scrollable_image_preview)) + .check((view, exception) -> { + if (exception != null) { + throw exception; + } + RecyclerView recyclerView = (RecyclerView) view; + assertThat(recyclerView.getAdapter().getItemCount(), is(1)); + assertThat(recyclerView.getChildCount(), is(1)); + View imageView = recyclerView.getChildAt(0); + Rect rect = new Rect(); + boolean isPartiallyVisible = imageView.getGlobalVisibleRect(rect); + assertThat( + "image preview view is not fully visible", + isPartiallyVisible + && rect.width() == imageView.getWidth() + && rect.height() == imageView.getHeight()); + }); + } + + @Test + public void allThumbnailsFailedToLoad_hidePreview() { + Uri uri = createTestContentProviderUri("image/jpg", null); + + ArrayList<Uri> uris = new ArrayList<>(); + uris.add(uri); + uris.add(uri); + + Intent sendIntent = createSendUriIntentWithPreview(uris); + ChooserActivityOverrideData.getInstance().imageLoader = + new TestPreviewImageLoader(Collections.emptyMap()); + + List<ResolvedComponentInfo> resolvedComponentInfos = createResolvedComponentsForTest(2); + + setupResolverControllers(resolvedComponentInfos); + mActivityRule.launchActivity(Intent.createChooser(sendIntent, null)); + waitForIdle(); + onView(withId(R.id.scrollable_image_preview)) + .check(matches(withEffectiveVisibility(ViewMatchers.Visibility.GONE))); + } + + @Test + public void testSlowUriMetadata_fallbackToFilePreview() throws InterruptedException { + Uri uri = createTestContentProviderUri( + "application/pdf", "image/png", /*streamTypeTimeout=*/4_000); + ArrayList<Uri> uris = new ArrayList<>(1); + uris.add(uri); + Intent sendIntent = createSendUriIntentWithPreview(uris); + ChooserActivityOverrideData.getInstance().imageLoader = + createImageLoader(uri, createBitmap()); + + List<ResolvedComponentInfo> resolvedComponentInfos = createResolvedComponentsForTest(2); + + setupResolverControllers(resolvedComponentInfos); + assertThat(launchActivityWithTimeout(Intent.createChooser(sendIntent, null), 2_000)) + .isTrue(); + waitForIdle(); + + onView(withId(R.id.content_preview_filename)).check(matches(isDisplayed())); + onView(withId(R.id.content_preview_filename)).check(matches(withText("image.png"))); + onView(withId(R.id.content_preview_file_icon)).check(matches(isDisplayed())); + } + + @Test + public void testSendManyFilesWithSmallMetadataDelayAndOneImage_fallbackToFilePreviewUi() + throws InterruptedException { + Uri fileUri = createTestContentProviderUri( + "application/pdf", "application/pdf", /*streamTypeTimeout=*/150); + Uri imageUri = createTestContentProviderUri("application/pdf", "image/png"); + ArrayList<Uri> uris = new ArrayList<>(50); + for (int i = 0; i < 49; i++) { + uris.add(fileUri); + } + uris.add(imageUri); + Intent sendIntent = createSendUriIntentWithPreview(uris); + ChooserActivityOverrideData.getInstance().imageLoader = + createImageLoader(imageUri, createBitmap()); + + List<ResolvedComponentInfo> resolvedComponentInfos = createResolvedComponentsForTest(2); + setupResolverControllers(resolvedComponentInfos); + assertThat(launchActivityWithTimeout(Intent.createChooser(sendIntent, null), 2_000)) + .isTrue(); + + waitForIdle(); + + onView(withId(R.id.content_preview_filename)).check(matches(isDisplayed())); + onView(withId(R.id.content_preview_filename)).check(matches(withText("image.png"))); + onView(withId(R.id.content_preview_file_icon)).check(matches(isDisplayed())); + } + + @Test + public void testManyVisibleImagePreview_ScrollableImagePreview() { + Uri uri = createTestContentProviderUri("image/png", null); + + ArrayList<Uri> uris = new ArrayList<>(); + uris.add(uri); + uris.add(uri); + uris.add(uri); + uris.add(uri); + uris.add(uri); + uris.add(uri); + uris.add(uri); + uris.add(uri); + uris.add(uri); + uris.add(uri); + + Intent sendIntent = createSendUriIntentWithPreview(uris); + ChooserActivityOverrideData.getInstance().imageLoader = + createImageLoader(uri, createBitmap()); + + List<ResolvedComponentInfo> resolvedComponentInfos = createResolvedComponentsForTest(2); + + setupResolverControllers(resolvedComponentInfos); + mActivityRule.launchActivity(Intent.createChooser(sendIntent, null)); + waitForIdle(); + onView(withId(R.id.scrollable_image_preview)) + .perform(RecyclerViewActions.scrollToLastPosition()) + .check((view, exception) -> { + if (exception != null) { + throw exception; + } + RecyclerView recyclerView = (RecyclerView) view; + assertThat(recyclerView.getAdapter().getItemCount(), is(uris.size())); + }); + } + + @Test + public void testPartiallyLoadedMetadata_previewIsShownForTheLoadedPart() + throws InterruptedException { + Uri imgOneUri = createTestContentProviderUri("image/png", null); + Uri imgTwoUri = createTestContentProviderUri("image/png", null) + .buildUpon() + .path("image-2.png") + .build(); + Uri docUri = createTestContentProviderUri("application/pdf", "image/png", 3_000); + ArrayList<Uri> uris = new ArrayList<>(2); + // two large previews to fill the screen and be presented right away and one + // document that would be delayed by the URI metadata reading + uris.add(imgOneUri); + uris.add(imgTwoUri); + uris.add(docUri); + + Intent sendIntent = createSendUriIntentWithPreview(uris); + Map<Uri, Bitmap> bitmaps = new HashMap<>(); + bitmaps.put(imgOneUri, createWideBitmap(Color.RED)); + bitmaps.put(imgTwoUri, createWideBitmap(Color.GREEN)); + bitmaps.put(docUri, createWideBitmap(Color.BLUE)); + ChooserActivityOverrideData.getInstance().imageLoader = + new TestPreviewImageLoader(bitmaps); + + List<ResolvedComponentInfo> resolvedComponentInfos = createResolvedComponentsForTest(2); + setupResolverControllers(resolvedComponentInfos); + + assertThat(launchActivityWithTimeout(Intent.createChooser(sendIntent, null), 1_000)) + .isTrue(); + waitForIdle(); + + onView(withId(R.id.scrollable_image_preview)) + .check((view, exception) -> { + if (exception != null) { + throw exception; + } + RecyclerView recyclerView = (RecyclerView) view; + assertThat(recyclerView.getChildCount()).isAtLeast(1); + // the first view is a preview + View imageView = recyclerView.getChildAt(0).findViewById(R.id.image); + assertThat(imageView).isNotNull(); + }) + .perform(RecyclerViewActions.scrollToLastPosition()) + .check((view, exception) -> { + if (exception != null) { + throw exception; + } + RecyclerView recyclerView = (RecyclerView) view; + assertThat(recyclerView.getChildCount()).isAtLeast(1); + // check that the last view is a loading indicator + View loadingIndicator = + recyclerView.getChildAt(recyclerView.getChildCount() - 1); + assertThat(loadingIndicator).isNotNull(); + }); + waitForIdle(); + } + + @Test + public void testImageAndTextPreview() { + final Uri uri = createTestContentProviderUri("image/png", null); + final String sharedText = "text-" + System.currentTimeMillis(); + + ArrayList<Uri> uris = new ArrayList<>(); + uris.add(uri); + + Intent sendIntent = createSendUriIntentWithPreview(uris); + sendIntent.putExtra(Intent.EXTRA_TEXT, sharedText); + ChooserActivityOverrideData.getInstance().imageLoader = + createImageLoader(uri, createBitmap()); + + List<ResolvedComponentInfo> resolvedComponentInfos = createResolvedComponentsForTest(2); + + setupResolverControllers(resolvedComponentInfos); + mActivityRule.launchActivity(Intent.createChooser(sendIntent, null)); + waitForIdle(); + onView(withText(sharedText)) + .check(matches(isDisplayed())); + } + + @Test + public void test_shareImageWithRichText_RichTextIsDisplayed() { + final Uri uri = createTestContentProviderUri("image/png", null); + final CharSequence sharedText = new SpannableStringBuilder() + .append( + "text-", + new StyleSpan(Typeface.BOLD_ITALIC), + Spannable.SPAN_INCLUSIVE_EXCLUSIVE) + .append( + Long.toString(System.currentTimeMillis()), + new ForegroundColorSpan(Color.RED), + Spanned.SPAN_INCLUSIVE_EXCLUSIVE); + + ArrayList<Uri> uris = new ArrayList<>(); + uris.add(uri); + + Intent sendIntent = createSendUriIntentWithPreview(uris); + sendIntent.putExtra(Intent.EXTRA_TEXT, sharedText); + ChooserActivityOverrideData.getInstance().imageLoader = + createImageLoader(uri, createBitmap()); + + List<ResolvedComponentInfo> resolvedComponentInfos = createResolvedComponentsForTest(2); + + setupResolverControllers(resolvedComponentInfos); + mActivityRule.launchActivity(Intent.createChooser(sendIntent, null)); + waitForIdle(); + onView(withText(sharedText.toString())) + .check(matches(isDisplayed())) + .check((view, e) -> { + if (e != null) { + throw e; + } + assertThat(view).isInstanceOf(TextView.class); + CharSequence text = ((TextView) view).getText(); + assertThat(text).isInstanceOf(Spanned.class); + Spanned spanned = (Spanned) text; + Object[] spans = spanned.getSpans(0, text.length(), Object.class); + assertThat(spans).hasLength(2); + assertThat(spanned.getSpans(0, 5, StyleSpan.class)).hasLength(1); + assertThat(spanned.getSpans(5, text.length(), ForegroundColorSpan.class)) + .hasLength(1); + }); + } + + @Test + public void testTextPreviewWhenTextIsSharedWithMultipleImages() { + final Uri uri = createTestContentProviderUri("image/png", null); + final String sharedText = "text-" + System.currentTimeMillis(); + + ArrayList<Uri> uris = new ArrayList<>(); + uris.add(uri); + uris.add(uri); + + Intent sendIntent = createSendUriIntentWithPreview(uris); + sendIntent.putExtra(Intent.EXTRA_TEXT, sharedText); + ChooserActivityOverrideData.getInstance().imageLoader = + createImageLoader(uri, createBitmap()); + + List<ResolvedComponentInfo> resolvedComponentInfos = createResolvedComponentsForTest(2); + + when( + ChooserActivityOverrideData + .getInstance() + .resolverListController + .getResolversForIntentAsUser( + Mockito.anyBoolean(), + Mockito.anyBoolean(), + Mockito.anyBoolean(), + Mockito.isA(List.class), + Mockito.any(UserHandle.class))) + .thenReturn(resolvedComponentInfos); + mActivityRule.launchActivity(Intent.createChooser(sendIntent, null)); + waitForIdle(); + onView(withText(sharedText)).check(matches(isDisplayed())); + } + + @Test + public void testOnCreateLogging() { + Intent sendIntent = createSendTextIntent(); + sendIntent.setType(TEST_MIME_TYPE); + + ChooserWrapperActivity activity = + mActivityRule.launchActivity(Intent.createChooser(sendIntent, "logger test")); + waitForIdle(); + + FakeEventLog eventLog = getEventLog(activity); + FakeEventLog.ChooserActivityShown event = eventLog.getChooserActivityShown(); + assertThat(event).isNotNull(); + assertThat(event.isWorkProfile()).isFalse(); + assertThat(event.getTargetMimeType()).isEqualTo(TEST_MIME_TYPE); + } + + @Test + public void testOnCreateLoggingFromWorkProfile() { + Intent sendIntent = createSendTextIntent(); + sendIntent.setType(TEST_MIME_TYPE); + ChooserActivityOverrideData.getInstance().alternateProfileSetting = + MetricsEvent.MANAGED_PROFILE; + + ChooserWrapperActivity activity = + mActivityRule.launchActivity(Intent.createChooser(sendIntent, "logger test")); + waitForIdle(); + + FakeEventLog eventLog = getEventLog(activity); + FakeEventLog.ChooserActivityShown event = eventLog.getChooserActivityShown(); + assertThat(event).isNotNull(); + assertThat(event.isWorkProfile()).isTrue(); + assertThat(event.getTargetMimeType()).isEqualTo(TEST_MIME_TYPE); + } + + @Test + public void testEmptyPreviewLogging() { + Intent sendIntent = createSendTextIntentWithPreview(null, null); + + ChooserWrapperActivity activity = + mActivityRule.launchActivity(Intent.createChooser(sendIntent, + "empty preview logger test")); + waitForIdle(); + + FakeEventLog eventLog = getEventLog(activity); + FakeEventLog.ChooserActivityShown event = eventLog.getChooserActivityShown(); + assertThat(event).isNotNull(); + assertThat(event.isWorkProfile()).isFalse(); + assertThat(event.getTargetMimeType()).isNull(); + } + + @Test + public void testTitlePreviewLogging() { + Intent sendIntent = createSendTextIntentWithPreview("TestTitle", null); + + List<ResolvedComponentInfo> resolvedComponentInfos = createResolvedComponentsForTest(2); + + setupResolverControllers(resolvedComponentInfos); + + ChooserWrapperActivity activity = + mActivityRule.launchActivity(Intent.createChooser(sendIntent, null)); + waitForIdle(); + + FakeEventLog eventLog = getEventLog(activity); + assertThat(eventLog.getActionShareWithPreview()) + .isEqualTo(new FakeEventLog.ActionShareWithPreview( + /* previewType = */ CONTENT_PREVIEW_TEXT)); + } + + @Test + public void testImagePreviewLogging() { + Uri uri = createTestContentProviderUri("image/png", null); + + ArrayList<Uri> uris = new ArrayList<>(); + uris.add(uri); + + Intent sendIntent = createSendUriIntentWithPreview(uris); + ChooserActivityOverrideData.getInstance().imageLoader = + createImageLoader(uri, createBitmap()); + + List<ResolvedComponentInfo> resolvedComponentInfos = createResolvedComponentsForTest(2); + + setupResolverControllers(resolvedComponentInfos); + + ChooserWrapperActivity activity = + mActivityRule.launchActivity(Intent.createChooser(sendIntent, null)); + waitForIdle(); + + FakeEventLog eventLog = getEventLog(activity); + assertThat(eventLog.getActionShareWithPreview()) + .isEqualTo(new FakeEventLog.ActionShareWithPreview( + /* previewType = */ CONTENT_PREVIEW_IMAGE)); + } + + @Test + public void oneVisibleFilePreview() throws InterruptedException { + Uri uri = Uri.parse("content://com.android.frameworks.coretests/app.pdf"); + + ArrayList<Uri> uris = new ArrayList<>(); + uris.add(uri); + + Intent sendIntent = createSendUriIntentWithPreview(uris); + + List<ResolvedComponentInfo> resolvedComponentInfos = createResolvedComponentsForTest(2); + + setupResolverControllers(resolvedComponentInfos); + mActivityRule.launchActivity(Intent.createChooser(sendIntent, null)); + waitForIdle(); + onView(withId(R.id.content_preview_filename)).check(matches(isDisplayed())); + onView(withId(R.id.content_preview_filename)).check(matches(withText("app.pdf"))); + onView(withId(R.id.content_preview_file_icon)).check(matches(isDisplayed())); + } + + + @Test + public void moreThanOneVisibleFilePreview() throws InterruptedException { + Uri uri = Uri.parse("content://com.android.frameworks.coretests/app.pdf"); + + ArrayList<Uri> uris = new ArrayList<>(); + uris.add(uri); + uris.add(uri); + uris.add(uri); + + Intent sendIntent = createSendUriIntentWithPreview(uris); + + List<ResolvedComponentInfo> resolvedComponentInfos = createResolvedComponentsForTest(2); + + setupResolverControllers(resolvedComponentInfos); + mActivityRule.launchActivity(Intent.createChooser(sendIntent, null)); + waitForIdle(); + onView(withId(R.id.content_preview_filename)).check(matches(isDisplayed())); + onView(withId(R.id.content_preview_filename)).check(matches(withText("app.pdf"))); + onView(withId(R.id.content_preview_more_files)).check(matches(isDisplayed())); + onView(withId(R.id.content_preview_more_files)).check(matches(withText("+ 2 more files"))); + onView(withId(R.id.content_preview_file_icon)).check(matches(isDisplayed())); + } + + @Test + public void contentProviderThrowSecurityException() throws InterruptedException { + Uri uri = Uri.parse("content://com.android.frameworks.coretests/app.pdf"); + + ArrayList<Uri> uris = new ArrayList<>(); + uris.add(uri); + + Intent sendIntent = createSendUriIntentWithPreview(uris); + + List<ResolvedComponentInfo> resolvedComponentInfos = createResolvedComponentsForTest(2); + setupResolverControllers(resolvedComponentInfos); + + ChooserActivityOverrideData.getInstance().resolverForceException = true; + + mActivityRule.launchActivity(Intent.createChooser(sendIntent, null)); + waitForIdle(); + onView(withId(R.id.content_preview_filename)).check(matches(isDisplayed())); + onView(withId(R.id.content_preview_filename)).check(matches(withText("app.pdf"))); + onView(withId(R.id.content_preview_file_icon)).check(matches(isDisplayed())); + } + + @Test + public void contentProviderReturnsNoColumns() throws InterruptedException { + Uri uri = Uri.parse("content://com.android.frameworks.coretests/app.pdf"); + + ArrayList<Uri> uris = new ArrayList<>(); + uris.add(uri); + uris.add(uri); + + Intent sendIntent = createSendUriIntentWithPreview(uris); + + List<ResolvedComponentInfo> resolvedComponentInfos = createResolvedComponentsForTest(2); + setupResolverControllers(resolvedComponentInfos); + + Cursor cursor = mock(Cursor.class); + when(cursor.getCount()).thenReturn(1); + Mockito.doNothing().when(cursor).close(); + when(cursor.moveToFirst()).thenReturn(true); + when(cursor.getColumnIndex(Mockito.anyString())).thenReturn(-1); + + ChooserActivityOverrideData.getInstance().resolverCursor = cursor; + + mActivityRule.launchActivity(Intent.createChooser(sendIntent, null)); + waitForIdle(); + onView(withId(R.id.content_preview_filename)).check(matches(isDisplayed())); + onView(withId(R.id.content_preview_filename)).check(matches(withText("app.pdf"))); + onView(withId(R.id.content_preview_more_files)).check(matches(isDisplayed())); + onView(withId(R.id.content_preview_more_files)).check(matches(withText("+ 1 more file"))); + onView(withId(R.id.content_preview_file_icon)).check(matches(isDisplayed())); + } + + @Test + public void testGetBaseScore() { + final float testBaseScore = 0.89f; + + Intent sendIntent = createSendTextIntent(); + List<ResolvedComponentInfo> resolvedComponentInfos = createResolvedComponentsForTest(2); + + setupResolverControllers(resolvedComponentInfos); + + when( + ChooserActivityOverrideData + .getInstance() + .resolverListController + .getScore(Mockito.isA(DisplayResolveInfo.class))) + .thenReturn(testBaseScore); + + final IChooserWrapper activity = (IChooserWrapper) + mActivityRule.launchActivity(Intent.createChooser(sendIntent, null)); + waitForIdle(); + + final DisplayResolveInfo testDri = + activity.createTestDisplayResolveInfo( + sendIntent, + ResolverDataProvider.createResolveInfo(3, 0, PERSONAL_USER_HANDLE), + "testLabel", + "testInfo", + sendIntent); + final ChooserListAdapter adapter = activity.getAdapter(); + + assertThat(adapter.getBaseScore(null, 0), is(CALLER_TARGET_SCORE_BOOST)); + assertThat(adapter.getBaseScore(testDri, TARGET_TYPE_DEFAULT), is(testBaseScore)); + assertThat(adapter.getBaseScore(testDri, TARGET_TYPE_CHOOSER_TARGET), is(testBaseScore)); + assertThat(adapter.getBaseScore(testDri, TARGET_TYPE_SHORTCUTS_FROM_PREDICTION_SERVICE), + is(testBaseScore * SHORTCUT_TARGET_SCORE_BOOST)); + assertThat(adapter.getBaseScore(testDri, TARGET_TYPE_SHORTCUTS_FROM_SHORTCUT_MANAGER), + is(testBaseScore * SHORTCUT_TARGET_SCORE_BOOST)); + } + + // This test is too long and too slow and should not be taken as an example for future tests. + @Test + public void testDirectTargetSelectionLogging() { + Intent sendIntent = createSendTextIntent(); + // We need app targets for direct targets to get displayed + List<ResolvedComponentInfo> resolvedComponentInfos = createResolvedComponentsForTest(2); + setupResolverControllers(resolvedComponentInfos); + + // create test shortcut loader factory, remember loaders and their callbacks + SparseArray<Pair<ShortcutLoader, Consumer<ShortcutLoader.Result>>> shortcutLoaders = + createShortcutLoaderFactory(); + + // Start activity + ChooserWrapperActivity activity = + mActivityRule.launchActivity(Intent.createChooser(sendIntent, null)); + waitForIdle(); + + // verify that ShortcutLoader was queried + ArgumentCaptor<DisplayResolveInfo[]> appTargets = + ArgumentCaptor.forClass(DisplayResolveInfo[].class); + verify(shortcutLoaders.get(0).first, times(1)).updateAppTargets(appTargets.capture()); + + // 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(); + + 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(); + onView(withText(name)) + .perform(click()); + waitForIdle(); + + FakeEventLog eventLog = getEventLog(activity); + assertThat(eventLog.getShareTargetSelected()).hasSize(1); + FakeEventLog.ShareTargetSelected call = eventLog.getShareTargetSelected().get(0); + assertThat(call.getTargetType()).isEqualTo(EventLog.SELECTION_TYPE_SERVICE); + assertThat(call.getDirectTargetAlsoRanked()).isEqualTo(-1); + var hashResult = call.getDirectTargetHashed(); + var hash = hashResult == null ? "" : hashResult.hashedString; + assertWithMessage("Hash is not predictable but must be obfuscated") + .that(hash).isNotEqualTo(name); + } + + // This test is too long and too slow and should not be taken as an example for future tests. + @Test + public void testDirectTargetLoggingWithRankedAppTarget() { + Intent sendIntent = createSendTextIntent(); + // We need app targets for direct targets to get displayed + List<ResolvedComponentInfo> resolvedComponentInfos = createResolvedComponentsForTest(2); + setupResolverControllers(resolvedComponentInfos); + + // create test shortcut loader factory, remember loaders and their callbacks + SparseArray<Pair<ShortcutLoader, Consumer<ShortcutLoader.Result>>> shortcutLoaders = + createShortcutLoaderFactory(); + + // Start activity + ChooserWrapperActivity activity = + mActivityRule.launchActivity(Intent.createChooser(sendIntent, null)); + waitForIdle(); + + // verify that ShortcutLoader was queried + ArgumentCaptor<DisplayResolveInfo[]> appTargets = + ArgumentCaptor.forClass(DisplayResolveInfo[].class); + verify(shortcutLoaders.get(0).first, times(1)).updateAppTargets(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<>() + ); + activity.getMainExecutor().execute(() -> shortcutLoaders.get(0).second.accept(result)); + waitForIdle(); + + 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(); + onView(withText(name)) + .perform(click()); + waitForIdle(); + + FakeEventLog eventLog = getEventLog(activity); + assertThat(eventLog.getShareTargetSelected()).hasSize(1); + FakeEventLog.ShareTargetSelected call = eventLog.getShareTargetSelected().get(0); + + assertThat(call.getTargetType()).isEqualTo(EventLog.SELECTION_TYPE_SERVICE); + assertThat(call.getDirectTargetAlsoRanked()).isEqualTo(0); + } + + @Test + public void testShortcutTargetWithApplyAppLimits() { + // Set up resources + Resources resources = Mockito.spy( + InstrumentationRegistry.getInstrumentation().getContext().getResources()); + ChooserActivityOverrideData.getInstance().resources = resources; + doReturn(1).when(resources).getInteger(R.integer.config_maxShortcutTargetsPerApp); + Intent sendIntent = createSendTextIntent(); + // We need app targets for direct targets to get displayed + List<ResolvedComponentInfo> resolvedComponentInfos = createResolvedComponentsForTest(2); + setupResolverControllers(resolvedComponentInfos); + + // 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(); + + // verify that ShortcutLoader was queried + ArgumentCaptor<DisplayResolveInfo[]> appTargets = + ArgumentCaptor.forClass(DisplayResolveInfo[].class); + verify(shortcutLoaders.get(0).first, times(1)).updateAppTargets(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<>() + ); + activity.getMainExecutor().execute(() -> shortcutLoaders.get(0).second.accept(result)); + waitForIdle(); + + 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 + public void testShortcutTargetWithoutApplyAppLimits() { + setDeviceConfigProperty( + SystemUiDeviceConfigFlags.APPLY_SHARING_APP_LIMITS_IN_SYSUI, + Boolean.toString(false)); + // Set up resources + Resources resources = Mockito.spy( + InstrumentationRegistry.getInstrumentation().getContext().getResources()); + ChooserActivityOverrideData.getInstance().resources = resources; + doReturn(1).when(resources).getInteger(R.integer.config_maxShortcutTargetsPerApp); + Intent sendIntent = createSendTextIntent(); + // We need app targets for direct targets to get displayed + List<ResolvedComponentInfo> resolvedComponentInfos = createResolvedComponentsForTest(2); + setupResolverControllers(resolvedComponentInfos); + + // 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(); + + // verify that ShortcutLoader was queried + ArgumentCaptor<DisplayResolveInfo[]> appTargets = + ArgumentCaptor.forClass(DisplayResolveInfo[].class); + verify(shortcutLoaders.get(0).first, times(1)).updateAppTargets(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<>() + ); + activity.getMainExecutor().execute(() -> shortcutLoaders.get(0).second.accept(result)); + waitForIdle(); + + 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 + public void testLaunchWithCallerProvidedTarget() { + setDeviceConfigProperty( + SystemUiDeviceConfigFlags.APPLY_SHARING_APP_LIMITS_IN_SYSUI, + Boolean.toString(false)); + // Set up resources + Resources resources = Mockito.spy( + InstrumentationRegistry.getInstrumentation().getContext().getResources()); + ChooserActivityOverrideData.getInstance().resources = resources; + doReturn(1).when(resources).getInteger(R.integer.config_maxShortcutTargetsPerApp); + + // We need app targets for direct targets to get displayed + List<ResolvedComponentInfo> resolvedComponentInfos = createResolvedComponentsForTest(2); + setupResolverControllers(resolvedComponentInfos, resolvedComponentInfos); + markOtherProfileAvailability(/* workAvailable= */ true, /* cloneAvailable= */ false); + + // set caller-provided target + Intent chooserIntent = Intent.createChooser(createSendTextIntent(), null); + String callerTargetLabel = "Caller Target"; + ChooserTarget[] targets = new ChooserTarget[] { + new ChooserTarget( + callerTargetLabel, + Icon.createWithBitmap(createBitmap()), + 0.1f, + resolvedComponentInfos.get(0).name, + new Bundle()) + }; + chooserIntent.putExtra(Intent.EXTRA_CHOOSER_TARGETS, targets); + + // 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(chooserIntent); + waitForIdle(); + + // verify that ShortcutLoader was queried + ArgumentCaptor<DisplayResolveInfo[]> appTargets = + ArgumentCaptor.forClass(DisplayResolveInfo[].class); + verify(shortcutLoaders.get(0).first, times(1)).updateAppTargets(appTargets.capture()); + + // send shortcuts + assertThat( + "Wrong number of app targets", + appTargets.getValue().length, + is(resolvedComponentInfos.size())); + ShortcutLoader.Result result = new ShortcutLoader.Result( + true, + appTargets.getValue(), + new ShortcutLoader.ShortcutResultInfo[0], + new HashMap<>(), + new HashMap<>()); + activity.getMainExecutor().execute(() -> shortcutLoaders.get(0).second.accept(result)); + waitForIdle(); + + final ChooserListAdapter activeAdapter = activity.getAdapter(); + assertThat( + "Chooser should have 3 targets (2 apps, 1 direct)", + activeAdapter.getCount(), + is(3)); + assertThat( + "Chooser should have exactly two selectable direct target", + activeAdapter.getSelectableServiceTargetCount(), + is(1)); + assertThat( + "The display label must match", + activeAdapter.getItem(0).getDisplayLabel(), + is(callerTargetLabel)); + + // Switch to work profile and ensure that the target *doesn't* show up there. + onView(withText(R.string.resolver_work_tab)).perform(click()); + waitForIdle(); + + for (int i = 0; i < activity.getWorkListAdapter().getCount(); i++) { + assertThat( + "Chooser target should not show up in opposite profile", + activity.getWorkListAdapter().getItem(i).getDisplayLabel(), + not(callerTargetLabel)); + } + } + + @Test + public void testLaunchWithCustomAction() throws InterruptedException { + List<ResolvedComponentInfo> resolvedComponentInfos = createResolvedComponentsForTest(2); + setupResolverControllers(resolvedComponentInfos); + + Context testContext = InstrumentationRegistry.getInstrumentation().getContext(); + final String customActionLabel = "Custom Action"; + final String testAction = "test-broadcast-receiver-action"; + Intent chooserIntent = Intent.createChooser(createSendTextIntent(), null); + chooserIntent.putExtra( + Intent.EXTRA_CHOOSER_CUSTOM_ACTIONS, + new ChooserAction[] { + new ChooserAction.Builder( + Icon.createWithResource("", Resources.ID_NULL), + customActionLabel, + PendingIntent.getBroadcast( + testContext, + 123, + new Intent(testAction), + PendingIntent.FLAG_IMMUTABLE | PendingIntent.FLAG_ONE_SHOT)) + .build() + }); + // Start activity + mActivityRule.launchActivity(chooserIntent); + waitForIdle(); + + final CountDownLatch broadcastInvoked = new CountDownLatch(1); + BroadcastReceiver testReceiver = new BroadcastReceiver() { + @Override + public void onReceive(Context context, Intent intent) { + broadcastInvoked.countDown(); + } + }; + testContext.registerReceiver(testReceiver, new IntentFilter(testAction), + Context.RECEIVER_EXPORTED); + + try { + onView(withText(customActionLabel)).perform(click()); + assertTrue("Timeout waiting for broadcast", + broadcastInvoked.await(5000, TimeUnit.MILLISECONDS)); + } finally { + testContext.unregisterReceiver(testReceiver); + } + } + + @Test + public void testLaunchWithShareModification() throws InterruptedException { + List<ResolvedComponentInfo> resolvedComponentInfos = createResolvedComponentsForTest(2); + setupResolverControllers(resolvedComponentInfos); + + Context testContext = InstrumentationRegistry.getInstrumentation().getContext(); + final String modifyShareAction = "test-broadcast-receiver-action"; + Intent chooserIntent = Intent.createChooser(createSendTextIntent(), null); + String label = "modify share"; + PendingIntent pendingIntent = PendingIntent.getBroadcast( + testContext, + 123, + new Intent(modifyShareAction), + PendingIntent.FLAG_IMMUTABLE | PendingIntent.FLAG_ONE_SHOT); + ChooserAction action = new ChooserAction.Builder(Icon.createWithBitmap( + createBitmap()), label, pendingIntent).build(); + chooserIntent.putExtra( + Intent.EXTRA_CHOOSER_MODIFY_SHARE_ACTION, + action); + // Start activity + mActivityRule.launchActivity(chooserIntent); + waitForIdle(); + + final CountDownLatch broadcastInvoked = new CountDownLatch(1); + BroadcastReceiver testReceiver = new BroadcastReceiver() { + @Override + public void onReceive(Context context, Intent intent) { + broadcastInvoked.countDown(); + } + }; + testContext.registerReceiver(testReceiver, new IntentFilter(modifyShareAction), + Context.RECEIVER_EXPORTED); + + try { + onView(withText(label)).perform(click()); + assertTrue("Timeout waiting for broadcast", + broadcastInvoked.await(5000, TimeUnit.MILLISECONDS)); + + } finally { + testContext.unregisterReceiver(testReceiver); + } + } + + @Test + public void testUpdateMaxTargetsPerRow_columnCountIsUpdated() throws InterruptedException { + updateMaxTargetsPerRowResource(/* targetsPerRow= */ 4); + givenAppTargets(/* appCount= */ 16); + Intent sendIntent = createSendTextIntent(); + final ChooserActivity activity = + mActivityRule.launchActivity(Intent.createChooser(sendIntent, null)); + + updateMaxTargetsPerRowResource(/* targetsPerRow= */ 6); + InstrumentationRegistry.getInstrumentation() + .runOnMainSync(() -> activity.onConfigurationChanged( + InstrumentationRegistry.getInstrumentation() + .getContext().getResources().getConfiguration())); + + waitForIdle(); + onView(withId(com.android.internal.R.id.resolver_list)) + .check(matches(withGridColumnCount(6))); + } + + // This test is too long and too slow and should not be taken as an example for future tests. + @Test @Ignore + public void testDirectTargetLoggingWithAppTargetNotRankedPortrait() + throws InterruptedException { + testDirectTargetLoggingWithAppTargetNotRanked(Configuration.ORIENTATION_PORTRAIT, 4); + } + + @Test @Ignore + public void testDirectTargetLoggingWithAppTargetNotRankedLandscape() + throws InterruptedException { + testDirectTargetLoggingWithAppTargetNotRanked(Configuration.ORIENTATION_LANDSCAPE, 8); + } + + private void testDirectTargetLoggingWithAppTargetNotRanked( + int orientation, int appTargetsExpected) { + Configuration configuration = + new Configuration(InstrumentationRegistry.getInstrumentation().getContext() + .getResources().getConfiguration()); + configuration.orientation = orientation; + + Resources resources = Mockito.spy( + InstrumentationRegistry.getInstrumentation().getContext().getResources()); + ChooserActivityOverrideData.getInstance().resources = resources; + doReturn(configuration).when(resources).getConfiguration(); + + Intent sendIntent = createSendTextIntent(); + // We need app targets for direct targets to get displayed + List<ResolvedComponentInfo> resolvedComponentInfos = createResolvedComponentsForTest(15); + setupResolverControllers(resolvedComponentInfos); + + // Create direct share target + List<ChooserTarget> serviceTargets = createDirectShareTargets(1, + resolvedComponentInfos.get(14).getResolveInfoAt(0).activityInfo.packageName); + ResolveInfo ri = ResolverDataProvider.createResolveInfo(16, 0, PERSONAL_USER_HANDLE); + + // Start activity + ChooserWrapperActivity activity = + mActivityRule.launchActivity(Intent.createChooser(sendIntent, null)); + // 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), + serviceTargets, + TARGET_TYPE_CHOOSER_TARGET, + directShareToShortcutInfos, + /* directShareToAppTargets */ null) + ); + + assertThat( + String.format("Chooser should have %d targets (%d apps, 1 direct, 15 A-Z)", + appTargetsExpected + 16, appTargetsExpected), + activity.getAdapter().getCount(), is(appTargetsExpected + 16)); + 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)); + + // Click on the direct target + String name = serviceTargets.get(0).getTitle().toString(); + onView(withText(name)) + .perform(click()); + waitForIdle(); + + FakeEventLog eventLog = getEventLog(activity); + var invocations = eventLog.getShareTargetSelected(); + assertWithMessage("Only one ShareTargetSelected event logged") + .that(invocations).hasSize(1); + FakeEventLog.ShareTargetSelected call = invocations.get(0); + assertWithMessage("targetType should be SELECTION_TYPE_SERVICE") + .that(call.getTargetType()).isEqualTo(EventLog.SELECTION_TYPE_SERVICE); + assertWithMessage( + "The packages shouldn't match for app target and direct target") + .that(call.getDirectTargetAlsoRanked()).isEqualTo(-1); + } + + @Test + public void testWorkTab_displayedWhenWorkProfileUserAvailable() { + Intent sendIntent = createSendTextIntent(); + sendIntent.setType(TEST_MIME_TYPE); + markOtherProfileAvailability(/* workAvailable= */ true, /* cloneAvailable= */ false); + + mActivityRule.launchActivity(Intent.createChooser(sendIntent, "work tab test")); + waitForIdle(); + + onView(withId(android.R.id.tabs)).check(matches(isDisplayed())); + } + + @Test + public void testWorkTab_hiddenWhenWorkProfileUserNotAvailable() { + Intent sendIntent = createSendTextIntent(); + sendIntent.setType(TEST_MIME_TYPE); + + mActivityRule.launchActivity(Intent.createChooser(sendIntent, "work tab test")); + waitForIdle(); + + onView(withId(android.R.id.tabs)).check(matches(not(isDisplayed()))); + } + + @Test + public void testWorkTab_eachTabUsesExpectedAdapter() { + int personalProfileTargets = 3; + int otherProfileTargets = 1; + List<ResolvedComponentInfo> personalResolvedComponentInfos = + createResolvedComponentsForTestWithOtherProfile( + personalProfileTargets + otherProfileTargets, /* userID */ 10); + int workProfileTargets = 4; + List<ResolvedComponentInfo> workResolvedComponentInfos = createResolvedComponentsForTest( + workProfileTargets); + setupResolverControllers(personalResolvedComponentInfos, workResolvedComponentInfos); + Intent sendIntent = createSendTextIntent(); + sendIntent.setType(TEST_MIME_TYPE); + markOtherProfileAvailability(/* workAvailable= */ true, /* cloneAvailable= */ false); + + final IChooserWrapper activity = (IChooserWrapper) + mActivityRule.launchActivity(Intent.createChooser(sendIntent, "work tab test")); + waitForIdle(); + + assertThat(activity.getCurrentUserHandle().getIdentifier(), is(0)); + 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)); + } + + @Test + public void testWorkTab_workProfileHasExpectedNumberOfTargets() throws InterruptedException { + markOtherProfileAvailability(/* workAvailable= */ true, /* cloneAvailable= */ false); + int workProfileTargets = 4; + List<ResolvedComponentInfo> personalResolvedComponentInfos = + createResolvedComponentsForTestWithOtherProfile(3, /* userId */ 10); + List<ResolvedComponentInfo> workResolvedComponentInfos = + createResolvedComponentsForTest(workProfileTargets); + setupResolverControllers(personalResolvedComponentInfos, workResolvedComponentInfos); + Intent sendIntent = createSendTextIntent(); + sendIntent.setType(TEST_MIME_TYPE); + + final IChooserWrapper activity = (IChooserWrapper) + mActivityRule.launchActivity(Intent.createChooser(sendIntent, "work tab test")); + waitForIdle(); + onView(withText(R.string.resolver_work_tab)).perform(click()); + waitForIdle(); + + assertThat(activity.getWorkListAdapter().getCount(), is(workProfileTargets)); + } + + @Test @Ignore + public void testWorkTab_selectingWorkTabAppOpensAppInWorkProfile() { + markOtherProfileAvailability(/* workAvailable= */ true, /* cloneAvailable= */ false); + List<ResolvedComponentInfo> personalResolvedComponentInfos = + createResolvedComponentsForTestWithOtherProfile(3, /* userId */ 10); + int workProfileTargets = 4; + List<ResolvedComponentInfo> workResolvedComponentInfos = + createResolvedComponentsForTest(workProfileTargets); + setupResolverControllers(personalResolvedComponentInfos, workResolvedComponentInfos); + Intent sendIntent = createSendTextIntent(); + sendIntent.setType(TEST_MIME_TYPE); + ResolveInfo[] chosen = new ResolveInfo[1]; + ChooserActivityOverrideData.getInstance().onSafelyStartInternalCallback = targetInfo -> { + chosen[0] = targetInfo.getResolveInfo(); + return true; + }; + + mActivityRule.launchActivity(Intent.createChooser(sendIntent, "work tab test")); + 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()); + waitForIdle(); + assertThat(chosen[0], is(workResolvedComponentInfos.get(0).getResolveInfoAt(0))); + } + + @Test + public void testWorkTab_crossProfileIntentsDisabled_personalToWork_emptyStateShown() { + markOtherProfileAvailability(/* workAvailable= */ true, /* cloneAvailable= */ false); + int workProfileTargets = 4; + List<ResolvedComponentInfo> personalResolvedComponentInfos = + createResolvedComponentsForTestWithOtherProfile(3, /* userId */ 10); + List<ResolvedComponentInfo> workResolvedComponentInfos = + createResolvedComponentsForTest(workProfileTargets); + ChooserActivityOverrideData.getInstance().hasCrossProfileIntents = false; + setupResolverControllers(personalResolvedComponentInfos, workResolvedComponentInfos); + Intent sendIntent = createSendTextIntent(); + sendIntent.setType(TEST_MIME_TYPE); + + mActivityRule.launchActivity(Intent.createChooser(sendIntent, "work tab test")); + waitForIdle(); + onView(withText(R.string.resolver_work_tab)).perform(click()); + waitForIdle(); + onView(withId(com.android.internal.R.id.contentPanel)) + .perform(swipeUp()); + + onView(withText(R.string.resolver_cross_profile_blocked)) + .check(matches(isDisplayed())); + } + + @Test + public void testWorkTab_workProfileDisabled_emptyStateShown() { + markOtherProfileAvailability(/* workAvailable= */ true, /* cloneAvailable= */ false); + int workProfileTargets = 4; + List<ResolvedComponentInfo> personalResolvedComponentInfos = + createResolvedComponentsForTestWithOtherProfile(3, /* userId */ 10); + List<ResolvedComponentInfo> workResolvedComponentInfos = + createResolvedComponentsForTest(workProfileTargets); + ChooserActivityOverrideData.getInstance().isQuietModeEnabled = true; + setupResolverControllers(personalResolvedComponentInfos, workResolvedComponentInfos); + Intent sendIntent = createSendTextIntent(); + sendIntent.setType(TEST_MIME_TYPE); + + mActivityRule.launchActivity(Intent.createChooser(sendIntent, "work tab test")); + waitForIdle(); + onView(withId(com.android.internal.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() { + markOtherProfileAvailability(/* workAvailable= */ true, /* cloneAvailable= */ false); + List<ResolvedComponentInfo> personalResolvedComponentInfos = + createResolvedComponentsForTest(3); + List<ResolvedComponentInfo> workResolvedComponentInfos = + createResolvedComponentsForTest(0); + setupResolverControllers(personalResolvedComponentInfos, workResolvedComponentInfos); + Intent sendIntent = createSendTextIntent(); + sendIntent.setType(TEST_MIME_TYPE); + + mActivityRule.launchActivity(Intent.createChooser(sendIntent, "work tab test")); + waitForIdle(); + onView(withId(com.android.internal.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 + @RequiresFlagsEnabled(Flags.FLAG_SCROLLABLE_PREVIEW) + public void testWorkTab_previewIsScrollable() { + markOtherProfileAvailability(/* workAvailable= */ true, /* cloneAvailable= */ false); + List<ResolvedComponentInfo> personalResolvedComponentInfos = + createResolvedComponentsForTest(300); + List<ResolvedComponentInfo> workResolvedComponentInfos = + createResolvedComponentsForTest(3); + setupResolverControllers(personalResolvedComponentInfos, workResolvedComponentInfos); + + Uri uri = createTestContentProviderUri("image/png", null); + + ArrayList<Uri> uris = new ArrayList<>(); + uris.add(uri); + + Intent sendIntent = createSendUriIntentWithPreview(uris); + ChooserActivityOverrideData.getInstance().imageLoader = + createImageLoader(uri, createWideBitmap()); + + mActivityRule.launchActivity(Intent.createChooser(sendIntent, "Scrollable preview test")); + waitForIdle(); + + onView(withId(R.id.scrollable_image_preview)) + .check(matches(isDisplayed())); + + onView(withId(com.android.internal.R.id.contentPanel)).perform(swipeUp()); + waitForIdle(); + + onView(withId(R.id.chooser_headline_row_container)) + .check(matches(isCompletelyDisplayed())); + onView(withId(R.id.headline)) + .check(matches(isDisplayed())); + onView(withId(R.id.scrollable_image_preview)) + .check(matches(not(isDisplayed()))); + } + + @Ignore // b/220067877 + @Test + public void testWorkTab_xProfileOff_noAppsAvailable_workOff_xProfileOffEmptyStateShown() { + markOtherProfileAvailability(/* workAvailable= */ true, /* cloneAvailable= */ false); + List<ResolvedComponentInfo> personalResolvedComponentInfos = + createResolvedComponentsForTest(3); + List<ResolvedComponentInfo> workResolvedComponentInfos = + createResolvedComponentsForTest(0); + setupResolverControllers(personalResolvedComponentInfos, workResolvedComponentInfos); + ChooserActivityOverrideData.getInstance().isQuietModeEnabled = true; + ChooserActivityOverrideData.getInstance().hasCrossProfileIntents = false; + Intent sendIntent = createSendTextIntent(); + sendIntent.setType(TEST_MIME_TYPE); + + mActivityRule.launchActivity(Intent.createChooser(sendIntent, "work tab test")); + waitForIdle(); + onView(withId(com.android.internal.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 testWorkTab_noAppsAvailable_workOff_noAppsAvailableEmptyStateShown() { + markOtherProfileAvailability(/* workAvailable= */ true, /* cloneAvailable= */ false); + List<ResolvedComponentInfo> personalResolvedComponentInfos = + createResolvedComponentsForTest(3); + List<ResolvedComponentInfo> workResolvedComponentInfos = + createResolvedComponentsForTest(0); + setupResolverControllers(personalResolvedComponentInfos, workResolvedComponentInfos); + ChooserActivityOverrideData.getInstance().isQuietModeEnabled = true; + Intent sendIntent = createSendTextIntent(); + sendIntent.setType(TEST_MIME_TYPE); + + mActivityRule.launchActivity(Intent.createChooser(sendIntent, "work tab test")); + waitForIdle(); + onView(withId(com.android.internal.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 @Ignore("b/222124533") + public void testAppTargetLogging() throws InterruptedException { + Intent sendIntent = createSendTextIntent(); + List<ResolvedComponentInfo> resolvedComponentInfos = createResolvedComponentsForTest(2); + + setupResolverControllers(resolvedComponentInfos); + + final IChooserWrapper activity = (IChooserWrapper) + mActivityRule.launchActivity(Intent.createChooser(sendIntent, null)); + waitForIdle(); + + // TODO(b/222124533): other test cases use a timeout to make sure that the UI is fully + // populated; without one, this test flakes. Ideally we should address the need for a + // timeout everywhere instead of introducing one to fix this particular test. + + assertThat(activity.getAdapter().getCount(), is(2)); + onView(withId(com.android.internal.R.id.profile_button)).check(doesNotExist()); + + ResolveInfo[] chosen = new ResolveInfo[1]; + ChooserActivityOverrideData.getInstance().onSafelyStartInternalCallback = targetInfo -> { + chosen[0] = targetInfo.getResolveInfo(); + return true; + }; + + ResolveInfo toChoose = resolvedComponentInfos.get(0).getResolveInfoAt(0); + onView(withText(toChoose.activityInfo.name)) + .perform(click()); + waitForIdle(); + + // TODO(b/211669337): Determine the expected SHARESHEET_DIRECT_LOAD_COMPLETE events. + } + + @Test + public void testDirectTargetLogging() { + Intent sendIntent = createSendTextIntent(); + // We need app targets for direct targets to get displayed + List<ResolvedComponentInfo> resolvedComponentInfos = createResolvedComponentsForTest(2); + setupResolverControllers(resolvedComponentInfos); + + // create test shortcut loader factory, remember loaders and their callbacks + SparseArray<Pair<ShortcutLoader, Consumer<ShortcutLoader.Result>>> shortcutLoaders = + new SparseArray<>(); + ChooserActivityOverrideData.getInstance().shortcutLoaderFactory = + (userHandle, callback) -> { + Pair<ShortcutLoader, Consumer<ShortcutLoader.Result>> pair = + new Pair<>(mock(ShortcutLoader.class), callback); + shortcutLoaders.put(userHandle.getIdentifier(), pair); + return pair.first; + }; + + // Start activity + ChooserWrapperActivity activity = + mActivityRule.launchActivity(Intent.createChooser(sendIntent, null)); + waitForIdle(); + + // verify that ShortcutLoader was queried + ArgumentCaptor<DisplayResolveInfo[]> appTargets = + ArgumentCaptor.forClass(DisplayResolveInfo[].class); + verify(shortcutLoaders.get(0).first, times(1)) + .updateAppTargets(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<>() + ); + 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(resolvedComponentInfos.get(0).getResolveInfoAt(0))); + + // Click on the direct target + String name = serviceTargets.get(0).getTitle().toString(); + onView(withText(name)) + .perform(click()); + waitForIdle(); + + FakeEventLog eventLog = getEventLog(activity); + assertThat(eventLog.getShareTargetSelected()).hasSize(1); + FakeEventLog.ShareTargetSelected call = eventLog.getShareTargetSelected().get(0); + assertThat(call.getTargetType()).isEqualTo(EventLog.SELECTION_TYPE_SERVICE); + } + + @Test + public void testDirectTargetPinningDialog() { + Intent sendIntent = createSendTextIntent(); + // We need app targets for direct targets to get displayed + List<ResolvedComponentInfo> resolvedComponentInfos = createResolvedComponentsForTest(2); + setupResolverControllers(resolvedComponentInfos); + + // create test shortcut loader factory, remember loaders and their callbacks + SparseArray<Pair<ShortcutLoader, Consumer<ShortcutLoader.Result>>> shortcutLoaders = + new SparseArray<>(); + ChooserActivityOverrideData.getInstance().shortcutLoaderFactory = + (userHandle, callback) -> { + Pair<ShortcutLoader, Consumer<ShortcutLoader.Result>> pair = + new Pair<>(mock(ShortcutLoader.class), callback); + shortcutLoaders.put(userHandle.getIdentifier(), pair); + return pair.first; + }; + + // Start activity + final IChooserWrapper activity = (IChooserWrapper) + mActivityRule.launchActivity(Intent.createChooser(sendIntent, null)); + waitForIdle(); + + // verify that ShortcutLoader was queried + ArgumentCaptor<DisplayResolveInfo[]> appTargets = + ArgumentCaptor.forClass(DisplayResolveInfo[].class); + verify(shortcutLoaders.get(0).first, times(1)) + .updateAppTargets(appTargets.capture()); + + // send shortcuts + List<ChooserTarget> serviceTargets = createDirectShareTargets( + 1, + resolvedComponentInfos.get(0).getResolveInfoAt(0).activityInfo.packageName); + ShortcutLoader.Result result = new ShortcutLoader.Result( + // TODO: test another value as well + false, + appTargets.getValue(), + new ShortcutLoader.ShortcutResultInfo[] { + new ShortcutLoader.ShortcutResultInfo( + appTargets.getValue()[0], + serviceTargets + ) + }, + new HashMap<>(), + new HashMap<>() + ); + activity.getMainExecutor().execute(() -> shortcutLoaders.get(0).second.accept(result)); + waitForIdle(); + + // Long-click on the direct target + String name = serviceTargets.get(0).getTitle().toString(); + onView(withText(name)).perform(longClick()); + waitForIdle(); + + onView(withId(R.id.chooser_dialog_content)).check(matches(isDisplayed())); + } + + @Test @Ignore + public void testEmptyDirectRowLogging() throws InterruptedException { + Intent sendIntent = createSendTextIntent(); + // We need app targets for direct targets to get displayed + List<ResolvedComponentInfo> resolvedComponentInfos = createResolvedComponentsForTest(2); + setupResolverControllers(resolvedComponentInfos); + + // Start activity + final IChooserWrapper activity = (IChooserWrapper) + mActivityRule.launchActivity(Intent.createChooser(sendIntent, 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 + Thread.sleep(3000); + + assertThat("Chooser should have 2 app targets", + activity.getAdapter().getCount(), is(2)); + assertThat("Chooser should have no direct targets", + activity.getAdapter().getSelectableServiceTargetCount(), is(0)); + + // TODO(b/211669337): Determine the expected SHARESHEET_DIRECT_LOAD_COMPLETE events. + } + + @Ignore // b/220067877 + @Test + public void testCopyTextToClipboardLogging() throws Exception { + Intent sendIntent = createSendTextIntent(); + List<ResolvedComponentInfo> resolvedComponentInfos = createResolvedComponentsForTest(2); + + setupResolverControllers(resolvedComponentInfos); + + final IChooserWrapper activity = (IChooserWrapper) + mActivityRule.launchActivity(Intent.createChooser(sendIntent, null)); + waitForIdle(); + + 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. + } + + @Test @Ignore("b/222124533") + public void testSwitchProfileLogging() throws InterruptedException { + markOtherProfileAvailability(/* workAvailable= */ true, /* cloneAvailable= */ false); + int workProfileTargets = 4; + List<ResolvedComponentInfo> personalResolvedComponentInfos = + createResolvedComponentsForTestWithOtherProfile(3, /* userId */ 10); + List<ResolvedComponentInfo> workResolvedComponentInfos = + createResolvedComponentsForTest(workProfileTargets); + setupResolverControllers(personalResolvedComponentInfos, workResolvedComponentInfos); + Intent sendIntent = createSendTextIntent(); + sendIntent.setType(TEST_MIME_TYPE); + + final IChooserWrapper activity = (IChooserWrapper) + mActivityRule.launchActivity(Intent.createChooser(sendIntent, "work tab test")); + waitForIdle(); + onView(withText(R.string.resolver_work_tab)).perform(click()); + waitForIdle(); + onView(withText(R.string.resolver_personal_tab)).perform(click()); + waitForIdle(); + + // TODO(b/211669337): Determine the expected SHARESHEET_DIRECT_LOAD_COMPLETE events. + } + + @Test + public void testWorkTab_onePersonalTarget_emptyStateOnWorkTarget_doesNotAutoLaunch() { + markOtherProfileAvailability(/* workAvailable= */ true, /* cloneAvailable= */ false); + int workProfileTargets = 4; + List<ResolvedComponentInfo> personalResolvedComponentInfos = + createResolvedComponentsForTestWithOtherProfile(2, /* userId */ 10); + List<ResolvedComponentInfo> workResolvedComponentInfos = + createResolvedComponentsForTest(workProfileTargets); + ChooserActivityOverrideData.getInstance().hasCrossProfileIntents = false; + setupResolverControllers(personalResolvedComponentInfos, workResolvedComponentInfos); + Intent sendIntent = createSendTextIntent(); + ResolveInfo[] chosen = new ResolveInfo[1]; + ChooserActivityOverrideData.getInstance().onSafelyStartInternalCallback = targetInfo -> { + chosen[0] = targetInfo.getResolveInfo(); + return true; + }; + + mActivityRule.launchActivity(Intent.createChooser(sendIntent, "Test")); + waitForIdle(); + + assertNull(chosen[0]); + } + + @Test + public void testOneInitialIntent_noAutolaunch() { + List<ResolvedComponentInfo> personalResolvedComponentInfos = + createResolvedComponentsForTest(1); + setupResolverControllers(personalResolvedComponentInfos); + Intent chooserIntent = createChooserIntent(createSendTextIntent(), + new Intent[] {new Intent("action.fake")}); + ResolveInfo[] chosen = new ResolveInfo[1]; + ChooserActivityOverrideData.getInstance().onSafelyStartInternalCallback = targetInfo -> { + chosen[0] = targetInfo.getResolveInfo(); + return true; + }; + ChooserActivityOverrideData.getInstance().packageManager = mock(PackageManager.class); + ResolveInfo ri = createFakeResolveInfo(); + when( + ChooserActivityOverrideData + .getInstance().packageManager + .resolveActivity(any(Intent.class), any())) + .thenReturn(ri); + waitForIdle(); + + IChooserWrapper activity = (IChooserWrapper) mActivityRule.launchActivity(chooserIntent); + waitForIdle(); + + assertNull(chosen[0]); + assertThat(activity + .getPersonalListAdapter().getCallerTargetCount(), is(1)); + } + + @Test + public void testWorkTab_withInitialIntents_workTabDoesNotIncludePersonalInitialIntents() { + markOtherProfileAvailability(/* workAvailable= */ true, /* cloneAvailable= */ false); + int workProfileTargets = 1; + List<ResolvedComponentInfo> personalResolvedComponentInfos = + createResolvedComponentsForTestWithOtherProfile(2, /* userId */ 10); + List<ResolvedComponentInfo> workResolvedComponentInfos = + createResolvedComponentsForTest(workProfileTargets); + setupResolverControllers(personalResolvedComponentInfos, workResolvedComponentInfos); + Intent[] initialIntents = { + new Intent("action.fake1"), + new Intent("action.fake2") + }; + Intent chooserIntent = createChooserIntent(createSendTextIntent(), initialIntents); + ChooserActivityOverrideData.getInstance().packageManager = mock(PackageManager.class); + when( + ChooserActivityOverrideData + .getInstance() + .packageManager + .resolveActivity(any(Intent.class), any())) + .thenReturn(createFakeResolveInfo()); + waitForIdle(); + + IChooserWrapper activity = (IChooserWrapper) mActivityRule.launchActivity(chooserIntent); + waitForIdle(); + + assertThat(activity.getPersonalListAdapter().getCallerTargetCount(), is(2)); + assertThat(activity.getWorkListAdapter().getCallerTargetCount(), is(0)); + } + + @Test + public void testWorkTab_xProfileIntentsDisabled_personalToWork_nonSendIntent_emptyStateShown() { + markOtherProfileAvailability(/* workAvailable= */ true, /* cloneAvailable= */ false); + int workProfileTargets = 4; + List<ResolvedComponentInfo> personalResolvedComponentInfos = + createResolvedComponentsForTestWithOtherProfile(3, /* userId */ 10); + List<ResolvedComponentInfo> workResolvedComponentInfos = + createResolvedComponentsForTest(workProfileTargets); + ChooserActivityOverrideData.getInstance().hasCrossProfileIntents = false; + setupResolverControllers(personalResolvedComponentInfos, workResolvedComponentInfos); + Intent[] initialIntents = { + new Intent("action.fake1"), + new Intent("action.fake2") + }; + Intent chooserIntent = createChooserIntent(new Intent(), initialIntents); + ChooserActivityOverrideData.getInstance().packageManager = mock(PackageManager.class); + when( + ChooserActivityOverrideData + .getInstance() + .packageManager + .resolveActivity(any(Intent.class), any())) + .thenReturn(createFakeResolveInfo()); + + mActivityRule.launchActivity(chooserIntent); + waitForIdle(); + onView(withText(R.string.resolver_work_tab)).perform(click()); + waitForIdle(); + onView(withId(com.android.internal.R.id.contentPanel)) + .perform(swipeUp()); + + onView(withText(R.string.resolver_cross_profile_blocked)) + .check(matches(isDisplayed())); + } + + @Test + public void testWorkTab_noWorkAppsAvailable_nonSendIntent_emptyStateShown() { + markOtherProfileAvailability(/* workAvailable= */ true, /* cloneAvailable= */ false); + List<ResolvedComponentInfo> personalResolvedComponentInfos = + createResolvedComponentsForTest(3); + List<ResolvedComponentInfo> workResolvedComponentInfos = + createResolvedComponentsForTest(0); + setupResolverControllers(personalResolvedComponentInfos, workResolvedComponentInfos); + Intent[] initialIntents = { + new Intent("action.fake1"), + new Intent("action.fake2") + }; + Intent chooserIntent = createChooserIntent(new Intent(), initialIntents); + ChooserActivityOverrideData.getInstance().packageManager = mock(PackageManager.class); + when( + ChooserActivityOverrideData + .getInstance() + .packageManager + .resolveActivity(any(Intent.class), any())) + .thenReturn(createFakeResolveInfo()); + + mActivityRule.launchActivity(chooserIntent); + waitForIdle(); + onView(withId(com.android.internal.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 testDeduplicateCallerTargetRankedTarget() { + // Create 4 ranked app targets. + List<ResolvedComponentInfo> personalResolvedComponentInfos = + createResolvedComponentsForTest(4); + setupResolverControllers(personalResolvedComponentInfos); + // Create caller target which is duplicate with one of app targets + Intent chooserIntent = createChooserIntent(createSendTextIntent(), + new Intent[] {new Intent("action.fake")}); + ChooserActivityOverrideData.getInstance().packageManager = mock(PackageManager.class); + ResolveInfo ri = ResolverDataProvider.createResolveInfo(0, + UserHandle.USER_CURRENT, PERSONAL_USER_HANDLE); + when( + ChooserActivityOverrideData + .getInstance() + .packageManager + .resolveActivity(any(Intent.class), any())) + .thenReturn(ri); + waitForIdle(); + + IChooserWrapper activity = (IChooserWrapper) mActivityRule.launchActivity(chooserIntent); + waitForIdle(); + + // Total 4 targets (1 caller target, 3 ranked targets) + assertThat(activity.getAdapter().getCount(), is(4)); + assertThat(activity.getAdapter().getCallerTargetCount(), is(1)); + assertThat(activity.getAdapter().getRankedTargetCount(), is(3)); + } + + @Test + public void test_query_shortcut_loader_for_the_selected_tab() { + markOtherProfileAvailability(/* workAvailable= */ true, /* cloneAvailable= */ false); + List<ResolvedComponentInfo> personalResolvedComponentInfos = + createResolvedComponentsForTestWithOtherProfile(3, /* userId */ 10); + List<ResolvedComponentInfo> workResolvedComponentInfos = + createResolvedComponentsForTest(3); + setupResolverControllers(personalResolvedComponentInfos, workResolvedComponentInfos); + 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(withId(com.android.internal.R.id.contentPanel)) + .perform(swipeUp()); + waitForIdle(); + + verify(personalProfileShortcutLoader, times(1)).updateAppTargets(any()); + + onView(withText(R.string.resolver_work_tab)).perform(click()); + waitForIdle(); + + verify(workProfileShortcutLoader, times(1)).updateAppTargets(any()); + } + + @Test + public void testClonedProfilePresent_personalAdapterIsSetWithPersonalProfile() { + // enable cloneProfile + markOtherProfileAvailability(/* workAvailable= */ false, /* cloneAvailable= */ true); + List<ResolvedComponentInfo> resolvedComponentInfos = + createResolvedComponentsWithCloneProfileForTest( + 3, + PERSONAL_USER_HANDLE, + CLONE_PROFILE_USER_HANDLE); + setupResolverControllers(resolvedComponentInfos); + Intent sendIntent = createSendTextIntent(); + + final IChooserWrapper activity = (IChooserWrapper) mActivityRule + .launchActivity(Intent.createChooser(sendIntent, "personalProfileTest")); + waitForIdle(); + + assertThat(activity.getPersonalListAdapter().getUserHandle(), is(PERSONAL_USER_HANDLE)); + assertThat(activity.getAdapter().getCount(), is(3)); + } + + @Test + public void testClonedProfilePresent_personalTabUsesExpectedAdapter() { + markOtherProfileAvailability(/* workAvailable= */ true, /* cloneAvailable= */ true); + List<ResolvedComponentInfo> personalResolvedComponentInfos = + createResolvedComponentsForTest(3); + List<ResolvedComponentInfo> workResolvedComponentInfos = createResolvedComponentsForTest( + 4); + setupResolverControllers(personalResolvedComponentInfos, workResolvedComponentInfos); + Intent sendIntent = createSendTextIntent(); + sendIntent.setType(TEST_MIME_TYPE); + + + final IChooserWrapper activity = (IChooserWrapper) + mActivityRule.launchActivity(Intent.createChooser(sendIntent, "multi tab test")); + waitForIdle(); + + assertThat(activity.getCurrentUserHandle(), is(PERSONAL_USER_HANDLE)); + } + + private Intent createChooserIntent(Intent intent, Intent[] initialIntents) { + Intent chooserIntent = new Intent(); + chooserIntent.setAction(Intent.ACTION_CHOOSER); + chooserIntent.putExtra(Intent.EXTRA_TEXT, "testing intent sending"); + chooserIntent.putExtra(Intent.EXTRA_TITLE, "some title"); + chooserIntent.putExtra(Intent.EXTRA_INTENT, intent); + chooserIntent.setType("text/plain"); + if (initialIntents != null) { + chooserIntent.putExtra(Intent.EXTRA_INITIAL_INTENTS, initialIntents); + } + return chooserIntent; + } + + /* This is a "test of a test" to make sure that our inherited test class + * is successfully configured to operate on the unbundled-equivalent + * ChooserWrapperActivity. + * + * TODO: remove after unbundling is complete. + */ + @Test + public void testWrapperActivityHasExpectedConcreteType() { + final ChooserActivity activity = mActivityRule.launchActivity( + Intent.createChooser(new Intent("ACTION_FOO"), "foo")); + waitForIdle(); + assertThat(activity).isInstanceOf(ChooserWrapperActivity.class); + } + + private ResolveInfo createFakeResolveInfo() { + ResolveInfo ri = new ResolveInfo(); + ri.activityInfo = new ActivityInfo(); + ri.activityInfo.name = "FakeActivityName"; + ri.activityInfo.packageName = "fake.package.name"; + ri.activityInfo.applicationInfo = new ApplicationInfo(); + ri.activityInfo.applicationInfo.packageName = "fake.package.name"; + ri.userHandle = UserHandle.CURRENT; + return ri; + } + + private Intent createSendTextIntent() { + Intent sendIntent = new Intent(); + sendIntent.setAction(Intent.ACTION_SEND); + sendIntent.putExtra(Intent.EXTRA_TEXT, "testing intent sending"); + sendIntent.setType("text/plain"); + return sendIntent; + } + + private Intent createSendImageIntent(Uri imageThumbnail) { + Intent sendIntent = new Intent(); + sendIntent.setAction(Intent.ACTION_SEND); + sendIntent.putExtra(Intent.EXTRA_STREAM, imageThumbnail); + sendIntent.setType("image/png"); + if (imageThumbnail != null) { + ClipData.Item clipItem = new ClipData.Item(imageThumbnail); + sendIntent.setClipData(new ClipData("Clip Label", new String[]{"image/png"}, clipItem)); + } + + return sendIntent; + } + + private Uri createTestContentProviderUri( + @Nullable String mimeType, @Nullable String streamType) { + return createTestContentProviderUri(mimeType, streamType, 0); + } + + private Uri createTestContentProviderUri( + @Nullable String mimeType, @Nullable String streamType, long streamTypeTimeout) { + String packageName = + InstrumentationRegistry.getInstrumentation().getContext().getPackageName(); + Uri.Builder builder = Uri.parse("content://" + packageName + "/image.png") + .buildUpon(); + if (mimeType != null) { + builder.appendQueryParameter(TestContentProvider.PARAM_MIME_TYPE, mimeType); + } + if (streamType != null) { + builder.appendQueryParameter(TestContentProvider.PARAM_STREAM_TYPE, streamType); + } + if (streamTypeTimeout > 0) { + builder.appendQueryParameter( + TestContentProvider.PARAM_STREAM_TYPE_TIMEOUT, + Long.toString(streamTypeTimeout)); + } + return builder.build(); + } + + private Intent createSendTextIntentWithPreview(String title, Uri imageThumbnail) { + Intent sendIntent = new Intent(); + sendIntent.setAction(Intent.ACTION_SEND); + sendIntent.putExtra(Intent.EXTRA_TEXT, "testing intent sending"); + sendIntent.putExtra(Intent.EXTRA_TITLE, title); + if (imageThumbnail != null) { + ClipData.Item clipItem = new ClipData.Item(imageThumbnail); + sendIntent.setClipData(new ClipData("Clip Label", new String[]{"image/png"}, clipItem)); + } + + return sendIntent; + } + + private Intent createSendUriIntentWithPreview(ArrayList<Uri> uris) { + Intent sendIntent = new Intent(); + + if (uris.size() > 1) { + sendIntent.setAction(Intent.ACTION_SEND_MULTIPLE); + sendIntent.putExtra(Intent.EXTRA_STREAM, uris); + } else { + sendIntent.setAction(Intent.ACTION_SEND); + sendIntent.putExtra(Intent.EXTRA_STREAM, uris.get(0)); + } + + return sendIntent; + } + + private Intent createViewTextIntent() { + Intent viewIntent = new Intent(); + viewIntent.setAction(Intent.ACTION_VIEW); + viewIntent.putExtra(Intent.EXTRA_TEXT, "testing intent viewing"); + return viewIntent; + } + + private List<ResolvedComponentInfo> createResolvedComponentsForTest(int numberOfResults) { + List<ResolvedComponentInfo> infoList = new ArrayList<>(numberOfResults); + for (int i = 0; i < numberOfResults; i++) { + infoList.add(ResolverDataProvider.createResolvedComponentInfo(i, PERSONAL_USER_HANDLE)); + } + return infoList; + } + + private List<ResolvedComponentInfo> createResolvedComponentsWithCloneProfileForTest( + int numberOfResults, + UserHandle resolvedForPersonalUser, + UserHandle resolvedForClonedUser) { + List<ResolvedComponentInfo> infoList = new ArrayList<>(numberOfResults); + for (int i = 0; i < 1; i++) { + infoList.add(ResolverDataProvider.createResolvedComponentInfo(i, + resolvedForPersonalUser)); + } + for (int i = 1; i < numberOfResults; i++) { + infoList.add(ResolverDataProvider.createResolvedComponentInfo(i, + resolvedForClonedUser)); + } + 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, + PERSONAL_USER_HANDLE)); + } else { + infoList.add(ResolverDataProvider.createResolvedComponentInfo(i, + PERSONAL_USER_HANDLE)); + } + } + 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, + PERSONAL_USER_HANDLE)); + } else { + infoList.add(ResolverDataProvider.createResolvedComponentInfo(i, + PERSONAL_USER_HANDLE)); + } + } + return infoList; + } + + private List<ResolvedComponentInfo> createResolvedComponentsForTestWithUserId( + int numberOfResults, int userId) { + List<ResolvedComponentInfo> infoList = new ArrayList<>(numberOfResults); + for (int i = 0; i < numberOfResults; i++) { + infoList.add(ResolverDataProvider.createResolvedComponentInfoWithOtherId(i, userId, + PERSONAL_USER_HANDLE)); + } + return infoList; + } + + private List<ChooserTarget> createDirectShareTargets(int numberOfResults, String packageName) { + Icon icon = Icon.createWithBitmap(createBitmap()); + String testTitle = "testTitle"; + List<ChooserTarget> targets = new ArrayList<>(); + for (int i = 0; i < numberOfResults; i++) { + ComponentName componentName; + if (packageName.isEmpty()) { + componentName = ResolverDataProvider.createComponentName(i); + } else { + componentName = new ComponentName(packageName, packageName + ".class"); + } + ChooserTarget tempTarget = new ChooserTarget( + testTitle + i, + icon, + (float) (1 - ((i + 1) / 10.0)), + componentName, + null); + targets.add(tempTarget); + } + return targets; + } + + private void waitForIdle() { + InstrumentationRegistry.getInstrumentation().waitForIdleSync(); + } + + private boolean launchActivityWithTimeout(Intent intent, long timeout) + throws InterruptedException { + final int initialState = 0; + final int completedState = 1; + final int timeoutState = 2; + final AtomicInteger state = new AtomicInteger(initialState); + final CountDownLatch cdl = new CountDownLatch(1); + + ScheduledExecutorService executor = Executors.newScheduledThreadPool(2); + try { + executor.execute(() -> { + mActivityRule.launchActivity(intent); + state.compareAndSet(initialState, completedState); + cdl.countDown(); + }); + executor.schedule( + () -> { + state.compareAndSet(initialState, timeoutState); + cdl.countDown(); + }, + timeout, + TimeUnit.MILLISECONDS); + cdl.await(); + return state.get() == completedState; + } finally { + executor.shutdownNow(); + } + } + + private Bitmap createBitmap() { + return createBitmap(200, 200); + } + + private Bitmap createWideBitmap() { + return createWideBitmap(Color.RED); + } + + private Bitmap createWideBitmap(int bgColor) { + WindowManager windowManager = InstrumentationRegistry.getInstrumentation() + .getTargetContext() + .getSystemService(WindowManager.class); + int width = 3000; + if (windowManager != null) { + Rect bounds = windowManager.getMaximumWindowMetrics().getBounds(); + width = bounds.width() + 200; + } + return createBitmap(width, 100, bgColor); + } + + private Bitmap createBitmap(int width, int height) { + return createBitmap(width, height, Color.RED); + } + + private Bitmap createBitmap(int width, int height, int bgColor) { + Bitmap bitmap = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888); + Canvas canvas = new Canvas(bitmap); + + Paint paint = new Paint(); + paint.setColor(bgColor); + paint.setStyle(Paint.Style.FILL); + canvas.drawPaint(paint); + + paint.setColor(Color.WHITE); + paint.setAntiAlias(true); + paint.setTextSize(14.f); + paint.setTextAlign(Paint.Align.CENTER); + canvas.drawText("Hi!", (width / 2.f), (height / 2.f), paint); + + return bitmap; + } + + private List<ShareShortcutInfo> createShortcuts(Context context) { + Intent testIntent = new Intent("TestIntent"); + + List<ShareShortcutInfo> shortcuts = new ArrayList<>(); + shortcuts.add(new ShareShortcutInfo( + new ShortcutInfo.Builder(context, "shortcut1") + .setIntent(testIntent).setShortLabel("label1").setRank(3).build(), // 0 2 + new ComponentName("package1", "class1"))); + shortcuts.add(new ShareShortcutInfo( + new ShortcutInfo.Builder(context, "shortcut2") + .setIntent(testIntent).setShortLabel("label2").setRank(7).build(), // 1 3 + new ComponentName("package2", "class2"))); + shortcuts.add(new ShareShortcutInfo( + new ShortcutInfo.Builder(context, "shortcut3") + .setIntent(testIntent).setShortLabel("label3").setRank(1).build(), // 2 0 + new ComponentName("package3", "class3"))); + shortcuts.add(new ShareShortcutInfo( + new ShortcutInfo.Builder(context, "shortcut4") + .setIntent(testIntent).setShortLabel("label4").setRank(3).build(), // 3 2 + new ComponentName("package4", "class4"))); + + return shortcuts; + } + + private void markOtherProfileAvailability(boolean workAvailable, boolean cloneAvailable) { + AnnotatedUserHandles.Builder handles = AnnotatedUserHandles.newBuilder(); + handles + .setUserIdOfCallingApp(1234) // Must be non-negative. + .setUserHandleSharesheetLaunchedAs(PERSONAL_USER_HANDLE) + .setPersonalProfileUserHandle(PERSONAL_USER_HANDLE); + if (workAvailable) { + handles.setWorkProfileUserHandle(WORK_PROFILE_USER_HANDLE); + } + if (cloneAvailable) { + handles.setCloneProfileUserHandle(CLONE_PROFILE_USER_HANDLE); + } + ChooserWrapperActivity.sOverrides.annotatedUserHandles = handles.build(); + } + + private void setupResolverControllers( + List<ResolvedComponentInfo> personalResolvedComponentInfos) { + setupResolverControllers(personalResolvedComponentInfos, new ArrayList<>()); + } + + private void setupResolverControllers( + List<ResolvedComponentInfo> personalResolvedComponentInfos, + List<ResolvedComponentInfo> workResolvedComponentInfos) { + when( + ChooserActivityOverrideData + .getInstance() + .resolverListController + .getResolversForIntentAsUser( + Mockito.anyBoolean(), + Mockito.anyBoolean(), + Mockito.anyBoolean(), + Mockito.isA(List.class), + eq(UserHandle.SYSTEM))) + .thenReturn(new ArrayList<>(personalResolvedComponentInfos)); + when( + ChooserActivityOverrideData + .getInstance() + .workResolverListController + .getResolversForIntentAsUser( + Mockito.anyBoolean(), + Mockito.anyBoolean(), + Mockito.anyBoolean(), + Mockito.isA(List.class), + eq(UserHandle.SYSTEM))) + .thenReturn(new ArrayList<>(personalResolvedComponentInfos)); + when( + ChooserActivityOverrideData + .getInstance() + .workResolverListController + .getResolversForIntentAsUser( + Mockito.anyBoolean(), + Mockito.anyBoolean(), + Mockito.anyBoolean(), + Mockito.isA(List.class), + eq(UserHandle.of(10)))) + .thenReturn(new ArrayList<>(workResolvedComponentInfos)); + } + + private static GridRecyclerSpanCountMatcher withGridColumnCount(int columnCount) { + return new GridRecyclerSpanCountMatcher(Matchers.is(columnCount)); + } + + private static class GridRecyclerSpanCountMatcher extends + BoundedDiagnosingMatcher<View, RecyclerView> { + + private final Matcher<Integer> mIntegerMatcher; + + private GridRecyclerSpanCountMatcher(Matcher<Integer> integerMatcher) { + super(RecyclerView.class); + this.mIntegerMatcher = integerMatcher; + } + + @Override + protected void describeMoreTo(Description description) { + description.appendText("RecyclerView grid layout span count to match: "); + this.mIntegerMatcher.describeTo(description); + } + + @Override + protected boolean matchesSafely(RecyclerView view, Description mismatchDescription) { + int spanCount = ((GridLayoutManager) view.getLayoutManager()).getSpanCount(); + if (this.mIntegerMatcher.matches(spanCount)) { + return true; + } else { + mismatchDescription.appendText("RecyclerView grid layout span count was ") + .appendValue(spanCount); + return false; + } + } + } + + private void givenAppTargets(int appCount) { + List<ResolvedComponentInfo> resolvedComponentInfos = + createResolvedComponentsForTest(appCount); + setupResolverControllers(resolvedComponentInfos); + } + + private void updateMaxTargetsPerRowResource(int targetsPerRow) { + Resources resources = Mockito.spy( + InstrumentationRegistry.getInstrumentation().getContext().getResources()); + ChooserActivityOverrideData.getInstance().resources = resources; + doReturn(targetsPerRow).when(resources).getInteger( + R.integer.config_chooser_max_targets_per_row); + } + + 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; + } + + private static ImageLoader createImageLoader(Uri uri, Bitmap bitmap) { + return new TestPreviewImageLoader(Collections.singletonMap(uri, bitmap)); + } +} diff --git a/java/tests/src/com/android/intentresolver/v2/UnbundledChooserActivityWorkProfileTest.java b/java/tests/src/com/android/intentresolver/v2/UnbundledChooserActivityWorkProfileTest.java new file mode 100644 index 00000000..e4ec1776 --- /dev/null +++ b/java/tests/src/com/android/intentresolver/v2/UnbundledChooserActivityWorkProfileTest.java @@ -0,0 +1,481 @@ +/* + * 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.v2; + +import static android.testing.PollingCheck.waitFor; +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.isSelected; +import static androidx.test.espresso.matcher.ViewMatchers.withId; +import static androidx.test.espresso.matcher.ViewMatchers.withText; +import static com.android.intentresolver.v2.ChooserWrapperActivity.sOverrides; +import static com.android.intentresolver.v2.UnbundledChooserActivityWorkProfileTest.TestCase.ExpectedBlocker.NO_BLOCKER; +import static com.android.intentresolver.v2.UnbundledChooserActivityWorkProfileTest.TestCase.ExpectedBlocker.PERSONAL_PROFILE_ACCESS_BLOCKER; +import static com.android.intentresolver.v2.UnbundledChooserActivityWorkProfileTest.TestCase.ExpectedBlocker.PERSONAL_PROFILE_SHARE_BLOCKER; +import static com.android.intentresolver.v2.UnbundledChooserActivityWorkProfileTest.TestCase.ExpectedBlocker.WORK_PROFILE_ACCESS_BLOCKER; +import static com.android.intentresolver.v2.UnbundledChooserActivityWorkProfileTest.TestCase.ExpectedBlocker.WORK_PROFILE_SHARE_BLOCKER; +import static com.android.intentresolver.v2.UnbundledChooserActivityWorkProfileTest.TestCase.Tab.PERSONAL; +import static com.android.intentresolver.v2.UnbundledChooserActivityWorkProfileTest.TestCase.Tab.WORK; +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.intentresolver.AnnotatedUserHandles; +import com.android.intentresolver.R; +import com.android.intentresolver.ResolvedComponentInfo; +import com.android.intentresolver.ResolverDataProvider; +import com.android.intentresolver.v2.UnbundledChooserActivityWorkProfileTest.TestCase.Tab; + +import junit.framework.AssertionFailedError; + +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; + +import dagger.hilt.android.testing.HiltAndroidRule; +import dagger.hilt.android.testing.HiltAndroidTest; + +@DeviceFilter.MediumType +@RunWith(Parameterized.class) +@HiltAndroidTest +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(order = 0) + public HiltAndroidRule mHiltAndroidRule = new HiltAndroidRule(this); + + @Rule(order = 1) + 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(); + + 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 + ), + 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 + ), + 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, UserHandle resolvedForUser) { + List<ResolvedComponentInfo> infoList = new ArrayList<>(numberOfResults); + for (int i = 0; i < numberOfResults; i++) { + infoList.add( + ResolverDataProvider + .createResolvedComponentInfoWithOtherId(i, userId, resolvedForUser)); + } + return infoList; + } + + private List<ResolvedComponentInfo> createResolvedComponentsForTest(int numberOfResults, + UserHandle resolvedForUser) { + List<ResolvedComponentInfo> infoList = new ArrayList<>(numberOfResults); + for (int i = 0; i < numberOfResults; i++) { + infoList.add(ResolverDataProvider.createResolvedComponentInfo(i, resolvedForUser)); + } + return infoList; + } + + private void setUpPersonalAndWorkComponentInfos() { + ChooserWrapperActivity.sOverrides.annotatedUserHandles = AnnotatedUserHandles.newBuilder() + .setUserIdOfCallingApp(1234) // Must be non-negative. + .setUserHandleSharesheetLaunchedAs(mTestCase.getMyUserHandle()) + .setPersonalProfileUserHandle(PERSONAL_USER_HANDLE) + .setWorkProfileUserHandle(WORK_USER_HANDLE) + .build(); + int workProfileTargets = 4; + List<ResolvedComponentInfo> personalResolvedComponentInfos = + createResolvedComponentsForTestWithOtherProfile(3, + /* userId */ WORK_USER_HANDLE.getIdentifier(), PERSONAL_USER_HANDLE); + List<ResolvedComponentInfo> workResolvedComponentInfos = + createResolvedComponentsForTest(workProfileTargets, WORK_USER_HANDLE); + setupResolverControllers(personalResolvedComponentInfos, workResolvedComponentInfos); + } + + private void setupResolverControllers( + List<ResolvedComponentInfo> personalResolvedComponentInfos, + List<ResolvedComponentInfo> workResolvedComponentInfos) { + when(sOverrides.resolverListController.getResolversForIntentAsUser( + Mockito.anyBoolean(), + Mockito.anyBoolean(), + Mockito.anyBoolean(), + Mockito.isA(List.class), + eq(UserHandle.SYSTEM))) + .thenReturn(new ArrayList<>(personalResolvedComponentInfos)); + when(sOverrides.workResolverListController.getResolversForIntentAsUser( + Mockito.anyBoolean(), + Mockito.anyBoolean(), + Mockito.anyBoolean(), + Mockito.isA(List.class), + eq(UserHandle.SYSTEM))) + .thenReturn(new ArrayList<>(personalResolvedComponentInfos)); + when(sOverrides.workResolverListController.getResolversForIntentAsUser( + Mockito.anyBoolean(), + Mockito.anyBoolean(), + Mockito.anyBoolean(), + Mockito.isA(List.class), + eq(WORK_USER_HANDLE))) + .thenReturn(new ArrayList<>(workResolvedComponentInfos)); + } + + private void waitForIdle() { + InstrumentationRegistry.getInstrumentation().waitForIdleSync(); + } + + 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; + + waitFor(() -> { + onView(withText(stringId)).perform(click()); + waitForIdle(); + + try { + onView(withText(stringId)).check(matches(isSelected())); + return true; + } catch (AssertionFailedError e) { + return false; + } + }); + + onView(withId(com.android.internal.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(); + } + } +} |